Repository navigation
Request-path latency grows with DB size: SQL on the 4-connection pool, partly under store.mu.RLock #2146
Description
Summary
API latency on larger instances is dominated by waiting, not by SQLite query execution. Two mechanisms combine:
- Several request handlers run SQL against the server's read pool, which is capped at 4 connections (
cmd/server/db.go:153). - Some of those handlers issue that SQL while holding
PacketStore.mu.RLock().muis async.RWMutex(store.go:187). Once the ingest poller callsLock(), every newRLock()queues behind it. So a single RLock holder waiting on a pool connection stalls ingest and then every store-reading endpoint, including/api/healthz.
The result: endpoints that do almost no work show maxima of 8 to 80 seconds, and they get worse as the database grows. This issue tracks the fixes per endpoint so the instance stays responsive as databases keep growing.
Related: #1442. Its conclusion (no Postgres) still holds: the isolated query timings below are fast, and a different engine does not remove lock or pool waits. Its premise that "SQLite isn't queried for normal API reads" no longer holds for the endpoints listed here.
All file references are against master at 9dbc2875.
Measurements
/api/perf from two instances, 2026-10-09.
| instance A | instance B | |
|---|---|---|
| DB size | 10.9 GB, 18.2M observations | 1.76 GB, 1.83M observations |
| CPUs | 4 | 2 |
| uptime at sample | 12.8 h | 35.7 h |
| Endpoint | A avg / p95 / max (ms) | B avg / p95 / max (ms) |
|---|---|---|
/api/nodes/:pubkey/reach |
14896 / 67908 / 74743 | 24217 / 40329 / 75163 |
/api/nodes/:pubkey/paths |
6650 / 30298 / 101136 | 433 / 1800 / 4747 |
/api/nodes/:pubkey/neighbors |
3188 / 13281 / 14496 | 59 / 7 / 29625 |
/api/channels |
1302 / 543 / 52551 | 1434 / 5390 / 23367 |
/api/perf |
202 / 142 / 46586 | 1559 / 2851 / 4656 |
/api/scope-stats |
not called | 2306 / 4594 / 8462 |
/api/analytics/hash-sizes, topology, rf |
2270 to 2867 avg | 188 to 685 avg |
/api/packets |
159 / 1854 / 11844 | 108 / 423 / 3318 |
/api/stats |
6 / 2 / 11706 | 19 / 2 / 80063 |
/api/nodes |
133 / 67 / 5489 | 156 / 118 / 144878 |
/api/observers |
60 / 24 / 47936 | |
/api/config/regions |
15 / 1 / 29408 | |
/api/health |
20 / 4 / 30349 | |
/api/packets/timestamps |
95 / 2 / 8463 | 23 / 8 / 35275 |
p95 covers only the last 100 requests per endpoint; avg and max cover the whole uptime.
The same queries in isolation (instance A, read-only, Python sqlite3 on the host)
| Query | isolated | endpoint avg / max |
|---|---|---|
| reach scan, 7 days, busiest node (56,052 rows) | 3.3 s | 14.9 s / 74.7 s |
GetChannels, no region |
0.27 s | 1.3 s / 52.6 s |
SELECT COUNT(*) FROM observations |
0.11 s | (part of /api/perf) |
| scope-stats 7d, all four queries | 0.05 to 0.54 s |
The gap between these two columns is waiting time, plus Go-side work after the query. I have not split those two apart.
Caveat: these timings come from the host's Python sqlite3, not the mattn/go-sqlite3 build inside the container. The plan can differ between SQLite versions. The page cache was warm from run 2 on.
Mechanism, with references
- Pool:
SetMaxOpenConns(4)(db.go:153). There is no global in-flight limit on SQL-backed handlers. Reach has its own semaphore of 2 (node_reach.go:816). The others have none. - SQL under
store.mu.RLock:QueryPacketsholds RLock (store.go:1916) while callingtxToMapWithRPper result row (store.go:1957,1967). That function runsfetchResolvedPathForTxBest(store.go:4037) and, withexpand=observations,fetchResolvedPathForObsper observation (store.go:4046). The LRU stores only non-nil results, so rows without a resolved path query again on every request.getCachedNodesAndPM(store.go:6983) runs a full nodes query on its 30 s miss. Several callers invoke it under RLock:node_hop_analytics.go:321-322,/api/resolve-hops,/api/nodes/:pubkey/paths. Nothing collapses concurrent misses, so every caller on a miss runs the query itself.GetNodeHealth(store.go:9660) runscanRelaySetsplus up to 20txToMapWithRPcalls under RLock.computeNodeHomeRegionscallsGetObserversunder RLock on a hash-sizes cache miss.
- Store-only endpoints that sit in the convoy:
/api/healthztakes RLock and loops over every packet to sum observations (healthz.go:29)./api/packets/timestampsand/api/healthalso take RLock. - Pool users on every request, no cache:
/api/config/regionsrunsSELECT DISTINCT iata FROM observerseach time (routes.go:585)./api/observersskips its cache whenever there is a query string.buildNodeInfoMaprunsSELECT id, name FROM observerson every neighbors request (neighbor_api.go:472). - HTTP timeouts:
WriteTimeoutis 60 s (main.go:519). The 75 to 145 s maxima above exceed it, so those clients most likely received a dropped connection, not a response. I have not checked whether/api/perfrecords time past the timeout.
Per-endpoint work
Each item is meant as its own PR that references this issue.
- Stop issuing SQL while holding
store.mu. Done in fix(server): no SQL while holding PacketStore.mu #2147. Copy what is needed under RLock, release it, then query. This applies toQueryPackets/txToMapWithRP,GetNodeHealth, thegetCachedNodesAndPMcallers, andcomputeNodeHomeRegions. This item has the widest effect: it removes the convoy that stalls/api/healthz,/api/statsand ingest. -
getCachedNodesAndPM: singleflight on the 30 s miss. Partly done in fix(server): no SQL while holding PacketStore.mu #2147: past the TTL, one background refresh per key. A cold or invalidated cache is still rebuilt by every concurrent caller. -
/api/nodes/:pubkey/reach: Query plan fixed in fix(server): drive the reach scan from the observations time window #2155 (3.2 s to 1.9 s on the 18M-observation database). The window scan withLIKEremains; an indexed hop table would remove it.- The plan is
SCAN t(all transmissions), followed by an index probe on observations (node_reach.go:767-770). Thepath_json LIKE '%"XX"%'filter cannot use an index. - Options: drive the query from
observations.timestamp, keep a hop-to-observation index table written by the ingestor, or serve reach from the in-memory store. - Also: Go-side time after the 3.3 s scan has not been measured yet.
- The plan is
-
/api/nodes/:pubkey/paths: 1 to 3 queries per candidate transmission (resolved_index.go:160,INSTR(LOWER(resolved_path), ?)), run one after another. The LRU does not cache negative results. Status: perf(server): /paths reads canonical paths in batches and skips redundant collision checks #2157 (merged). The per-candidate collision query is skipped where the canonical path decides, and canonical paths are read in batches: 3.4-5.2 s to 1.7-3.1 s per call on staging. -
/api/channelsand/api/channels/:hash/messages: both go to SQLite first and fall back to the store only whens.dbis nil (routes.go:2773,2805).GetChannelshas no time bound. Store-first, or a time window. Status: measured, no change for now. The message page query takes 74-81 ms warm on the 18M-observation database; one cold 17.6 s run did not reproduce. Usingtransmissions.last_seeninstead of the per-rowMAX(timestamp)gives the same page warm, andlast_seendiffers fromMAX(observations.timestamp)on 1,850 of 34,959 rows of the busiest channel. -
/api/perf: fourSELECT COUNT(*)on every call (db.go:580). Cache the counts, or take them from the store. Status: perf(server): battery history via the primary key, cached /api/perf row counts #2156 (merged), counts cached for 60 s (COUNT(*)on observations: 116 ms). -
/api/stats:GetSignatureDropCountis a whole-tableCOUNT(*) FROM dropped_packets(db.go:3184). Status: measured, no change:COUNT(*) FROM dropped_packetstakes 0.0 ms there. -
/api/nodes/:pubkey/neighbors:neighborMuis held for the whole graph rebuild (neighbor_api.go:85), plus a per-request observers query (neighbor_api.go:472). -
/api/config/regions: cache the distinct IATA list. Status: measured, no change:SELECT DISTINCT iata FROM observerstakes 0.2 ms. -
/api/observers: cache keyed by the query string, not bypassed by it. Status: measured, no change: the packet-count query takes 1.3 ms. -
/api/nodes/:pubkey/battery:LOWER(observer_id) = ?(node_battery.go:56) prevents use of an index onobserver_id. Status: perf(server): battery history via the primary key, cached /api/perf row counts #2156 (merged):observer_id IN (lower, upper)uses the primary key, 29 ms (174 ms cold) to 5.5 ms. -
/api/scope-stats: isolated cost is 0.05 to 0.54 s on instance A. Recheck after the items above. Its slowness on instance B may be waiting only. Status: measured, no change: the 24 h summary takes 7 ms warm. - Ingestor startup
ANALYZE: the daily planner-statistics refresh also ran 2 minutes after every start, holding the write connection for 4-9 minutes on a cold cache. Status: perf(ingestor): refresh planner statistics 24h after the last refresh, not after every start #2158 (merged): refresh 24 h after the previous one, recorded next to the database. - Read pool size: measured, no change: a staging A/B with 8 instead of 4 connections lowered
/pathsthroughput (133-146 to 81-103 calls per 120 s).
Not done
- No profiling of the running server (pprof, mutex or block profile). The convoy explanation comes from code reading plus the pattern in the maxima. It has not been observed directly.
- Go-side processing time per endpoint, separate from SQL wait, is not measured.
- Instance B was observed only through its public
/api/perfand/api/health, with no access to its config or logs. ItspacketStore.maxMBis 250, which keeps only about 23k packets in memory. Whether that pushes more requests onto the SQL fallback paths is not verified. - Analytics store functions (
topology,rf,hash-collisions) were read for SQL calls, not timed.
Question
Should the first PR be the "no SQL under store.mu" change (widest effect, touches shared code in store.go), or reach (largest total time on both instances, self-contained)?
Activity
fix(server): keep path parsing out of the /paths read lock
efiten commentedon Oct 9, 2026
Cross-reference: #2144 / #2145 (@n30nex) propose a full PostgreSQL conversion. That work was opened before this issue, and I missed it when writing the summary above. Noting here how the two relate, so neither thread has to guess.
What #2145 changes on the paths listed here
I read the server-side request paths on the PR head
fde34bf. I did not build or run the PR, and I did not review the migrator or the ingestor side.SetMaxOpenConns(4)(cmd/server/db.go:146).o.path_json ILIKE $n(cmd/server/node_reach.go:764). There is no trigram or GIN index in the PR (pg_trgm: 0 matches), so the filter still cannot use an index.COUNT(*)per candidate transmission (cmd/server/resolved_index.go:161).store.muis unchanged.store.gochanges by 90 lines, mostly placeholder and dialect syntax.So the pool wait and the RWMutex convoy described above would be the same on either engine. The per-endpoint items in this issue apply to #2145 as much as to
master.What the #2145 benchmark shows
The performance report is careful and publishes its regressions, which makes it easy to read against this issue:
/api/stats,/api/healthz) are not part of the measured workload. Corpus B is 2.05M observations, close to instance B above and about a ninth of instance A.Concerns about a full conversion
#2144 says maintainer acceptance is not assumed, so these are concerns to discuss, not a decision:
Not a closed door
Two parts of #2145 look useful regardless of the engine question:
scripts/postgres-benchmark/): paired runs against unmodified upstream, fixed resource budgets, table digests. It would be a good baseline to measure the fixes in this issue against.What would change the picture for me: the same paired comparison including reach, paths and
/api/healthzunder concurrent load, on a corpus at instance A scale (about 18M observations). If PostgreSQL clearly wins there, after the pool and lock fixes in this issue, that is a different discussion.Question
@n30nex, would you be open to either of these as separate PRs?
masterThe decision on #2145 itself stays open until the items here have landed and can be compared.
fix(server): topology counts active nodes without GetStats' table scans
n30nex commentedon Oct 9, 2026
Thanks for linking the work. I’m taking the upstream-first route: a standalone SQLite benchmark PR, then a separate retention improvement, with memory profiling alongside it. I’ll avoid duplicating #2147 and keep #2145’s existing measurements intact while preparing portable fixes.
One small clarification: the published mix includes reach and stats, but not concurrent paths/healthz or an 18M-observation workload. I agree that it does not establish performance at your larger-instance scale. The expanded measurements will separate SQL/pool waiting, lock waiting, processing time and memory while retaining the same working data.
fix(server): no SQL while holding PacketStore.mu (#2147)
fix(server): drive the reach scan from the observations time window
fix(server): drive the reach scan from the observations time window (#…
perf(server): read /paths stored resolved paths in one batched pass
perf(server): /paths skips the collision query when a canonical path …
perf(ingestor): refresh planner statistics 24h after the last refresh…
perf(server): /paths reads canonical paths in batches and skips redun…
perf(server): battery history via the primary key, cached /api/perf r…
perf(ingestor): refresh planner statistics 24h after the last refresh…
perf(server): share /api/nodes responses for identical queries for 15…
perf(scope-stats): keep the per-region count on the first_seen index (#…
perf(reach): answer /nodes/{pk}/reach from a rolling hop index, not a…
perf(ingestor): yield between committed retention batches (#2153)
perf(paths): batch the resolved-path fallback for /nodes/{pk}/paths (#…