Skip to content

fix(databases): stop OOM kills by streaming reference DBs into one dict - #63

Merged
ifnull merged 5 commits into
mainfrom
fix/db-loader-memory
Aug 30, 2026
Merged

ifnull merged 5 commits into
mainfrom
fix/db-loader-memory

Conversation

@ifnull

@ifnull ifnull commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Problem

The add-on was being killed and restarted in a loop on a 2 GB Raspberry Pi, a
few minutes after each start. The add-on log gave no reason at all — normal
startup, db_refreshed, then the log simply begins again:

{"event": "service_starting", ...04:08:16.988}
{"event": "journal_opened",   ...04:08:17.195}
{"tracks": 19962, "event": "journal_warm_loaded", ...04:08:17.386}
{"aircraft": 616410, "event": "db_refreshed", ...04:08:27.973}
[ha-airspace] rendering config from add-on options    <- restart, nothing between

No service_stopping, no service_crashed, no traceback. Host log had the
answer:

kernel: oom-kill:...,global_oom,task=ha-airspace
kernel: Out of memory: Killed process 122326 (ha-airspace) anon-rss:608804kB
supervisor.apps.app: App Airspace exited with non-zero exit code 137

Root cause

DatabaseLoader.refresh_once held up to three full copies of the ~620k-row
merged database simultaneously: parse mictronics into its own dict, copy it
entry-by-entry into merged via dict(entry), then parse adsbexchange into a
third dict while both were still reachable.

Two things made this hard to diagnose:

  • Supervisor sets an add-on's container mem_limit to total system RAM, so
    the cgroup limit never trips. ha apps stats showed memory_percent: 23%
    moments before death. The kernel fires a global OOM instead and picks this
    process as the largest target.
  • SIGKILL runs none of the service's exit paths, so both an OOM and a segfault
    in a C extension produce byte-identical evidence: nothing.

Changes

fix(databases) — both parsers take an optional accumulator and merge
per-key; the loader parses every enabled source in ascending priority straight
into one dict. Per-key merge (not whole-entry replace) is what preserves the
precedence contract: ADSBex overwrites the fields it has, absent keys still
fall through to Mictronics. Repeated string values (type, model, ownop)
reuse one object per distinct value via a local dedup table; reg is left
alone since tail numbers are ~unique.

feat(cli) — faulthandler enabled at startup, SIGUSR1 for on-demand
all-thread stack dumps. Exists only because diagnosing this was slower than it
should have been. It can't catch SIGKILL, and that asymmetry is the point: with
faulthandler on, no dump narrows the cause to an external kill and sends you
to the host log rather than into the source.

fix(receivers) — a receiver on a dead URL logged one warning per poll,
forever: 22,159 identical lines over 18h in the log that prompted this, every
one a journald write to the SD card this project targets. Failures up to the
unhealthy threshold still log individually (that window diagnoses a blip); past
it the interval doubles to a capped ~1200 (≈1h at a 3s poll), each line
carrying suppressed_since_last. Adds receiver_recovered, which didn't exist
and which the throttle would otherwise have made actively misleading.

chore(scripts) — memwatch.sh, because the obvious one-liners for
watching RSS are both wrong in ways that produce plausible numbers. Details in
the commit message; both were hit while verifying this.

Verification

Peak RSS for a full refresh, measured against the live reference DBs
(616,504 entries):

RSS
Field OOM kill (old code) 608 MB
Parsers in isolation, new code 253 MB
Full service, new code, in-container equivalent ~282 MB

