Add a batch scheduling API: call() + add_many()/replace_many()#443
Merged
chrisguidry merged 2 commits intoJul 3, 2026
Conversation
Docket's scheduling API was strictly one-execution-at-a-time: every
add()/replace() cost ~3 Redis round-trips (per-key lock SET, _schedule
EVALSHA, lock-release EVALSHA), so callers fanning out N schedules paid
N x 3 sequential round-trips on a single-threaded event loop.
docket.call(fn, when=..., key=...) mirrors the currying of add/replace
but returns a TaskCall spec; add_many()/replace_many() send a batch of
specs to Redis in pipelined chunks, costing O(N / chunk_size)
round-trips instead of O(N). Per-execution semantics are unchanged:
each task is individually strike-checked, deduplicated by key, and
scheduled atomically by the same _schedule Lua script, with the same
metrics counters; tracing emits one batch span with a count attribute
instead of N spans.
Details:
- @redis_script now returns a RedisScript object: __call__ is
unchanged, enqueue() queues the EVALSHA on a pipeline, and
enqueue_eval() sends the full source. Script parameters are
keyword-only, enforced at runtime (positional values were previously
silently dropped).
- On Redis Cluster, the batch path uses full-source EVAL: redis-py
blocks pipelined EVALSHA in cluster mode outright, and a node's
script cache can't be relied on across failover/resharding anyway.
Verified against a real local cluster; batch tests run un-skipped on
the cluster CI leg.
- Per-execution error capture: pipelines execute with
raise_on_error=False, and a Redis error for one task marks just that
execution Disposition.FAILED with the exception attached as
Execution.schedule_exception, so a caller always knows exactly which
tasks landed.
- chunk_size (default 1,000, None to disable) bounds client-side
buffering per round-trip and composes with error capture: a failure
in a later chunk leaves earlier chunks fully accounted for.
- The batch path deliberately skips the per-key {key}:lock taken by
the single-call path: the lock guards nothing beyond the single
atomic _schedule script call (no other code acquires it), and
pipelined invocations already serialize in Redis.
- Execution.schedule() is refactored into shared script-args and
reply-application halves used by both paths, so semantics can't
drift; _check_stricken/_record_schedule_metrics collapse the
strike/metrics triplication across add/replace/schedule/batch.
Verified at 100% coverage on the memory, Redis 8.0, and Redis 8.6
cluster legs.
CLOUD-4263
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The round-trip spy ignored burner's command-less __aexit__ re-execute with an `if replies:` -- a branch whose false arc only exists on the memory backend, failing the 100% branch-coverage gate on every real-Redis CI leg. A ternary increments identically but produces no branch arcs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
❌ 1 Tests Failed:
View the top 1 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Docket's scheduling API was strictly one-execution-at-a-time: every
add()/replace()costs ~3 Redis round-trips (per-key lock SET,_scheduleEVALSHA, lock-release EVALSHA), so callers that fan out N schedules pay N×3 sequential round-trips on a single-threaded event loop. Motivating case: a caller replacing up to 200 keyed tasks per poll — up to ~600 sequential Redis ops.Linear: CLOUD-4263
What
docket.call(...)mirrors the currying (and argument type-checking) ofadd/replacebut returns aTaskCallspec;add_many()/replace_many()send the batch to Redis in pipelined chunks — O(N / chunk_size) round-trips instead of O(N).Per-execution semantics are unchanged: each task is individually strike-checked, deduplicated by key, and scheduled atomically by the same
_scheduleLua script, with the same metrics counters. Tracing emits one batch span (docket.add_many/docket.replace_manywithdocket.batch.count) instead of N spans. There is no atomicity across the batch — by design.Notable details
@redis_scriptnow returns aRedisScriptobject instead of a wrapped function. Not out of any love for classes — the decorated script grew from one operation to three (__call__for the immediate EVALSHA,enqueue()for the pipelined EVALSHA,enqueue_eval()for cluster's full-source EVAL) plus two pieces of data callers need (luaforSCRIPT LOAD,sha). The closure version was already leaking under one extra:wrapper.__lua__ = luaneeded a# type: ignore, because pyright can't see attributes on a function type — so every_schedule.enqueue(...)call site would have needed a cast or a hand-maintained Protocol, and none of it would share the decorator's ParamSpec. As a genericRedisScript[P, R](viaConcatenate), all three entry points get the same fully-typed keyword contract as the declared function, checked by pyright with zero per-call-site annotations, and they share one_keys_and_argvso the KEYS/ARGV encoding (and the new positional-args guard) can't drift between the immediate and pipelined forms. It's a closure with names: no inheritance, no mutable state beyond what the old decorator closed over, and the hot call path is byte-for-byte the same strategy (pre-computed SHA, same encode loop, still bypassingSignature.bind).raise_on_error=False; a Redis error for one task marks just that executionDisposition.FAILEDwith the exception onExecution.schedule_exception, so callers always know exactly which tasks landed.chunk_size(default 1,000;Noneto disable) bounds client-side buffering per round-trip, and composes with error capture: a failure in a later chunk leaves earlier chunks fully accounted for.{key}:lockthe single path takes: the lock guards nothing beyond the single atomic script call (no other code acquires it) and pipelined invocations already serialize in Redis. Removing the vestigial lock from the single path is a possible follow-up, as is convertingAgenda.scatter()to useadd_manyinternally.Execution.schedule()was refactored into shared script-args/reply-application halves used by both paths, and_check_stricken/_record_schedule_metricscollapse the strike/metrics triplication inDocket.Testing
tests/test_batch_scheduling.py(dispositions, ordering, dedup within and across batches, strikes, replace semantics, wire-level round-trip counting, chunking, chunk validation, per-execution and cross-chunk error capture), plus batch counter tests, batch span tests, andRedisScriptenqueue/guard tests.🤖 Generated with Claude Code