Skip to content

fix(crdt): cap distinct origin site-slots per tenant_spend counter - #63

Merged
nerdalert merged 2 commits into
praxis-proxy:mainfrom
jordigilh:fix/grid-52-tenant-spend-origin-slot-cap
Aug 16, 2026
Merged

fix(crdt): cap distinct origin site-slots per tenant_spend counter#63
nerdalert merged 2 commits into
praxis-proxy:mainfrom
jordigilh:fix/grid-52-tenant-spend-origin-slot-cap

Conversation

@jordigilh

Copy link
Copy Markdown
Contributor

What

Bounds the number of distinct origin site-slots a single tenant's tenant_spend GCounter can accumulate, closing the site-slot-cap half of #52 (proposal item 2).

Why

#47's fix (correctly) stopped pruning tenant_spend on SWIM membership eviction, but that traded away the growth bound the buggy behavior accidentally provided. Nothing today removes a site's slot from a tenant's counter once recorded, so a compromised or churning origin repeatedly claiming new site identities could grow a single tenant's counter without bound. MAX_TRACKED_TENANTS already bounds distinct tenant_id keys — this is the independent, previously-missing bound on distinct origins within one tenant's counter that #52 called out.

How

  • GCounter::slot_count/has_slot — new introspection needed to check the cap.
  • GridStateSnapshot::merge_tenant_spend_from_origin: new MAX_TENANT_SPEND_ORIGINS = 256 bound, enforced the same way MAX_TRACKED_TENANTS already is — a brand-new origin is refused once a tenant's counter is at capacity; already-tracked origins keep accepting updates.

Testing (pyramid invariant)

  • Unit (crdt): GCounter slot_count/has_slot behavior, plus 4 new grid_state tests pinning the cap's refuse/admit/boundary/independence behavior.
  • Integration (swim): 2 new tests exercise the cap through the real SWIM gossip-ingest entry point (StateBroadcastHandler::receive_item), not just the internal merge function directly.
  • E2E: intentionally skipped, called out explicitly rather than silently — simulating 256 distinct origins against a live cluster is impractical and low-signal. This mirrors the already-shipped precedent: MAX_TRACKED_TENANTS itself has unit-tier coverage only, no integration/E2E.

All of cargo fmt, cargo clippy -D warnings, cargo doc (deny-level rustdoc link checks), cargo machete, and this repo's ai-slop-lint pass clean.

Scope

This PR intentionally covers only proposal item 2 from #52 (the site-slot cap). Proposal item 1 (the budget-epoch/window reset) needs CRD schema changes plus cross-site epoch-fencing design to avoid a monotonic GCounter re-merge reviving a "reset" total — that's real additional design surface, so it's tracked as its own follow-up issue rather than bundled here. #52 stays open until that lands.

Closes half of #52 (item 2); does not close the issue.

grid#47 stopped pruning tenant_spend on SWIM membership eviction (a
site marked Suspect/Dead was wiping its own contribution, letting an
exhausted budget reopen on a transient partition or restart). That fix
traded away the growth bound the buggy behavior accidentally provided:
nothing now removes a site's slot from a tenant's GCounter once
recorded, so a compromised or churning origin repeatedly claiming new
site identities could grow a single tenant's counter without bound.

MAX_TRACKED_TENANTS already bounds distinct tenant_id keys; this adds
MAX_TENANT_SPEND_ORIGINS as an independent bound on distinct
site-slots within a single tenant's counter, enforced in
merge_tenant_spend_from_origin: once a tenant's counter has that many
origins, a brand-new origin's contribution is refused, while
already-tracked origins keep accepting updates. Mirrors the existing
MAX_TRACKED_TENANTS bound's shape exactly.

Adds GCounter::slot_count/has_slot to support the check. Covered at
the unit tier (GCounter, GridStateSnapshot::merge_tenant_spend_from_origin)
and the integration tier (StateBroadcastHandler::receive_item, the
real SWIM gossip-ingest entry point). E2E coverage is intentionally
skipped: exercising a 256-distinct-origin adversarial scenario against
a live cluster is impractical and low-signal, the same class of
exception already accepted for MAX_TRACKED_TENANTS itself (unit-tier
only, no integration/E2E coverage).

This closes the site-slot-cap half of grid#52 (proposal item 2). The
budget-epoch/window-reset half (proposal item 1) is deliberately out
of scope here -- it needs CRD schema changes and cross-site epoch
fencing to avoid a monotonic GCounter re-merge reviving a "reset"
total, and is tracked separately in a new follow-up issue.

Part of grid#52 (site-slot-cap half only; issue stays open for the
epoch-reset half).

Signed-off-by: Jordi Gil <jgil@redhat.com>
@jordigilh

Copy link
Copy Markdown
Contributor Author

Follow-up issue for the epoch-reset half of #52: #64.

@praxis-bot praxis-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.

PR Review

Summary: Caps distinct origin site-slots per tenant's tenant_spend GCounter to bound memory growth from churning or compromised origins (issue #52, item 2).

Assessment: The cap logic in merge_tenant_spend_from_origin correctly gates on has_slot/slot_count before merging, and the check ordering with the existing MAX_TRACKED_TENANTS guard is sound: a brand-new tenant enters with 0 slots, so no empty GCounter leaks into the map via or_insert_with. The retain_origin call upstream guarantees each merge adds at most one slot, making the pre-check slot_count() >= MAX_TENANT_SPEND_ORIGINS sufficient without a post-merge recheck.

Severity Count
Critical 0
Large 0
Medium 1

Comment thread crdt/src/grid_state.rs Outdated
Addresses grid#63 review feedback (praxis-bot): six new test bodies in
grid_state.rs and state_broadcast.rs had leading comment blocks
carrying the test's behavioral rationale, violating the project
convention (CLAUDE.md, Test Organization: "No comments in test bodies
— use assertion messages"). Folded each comment's context into the
relevant assertion's message instead of dropping it outright, since
the rationale (why the boundary/independence/still-merges behavior
matters) wasn't otherwise captured.

Signed-off-by: Jordi Gil <jgil@redhat.com>
@nerdalert
nerdalert merged commit 55a1d09 into praxis-proxy:main Aug 16, 2026
16 checks passed
nerdalert pushed a commit that referenced this pull request Aug 16, 2026
…st (#67)

resolve-source's "Verify PR merge checkout" step compares the checked-out
merge commit's parents against a live-resolved base_sha (fixed in #62),
but the checked-out commit itself still came from GitHub's
refs/pull/<n>/merge -- a commit GitHub computes and caches
asynchronously. Per GitHub's own changelog ("Changes to test merge
commit generation for pull requests", 2026-02-19), that ref is only
regenerated on a push to the PR branch, a merge-base change, or a 12h
max-age timer, and viewing the PR no longer forces a refresh. Live
reproduction against PR #63 right after #62 merged: the ref stayed
stale for 49+ minutes, including after an explicit mergeable recheck,
which would fail "Verify PR merge checkout" with a "base changed" error
for a transient, unrelated-to-the-PR reason (see grid#66).

Stop depending on that ref. resolve-source already resolves head_sha
(PR API) and base_sha (live refs/heads/main) as pinned commit SHAs --
construct the merge locally from those two SHAs with `git merge --no-ff`
instead, in both resolve-source (to fail fast on real conflicts before
the 120-minute glb-e2e job) and glb-e2e's checkout (to get the actual
tested tree). Pinning author/committer identity and date makes the
merge commit fully deterministic, so both jobs independently arrive at
the identical SHA despite running on separate runners with no shared
git state -- verified live: two independent fresh-clone reconstructions
of PR #63's merge (base 7ebabdb.., head 50720c9..) produced the exact
same commit SHA (6df4a70a..).

fetch-depth changes from 2 to 0 (full clone) for the shared checkout
step, since `git merge` needs enough history to find the true
merge-base between base_sha and head_sha, which a shallow clone cannot
guarantee for a PR that is many commits behind main.

Fixes grid#66

Signed-off-by: Jordi Gil <jgil@redhat.com>
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.

3 participants