v3.12.0
29 commits since v3.11.0: 15 fixes, 5 test, 3 perf, 3 feat, 2 ci, 1 chore.
Read this before upgrading
The first start after this release runs ANALYZE on your database, and ingest
stalls while it does.
SQLite's query planner had no statistics at all, because ANALYZE had never run
against these databases and nothing in the code ever called it. Without
sqlite_stat1 the planner guesses, and on the channel queries it guessed wrong:
it drove from the plain idx_transmissions_payload_type instead of
idx_tx_channel_hash, the partial index the schema already carries for exactly
that filter (#2058, reported with measurements by @anieto).
The ingestor now builds those statistics, once, on the first start against a
database that has none. Measured on a 9.4 GB database with 1,250,489
transmissions and 14,169,329 observations:
ANALYZE duration, cold page cache
| 3m43.9s |
| ingest during that time | stalled, buffered, nothing dropped |
| rows written in the minute it finished | 1027, against ~130 either side |
It holds the single SQLite write connection throughout, so observations stop
arriving and queue in the ingest buffer, then land in one burst when it
completes. On the deployment measured, the ingest buffer absorbed all of it and
the log reported 0 dropped. The log now warns before starting, so this is
distinguishable from a hang.
This happens once per database, not once per restart: sqlite_stat1 is an
ordinary table and stays in the file. Every later start costs one query.
A deploy already restarts the service, so the stall overlaps an interruption you
were having anyway. It is still worth picking the moment on a busy instance.
To skip it entirely, set db.analysisLimit to a negative number. The statistics
then never get built and the channel queries keep the old plan.
What it buys, measured on the same database, region-filtered GetChannels:
| pages read | |
|---|---|
| before | 143,442 |
| after | 107,429 |
25% fewer pages, about 147 MB less per query at a 4 KB page. Wall time when the
OS page cache is warm does not change (0.80s either way); the gain is on the cold
path, which measured 56.7s on that file. The 3-4x improvement reported in #2058
was measured on a smaller database (464,667 transmissions) and did not reproduce
at this size.
db.analysisLimit defaults to 10000 rather than the 400 SQLite's documentation
suggests. 400 and 1000 were measured to leave the plan unchanged; 10000 produces
the same plan as an unbounded ANALYZE while costing 2.0s warm against that
one's 242.9s.
Features
- CoreDrive RX region answers are stored (#2047) -
node_declared_regions
was read byregion_keys.goandconfig.gobut created by nothing, so the
Scope Audit page described a source that never arrived: the client-topic switch
handled packets and rf only and dropped/regionswithout a log line. The
table is now created at boot, and the answer is kept with position, the
repeater's own clock, and per-collector history bounded by age rather than one
row per target. Opt in throughclientRegions, separate from
clientRxCoverage, so region answers do not require GPS-tagged reception
uploads. Measured on the instance CoreDrive RX publishes to, 2026-08-18 to
2026-09-19: 1840 answers about 275 repeaters from 51 collectors, of which 138
repeaters have answers from more than one collector and 40 have collectors that
disagree, which is the disagreement the Scope Audit exists to surface. - The running version is in the nav drawer (#2068) - read from
/api/health, with commit and build time in the tooltip. A version-less or
failing health endpoint leaves the plain label rather than "CoreScope
undefined". - The IATA whitelist says when it drops a region (#2067) - one throttled
warning per rejected region, so a misconfiguredobserverIATAWhitelistis
visible instead of silent.
Reception and node health
- Zero-hop adverts count as direct reception (#2064) - a node's own advert
with an empty path is credited to the observer that heard it, without needing
prefix resolution. - Heard By lists only observers that heard the node on air (#2057) - a
relayed packet no longer implies its observer heard the originator directly. - The Heard By empty state stopped claiming the node is out of range (#2063)
- no direct reception recorded is not the same as out of range.
- The node-detail advert section is called Recent Adverts (#2042, #2071) -
it never held anything else, becausetransmissions.from_pubkeyis populated
for adverts only. The heading now says so in its tooltip instead of promising
every packet type. - Blacklist retention is queried by
from_pubkey(#2069).
Data correctness
- Each observation carries its own wire bytes (#1999, #2055) - the packet
detail API was handing every observation the same canonical frame, so
per-observer differences were invisible. - Background chunks merge in order (#2050) - they were prepended, which put
history out of sequence. - Pagination continues past a page that post-LIMIT filtering shortened
(#2061) - a filtered page that came back short ended the walk early. maxMemoryMBeviction actually triggers (#2035) - path, decode-cache and
dedup-key bytes were not accounted, so the measured footprint sat below the
configured limit and eviction never fired. Instances relying on that limit
will now evict where they previously grew.- The distance index shows a building state on its 202 (#1997, #2051) - the
Analytics distance tab treated the lazy index's 202 as data and crashed, and
the 202 was being cached, so the tab stayed broken until the cache expired.
Interface
- Live legend toggle is clickable again (#2049) - the VCR bar was eating its
clicks. - Map inspector controls stay clickable, stale loads are ignored (#2048).
- RX coverage opens at the configured map default (#2033), with its own saved
viewport. - Scope Audit observed-region chips meet 4.5:1 in light theme (#1996, #2070).
Performance
- Concurrent
GetChannelscache misses coalesce (#2059) - the second and
later callers wait for the first flight instead of each running the query. - Planner statistics (#2058, #2072, #2074) - see the top of this file.
Tests, CI and release tooling
- Every suite in
tests/e2eruns (#2053, #2045) - eight suites had no runner
at all, and the aggregate reported a pass while skipping them. - Each E2E suite is named in the log before it runs (#2046).
- Root
test-*.jsmoved intotests/unitandtests/e2e(#2036). - RX-coverage viewport assertions budgeted in pixels rather than a flat 0.001°
(#2040). - The #1239 lock-hold threshold set from measurement, 150µs to 5ms (#2039) - the
old value sat inside the healthy band and failed at random. - RF-sample fixtures anchored to now instead of a calendar date (#2034).
- Ingestor test stores wait for boot migrations before being handed over (#2065,
#2066) - this was a real data race under-race. - Re-tagging
:edgeas a release keeps every platform (#2031) - v3.11.0's tags
were flattened to amd64 and had to be repaired.
Issues closed
13 issues, from the 30 pull requests in this range. Collected from each pull
request's closing references rather than from commit text.
- #1996 bug(scope-audit): observed region chips fail light-theme contrast
- #1997 bug(analytics): Distance tab crashes on the initial 202 index-building response
- #1998 bug(map): Map Controls intercepts the Path Inspector toggle on desktop
- #1999 bug(packets): detail API discards distinct per-observation wire bytes
- #2024 store: retention eviction assumes packets sorted by first_seen, but startup load breaks that order
- #2029 GetChannels/GetEncryptedChannels: no request coalescing on cache-miss
- #2032 feat: make rx-coverage menu use the center that config.json sets for Map
- #2037 18 suites in tests/e2e are invoked by no runner, and one cannot run at all
- #2038 TestComputeAnalyticsDistanceLockHoldDuration fails on runner noise: the 150µs limit is absolute
- #2042 node detail "recent packets" only shows adverts, so the name misleads
- #2056 Node detail "Heard By" lists observers that never heard the node, with signal numbers belonging to another node
- #2058 GetChannels/GetEncryptedChannels: cold query cost is driven by payload_type, not region
- #2065 Race detector: an async-migration goroutine outlives its test and races the log capture
Contributors
Pull request authors: @efiten (22), @liquidraver (3), @A13xB0 (2), @n30nex (1),
@dborup (1), @anieto (1).
Also credited as co-authors in the commits: @nullrouten, @anieto, SaarMesh-Bot,
Openclaw.
Issues in this release were reported by @efiten (5), @n30nex (4), @anieto (2),
@liquidraver (1), @damn-simple-scripts (1).
22 of the 30 pull requests are the interim maintainer's own, which is the shape
of a release cut while the owner is unreachable rather than a healthy ratio.
Upgrade notes
No manual migration step. #2047 adds the node_declared_regions table, created
at boot with CREATE TABLE IF NOT EXISTS, and #2058 creates sqlite_stat1 the
same way; nothing else touches the schema.
clientRegions is opt-in and off by default.
The ANALYZE described at the top needs no action, only awareness. Set
db.analysisLimit to a negative number to disable it.
One behaviour change worth repeating here: #2035 makes maxMemoryMB eviction
fire where it previously did not, because the footprint it compares against the
limit was undercounted. An instance that set the limit and never saw eviction
will start seeing it.