zeroae / zeroae/zae-limiter

✨ Add API/CLI to reset bucket usage for an entity/resource pair

Open
#470 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

api-design area/cli area/limiter
Dominant language
Python
Stars
0
Forks
0
Avg merge
6h 51m
Merged PRs (30d)
104

Description

[!NOTE]
Parked pending the scheduled-limits ADR tracked in #222 — moved to the v1.0.0 milestone.
This is a sequencing decision, not a rejection of the work.

reset_bucket() was requested as a primitive for an out-of-tree cron job that swaps an
entity/resource's capacity at each tick. #222 is to make the library do time-based scheduling
natively, which may remove the need for this entirely — a calendar-aligned refill supersedes
"reset usage at a boundary". Decide this issue only after the #222 ADR is accepted: it may
be closed as unnecessary, or narrowed.

Design objections already on record, kept here so they are not lost:

  • Deleting bucket items throws away total_consumed_milli (the counter the aggregator diffs to
    produce usage snapshots — see #179), the ADR-125 disabled stamp, and shard_count.
  • In-flight adjust()/rollback writes are unconditional ADDs, so one landing after the delete
    recreates a skeleton item that lacks cp/ra/rp.
  • An in-place SET b_{l}_tk = :effective_cp, rf = :now per shard avoids all of the above at the
    same cost, and is the shape any narrowed version should take — so the "Mechanism: delete, don't
    in-place update" section below is the part most likely to change.

Problem or Use Case

There's no way to clear a single entity's accumulated usage state for one resource. Today the only way to change how much of a bucket is consumed is to wait for lazy refill, or to call lease.adjust() — which can only nudge the bucket, not reset it to a clean state.

The concrete case this blocks: an operator tightens or corrects a misconfigured limit (set_resource_defaults() / set_limits()), but the entity's existing bucket item still carries the old b_{limit}_cp/ra/rp values baked in at bucket-creation time (see build_composite_create in repository.py), plus whatever debt/consumption already accumulated under the old config. Support wants to give that one entity a blank slate — e.g. after fixing a bug that over-consumed tokens, or after raising an entity's limit and wanting it to take effect immediately rather than after a long refill period — without resetting every other entity on the resource (ruling out disable_resource/enable_resource, which are namespace/resource-wide) and without deleting and recreating the entity itself (which would lose its config and audit history).

Proposed Solution

Add Repository.reset_bucket(entity_id, resource, principal=None) -> int (Async; SyncRepository.reset_bucket(...) generated per ADR-121), mirroring the Repository-level placement of disable_entity()/enable_entity() (ADR-125) rather than RateLimiter — this is an admin action, not runtime limiting.

from zae_limiter import Repository

repo = await Repository.open("my-app")

# Wipe accumulated usage for one entity+resource, all shards.
count = await repo.reset_bucket("user-123", "gpt-4")
# count == number of bucket shard items deleted (0 if none existed)

CLI, following the existing entity disable/entity enable/entity clear-disabled command shape:

zae-limiter entity reset-bucket ENTITY_ID --resource RESOURCE [--namespace NS]
Mechanism: delete, don't in-place update

reset_bucket deletes the composite bucket item(s) for (entity_id, resource) rather than issuing UpdateItem SET tk = cp on each limit:

  1. Discover shards via a resource-scoped GSI3 query — GSI3PK={ns}/ENTITY#{id}, GSI3SK begins_with BUCKET#{resource}# — not the unscoped get_buckets() discovery query, since we only want this resource's shards, not every resource the entity has a bucket for.
  2. DeleteItem each shard's PK={ns}/BUCKET#{id}#{resource}#{shard}, SK=#STATE (unconditional — a concurrent write to a bucket we're deleting is fine; the entity just gets one more shard-worth of consumption that also gets wiped).
  3. Return the count of items deleted.

Deleting (rather than resetting tk in place) means the next acquire() recreates the bucket via the normal slow path with whatever limits are configured now (picking up any interim set_limits()/set_resource_defaults() change for free) and with shard_count collapsed back to 1 — instead of leaving stale cp/ra/rp values or an inflated shard_count from prior write-sharding (GHSA-76rv-2r9v-c5m6) in place.

