Skip to content

fix(#1901): detectSchema fails loud instead of caching wrong schema mode - #1909

Merged
efiten merged 1 commit into
Kpa-clawbot:masterfrom
SaarMesh-Bot:fix/1901-schema-detection-fail-loud
Sep 2, 2026
Merged

fix(#1901): detectSchema fails loud instead of caching wrong schema mode#1909
efiten merged 1 commit into
Kpa-clawbot:masterfrom
SaarMesh-Bot:fix/1901-schema-detection-fail-loud

Conversation

@SaarMesh-Bot

Copy link
Copy Markdown
Contributor

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 bare return, so a single transient failure of the first PRAGMA table_info(observations) at startup left isV3 (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 of no 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:

  • Don't swallow the error. detectSchema now returns error and OpenDB aborts on it. main.go already log.Fatalfs on an OpenDB failure, so the supervisord/Docker restart policy retries and a transient cause clears on the next attempt — strictly better than serving a broken read API.
  • Log the mode unconditionally[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.
  • Run detection on a single pinned connection (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 a mode=ro connection always failed with disk 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 ignoring Scan failures.

On the "single source of truth" item

The report suggests deriving isV3 from dbschema.TableHasColumn(...). I kept the PRAGMA-scan structure here because detectSchema sets six flags from three tables in a single pass; swapping to TableHasColumn would 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 connection Ping() validated (database/sql doesn'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 a rowQuerier and asserts the error propagates and isV3 stays unset (the invariant the old bare-return violated).
  • TestDetectSchemaV3AndV2 — covers both schema shapes through OpenDB.

go vet ./cmd/server and go build are clean; targeted go test -run 'DetectSchema|OpenDB' is green.

Heads-up on the full go test ./cmd/server run: a handful of TestHandleNodePaths_* / TestHandleAnalytics* tests return 503 index loading, plus one intentional panic test — these fail identically on pristine master (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.

…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>
@efiten

efiten commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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
review itself.

The core change is right: a swallowed PRAGMA failure previously left isV3=false against a v3
database for the whole process lifetime. Failing startup lets the supervisor retry. The
rowQuerier interface makes the failure path unit-testable, and the injected-probe-failure test
is a proper regression guard. fmt.Sprintf into the PRAGMA is fine: PRAGMA takes no bound
parameters and the table name is a caller-supplied literal, which the comment states.

  1. Scope creep worth flagging. Close() also loses its PRAGMA wal_checkpoint(TRUNCATE).
    The reasoning is sound (the handle is mode=ro, so the checkpoint can only ever fail) but it
    is an unrelated change bundled into a schema-detection PR, and it removes the
    [db] WAL checkpoint complete line from shutdown logs.
  2. Behaviour on a persistent probe failure changes from "run wrong" to "crash loop under
    restart: unless-stopped". That is the correct trade and exactly what bug(db): detectSchema() silently swallows PRAGMA failure — server runs v2 SQL against a v3 DB until restart (empty Packets, /channels/*/messages 500) #1901 asks for, but
    say it out loud in the PR.

Merge-order finding: #1909 and #1878 conflict, in two places. Both rewrite OpenDB right
after d := &DB{conn: conn, path: path} (one inserts prepareStatements(), the other replaces
d.detectSchema()), and both rewrite func (db *DB) Close() (one prepends statement closing,
the other deletes the checkpoint block). Both report MERGEABLE against master because neither
has merged yet. Whichever lands first forces a rebase on the other. Decide the order
deliberately; I would take #1909 first, since it is the correctness fix and #1878 is an
optimisation.

@efiten

efiten commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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 action_required run makes it execute against the merge commit from when the run was created, not against current master. This one was created weeks ago, so it tested a base that predates the #1923 fix and five merges that have landed since. The result it produced says nothing useful.

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.

@efiten efiten closed this Sep 2, 2026
@efiten efiten reopened this Sep 2, 2026
@efiten
efiten merged commit 1441734 into Kpa-clawbot:master Sep 2, 2026
8 of 12 checks passed
efiten added a commit that referenced this pull request Sep 2, 2026
… (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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(db): detectSchema() silently swallows PRAGMA failure — server runs v2 SQL against a v3 DB until restart (empty Packets, /channels/*/messages 500)

2 participants