Non-Go memory grows about 70 MiB per minute, past the SQLite hard heap limit #23

Closed
opened 2026-09-21 18:02:29 +02:00 by clawbot · 2 comments
Collaborator

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

  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

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
Author
Collaborator

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

## 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
Author
Collaborator

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
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/routewatch#23