Rate Limiting¶
Token bucket, sliding window, concurrency reservation, and the rate-limit registry.
ratelimit ¶
Rate-limiting primitives for TaskQ.
QUEUE_CONCURRENCY_PREFIX
module-attribute
¶
Reserved namespace for fleet-wide per-queue concurrency cap reservations.
Primitives whose name starts with this prefix are internal to TaskQ's
queue-cap bootstrap path and must be registered via
RateLimitRegistry.register_queue_cap_reservation, not via the public
RateLimitRegistry.register (which rejects prefixed names to prevent
accidental or malicious shadowing of an internal queue cap). Keyed refs
must never derive concrete names into this namespace either, see the
base_name validators in taskq.ratelimit.refs.
Lives here (rather than in ratelimit.registry) because
ratelimit.refs must reference it in its validators, and refs is
imported BY registry, defining it in registry would be circular.
__all__
module-attribute
¶
__all__ = [
"QUEUE_CONCURRENCY_PREFIX",
"AcquiredResource",
"ConcurrencyReservation",
"KeyedRateLimitRef",
"KeyedReservationRef",
"RateLimitBackend",
"RateLimitDecision",
"RateLimitHandle",
"RateLimitRef",
"RateLimitRegistry",
"RateLimitState",
"ReservationHandle",
"ReservationRef",
"SlidingWindow",
"SyncResult",
"TokenBucket",
"get_redis_pool",
"queue_concurrency_reservation_name",
"register_rate_limit_registry",
"register_redis_pool",
"registry",
"sync_rate_limit_buckets",
"sync_slots",
]
AcquiredResource ¶
RateLimitHandle
dataclass
¶
RateLimitHandle(
name: str,
primitive: TokenBucket | SlidingWindow,
decision: RateLimitDecision,
redis_client: Redis | None,
pg_pool: Pool | None,
clock: Clock | None,
settings: WorkerSettings | None = None,
count: float = 1.0,
refund_on_release: bool = True,
)
Handle for a successfully acquired rate-limit token.
release() is a no-op when refund_on_release is False (post-actor
path, token consumption is permanent). When refund_on_release is
True (rollback path), release() refunds count tokens via
primitive.refund().
release
async
¶
Source code in src/taskq/ratelimit/composition.py
ReservationHandle
dataclass
¶
ReservationHandle(
name: str,
reservation: ConcurrencyReservation,
slot_index: int,
job_id: UUID,
worker_id: UUID,
pool: Pool | None,
)
Handle for a successfully acquired reservation slot.
release() calls ConcurrencyReservation.release(slot_index, worker_id, pool)
which sets the slot row's job_id to NULL. Idempotent.
slot_index is whatever acquire() returned, in practice a
:class:~taskq.ratelimit.reservation.SlotLease, an int subclass that
also carries the lease fence. Storing and handing it back unchanged is
what makes release() safe against a zombie attempt: a handle from a
lease that has since expired and been re-acquired frees nothing, so the
bucket cannot silently exceed max_concurrent. Anything that rebuilds
a handle must carry the value through as-is rather than re-deriving an
int from it.
RateLimitDecision
dataclass
¶
RateLimitDecision(
allowed: bool,
remaining: float,
retry_after: timedelta | None,
bucket_name: str,
backend: RateLimitBackend,
request_id: str | None = None,
previous_state: dict[str, object] | None = None,
)
RateLimitState
dataclass
¶
RateLimitState(
bucket_name: str,
backend: RateLimitBackend,
is_exhausted: bool,
tokens_remaining: float = 0.0,
remaining: float = 0.0,
retry_after: timedelta | None = None,
capacity: float | None = None,
limit: int | None = None,
window: timedelta | None = None,
style: str | None = None,
refill_per_second: float | None = None,
)
Read-only snapshot of a rate-limit bucket's current state.
Returned by TokenBucket.peek(), SlidingWindow.peek(),
and ConcurrencyReservation.peek(). Fields are backend-agnostic:
TB backends populate tokens_remaining and capacity; SW backends
populate remaining, limit, window, and style.
KeyedRateLimitRef ¶
Bases: BaseModel
Reference to a per-key token bucket, derived from the payload.
Mirrors :class:KeyedReservationRef but for rate limits: base_name
namespaces the derived buckets (concrete name is f"{base_name}:{key}"),
key_fn derives the key from the actor's validated payload, and
capacity / refill_per_second configure every bucket derived
from this ref identically (all keys share the same per-key budget).
payload_type is the :class:~pydantic.BaseModel subclass that the
payload will be validated against. Use :meth:typed for type-safe
construction that binds key_fn to the same payload_type:
KeyedRateLimitRef.typed(MyPayload, base_name="api-per-tenant",
key_fn=lambda p: p.tenant_id, capacity=10, refill_per_second=1.0).
A consumer calling an external API with per-tenant rate limits would
declare rate_limits=[KeyedRateLimitRef.typed(MyPayload,
base_name="api-per-tenant", key_fn=lambda p: p.tenant_id, capacity=10,
refill_per_second=1.0)] to give each tenant its own independent
token budget, with each tenant's bucket materializing on first use.
Concurrency caps vs. rate limits. A concurrency limiter (how many
jobs at once, e.g. :class:KeyedReservationRef /
:class:~taskq.ratelimit.reservation.ConcurrencyReservation) and a
rate limiter (how many per unit time, e.g.
:class:~taskq.ratelimit.token_bucket.TokenBucket) solve different
problems: N concurrent slots with fast responses can still burst well
past a per-time-unit budget, so both may be needed together on the
same actor.
Backend selection. The backend field (default "redis")
controls which storage backend the materialized
:class:~taskq.ratelimit.token_bucket.TokenBucket uses, identical to
the backend constructor parameter on a static TokenBucket. In a
deployment without Redis configured, set backend="postgres" or
backend="memory" to avoid the Redis-required failure mode, a
keyed bucket with backend="redis" but no redis_client raises
RuntimeError on acquire (not caught by with_pg_fallback, which
only handles ConnectionError/TimeoutError).
PG fallback inheritance. _resolve_rate_limit_name constructs a
plain :class:~taskq.ratelimit.token_bucket.TokenBucket (with the
backend from this ref, default "redis") and calls its normal .acquire().
The existing with_pg_fallback path in
token_bucket._acquire_redis_wrapped is therefore inherited
automatically, on Redis ConnectionError/TimeoutError, the
acquire falls back to the PG rate_limit_buckets table governed by
settings.rate_limit_pg_fallback_enabled. No second fallback
mechanism is built or needed.
Dual growth bounds. Per-key Redis memory is self-bounding because
the token-bucket Lua script sets an EXPIRE TTL on each bucket's
Redis hash (computed from capacity/refill_per_second). The
Python-process-local dict/registry growth is bounded separately by
:meth:~taskq.ratelimit.registry.RateLimitRegistry.evict_idle_keyed_rate_limits,
which evicts idle entries from the in-memory registry. These are two
independent bounds, Redis TTL bounds Redis memory; registry eviction
bounds Python memory.
Concrete per-key :class:~taskq.ratelimit.token_bucket.TokenBucket
instances are registered lazily on first acquisition and are not
automatically removed, see
:meth:~taskq.ratelimit.registry.RateLimitRegistry.evict_idle_keyed_rate_limits
for bounding registry growth under high key cardinality.
.. note::
The declared type Callable[[BaseModel], str] is deliberately
unsound at the field level, :meth:typed stores a
Callable[[P], str] (contravariance prevents direct assignment).
Runtime safety is enforced by the registry's isinstance check
against payload_type before calling key_fn. Direct
invocation of ref.key_fn(model) is unchecked, prefer
:meth:typed for compile-time safety.
model_config
class-attribute
instance-attribute
¶
typed
classmethod
¶
typed(
payload_type: type[P],
*,
base_name: str,
key_fn: Callable[[P], str],
capacity: float,
refill_per_second: float,
backend: RateLimitBackend = "redis",
) -> KeyedRateLimitRef
Type-safe constructor that binds key_fn to payload_type.
The key_fn parameter is typed as Callable[[P], str] where
P is the provided payload_type, at static-analysis time
the caller's lambda or function is checked against the concrete
model's attributes (e.g. lambda p: p.tenant_id is verified
against payload_type.tenant_id).
At runtime the registry passes the validated payload model to
key_fn, so the callable always receives an instance of
payload_type (verified by isinstance in the registry ,
same-type payloads pass through directly; different-type or dict
payloads are re-validated via model_validate).
Source code in src/taskq/ratelimit/refs.py
KeyedReservationRef ¶
Bases: BaseModel
Reference to a per-key concurrency reservation, derived from the payload.
base_name namespaces the derived reservations (the concrete name
registered for a given key is f"{base_name}:{key}") so distinct
KeyedReservationRef declarations never collide. key_fn receives
the actor's validated payload (as a :class:~pydantic.BaseModel
instance, validated from the job row's raw JSON payload) and
must return a non-empty string, typically a tenant, session, or
account identifier already present on the payload.
payload_type is the :class:~pydantic.BaseModel subclass that the
payload will be validated against. Use :meth:typed for type-safe
construction that binds key_fn to the same payload_type:
KeyedReservationRef.typed(MyPayload, base_name="geocode-session",
key_fn=lambda p: p.session_id, slots=3, lease=timedelta(minutes=5)).
slots and lease configure every reservation derived from this
ref identically (all keys share the same per-key cap and lease
duration); use a separate KeyedReservationRef if different keys
need different caps.
Concrete per-key reservations are registered lazily on first
acquisition and are not automatically removed, see
:meth:~taskq.ratelimit.registry.RateLimitRegistry.evict_idle_keyed_reservations
for bounding registry growth under high key cardinality.
.. note::
The declared type Callable[[BaseModel], str] is deliberately
unsound at the field level, :meth:typed stores a
Callable[[P], str] (contravariance prevents direct assignment).
Runtime safety is enforced by the registry's isinstance check
against payload_type before calling key_fn. Direct
invocation of ref.key_fn(model) is unchecked, prefer
:meth:typed for compile-time safety.
model_config
class-attribute
instance-attribute
¶
typed
classmethod
¶
typed(
payload_type: type[P],
*,
base_name: str,
key_fn: Callable[[P], str],
slots: int,
lease: timedelta,
) -> KeyedReservationRef
Type-safe constructor that binds key_fn to payload_type.
The key_fn parameter is typed as Callable[[P], str] where
P is the provided payload_type, at static-analysis time
the caller's lambda or function is checked against the concrete
model's attributes (e.g. lambda p: p.session_id is verified
against payload_type.session_id).
At runtime the registry passes the validated payload model to
key_fn, so the callable always receives an instance of
payload_type (verified by isinstance in the registry ,
same-type payloads pass through directly; different-type or dict
payloads are re-validated via model_validate).
Source code in src/taskq/ratelimit/refs.py
RateLimitRef ¶
ReservationRef ¶
Bases: BaseModel
Typed reference to a concurrency reservation primitive by name.
RateLimitRegistry ¶
Unified registry for rate-limit and reservation primitives.
Stores two separate dicts: _rate_limits for TokenBucket /
SlidingWindow and _reservations for ConcurrencyReservation.
Cross-dict name collision is allowed, they live in separate namespaces.
Ownership. A registry is an ordinary ownable object: construct one
per process and pass it to worker_main(..., rate_limit_registry=...)
/ create_router(..., rate_limit_registry=...), or rely on the
module-level registry singleton (the default at every entry point).
Actor-declared primitive instances are registered by the worker
bootstrap's collection pass; use explicit .register() for
primitives shared outside actor dispatch. :meth:clear resets all
state and is a test aid only, NOT safe while a worker is running.
Source code in src/taskq/ratelimit/registry.py
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | |
has_pending_reservation_reclaims
property
¶
Whether any evicted keyed bucket still awaits its row deletion.
Covers both pending sets, reservation slot rows and published rate-limit bucket rows. The sweep loop uses this single gate to skip the drain entirely (no connection acquired) when there is nothing to reclaim, so a pending set the gate cannot see would never be drained.
is_keyed_rate_limit ¶
Whether name is a keyed-MATERIALISED rate-limit entry.
A keyed ref's first acquire derives base_name:key and lazily
registers a primitive tracked in _keyed_rate_limit_last_used;
a STATICALLY pre-registered entry is deliberately never stamped
there (the idle-eviction sweep must not evict it). This
predicate is that same discrimination, published for the
metrics sampler: named series are for statically-registered
buckets only (bounded by the code the user ships), keyed
materialisations — whose names embed caller-controlled keys —
are aggregated so they can never become label cardinality.
Source code in src/taskq/ratelimit/registry.py
has_reservation ¶
O(1) membership test against the live reservations dict.
Unlike the :attr:reservations property this does NOT defensively
copy the dict, use it on per-job hot paths (e.g. the dispatch
queue-cap check), where copying the whole registry per call is
prohibitive at high keyed-entry cardinality.
Source code in src/taskq/ratelimit/registry.py
has_rate_limit ¶
O(1) membership test against the live rate-limits dict.
See :meth:has_reservation, the same no-copy guarantee applies.
register ¶
Source code in src/taskq/ratelimit/registry.py
register_queue_cap_reservation ¶
Register a fleet-wide queue-cap reservation in the reserved namespace.
This is the ONLY way to register a reservation whose name starts with
:data:QUEUE_CONCURRENCY_PREFIX. The public :meth:register rejects
such names to prevent users from accidentally shadowing internal
queue caps. Idempotency and conflict detection are identical to
:meth:register (duplicate-name-with-different-config →
ValueError; duplicate-name-with-same-config → idempotent no-op).
Source code in src/taskq/ratelimit/registry.py
get_rate_limit ¶
get_reservation ¶
acquire
async
¶
acquire(
name: str,
count: float = 1.0,
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> AsyncGenerator[RateLimitDecision, None]
Source code in src/taskq/ratelimit/registry.py
acquire_for_actor
async
¶
acquire_for_actor(
rate_limits: Sequence[
str
| KeyedRateLimitRef
| TokenBucket
| SlidingWindow
],
reservations: Sequence[
str | KeyedReservationRef | ConcurrencyReservation
],
*,
job_id: UUID,
worker_id: UUID,
payload: dict[str, object] | BaseModel | None = None,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> list[AcquiredResource]
AND-composition: acquire reservations first, then rate limits.
reservations entries may be plain names (resolved against
statically pre-registered primitives), :class:KeyedReservationRef
instances (resolved dynamically per job from payload, see
:meth:_resolve_reservation_name), or
:class:ConcurrencyReservation instances (normalized to their
.name up front, the instance must already be registered,
e.g. by the worker bootstrap's actor-declaration collection
pass; an unregistered instance raises KeyError exactly like
an unknown name). rate_limits entries may likewise be plain
names, :class:KeyedRateLimitRef instances, or
:class:TokenBucket / :class:SlidingWindow instances.
payload is required if any entry is a KeyedReservationRef
or KeyedRateLimitRef. It may be a dict (validated via
ref.payload_type.model_validate) or a BaseModel (used
directly if it matches ref.payload_type, otherwise re-validated
via model_dump() → model_validate()).
Returns the list of AcquiredResource handles on full success.
Raises ReservationUnavailable on any denial, rollback is performed
internally before re-raising (already-acquired resources released in
reverse order, each failure logged at ERROR).
Source code in src/taskq/ratelimit/registry.py
1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 | |
peek
async
¶
peek(
name: str,
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> RateLimitState
Look up a rate-limit primitive by name and return its current state.
Source code in src/taskq/ratelimit/registry.py
peek_all
async
¶
peek_all(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
timeout: float | None = None,
) -> dict[str, RateLimitState]
Peek all registered rate limits. Returns {name: RateLimitState}.
Each bucket's read is a separate Redis/PG round trip, so a call
costs O(buckets) round trips, and the registry can hold up to
max_keyed_rate_limits keyed-materialised buckets. timeout
bounds the WHOLE pass: a bucket whose store hangs (a black-holed
broker answers no read) must not park the caller past it. Raises
:class:TimeoutError when the bound fires, per-bucket failures
are still caught and logged per bucket, but a read that never
RETURNS is indistinguishable from a dead registry at page-render
time, so the caller learns of the bound instead of rendering a
half-empty live-state map as if it were current. None (the
default) keeps the unbounded shape for callers that manage their
own deadline.
Source code in src/taskq/ratelimit/registry.py
reset
async
¶
reset(
name: str,
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
timeout: float | None = None,
) -> None
Reset a rate-limit bucket to full capacity.
timeout bounds the reset's backend round trip (a Redis DEL or a
PG upsert against a dead store can hang the caller forever);
raises :class:TimeoutError when it fires. None (the default)
keeps the unbounded shape for callers that manage their own
deadline.
Source code in src/taskq/ratelimit/registry.py
release_for_actor
async
¶
Release acquired resources after actor completion.
Sets refund_on_release=False on all RateLimitHandle instances
before iterating (token consumption is permanent after actor ran).
Releases in reverse acquisition order. Each release failure is caught,
logged at ERROR, and loop continues (same pattern as rollback).
Why pg_pool is unused: each handle captured the pool it needs at
acquisition time, so handle.release() is self-contained. The
parameter mirrors :meth:acquire_for_actor, which does need it, so
the acquire/release pair reads as one symmetric surface at the call
site (taskq.worker._consumer passes the same worker_pool to
both). It is public API, so it is kept rather than removed.
Source code in src/taskq/ratelimit/registry.py
bucket_release_event ¶
Return (creating on first use) the release-wake event for
bucket_name. The retry path in taskq.worker._consumer waits
on this between acquire attempts; see _notify_bucket_release.
Source code in src/taskq/ratelimit/registry.py
evict_idle_keyed_reservations ¶
evict_idle_keyed_reservations(
idle_for: timedelta,
*,
max_pending_reclaims: int | None = None,
) -> int
Drop registry entries for keyed reservations idle at least idle_for.
Reservations derived from a :class:KeyedReservationRef are
registered lazily and never removed automatically, under high key
cardinality (e.g. one reservation per import session over a long
worker lifetime) this dict grows without bound. Each worker's
30-second sweep calls this automatically against its own registry
(not leader-gated) with a 1-hour idle threshold; call directly for
custom eviction windows.
Removes the in-memory registry entry, its acquire-recency
tracking, and its release-wake event (the denial-retry's wake
signal, popped with the same idle criterion so sustained denials
under high key cardinality cannot grow the event dict without
bound), and RECORDS the bucket in the pending-reclaim set so
its reservation_slots rows are deleted by
:meth:drain_pending_reservation_reclaims on the same sweep
cadence. The lock-expiry sweep is an UPDATE ... SET job_id =
NULL, it clears a row's holder but never deletes the row, and
sync_slots iterates only currently-registered reservations,
so without the pending-reclaim drain an evicted bucket's rows
would be orphaned permanently (steady-state cardinality: slots x
every key ever seen, unbounded in the caller-controlled key
space). A slot still held by a live lease survives the drain's
idle-guarded DELETE and stays pending until its lease expires.
max_pending_reclaims caps the pending-reclaim set (default:
:data:taskq.constants.DEFAULT_MAX_KEYED_RESERVATIONS; both
production call sites, the per-worker sweep and the acquisition
path's opportunistic eviction, pass the settings-derived
WorkerSettings.max_keyed_reservations, so the pending set is
bounded by the same ceiling that bounds the tracked entries;
direct callers without a settings object get the constant). At
the cap the eviction is VETOED, the entry stays registered and
re-scanned on the next sweep, so no structure grows unbounded
and no rows are orphaned by an eviction that could not be
recorded. The visible signals are the pending-depth gauge
(taskq.ratelimit.reclaim_pending, the steady signal), the
registry-keyed-reclaim-pending-cap-veto warning (one
aggregated line per eviction call that shed evictions, pending
at cap means reclamation is falling behind), and, if the veto
persists up to the registry's own entry cap, the existing
registry-keyed-reservation-limit-exceeded soft-cap warning.
A key that is acquired again after eviction is
re-registered on next use (idempotent, see
:meth:_resolve_reservation_name), and the drain drops its name
from the pending set without deleting anything (a re-activated
key owns its rows again), so eviction is always safe to call,
including concurrently with in-flight acquisitions for other
keys.
Returns the number of entries evicted.
Source code in src/taskq/ratelimit/registry.py
1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 | |
evict_idle_keyed_rate_limits ¶
evict_idle_keyed_rate_limits(
idle_for: timedelta,
*,
max_pending_reclaims: int | None = None,
) -> int
Drop registry entries for keyed rate limits idle at least idle_for.
Rate limits derived from a :class:KeyedRateLimitRef are registered
lazily and never removed automatically, under high key cardinality
(e.g. one token bucket per tenant over a long worker lifetime) this
dict grows without bound. Each worker's 30-second sweep calls this
automatically against its own registry (not leader-gated) with a
1-hour idle threshold; call directly for custom eviction windows.
Removes the in-memory registry entry and its acquire-recency
tracking. The underlying Redis hash is deliberately NOT touched:
per-key Redis memory is already self-bounding via the Lua script's
EXPIRE TTL on the bucket's hash (see
:meth:_resolve_rate_limit_name), that TTL governs Redis, not
the PG rate_limit_buckets row the materialization path
publishes, so a bucket resolved with a PG pool has its schema
captured at resolution time (publish outcome irrelevant, the
acquire path can preseed the row) and is recorded here for row
reclamation: :meth:drain_pending_reservation_reclaims deletes
the published row on the same sweep cadence, the exact shape of
the reservation-side reclamation (steady state without it: one
rate_limit_buckets row per key ever seen, unbounded in the
caller-controlled key space). A key that is acquired again after
eviction is re-registered on next use (idempotent, see
:meth:_resolve_rate_limit_name), and the drain drops its name
from the pending set without deleting anything (a re-activated
key owns its row again), so eviction is always safe to call,
including concurrently with in-flight acquisitions for other
keys.
max_pending_reclaims caps the rate-limit pending-reclaim set
(default: :data:taskq.constants.DEFAULT_MAX_KEYED_RESERVATIONS,
the shared keyed-pending ceiling; the acquisition path's
opportunistic eviction passes the settings-derived
WorkerSettings.max_keyed_rate_limits, so the pending set is
bounded by the same ceiling that bounds the tracked entries). At
the cap the eviction is VETOED, the entry stays registered and
re-scanned on the next sweep, so no structure grows unbounded
and no published row is orphaned by an eviction that could not
be recorded (the same fail-closed bound as the reservation
side; the pending-depth gauge and the
registry-keyed-reclaim-pending-cap-veto warning are the
visible signals).
Exemption: memory fixed-quota buckets. A backend="memory"
bucket with refill_per_second == 0 that has consumed part of its
quota is NOT evicted (see :meth:TokenBucket.holds_consumed_quota):
its token state lives only on the in-process instance, so eviction
would silently reset the drained quota to full, and the next acquire
would over-admit against a budget the tenant already spent. The
exemption applies to both callers of this method (the per-worker
sweep and the cap-pressure opportunistic eviction). Trade-off,
deliberately chosen: an exempt bucket counts against
settings.max_keyed_rate_limits until its quota returns to full
(refund/reset) or the process restarts, so under sustained
high-cardinality memory fixed-quota keys the cardinality cap can
fill and deny NEW keys, the cap fails CLOSED with a warning rather
than silently resetting quotas, which is the correct failure
direction for a limiter.
PG fixed-quota buckets are NOT exempt: their quota state lives in
the rate_limit_buckets row, and no reclamation path here can
lose it, the drain's DELETE below and the maintenance leader's
fleet sweep share the consumed-quota veto
(_no_consumed_quota_sql) that keeps a partly-spent row, and a
re-materialized bucket resumes from that row (the acquire path
preseeds ON CONFLICT DO NOTHING and reads the surviving state).
Evicting the registry entry is pure bookkeeping recycling, which is
what keeps the max_keyed_rate_limits cap from filling with
never-again-used fixed-quota keys and refusing every new key.
Returns the number of entries evicted.
Source code in src/taskq/ratelimit/registry.py
1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 | |
drain_pending_reservation_reclaims
async
¶
drain_pending_reservation_reclaims(
pool: Pool,
*,
batch_names: int = _DEFAULT_RECLAIM_BATCH_NAMES,
acquire_timeout: float | None = None,
) -> int
Delete the PG rows of evicted keyed buckets.
Two row kinds, one bounded pass per schema per call:
reservation_slots rows of evicted keyed reservations, and the
published rate_limit_buckets rows of evicted keyed rate
limits. For each kind and schema, at most batch_names pending
bucket names and ONE batched DELETE. The slice is the FRONT of
the per-schema insertion-ordered pending set. Driven by the
per-worker sweep loop on the sweep cadence, immediately after
the keyed evictions that feed it; a no-op (no connection
acquired) when nothing is pending on either side.
Semantics, what stays pending and why:
- A name that re-registered since eviction is dropped from its pending set WITHOUT a statement: a re-activated key owns its rows again.
- The reservation DELETE removes only free or lease-expired rows , a slot still held by a live lease survives (the over-admission invariant). A HELD slot is therefore the only reason a reservation name stays pending after its slice ran: the existence probe names the buckets whose rows survived the DELETE, and those names rotate to the BACK of the queue, the lease expires, the holder's release or the lock-expiry sweep frees the row, and a later drain (at most one full rotation away) deletes it. A name with no rows left, fully deleted this tick, or never materialized at all, leaves the pending set.
- The rate-limit DELETE needs no HOLDER guard and no survivor
probe, a bucket row has no holder or lease, so nothing
survives the statement on that axis and every sliced name
leaves the pending set in one pass, the queue drains FIFO
with no survivors to rotate. It DOES carry a guard, and that
guard is critical: the consumed-quota veto
(
_no_consumed_quota_sql, the same predicate the fleet sweep applies) refuses to delete a row whose fixed quota is partly spent, so an evicted spent bucket's row survives the drain and a re-materialized key resumes its spent state instead of resetting to full capacity (pinned by tests/test_keyed_fixed_quota_eviction.py). A vetoed name still leaves the pending set, the row stays, and the fleet sweep is the backstop once the quota is no longer consumed. A live bucket never reaches the statement at all: only evicted keyed names are ever recorded, and the re-registered check above drops a re-activated key first. - A schema key whose pending set empties (every name re-registered, or every row reclaimed) is popped after the pass, so a later drain with nothing pending acquires no connection at all.
Instrumentation, on the failure path as much as the success
path: duration in a finally (a timed-out drain still leaves
a duration sample), deleted rows only as RETURNING-confirmed
(a timed-out statement cannot masquerade as an empty drain),
the drain-failure counter on exception (a STORAGE signal, a
failing drain strands rows; the pending-depth gauge shows the
backlog forming), and the pending-depth gauge after each drain.
Raises on failure, callers guard (the sweep loop warns and
continues on the next tick).
acquire_timeout bounds the pool wait, for callers on a loop that must never block indefinitely on an exhausted pool (the sweep loop passes its dispatcher command timeout); the default matches the registry's other pool-bearing methods (unbounded).
Returns the number of rows deleted.
Source code in src/taskq/ratelimit/registry.py
1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 | |
clear ¶
Reset ALL mutable registry state, a test aid, NOT safe while running.
Clears the nine dicts (_rate_limits, _reservations,
_keyed_reservation_last_used, _keyed_rate_limit_last_used,
_keyed_rate_limit_row_schemas,
_pending_reservation_reclaims,
_pending_rate_limit_reclaims,
_keyed_reservation_heal_attempted,
_keyed_reservation_heal_failure_logged)
AND resets the two opportunistic-eviction scan timestamps
(_keyed_reservation_last_eviction_scan /
_keyed_rate_limit_last_eviction_scan) to float("-inf").
Omitting the timestamps would leave the opportunistic-eviction
throttle stamped, silently suppressing scans for up to
_OPPORTUNISTIC_EVICT_MIN_INTERVAL (30 s) in the next test;
omitting a pending-reclaim set, a publish-schema capture, or a
heal stamp would leak one test's evictions into the next.
Not safe to call while a worker is running, concurrent dispatch / sweep iteration over the dicts would observe inconsistent state. Use for per-test isolation only.
Like the eviction methods, this resets IN-PROCESS bookkeeping
only, it does NOT touch Redis bucket hashes or Postgres
reservation_slots / rate_limit_buckets rows; backend
state persists and will be observed on next acquire.
Source code in src/taskq/ratelimit/registry.py
ConcurrencyReservation ¶
ConcurrencyReservation(
name: str,
slots: int,
lease: timedelta | float,
lock_lease: timedelta | None = None,
*,
clock: Clock | None = None,
schema: str = "taskq",
keyed: bool = False,
)
Concurrency reservation using pre-allocated slot rows.
Raises :class:ValueError if slots < 1 or lease <= 0.
Raises :class:ReservationUnavailable when no slot is available.
keyed (default False) marks a reservation materialised from a
:class:~taskq.ratelimit.refs.KeyedReservationRef: its slot rows
carry the fleet-reclaimable keyed flag and a last_used_at
stamp (refreshed by this class's acquire/release/ensure statements),
so the maintenance leader's sweep_idle_keyed_rows can reclaim
them after the OWNING PROCESS dies, the in-process registry
bookkeeping that would otherwise name them dies with it. A
statically declared reservation keeps the default: its rows are
never fleet-reclaimable (there is no keyed lifecycle, no acquire-path
heal, and no re-materialisation, a sweep that deleted them would
leave a permanently denying limiter).
Source code in src/taskq/ratelimit/reservation.py
__slots__
class-attribute
instance-attribute
¶
__slots__ = (
"_acquire_sql",
"_ensure_sql",
"_keyed",
"_lease",
"_lock_lease",
"_name",
"_release_fenced_sql",
"_release_sql",
"_schema",
"_slots",
"_table",
)
schema
property
¶
The PG schema this reservation's slot table lives in.
Workers filter registry-global reservations by their own schema at startup (a process-global registry may carry reservations declared for other schemas/databases, touching those would write into the wrong schema or fail noisily).
keyed
property
¶
Whether this reservation was keyed-materialised.
Carried on the instance so the row-writing statements can stamp the fleet-reclaimable mark without every call site re-deriving it, the constructor is the single point that knows the reservation's lifecycle origin.
table
property
¶
The in-memory slot table (requires clock at construction).
ensure_slots
async
¶
Idempotent pre-allocation of slot rows.
timeout bounds the pool acquire (forwarded to
asyncpg.Pool.acquire): None, the historical default, waits
unboundedly under pool starvation, which is how a bootstrap startup
could hang forever on a contended dispatcher pool. Callers in
bounded contexts (the bootstrap's ensure-slots loop passes its
dispatcher_command_timeout budget) must pass it.
Inserts the bucket's full slot row set with this reservation's
fleet-reclaimable mark and a fresh last_used_at; the conflict
arm (rows already present, re-materialisation over survivors,
or another process's earlier materialisation) refreshes
last_used_at on every conflicting row and converges the
keyed mark toward immortality without ever touching the
holder/lease columns (held state is untouched):
- a keyed materialisation (or its heal) re-marks rows that are already keyed and refreshes their staleness, re-materialised survivors restart the horizon;
- a keyed materialisation over rows marked
keyed=falseleaves them false: that mark is a STATIC claim (born false, possibly declared in a process whose registry this worker's process-local collision guard cannot see), and a static reservation has no acquire-path heal, swept rows would deny forever; - a later static declaration of the same name (the bootstrap's
startup ensure) retires keyed rows to
keyed=false, never-sweep again, the immortality direction a live static declaration owns.
Source code in src/taskq/ratelimit/reservation.py
slot_rows_exist
async
¶
Whether any reservation_slots row exists for this bucket.
The acquire-path heal for keyed reservations uses this to
distinguish a bucket whose rows were deleted out from under it
(zero rows, re-materialise via :meth:ensure_slots) from
ordinary contention (rows present, all held, deny). A read-only
existence probe: it never writes. timeout bounds the pool
acquire exactly as:meth:ensure_slots' does.
Source code in src/taskq/ratelimit/reservation.py
acquire
async
¶
acquire(
job_id: UUID,
worker_id: UUID,
pool: Pool | None = None,
*,
timeout: float | None = None,
) -> SlotLease
Acquire a slot. Returns the acquired slot_index.
The return value is a :class:SlotLease, an int that also
carries the fence :meth:release needs to tell this lease apart from
an earlier, expired one on the same slot. Pass it back to
:meth:release (which is what ReservationHandle does) to get that
protection.
When pool is None, the in-memory table (clock= at
construction) is used. Raises :class:ReservationUnavailable when
no slot is available.
timeout bounds the pool acquire (forwarded to
asyncpg.Pool.acquire; None, the default, waits
unboundedly under pool starvation, the shape). Callers that
already run under their own budget wrapper can leave it unset;
callers with a budget in hand should pass it.
Source code in src/taskq/ratelimit/reservation.py
688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 | |
release
async
¶
Release slot. No-op if worker_id mismatch.
Also a no-op when slot_index is a :class:SlotLease whose fence no
longer matches the slot row, i.e. the caller is releasing a lease that
has since expired and been re-acquired. Without that gate a zombie
attempt (heartbeats stalled, job reclaimed and redispatched, original
coroutine never cancelled) frees the slot its own LIVE successor
holds, and the bucket silently exceeds max_concurrent: the job id
cannot discriminate, because a retry reuses the same job row, and
neither can the worker id, because the redispatch commonly lands on
the same worker. A plain int releases unfenced, as before.
When pool is None, the in-memory table is used.
Source code in src/taskq/ratelimit/reservation.py
peek
async
¶
Return {"free_count": int, "total_slots": int} for the bucket.
When pool is None, the in-memory table is used.
Source code in src/taskq/ratelimit/reservation.py
SyncResult
dataclass
¶
SlidingWindow ¶
SlidingWindow(
name: str,
limit: int,
window: timedelta,
backend: Literal[
"redis", "postgres", "memory"
] = "redis",
style: SlidingWindowStyle = "log",
ttl: timedelta | None = None,
)
Sliding-window rate limiter with pluggable backends.
Raises :class:ValueError if limit < 1, window <= timedelta(0),
or style is not "log" or "gcra".
Source code in src/taskq/ratelimit/sliding_window.py
__slots__
class-attribute
instance-attribute
¶
__slots__ = (
"_backend",
"_limit",
"_mem_gcra",
"_mem_log",
"_name",
"_redis_gcra_refund_script",
"_redis_gcra_refund_script_client",
"_redis_gcra_script",
"_redis_gcra_script_client",
"_redis_log_script",
"_redis_log_script_client",
"_script_lock",
"_style",
"_ttl",
"_window",
)
acquire
async
¶
acquire(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> RateLimitDecision
Acquire one admission slot.
The shared admission state is measured in the data store's own
clock domain: PG paths use clock_timestamp() and Redis paths
use Redis TIME inside their scripts, so callers on nodes with
divergent Python clocks are all measured against the same window.
The injected clock drives the memory backend only (its single
domain) and remains part of the public call shape, the unified
TokenBucket contract.
Source code in src/taskq/ratelimit/sliding_window.py
refund
async
¶
refund(
decision: RateLimitDecision,
*,
count: float = 1.0,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> None
Source code in src/taskq/ratelimit/sliding_window.py
peek
async
¶
peek(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> RateLimitState
Read-only state snapshot.
PG and Redis peeks measure against the store's own clock (the same domain their admission state is stamped in); the memory backend uses the injected clock (its single domain), the unified TokenBucket contract: clock is required only on memory.
Source code in src/taskq/ratelimit/sliding_window.py
reset
async
¶
reset(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> None
Source code in src/taskq/ratelimit/sliding_window.py
TokenBucket ¶
TokenBucket(
name: str,
capacity: float,
refill_per_second: float,
backend: RateLimitBackend = "redis",
ttl: timedelta | None = None,
*,
keyed: bool = False,
)
Token-bucket rate limiter with pluggable backends.
Raises :class:ValueError if capacity <= 0 or
refill_per_second < 0.
keyed (default False) marks a bucket materialised from a
:class:~taskq.ratelimit.refs.KeyedRateLimitRef whose
rate_limit_buckets row is FLEET-reclaimable: keyed-materialised
AND PG-state-backed. The restriction to backend="postgres" is
the point, not an accident: only a PG-state-backed bucket's acquire
path touches its PG row, so only there does the row's
last_used_at stamp (refreshed by the preseed/upsert/refund
statements below) truthfully track use, a redis-backend keyed
bucket's healthy acquire never touches PG, and its PG row is
outage-fallback state plus admin metadata that the fleet sweep must
never delete (the stamp would be a false staleness signal for an
actively-used bucket). The registry's materialisation arm is the
single call site that knows both facts and passes the flag
accordingly; every other constructor call keeps the static default.
Source code in src/taskq/ratelimit/token_bucket.py
__slots__
class-attribute
instance-attribute
¶
__slots__ = (
"_backend",
"_capacity",
"_keyed",
"_mem_bucket",
"_name",
"_redis_refund_script",
"_redis_refund_script_client",
"_redis_script",
"_redis_script_client",
"_refill",
"_script_lock",
"_ttl",
)
keyed
property
¶
Whether this bucket's rate_limit_buckets row is
fleet-reclaimable (see the class docstring for the exact
marking rule).
holds_consumed_quota ¶
True if idle-evicting this bucket would silently reset a consumed fixed quota.
Idle eviction exists to bound registry growth, not to hand back
capacity a tenant already spent. A fixed quota
(refill_per_second == 0) is drained forever by design, so
nothing about it recovers on its own and idleness is never
evidence that it is safe to discard. Refilling buckets are not
exempt: their state converges back toward full anyway, so eviction
loses at most one refill window's worth of tokens, an accepted,
bounded divergence.
What eviction destroys differs by where the state lives, and only ONE backend actually loses the budget:
- memory: token state lives on the instance and nowhere else, so eviction discards it and the next acquire materializes at FULL capacity. The instance is right here, so the exemption is exact: only a bucket that has actually spent some quota is held.
- postgres, the
rate_limit_bucketsrow IS the state, and evicting the REGISTRY entry does not delete it: both row-delete paths (the maintenance leader's fleet sweep and the per-worker pending-reclaim drain, sharing_no_consumed_quota_sql) veto deleting a row whose fixed quota is partly spent, and re-materialization resumes from the surviving row (the acquire preseedsON CONFLICT DO NOTHINGand reads the existing state under the row lock). The registry entry is pure bookkeeping, dropping it loses nothing, so no PG fixed-quota bucket is held. Holding them anyway is worse than a wasted entry: every PG fixed-quota key ever seen would count againstmax_keyed_rate_limitsforever, so once the cap filled, every NEW key would be refused withReservationUnavailableand its jobs snooze-looped until process restart. - redis: the backend keeps fixed-quota state for 24 h of its
own accord (see
_compute_ttl_seconds), so a re-materialized bucket resumes prior state there and eviction is state-safe.
Reads _tokens without the bucket's async lock; safe because the
only caller (the registry's idle-eviction sweep) runs synchronously
in the event loop with no await between this read and the dict pop,
so the value is consistent at the sweep instant.
Source code in src/taskq/ratelimit/token_bucket.py
acquire
async
¶
acquire(
count: float = 1.0,
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> RateLimitDecision
Spend count tokens, or return the denial with its retry hint.
The shared admission state is measured in the data store's own
clock domain: PG paths use statement_timestamp() and Redis
paths use Redis TIME inside their scripts, so callers on
nodes with divergent Python clocks are all measured against the
same bucket. The injected clock drives the memory backend only
(its single domain) and remains part of the public call shape,
the unified limiter contract shared with
:class:~taskq.ratelimit.sliding_window.SlidingWindow.
With rate_limit_pg_fallback_enabled (the default) the redis
backend fails over to Postgres on connection-family errors, and
the returned decision's backend names the store that
actually paid, the field refund dispatches on. A PG
row-lock timeout fails closed (a denial, never an exception,
never an admission); a store reply no honest write could have
produced raises :class:RateLimitStoreCorrupt.
Source code in src/taskq/ratelimit/token_bucket.py
refund
async
¶
refund(
decision: RateLimitDecision,
*,
count: float = 1.0,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> None
Return count tokens to the bucket that actually paid them.
Dispatches on decision's backend (see the comment in the
body for why not the bucket's own: a redis acquire during a
Redis outage may have spent its token in Postgres), so the
refund lands in the store whose quota was consumed. The memory
path re-adds under the bucket lock capped at capacity; the
Redis path runs the refund Lua script (elapsed refill plus
count, capped); the PG path rewrites the row under the same
bounded FOR UPDATE lock the acquire takes, and raises on
lock-budget exhaustion rather than silently reporting a lost
refund (for a fixed-quota bucket that loss is permanent).
clock is accepted and unused, a token-bucket refund needs no
timestamp; it stays in the signature because
RateLimitRegistry dispatches refund/peek/reset polymorphically
over both primitives with one fixed keyword block.
Source code in src/taskq/ratelimit/token_bucket.py
peek
async
¶
peek(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> RateLimitState
Source code in src/taskq/ratelimit/token_bucket.py
reset
async
¶
reset(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> None
Source code in src/taskq/ratelimit/token_bucket.py
get_redis_pool
async
¶
Yield a Redis client for the worker loop lifetime.
The return type is AsyncIterator[Any] rather than
AsyncIterator[redis.asyncio.Redis] so the DI system can introspect
the annotation without requiring the [redis] extra at runtime.
The actual yielded value is a redis.asyncio.Redis instance.
Raises :class:RuntimeError when settings.redis_url is None
(Redis not configured but a Redis-backed rate limiter was registered).
Raises :class:ImportError when the [redis] extra is not installed.
Source code in src/taskq/ratelimit/_provider.py
register_rate_limit_registry ¶
register_rate_limit_registry(
di_registry: ProviderRegistry,
rl_registry: RateLimitRegistry,
) -> None
Idempotent registration of the resolved RateLimitRegistry instance.
Registers the given :class:RateLimitRegistry as a Scope.LOOP value
so it is available at dispatch time via DI resolution. Skips when a
provider is already registered, a user pre-registered LOOP-scope value
provider wins (bootstrap resolution rule 2); after the bootstrap
kind/scope checks the DI-cached instance and the bootstrap
instance are provably the same object. When the module singleton is
resolved (the default), the DI-registered instance and
:data:taskq.ratelimit.registry.registry are the same object, both
paths observe identical state.
Source code in src/taskq/ratelimit/_provider.py
register_redis_pool ¶
Idempotent registration of the LOOP-scoped Redis pool factory.
Calls registry.register_factory(redis.asyncio.Redis, Scope.LOOP,
get_redis_pool) only when registry.has_provider(redis.asyncio.Redis)
is False, so user-supplied registrations take precedence.
Silently skips registration when the [redis] extra is not installed.
Source code in src/taskq/ratelimit/_provider.py
queue_concurrency_reservation_name ¶
Return the registry name for the fleet-wide concurrency cap of queue.
The taskq:global:queue: prefix namespaces these internally-generated
reservations apart from user-declared ones. A reservation only exists
in the registry for a queue if that queue's max_concurrent column
was set in the queues table (read from Postgres at worker startup);
there is no other config source. All workers sharing the schema
register and acquire against the same PG reservation_slots rows,
giving a true fleet-wide cap per queue.
Source code in src/taskq/ratelimit/registry.py
sync_rate_limit_buckets
async
¶
sync_rate_limit_buckets(
rl_registry: RateLimitRegistry,
pool: Pool,
*,
schema: str = "taskq",
) -> None
Publish every registered rate limit to rate_limit_buckets.
Each worker calls this at startup so the admin UI can discover
configured buckets from PG without depending on the in-memory
singleton being populated in the admin process. Keyed buckets
materialized lazily AFTER startup are published individually by the
acquisition path, see
:meth:RateLimitRegistry._resolve_rate_limit_name.
Uses ON CONFLICT DO NOTHING so concurrent workers and restarts
are idempotent. Only PG-backed primitives are written; memory-only
and log-style sliding windows (which have no PG backend) are skipped.
Source code in src/taskq/ratelimit/registry.py
sync_slots
async
¶
sync_slots(
reservations: list[ConcurrencyReservation],
pool: Pool,
*,
schema: str = "taskq",
timeout: float | None = None,
) -> SyncResult
Synchronise slot rows to match the registered reservation config.
For each reservation: insert missing slots (filling gaps from prior held-slot-preserving shrinks), delete excess free slots, and report held slots that could not be deleted.
timeout bounds the WHOLE pass, one connection acquire plus a
transaction of statements per reservation, so a call costs
O(reservations) round trips and a wedged store must not park the
caller past the bound. Raises :class:TimeoutError when it fires
(a reservation whose transaction already committed stays synced;
the pass is safe to re-run). None (the default) keeps the
unbounded shape for callers that manage their own deadline.
"Free" / "held" use the same definition as the acquire CTE: a slot row
with an EXPIRED lease is acquirable, hence deletable; only rows with a
live (unexpired) lease are reported as skipped_held. The anomalous
job_id NOT NULL / lease_expires_at NULL state (no code path
produces it, but the nullable schema permits it) is conservatively
reported as held rather than deleted, matching the in-memory table.
Treating expired-lease rows as held would let a dead worker's leaked slots
defeat a shrink indefinitely, the rows stay acquirable, so the old
larger cap would keep being honored. Deleting an expired-lease row is
safe: lease expiry is the design's abandonment signal (the acquire CTE
would hand the same slot to a new job anyway), and a late release by
the old holder is a harmless no-op (worker_id mismatch or missing
row). A row acquired concurrently with the delete is protected by its
row lock: the delete blocks, then re-evaluates its predicate against
the post-acquire row (job_id now set, lease in the future) and
skips it.
Source code in src/taskq/ratelimit/reservation.py
880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 | |