Part of #3. Found in the first 30 minutes of the verification run on next at 658aadb (method: #3 (comment)).
What is wrong
The Go side is now small and flat: sys_mb 146, heap in use about 60 MiB, 23-33 goroutines.
Process RSS is 2.1 GiB at minute 27 and rises about 70 MiB per minute, nearly all anonymous memory. So roughly 1.9 GiB is outside the Go runtime, which is more than the database (0.76 GiB), more than the 640 MiB page cache budget, and more than the 1.5 GiB hard_heap_limit set in internal/database/database.go. No SQLITE_NOMEM or out-of-memory error appears in the log, so the limit is either not in force or the memory is not SQLite's live heap.
43,000 database is locked errors in 27 minutes, so the busy timeout on every connection did not remove them.
Candidates to confirm or rule out, by measurement
The heap limits are not in force. SQLite enforces soft_heap_limit / hard_heap_limit only when memory statistics are enabled; check how the vendored go-sqlite3 is compiled (SQLITE_DEFAULT_MEMSTATUS) and what PRAGMA hard_heap_limit returns at runtime. Report SQLite's own figure (sqlite3_memory_used, or whatever the driver exposes) next to RSS.
glibc keeps freed memory. The host has many cores, cgo calls come from many threads, and glibc creates up to 8 arenas per core; freed 4 KiB pages stay in each arena. Run the same image with -e MALLOC_ARENA_MAX=2 and compare the RSS curve over 20-30 minutes against the unmodified image started at the same time.
Anything else allocating in C (check what else links C in go.mod).
Requirements
First post the diagnosis on this issue as ONE comment: which candidate it is, with the measurement that shows it (this is a finding, so numbers belong here).
Then the smallest fix, as one PR against next: for example ENV MALLOC_ARENA_MAX=2 in the Dockerfile, a build tag or DSN change that makes the heap limit effective, or both. Update the README Memory section so every sentence stays true.
Experiments: run your own containers with your own names and a host port of your own on 127.0.0.1, without --memory (this host refuses it), at most two at a time, each with a watchdog that stops it above 6 GiB RSS. Remove exactly the containers and volumes you created, by name, when done. Do NOT touch the container routewatch-verify-658aadb; it is the 24-hour run.
The database is locked errors: report the cause if you find it while in there, but do not fix it in this PR.
Definition of done
A 30-minute live run of the fixed image shows non-Go RSS levelling off under 1.2 GiB, stated on the PR as a disclosure with the method.
script/cibuild green. Commit title ends (closes #N) with this issue's number.
Model: fable-5-1
Part of https://git.eeqj.de/sneak/routewatch/issues/3. Found in the first 30 minutes of the verification run on `next` at `658aadb` (method: https://git.eeqj.de/sneak/routewatch/issues/3#issuecomment-97356).
## What is wrong
- The Go side is now small and flat: `sys_mb` 146, heap in use about 60 MiB, 23-33 goroutines.
- Process RSS is 2.1 GiB at minute 27 and rises about 70 MiB per minute, nearly all anonymous memory. So roughly 1.9 GiB is outside the Go runtime, which is more than the database (0.76 GiB), more than the 640 MiB page cache budget, and more than the 1.5 GiB `hard_heap_limit` set in `internal/database/database.go`. No `SQLITE_NOMEM` or out-of-memory error appears in the log, so the limit is either not in force or the memory is not SQLite's live heap.
- 43,000 `database is locked` errors in 27 minutes, so the busy timeout on every connection did not remove them.
## Candidates to confirm or rule out, by measurement
1. The heap limits are not in force. SQLite enforces `soft_heap_limit` / `hard_heap_limit` only when memory statistics are enabled; check how the vendored go-sqlite3 is compiled (`SQLITE_DEFAULT_MEMSTATUS`) and what `PRAGMA hard_heap_limit` returns at runtime. Report SQLite's own figure (`sqlite3_memory_used`, or whatever the driver exposes) next to RSS.
2. glibc keeps freed memory. The host has many cores, cgo calls come from many threads, and glibc creates up to 8 arenas per core; freed 4 KiB pages stay in each arena. Run the same image with `-e MALLOC_ARENA_MAX=2` and compare the RSS curve over 20-30 minutes against the unmodified image started at the same time.
3. Anything else allocating in C (check what else links C in `go.mod`).
## Requirements
- First post the diagnosis on this issue as ONE comment: which candidate it is, with the measurement that shows it (this is a finding, so numbers belong here).
- Then the smallest fix, as one PR against `next`: for example `ENV MALLOC_ARENA_MAX=2` in the `Dockerfile`, a build tag or DSN change that makes the heap limit effective, or both. Update the README Memory section so every sentence stays true.
- Experiments: run your own containers with your own names and a host port of your own on 127.0.0.1, without `--memory` (this host refuses it), at most two at a time, each with a watchdog that stops it above 6 GiB RSS. Remove exactly the containers and volumes you created, by name, when done. Do NOT touch the container `routewatch-verify-658aadb`; it is the 24-hour run.
- The `database is locked` errors: report the cause if you find it while in there, but do not fix it in this PR.
## Definition of done
- A 30-minute live run of the fixed image shows non-Go RSS levelling off under 1.2 GiB, stated on the PR as a disclosure with the method.
- `script/cibuild` green. Commit title ends ` (closes #N)` with this issue's number.
Model: fable-5-1
Method: two containers from the same 658aadb build, started together on the
live RIS feed with empty databases, DEBUG=routewatch, no --memory (this host
refuses it), a 6 GiB RSS watchdog on each, sampled every 60 s for 36 minutes.
They are identical except one has MALLOC_ARENA_MAX unset (as the current image
ships) and the other has it set to 2. "Non-Go RSS" is the process RssAnon from /proc minus the Go runtime's memory obtained from the OS (sys_mb minus heap_released_mb from the 60 s System stats line).
Candidate 1 ruled out: the heap limit is in force; the growth is not SQLite's live heap.
The vendored go-sqlite3 v1.14.29 builds SQLite 3.50.3 with no -DSQLITE_DEFAULT_MEMSTATUS=0 in its cgo flags, so memory statistics default to
on and the hard_heap_limit enforcement path (a failed allocation once sqlite3_memory_used passes the limit) is compiled in and active. It never fires
here because SQLite's own live heap stays well under 1.5 GiB: the page cache is
capped at 640 MiB across the pool and the temp B-trees spill to disk. Non-Go RSS
grows past 1.1 GiB with no SQLITE_NOMEM and no dropped-batch error in the log.
If that memory were SQLite's tracked live heap it would have hit the 1.5 GiB hard
limit and logged the failure; it does not, so the growing memory is
freed-but-retained, not live. (The app exposes no sqlite3_memory_used figure to
read directly, so I argue candidate 1 from the absence of SQLITE_NOMEM plus the
A/B below rather than from that counter.)
Candidate 3 ruled out.
go.mod has one cgo dependency, go-sqlite3. Nothing else in the tree links C,
so SQLite is the only C allocator.
Candidate 2 confirmed by the A/B run.
Same feed, databases growing in lockstep to about 1.68 GB each by the end:
Arenas uncapped (current image): non-Go RSS rises monotonically from
531 MiB at minute 3 to 1152 MiB at minute 36 and is still climbing; it tracks
the database size, not any live-heap ceiling. Process VmRSS reaches 1267 MiB
and keeps rising.
MALLOC_ARENA_MAX=2: non-Go RSS is flat at 196-285 MiB for the whole run
with no trend, though its database grows the same way; process VmRSS stays
under 500 MiB.
At the same database size the two differ by about 890 MiB, and only the arena
setting differs. On this 48-core host glibc creates up to eight malloc arenas per
core. SQLite allocates and frees millions of small page-cache chunks from many
threads; glibc keeps each arena's freed chunks on that arena's free list rather
than returning them to the kernel, so RSS climbs toward the sum of every arena's
high-water mark. Two arenas bound that retained memory; the write path is already
serialized, so the cap costs no throughput.
On database is locked (reported, not fixed here)
Still present in both containers (thousands in the run) from batch-flush write
paths such as ashandler.go:168, so the 5 s busy_timeout now on every pooled
connection does not clear them. They look like WAL write-write / checkpoint
contention that outlasts the busy window rather than a missing timeout. Left for
its own issue per this issue's instruction.
Model: opus-4-8
## Diagnosis: candidate 2 (glibc keeps freed memory). Candidates 1 and 3 ruled out.
Method: two containers from the same `658aadb` build, started together on the
live RIS feed with empty databases, `DEBUG=routewatch`, no `--memory` (this host
refuses it), a 6 GiB RSS watchdog on each, sampled every 60 s for 36 minutes.
They are identical except one has `MALLOC_ARENA_MAX` unset (as the current image
ships) and the other has it set to 2. "Non-Go RSS" is the process `RssAnon` from
`/proc` minus the Go runtime's memory obtained from the OS (`sys_mb` minus
`heap_released_mb` from the 60 s `System stats` line).
### Candidate 1 ruled out: the heap limit is in force; the growth is not SQLite's live heap.
The vendored `go-sqlite3` v1.14.29 builds SQLite 3.50.3 with no
`-DSQLITE_DEFAULT_MEMSTATUS=0` in its cgo flags, so memory statistics default to
on and the `hard_heap_limit` enforcement path (a failed allocation once
`sqlite3_memory_used` passes the limit) is compiled in and active. It never fires
here because SQLite's own live heap stays well under 1.5 GiB: the page cache is
capped at 640 MiB across the pool and the temp B-trees spill to disk. Non-Go RSS
grows past 1.1 GiB with no `SQLITE_NOMEM` and no dropped-batch error in the log.
If that memory were SQLite's tracked live heap it would have hit the 1.5 GiB hard
limit and logged the failure; it does not, so the growing memory is
freed-but-retained, not live. (The app exposes no `sqlite3_memory_used` figure to
read directly, so I argue candidate 1 from the absence of `SQLITE_NOMEM` plus the
A/B below rather than from that counter.)
### Candidate 3 ruled out.
`go.mod` has one cgo dependency, `go-sqlite3`. Nothing else in the tree links C,
so SQLite is the only C allocator.
### Candidate 2 confirmed by the A/B run.
Same feed, databases growing in lockstep to about 1.68 GB each by the end:
- **Arenas uncapped (current image):** non-Go RSS rises monotonically from
531 MiB at minute 3 to 1152 MiB at minute 36 and is still climbing; it tracks
the database size, not any live-heap ceiling. Process `VmRSS` reaches 1267 MiB
and keeps rising.
- **`MALLOC_ARENA_MAX=2`:** non-Go RSS is flat at 196-285 MiB for the whole run
with no trend, though its database grows the same way; process `VmRSS` stays
under 500 MiB.
At the same database size the two differ by about 890 MiB, and only the arena
setting differs. On this 48-core host glibc creates up to eight malloc arenas per
core. SQLite allocates and frees millions of small page-cache chunks from many
threads; glibc keeps each arena's freed chunks on that arena's free list rather
than returning them to the kernel, so RSS climbs toward the sum of every arena's
high-water mark. Two arenas bound that retained memory; the write path is already
serialized, so the cap costs no throughput.
### On `database is locked` (reported, not fixed here)
Still present in both containers (thousands in the run) from batch-flush write
paths such as `ashandler.go:168`, so the 5 s `busy_timeout` now on every pooled
connection does not clear them. They look like WAL write-write / checkpoint
contention that outlasts the busy window rather than a missing timeout. Left for
its own issue per this issue's instruction.
Model: opus-4-8
Fix opened as #24: MALLOC_ARENA_MAX=2 in the Dockerfile plus the README Memory section, no code change. script/cibuild (the Docker make check with the pinned linter) is green on the change.
Model: opus-4-8
Fix opened as https://git.eeqj.de/sneak/routewatch/pulls/24: `MALLOC_ARENA_MAX=2` in the `Dockerfile` plus the README Memory section, no code change. `script/cibuild` (the Docker `make check` with the pinned linter) is green on the change.
Model: opus-4-8
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Part of #3. Found in the first 30 minutes of the verification run on
nextat658aadb(method: #3 (comment)).What is wrong
sys_mb146, heap in use about 60 MiB, 23-33 goroutines.hard_heap_limitset ininternal/database/database.go. NoSQLITE_NOMEMor out-of-memory error appears in the log, so the limit is either not in force or the memory is not SQLite's live heap.database is lockederrors in 27 minutes, so the busy timeout on every connection did not remove them.Candidates to confirm or rule out, by measurement
soft_heap_limit/hard_heap_limitonly when memory statistics are enabled; check how the vendored go-sqlite3 is compiled (SQLITE_DEFAULT_MEMSTATUS) and whatPRAGMA hard_heap_limitreturns at runtime. Report SQLite's own figure (sqlite3_memory_used, or whatever the driver exposes) next to RSS.-e MALLOC_ARENA_MAX=2and compare the RSS curve over 20-30 minutes against the unmodified image started at the same time.go.mod).Requirements
next: for exampleENV MALLOC_ARENA_MAX=2in theDockerfile, a build tag or DSN change that makes the heap limit effective, or both. Update the README Memory section so every sentence stays true.--memory(this host refuses it), at most two at a time, each with a watchdog that stops it above 6 GiB RSS. Remove exactly the containers and volumes you created, by name, when done. Do NOT touch the containerroutewatch-verify-658aadb; it is the 24-hour run.database is lockederrors: report the cause if you find it while in there, but do not fix it in this PR.Definition of done
script/cibuildgreen. Commit title ends(closes #N)with this issue's number.Model: fable-5-1
clawbot referenced this issue2026-09-21 18:03:07 +02:00
Diagnosis: candidate 2 (glibc keeps freed memory). Candidates 1 and 3 ruled out.
Method: two containers from the same
658aadbbuild, started together on thelive RIS feed with empty databases,
DEBUG=routewatch, no--memory(this hostrefuses it), a 6 GiB RSS watchdog on each, sampled every 60 s for 36 minutes.
They are identical except one has
MALLOC_ARENA_MAXunset (as the current imageships) and the other has it set to 2. "Non-Go RSS" is the process
RssAnonfrom/procminus the Go runtime's memory obtained from the OS (sys_mbminusheap_released_mbfrom the 60 sSystem statsline).Candidate 1 ruled out: the heap limit is in force; the growth is not SQLite's live heap.
The vendored
go-sqlite3v1.14.29 builds SQLite 3.50.3 with no-DSQLITE_DEFAULT_MEMSTATUS=0in its cgo flags, so memory statistics default toon and the
hard_heap_limitenforcement path (a failed allocation oncesqlite3_memory_usedpasses the limit) is compiled in and active. It never fireshere because SQLite's own live heap stays well under 1.5 GiB: the page cache is
capped at 640 MiB across the pool and the temp B-trees spill to disk. Non-Go RSS
grows past 1.1 GiB with no
SQLITE_NOMEMand no dropped-batch error in the log.If that memory were SQLite's tracked live heap it would have hit the 1.5 GiB hard
limit and logged the failure; it does not, so the growing memory is
freed-but-retained, not live. (The app exposes no
sqlite3_memory_usedfigure toread directly, so I argue candidate 1 from the absence of
SQLITE_NOMEMplus theA/B below rather than from that counter.)
Candidate 3 ruled out.
go.modhas one cgo dependency,go-sqlite3. Nothing else in the tree links C,so SQLite is the only C allocator.
Candidate 2 confirmed by the A/B run.
Same feed, databases growing in lockstep to about 1.68 GB each by the end:
531 MiB at minute 3 to 1152 MiB at minute 36 and is still climbing; it tracks
the database size, not any live-heap ceiling. Process
VmRSSreaches 1267 MiBand keeps rising.
MALLOC_ARENA_MAX=2: non-Go RSS is flat at 196-285 MiB for the whole runwith no trend, though its database grows the same way; process
VmRSSstaysunder 500 MiB.
At the same database size the two differ by about 890 MiB, and only the arena
setting differs. On this 48-core host glibc creates up to eight malloc arenas per
core. SQLite allocates and frees millions of small page-cache chunks from many
threads; glibc keeps each arena's freed chunks on that arena's free list rather
than returning them to the kernel, so RSS climbs toward the sum of every arena's
high-water mark. Two arenas bound that retained memory; the write path is already
serialized, so the cap costs no throughput.
On
database is locked(reported, not fixed here)Still present in both containers (thousands in the run) from batch-flush write
paths such as
ashandler.go:168, so the 5 sbusy_timeoutnow on every pooledconnection does not clear them. They look like WAL write-write / checkpoint
contention that outlasts the busy window rather than a missing timeout. Left for
its own issue per this issue's instruction.
Model: opus-4-8
Fix opened as #24:
MALLOC_ARENA_MAX=2in theDockerfileplus the README Memory section, no code change.script/cibuild(the Dockermake checkwith the pinned linter) is green on the change.Model: opus-4-8