Full service flat at 314 MB (282 MB excluding the uv run wrapper, which the
container doesn't have) for 2.5 minutes past the DB load — confirming there is
no second leak behind the loader, consistent with the add-on dying 14s after
db_refreshed rather than climbing slowly.

786 tests pass, ruff check / ruff format --check / mypy --strict clean,
coverage 96%.

Notes

  • Nothing HA-facing changed — no MQTT payloads, no discovery, no config schema.
  • publish.yml fires only on v* tags, so merging doesn't reach any device.
  • Not the end state. ~282 MB is dominated by the 616k-entry dict sitting
    resident forever; a SQLite-backed DatabaseStore would take that to near zero
    and is the real fix for a 2 GB Pi. The # TODO(phase-2a-slice-2) disk cache
    is where it belongs. This stops the bleeding.
  • Separately noticed, not fixed: discovery unique_ids are hardcoded
    airspace_* and don't incorporate base_topic, so two instances against one
    broker fight over the same HA entities.

🤖 Generated with Claude Code

ifnull and others added 5 commits August 27, 2026 23:31
…B Pis

The refresh path allocated up to three full copies of the 620k-row merged
database simultaneously: parse mictronics into its own dict, copy it
entry-by-entry into `merged` via `dict(entry)`, then parse adsbexchange into
*another* full dict while both were still reachable. Measured peak was ~610 MB
anon-RSS on aarch64 with the live files.

That is fatal on a 2 GB Pi, and it fails in a way that gives no diagnostic
signal. Supervisor sets an add-on's container `mem_limit` to total system RAM,
so the cgroup limit never trips; instead the kernel fires a *global* OOM and
picks this process as the largest target. SIGKILL means none of the service's
own exit paths run, so the add-on log shows `db_refreshed` and then simply
starts over — no `service_stopping`, no `service_crashed`, no traceback.
Observed in the field 2026-08-28: killed at 608804 kB, exit 137, on a loop.

Both parsers now take an optional accumulator and merge into it per-key, and
the loader parses every enabled source in ascending priority order straight
into one dict. Per-key merge (rather than whole-entry replace) is what
preserves the existing precedence contract: ADSBex overwrites the fields it
has, absent keys still fall through to Mictronics.

The repeated string fields are also deduped to one object per distinct value —
`type` for Mictronics, `type`/`model`/`ownop` for ADSBex. A local table rather
than `sys.intern` so nothing lands in the interpreter-wide intern dict and the
table is freed with the parse. `reg` is left alone: tail numbers are ~unique,
so deduping them costs more than it saves.

Peak RSS for a full refresh drops from ~610 MB to ~240 MB. The alternative —
backing the store with SQLite instead of a dict — would get this to near zero,
but it changes the enricher's lookup path and the `# TODO(phase-2a-slice-2)`
disk cache is the natural place for that; this keeps the fix contained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiVqZDdasMeshTd7CqdxEp
…e from OOM

Diagnosing the OOM kill in the previous commit was slower than it should have
been because the add-on log for a SIGKILL and for a segfault in sqlite3 or
pydantic-core look exactly the same: the service logs normally, then the log
simply restarts. None of `service_stopping`, `service_crashed`, or a traceback
appears, because none of our exit paths run in either case.

faulthandler closes half of that gap for free. A fatal signal now dumps a
Python traceback to stderr, which Docker captures, so a fault says so. It
cannot catch SIGKILL — but that asymmetry is the point: with faulthandler on,
*no* dump narrows the cause to an external kill and sends you to the host log
rather than into the source.

SIGUSR1 is registered for on-demand all-thread stack dumps, which is the
cheapest way to inspect a wedged poll loop or a stuck executor thread on a
running add-on. SIGTERM is deliberately left alone — the event loop owns it for
graceful shutdown.

Enabling is best-effort and suppressed: an environment with a non-dupable
stderr should lose the diagnostic, not fail to start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiVqZDdasMeshTd7CqdxEp
…very

A receiver pointed at a dead URL logged one warning per poll, forever. The
field log that prompted this carried 22159 consecutive identical
`receiver_fetch_failed` lines across 18 hours from a single misconfigured
remoteid feed — enough to bury every other event in the add-on log, and on
HAOS every line is a journald write to the SD card this project explicitly
targets.

Failures up to UNHEALTHY_AFTER_FAILURES are still logged individually: that
window is what distinguishes a transient blip from a real outage, and it is
short. Past it the receiver's state is established and repetition carries no
new information, so the log interval doubles to a capped stride of 1200
failures (~1h at a 3s poll) — sparse enough not to flood, frequent enough to
show the service is still trying. Each line reports how many it swallowed via
`suppressed_since_last`, so nothing is silently lost.

Throttling on failure count rather than elapsed time keeps this testable
without injecting a clock, per CLAUDE.md's "time is a fixture" rule.

Recovery was previously silent, which the throttle would have made actively
misleading — a receiver that stopped logging would be indistinguishable from
one that came back. `receiver_recovered` now fires on the first success after
any failure and rearms the throttle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiVqZDdasMeshTd7CqdxEp
Reproducing the OOM required watching RSS across a full refresh, and the
obvious one-liners for it are both wrong in ways that quietly produce a
plausible number:

  * `uv run ... | grep ... &` then `ps -p $!` measures the *last* process in
    the pipeline, so it reports grep's 2 MB, flat, forever.
  * matching on the config path sums every instance using that config, so a
    second run started from another terminal double-counts and reports roughly
    the pre-fix figure.

Both were hit while verifying the loader fix. The script sums the process tree
rooted at the service it launched (uv spawns python as a child, so the launched
PID alone reports the wrapper), and refuses to start when an instance is
already running against the same config.

The duplicate check reads argv out of /proc rather than using `pgrep -f`: the
pattern would otherwise match any shell whose command line merely quotes this
script, which false-positived on the very command that wrote the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiVqZDdasMeshTd7CqdxEp
CI requires main to be ahead of the last tag, and v1.1.0 is released. Patch
rather than minor: the changes are a memory fix, a log-volume change, and a
startup diagnostic — no payload, discovery, or config-schema surface moves, so
nothing downstream has to adapt.

Bumping the version does not publish; publish.yml fires only on a v* tag push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiVqZDdasMeshTd7CqdxEp
@ifnull
ifnull merged commit 039a826 into main Aug 30, 2026
3 checks passed
@ifnull
ifnull deleted the fix/db-loader-memory branch August 30, 2026 23:29
ifnull added a commit that referenced this pull request Aug 30, 2026
Rebased onto main after #63 released 1.1.1. This PR originally claimed 1.1.1;
that slot is now a released tag, so the docs fix moves to 1.1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant