Skip to content

perf(db): port upstream bounded ANALYZE planner statistics (issue #100) - #106

Closed
dborup wants to merge 7 commits into
masterfrom
codex/port-upstream-2072-2074-analyze
Closed

dborup wants to merge 7 commits into
masterfrom
codex/port-upstream-2072-2074-analyze

Conversation

@dborup

@dborup dborup commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Summary

Ports upstream Kpa-clawbot/CoreScope#2072 and Kpa-clawbot/CoreScope#2074 (SQLite planner statistics via a bounded ANALYZE in the ingestor). Decision issue: issue #100.

What upstream adds:

  • Store.RefreshPlannerStats: PRAGMA analysis_limit (default 10000) followed by ANALYZE.
  • Store.EnsurePlannerStats: builds statistics at startup, but only on a database without sqlite_stat1. It runs once per database and logs a warning before it starts.
  • A routine refresh 2 minutes after startup, then every 24 h.
  • db.analysisLimit in internal/dbconfig. A negative value disables the feature.

This fork adds one thing on top: a fixed startup order against the route_mask backfill from #93.

The DB writes stay in cmd/ingestor. cmd/server only gains a read-only test.

Upstream was unreviewed. Both upstream PRs were written and merged by the same author with 0 reviews. The only comments are the author's own correction. Treat the code here as unreviewed until the separate review.

Cherry-picks (git cherry-pick -x, upstream order)

here upstream author / committer conflicts
9bfa9cd9 05c8bf295fd0 (#2072) efiten / dborup none
197845a9 df6c7ed5c9b4 (#2072) efiten / dborup none
d24e917e 4500cfa66601 (#2074) efiten / dborup none (main.go auto-merged)
e5b0b502 7bae1626d6ce (#2074) efiten / dborup none (main.go auto-merged)
5bd94224 5fbf11471329 (#2074) efiten / dborup none (main.go auto-merged)

All five applied cleanly. The added and removed lines are identical to upstream d14317b0..6e121ff8 for cmd/ingestor and internal/dbconfig (verified by diffing the hunks).

Fork commits (author and committer dborup):

Fork adaptation: startup order against the route_mask backfill

Two startup steps each hold the single writer (writerMu, SetMaxOpenConns(1)) for one long statement:

Started side by side (as upstream would do next to our #93), they queue behind each other on writerMu. Started the moment ingestBuffer.Ready() fires, either of them also sits on top of the backlog collected during the startup migrations.

startPlannerStatsThenRouteMaskBackfill (cmd/ingestor/planner_stats_startup.go) therefore runs one goroutine:

drain ingest buffer → EnsurePlannerStats → drain → StartRouteMaskBackfill
  • Statistics first. The channel queries are waiting on them. The backfill is background repair whose batch phase runs for minutes on a staging-sized table (100 rows per 50 ms).
  • Each drain wait is bounded to 2 min. If ingest outpaces the writer, the next step still runs. On shutdown (ctx cancelled) the backfill is not started; it resumes on the next start, as before.
  • Restarts. On a restart, EnsurePlannerStats is a single sqlite_master query, so the backfill starts after the first drain, i.e. at the same point as before, give or take a few seconds.
  • Routine refresh. schedulePlannerStatsRefresh starts its 2 minute stagger when the sequence hands over, so it cannot land on either hold. It is also stopped on shutdown.
  • Other tickers. The WAL checkpoint (+30 s), observer retention (+90 s), multibyte persist (+2 min) and neighbor prune (+4 min) stay where they were. Each is a short write that queues behind the running hold and then releases writerMu. They add seconds, not another long hold.

Other adaptations:

  • EnsurePlannerStats returns early on a negative limit. Upstream logged its "ingest will pause" warning even when nothing was built.
  • db.go:142 references now say "in OpenStore". Here SetMaxOpenConns(1) is on line 150.
  • db.analysisLimit is documented in config.example.json, docs/user-guide/configuration.md and docs/user-guide/database.md, including rollback. Upstream had no docs for it; our docs describe the sibling knob incrementalVacuumPages.

Tests

The upstream file refresh_planner_stats_test.go passes on our schema: 13/13.

New tests in planner_stats_startup_test.go (written first; red before the implementation):

  • TestPlannerStatsAndRouteMaskBackfillAtStartup_Issue100: EnsurePlannerStats and the backfill both run under live ingest through a real IngestBuffer. No deadlock, statistics built, backfill done, no NULL rows left. All packets fed during the run are written (0 dropped, 0 failed), and a packet sent afterwards is written too.
  • TestPlannerStatsBeforeRouteMaskBackfillWithDrains_Issue100: the order is drain → ANALYZE → drain → backfill start → index build.
  • TestStartupSkipsAnalyzeWhenStatsExist_Issue100: with an existing sqlite_stat1 there is no ANALYZE at startup. A sentinel row in sqlite_stat1 survives, and the backfill still runs.
  • TestNegativeAnalysisLimitDisablesBothStartupAndRefresh_Issue100: analysis_limit < 0 disables both the build and the refresh, but not the backfill.
  • TestPlannerStatsRefreshWaitsForStartupThenTicks_Issue100, TestStartupDrainWaitIsBounded_Issue100, TestStartupSequenceStopsOnCancel_Issue100 (cancel between the two writers).
  • TestHasPlannerStatsErrorReadsAsAbsent_Issue100, TestEnsurePlannerStatsDisabledIsSilent_Issue100.
  • TestRefreshPlannerStatsRewritesExistingStats_Issue100: a refresh rewrites existing statistics. This is needed because upstream's guard against PRAGMA optimize is vacuous here: modernc.org/sqlite bundles SQLite 3.46.0, where PRAGMA optimize does analyze a never-analyzed table. Upstream measured on 3.45.1.

In cmd/server, TestServerReadsPlannerStatsThroughReadOnlyHandle_Issue100 only reads:

  • sqlite_stat1, written by a separate connection, reaches the server's mode=ro handle, both an already-open one and a freshly opened one.
  • The region channel plan flips from idx_transmissions_payload_type to idx_tx_channel_hash.
  • ANALYZE fails on the server handle.
  • GetChannels still answers.

Mutations (15)

Each mutation was run against go test -run '_Issue100$|_Issue2058$'. Final round on the pushed code: 15/15 killed, 0 survivors. In the first round M9 and M11 survived; each got a new test (the cancel window between the writers, and TestRefreshPlannerStatsRewritesExistingStats_Issue100).

mutation killed by
M1 EnsurePlannerStats always runs SkipsWhenPresent, IsQuietWhenPresent, SurviveReopen, StartupSkipsAnalyzeWhenStatsExist
M2 EnsurePlannerStats never runs BuildsWhenAbsent, WarnsBeforeBuilding, SurviveReopen, the startup and order tests
M3a negative limit ignored in EnsurePlannerStats DisabledIsSilent, NegativeAnalysisLimitDisablesBoth…
M3b negative limit ignored in RefreshPlannerStats RefreshPlannerStatsNegativeLimitIsANoop
M3c negative limit ignored in the scheduler NegativeAnalysisLimitDisablesBoth…
M3d config maps a negative limit to the default AnalysisLimitConfigDefault
M4 hasPlannerStats error read as "has stats" HasPlannerStatsErrorReadsAsAbsent
M5 wrong order: backfill before EnsurePlannerStats PlannerStatsBeforeRouteMaskBackfillWithDrains, StartupSequenceStopsOnCancel
M6a first drain removed PlannerStatsBeforeRouteMaskBackfillWithDrains
M6b drain between ANALYZE and backfill removed PlannerStatsBeforeRouteMaskBackfillWithDrains, StartupSequenceStopsOnCancel
M7 refresh does not wait for the startup sequence PlannerStatsRefreshWaitsForStartupThenTicks
M8 default limit 400 AnalysisLimitConfigDefault, AnalysisLimitDefaultIsHighEnoughToMatter
M9 ctx check before the backfill removed StartupSequenceStopsOnCancel
M10 drain timeout ignored StartupDrainWaitIsBounded
M11 ANALYZE → PRAGMA optimize RefreshPlannerStatsRewritesExistingStats

Measurements (local, not staging)

Hardware: KVM guest with 4 vCPU (Intel Xeon @ 2.80 GHz), 16 GB RAM, virtio disk, Linux 6.18. SQLite 3.46.0 (modernc.org/sqlite v1.34.5). "Cold" means sync; echo 3 > /proc/sys/vm/drop_caches inside the guest; the host's cache is outside my control.

Synthetic DB: 1.94 GB, created with the ingestor's own OpenStore schema.

  • 450,000 transmissions and 4,495,025 observations (≈10/tx; staging has 11.3).
  • 10 % GRP_TXT, as on staging (126k of 1.25M). Of those, 45 % are plaintext channels (40 names, #public dominant) and 55 % are enc_XX (200 values).
  • 150 observers in 13 IATA regions (AMS largest). 30 days.

Channel queries, without and with sqlite_stat1 (identical copies)

Plan for GetChannels(region):

  • without: idx_transmissions_payload_type + TEMP B-TREE GROUP BY
  • with: idx_tx_channel_hash, no GROUP BY b-tree

The plan for GetEncryptedChannels(region) does not change: LIKE 'enc_%' cannot use the partial index.

Cache dropped before each call, 3 repetitions × 2 passes:

without stats with stats
GetChannels AMS, read from disk 1,384–1,396 MB 162–173 MB
GetChannels AMS, cold 2.28–2.52 s 2.46–2.67 s
GetChannels EIN, cold 2.03–2.25 s 2.53–2.70 s
GetEncryptedChannels AMS, read 913–919 MB 498–504 MB
GetEncryptedChannels AMS, cold 2.74–3.11 s 3.18–3.52 s
GetChannels AMS, warm 405–456 ms 335–369 ms
GetEncryptedChannels AMS, warm 386–434 ms 390–507 ms
  • Reading: the statistics cut the bytes read by ~88 % (GetChannels) and ~45 % (GetEncryptedChannels, same plan text; I cannot explain that drop). This matches the direction of upstream's page-miss figure.
  • Cold wall time: it did not fall. It rose 5–25 % on this disk: random access in channel_hash order against sequential readahead.
  • Warm: GetChannels(AMS) is ~15–20 % faster.
  • Staging decides. Whether the byte savings turn into time on staging's disk is exactly what the staging test must show. I make no speedup claim here.

First ANALYZE (limit 10000) and the ingest buffer

  • What the feed exercises: the real handleMessage (channel-message path) through a real IngestBuffer (cap 50,000).
run ANALYZE longest gap between writes max pending dropped caught up after
cold, 130/min 8.68 s 8.91 s 18 0 ~2.5 s
cold, 600/min 9.18 s 9.18 s 90 0 ~3 s
warm, 600/min 0.30 s 0.30 s 1 0 –
restart: stats present, routine refresh cold, 600/min 8.25 s 8.25 s 81 0 ~3 s

Order against the backfill (cold, no stats, 20,454 NULL route_mask rows, 600/min, 2 runs each)

ANALYZE index build longest gap max pending dropped
this PR (drain → ANALYZE → drain → backfill) 8.50 / 9.01 s 3.25 / 3.21 s 8.50 / 9.01 s 84 / 89 0
concurrent start (upstream wiring next to #93) 8.69 / 8.73 s 3.14 / 3.24 s 8.69 / 8.73 s 116 / 116 0
  • Honest reading: with concurrent start the two holds do not merge into one gap either. The ingest consumer, already waiting on writerMu, gets one write in between, because the backfill's bookkeeping first queues on the connection.
  • What the order buys, measured: ~25 % lower peak backlog here. The single longest pause is set by the ANALYZE in both cases.
  • Why it is kept anyway: the order is deterministic instead of depending on who gets to writerMu first, and the backfill no longer lands on top of the startup backlog.

Expected ingest pause per database (one time)

  • Size: one ANALYZE at startup on a database without sqlite_stat1. Upstream measured 3m43.9s cold on 9.4 GB (staging, 1.25M tx / 14.2M obs); here 8.7–9.2 s cold on 1.94 GB.
  • Effect on ingest: ingest stops writing for that duration and buffers. At 130–600 msg/min that is ~500–2,300 messages over upstream's 3m44 figure, far below the 50,000 capacity. Nothing is dropped. The route_mask backfill (if it has work) starts only after the backlog has drained.
  • Every restart after that: no startup ANALYZE.

Known limitation (upstream design, unchanged here): the routine refresh 2 minutes after every restart is a full bounded ANALYZE. Warm it took 0.3 s here (upstream: 2.0 s). With a cold cache it cost as much as the first build (8.25 s against 9.2 s here). If the host cache is cold after a staging/production restart, that means a pause of the same size as the first build on every restart. The staging test should measure this.

Rollback

  • Code: an older image ignores sqlite_stat1. SQLite itself uses it for planning, but nothing in the old code writes or requires it.
  • Statistics: remove them with DROP TABLE sqlite_stat1;, only via the ingestor side, with the ingestor stopped. The server's handle is mode=ro and must not do it. With analysisLimit enabled, the next start rebuilds the table.
  • Disable without rollback: "db": {"analysisLimit": -1}.

Staging test plan

  1. Before deploy: record SELECT count(*) FROM sqlite_master WHERE name='sqlite_stat1' and EXPLAIN QUERY PLAN for the region channel query (read-only).
  2. Deploy. Expect [analyze] this database has no planner statistics; building them now … and then planner statistics built in …. Record the duration.
  3. Ingest per minute during the ANALYZE, plus [ingest-buffer] lines: expect 0 dropped, a pause of the same length and a catch-up burst.
  4. The order in the log: planner statistics built before "tx_route_mask_backfill_v1" starting (if the backfill has work).
  5. After deploy: plan on idx_tx_channel_hash. Measure GetChannels/GetEncryptedChannels cold and warm against the pre-deploy figures (the local result above does not settle this).
  6. Restart the container: expect no "no planner statistics" line. Measure the planner statistics refreshed in … duration 2 min later (cold/warm, see the known limitation).
  7. Rollback drill on a copy: DROP TABLE sqlite_stat1, confirm the plan flips back.

Checks

  • Ingestor: go vet ./... clean in cmd/ingestor. gofmt clean on touched files (some untouched files on master are not gofmt-clean; unchanged). go test -race -timeout 20m ./... in cmd/ingestor: ok (663 s).
  • Server: go vet ./... clean in cmd/server. go test -race ./... in cmd/server: ok (616 s).
  • Other: internal/dbconfig ok. Read-only invariant (readonly_invariant_test.go, 4 tests) ok.
  • Not run: browser validation (no UI change).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ut9z1opV6nVhm2vzqNz8ZV


Generated by Claude Code

efiten and others added 7 commits September 27, 2026 08:21
…a-clawbot#2058)

ANALYZE has never run against these databases, so sqlite_stat1 does not exist
and the planner works from built-in guesses. On the channel queries it guesses
wrong: it drives from the plain idx_transmissions_payload_type instead of
idx_tx_channel_hash, the partial index (WHERE payload_type = 5) the schema
already carries for that exact filter.

Reported in Kpa-clawbot#2058 by anieto with measurements against a 464,667-transmission
production database. A plain ANALYZE, no query and no schema change, moved
GetChannels from 13.19s to 4.25s for one region and 10.44s to 2.45s for another,
and the GROUP BY temp b-tree disappeared because the partial index is already in
channel_hash order. The same report rules out the obvious alternative: forcing
the plan to drive from the observer side measured 56.71s, worse than the 13.19s
it was meant to fix.

Verified independently on this deployment before writing any of this: the
sqlite_master query for sqlite_stat% returns nothing against the live 9.7 GB
meshcore.db, and there is no occurrence of ANALYZE, PRAGMA optimize or
sqlite_stat anywhere under cmd/ or internal/. So the gap is real here too, at
roughly 20x the row count of the reported database.

The refresh runs from the ingestor, not the server: cmd/server/db.go:145 opens
its handle with mode=ro and ANALYZE writes sqlite_stat1.

analysis_limit is what makes this schedulable. A bare ANALYZE walks every index
in full, which is not something to point at a 9.7 GB file on a ticker; the
bounded form stops after N index rows. 400 is the value SQLite's documentation
gives. PRAGMA optimize then re-analyzes only the tables whose statistics it
judges missing or stale, so later runs do less than the first. Wired next to the
existing WAL checkpoint ticker at 24h with a 2 minute startup stagger, following
the surrounding maintenance pattern, and configurable through db.analysisLimit
(negative disables it).

Note analysis_limit=0 means *no* limit to SQLite rather than "use a default",
which is why Config.AnalysisLimit maps an unset config to 400 and why a test
covers that specific case.

NOT verified, and it is the open question on this change: anieto measured a full
ANALYZE, while this ships the bounded form. Whether analysis_limit=400 produces
statistics good enough to flip the same plan is untested on a database of this
size. The 9.4 GB staging database is the place to measure it, and that
measurement has not been run.

Go tests were not run locally: this dev box has CGO_ENABLED=0 and no C compiler,
so the ingestor module does not build here (github.com/mattn/go-sqlite3 needs
cgo). gofmt is clean. CI is the only verification these six tests have had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YAR8fdNTzqjtsggq4xCX6
(cherry picked from commit 05c8bf2)
…#2058)

Measurement on the 9.4 GB staging database showed the previous commit did
nothing, twice over. Both faults are corrected here.

PRAGMA optimize was the wrong statement. It analyzes only tables that the
calling connection has itself queried during the session, and a maintenance call
has queried none, so it writes nothing: run against staging it left sqlite_stat1
absent, and PRAGMA optimize(0x03) returned no statements at all. Replaced with
ANALYZE, which is deterministic. Confirmed on an empty database too (SQLite
3.45.1): ANALYZE creates sqlite_stat1, PRAGMA optimize does not, which is what
makes the behavioural test a real guard rather than a no-op.

analysis_limit=400 was the wrong value. Staging is 1,250,489 transmissions and
14,169,329 observations; the region-filtered GetChannels plan there is identical
to the one reported in Kpa-clawbot#2058, so the diagnosis transfers, but the remedy needed
measuring. Counting page-cache misses rather than wall time, because wall time on
that file is dominated by the OS page cache (the same query and plan measures
56.7s cold and 0.80s warm):

  analysis_limit   ANALYZE    driving index                     page misses
  none (no stats)  -          idx_transmissions_payload_type     143,442
  400              171ms      idx_transmissions_payload_type     143,449
  1000             171ms      idx_transmissions_payload_type     143,450
  10000            2.0s       idx_tx_channel_hash                107,429
  0 (unbounded)    242.9s     idx_tx_channel_hash                107,429

400 and 1000 sample too few rows to separate the 126,336-row partial index from
the 920,700-row plain one, and leave the plan exactly as it was. 10000 buys the
whole change for 2.0s, and the four-minute unbounded ANALYZE buys nothing
further. Default is now 10000, with a test that fails if it is ever lowered back
into the range measured ineffective.

What this is worth, stated as measured: 25% fewer pages read per query
(143,442 to 107,429, about 147 MB at a 4 KB page). Warm wall time does not move,
0.80s either way. The gain lands on the cold path, which is the one that measured
56.7s, so the honest claim is fewer pages read rather than a warm speedup. This
is a smaller and differently-shaped win than the 3-4x in Kpa-clawbot#2058, whose database is
2.7x smaller in transmissions and 7x in observations.

Renamed Store.OptimizeStats to RefreshPlannerStats, since it no longer runs
PRAGMA optimize, and the test file with it.

Staging was left carrying limit-10000 statistics, matching what this code
produces. Reversible throughout: DROP TABLE sqlite_stat1 was verified on a
scratch database before any of this ran. The staging container is up and
unaffected.

Still not run locally: the Go tests. This box has CGO_ENABLED=0 and no C
compiler, so the ingestor module does not build here. gofmt is clean. The SQLite
behaviours above were measured against 3.45.1 on the server, not against the
amalgamation mattn/go-sqlite3 bundles in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YAR8fdNTzqjtsggq4xCX6
(cherry picked from commit df6c7ed)
…a-clawbot#2058)

Kpa-clawbot#2072 left the 2 minute startup stagger as an open gap: a query arriving in that
window against a database with no statistics runs the plan measured there at
143,442 pages read, 56.7s cold on the 9.4 GB staging file. This closes it.

The gap is narrower than it first looks, and the fix is shaped by that.
sqlite_stat1 is an ordinary table, so once ANALYZE has written it the statistics
stay in the file: verified that they survive closing the connection, that a
mode=ro handle reads them back, and, in a test here, that reopening the same path
through a second OpenStore finds them and skips the rebuild. So the window exists
once per database, on the first start after this code lands, and not on any
restart after that.

EnsurePlannerStats therefore checks before it builds. On the common path it costs
one sqlite_master query; only a never-analyzed database pays the ANALYZE,
measured at 2.0s on 9.4 GB. It runs on the refresh goroutine rather than the
startup path, so no boot step waits for it. Writes serialise through the store's
single connection either way (SetMaxOpenConns(1), db.go:142), so this queues
behind the boot migrations rather than contending with them.

The staggered refresh 2 minutes later stays. On a first start it re-runs work
that is 2 minutes old, which is 2s wasted once; on every restart it is the run
that does the work. Keeping both means the emergency path and the routine path
each have one job.

hasPlannerStats now gates a decision rather than only wording a log line, so its
comment says what its swallowed error costs: a query failure reads as "no stats",
which spends one unnecessary ANALYZE instead of skipping a necessary one.

Four tests added, 11 in the file now: builds when absent, skips when present,
stays disabled on a negative limit, and survives a close-and-reopen. That last
one is the guard on the whole design: if statistics ever stopped persisting,
EnsurePlannerStats would quietly run a full ANALYZE on every restart.

Ran locally this time, with the mingw-w64 toolchain that Kpa-clawbot#2072's description
wrongly said did not exist here: 11/11 on the Kpa-clawbot#2058 tests, go vet clean, gofmt
clean, and the rest of cmd/ingestor green apart from
TestWriteStatsAtomic_SymlinkAtDestIsReplaced, which fails on os.Symlink
("A required privilege is not held by the client") on Windows without elevation
and lives in a file this branch does not touch.

Not done: nothing here re-plans the query itself, and no deployment has run this
yet. Live still carries no statistics at all; staging carries the limit-10000 set
I wrote by hand while measuring, on a container still running the older code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YAR8fdNTzqjtsggq4xCX6
(cherry picked from commit 4500cfa)
…e it

Deployed this branch to staging with sqlite_stat1 dropped first, so the build
path actually ran. It works, and it costs far more than this branch claimed.

Observed, 9.4 GB staging database, commit 4500cfa:

  13:51:26  [analyze] refresh scheduled every 24h (analysis_limit=10000)
  13:55:10  [analyze] planner statistics built in 3m43.874s (first run)

3m43.9s, not the 2.0s written into this branch and into Kpa-clawbot#2072. Every ANALYZE
duration in that ladder was timed warm, one run after another; cold, on a freshly
started container, the same statement at limit 10000 takes nearly four minutes.
That is the same warm/cold confusion this work already identified for the query
itself (56.7s against 0.80s) and then repeated for the ANALYZE. Every 2.0s in the
tree is now marked as warm and points at the cold figure.

It holds the single write connection throughout, so ingest stalls and buffers.
Measured per minute in observations:

  13:49   220
  13:50   106
  13:51     0   <- ANALYZE running
  13:52     0
  13:53     0
  13:54     0
  13:55  1027   <- backlog drained in one burst
  13:56   154

Nothing was dropped: the burst is about four minutes of traffic at the
surrounding rate, and the only ingest-buffer line in the log is the startup one
reporting 0 dropped. So the cost is a four-minute write stall, once, not data
loss.

That cost is not introduced here. The refresh ticker in Kpa-clawbot#2072 pays the identical
3m43.9s two minutes later on any database with no statistics, so Kpa-clawbot#2072 as merged
will stall live ingest for about four minutes on its first run. This branch only
moves it earlier. Said plainly because it affects the live deploy either way.

What is added is the warning an operator needs to tell it apart from a hang:
EnsurePlannerStats now logs why ingest is about to pause, how long it took on
9.4 GB, and that it happens once per database, before starting the ANALYZE. Two
tests pin it, including that it stays quiet on a restart, since a warning on every
boot would be worse than none.

End state on staging verified: 50 rows in sqlite_stat1, and the region-filtered
channels plan drives from idx_tx_channel_hash. Dropping the table first flipped
the plan back to idx_transmissions_payload_type, so the causality holds in both
directions.

13 tests in the file, all passing locally, go vet and gofmt clean. The rest of
cmd/ingestor is green apart from TestWriteStatsAtomic_SymlinkAtDestIsReplaced,
the Windows os.Symlink privilege failure in a file this branch does not touch.

Not done: staging currently runs this upstream-based branch, so it has lost the
fork-local features until I redeploy the fork branch. No live deploy yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YAR8fdNTzqjtsggq4xCX6
(cherry picked from commit 7bae162)
…pa-clawbot#2058)

Self-review catch, comment only. The block still justified the 2 minute stagger
as keeping the ANALYZE clear of the initial ingest burst, while the first
statement in the goroutine now deliberately runs inside that burst. Both are
intended, for different reasons, so the comment says which is which: the routine
refresh waits so it also sees the rows the burst added, the one-off build does
not wait because a database with no statistics has nothing better to offer the
queries arriving in those two minutes.

No behaviour change. gofmt, go vet and the 13 Issue2058 tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YAR8fdNTzqjtsggq4xCX6
(cherry picked from commit 5fbf114)
…ssue #100)

Fork adaptation of the `Kpa-clawbot#2072` and `Kpa-clawbot#2074` port.

Upstream starts EnsurePlannerStats on its own goroutine next to startup.
This fork also starts the #89 route_mask backfill at that point, and both
hold the single writer for one long statement: the first ANALYZE (3m43.9s
cold on upstream's 9.4 GB staging file) and the backfill's pending-index
CREATE INDEX (~20 s on a staging-sized DB). Started side by side they run
back to back on writerMu and the ingest pause is their sum; started the
moment the ingest buffer opens, either also sits on top of the backlog
the buffer collected during the startup migrations.

startPlannerStatsThenRouteMaskBackfill now runs them in sequence on one
goroutine: drain the ingest buffer, EnsurePlannerStats, drain again,
StartRouteMaskBackfill. Statistics first, because the channel queries
wait on them, while the backfill is background repair whose batch phase
runs for minutes. Each drain is bounded (2 min) so a buffer that never
reads empty cannot hold the backfill back. On a restart
EnsurePlannerStats is one sqlite_master query, so the backfill starts
after the first drain only. The routine refresh
(schedulePlannerStatsRefresh) starts its 2 minute stagger after that
sequence hands over, so it cannot land on either hold, and is stopped on
shutdown.

Also:
- EnsurePlannerStats returns early on a negative limit; upstream logged
  its "ingest will pause" warning even when nothing was built.
- Line references to db.go:142 replaced; SetMaxOpenConns(1) is in
  OpenStore here.
- db.analysisLimit documented in config.example.json,
  docs/user-guide/configuration.md and database.md, with rollback.

Tests (test-first): both startup writers under live ingest through the
real IngestBuffer without deadlock or loss; the order drain -> ANALYZE ->
drain -> backfill; no ANALYZE when sqlite_stat1 exists (sentinel row);
analysis_limit < 0 disables build and refresh but not the backfill; the
drain wait is bounded; cancel between the writers does not schedule the
backfill; hasPlannerStats errors read as "absent"; a refresh rewrites
existing statistics (upstream's PRAGMA optimize guard is vacuous on the
SQLite 3.46.0 bundled with modernc.org/sqlite, where optimize does analyze
a never-analyzed table). In cmd/server, a read-only test: sqlite_stat1
written by a separate connection reaches the mode=ro handle (open and
reopened), flips the region channel plan from
idx_transmissions_payload_type to idx_tx_channel_hash, and ANALYZE fails
on that handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ut9z1opV6nVhm2vzqNz8ZV
…issue #100)

TestMain_StartsRouteMaskBackfillAfterBufferReady looked for the literal
store.StartRouteMaskBackfill( call in main.go. Since 521fab1 main
starts the backfill through startPlannerStatsThenRouteMaskBackfill,
which runs the one-off ANALYZE first, so the marker moves to that call.
The guarantee is unchanged (after MQTT subscribe and ingestBuffer.Ready,
cancelled before the MQTT disconnect); that the sequence itself starts
the backfill is pinned by the behavioural tests in
planner_stats_startup_test.go.

Missed before 521fab1 because only the targeted tests ran there; the
full go test -race ./... in cmd/ingestor caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ut9z1opV6nVhm2vzqNz8ZV
@dborup

dborup commented Sep 28, 2026

Copy link
Copy Markdown
Owner Author

Closing without merge: superseded by #107 (merged as 1bdf088e).

Staging-iso measurement of this PR (2026-09-27) showed that a cold ANALYZE on the 5 GB staging database takes 63–67 s and reads every index in full despite analysis_limit, while holding the ingestor's write lock. #107 gets the same query plans without statistics by pinning GetChannels to idx_tx_channel_hash (with a fallback to the unpinned query) and keeping CountFloodAdvertsForNode off idx_transmissions_payload_type.

The branch is kept. If planner statistics become relevant again (for example for queries #107 does not cover), this port can be revisited.

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.

2 participants