Skip to content

Sustained CPU in sync: full manifest exchange per peer per pass (size x mutation rate), unswept bus tombstones, Content-buffered Entry #52

Description

@myobie

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_onceserde_json::from_slice::<ReplyHeader>ManifestBTreeMap<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

  1. 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".
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions