Skip to content

perf: use prepared statements for frequently-called server DB queries - #1878

Closed
Joel-Claw wants to merge 2 commits into
Kpa-clawbot:masterfrom
Joel-Claw:perf/server-prepared-statements
Closed

perf: use prepared statements for frequently-called server DB queries#1878
Joel-Claw wants to merge 2 commits into
Kpa-clawbot:masterfrom
Joel-Claw:perf/server-prepared-statements

Conversation

@Joel-Claw

Copy link
Copy Markdown
Contributor

Problem

The server makes 72+ raw db.conn.QueryRow / db.conn.Query calls. Every call requires SQLite to parse and compile the SQL from scratch. The ingestor already uses 10 prepared statements (stmtGetTxByHash, stmtInsertTransmission, etc.) but the server layer never adopted the pattern.

Fix

Add 13 prepared statements to the DB struct, prepared once at OpenDB time and reused across all requests:

Statement Used by
COUNT(*) FROM transmissions GetStats + 2 query fast paths
COUNT(*) FROM observations GetStats
COUNT(*) FROM nodes WHERE last_seen > ? GetStats
COUNT(*) FROM nodes GetStats
COUNT(*) FROM observers WHERE inactive IS NULL OR inactive = 0 GetStats
COUNT(*) FROM observations WHERE timestamp > ? GetStats (last hour + last day)
SELECT public_key FROM nodes WHERE public_key = ? OR name = ? resolveNodePubkey
SELECT id FROM transmissions WHERE hash = ? GetObservationsForHash
COUNT(*) FROM nodes WHERE role = ? AND last_seen > ? GetRoleCounts
COUNT(*) FROM nodes WHERE role = ? GetAllRoleCounts
COALESCE(MAX(id), 0) FROM transmissions GetMaxTransmissionID
COALESCE(MAX(id), 0) FROM observations GetMaxObservationID

Impact

  • GetStats is called on every /api/stats request — 8 queries, now all prepared
  • GetRoleCounts + GetAllRoleCounts — 8 queries (4 roles x 2 functions), now all prepared
  • GetMaxTransmissionID + GetMaxObservationID — polled by WebSocket clients for new-data detection
  • resolveNodePubkey — called per-request for node name resolution
  • GetObservationsForHash — called on packet detail lookups

These are the hottest query paths. The remaining ~50 raw calls are dynamic query builders (optional WHERE clauses, GROUP BY, etc.) that are harder to prepare and less frequently called.

Testing

  • go build passes
  • go vet passes
  • No behavior change — same queries, same results, just compiled once

Closes #1875

Joel Claw added 2 commits July 28, 2026 13:38
Add 13 prepared statements to the DB struct, prepared once at OpenDB
time and reused across all requests. Previously, every call to
GetStats, GetRoleCounts, GetAllRoleCounts, resolveNodePubkey,
GetObservationsForHash, GetMaxTransmissionID, GetMaxObservationID,
and count-only fast paths in QueryPackets/GetPacketGroups issued
raw db.conn.QueryRow calls that required SQLite to parse and compile
the SQL from scratch each time.

Statements prepared:
- COUNT(*) FROM transmissions (used in GetStats + 2 query fast paths)
- COUNT(*) FROM observations (GetStats)
- COUNT(*) FROM nodes WHERE last_seen > ? (GetStats)
- COUNT(*) FROM nodes (GetStats)
- COUNT(*) FROM observers WHERE inactive IS NULL OR inactive = 0
- COUNT(*) FROM observations WHERE timestamp > ? (last hour + last day)
- SELECT public_key FROM nodes WHERE public_key = ? OR name = ? (resolveNodePubkey)
- SELECT id FROM transmissions WHERE hash = ? (GetObservationsForHash)
- COUNT(*) FROM nodes WHERE role = ? AND last_seen > ? (GetRoleCounts)
- COUNT(*) FROM nodes WHERE role = ? (GetAllRoleCounts)
- COALESCE(MAX(id), 0) FROM transmissions (GetMaxTransmissionID)
- COALESCE(MAX(id), 0) FROM observations (GetMaxObservationID)

The ingestor already uses prepared statements (10 stmts in db.go).
This brings the server in line with the same pattern.

Matches issue Kpa-clawbot#1875.
…atibility

Test DB setup functions (setupTestDB, setupTestDBv2, setupTestDBV2,
setupCapabilityTestDB) don't call prepareStatements(), leaving prepared
statement fields nil. This caused TestBridgeScore_HandleNodesSurface to
panic with nil pointer dereference at database/sql.(*Stmt).QueryRowContext.

Add stmtQueryRow helper that uses the prepared statement when non-nil
(production path) or falls back to a direct db.conn.QueryRow query when
nil (test path). Update all 14 prepared statement call sites to use the
helper.

