mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* docs(self-hosting): correct the PostgreSQL extension requirements The docs claimed Multica requires pgvector and told operators to run `CREATE EXTENSION IF NOT EXISTS vector`. Nothing in the codebase uses it: across all 429 migrations there is no `vector` column and no migration creating the extension, and no Go package imports a pgvector driver. On a stock PostgreSQL 17 that `CREATE EXTENSION` fails outright, so the instruction actively blocked the "use your own PostgreSQL" path it appeared in. There are exactly four `CREATE EXTENSION` statements in the migration set. `pgcrypto` (001) and `pg_trgm` (137) are bare statements that fail the migration when missing; both ship with every standard PostgreSQL 17 build. `pg_bigm` (032) and `pg_cron` (076) are wrapped in `DO ... EXCEPTION` and skip gracefully. Document all four with their real status, and note that a `schema_migrations` row does not prove a conditionally skipped object exists. `pg_bigm` is described as what it actually is — a CJK search-quality improvement, not a correctness requirement. Migrations 138-142 install portable `pg_trgm` fallback indexes covering every column the search handlers touch, so a deployment without `pg_bigm` still searches on an index. References to the bundled image name are left alone: those describe which image the compose file pulls, which is still accurate. The Helm chart description and values comment are updated because they present pgvector as part of the stack rather than as an image name. Co-authored-by: sail-cn <[email protected]> Co-authored-by: multica-agent <[email protected]> * docs(self-hosting): document the local disk attachment storage variables The File Storage section only covered S3 and CloudFront, but local disk is what the backend uses whenever `S3_BUCKET` is unset — every default self-host install. `LOCAL_UPLOAD_DIR` and `LOCAL_UPLOAD_BASE_URL` were absent from the self-hosting docs. The default `./data/uploads` resolves against the backend's working directory. Inside the bundled image that is `/app`, where the compose file mounts the `backend_uploads` volume, so it happens to be correct. Running the binary manually resolves it against whatever directory the process was launched from, which silently scatters attachments as that directory changes and leaves `attachment` rows pointing at files the server no longer looks for. Co-authored-by: sail-cn <[email protected]> Co-authored-by: multica-agent <[email protected]> * docs(search): fix the migration numbers in the statement-timeout comment The comment on `searchStatementTimeout` cited migration 134 as the one that installed the extensions the search fast path needs. 134 is `134_runtime_profile_add_qoder`, which widens a `protocol_family` CHECK constraint and has nothing to do with search. The migration that runs `CREATE EXTENSION IF NOT EXISTS pg_trgm` is 137. The referenced index list is also extended to 137-142: 137 only installs the extension, while the trigram indexes themselves are created by 138-142. Co-authored-by: sail-cn <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: sail-cn <[email protected]> Co-authored-by: multica-agent <[email protected]>
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, 137–142). When those extensions are
|
||
// missing — as they were on every self-hosted deployment using the bundled
|
||
// pgvector/pgvector:pg17 image before migration 137 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
|
||
}
|