The daemon spends sustained CPU decoding sync manifests. Observed on Silber 2026-08-14: PID 11655 at ~44.7% CPU on an otherwise idle machine, with no active dial handler. A sample run places the work in sync_once → serde_json::from_slice::<ReplyHeader> → Manifest → BTreeMap<String, Entry> → Entry decode.
The numbers
| Entry |
Entries |
manifest.json |
state.json |
st2-bus-default |
23,407 (11,636 present + 11,771 tombstones) |
19.4 MB |
33.5 MB |
st2-declarations-default |
66 |
69 KB |
140 KB |
The bus manifest is ~355× the declarations manifest by entry count. All of the cost is the bus entry.
Three compounding causes
1. Tombstones are never swept
SyncPolicy::Bus declares sweep_tombstones: true (src/sync/config.rs:62), but the flag has no consumer in production code — the only read in the tree is a test assertion (src/sync/config.rs:321). The policy doc already admits it: "not yet fully implemented" (src/sync/config.rs:35-36).
So bus tombstones accumulate permanently. They are currently half the manifest (11,771 of 23,407), and they are decoded on every pass forever. This grows without bound with every message and status write the bus has ever carried.
2. Entry decodes through serde's Content buffer
Entry is an internally-tagged enum (#[serde(tag = "kind")], src/sync/manifest.rs:105-107). Serde buffers every internally-tagged value into an intermediate Content tree and re-deserializes from it, which is its documented slow path. The sample confirms this is where the time goes and shows nothing else: TaggedContentVisitor, ContentDeserializer, __deserialize_content_v1.
This is the same representation that caused the u128 decode failure in PR 36 — ControlResponse is internally tagged, so it routed through Content, which has no u128. One representation choice, a correctness bug then and a performance bug now.
3. The whole manifest is decoded per peer, per pass
sync_once clones the local manifest and reconciles against each peer (src/sync/engine.rs:751-792); the reply header carries the peer's entire manifest and is decoded with from_slice. With two peers, a pass on every mutation signal plus every 30s, that is a 19.4 MB JSON decode each time.
Fix directions, in the order I would take them
- Implement the tombstone sweep for bus policy. It is already declared, already documented, and removes about half the input. Bounded and it needs no wire change. Sweeping must be a normal versioned change so peers converge rather than resurrect — a swept tombstone must not read as "never seen".
- Stop re-decoding an unchanged manifest. Both sides already track a mutation generation. A pass where neither side has changed should not pay a full decode; a cheap generation or digest exchange before the manifest body would remove most passes entirely.
- Change the
Entry representation last, and treat it as a wire change. Adjacently tagged, or a hand-written Deserialize, avoids the Content buffer. This changes bytes on the wire, so it needs the same care as any manifest format change: peers on older builds must still parse, or it must be staged behind a version. Do not do this first just because the profile points at it.
Test notes
- Measure before and after on a manifest of realistic size. A microbenchmark on a handful of entries will not show any of this.
- For the sweep, prove convergence across three nodes, not just locally: the failure mode is a swept tombstone reappearing from a peer that still holds it.
- Prove any regression test fails against the current code before trusting it.
Not caused by the catalog backup directory
Ruled out by measurement. The six leftover .hetz-backup-1786658066 declarations are 6 entries of 23,473 (0.026%) and ~12.7 KB of ~33.7 MB (0.04%). Removing them is worth doing for other reasons (#50) but will not change the CPU.
Correction and measurement, 2026-08-24
Cause 3 is misnamed above, and the title was too. It said full decode per
pass. My own profile disproved that framing on 2026-08-14 and it was never
corrected here. Cause 3 is full manifest EXCHANGE per peer, per pass.
Live cost is manifest size MULTIPLIED BY mutation rate
The lab ran at 2.6% of a core against 60% live, on the same corpus, the
same code and the same manifest size. The only difference: the lab corpus is
static and the live bus is not, because it is a message bus.
So shrinking the manifest attacks one factor of a product. The other factor
is how often we send it, and we send it in full on every pass, and passes are
driven by mutations we generate ourselves. Every status write, every message and
every archive is a pass that re-sends the whole manifest to every peer.
Sweeping tombstones (cause 1) attacks size only. It cannot fix a product.
Measured directly, 2026-08-24
sync_once was instrumented with per-phase counters (#68). Silber, on
0.2.0+0ca1bd8, 420 s window taken after a 240 s settle, under live fleet load:
| phase |
ms per pass |
share |
| reconcile |
4057 |
90.8% |
| scan |
197 |
4.4% |
| materialize |
111 |
2.5% |
| persist |
101 |
2.3% |
This is CPU, not network waiting. Phase time is wall inside the phase, so a
phase parked in an await reads identically to one doing work. Daemon CPU over
the same window was 66.2 s against 72.5 s of measured phase wall. If
reconcile were mostly blocking, CPU would fall far below phase wall; it does
not, and no other phase is large enough to hold 66 s of CPU. Peer round trip is
45–52 ms, which rules out latency independently.
Two measurements, ten days apart, by different methods, agree on the same cause.
Current size, for comparison with the table above
st2-bus-default is now 30,924 entries (17,404 present + 13,520 tombstones),
manifest.json 26 MB, state.json 46 MB — describing a watched tree of 16.6
MB across 17,175 files. The state on disk is over four times the size of the
data it describes.
What the walk is worth, so nobody re-targets it
4.4% of a pass. A full lstat pass over the tree costs ~36 ms and reading
and hashing every byte would cost ~20 ms more. #66 already removed one redundant
stat per file. The walk is not the problem and cannot be made into one.
Direction
Digest-first: exchange a hash, fall through to a manifest only when the
hashes differ. The common case is a peer that already has what we are about to
send, and that exchange should cost a hash rather than a manifest.
It is load-bearing rather than an optimisation, and this is the trade worth
stating out loud: presence needs exactly one mutation per refresh to be visible,
and this issue says every mutation costs a full manifest exchange with every
peer. We deliberately made the bus busier in order to make it honest. That
is the right trade, but it only stays affordable if a presence write costs a
changed hash and one small entry rather than a whole manifest.
Not started. It is a design conversation first.
The daemon spends sustained CPU decoding sync manifests. Observed on Silber 2026-08-14: PID 11655 at ~44.7% CPU on an otherwise idle machine, with no active dial handler. A
samplerun places the work insync_once→serde_json::from_slice::<ReplyHeader>→Manifest→BTreeMap<String, Entry>→Entrydecode.The numbers
manifest.jsonstate.jsonst2-bus-defaultst2-declarations-defaultThe bus manifest is ~355× the declarations manifest by entry count. All of the cost is the bus entry.
Three compounding causes
1. Tombstones are never swept
SyncPolicy::Busdeclaressweep_tombstones: true(src/sync/config.rs:62), but the flag has no consumer in production code — the only read in the tree is a test assertion (src/sync/config.rs:321). The policy doc already admits it: "not yet fully implemented" (src/sync/config.rs:35-36).So bus tombstones accumulate permanently. They are currently half the manifest (11,771 of 23,407), and they are decoded on every pass forever. This grows without bound with every message and status write the bus has ever carried.
2.
Entrydecodes through serde'sContentbufferEntryis an internally-tagged enum (#[serde(tag = "kind")],src/sync/manifest.rs:105-107). Serde buffers every internally-tagged value into an intermediateContenttree and re-deserializes from it, which is its documented slow path. The sample confirms this is where the time goes and shows nothing else:TaggedContentVisitor,ContentDeserializer,__deserialize_content_v1.This is the same representation that caused the
u128decode failure in PR 36 —ControlResponseis internally tagged, so it routed throughContent, which has nou128. One representation choice, a correctness bug then and a performance bug now.3. The whole manifest is decoded per peer, per pass
sync_onceclones the local manifest and reconciles against each peer (src/sync/engine.rs:751-792); the reply header carries the peer's entire manifest and is decoded withfrom_slice. With two peers, a pass on every mutation signal plus every 30s, that is a 19.4 MB JSON decode each time.Fix directions, in the order I would take them
Entryrepresentation last, and treat it as a wire change. Adjacently tagged, or a hand-writtenDeserialize, avoids theContentbuffer. This changes bytes on the wire, so it needs the same care as any manifest format change: peers on older builds must still parse, or it must be staged behind a version. Do not do this first just because the profile points at it.Test notes
Not caused by the catalog backup directory
Ruled out by measurement. The six leftover
.hetz-backup-1786658066declarations are 6 entries of 23,473 (0.026%) and ~12.7 KB of ~33.7 MB (0.04%). Removing them is worth doing for other reasons (#50) but will not change the CPU.Correction and measurement, 2026-08-24
Cause 3 is misnamed above, and the title was too. It said full decode per
pass. My own profile disproved that framing on 2026-08-14 and it was never
corrected here. Cause 3 is full manifest EXCHANGE per peer, per pass.
Live cost is manifest size MULTIPLIED BY mutation rate
The lab ran at 2.6% of a core against 60% live, on the same corpus, the
same code and the same manifest size. The only difference: the lab corpus is
static and the live bus is not, because it is a message bus.
So shrinking the manifest attacks one factor of a product. The other factor
is how often we send it, and we send it in full on every pass, and passes are
driven by mutations we generate ourselves. Every status write, every message and
every archive is a pass that re-sends the whole manifest to every peer.
Sweeping tombstones (cause 1) attacks size only. It cannot fix a product.
Measured directly, 2026-08-24
sync_oncewas instrumented with per-phase counters (#68). Silber, on0.2.0+0ca1bd8, 420 s window taken after a 240 s settle, under live fleet load:This is CPU, not network waiting. Phase time is wall inside the phase, so a
phase parked in an
awaitreads identically to one doing work. Daemon CPU overthe same window was 66.2 s against 72.5 s of measured phase wall. If
reconcile were mostly blocking, CPU would fall far below phase wall; it does
not, and no other phase is large enough to hold 66 s of CPU. Peer round trip is
45–52 ms, which rules out latency independently.
Two measurements, ten days apart, by different methods, agree on the same cause.
Current size, for comparison with the table above
st2-bus-defaultis now 30,924 entries (17,404 present + 13,520 tombstones),manifest.json26 MB,state.json46 MB — describing a watched tree of 16.6MB across 17,175 files. The state on disk is over four times the size of the
data it describes.
What the walk is worth, so nobody re-targets it
4.4% of a pass. A full
lstatpass over the tree costs ~36 ms and readingand hashing every byte would cost ~20 ms more. #66 already removed one redundant
statper file. The walk is not the problem and cannot be made into one.Direction
Digest-first: exchange a hash, fall through to a manifest only when the
hashes differ. The common case is a peer that already has what we are about to
send, and that exchange should cost a hash rather than a manifest.
It is load-bearing rather than an optimisation, and this is the trade worth
stating out loud: presence needs exactly one mutation per refresh to be visible,
and this issue says every mutation costs a full manifest exchange with every
peer. We deliberately made the bus busier in order to make it honest. That
is the right trade, but it only stays affordable if a presence write costs a
changed hash and one small entry rather than a whole manifest.
Not started. It is a design conversation first.