Skip to content

perf: a watch pinned to one name polls only the node that holds its entry - #47

Closed
CMGS wants to merge 6 commits into
masterfrom
perf/pinned-watch-one-node
Closed

CMGS wants to merge 6 commits into
masterfrom
perf/pinned-watch-one-node

Conversation

@CMGS

@CMGS CMGS commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Stacked on #46. The base is fix/name-pinned-reads, so this diff shows only this change.

Problem

Every kubectl wait and kubectl delete opens a watch pinned to one name. Each one-second tick of that watch called listInventories: it read every node's inventory, built a Sandbox for every entry in the namespace and sorted them, only to follow one name. The cost grows with the whole fleet, and each concurrent wait pays it again. #46 lists this as its follow-up.

Change

  • runWatch picks its poll once: a pinned watch calls pollPinned, and every other watch keeps listInventories.
  • While the watch holds its entry, pollPinned reads only that entry's node through matchOnNode.
  • While it holds none, it sweeps the inventories with FirstHit and stops at the first hit. It asks no node's sandboxd, so an absent name still costs no node request.
  • The keep-while-the-node-holds-it check from fix: a list or watch pinned to one name agrees with Get before the node publishes #46 is unchanged.
  • listPinned and pollPinned share selectedOne, which returns zero or one sandbox that passes the selectors.
  • docs/scaling-design.md states the exception to "one watcher per fleet".

Behavior notes:

  • A name deleted on one node and claimed again on another is now reported as Deleted, then Added on the next tick. A fleet watch already reports it that way when the two land in different ticks. Synthesized sandboxes carry no UID, so an informer converges either way.
  • A name claimed again on the same node is still reported as Modified.

Cost

The claim path is unchanged. Production code is +24/−5, with no comment added.

BenchmarkClientInventoryWatchTick runs one pinned tick through the informer-fed cache that production uses. Each arm ran 6 times, interleaved, 3 rounds in each order. The figures are medians.

fleet (nodes × sandboxes per node) fleet poll pinned poll ratio
26 × 100 2.05 ms, 9.7 MB 0.062 ms, 0.024 MB 33×
26 × 2000 23.2 ms, 192 MB 1.11 ms, 0.36 MB 21×
200 × 2000 164 ms, 1480 MB 1.11 ms, 0.36 MB 147×

The pinned tick grows with the entries on one node, not with the fleet. It still decodes that node's whole inventory, which is the shared decode every read path uses.

Tests

TestANamePinnedWatchPollsOnlyTheNodeThatHoldsItsEntry in pkg/scale/sandboxstore_live_test.go, under synctest:

  • five ticks on a held entry send no event and do not enumerate the fleet;
  • a change on the entry's node is Modified, still without a fleet enumeration;
  • the name claimed again on another node is Deleted, then Added.

With the tick forced back to listInventories, both fleet-enumeration assertions fail. The #46 pinned-watch tests pass unchanged.

Gates, all with GOWORK=off:

  • make fmt-check
  • make lint: 6 × 0 issues.
  • make test
  • asl -forwarder=false ./... on darwin and linux

Merging

Merge #46 first. Squash-merging #46 with its branch deleted closes this PR, and GitHub cannot reopen a PR whose base ref is gone. So retarget this PR to master before #46's branch is deleted.

After the retarget, a squash merge of this PR applies cleanly: #46's commits here and its squash on master make the same changes. Until this branch is rebased, the PR page also lists #46's lines. The rebase is a force push, so it is the owner's call.

Hardware verification

Pending. The next two-host round re-runs SBL-06b and SBL-08 on this branch.

…de publishes

kubectl wait --for=delete and kubectl delete read the sandbox with Get,
then open a watch-list with fieldSelector=metadata.name=<name>. Get asks
the nodes for a claim the NodeInventory does not hold yet; List and Watch
read only the inventories. So a sandbox claimed before its node published
(up to 30 s) was found by the Get, missing from the watch's initial sync,
and reported deleted at once. Before the watch-list bookmark fix the same
wait hung instead.

A list pinned to one name in a namespace now resolves through Get. A
watch with that pin starts from it, and before it reports a known entry
deleted it asks the entry's node, keeping the entry while the node holds
it and it still matches the selectors. An absent name polls the
inventories only, so the pin adds no node traffic. Fleet lists and
watches are unchanged.
The SandboxStore and ClaimIDResolver interfaces, WithClaimRouting and WithWatchPollInterval already state every fact these five godocs repeated; the lifecycle verbs in the same package carry none (Comment Style: interface-implementing methods omit godoc).
…de publishes

A read by name now takes the claim id, deadline and claim time from the node's own row, so synthAnnotations, lifecycle.md, usage.md and the lifecycle example no longer say these wait for a publish, and the example limits the lag to fleet List and Watch. scaling-design.md adds that a list or watch pinned to a name no node holds asks every node once when it opens.
Multi-line godoc and const comments become one fact per line. Comments that restated a name, a signature, the interface godoc or scaling-design.md are gone: warmCandidate, scatterGatherStore, its claim-routing fields, matchOnNode, parseSelectors, the embedded SandboxLifecycle note, and the watch cost figures the design doc already measures. The NodeInventoryGVK reason moves onto its var entry. The files drop from 82 and 146 comment lines to 32 and 59.
…ilent-node test runs under synctest

heldByNode passes objKey as the claim ref that Claim and lookupName spell with namespacedName, so objKey now calls it. TestASilentNodeBoundsAMiss waited out two real 500 ms timeouts; under synctest they elapse on the fake clock, and dropping the timeout still fails it as a deadlock. unpublishedStore moves below the countingSource type it builds.
…ntry

Every kubectl wait and kubectl delete opens a watch pinned to one name, and each of its one-second ticks re-derived the whole fleet's inventories to follow that name. While the watch holds its entry, a tick now reads only that entry's node. While it holds none, the tick sweeps the inventories until the first hit, without building the rest of the fleet. The keep-while-the-node-holds-it check is unchanged, and fleet watches are unchanged.

BenchmarkClientInventoryWatchTick, informer-fed cache, median of 6 runs, arms interleaved in both orders: 26x100 2.05 ms to 0.062 ms, 26x2000 23.2 ms to 1.11 ms, 200x2000 164 ms to 1.11 ms per tick.
@CMGS

CMGS commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #48. Squashing #46 left this branch in conflict with master, and moving it would need a force push, so #48 carries the same commit on the merged master with the same tree.

@CMGS CMGS closed this Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant