mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
* fix(search): add pg_trgm index fallback + statement_timeout guard (MUL-4059) Root cause of the "search freezes with no response" symptom reported in MUL-4059: the search handler runs LOWER(col) LIKE '%pattern%' queries that expect a pg_bigm GIN index (migrations 032, 033, 036), but every migration wraps the CREATE EXTENSION + CREATE INDEX in a DO/EXCEPTION handler that silently skips when pg_bigm is unavailable. The bundled self-host / dev / CI Postgres image (pgvector/pgvector:pg17) does not ship pg_bigm, so on every self-hosted deployment the migrations no-op and no GIN indexes get built. Every /api/issues/search + /api/projects/search request then falls back to a Seq Scan on `issue` + correlated Seq Scans on `comment` — verified with EXPLAIN on the local dev DB, which has zero title/description/comment search indexes before this change. Two independent guardrails are added, either of which alone would have prevented the reported hang: 1) Migration 134 installs pg_trgm (ships in all standard Postgres + pgvector images) and builds GIN indexes with gin_trgm_ops on `LOWER(title)`, `LOWER(COALESCE(description, ''))`, and `LOWER(content)`. The expression signatures match the search handler's WHERE clauses exactly, so the planner picks the index without further changes. The pg_bigm indexes from 036 are left intact — deployments on AWS RDS with pg_bigm 1.2 keep the CJK-friendly bigram path; deployments without it get the trigram fallback. Verified against a local 25k-row fixture: the description LIKE hits `Bitmap Index Scan on idx_issue_description_trgm` in 0.5 ms. 2) runSearchQuery wraps both search handlers in a short-lived read-only transaction with SET LOCAL statement_timeout = 3 s. In the pathological case where indexes are still missing or the query plan is bad, callers see a fast 503 with a descriptive error instead of a stalled request. Verified against a live Postgres: a deliberate pg_sleep(2) with the test override at 200 ms is cut off in 230 ms with SQLSTATE 57014, as asserted by TestRunSearchQuery_StatementTimeoutFires. Non-goals: this change does not remove the pg_bigm code path, does not change the SQL the handler builds, and does not change the API response shape. It is the minimum diff to unblock production while preserving the CJK-search advantage that pg_bigm provides where it is available. Co-authored-by: multica-agent <github@multica.ai> * fix(search): scope comment subqueries to workspace to unblock prd hang (MUL-4059) Follow-up correction after PRD investigation: pg_bigm IS installed on prd `multica-prod` and all five bigm indexes exist in the correct `LOWER(...) gin_bigm_ops` form. The initial "missing index" hypothesis was wrong; migration 134 (pg_trgm fallback) still helps self-host but does not touch the production hang path. Actual prd EXPLAIN (workspace with 60k issues, keyword "search"): Index Scan using idx_issue_workspace on issue i Rows Removed by Filter: 59123 SubPlan 2 Bitmap Heap Scan on comment c Rows Removed by Index Recheck: 1928275 Heap Blocks: exact=48297 lossy=164696 Bitmap Index Scan on idx_comment_content_bigm rows=536761 Execution Time: 32345.002 ms Root cause: the correlated `EXISTS` over `comment` gets rewritten by the planner into a *hashed* subplan. Without a workspace_id filter in the subquery, that hashed set covers every comment in every workspace matching the LIKE — 536k rows for "search" — which spills work_mem into a lossy bitmap and rechecks 1.9M rows. Two-part fix: 1. Query rewrite. buildSearchQuery now emits `c.workspace_id = $wsParam` inside every comment subquery (WHERE phrase match, WHERE multi-term match, tier 7 rank, tier 8 rank, and the matched_comment_content COALESCE). The same $4 parameter is reused so Postgres treats it as a compile-time constant and pushes it into the hashed subplan's key, collapsing the set to this workspace's comments. 2. Supporting index (migration 135). New `idx_comment_workspace ON comment (workspace_id)`. Without it, the pushed-down filter still triggers a Seq Scan on `comment` because comment has no btree on workspace_id (only the FK constraint and composite (issue_id, ...) indexes). Locally verified against a repro that mirrors prd (5k issues in the target workspace, 100k comments in a sibling workspace all containing "search"): the plan drops from 60 ms (hashed global scan, no support index) to 3 ms (subplan uses idx_comment_workspace). Prd extrapolation from the same shape: 32.3 s → tens of milliseconds. Regression test TestBuildSearchQuery_CommentSubqueryWorkspaceScope asserts every `FROM comment c` in the generated SQL is followed by a `c.workspace_id = $4` filter, so a future refactor can't silently regress the plan back to the global-hash pathology. The statement_timeout guard from the earlier commit in this branch is kept — it still bounds the worst case if any future query shape regresses. Co-authored-by: multica-agent <github@multica.ai> * fix(search): address PR review — unwrap 135 + add project trigram indexes (MUL-4059) Both must-fix items from GPT-Boy's review: 1. Migration 135 unwrapped. The previous version buried `CREATE INDEX idx_comment_workspace` inside `DO $$ ... EXCEPTION WHEN OTHERS $$` — exactly the anti-pattern that caused MUL-4059 in the first place. `idx_comment_workspace` is not a CJK-bonus fallback; it is the critical support that makes the query rewrite land on an Index Scan instead of a Seq Scan. A silent failure (lock timeout, disk full, permission denied, schema drift) MUST abort the migration and fail deployment, not slip through as green. The unwrapped `CREATE INDEX IF NOT EXISTS` now propagates real errors to the migration runner, which aborts and does NOT record the version as applied. IF NOT EXISTS keeps idempotency for the operator-precreated case (`CREATE INDEX CONCURRENTLY ...` before running migrations on large prd tables). 2. Migration 134 now covers project search too. SearchProjects reads `LOWER(project.title)` and `LOWER(COALESCE(project.description, ''))`, and the pg_bigm equivalents in migration 039 silently no-op on pg_bigm-less images just like 032/033/036. Without the trigram fallback, project searches on self-host would still Seq Scan and hit the 3 s statement_timeout guard as a 503 — technically bounded but not actually fixed. Added `idx_project_title_trgm` and `idx_project_description_trgm`; the down migration drops them too. Also: fixed the search.go comment that said callers get a "standard 500" — they get a 503 with SQLSTATE-57014 mapping; the comment now matches reality. Verified: build clean, vet clean, existing search / timeout tests still green. Migration 135 dry-run (dropping the index, re-applying the unwrapped SQL under `ON_ERROR_STOP=1`) creates the index cleanly; a deliberate `CREATE INDEX` on a non-existent column now aborts psql with exit 3, confirming the migration runner would fail loudly on any real error. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
122 lines
4.2 KiB
Go
122 lines
4.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// searchStatementTimeout bounds every /search request at the Postgres level.
|
|
//
|
|
// The two search handlers (SearchIssues, SearchProjects) run LOWER(col) LIKE
|
|
// '%pattern%' queries whose fast path depends on pg_bigm / pg_trgm GIN
|
|
// indexes (see migrations 032, 033, 036, 134). When those extensions are
|
|
// missing — as they were on every self-hosted deployment using the bundled
|
|
// pgvector/pgvector:pg17 image before migration 134 shipped — Postgres
|
|
// falls back to a Seq Scan on `issue` plus correlated Seq Scans on
|
|
// `comment`. On workspaces with thousands of rows the query takes long
|
|
// enough that the frontend Loader2 spinner appears to hang forever
|
|
// ("搜索卡死没有任何反应", MUL-4059).
|
|
//
|
|
// The 3 s cap is generous compared to a properly indexed search (typically
|
|
// <50 ms) and short enough that the frontend's implicit request timeout
|
|
// (browser default, ~30 s) never kicks in. On timeout the caller sees a
|
|
// 503 with a descriptive error rather than a stalled connection —
|
|
// SearchIssues / SearchProjects map SQLSTATE 57014 to
|
|
// http.StatusServiceUnavailable so the frontend can distinguish this
|
|
// from a generic 500.
|
|
const searchStatementTimeout = 3 * time.Second
|
|
|
|
// searchStatementTimeoutOverride, when non-zero, replaces
|
|
// searchStatementTimeout for the duration of a test. Never read outside
|
|
// of the runSearchQuery hot path — see search_timeout_test.go.
|
|
var searchStatementTimeoutOverride time.Duration
|
|
|
|
func effectiveSearchStatementTimeout() time.Duration {
|
|
if searchStatementTimeoutOverride > 0 {
|
|
return searchStatementTimeoutOverride
|
|
}
|
|
return searchStatementTimeout
|
|
}
|
|
|
|
// runSearchQuery executes a search SQL query inside a short-lived read-only
|
|
// transaction with SET LOCAL statement_timeout as the safety net. rowsFn
|
|
// receives each pgx.Rows result and is responsible for scanning /
|
|
// accumulating results before returning; runSearchQuery handles
|
|
// commit / rollback and returns the first error encountered.
|
|
//
|
|
// tx uses IsoLevel ReadCommitted (Postgres default) and AccessMode ReadOnly
|
|
// so a stuck search cannot hold row locks against writers.
|
|
func runSearchQuery(
|
|
ctx context.Context,
|
|
txStarter txStarter,
|
|
sql string,
|
|
args []any,
|
|
rowsFn func(pgx.Rows) error,
|
|
) error {
|
|
tx, err := txStarter.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin search tx: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
// Best-effort rollback with a fresh context so a caller
|
|
// cancellation still lets the connection go back clean.
|
|
_ = tx.Rollback(context.Background())
|
|
}
|
|
}()
|
|
|
|
// SET LOCAL is transaction-scoped, so pgxpool can safely hand this
|
|
// connection back out after COMMIT without the timeout leaking to
|
|
// unrelated queries.
|
|
timeoutMs := int(effectiveSearchStatementTimeout() / time.Millisecond)
|
|
if _, err := tx.Exec(ctx, fmt.Sprintf("SET LOCAL statement_timeout = %d", timeoutMs)); err != nil {
|
|
return fmt.Errorf("set search statement_timeout: %w", err)
|
|
}
|
|
// The read-only mode is applied here rather than via TxOptions so we
|
|
// keep the txStarter interface signature (Begin only) intact. It's
|
|
// belt-and-suspenders — the search queries only SELECT anyway.
|
|
if _, err := tx.Exec(ctx, "SET LOCAL transaction_read_only = on"); err != nil {
|
|
return fmt.Errorf("set search transaction_read_only: %w", err)
|
|
}
|
|
|
|
rows, err := tx.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Close rows before commit so pgx does not complain about a busy
|
|
// connection during Commit.
|
|
if err := rowsFn(rows); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
rows.Close()
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return err
|
|
}
|
|
committed = true
|
|
return nil
|
|
}
|
|
|
|
// isSearchStatementTimeout reports whether err is the canonical Postgres
|
|
// query_canceled error (SQLSTATE 57014). Both `SET LOCAL statement_timeout`
|
|
// firing and a client-side context cancellation surface as 57014 — the two
|
|
// are indistinguishable from the client side, which is intentional in the
|
|
// pgx layer.
|
|
func isSearchStatementTimeout(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) {
|
|
return pgErr.Code == "57014"
|
|
}
|
|
return false
|
|
}
|