Production behavior is unchanged — prepared statements are still used
when available. Test DBs without prepareStatements() now gracefully
fall back to ad-hoc queries.
@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 comments. No correctness defect found.

  1. The stmtQueryRow fallback is genuinely needed, which I did not expect. Twelve test
    helpers construct &DB{conn: ...} directly and never call prepareStatements
    (db_test.go:141,1273,2237, coverage_test.go:69, multibyte_capability_test.go:39,468,
    node_reach_bench_test.go:66,167, node_reach_endpoint_test.go:96, node_reach_test.go:36,
    resolved_index_test.go:1168, scopes_test.go:50). The cost is that every SQL string now
    exists twice, prepared and as a fallback literal, with nothing keeping them in sync. Worth
    asking whether the test helpers should get a constructor instead.
  2. stmtCountObsLastHour and stmtCountObsLastDay are byte-identical SQL
    (SELECT COUNT(*) FROM observations WHERE timestamp > ?). Two statements for one query.
  3. OpenDB now returns an error if any Prepare fails, where a missing column previously
    degraded a single query. detectSchema shows the codebase does expect schema variance
    (isV3, hasResolvedPath, hasObsRawHex, hasScopeName, hasLastSeen,
    hasMultibyteSupCols). None of the 13 prepared queries touch a conditional column, so the
    risk is contained today, but the failure mode changed from degrade to refuse-to-start.
    Flag it, do not block on it.

@efiten

efiten commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

This went CONFLICTING because #1909 merged first, which I flagged in my review above as the deliberate order: #1909 is the correctness fix (detectSchema failing loud instead of caching a wrong schema mode) and this is the optimisation, so it went first.

The conflict is in the two places I named: both PRs rewrite OpenDB right after d := &DB{conn: conn, path: path}, and both rewrite func (db *DB) Close(). #1909 replaced d.detectSchema() with the pinned-connection version and deleted the WAL checkpoint from Close; this PR inserts prepareStatements() and prepends statement closing.

Want me to do the rebase? I have done the same for #1916, #1771 and #1863 today, keeping the original authorship on the commits and opening it as a continuation PR rather than force-pushing to your branch. Say the word and I will, or take it yourself if you would rather.

My review points are unchanged and none of them block: the duplicated SQL between stmtQueryRow's fallback and prepareStatements (genuinely needed, because twelve test helpers build &DB{conn: ...} directly), the two byte-identical statements for the observations count, and OpenDB now refusing to start rather than degrading when a Prepare fails.

@Joel-Claw

Copy link
Copy Markdown
Contributor Author

Yes, please go ahead with the rebase. Happy to let you handle it given you have the context from the other rebases today. Thanks.

@Joel-Claw

Copy link
Copy Markdown
Contributor Author

Closing in favour of #1934 (rebase by @efiten). Thanks for handling the rebase.

@Joel-Claw Joel-Claw closed this Sep 2, 2026
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>
@efiten

efiten commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Merged via #1934, which is your two commits rebased onto master with your authorship intact. Closing this as the vehicle rather than the work.

The conflict resolution, for the record: prepareStatements() now runs after detectSchema, so statements can never be compiled against a schema mode #1909 exists to stop trusting. And Close() keeps your statement closing but does not restore the WAL checkpoint, since #1909 removed it deliberately on the grounds that a mode=ro handle can only ever fail it.

The three review points from my earlier comment are unchanged and none of them blocked: the SQL duplicated between stmtQueryRow's fallback and prepareStatements, the two byte-identical observations-count statements, and OpenDB now refusing to start rather than degrading when a Prepare fails. Worth a follow-up if you feel like it, not worth holding the PR for.

Thanks for the quick reply on the rebase offer, and for turning around the ParsedDecoded copy fix on #1871 this morning.

efiten added a commit that referenced this pull request Sep 2, 2026
… (#1936)

Continues #1887 by @Jonher937. The commit is theirs, authorship
unchanged; I only rebased it onto master.

It went CONFLICTING because #1934 (prepared statements, originally
@Joel-Claw's #1878) landed in the same `DB` struct. Both PRs add fields
there and this one also replaces the single-slot channels cache.

Resolution: kept this PR's keyed caches (`channelsCache`,
`encChannelsCache`, `msgCache` plus their entry types and TTL constants)
and kept master's thirteen prepared-statement fields alongside them. The
old single-slot `channelsCacheKey`/`channelsCacheRes`/`channelsCacheExp`
trio is gone, which is the point of this PR. Nothing else touched.

Verified: `cmd/server` builds and the **full suite passes**, not just
the channel tests.

My review stands: approve, with two questions that do not block and are
worth a look at some point.

1. `msgCache` is keyed by `hash|limit|offset|region`, and `offset` grows
without bound as someone pages through a channel. Each entry also holds
a full page of message maps, so a full 256-entry cache at `limit=50`
holds around 12,800 maps. The other two caches are keyed by region only
and genuinely low-cardinality as your comment says; this one is the odd
one out.
2. `getMsgCache` returns the cached slice directly, so every hit hands
the caller the same message maps. If any handler mutates one before
serialising, it corrupts the cache for the next ten seconds. Same class
as the finding on #1871, which was fixed there by copying at the two
broadcast sites.

Co-authored-by: Jonathan Herlin <jonte@jherlin.se>
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.

perf: use prepared statements in server DB layer (matching ingestor pattern)

2 participants