ADR-125: Resource and Entity Disable¶
Status: Accepted Date: 2026-08-30 Issue: TBD — link the tracking issue once filed
Context¶
Operators need to turn a resource off, at the resource level and per entity, without
deleting its limit configuration. Two designs were considered: a disabled flag stored
beside limits, and reusing a limit with capacity: 0.
capacity: 0 does not work. acquire() defaults to speculative_writes=True, whose
admission test is a conditional UpdateItem on the bucket item with
b_{limit}_tk >= :consumed — tokens, not capacity, and no config read at all.
_sync_bucket_params (ADR-120) propagates changed cp/ra/rp to an existing bucket
but never resets tk, and it only fires for entity-level set_limits/delete_limits
— set_resource_defaults and set_system_defaults have no fan-out. A bucket holding a
balance therefore keeps admitting traffic against a "disabled" resource. capacity: 0
would also require loosening Limit.__post_init__'s positive-only invariants, destroys
the configured capacity it overwrites, and surfaces a permanently-off resource as a
retryable 429 with a finite retry_after_seconds that never comes good.
Decision¶
-
Store
disabledas a tri-state attribute on existing config items: absent means inherit,true/falseare explicit. Config items already carry non-limit siblings (on_unavailable,resource,entity_id), and_deserialize_composite_limitsdiscovers limits by scanning forl_*_cp, so a sibling attribute is invisible to it. -
Resolve
disabledby an independent walk over entity(resource) → entity(_default_) → resource. First explicit value wins, regardless of whether that level defines limits. This is what makes an entity-leveldisabled: falsere-admit a specific entity to a disabled resource. -
Denormalize the resolved value onto bucket items and enforce it by adding
attribute_not_exists(#disabled)to the speculativeConditionExpression. Bucket items already denormalizecascade,parent_idandshard_countfor exactly this reason, and the same expression already carries a non-token guard for TTL. -
Enforce eagerly: the disable call writes config and then fans out to every affected bucket before returning, via GSI2 (
GSI2PK={ns}/RESOURCE#{name},GSI2SK begins_with BUCKET#) for resource scope and GSI3 (GSI3PK={ns}/ENTITY#{id}) for entity scope. -
Raise a distinct
ResourceDisabledexception rather thanRateLimitExceeded. Disabled is closer to a 403 than a 429 and nothing should tell a client to retry.
Scope¶
System-level disabled is not implemented. A system-level kill switch would have to
fan out across every bucket in the namespace (GSI4) and its blast radius warrants its own
decision. Resolution stops at the resource level.
Consequences¶
Positive:
- Takes effect on the default fast path, which is the only path that matters in steady state.
- Limits survive the disable; re-enabling is one attribute.
- Token-bucket invariants in models.py and bucket.py are untouched.
- Callers can distinguish "intentionally off" from "temporarily saturated".
Negative:
- Disable is O(buckets for the resource) writes, not O(1). Bounded by entity count x shards.
- A narrow race exists between the config write and the fan-out query: an acquire()
already in flight can create a bucket the fan-out's GSI query does not see. Mitigated by
a second fan-out pass; the residual window is one in-flight acquire.
- set_resource_defaults / set_limits become read-before-write to preserve disabled,
costing 1 extra RCU on an infrequent admin path.
- The provisioner's Lambda-side fan-out does not evaluate per-entity overrides; the
handler orders resource changes before entity changes so a carve-out re-stamps last.
Alternatives Considered¶
Limit with capacity: 0¶
Rejected — see Context. Does not reach the fast path, and overloads a real bucket parameter.
Config-only flag with no denormalization¶
Rejected because the fast path never reads config; the flag would only take effect on the slow path, which steady-state traffic does not use.
Stream-driven fan-out via the aggregator¶
Rejected for the initial implementation because it makes disable asynchronous with no completion signal. A kill switch should not return before it has taken effect. Worth revisiting as a repair mechanism for the in-flight-acquire race.
Reserved synthetic always-failing limit (wcu-style)¶
Rejected because it still rides on tk and inherits the same stale-balance problem
unless tokens are explicitly zeroed.