Encode a broadcast once, not once per connection (#288) - #296
Conversation
ac19002 to
f47b4f3
Compare
dd3e723 to
be549b0
Compare
Independent review of #296 at
|
| # | Invariant | Where it lives now | Status |
|---|---|---|---|
| 1 | supersedingKey is per graph, never a constant |
GraphStore.encode, "graphChanged:\(changed.id)" — moved out of send into EncodedEvent, still derived from the event's own graph |
✅ intact |
| 2 | OutboundChannel writes MSG_DONTWAIT + bounded poll, not a blocking write |
OutboundChannel.swift:286 / :302, untouched by this PR |
✅ intact |
| 3 | Valve measures pendingBytes - data.count |
OutboundChannel.swift:142, untouched |
✅ intact |
| 4 | OutboundChannels.send refuses an unregistered fd |
OutboundChannel.swift:384 — guard let channel else { return false }, no lazy creation |
✅ intact |
Invariant 1 is the one this PR could plausibly have broken, and it doesn't. The key is computed per encode, and every notifyClients encode is for exactly one store's own graph, so one key per broadcast is the same key send would have computed per connection. The key travels with the frame in EncodedEvent rather than being computed once and reused across graphs — which is the distinction that matters.
The behaviour callers depend on
deliver keeps the else { connections.removeValue(...) } on a false return, and it is the only path to OutboundChannels.send, so both the broadcast and unicast shapes still forget a dead client. ✅
announceError is equivalent: .errorOccurred carries supersedingKey == nil before and after, and onAnnounceError? still fires whether or not the encode succeeded.
The for id in connections.keys loop mutating connections from inside deliver is safe for the same reason it was before — the Keys view triggers COW on the first removal and the iteration finishes over the original.
Verification I ran myself
- Full Xcode gate: 1619 tests / 169 suites / 0 failures, run by me to completion. To be precise about what that measures: it ran on
4392e12, the pre-rebase tip of Ship the presence tick as the loops it moved, not the whole graph (#288) #297 (which contains Encode a broadcast once, not once per connection (#288) #296). I have not run a full gate onbe549b0eitself. Linux CI is green onbe549b0e. - Rebase verified byte-identical.
git diff origin/main..be549b0eandgit diff ac19002..dd3e723differ only in hunk headers. The rebase onto post-Serve the Mailroom from a bounded mailbox request, not every broadcast (#288) #293maindropped nothing from either side, which was the specific risk — both branches editGraphStore.sendandnotifyClients.
One thing worth fixing (not a blocker)
notifyClients now encodes even when nobody is connected. Before, send checked connections[connectionID] first and only then encoded, so a zero-connection store did zero encodes — the loop body simply never ran. Now the encode is hoisted above the loop and is unconditional.
That matters because the daemon deliberately runs with no client attached: pollPresence guards !connections.isEmpty for exactly this reason ("with no client attached there is no surface to show it on"), but broadcast() has no such guard, and predicate polling, session lifecycle and time-based triggers all reach it. On this repo's own graph the wasted encode is a 63,482 B snapshot per graph change, going nowhere.
It's a pure regression in the one dimension this PR is about, and a one-liner:
private func notifyClients() {
guard !connections.isEmpty else { return }
...
}Nit
send(_:to:) now encodes before checking that the connection still exists, so a unicast to a departed client pays for an encode it throws away. Trivial next to the above, and fixed by the same reordering if you'd rather do it once.
Verdict: approve. Fix the zero-connection encode if you agree it's a regression; I don't think it needs another round of review either way.
🤖 Generated with Claude Code
GraphStore.notifyClients looped over connections calling send, and send ran JSONEncoder().encode(event) — so a graph change cost one full encode of the snapshot per connection, presence tick included. The encode is hoisted out of the loop: notifyClients encodes the snapshot once into the bytes and the per-graph superseding key #291 introduced, and hands that to every connection through deliver, which keeps dropping a connection whose channel is gone. send keeps its shape for the unicast callers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N
be549b0 to
5a42063
Compare
Advance the mail cursor to the highest post handed over (#288) mail inbox read the room in one step and moved the cursor in another, to the room's latest post — so a post landing between the two was marked read without ever being printed. The CLI called that race accepted. The cursor now moves to Mailbox.highestDeliveredID inside the same actor turn the answer is drawn, so nothing can land in between: a cursor moves through mail that was delivered, never past mail that was not. This also closes the blocker #293 carried. An older CLI still sending the legacy mailroomInbox read an empty board off a snapshot that no longer holds posts and advanced its cursor anyway, losing that mail permanently, upgrade or not. The legacy command now moves no cursor and is refused loudly, to the asking connection alone. Two defects the review found in the fix itself, both closed: a searched inbox could advance the cursor past unread posts it had filtered out, now refused in the daemon rather than by convention; and a page smaller than retention opened a window where page two could be pruned before the reader asked for it, so the page is now the room's own cap and pruning between requests is reported as prunedUnread rather than passing in silence. Reviewed independently; every fix above was verified in the pushed source by the reviewer and again by me. Linux CI green; the mail suites pass at exit 0 on this head and #296's full gate at the sibling head is 1621/168/0. Lifts the release hold on main. Partially addresses #288.
Stacked on #293; rebases onto
mainonce that lands.The defect
GraphStore.notifyClientslooped over connections callingsend(_:to:), andsendranJSONEncoder().encode(event)— so a graph change cost one full encode of the snapshot per connection. With C clients attached that was C encodes of the same bytes on every change, presence tick included: the CPU amplifier the #288 root-cause report named beside the blocking write that #291 removed.The change
The encode is hoisted out of the loop.
notifyClientsencodes the snapshot once into anEncodedEvent— the bytes plus the per-graph superseding key #291 introduced, everything about a frame that does not depend on who receives it — and hands that to every connection throughdeliver(_:to:).send(_:to:)keeps its shape for the unicast callers (the joining snapshot, a refusal) and now just encodes and delivers.announceErrorgets the same treatment. The one behaviour callers rely on — a connection whose channel is gone is dropped by the broadcast that finds it so — lives indeliver, unchanged.C encodes → 1 per broadcast; nothing else moves.
Verification
Gate: full Xcode suite (1616 tests in 168 suites, 0 failures), swiftlint 0 errors, swift-format clean,
graphcodedandgraphcode-clischemes build; SwiftPMswift buildand.build/debug/graphcodepass locally.Encoding is not observable from outside the actor, so
BroadcastEncodingTestspins what the refactor must keep: two connections receive byte-identical frames for one change, a connection whose channel was closed is dropped on the broadcast that finds it, and the live one still hears the change. #289's diagnostics (next PR) record the encode duration once per broadcast, which is where the count becomes visible.Refs #288
🤖 Generated with Claude Code
https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N