Repository navigation
fix(mqtt): keep startup memory and time flat on large estates - #66
Merged
Merged
Conversation
#65 made startup reconcile every Module in the estate in one batch: each system mapped as the routers loaded queued a reconcile, so the first pass read every Module's state into memory at once, on top of the initial sync that already publishes it with backpressure. Mapping each system also scanned every Module, so loading was quadratic in the estate's size. - systems read by the initial load are mapped from that row and queue no reconcile; the initial sync covers them - index system modules by system, and the published registry by system, so per-system work no longer scans everything - reconcile reads status names up front and values a Module at a time - the broker sweep checks each retained topic as it arrives and keeps only the stale ones, rather than comparing two sets of every topic Mapping 20,000 systems of 5 modules: 9.8s before, 0.04s after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 14, 2026
The Module and Driver routers asked the database whether each row was in scope as they loaded: a Module's systems and their zones, and for a Driver every one of its Modules, then theirs. Every system and Module is already mapped by then, so loading asks the mappings instead. Metadata is broadcast inline rather than from a fiber per model, so the bounded publisher queues hold loading back instead of thousands of fibers each holding a model's JSON, and is published once per model rather than once per top-level zone, as the topic does not depend on the zone. A system being loaded is mapped from the zones already read with it. With the rest of this branch and place-labs/resource#20, at 3,000 systems and 15,000 modules the routers finish loading in 4s (26s on master) and boot peaks at 73MiB (1.1GiB on master). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stakach
force-pushed
the
fix/mqtt-startup-memory
branch
from
October 14, 2026 22:12
61fdb2b to
0dd33d2
Compare
Co-Authored-By: Claude Opus 5.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.
Why
#65 added work at startup that grows with the size of the estate, all at once:
reconciled retained state … added: "278"straight after boot.Changes
Initial load: a system read by the routers' initial load is mapped from that row (no second query per system) and queues no reconcile. Status events haven't started at that point, nothing is published yet that needs removing, and the initial sync publishes every Module against these mappings. Changes after startup reconcile exactly as before.
Indexes:
Mappings::Statekeeps a reverse index of system → modules, maintained bymap_module/unmap_system/unmap_module, andPublishedkeeps one of system → modules. Per-system lookups no longer scan everything. Mapping 20,000 systems: 9.8s → 0.04s.Reconcile: status names are read for the whole set (
HKEYS), which is still needed so a removal never deletes a topic another Module now owns. Values are read one Module at a time as they're published, so payloads never accumulate. For a large zone or driver change, peak memory is now the affected Modules' topic names, not their values.Sweep: each retained topic is checked against the registry as the broker streams it, through its system with a small cache of recent systems, and only stale-looking topics are kept. They're re-checked in the processing fiber before removal, as before, so a status published mid-sweep isn't removed. Peak memory is the stale topics plus a few systems' topics.
Router loading: the Module and Driver routers used to ask the database whether each row was in scope as they loaded. That meant each Module's systems and their zones, and for a Driver every one of its Modules and then theirs. Every system and Module is already mapped by then, so loading now asks the mappings. A system being loaded is mapped from the zones already read with it.
Metadata: it's broadcast inline instead of from a fiber per model, so the bounded queues hold loading back. Before, every system and zone at boot got a fiber holding its JSON. It's also published once per model instead of once per top-level zone, since the topic doesn't depend on the zone.
Table loading: this is in fix: page the initial load by key, not by offset place-labs/resource#20 (keyset paging instead of
OFFSET, which walked about n²/128 rows per table: 10.4s of database time against 0.04s on 100k rows). Released as placeos-resource 3.2.0, whichshard.locknow uses.At scale
3,000 systems, 15,000 modules and 165,312 statuses in redis, with core stopped and an anonymous mosquitto (the e2e broker checks every publish with rest-api):
A warm restart (165k topics retained) took 11.7s at a 72 MiB peak. Master published every value twice (a 165,312-value reconcile on top of the initial sync).
Known limitation: the sweep can't see a whole large broker
mosquitto drops messages for a client whose outgoing queue passes
max_queued_messages(1000 by default), and a subscription delivers every retained topic at once. A client that drains slower than the broker sends gets only part of them. After deleting 2,500 modules (about 27,500 stale topics) while source was down, the sweep saw and removed 385, and the broker logged dropped messages for source's client.mosquitto_sub, which drains faster, received all 14,965connectedtopics. #65's sweep has the same limit. Changes made while source is running are reconciled exactly and don't depend on the sweep. This only affects stale topics left from before a start.Testing
./test: 69 examples, 0 failures. Newspec/published_spec.cr, new mappings index specs, and a router spec showing that a loaded system queues no reconcile but a change after startup does.localsuite) against an image of this branch: 15/15. Startup now logs the initial sync and sweep with no batch reconcile.checked: "2", removed: "2", and the live topic was kept.🤖 Generated with Claude Code