Skip to main content

max / makenotwork

multithreaded: make the live-post predicate a generated column, sweep the sites Closes the escalation trigger from the Run #3 re-verify cluster (problem fd1c52ca). A post is live iff removed_at AND deleted_at are both null, and that pair was hand-copied at every read. Five sites drifted: the search CTE, the tracked-thread unread count, the tracked-thread mention flag, the quote loader, and the thread-list mention badge. Two were wrong in production behaviour, not merely latent. The failure mode is specifically "wrote one predicate, forgot the other", so migration 034 makes that unrepresentable rather than adding another rule to remember: posts.is_active, GENERATED ALWAYS, one token at the call site. Postgres rejects any direct write to it, so it cannot drift from its two sources the way a copied predicate can. Both properties have tests. Of the three mechanisms considered, this was the one left standing. A shared Rust constant is impossible: sqlx's macros take a string literal, and query_scalar!(concat!(...)) fails to compile. A posts_active view only serves part of the surface, since four readers deliberately want removed rows (list_posts_in_thread renders tombstones, PostForEdit has to resolve a removed post for restore, the flag queue shows flags on hidden posts). Swept all ten read sites across six query modules. Deliberately not swept: the write guards in mutations, which key on removed_at specifically because they are the thing that sets it, and the mod-facing reads that want removed_at IS NOT NULL. idx_posts_active mirrors idx_posts_not_removed's shape and the planner picks it for an is_active predicate. The old index stays for now; after this sweep no query reads it, but the remaining removed_at IS NULL uses are PK-equality write guards, and settling whether it is droppable wants production index stats rather than a plan against an empty dev table. Noted in the migration.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 14:58 UTC
Signed with PGP, not checked
Commit: 5ecfc4a618a1d9150b5b173c723c0f523ab47103
Parent: 307a3fe
21 files changed, +456 insertions, -320 deletions
@@ -867,7 +867,6 @@
867 867 .await
868 868 .unwrap();
869 869
870 - // Insert mentions
871 870 mt_db::mutations::insert_mentions(&h.db, post_id, &[mentioned])
872 871 .await
873 872 .unwrap();
@@ -1058,3 +1057,113 @@
1058 1057 .unwrap();
1059 1058 assert_eq!(name, "stable");
1060 1059 }
1060 +
1061 + /// `posts.is_active` (migration 034) is the canonical live-post predicate. These
1062 + /// pin the two properties that make it worth having over a hand-copied
1063 + /// `removed_at IS NULL AND deleted_at IS NULL` pair: it tracks BOTH columns, and
1064 + /// it cannot be set independently of them.
1065 + #[tokio::test]
1066 + async fn posts_is_active_tracks_both_soft_delete_columns() {
1067 + let mut h = TestHarness::new().await;
1068 + let author_id = h.login_as("isactiveauthor").await;
1069 + let comm_id = h.create_community("Test", "test").await;
1070 + let cat_id = h.create_category(comm_id, "General", "general").await;
1071 + h.add_membership(author_id, comm_id, "member").await;
1072 +
1073 + let thread_id = h
1074 + .create_thread_with_post(cat_id, author_id, "Active", "Content")
1075 + .await;
1076 + let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
1077 + .await
1078 + .unwrap()[0]
1079 + .id;
1080 +
1081 + let active = |id: Uuid, db: sqlx::PgPool| async move {
1082 + sqlx::query_scalar::<_, bool>("SELECT is_active FROM posts WHERE id = $1")
1083 + .bind(id)
1084 + .fetch_one(&db)
1085 + .await
1086 + .unwrap()
1087 + };
1088 +
1089 + assert!(
1090 + active(post_id, h.db.clone()).await,
1091 + "a fresh post is active"
1092 + );
1093 +
1094 + // The mod column alone flips it.
1095 + sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1")
1096 + .bind(post_id)
1097 + .execute(&h.db)
1098 + .await
1099 + .unwrap();
1100 + assert!(
1101 + !active(post_id, h.db.clone()).await,
1102 + "removed_at deactivates"
1103 + );
1104 +
1105 + sqlx::query("UPDATE posts SET removed_at = NULL WHERE id = $1")
1106 + .bind(post_id)
1107 + .execute(&h.db)
1108 + .await
1109 + .unwrap();
1110 + assert!(
1111 + active(post_id, h.db.clone()).await,
1112 + "clearing it reactivates"
1113 + );
1114 +
1115 + // The author column alone flips it too. This is the half that drifted at
1116 + // five call sites, because it is dormant and nothing catches a missing check.
1117 + sqlx::query("UPDATE posts SET deleted_at = now() WHERE id = $1")
1118 + .bind(post_id)
1119 + .execute(&h.db)
1120 + .await
1121 + .unwrap();
1122 + assert!(
1123 + !active(post_id, h.db.clone()).await,
1124 + "deleted_at must deactivate too, not just removed_at"
1125 + );
1126 + }
1127 +
1128 + #[tokio::test]
1129 + async fn posts_is_active_cannot_be_written_directly() {
1130 + let mut h = TestHarness::new().await;
1131 + let author_id = h.login_as("nowriteauthor").await;
1132 + let comm_id = h.create_community("Test", "test").await;
1133 + let cat_id = h.create_category(comm_id, "General", "general").await;
1134 + h.add_membership(author_id, comm_id, "member").await;
1135 +
1136 + let thread_id = h
1137 + .create_thread_with_post(cat_id, author_id, "NoWrite", "Content")
1138 + .await;
1139 + let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
1140 + .await
1141 + .unwrap()[0]
1142 + .id;
1143 +
1144 + sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1")
1145 + .bind(post_id)
1146 + .execute(&h.db)
1147 + .await
1148 + .unwrap();
1149 +
1150 + // Postgres refuses the write, so the column can never disagree with the two
1151 + // it is derived from. This is the property a shared SQL fragment could not
1152 + // have given us.
1153 + let err = sqlx::query("UPDATE posts SET is_active = true WHERE id = $1")
1154 + .bind(post_id)
1155 + .execute(&h.db)
1156 + .await
1157 + .expect_err("a generated column must reject a direct write");
1158 + assert!(
1159 + err.to_string().contains("can only be updated to DEFAULT"),
1160 + "expected Postgres to reject the write to a generated column, got: {err}"
1161 + );
1162 +
1163 + let still_inactive: bool = sqlx::query_scalar("SELECT is_active FROM posts WHERE id = $1")
1164 + .bind(post_id)
1165 + .fetch_one(&h.db)
1166 + .await
1167 + .unwrap();
1168 + assert!(!still_inactive, "the removed post must stay inactive");
1169 + }
@@ -2,10 +2,10 @@
2 2
3 3 /// Batch-check which threads have at least one *live* mention of the given user.
4 4 ///
5 - /// Removed and author-deleted posts do not count, matching the `has_mention`
6 - /// flag in `list_tracked_threads`. Both drive a badge that sends the reader to a
7 - /// thread to find the mention, and a badge naming content that is no longer
8 - /// there is worse than no badge.
5 + /// Only live posts count (`p.is_active`, migration 034), matching the
6 + /// `has_mention` flag in `list_tracked_threads`. Both drive a badge that sends
7 + /// the reader to a thread to find the mention, and a badge naming content that
8 + /// is no longer there is worse than no badge.
9 9 #[tracing::instrument(skip_all)]
10 10 pub async fn get_threads_with_mentions_for_user(
11 11 pool: &PgPool,
@@ -21,8 +21,7 @@
21 21 JOIN posts p ON p.id = pm.post_id
22 22 WHERE pm.mentioned_user_id = $1
23 23 AND p.thread_id = ANY($2)
24 - AND p.removed_at IS NULL
25 - AND p.deleted_at IS NULL",
24 + AND p.is_active",
26 25 user_id,
27 26 thread_ids,
28 27 )
@@ -176,17 +176,16 @@
176 176 ) -> Result<Option<(Uuid, String)>, sqlx::Error> {
177 177 // Only live posts can be quoted: a mod-removed post, an author-deleted one,
178 178 // or one in a soft-deleted thread must fail quote verification so its text
179 - // cannot be re-surfaced into a live post by quoting it back. All three
180 - // conditions are spelled out because "live" is not one column: `removed_at`
181 - // is the mod action, `deleted_at` the author's, and either one alone would
182 - // let the other class through.
179 + // cannot be re-surfaced into a live post by quoting it back. `p.is_active`
180 + // (migration 034) covers the first two; the thread's own `deleted_at` is a
181 + // separate row and still needs saying.
183 182 sqlx::query!(
184 183 "SELECT p.author_id, p.body_markdown
185 184 FROM posts p
186 185 JOIN threads t ON t.id = p.thread_id
187 186 JOIN categories c ON c.id = t.category_id
188 187 WHERE p.id = $1 AND c.community_id = $2
189 - AND p.removed_at IS NULL AND p.deleted_at IS NULL
188 + AND p.is_active
190 189 AND t.deleted_at IS NULL",
191 190 post_id,
192 191 community_id,
@@ -90,8 +90,7 @@
90 90 CROSS JOIN q
91 91 WHERE t.deleted_at IS NULL
92 92 AND co.suspended_at IS NULL
93 - AND p.removed_at IS NULL
94 - AND p.deleted_at IS NULL
93 + AND p.is_active
95 94 AND p.search_tsv @@ q.tsq
96 95 AND ($3::text IS NULL OR co.slug = $3)
97 96 AND NOT EXISTS (SELECT 1 FROM thread_matches tm WHERE tm.thread_id = t.id)
@@ -143,8 +143,7 @@
143 143 r#"SELECT COUNT(p.id) AS "count!", MAX(p.created_at) AS "max_created: chrono::DateTime<chrono::Utc>"
144 144 FROM posts p
145 145 WHERE p.thread_id = $1
146 - AND p.removed_at IS NULL
147 - AND p.deleted_at IS NULL"#,
146 + AND p.is_active"#,
148 147 thread_id,
149 148 )
150 149 .fetch_optional(pool)
@@ -32,14 +32,14 @@
32 32 ///
33 33 /// Paginated (`limit`/`offset`) so a user tracking hundreds of threads doesn't
34 34 /// render (and recompute) an unbounded page. The last-read cutoff is resolved
35 - /// with a single LEFT JOIN rather than a per-row nested subquery, and the unread
36 - /// count excludes mod-removed posts (`p.removed_at IS NULL`) so the badge can't
37 - /// be inflated by removed content and can use the `idx_posts_not_removed` index.
35 + /// with a single LEFT JOIN rather than a per-row nested subquery, and both the
36 + /// unread count and the mention flag count only live posts (`p.is_active`,
37 + /// migration 034), so neither badge can be inflated by removed content and both
38 + /// can use the `idx_posts_active` index.
38 39 ///
39 - /// The mention flag carries the same filter as the unread count. Both point the
40 - /// reader at content, so a badge that outlives the post it names sends them to a
41 - /// thread with nothing to find. `deleted_at` is dormant today (migration 031),
42 - /// but the two predicates travel together everywhere else that reads posts.
40 + /// The two carry the same filter deliberately. Both point the reader at content,
41 + /// so a badge that outlives the post it names sends them to a thread with
42 + /// nothing to find.
43 43 #[tracing::instrument(skip_all)]
44 44 pub async fn list_tracked_threads(
45 45 pool: &PgPool,
@@ -56,8 +56,7 @@
56 56 cat.slug AS category_slug,
57 57 (SELECT COUNT(*) FROM posts p
58 58 WHERE p.thread_id = tt.thread_id
59 - AND p.removed_at IS NULL
60 - AND p.deleted_at IS NULL
59 + AND p.is_active
61 60 AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at)
62 61 ) AS "unread_count!",
63 62 EXISTS (
@@ -65,8 +64,7 @@
65 64 JOIN posts p ON p.id = pm.post_id
66 65 WHERE pm.mentioned_user_id = tt.user_id
67 66 AND p.thread_id = tt.thread_id
68 - AND p.removed_at IS NULL
69 - AND p.deleted_at IS NULL
67 + AND p.is_active
70 68 ) AS "has_mention!",
71 69 tt.tracked_at AS "tracked_at: chrono::DateTime<chrono::Utc>"
72 70 FROM tracked_threads tt
@@ -50,8 +50,7 @@
50 50 JOIN categories c ON c.id = t.category_id
51 51 WHERE p.author_id = u.mnw_account_id
52 52 AND c.community_id = co.id
53 - AND p.removed_at IS NULL
54 - AND p.deleted_at IS NULL
53 + AND p.is_active
55 54 AND t.deleted_at IS NULL) AS "post_count!",
56 55 (SELECT COUNT(*) FROM post_endorsements pe
57 56 JOIN posts p ON p.id = pe.post_id
@@ -59,8 +58,7 @@
59 58 JOIN categories c ON c.id = t.category_id
60 59 WHERE p.author_id = u.mnw_account_id
61 60 AND c.community_id = co.id
62 - AND p.removed_at IS NULL
63 - AND p.deleted_at IS NULL
61 + AND p.is_active
64 62 AND t.deleted_at IS NULL) AS "endorsement_count!"
65 63 FROM users u
66 64 JOIN memberships m ON m.user_id = u.mnw_account_id
@@ -104,8 +102,7 @@
104 102 JOIN categories c ON c.id = t.category_id
105 103 WHERE c.community_id = $1
106 104 AND p.author_id = $2
107 - AND p.removed_at IS NULL
108 - AND p.deleted_at IS NULL
105 + AND p.is_active
109 106 AND t.deleted_at IS NULL
110 107 ORDER BY p.created_at DESC
111 108 LIMIT $3"#,
@@ -143,8 +140,7 @@
143 140 JOIN categories c ON c.id = t.category_id
144 141 WHERE p.author_id = $1
145 142 AND c.community_id = co.id
146 - AND p.removed_at IS NULL
147 - AND p.deleted_at IS NULL
143 + AND p.is_active
148 144 AND t.deleted_at IS NULL) AS "post_count!"
149 145 FROM memberships m
150 146 JOIN communities co ON co.id = m.community_id
@@ -1,6 +1,6 @@
1 1 {
2 2 "db_name": "PostgreSQL",
3 - "query": "SELECT t.id AS thread_id,\n t.title AS thread_title,\n c.name AS category_name,\n c.slug AS category_slug,\n p.created_at AS \"post_created_at: chrono::DateTime<chrono::Utc>\",\n (t.author_id = $2) AS \"is_thread_author!\"\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE c.community_id = $1\n AND p.author_id = $2\n AND p.removed_at IS NULL\n AND p.deleted_at IS NULL\n AND t.deleted_at IS NULL\n ORDER BY p.created_at DESC\n LIMIT $3",
3 + "query": "SELECT t.id AS thread_id,\n t.title AS thread_title,\n c.name AS category_name,\n c.slug AS category_slug,\n p.created_at AS \"post_created_at: chrono::DateTime<chrono::Utc>\",\n (t.author_id = $2) AS \"is_thread_author!\"\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE c.community_id = $1\n AND p.author_id = $2\n AND p.is_active\n AND t.deleted_at IS NULL\n ORDER BY p.created_at DESC\n LIMIT $3",
4 4 "describe": {
5 5 "columns": [
6 6 {
@@ -50,5 +50,5 @@
50 50 null
51 51 ]
52 52 },
53 - "hash": "150877ff6d3dd6d1c8034f0f5f9af152b5759f9c0b7e0c3549b636be95f65f89"
53 + "hash": "08e26506115a12ad35f6a5490448262e8a2b94c621b8fb6756c792be568e00a9"
54 54 }
@@ -1,6 +1,6 @@
1 1 {
2 2 "db_name": "PostgreSQL",
3 - "query": "SELECT COUNT(p.id) AS \"count!\", MAX(p.created_at) AS \"max_created: chrono::DateTime<chrono::Utc>\"\n FROM posts p\n WHERE p.thread_id = $1\n AND p.removed_at IS NULL\n AND p.deleted_at IS NULL",
3 + "query": "SELECT COUNT(p.id) AS \"count!\", MAX(p.created_at) AS \"max_created: chrono::DateTime<chrono::Utc>\"\n FROM posts p\n WHERE p.thread_id = $1\n AND p.is_active",
4 4 "describe": {
5 5 "columns": [
6 6 {
@@ -24,5 +24,5 @@
24 24 null
25 25 ]
26 26 },
27 - "hash": "d99816410918865cf778a04ad5c928a784c2ea40be45a32d7b4a30e139b4815a"
27 + "hash": "c5f10fc91f3919a8d29588de1fefc5e57e3a928949f91ce1340c6c2b37faec62"
28 28 }
@@ -1,6 +1,6 @@
1 1 {
2 2 "db_name": "PostgreSQL",
3 - "query": "SELECT p.author_id, p.body_markdown\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE p.id = $1 AND c.community_id = $2\n AND p.removed_at IS NULL AND p.deleted_at IS NULL\n AND t.deleted_at IS NULL",
3 + "query": "SELECT p.author_id, p.body_markdown\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE p.id = $1 AND c.community_id = $2\n AND p.is_active\n AND t.deleted_at IS NULL",
4 4 "describe": {
5 5 "columns": [
6 6 {
@@ -25,5 +25,5 @@
25 25 false
26 26 ]
27 27 },
28 - "hash": "4d9de685a37677d8ec3211a14d314ee7dabd93d602f20c0d343ec9b79b51d2e1"
28 + "hash": "44d7dba7092ba4175f5026f89b164c7fd50f3b7b40bb27e0e6e549a26fa80ef9"
29 29 }
@@ -1,0 +1,23 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "SELECT DISTINCT p.thread_id\n FROM post_mentions pm\n JOIN posts p ON p.id = pm.post_id\n WHERE pm.mentioned_user_id = $1\n AND p.thread_id = ANY($2)\n AND p.is_active",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "thread_id",
9 + "type_info": "Uuid"
10 + }
11 + ],
12 + "parameters": {
13 + "Left": [
14 + "Uuid",
15 + "UuidArray"
16 + ]
17 + },
18 + "nullable": [
19 + false
20 + ]
21 + },
22 + "hash": "418e4e5f815910b3ce001e158d6c19aae819d459fb59371c589652df410aef49"
23 + }