Why this doesn't bypass disabled (ADR-125)

Deleting the bucket item also removes any disabled stamp on it. This is safe: a deleted bucket forces the next acquire() onto the slow path (speculative_consume() requires attribute_exists(PK); a missing item always falls back per the "Speculative Writes" invariant), and the slow path calls resolve_disabled() before creating a new bucket. So an entity/resource disabled via disable_entity()/disable_resource() cannot be re-admitted by calling reset_bucket().

Edge cases
Case Behavior
No bucket exists for (entity_id, resource) No-op, returns 0. Not an error — matches enable_entity()/clear_resource_disabled()'s no-op-on-missing precedent.
Bucket was write-sharded (shard_count > 1) All shards discovered and deleted, not just shard 0.
Entity has cascade=True Only the child's bucket for resource is reset. The parent's bucket (and any other resource) is untouched — cascade is out of scope for entity-scoped operations, same precedent as ADR-125's entity-level disabled.
resource never had any limits configured (bucket never created) Same as "no bucket exists" — 0, no validation error beyond validate_resource().

Alternatives Considered

  • In-place UpdateItem (SET b_{limit}_tk = b_{limit}_cp) per limit — rejected: requires already knowing every limit name on the item (an extra read), doesn't collapse a doubled shard_count, and leaves stale cp/ra/rp in place if limits changed since bucket creation. Delete-and-let-slow-path-recreate is a single unconditional op per shard and self-corrects all of the above.
  • Reset only the total_consumed_milli aggregator-refill counter, leave tk untouched — rejected: doesn't address the actual ask (clearing consumed/negative tokens), only affects the aggregator's refill-decision accounting (Issue #317).

Acceptance Criteria

  • Repository.reset_bucket(entity_id, resource, principal=None) -> int deletes every shard item for (entity_id, resource), discovered via a GSI3 query scoped with GSI3SK begins_with BUCKET#{resource}# (not an unscoped full-entity bucket discovery).
  • SyncRepository.reset_bucket(...) exists in sync_repository.py and is generated (not hand-edited) from the async source per ADR-121; hatch run generate-sync produces no diff.
  • Returns 0 and raises no exception when no bucket item exists for the pair.
  • Unit test (moto, tests/unit/test_repository.py + generated tests/unit/test_sync_repository.py) covers: no-op on missing bucket, deletion of all shards when shard_count > 1, and that a cascading child's reset does not delete or modify the parent's bucket item.
  • Integration test (LocalStack) verifies acquire() after reset_bucket() observes full capacity rather than the pre-reset consumption/debt.
  • zae-limiter entity reset-bucket ENTITY_ID --resource RESOURCE [--namespace NS] CLI command added in cli.py, following the existing entity disable/entity enable command structure, and prints the number of items reset.
  • A new AuditAction constant (e.g. BUCKET_RESET) is logged with entity_id and resource on every non-zero reset, consistent with existing audit logging for entity/limit mutations.
  • CLAUDE.md's "DynamoDB Access Patterns" table and "API methods for managing stored limits" section are updated to document the new method and CLI command.
  • docs/cli.md and docs/api/ reference the new command/method (via docs-updater agent per docs-parity rule).

Dependencies

None — builds on existing GSI3 bucket-discovery (GHSA-76rv-2r9v-c5m6) and the Repository-level admin-action placement established by ADR-125 (disable_entity/enable_entity).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

First read the scheduled-limits ADR in #222 and confirm whether this work remains needed. Then inspect build_composite_create in repository.py, the existing entity commands in cli.py, and the repository and sync-repository tests. Done means the accepted scope is implemented across API, generated sync API, CLI, audit logging, tests, and the named documentation, with hatch run generate-sync producing no diff.

Written by the indexing model from the issue text.

Assessment

Tech stack
aws, python
Domain
backend, cli, databases, documentation, testing
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.