Skip to content

feat(attribution): index facts in memory and publish them per type - #286

Merged
sunib merged 3 commits into
mainfrom
feat/attribution-fact-index
Jul 28, 2026
Merged

feat(attribution): index facts in memory and publish them per type#286
sunib merged 3 commits into
mainfrom
feat/attribution-fact-index

Conversation

@sunib

@sunib sunib commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Stacked on #284 (the transport seam). Base is feat/attribution-fact-stream, so this PR carries only its own diff.

This is PR 2 and PR 3 of the attribution fact stream design together: the transport-agnostic layer above the seam, and the publish side that feeds it. Nothing reads from it yet — the resolver still polls the v1 keys, so behaviour is unchanged and e2e attribution keeps passing on the path it always used.

The transport-agnostic layer

internal/queue/fact_index.go and friends: the four match structures, the reference-counted subscription set, the waiter registry, and the follower loop. Everything here has exactly one implementation and never learns which transport it has.

The route leads every key. The index is one per process while the streams are one per (route, group/resource), so an index keyed on the type alone would pool two clusters' facts in one map and hand a watch event on cluster B an author from cluster A. The rv-only tier is where that bites hardest — a resourceVersion is opaque and not unique across clusters — and the collection tier is where it bites most quietly, because a namespace name says nothing about which cluster it is in. TestFactIndex_RouteIsolatesOtherwiseIdenticalFacts stores identical (group/resource, uid, rv) facts under two routes and resolves each from its own, through both the exact and the rv-only tier.

Entries apply in delivery order, because the latest tier is last-writer-wins. Each carries its insertion time, and expiry is decided on READ as well as swept: an aged-out fact is never joined merely because the sweep has not run.

Eviction is counted, never silently absorbed. An attribution dropped because the index was full has to look different in the metrics from one that was never published.

The waiter registry registers THEN checks. A fact applied in the gap between the two must signal a waiter that is already listening; registering after the check loses exactly that fact, which is the race the poll loop papers over by looking again. The signal is buffered, so a waiter that was not listening yet still finds it.

The follower loop applies entries, wakes waiters, and reports trim gaps as a counter plus a log line naming the stream. A transport error is retried rather than returned: a follower that gave up would leave attribution silently dead for the life of the process.

AttributionResolutionsTotal and AttributionResolutionWaitSeconds keep their names and meanings — the wait histogram is how this whole change gets measured. New in internal/telemetry: attribution_fact_index_evictions_total{reason}, attribution_fact_stream_gaps_total{stream}, attribution_collection_degraded_total{reason}. They are registered here and first emitted when the index is wired in the switch-over PR, which is also where they get their row in docs/interpreting-metrics.md.

FactStreamSet is exposed for target_watch.go to drive in a later PR; nothing acquires a reference here.

The publish side

internal/webhook/audit_handler.go accumulates rather than writing per event: each accepted event is reduced to an AuthorFact, the facts are grouped by (route, group/resource) across the whole request, and each group is appended once. A batch of 400 events over three types becomes three appends.

A deletecollection is published as one fact describing the collection rather than expanded into one fact per object, so the "no resolvable name" rule becomes "no name and not a collection verb". The fact carries the actor, verb, type, namespace, stage timestamp, the selector from the request URI, and the uids the response body listed — reduced at the receiver, dropped past a cap with the degradation counted. The selector is the better evidence of the two: it is the intent the actor expressed, and it is there even when a cluster with --audit-webhook-truncate-enabled sends no body at all.

Only facts that would have been stored are published. An event with no objectRef or no user produces nothing, or waiters wake for facts that can name nobody.

Deliberately not in this PR

RecordFact, the fact key builders, RecordDeleteCollectionFacts, and their tests are untouched, and author_resolver.go is not touched at all. The v1 keys are still written and still read. The switch-over and the deletions are the next PR; doing them here would produce a diff that cannot be validated in halves.

The three open numbers

The design leaves three tuning numbers open. All are fields on FactIndexConfig / a queue constant, defaulted here; the flag wiring lands with the switch-over, when cmd/main.go first constructs an index (a flag whose consumer does not exist yet is a flag nobody can test).

Number Chosen Why
Per-type cap 4096 facts per (route, group/resource) The primary cap, because it is the fair one: a burst on one noisy type must not evict every other type's facts. An entry is a few hundred bytes, so a type at its cap costs low single-digit megabytes.
Global cap 65536 facts, as well as per-type The design asks "per type, or one global cap?" and answers "probably both". Per-type alone lets the pod's memory scale with how many types happen to be watched; the total cap bounds it with a number that does not. Overflow evicts from the type holding the most, so the pressure lands where it came from.
Collection window 30s Ten times the default grace window. It only has to cover audit batching plus clock skew: under the deletion-as-intent rule the removal being attributed happens at delete-REQUEST time, so finalizers do not stretch it. Short enough that an unrelated delete a minute later is not claimed.
uid-set cap 10000 uids A few hundred kilobytes against a response body for the same request that runs to tens of megabytes. A tuning number rather than a correctness one — the scope fallback is already correct — and a collection that big is exactly the one a truncating cluster would not have sent a body for anyway.

Validation

task fmt, task generate, task manifests, task vet, task lint, task test (coverage 78.3% → 78.4%, baseline committed), task test-e2e.

Summary by CodeRabbit

  • New Features

    • Improved attribution for collection deletions using object membership, selectors, and namespace scope.
    • Added reliable handling for collection events, including those without response bodies.
    • Batched attribution updates by resource stream while preserving event order.
    • Added bounded retention and expiration for attribution data to improve consistency and resource usage.
  • Monitoring

    • Added metrics for attribution evictions, degraded collection matching, and stream gaps.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sunib, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c79df45-fa00-40e4-a30b-923d5d72edc8

📥 Commits

Reviewing files that changed from the base of the PR and between 4079d5c and 091052e.

📒 Files selected for processing (1)
  • internal/controller/gittarget_controller_test.go
📝 Walkthrough

Walkthrough

This PR adds collection-delete attribution facts with selectors and UID coverage, introduces a bounded in-memory fact index with asynchronous stream following and waiter coordination, and batches webhook-derived facts by stream for publication with new telemetry and tests.

Changes

Attribution fact pipeline

Layer / File(s) Summary
Collection fact contracts and extraction
internal/queue/attribution_index.go, internal/queue/author_fact.go, internal/queue/author_fact_test.go, internal/telemetry/exporter.go
Author facts now carry collection selectors and UID sets; deletecollection conversion applies UID caps, scope fallback, and degradation metrics.
Fact storage and attribution resolution
internal/queue/fact_index.go, internal/queue/fact_index_store.go, internal/queue/fact_index_test.go
Facts are stored by route and resource scope with exact, latest, RV-only, and collection matching tiers, TTL cleanup, capacity eviction, and precedence tests.
Fact streams and asynchronous waiting
internal/queue/fact_streams.go, internal/queue/fact_waiters.go, internal/queue/fact_index.go, internal/queue/fact_index_test.go
Reference-counted stream subscriptions, register-before-check waiters, follower retries, retention replay, sweeps, and gap reporting coordinate late fact resolution.
Webhook fact batching and validation
internal/webhook/audit_handler.go, internal/webhook/audit_fact_publish_test.go, .coverage-baseline
Audit events are converted into ordered per-stream batches and published through an optional interface; publication, retry, collection, and disabled-publisher paths are tested, and the coverage baseline is raised to 78.4.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AuditHandler
  participant AuthorFactFromEvent
  participant AuditFactPublisher
  participant FactIndex
  participant WatchResolver
  AuditHandler->>AuthorFactFromEvent: convert accepted audit events
  AuthorFactFromEvent-->>AuditHandler: return grouped author facts
  AuditHandler->>AuditFactPublisher: publish facts by stream
  AuditFactPublisher->>FactIndex: deliver fact entries
  WatchResolver->>FactIndex: await attribution query
  FactIndex-->>WatchResolver: return tiered attribution result
Loading

Possibly related PRs

  • ConfigButler/gitops-reverser#251: Also changes internal/queue/attribution_index.go and threads provider-scoped attribution data through webhook and watch lookup paths.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is detailed, but it does not follow the repository template sections for type of change, testing, checklist, related issues, or notes. Add the required template sections and fill in Type of Change, Testing, Checklist, Related Issues, Screenshots if needed, and Additional Notes.
Docstring Coverage ⚠️ Warning Docstring coverage is 17.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: in-memory fact indexing and per-type publishing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/attribution-fact-index

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from feat/attribution-fact-stream to main July 28, 2026 12:45
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

sunib added 2 commits July 28, 2026 12:53
The layer that sits above the fact transport seam and never learns which
transport it has: the four match structures the join reads, the
reference-counted subscription set, the waiter registry a blocked resolver
parks on, and the follower loop that fills all three.

