Skip to main content

max / makenotwork

perf(search): index-serve the fuzzy title branch; add request timeout - N1: rewrite search_threads' fuzzy branch from `similarity(title,$2) > 0.1` (function form, unindexable -> seq scan on every public search) to the `title % $2` operator, which the gin_trgm_ops index idx_threads_title_trgm can serve. Run the query in a tx that SET LOCALs pg_trgm.similarity_threshold to 0.1 to preserve the old loose threshold without leaking across the pool. EXPLAIN confirms a BitmapOr over the tsv + trigram GIN indexes. Regression test covers a typo-only (trigram-only) match. - N2: add an outermost tower-http TimeoutLayer (30s, 408) so a hung handler sheds instead of pinning a connection; enable the tower-http timeout feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 23:05 UTC
Commit: 5514774cd19e6a8a6d482c997f9c2900bb6ea56c
Parent: 8e7b5fd
6 files changed, +140 insertions, -84 deletions
@@ -0,0 +1,79 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "WITH q AS (\n SELECT websearch_to_tsquery('english', $1) AS tsq\n ),\n thread_matches AS (\n SELECT t.id AS thread_id,\n t.title AS thread_title,\n COALESCE(u.display_name, u.username) AS author_username,\n co.name AS community_name,\n co.slug AS community_slug,\n c.name AS category_name,\n c.slug AS category_slug,\n LEFT(t.title, 200) AS snippet,\n t.last_activity_at,\n (ts_rank(t.search_tsv, q.tsq) * 2.0\n + similarity(t.title, $2)) AS rank\n FROM threads t\n JOIN categories c ON c.id = t.category_id\n JOIN communities co ON co.id = c.community_id\n JOIN users u ON u.mnw_account_id = t.author_id\n CROSS JOIN q\n WHERE t.deleted_at IS NULL\n AND co.suspended_at IS NULL\n AND (t.search_tsv @@ q.tsq\n OR t.title % $2)\n AND ($3::text IS NULL OR co.slug = $3)\n ),\n post_matches AS (\n SELECT DISTINCT ON (t.id)\n t.id AS thread_id,\n t.title AS thread_title,\n COALESCE(pu.display_name, pu.username) AS author_username,\n co.name AS community_name,\n co.slug AS community_slug,\n c.name AS category_name,\n c.slug AS category_slug,\n LEFT(p.body_markdown, 200) AS snippet,\n t.last_activity_at,\n ts_rank(p.search_tsv, q.tsq) AS rank\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 JOIN communities co ON co.id = c.community_id\n -- Author of the matched *reply* (`p.author_id`), not the thread OP\n -- (`t.author_id`): the snippet is the reply's body, so it must be\n -- attributed to whoever wrote it.\n JOIN users pu ON pu.mnw_account_id = p.author_id\n CROSS JOIN q\n WHERE t.deleted_at IS NULL\n AND co.suspended_at IS NULL\n AND p.removed_at IS NULL\n AND p.search_tsv @@ q.tsq\n AND ($3::text IS NULL OR co.slug = $3)\n AND NOT EXISTS (SELECT 1 FROM thread_matches tm WHERE tm.thread_id = t.id)\n ORDER BY t.id, ts_rank(p.search_tsv, q.tsq) DESC\n )\n SELECT\n thread_id AS \"thread_id!\",\n thread_title AS \"thread_title!\",\n author_username AS \"author_username!\",\n community_name AS \"community_name!\",\n community_slug AS \"community_slug!\",\n category_name AS \"category_name!\",\n category_slug AS \"category_slug!\",\n snippet AS \"snippet!\",\n last_activity_at AS \"last_activity_at!: chrono::DateTime<chrono::Utc>\",\n rank AS \"rank!\"\n FROM (\n SELECT * FROM thread_matches\n UNION ALL\n SELECT * FROM post_matches\n ) results\n ORDER BY rank DESC, last_activity_at DESC\n LIMIT $4",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "thread_id!",
9 + "type_info": "Uuid"
10 + },
11 + {
12 + "ordinal": 1,
13 + "name": "thread_title!",
14 + "type_info": "Text"
15 + },
16 + {
17 + "ordinal": 2,
18 + "name": "author_username!",
19 + "type_info": "Text"
20 + },
21 + {
22 + "ordinal": 3,
23 + "name": "community_name!",
24 + "type_info": "Text"
25 + },
26 + {
27 + "ordinal": 4,
28 + "name": "community_slug!",
29 + "type_info": "Text"
30 + },
31 + {
32 + "ordinal": 5,
33 + "name": "category_name!",
34 + "type_info": "Text"
35 + },
36 + {
37 + "ordinal": 6,
38 + "name": "category_slug!",
39 + "type_info": "Text"
40 + },
41 + {
42 + "ordinal": 7,
43 + "name": "snippet!",
44 + "type_info": "Text"
45 + },
46 + {
47 + "ordinal": 8,
48 + "name": "last_activity_at!: chrono::DateTime<chrono::Utc>",
49 + "type_info": "Timestamptz"
50 + },
51 + {
52 + "ordinal": 9,
53 + "name": "rank!",
54 + "type_info": "Float8"
55 + }
56 + ],
57 + "parameters": {
58 + "Left": [
59 + "Text",
60 + "Text",
61 + "Text",
62 + "Int8"
63 + ]
64 + },
65 + "nullable": [
66 + null,
67 + null,
68 + null,
69 + null,
70 + null,
71 + null,
72 + null,
73 + null,
74 + null,
75 + null
76 + ]
77 + },
78 + "hash": "9327e3845bd956638d9589cfbc92ca9259524556d45ec2b954d88dd812ac1a10"
79 + }
@@ -1,79 +0,0 @@
1 - {
2 - "db_name": "PostgreSQL",
3 - "query": "WITH q AS (\n SELECT websearch_to_tsquery('english', $1) AS tsq\n ),\n thread_matches AS (\n SELECT t.id AS thread_id,\n t.title AS thread_title,\n COALESCE(u.display_name, u.username) AS author_username,\n co.name AS community_name,\n co.slug AS community_slug,\n c.name AS category_name,\n c.slug AS category_slug,\n LEFT(t.title, 200) AS snippet,\n t.last_activity_at,\n (ts_rank(t.search_tsv, q.tsq) * 2.0\n + similarity(t.title, $2)) AS rank\n FROM threads t\n JOIN categories c ON c.id = t.category_id\n JOIN communities co ON co.id = c.community_id\n JOIN users u ON u.mnw_account_id = t.author_id\n CROSS JOIN q\n WHERE t.deleted_at IS NULL\n AND co.suspended_at IS NULL\n AND (t.search_tsv @@ q.tsq\n OR similarity(t.title, $2) > 0.1)\n AND ($3::text IS NULL OR co.slug = $3)\n ),\n post_matches AS (\n SELECT DISTINCT ON (t.id)\n t.id AS thread_id,\n t.title AS thread_title,\n COALESCE(pu.display_name, pu.username) AS author_username,\n co.name AS community_name,\n co.slug AS community_slug,\n c.name AS category_name,\n c.slug AS category_slug,\n LEFT(p.body_markdown, 200) AS snippet,\n t.last_activity_at,\n ts_rank(p.search_tsv, q.tsq) AS rank\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 JOIN communities co ON co.id = c.community_id\n -- Author of the matched *reply* (`p.author_id`), not the thread OP\n -- (`t.author_id`): the snippet is the reply's body, so it must be\n -- attributed to whoever wrote it.\n JOIN users pu ON pu.mnw_account_id = p.author_id\n CROSS JOIN q\n WHERE t.deleted_at IS NULL\n AND co.suspended_at IS NULL\n AND p.removed_at IS NULL\n AND p.search_tsv @@ q.tsq\n AND ($3::text IS NULL OR co.slug = $3)\n AND NOT EXISTS (SELECT 1 FROM thread_matches tm WHERE tm.thread_id = t.id)\n ORDER BY t.id, ts_rank(p.search_tsv, q.tsq) DESC\n )\n SELECT\n thread_id AS \"thread_id!\",\n thread_title AS \"thread_title!\",\n author_username AS \"author_username!\",\n community_name AS \"community_name!\",\n community_slug AS \"community_slug!\",\n category_name AS \"category_name!\",\n category_slug AS \"category_slug!\",\n snippet AS \"snippet!\",\n last_activity_at AS \"last_activity_at!: chrono::DateTime<chrono::Utc>\",\n rank AS \"rank!\"\n FROM (\n SELECT * FROM thread_matches\n UNION ALL\n SELECT * FROM post_matches\n ) results\n ORDER BY rank DESC, last_activity_at DESC\n LIMIT $4",
4 - "describe": {
5 - "columns": [
6 - {
7 - "ordinal": 0,
8 - "name": "thread_id!",
9 - "type_info": "Uuid"
10 - },
11 - {
12 - "ordinal": 1,
13 - "name": "thread_title!",
14 - "type_info": "Text"
15 - },
16 - {
17 - "ordinal": 2,
18 - "name": "author_username!",
19 - "type_info": "Text"
20 - },
21 - {
22 - "ordinal": 3,
23 - "name": "community_name!",
24 - "type_info": "Text"
25 - },
26 - {
27 - "ordinal": 4,
28 - "name": "community_slug!",
29 - "type_info": "Text"
30 - },
31 - {
32 - "ordinal": 5,
33 - "name": "category_name!",
34 - "type_info": "Text"
35 - },
36 - {
37 - "ordinal": 6,
38 - "name": "category_slug!",
39 - "type_info": "Text"
40 - },
41 - {
42 - "ordinal": 7,
43 - "name": "snippet!",
44 - "type_info": "Text"
45 - },
46 - {
47 - "ordinal": 8,
48 - "name": "last_activity_at!: chrono::DateTime<chrono::Utc>",
49 - "type_info": "Timestamptz"
50 - },
51 - {
52 - "ordinal": 9,
53 - "name": "rank!",
54 - "type_info": "Float8"
55 - }
56 - ],
57 - "parameters": {
58 - "Left": [
59 - "Text",
60 - "Text",
61 - "Text",
62 - "Int8"
63 - ]
64 - },
65 - "nullable": [
66 - null,
67 - null,
68 - null,
69 - null,
70 - null,
71 - null,
72 - null,
73 - null,
74 - null,
75 - null
76 - ]
77 - },
78 - "hash": "a217df736603726ac00bdd228e258a67bee1158754bb6ea0713afedcc1e599ed"
79 - }
@@ -24,7 +24,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
24 24 # Web
25 25 axum = { version = "0.8", features = ["ws", "multipart"] }
26 26 tower = "0.5"
27 - tower-http = { version = "0.6", features = ["fs", "cors", "trace", "set-header"] }
27 + tower-http = { version = "0.6", features = ["fs", "cors", "trace", "set-header", "timeout"] }
28 28 tower-sessions = "0.14"
29 29 tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
30 30
@@ -1549,7 +1549,18 @@ pub async fn search_threads(
1549 1549 community_slug: Option<&str>,
1550 1550 limit: i64,
1551 1551 ) -> Result<Vec<SearchResultRow>, sqlx::Error> {
1552 - sqlx::query_as!(
1552 + // The trigram fuzzy branch uses the `%` operator (not `similarity(...) > k`)
1553 + // so `idx_threads_title_trgm` (gin_trgm_ops) can serve it and the planner can
1554 + // BitmapOr it with the tsvector GIN scan instead of seq-scanning every live
1555 + // thread. `%` compares against `pg_trgm.similarity_threshold`, so we pin that
1556 + // to 0.1 (the old inline constant) with `SET LOCAL` inside a transaction —
1557 + // session-scoped, so it can't leak to other pooled connections. `similarity()`
1558 + // still appears in the rank expression, but only computed for matched rows.
1559 + let mut tx = pool.begin().await?;
1560 + sqlx::query("SET LOCAL pg_trgm.similarity_threshold = 0.1")
1561 + .execute(&mut *tx)
1562 + .await?;
1563 + let rows = sqlx::query_as!(
1553 1564 SearchResultRow,
1554 1565 // `q` parses the tsquery once (it was parsed 5× inline); cross-joining
1555 1566 // the single-row CTE feeds it to every rank/match without re-parsing.
@@ -1578,7 +1589,7 @@ pub async fn search_threads(
1578 1589 WHERE t.deleted_at IS NULL
1579 1590 AND co.suspended_at IS NULL
1580 1591 AND (t.search_tsv @@ q.tsq
1581 - OR similarity(t.title, $2) > 0.1)
1592 + OR t.title % $2)
1582 1593 AND ($3::text IS NULL OR co.slug = $3)
1583 1594 ),
1584 1595 post_matches AS (
@@ -1633,8 +1644,10 @@ pub async fn search_threads(
1633 1644 community_slug,
1634 1645 limit,
1635 1646 )
1636 - .fetch_all(pool)
1637 - .await
1647 + .fetch_all(&mut *tx)
1648 + .await?;
1649 + tx.commit().await?;
1650 + Ok(rows)
1638 1651 }
1639 1652
1640 1653 // ============================================================================
@@ -158,6 +158,16 @@ async fn main() {
158 158 .layer(tower_http::set_header::SetResponseHeaderLayer::if_not_present(
159 159 axum::http::header::CACHE_CONTROL,
160 160 axum::http::HeaderValue::from_static("private, no-cache"),
161 + ))
162 + // Outermost: a hard per-request deadline so a hung handler (e.g. a slow
163 + // query that outlives the pool acquire, or a stuck await) sheds instead of
164 + // pinning a connection forever. 30s clears any legitimate request — every
165 + // outbound call is bounded to 5-15s and the DB acquire to 10s; the only
166 + // request that transfers meaningful bytes is an image upload, capped at
167 + // ~5 MB (needs ~1.4 Mbit/s to finish inside the window). Returns 408.
168 + .layer(tower_http::timeout::TimeoutLayer::with_status_code(
169 + axum::http::StatusCode::REQUEST_TIMEOUT,
170 + std::time::Duration::from_secs(30),
161 171 ));
162 172
163 173 // Default to loopback. Rate limiting uses TrustedProxyKeyExtractor, which
@@ -144,3 +144,36 @@ async fn search_deleted_thread_excluded() {
144 144 "Deleted thread should not appear in search"
145 145 );
146 146 }
147 +
148 + /// Regression (audit N1): the fuzzy title branch must still catch a misspelling.
149 + /// The query switched from `similarity(title, $2) > 0.1` (a seq scan) to the
150 + /// index-served `title % $2` with `pg_trgm.similarity_threshold` pinned to 0.1;
151 + /// this guards that the looser trigram threshold survives that rewrite. The typo
152 + /// term is not a lexeme of the title, so the tsvector branch cannot match it —
153 + /// only the trigram branch can.
154 + #[tokio::test]
155 + async fn search_fuzzy_title_typo_matches_via_trigram() {
156 + let mut h = TestHarness::new().await;
157 + let user_id = h.login_as("searchfuzzy").await;
158 + let comm_id = h.create_community("Test", "test").await;
159 + let cat_id = h.create_category(comm_id, "General", "general").await;
160 + h.add_membership(user_id, comm_id, "member").await;
161 +
162 + h.create_thread_with_post(cat_id, user_id, "Florbnak Widget Notes", "body text").await;
163 + h.create_thread_with_post(cat_id, user_id, "Totally Unrelated", "nothing here").await;
164 +
165 + // "florbnk" (dropped 'a') is a typo, not a stemmable lexeme of the title, so
166 + // it can only surface through the trigram similarity branch.
167 + let results = mt_db::queries::search_threads(&h.db, "florbnk", None, 20)
168 + .await
169 + .expect("search");
170 +
171 + assert!(
172 + results.iter().any(|r| r.thread_title == "Florbnak Widget Notes"),
173 + "typo query should surface the near-match title via the trigram branch"
174 + );
175 + assert!(
176 + !results.iter().any(|r| r.thread_title == "Totally Unrelated"),
177 + "dissimilar title must not match"
178 + );
179 + }