fix(#1901): detectSchema fails loud instead of caching wrong schema mode - #1909
Conversation
…ng schema mode detectSchema() swallowed any probe-query error with a bare `return`, leaving isV3 (and the feature flags) at their zero value for the whole process lifetime. A single transient failure of the first `PRAGMA table_info` — e.g. a read-only WAL open racing the ingestor's WAL recovery at startup — silently ran v2 SQL against a v3 DB, so the Packets page showed 0 rows and /api/channels/<name>/messages returned 500 while the database was perfectly healthy, until someone manually restarted the server. - detectSchema now returns an error; OpenDB aborts startup on it, so the supervisor/Docker restart policy clears any transient cause (fail fast). - Detection runs on a single pinned connection (conn.Conn) rather than an arbitrary pooled one, addressing the startup race directly. - The selected schema mode is logged unconditionally ([db] schema mode: v3 (observer_idx) | v2 (observer_id)), so a clean startup log is now evidence detection ran. - Close() no longer runs PRAGMA wal_checkpoint(TRUNCATE) on the read-only handle, which always failed with "disk I/O error (778)" and looked like a storage fault on every shutdown. Tests: TestDetectSchemaFailsLoudOnProbeError injects a probe failure and asserts the error propagates and isV3 stays unset; TestDetectSchemaV3AndV2 covers both schema shapes through OpenDB. Co-Authored-By: Claude <noreply@anthropic.com>
|
Review from the queue triage. Written 2026-08-30 against the tree at that time; posting now that the maintenance window in #1922 has opened. Verdict: approve with two notes, and one merge-order finding that matters more than the The core change is right: a swallowed PRAGMA failure previously left
Merge-order finding: #1909 and #1878 conflict, in two places. Both rewrite |
|
Recycling this again, and the reason is my mistake rather than anything about your PR. Earlier today I approved the pending workflow run on this PR. That was the wrong order: approving an Closing and reopening now gets a fresh merge commit against current master, which is what the run should have been all along. No action needed from you, and apologies for the second round of noise. |
… (rebase of #1878) (#1934) Continues #1878 by @Joel-Claw, at their request. Both commits are theirs, authorship unchanged; I only rebased them onto master and resolved the conflict with #1909. ## The conflict, and how it is resolved Exactly the two places I named in the review on #1878: `OpenDB` and `Close()`. Both PRs rewrite them, and #1909 went first because it is the correctness fix. **`OpenDB`** — kept #1909's pinned-connection `detectSchema` and added this PR's `prepareStatements()` after it: ```go derr := d.detectSchema(ctx, sc) _ = sc.Close() if derr != nil { conn.Close(); return nil, fmt.Errorf("schema detection failed: %w", derr) } // Statements are prepared after schema detection so they can never be // compiled against a schema mode that turned out to be wrong (#1901). if err := d.prepareStatements(); err != nil { ... } ``` The ordering matters and is not arbitrary: preparing before detection would compile statements against a schema mode that #1909 exists to stop trusting. **`Close()`** — kept this PR's statement closing and **did not** restore the WAL checkpoint. #1909 removed it deliberately: the handle is `mode=ro`, so `PRAGMA wal_checkpoint(TRUNCATE)` can only ever fail with "disk I/O error (778)" and was emitting a misleading storage-fault line on every shutdown. That reasoning survives; the statement closing is added in front of it. ## Verification - Both commits cherry-picked onto `e5595ad9` - `cmd/server` builds - **Full `cmd/server` suite: ok, 0 failures** (not just the targeted DB tests — after master briefly went red today from a two-PR interaction, a full local run seemed worth the two minutes) ## Review points still open, none blocking From my review on #1878, unchanged by the rebase: 1. Every SQL string now exists twice, once prepared and once as the `stmtQueryRow` fallback literal, with nothing keeping them in sync. The fallback is genuinely needed — twelve test helpers build `&DB{conn: ...}` directly and never call `prepareStatements` — but a constructor for those helpers would remove the duplication. 2. `stmtCountObsLastHour` and `stmtCountObsLastDay` are byte-identical SQL. 3. `OpenDB` now refuses to start rather than degrading when a Prepare fails. Contained today, since none of the 13 prepared queries touch a schema-conditional column, but the failure mode changed. @Joel-Claw — your work, your credit. Ping me if you would rather take it back. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Wzwr3eXseyNM7Xj598djjE --------- Co-authored-by: Joel Claw <358739783+Joel-Claw@users.noreply.github.com>
Fixes #1901.
Thanks to @MarekWo for the exceptionally thorough report — root cause, repro, and a prioritised fix checklist in one. This implements it.
Problem
detectSchema()swallowed any probe-query error with a barereturn, so a single transient failure of the firstPRAGMA table_info(observations)at startup leftisV3(and the feature flags) at their zero value for the entire process lifetime. The server then ran v2 SQL against a v3 DB: Packets page empty,/api/channels/<name>/messages→ 500, logs full ofno such column: o.observer_id, while the database was perfectly healthy. Nothing re-checked the flag, so only a manual restart recovered it.Fix
Works through the report's checklist:
detectSchemanow returnserrorandOpenDBaborts on it.main.goalreadylog.Fatalfs on anOpenDBfailure, so the supervisord/Docker restart policy retries and a transient cause clears on the next attempt — strictly better than serving a broken read API.[db] schema mode: v3 (observer_idx)/v2 (observer_id). A clean startup log is now positive evidence detection ran, not just an absence of errors.conn.Conn(ctx)) rather than an arbitrary pooled one, so the startup race in the report's hypothesis can't quietly hand detection a fresh, not-yet-openable handle — if the connection can't be acquired, we fail loud.Close()no longer checkpoints the read-only handle.PRAGMA wal_checkpoint(TRUNCATE)on amode=roconnection always failed withdisk I/O error (778)and looked like a storage fault on every shutdown (the report's aside). The ingestor (the writer) owns WAL checkpointing.The three near-identical PRAGMA scan loops are consolidated into one
schemaColumns()helper that returns errors instead of ignoringScanfailures.On the "single source of truth" item
The report suggests deriving
isV3fromdbschema.TableHasColumn(...). I kept the PRAGMA-scan structure here becausedetectSchemasets six flags from three tables in a single pass; swapping toTableHasColumnwould mean six separate probe calls and wouldn't actually be cleaner. The goal it was aimed at — never cache a false negative — is met by making the existing scan fail loud. Happy to switch to the single-probe-per-column shape if you'd prefer it.Honest note on the connection
conn.Conn(ctx)pins a single connection for all four probes and fails loud if it can't be acquired; it does not guarantee the literal connectionPing()validated (database/sqldoesn't expose that). The fail-fast is what actually closes the bug — a mis-detected schema aborts startup instead of persisting for the process lifetime.Tests
TestDetectSchemaFailsLoudOnProbeError— injects a probe failure through arowQuerierand asserts the error propagates andisV3stays unset (the invariant the old bare-returnviolated).TestDetectSchemaV3AndV2— covers both schema shapes throughOpenDB.go vet ./cmd/serverandgo buildare clean; targetedgo test -run 'DetectSchema|OpenDB'is green.Heads-up on the full
go test ./cmd/serverrun: a handful ofTestHandleNodePaths_*/TestHandleAnalytics*tests return503 index loading, plus one intentional panic test — these fail identically on pristinemaster(a06ac8ac) with this branch stashed, i.e. they're pre-existing/timing-related and untouched by this change.Out of scope (per the issue): frontend behaviour when the API 500s.
🤖 Authored with Claude · Co-Authored-By trailer on the commit.