Results and deduplication¶
The snippets on this page assume:
from uuid import UUID
from smallage import CollectingEnqueuer
from smallage.litestar import TaskRegistry
registry = TaskRegistry()
@registry.task
async def reindex(doc_id: UUID) -> None: ...
@registry.task
async def charge(invoice_id: UUID) -> None: ...
registry.bind({}, enqueuer=CollectingEnqueuer())
Results are asked for¶
Most work is enqueued and forgotten. Keeping an outcome for every job would be a key and a TTL spent on nobody, so a result is opt-in per job:
enqueue returns the job id whether or not a result was requested — that is
what an outcome is looked up by.
Waiting blocks rather than polls. The outcome is written to a key with a token pushed to a list beside it; a waiter that wakes puts the token back, so several waiters on one job all get it:
wait returns None on timeout.
Failures are recorded too¶
A job that exhausts its retries records the failure before it is buried. Otherwise a caller waiting on a job that will never finish blocks until its own timeout and learns nothing about why. A job that is merely being retried records nothing — it is not over, and the waiter should keep waiting.
result = await store.wait(job_id, timeout_s=30)
if result and not result.ok:
log.error("job failed: %s", result.error)
Only bytes travel as a result value. Anything richer is the application's own
encoding decision.
Deduplication¶
Delivery is at-least-once and no protocol change makes it otherwise, so an application that must not repeat a side effect needs a gate it controls:
The worker claims the key immediately before calling the handler. The check sits there because that is the only point a duplicate can still be stopped — by the time a worker has the job, both copies are already in the stream.
The key has two states, and its owner is the job id:
- claimed while the job is being attempted. The same job passes its own claim, so a retry after a failure, or a reclaim after its worker died, runs as it should. A different job with the same key is skipped and acknowledged, not failed.
- done once the handler has returned. Nothing passes it, including the job that wrote it: a worker that finished and died before its ack brings the same job id back, and that copy is skipped.
A job that ends in the dead letter queue releases its claim, so replaying it is not turned away for the rest of the window. The one gap left is a worker dying between the handler returning and the key being marked done: that job runs again. Narrowing it further takes a transaction shared with the side effect, which only the application has.
The window is WorkerConfig.dedup_ttl_ms, one day by default. Choose it to
cover the longest period over which a repeat would be wrong, not the longest a
job takes.