The route leads every key. The index is one per process while the streams are
one per (route, group/resource), so an index keyed on the type alone would pool
two clusters' facts in one map and hand a watch event on cluster B an author
from cluster A. The rv-only tier is where that bites hardest, because a
resourceVersion is opaque and not unique across clusters, and the collection
tier is where it bites most quietly, because a namespace name says nothing
about which cluster it is in.

Entries apply in delivery order, because the latest tier is last-writer-wins.
Each carries its insertion time, so expiry is decided on read and the sweep is
memory reclaim rather than correctness. Each type is capped, with oldest-first
eviction that is COUNTED: an attribution dropped because the index was full has
to look different in the metrics from one that was never published.

The waiter registry registers THEN checks, never the reverse. A fact applied in
the gap between the two must signal a waiter that is already listening;
registering after the check loses exactly that fact, which is the race the poll
loop papers over by looking again.

AuthorFact gains the collection fields the index reads — the selector the
request URI expressed, and the optional uid set — so this layer is complete on
its own. Nothing above it changes: the resolver still polls, the audit handler
is untouched, and the v1 fact keys are still the path in use.

Numbers picked here: 4096 facts per type as the primary cap, 65536 in total,
a 30s collection window, and a 30s sweep. All configurable on FactIndexConfig.
The audit receiver stops writing per event and accumulates instead: each
accepted event is reduced to an AuthorFact, the facts are grouped by
(audit route, group/resource) across the whole request, and each group is
appended once. An apiserver batch of 400 events over three types becomes three
appends. The batching that causes the delivery delay attribution waits out is
the same batching that makes publishing nearly free.

A deletecollection is published as ONE fact describing the collection rather
than expanded into one fact per object, so the "no resolvable name" rule
becomes "no name and not a collection verb". The fact carries what the audit
event actually said — actor, verb, type, namespace, stage timestamp, the
selector from the request URI — plus the uids the response body listed, reduced
at the receiver and dropped past a cap with the degradation counted. The
selector is the better evidence: it is the intent the actor expressed, and it
is there even when a truncating production cluster sends no body at all.

Only facts that would have been stored are published. An event with no
objectRef or no user produces nothing, or waiters wake for facts that can name
nobody.

The v1 keys are still written and still read. The resolver is not switched over
here, so behaviour is unchanged and e2e attribution keeps passing on the path it
always used.

The uid-set cap is 10000 — a few hundred kilobytes of uids against a response
body for the same request that runs to tens of megabytes.
@sunib
sunib force-pushed the feat/attribution-fact-index branch from 89551ca to 4079d5c Compare July 28, 2026 12:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
internal/queue/fact_index_store.go (2)

317-347: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

compact is O(refs × collections) while holding the index mutex.

live scans s.collections linearly for every collection ref, and compact calls live for every ref in order. Sweep holds FactIndex.mu for the whole call, so a scope near the per-type cap makes every lookup on the watch shard's blocking path wait on a quadratic scan. Building the live collection seqs once per compact makes it linear.

