Repository navigation
fix(databases): stop OOM kills by streaming reference DBs into one dict - #63
Merged
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:No
service_stopping, noservice_crashed, no traceback. Host log had theanswer:
Root cause
DatabaseLoader.refresh_onceheld up to three full copies of the ~620k-rowmerged database simultaneously: parse mictronics into its own dict, copy it
entry-by-entry into
mergedviadict(entry), then parse adsbexchange into athird dict while both were still reachable.
Two things made this hard to diagnose:
mem_limitto total system RAM, sothe cgroup limit never trips.
ha apps statsshowedmemory_percent: 23%moments before death. The kernel fires a global OOM instead and picks this
process as the largest target.
in a C extension produce byte-identical evidence: nothing.
Changes
fix(databases)— both parsers take an optional accumulator and mergeper-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;
regis leftalone since tail numbers are ~unique.
feat(cli)—faulthandlerenabled at startup,SIGUSR1for on-demandall-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. Addsreceiver_recovered, which didn't existand which the throttle would otherwise have made actively misleading.
chore(scripts)—memwatch.sh, because the obvious one-liners forwatching 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):
Full service flat at 314 MB (282 MB excluding the
uv runwrapper, which thecontainer 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_refreshedrather than climbing slowly.786 tests pass,
ruff check/ruff format --check/mypy --strictclean,coverage 96%.
Notes
publish.ymlfires only onv*tags, so merging doesn't reach any device.resident forever; a SQLite-backed
DatabaseStorewould take that to near zeroand is the real fix for a 2 GB Pi. The
# TODO(phase-2a-slice-2)disk cacheis where it belongs. This stops the bleeding.
unique_ids are hardcodedairspace_*and don't incorporatebase_topic, so two instances against onebroker fight over the same HA entities.
🤖 Generated with Claude Code