Skip to main content

max / makenotwork

mt: bound ?sort=replies with a trigger-maintained post_count (F3) The m027 reply_count fix (drop the drift-prone denormalized column, compute live) replaced it with a per-row correlated COUNT(*) subquery, turning ?sort=replies into a full-category aggregate on a cacheless public GET — the cheapest request for a scraper, one of the most expensive for Postgres. Migration 029 reintroduces a denormalized threads.post_count, but maintained by a TRIGGER rather than application code, so it cannot miss a mutation path the way the m022 code did (the root cause of the original drift). Read paths now use GREATEST(t.post_count - 1, 0); a covering index backs the sort. Ultra-fuzz Run #2 finding F3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 20:01 UTC
Commit: f8cd220c161140934bd1991a62990ba67c06df5a
Parent: 1fb0a75
2 files changed, +71 insertions, -6 deletions
@@ -325,8 +325,7 @@ pub async fn list_threads_in_category_paginated(
325 325 "SELECT t.id, t.title,
326 326 COALESCE(u.display_name, u.username) AS author_name,
327 327 u.username AS author_username,
328 - GREATEST((SELECT COUNT(*) FROM posts p
329 - WHERE p.thread_id = t.id AND p.removed_at IS NULL) - 1, 0)::BIGINT AS reply_count,
328 + GREATEST(t.post_count - 1, 0)::BIGINT AS reply_count,
330 329 t.last_activity_at,
331 330 t.pinned, t.locked
332 331 FROM threads t
@@ -368,8 +367,7 @@ pub async fn list_threads_in_category_sorted(
368 367 "SELECT t.id, t.title,
369 368 COALESCE(u.display_name, u.username) AS author_name,
370 369 u.username AS author_username,
371 - GREATEST((SELECT COUNT(*) FROM posts p
372 - WHERE p.thread_id = t.id AND p.removed_at IS NULL) - 1, 0)::BIGINT AS reply_count,
370 + GREATEST(t.post_count - 1, 0)::BIGINT AS reply_count,
373 371 t.last_activity_at,
374 372 t.pinned, t.locked
375 373 FROM threads t
@@ -1309,8 +1307,7 @@ pub async fn list_threads_in_category_sorted_filtered(
1309 1307 "SELECT t.id, t.title,
1310 1308 COALESCE(u.display_name, u.username) AS author_name,
1311 1309 u.username AS author_username,
1312 - GREATEST((SELECT COUNT(*) FROM posts p
1313 - WHERE p.thread_id = t.id AND p.removed_at IS NULL) - 1, 0)::BIGINT AS reply_count,
1310 + GREATEST(t.post_count - 1, 0)::BIGINT AS reply_count,
1314 1311 t.last_activity_at,
1315 1312 t.pinned, t.locked
1316 1313 FROM threads t
@@ -0,0 +1,68 @@
1 + -- Reintroduce a denormalized per-thread post counter to make `?sort=replies`
2 + -- bounded again. Migration 027 dropped the previous `reply_count` column
3 + -- because it was maintained by application code that incremented on reply but
4 + -- missed the decrement paths (mod-remove, soft-delete), so it drifted upward
5 + -- permanently. The live replacement — a correlated `COUNT(*)` subquery per row
6 + -- — is correct but turns `?sort=replies` into a full-category aggregate on a
7 + -- cacheless public GET (the cheapest request for a scraper, one of the most
8 + -- expensive for Postgres).
9 + --
10 + -- This counter is maintained by a TRIGGER, not application code. A trigger
11 + -- fires on every INSERT/UPDATE/DELETE regardless of which query issued it, so
12 + -- it cannot miss a mutation path the way the 022 application code did — that
13 + -- missed-path bug is exactly the root cause of the prior drift. `post_count` is
14 + -- the number of non-removed posts in the thread (including the OP); the display
15 + -- reply count is `GREATEST(post_count - 1, 0)`, computed at read time.
16 + --
17 + -- No `>= 0` CHECK constraint: the 027 comment notes the old CHECK blocked the
18 + -- naive decrement fix, and the read path floors with GREATEST regardless, so a
19 + -- constraint would only add a failure mode without adding safety.
20 +
21 + ALTER TABLE threads ADD COLUMN post_count INTEGER NOT NULL DEFAULT 0;
22 +
23 + -- Backfill from current state.
24 + UPDATE threads t
25 + SET post_count = (
26 + SELECT COUNT(*) FROM posts p
27 + WHERE p.thread_id = t.id AND p.removed_at IS NULL
28 + );
29 +
30 + CREATE OR REPLACE FUNCTION threads_maintain_post_count() RETURNS TRIGGER AS $$
31 + BEGIN
32 + IF TG_OP = 'INSERT' THEN
33 + IF NEW.removed_at IS NULL THEN
34 + UPDATE threads SET post_count = post_count + 1 WHERE id = NEW.thread_id;
35 + END IF;
36 + RETURN NEW;
37 + ELSIF TG_OP = 'DELETE' THEN
38 + IF OLD.removed_at IS NULL THEN
39 + UPDATE threads SET post_count = post_count - 1 WHERE id = OLD.thread_id;
40 + END IF;
41 + RETURN OLD;
42 + ELSE -- UPDATE: react to removed_at transitions (and the unlikely thread move)
43 + IF OLD.thread_id = NEW.thread_id THEN
44 + IF OLD.removed_at IS NULL AND NEW.removed_at IS NOT NULL THEN
45 + UPDATE threads SET post_count = post_count - 1 WHERE id = NEW.thread_id;
46 + ELSIF OLD.removed_at IS NOT NULL AND NEW.removed_at IS NULL THEN
47 + UPDATE threads SET post_count = post_count + 1 WHERE id = NEW.thread_id;
48 + END IF;
49 + ELSE
50 + IF OLD.removed_at IS NULL THEN
51 + UPDATE threads SET post_count = post_count - 1 WHERE id = OLD.thread_id;
52 + END IF;
53 + IF NEW.removed_at IS NULL THEN
54 + UPDATE threads SET post_count = post_count + 1 WHERE id = NEW.thread_id;
55 + END IF;
56 + END IF;
57 + RETURN NEW;
58 + END IF;
59 + END;
60 + $$ LANGUAGE plpgsql;
61 +
62 + CREATE TRIGGER trg_posts_maintain_thread_post_count
63 + AFTER INSERT OR DELETE OR UPDATE OF removed_at, thread_id ON posts
64 + FOR EACH ROW EXECUTE FUNCTION threads_maintain_post_count();
65 +
66 + -- Support the `?sort=replies` ordering: pinned first, then by post_count.
67 + CREATE INDEX idx_threads_category_replies
68 + ON threads (category_id, pinned DESC, post_count DESC, last_activity_at DESC);