♻️ Proposed fix: hoist the collection seq set out of the per-ref scan
 func (s *scopeFacts) compact() {
+	collectionSeqs := make(map[uint64]struct{}, len(s.collections))
+	for _, entry := range s.collections {
+		collectionSeqs[entry.seq] = struct{}{}
+	}
 	kept := s.order[:0]
 	for _, ref := range s.order {
-		if s.live(ref) {
+		if s.liveWith(ref, collectionSeqs) {
 			kept = append(kept, ref)
 		}
 	}
 	s.order = kept
 }

with liveWith taking the precomputed set for the factKindCollection case and delegating the map lookups for the other three kinds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/queue/fact_index_store.go` around lines 317 - 347, Update
scopeFacts.compact to build a set of live collection sequence numbers once
before iterating over s.order, then use a liveWith helper that accepts this
precomputed set for factKindCollection while preserving the existing map-based
checks for the other reference kinds. Replace the per-reference live call in
compact with liveWith so compaction remains linear in the number of references
and collections.

83-90: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

invalidSelector has no test.

The branch decides that a collection fact is never scope-matched — an over-attribution guard — and nothing in fact_index_test.go publishes a fact with an unparseable LabelSelector. A case asserting AttributionAbsent for a fact carrying e.g. "!!!" would pin the behaviour.

As per coding guidelines, "Cover new code with positive and negative tests".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/queue/fact_index_store.go` around lines 83 - 90, Add a negative test
in fact_index_test.go that publishes a collection fact with an unparseable
LabelSelector such as "!!!", then assert its attribution result is
AttributionAbsent. Exercise the invalidSelector branch in the fact indexing flow
and preserve existing valid-selector behavior.

Source: Coding guidelines

internal/webhook/audit_fact_publish_test.go (1)

49-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing test for FactRecorder + FactPublisher configured together.

The FactPublisher doc comment states it is "additive to FactRecorder" — both are expected to run side by side in production during this transition period. Every test here wires only one or the other (newPublishingHandler sets only FactPublisher; TestAuditHandler_NoPublisherPublishesNothing sets only FactRecorder), so there's no coverage confirming both fire correctly for the same event batch.

♻️ Suggested additional test
func TestAuditHandler_RecorderAndPublisherBothFire(t *testing.T) {
	recorder := &fakeFactRecorder{}
	publisher := &fakeFactPublisher{}
	handler, err := NewAuditHandler(AuditHandlerConfig{FactRecorder: recorder, FactPublisher: publisher})
	require.NoError(t, err)

	body := eventListBody(writeEvent("a", "apps", "deployments", "web", "alice"))
	require.Equal(t, http.StatusOK, serveBody(t, handler, http.MethodPost, "/audit-webhook/prod-eu-1", body).Code)

	require.Equal(t, 1, recorder.len())
	require.Len(t, publisher.recorded(), 1)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/webhook/audit_fact_publish_test.go` around lines 49 - 54, Add a test
alongside the existing audit handler tests that configures both FactRecorder and
FactPublisher in AuditHandlerConfig, sends one valid event batch through the
handler, and verifies a successful response plus exactly one recorded event in
each fake. Keep the existing single-component tests unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/queue/fact_index_store.go`:
- Around line 100-102: Correct the comment for parseStageTimestamp to state that
falling back to the later insertion time widens the window because inWindow
compares against at.Add(window), rather than claiming it narrows the window.

---

Nitpick comments:
In `@internal/queue/fact_index_store.go`:
- Around line 317-347: Update scopeFacts.compact to build a set of live
collection sequence numbers once before iterating over s.order, then use a
liveWith helper that accepts this precomputed set for factKindCollection while
preserving the existing map-based checks for the other reference kinds. Replace
the per-reference live call in compact with liveWith so compaction remains
linear in the number of references and collections.
- Around line 83-90: Add a negative test in fact_index_test.go that publishes a
collection fact with an unparseable LabelSelector such as "!!!", then assert its
attribution result is AttributionAbsent. Exercise the invalidSelector branch in
the fact indexing flow and preserve existing valid-selector behavior.

In `@internal/webhook/audit_fact_publish_test.go`:
- Around line 49-54: Add a test alongside the existing audit handler tests that
configures both FactRecorder and FactPublisher in AuditHandlerConfig, sends one
valid event batch through the handler, and verifies a successful response plus
exactly one recorded event in each fake. Keep the existing single-component
tests unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a6c4a0c-d936-4547-a691-80936926b4dd

📥 Commits

Reviewing files that changed from the base of the PR and between 80e711f and 4079d5c.

📒 Files selected for processing (12)
  • .coverage-baseline
  • internal/queue/attribution_index.go
  • internal/queue/author_fact.go
  • internal/queue/author_fact_test.go
  • internal/queue/fact_index.go
  • internal/queue/fact_index_store.go
  • internal/queue/fact_index_test.go
  • internal/queue/fact_streams.go
  • internal/queue/fact_waiters.go
  • internal/telemetry/exporter.go
  • internal/webhook/audit_fact_publish_test.go
  • internal/webhook/audit_handler.go

Comment thread internal/queue/fact_index_store.go
The recreate-on-deletion spec waits for a recovery only the periodic requeue
can deliver — this controller runs no control-plane Secret watch — and its
budget was exactly two RequeueStreamSettleInterval ticks with no slack. A CI
runner busy enough to delay one tick by a second failed the spec on timing
alone, twice in a row, on a branch whose diff cannot reach the age-key path.

Eventually returns as soon as the Secret is back, so the third tick costs
nothing on any run that was going to pass.
@sunib
sunib merged commit 7ece731 into main Jul 28, 2026
19 checks passed
@sunib
sunib deleted the feat/attribution-fact-index branch July 28, 2026 14:12
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