API reference¶
Core¶
smallage.core
¶
Transport, worker and scheduling core. Importing Litestar from here is forbidden.
ENVELOPE_VERSION = b'1'
module-attribute
¶
__all__ = ['ENVELOPE_VERSION', 'BrokerHandler', 'Codec', 'CollectingEnqueuer', 'ConfigurationError', 'CronJob', 'DeferredEnqueuer', 'EagerEnqueuer', 'Enqueuer', 'Envelope', 'FilePayloadStore', 'JsonCodec', 'MalformedEnvelope', 'PayloadMissing', 'PayloadStore', 'PayloadTooLarge', 'Pending', 'Record', 'RedisResultStore', 'RedisScheduler', 'RedisStreamsTransport', 'ResultStore', 'RetryPolicy', 'Scheduler', 'Sleeper', 'SmallageError', 'StreamTransport', 'TaskHandler', 'TaskResult', 'UnknownTask', 'WorkerConfig', 'WorkerStats', 'current_enqueuer', 'dlq_key', 'from_fields', 'run', 'run_with_signals', 'to_fields', 'worker_running']
module-attribute
¶
current_enqueuer = ContextVar('smallage_enqueuer', default=None)
module-attribute
¶
The enqueuer in force for this unit of work, if one was bound.
Set it and every enqueue inside the block routes through it, which is what lets publication know about the transaction without every call site being told.
Sleeper = Callable[[float], Awaitable[None]]
¶
BrokerHandler
¶
Bases: Protocol
Handles an entry this library did not write.
Broker mode gets the raw record: the payload is in somebody else's format, so there is no envelope to decode and no result to store.
Source code in src/smallage/core/protocols.py
Codec
¶
Bases: Protocol
Payload encoder/decoder. The plugin substitutes the app's msgspec pair.
Source code in src/smallage/core/protocols.py
CollectingEnqueuer
¶
Records what would have been enqueued, and runs nothing.
For asserting that a request scheduled the work it was supposed to, without dragging a worker or a Redis into the test.
Source code in src/smallage/core/testing.py
ConfigurationError
¶
Bases: SmallageError
Invalid configuration. Raised at construction, never from the worker loop.
CronJob
dataclass
¶
Source code in src/smallage/core/cron.py
payload = b'{}'
class-attribute
instance-attribute
¶
Encoded arguments. An empty object, not null: it decodes into the task's argument struct, and a task taking nothing still has one.
DeferredEnqueuer
¶
Holds jobs until something says the transaction went through.
Wrap the real enqueuer per unit of work, call flush from the commit hook
and discard from the rollback one.
Source code in src/smallage/core/deferred.py
active()
async
¶
Route every enqueue in this block through the buffer.
Binding only. Whether the block ends in flush or discard is the
caller's decision, because only the caller knows how the transaction
ended.
Source code in src/smallage/core/deferred.py
discard()
¶
enqueue(envelope, *, queue)
async
¶
Record the job. Nothing reaches Redis until flush.
flush()
async
¶
Publish everything buffered, oldest first, and forget it.
The buffer is cleared before publishing: a failure part way through must not leave jobs that would be published twice by the next flush.
Source code in src/smallage/core/deferred.py
EagerEnqueuer
¶
Runs each task inline, at the moment it is enqueued.
Nothing about retries, ordering or concurrency is reproduced -- that is the point. It keeps unit tests of application code free of infrastructure, and anything that depends on real queue behaviour belongs in an integration test.
Source code in src/smallage/core/testing.py
Enqueuer
¶
Bases: Protocol
Publishing seam.
Deferred publication (flushing on the SQLAlchemy after_commit hook) and the
eager mode used in application unit tests are implementations of this protocol,
not forks of the transport. The scheduler binds here too, so that it never has
to import a concrete transport.
Source code in src/smallage/core/protocols.py
Envelope
¶
Bases: Struct
Source code in src/smallage/core/envelope.py
dedup = None
class-attribute
instance-attribute
¶
Application-chosen key. At most one job per key runs within its window.
history = ()
class-attribute
instance-attribute
¶
One compact line per failed attempt, oldest first.
Bounded by the retry policy, and each line is truncated, so a job that keeps failing cannot grow its own entry without limit.
result_ttl_ms = None
class-attribute
instance-attribute
¶
Keep the outcome for this long. Absent means nobody is waiting for it.
FilePayloadStore
¶
Payloads as files under one directory.
Every worker must see the same directory -- a network volume, or a single host. A path local to one pod means the job runs wherever the file happens to be and fails everywhere else, so the reference records the path and reading it back from the wrong place says exactly that.
Source code in src/smallage/core/payloads.py
discard(reference)
async
¶
JsonCodec
¶
Default payload codec. The plugin layer substitutes the app's msgspec pair.
Source code in src/smallage/core/envelope.py
MalformedEnvelope
¶
Bases: SmallageError
A stream entry is missing a field the envelope requires.
PayloadMissing
¶
Bases: SmallageError
A record references a payload the store no longer has.
PayloadStore
¶
Bases: Protocol
Somewhere large arguments live instead of in Redis.
Redis holds the stream in memory, so a payload measured in megabytes is a direct route to an out-of-memory kill. Above a threshold the arguments go here and the entry carries a reference.
Source code in src/smallage/core/protocols.py
PayloadTooLarge
¶
Bases: SmallageError
Encoded arguments exceed the configured limit for an inline payload.
Pending
¶
Bases: Struct
One unacknowledged entry, as XPENDING describes it.
Source code in src/smallage/core/envelope.py
Record
¶
Bases: Struct
A stream entry as Redis returns it.
Broker mode consumes foreign payloads and never builds an Envelope, so the
transport hands back raw records and decoding is a separate step in the worker.
Source code in src/smallage/core/envelope.py
RedisResultStore
¶
Results in plain keys, with a list alongside so a waiter can block.
The list exists because polling for a result is the thing every queue gets asked to stop doing. A waiter blocks on it and puts the token back, so several waiters on one job all wake.
Source code in src/smallage/core/results.py
wait(job_id, *, timeout_s)
async
¶
Block until the job finishes, or give up. Returns None on timeout.
Source code in src/smallage/core/results.py
RedisScheduler
¶
Scheduled jobs held in a ZSET, with the payload beside it in a hash.
The entry lives in a hash rather than inside the ZSET member so payloads stay opaque bytes -- packing them into a member would mean encoding them twice.
Source code in src/smallage/core/scheduler.py
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | |
hold_leadership(token, *, ttl_ms)
async
¶
Take the lease, or extend it if it is already ours.
Extending is a compare-and-set: a worker that has already lost the lease must not be able to prolong the new holder's key.
Source code in src/smallage/core/scheduler.py
now_ms()
async
¶
Redis is the clock. Worker clocks drift apart; this one does not.
schedule_cron(jobs)
async
¶
Put the next occurrence of every job in the ZSET.
Idempotent by construction: the id encodes the instant, so a second leader doing the same work writes the same member.
Source code in src/smallage/core/scheduler.py
RedisStreamsTransport
¶
Stream operations for one queue.
Takes two clients on purpose: a blocking XREADGROUP occupies its connection
for the whole block window, and an ack or a liveness refresh queued behind it
makes a healthy worker look dead to its peers.
consumer must be unique per running worker. Redis groups entries in the
pending list by consumer name, and reclaim uses that name to tell its own
work from a peer's.
Source code in src/smallage/core/transport.py
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 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 370 371 372 373 374 375 | |
claim_dedup(key, *, owner, ttl_ms)
async
¶
Win the right to run this key, or report that someone already has.
The guarantee is at-least-once, so an application that must not repeat a side effect needs a gate it controls. This is that gate. The owner's own claim lets it back in, so a retry is not mistaken for a duplicate.
Source code in src/smallage/core/transport.py
clear_alive(entry_ids)
async
¶
Hand an entry over immediately instead of waiting out its TTL.
Source code in src/smallage/core/transport.py
dead_letter(record, *, reason, detail, times_delivered)
async
¶
Park an entry that will never succeed, keeping everything about it.
The original payload rides along untouched, so a fixed deployment can replay it without reconstructing anything.
Source code in src/smallage/core/transport.py
is_external(stream)
¶
lag()
async
¶
Depth from XINFO GROUPS. Redis reports NULL when it cannot reconcile.
Source code in src/smallage/core/transport.py
refresh_alive(entry_ids, *, ttl_ms)
async
¶
PEXPIRE rather than SET: it must not resurrect an already acked entry.
Source code in src/smallage/core/transport.py
sweep_consumers(*, min_idle_ms)
async
¶
Remove consumers that hold nothing and have gone quiet.
A name is derived per process start, so every deploy leaves one behind and Redis expires none of them. Deleting one that still owns pending entries would make them unclaimable, so the script checks and deletes in one step rather than trusting a reading taken a round trip earlier.
Deleting a live worker's idle consumer costs nothing -- Redis recreates it on the next read -- which is why the only hard condition is that it holds nothing.
Source code in src/smallage/core/transport.py
trim(*, retention_ms)
async
¶
Trim by time, floored at the oldest unacked entry.
MINID has the same hazard MAXLEN does once a pending entry is older than the retention window -- trimming it away would drop unacknowledged work.
Source code in src/smallage/core/transport.py
ResultStore
¶
Bases: Protocol
Where a job's outcome goes when somebody is waiting for one.
Source code in src/smallage/core/protocols.py
RetryPolicy
dataclass
¶
Source code in src/smallage/core/retry.py
delay_ms(attempt, *, rand=0.5)
¶
Backoff before retry number attempt.
rand is the caller's draw from [0, 1), kept as an argument so the
arithmetic stays pure and the spread stays testable. Jitter matters:
a batch that fails together would otherwise retry together forever.
Source code in src/smallage/core/retry.py
record_failure(history, attempt, error)
¶
Append one line about this failure, keeping the tail bounded.
attempt is the attempt that just failed, not the one about to run.
Truncating and capping matters: without it a job that keeps failing grows its own stream entry on every attempt.
Source code in src/smallage/core/retry.py
unknown_task_expired(*, age_ms)
¶
Scheduler
¶
Bases: Protocol
What the worker needs from a scheduler, and nothing more.
Source code in src/smallage/core/protocols.py
SmallageError
¶
StreamTransport
¶
Bases: Protocol
Everything the worker needs from a transport.
The worker binds here rather than to a concrete transport, which is what lets the consume loop and the supervisor loops be tested without a Redis at all.
Source code in src/smallage/core/protocols.py
TaskHandler
¶
TaskResult
¶
Bases: Struct
What a finished job left behind, for whoever asked to be told.
Source code in src/smallage/core/envelope.py
UnknownTask
¶
Bases: SmallageError
Eager mode was asked to run a task that is not registered.
WorkerConfig
dataclass
¶
Source code in src/smallage/core/worker.py
consumer_idle_ms = 60 * 60 * 1000
class-attribute
instance-attribute
¶
How long a consumer may hold nothing and say nothing before it is presumed gone and removed from the group.
A live worker touches the group at least every block_ms, so an hour is far
above anything a running process produces. Deleting one early is harmless --
Redis recreates it on the next read -- so the generous default costs nothing
but a slower cleanup.
recovery_interval_s = 1.0
class-attribute
instance-attribute
¶
Pause after a supervisor loop hits an error before it tries again.
WorkerStats
dataclass
¶
Live counters for one worker process.
Plain integers on one event loop: incremented between awaits, read whenever.
Source code in src/smallage/core/stats.py
dlq_key(namespace)
¶
Exported: reading the dead letter queue is an ordinary thing to want, and an application should not have to rebuild the key schema to do it.
from_fields(fields)
¶
Source code in src/smallage/core/envelope.py
run(transport, registry, config=None, *, scheduler, results=None, brokers=None, stats=None, sleep=anyio.sleep, shutdown=None, cron=())
async
¶
Source code in src/smallage/core/worker.py
run_with_signals(transport, registry, config=None, *, scheduler, results=None, brokers=None, stats=None, cron=())
async
¶
Run until SIGTERM or SIGINT; a second signal means now, not soon.
Source code in src/smallage/core/worker.py
to_fields(envelope)
¶
Source code in src/smallage/core/envelope.py
worker_running(transport, registry, config=None, *, scheduler, results=None, brokers=None, stats=None, cron=())
async
¶
Run a real worker for the duration of the block, then stop it cleanly.
Wrap it in a fixture for integration tests: leaving the block asks the worker to drain, so in-flight work finishes instead of vanishing mid-assertion.
Source code in src/smallage/core/testing.py
Litestar plugin¶
smallage.litestar
¶
Litestar integration layer: DI, CLI, serialization, health.
__all__ = ['QueueConfig', 'QueueHealth', 'QueuePlugin', 'Task', 'TaskRegistry', 'current_traceparent', 'queue_health']
module-attribute
¶
current_traceparent = ContextVar('smallage_traceparent', default=None)
module-attribute
¶
The traceparent of the job being executed, for the duration of the handler.
Read it to attach the task's span to the request that enqueued it. It is a context variable rather than an argument so that a task signature stays about the task.
QueueConfig
dataclass
¶
How this application talks to its queue.
There is deliberately no string path to the app object here. Requiring
"module:app" in a constructor pushes an import-time ordering problem onto
every user; the CLI already has the application it was invoked with.
Keyword-only: fields are grouped by what they configure rather than appended, so a new one lands mid-class and positional order was never a contract worth keeping.
Source code in src/smallage/litestar/config.py
brokers = field(default_factory=dict)
class-attribute
instance-attribute
¶
Streams somebody else writes, and what handles each of them.
One field rather than a list of streams beside a dictionary of handlers: a stream with no handler is read and dropped, a handler for a stream nobody subscribes to never runs, and two fields that must agree eventually will not.
consumer = None
class-attribute
instance-attribute
¶
The worker's consumer name, or None to derive one from consumer_prefix.
Must be unique per running worker: Redis groups pending entries by consumer
name, and reclaim uses that name to tell a worker's own work from a peer's.
A literal here is shared by every replica; take it from the environment.
--consumer sets this, which is how the name survives being settled before
the application's lifespan opens the queue.
fairness_every = 10
class-attribute
instance-attribute
¶
Passes between giving the lowest-priority queue first refusal. 0 for strict priority, and the starvation that comes with it.
max_payload_bytes = 128 * 1024
class-attribute
instance-attribute
¶
Refused above this when no payload store is configured.
payloads = None
class-attribute
instance-attribute
¶
Where arguments above offload_over_bytes go. Without one, the transport
refuses an oversized record rather than dropping it quietly.
run_app_lifespan = True
class-attribute
instance-attribute
¶
Whether a worker process enters the application's lifespan.
On by default, so a dependency that closes over something the lifespan opens resolves to an opened one in a worker as well as in a web process. Decline it when the lifespan does work that belongs to a web process -- starting a scheduler, warming a cache, claiming a lease -- which would otherwise happen in every worker replica.
thread_limit = 20
class-attribute
instance-attribute
¶
Threads for synchronous tasks. The asyncio default is a silent bottleneck.
traceparent = no_traceparent
class-attribute
instance-attribute
¶
Supplies the current W3C traceparent, so a task can join the request's trace.
QueueHealth
dataclass
¶
Source code in src/smallage/litestar/health.py
healthy
instance-attribute
¶
A real field, not a property: a probe reads the response, not the object.
lag
instance-attribute
¶
Depth from XINFO GROUPS. None when Redis cannot reconcile its counters.
stats
instance-attribute
¶
This process's own counters. All zero in a web process, which runs no tasks.
QueuePlugin
¶
Bases: InitPlugin, CLIPlugin
Wires the queue into an application, and the application into the CLI.
The plugin is itself the enqueuer the registry binds to, which is how tasks can be declared at import time while their Redis connections are opened by the application lifespan.
Source code in src/smallage/litestar/plugin.py
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | |
stats = WorkerStats()
instance-attribute
¶
Counters for this process, served by the health endpoint.
connected(*, consumer=None)
async
¶
Open the queue's connections for the duration of the block.
Used by the application lifespan and by the worker command alike, so a worker and a web process reach Redis in exactly the same shape.
The consumer name comes from config.consumer unless a caller opening
the queue itself passes one: a worker that enters the application's
lifespan never opens the queue, so it has nowhere to pass an argument.
Source code in src/smallage/litestar/plugin.py
health()
async
¶
Everything a readiness probe needs, for whoever is serving one.
The plugin registers no route of its own: which path a probe lives on,
whether it is public, and what else belongs in the same response are the
application's to decide, and a worker has no server to answer on at all.
QueueHealth.healthy is the field to turn into a status code -- a probe
reads the code, not the body, and answering 200 while unhealthy is a
probe that can never fail.
Source code in src/smallage/litestar/plugin.py
Task
¶
What @registry.task gives back.
Calling it runs the function directly, with its own signature intact, which
is what keeps type checking useful. enqueue takes the payload parameters
only -- the injected ones come from the application at execution time.
Source code in src/smallage/litestar/registry.py
enqueue(*, result_ttl_ms=None, dedup=None, **payload)
async
¶
Queue the task and hand back its job id.
The id is what a result is looked up by, so it is returned even when nobody asked for one.
Source code in src/smallage/litestar/registry.py
TaskRegistry
¶
Tasks an application declares, and their execution once bound to it.
Source code in src/smallage/litestar/registry.py
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 | |
bind(dependencies, *, enqueuer, type_encoders=None, type_decoders=None, traceparent=no_traceparent, results=None, payloads=None, thread_limit=DEFAULT_THREAD_LIMIT, offload_over_bytes=DEFAULT_OFFLOAD_OVER_BYTES)
¶
Settle every task against the application. Failures land here, on boot.
Source code in src/smallage/litestar/registry.py
execute(envelope)
async
¶
Run a task from its stream entry: decode, inject, call.