Repository navigation
fix: the Go inventory forgets departed and opted-out workloads (ADR 0018) - #44
Merged
Merged
Conversation
…018) inventory.Store was the one place in the agent whose keyspace only grew. Ingest had a single operation on it -- assign -- and no code path anywhere removed a record. A record entered when a node reported a binary that joined, and left only when the controller restarted. Not an oversight so much as a missing half. Every other payload is derived from state the controller can re-read: workload_metadata is rebuilt from the live informer index on each flush, so it drops what the cluster dropped without anything having to remember that it should. The inventory cannot be rebuilt that way -- it joins facts the nodes push with state the controller holds -- so accumulating was right and eviction was never written. The serious consequence is an opt-out that did not apply to what had already been collected. Annotating a pod with rebuildstack.co/collect=false drops it from the index immediately and stops it being named in the scan scope (ADR 0015); both halves worked. What followed is that no new fact ever arrived about that pod, and a store that only accumulates cannot act on the absence of facts. The record already held kept leaving the cluster -- with its namespace, workload name, container name and main module path -- in every snapshot for as long as the controller ran. security.md section 11 said the exclusion applies at the collection stage; for this payload it applied only to facts not yet collected. Every flush now drops records the controller's own filtered pod index no longer supports, and with them the dependency set of any build no surviving record references. The live index is the same PodWatcher.Pods() that workload_metadata is built from, so the two payloads agree about which workloads exist by construction rather than by two code paths staying in step. The mechanism was already there with one consumer too few: indexPod's comment already promised the index "drops a pod the moment" it is excluded. Three deliberate details. There is no empty-input guard: a cluster where nothing passes the filters legitimately has an empty inventory, and a guard would pin the last non-empty snapshot forever -- the exact failure being fixed. deps and written are dropped together, because dropping the set while leaving the digest marked written would leave a returning image permanently unsent, silently. And nodes_reported stops being cumulative: a departed node is dropped, since a count that outlives its nodes exceeds the fleet and hides the DaemonSet gap it exists to expose (correcting ADR 0017). Proven end to end in kind: a running pod is annotated out and its record leaves the payload. Co-Authored-By: Claude Opus 5 (1M context) <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.
inventory.Storewas the one place in the agent whose keyspace only ever grew.Ingesthad a single operation on it — assign — and no code path anywhere removed a record. A record entered when a node reported a binary that joined, and left only when the controller restarted.Not an oversight so much as a missing half. Every other payload is derived from state the controller can re-read:
workload_metadatais rebuilt from the live informer index on every flush, so it drops what the cluster dropped without anything having to remember that it should. The inventory cannot be rebuilt that way — it joins facts the nodes push with state the controller holds, and reports arrive once per scan interval. Accumulating was the right answer. Eviction was the part that was never written.Why this is not just stale data
A deleted workload's record kept shipping, and
workload_metadatahad already forgotten it, so the two payloads disagreed about which workloads exist.The serious one: an opt-out that did not apply to what had already been collected. Annotating a pod with
rebuildstack.co/collect: "false"drops it fromPodWatcher's index immediately and stops it being named in the scan scope handed to nodes (ADR 0015). Both halves worked. What followed is that no new fact ever arrived about that pod — and a store that only accumulates has no way to act on the absence of facts. The record already held kept leaving the cluster, with its namespace, workload name, container name and main module path, in every snapshot, for as long as the controller ran.docs/security.md§11 said "the exclusion applies at the collection stage". For this payload it applied only to facts not yet collected.What changes
Every flush now drops records whose (namespace, kind, name, container) is absent from the controller's own filtered pod index, and with them the dependency set of any build no surviving record references.
The live index is
PodWatcher.Pods()— the same sourceworkload_metadatais derived from. That is the substance, not an implementation detail: the two payloads now agree about which workloads exist by construction, because they read one index. The mechanism was already in place with one consumer too few —indexPod's own comment already promised the index "drops a pod the moment" it is excluded.Three details that were each a decision, not a default:
Retainwith an empty live set empties the inventory. A guard would have been the intuitive safety measure and would have been wrong: a cluster where nothing passes the filters legitimately has an empty inventory, and a guard would pin the last non-empty snapshot forever — the exact failure this fixes. It is safe without one because the index is only ever mutated by informer events and never wiped (handlers register after cache sync; a relist replays as adds/updates), so an empty index means an empty cluster, not an unsynced one. There is a test for this case specifically.depsandwrittenare dropped together. Dropping a dependency set while leaving its digest marked as written would mean a returning image is re-ingested and never sent — silently, with nothing failing. Same failure mode the two-step pending/mark split of ADR 0017 exists to make visible.nodes_reportedstops being cumulative. ADR 0017 (mine, last PR) described it as a running total. Wrong in the same way: its purpose is comparison against the node count innode_metadata, and a count that outlives its nodes exceeds the fleet — so a scaled-down cluster with a broken DaemonSet would have looked complete, which is exactly what the block was added to expose.Verification
make test(incl.go vet -tags e2e ./...) andmake lintgreen.LiveKeyscovering init containers (a native sidecar is declared as one and would otherwise flap).make inventory-e2epasses in kind (127s): the test finds the record, annotates the running pod out, and asserts the record leaves the payload. Only docs were edited after the image was built, so this run matches the committed Go code exactly.Docs
ADR 0018 amends ADR 0017 (the
nodes_reportedlifetime, and the record-lifetime question 0017 recorded as "not addressed here").docs/security.md§11 now states that the exclusion applies to what was already collected and why the inventory needs telling to forget rather than simply stopping learning; §8 states a record exists only while its workload does.docs/backend-requirements.mdgains the absence semantics: a record missing from a snapshot means "no longer collected", the backend must not carry forward records absent from a superseding snapshot, and a CronJob may legitimately flicker.🤖 Generated with Claude Code