max / makenotwork
6 files changed,
+140 insertions,
-84 deletions
A
multithreaded/.sqlx/query-9327e3845bd956638d9589cfbc92ca9259524556d45ec2b954d88dd812ac1a10.json
+79
| @@ -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 | + | } |
D
multithreaded/.sqlx/query-a217df736603726ac00bdd228e258a67bee1158754bb6ea0713afedcc1e599ed.json
-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 | + | } |