Jobs & Realtime¶
Background job queues (in-memory, synchronous, and Redis-backed), the in-app scheduler, WebSockets, and outgoing mail.
queue ¶
Background jobs: run work off the request/response cycle.
The default queue is an asyncio.Queue living in this process's memory,
drained by a worker task that starts lazily on the first job you push — no
framework wiring required, and it needs no ASGI lifespan hook to work
correctly in tests or under a real server alike.
That also means: a job pushed but not yet run is lost if the process
crashes or restarts. Fine for non-critical background work (a welcome
email, warming a cache); a real limitation for anything you'd be upset to
silently lose (payment capture, anything that must survive a crash).
:class:RedisQueue is the durable, opt-in alternative — the same
trade-off as RateLimiter and Cache, see docs/queues.md.
Job ¶
Bases: ABC
A unit of background work. Subclass and implement :meth:handle.
Jobs are plain Python objects (dataclasses are a natural fit) — the default queue never serializes them, so constructor arguments can be anything, not just JSON-safe values::
@dataclass
class SendWelcomeEmail(Job):
email: str
name: str
async def handle(self) -> None:
...
handle() can also declare type-hinted parameters beyond self to
have them resolved from the container that dispatched the job (the same
autowiring Container.call uses everywhere else)::
async def handle(self, mailer: Mailer) -> None:
await mailer.send(...)
Queue ¶
Bases: ABC
Accepts jobs to run in the background.
container, if given, is used to autowire any extra type-hinted
parameters on a job's handle() — see :class:Job. Without one,
handle() is called with no arguments beyond self.
Source code in src/zeython/queue.py
push
abstractmethod
async
¶
InMemoryQueue ¶
Bases: Queue
Runs jobs on a background asyncio task in this process.
The worker starts on the first :meth:push and keeps running for the
life of the event loop. Failed jobs are retried up to
job.max_attempts times, with each failure logged; :meth:close is
available for a clean shutdown (mainly useful in tests, to avoid
"task was destroyed but it is pending" warnings at interpreter exit).
delay schedules a job to be enqueued after a wait rather than
immediately, via a tracked background task -- also cleaned up by
:meth:close.
Source code in src/zeython/queue.py
join
async
¶
close
async
¶
Cancel the background worker task and any pending delayed pushes.
Source code in src/zeython/queue.py
SyncQueue ¶
Bases: Queue
Runs jobs immediately and synchronously — no background task, no retries.
Meant for tests and local dev: failures raise straight through push()
instead of being caught and logged, so you see them immediately rather
than digging through logs. delay is ignored -- a synchronous,
immediate-execution queue has nothing to schedule against.
Source code in src/zeython/queue.py
RedisQueue ¶
RedisQueue(
url: str,
*,
container: Container | None = None,
queue_name: str = "default",
prefix: str = "zeython:queue:",
)
Bases: Queue
A Redis-backed durable queue: a job pushed here survives a crash or
restart of the process that pushed it, and is processed by a separate
worker process (zeython queue work) rather than a background task
inside the web server. Requires the redis extra
(pip install zeython[redis]).
Jobs must be @dataclass subclasses of :class:Job -- see
:func:_serialize_job. Failed attempts are retried with capped
exponential backoff (2, 4, 8, ... up to 60 seconds between attempts);
a job that exhausts max_attempts is moved to a failed-jobs list
instead of being dropped, so nothing that couldn't be processed is
silently lost -- see :meth:failed_jobs.
All keys are namespaced under prefix + queue_name (default
"zeython:queue:default:") — safe to point at a Redis instance
shared with other subsystems (cache, rate limiting, sessions).
Source code in src/zeython/queue.py
failed_jobs
async
¶
Every job that exhausted its retries, most recently failed first.
run_worker
async
¶
Block, processing jobs from this queue until shutdown is set
(or forever, if none is given) -- what zeython queue work runs.
Reclaims any delayed/retry jobs whose wait has elapsed on every
poll, then blocks (up to poll_interval seconds) for the next
ready job via Redis's own BRPOP rather than busy-polling.
Source code in src/zeython/queue.py
QueueServiceProvider ¶
Bases: ServiceProvider
Binds a :class:Queue into the container.
.env: QUEUE_DRIVER —
memory(default) — :class:InMemoryQueue, a background task in this process. Jobs are lost on crash/restart.sync— :class:SyncQueue, runs jobs immediately in-line; useful for tests/local dev.redis— :class:RedisQueue, durable, processed by a separatezeython queue workprocess. RequiresREDIS_URLand theredisextra.QUEUE_NAMEpicks the queue (defaultdefault) -- useful if you want a dedicated worker/priority lane for, say, emails vs. report generation.
Source code in src/zeython/providers.py
dispatch
async
¶
Queue job to run in the background rather than blocking this request.
Uses whichever Queue is bound in the container — :class:InMemoryQueue
by default, :class:SyncQueue if QUEUE_DRIVER=sync, :class:RedisQueue
if QUEUE_DRIVER=redis (see :class:QueueServiceProvider). Pass
delay to run the job after a wait instead of as soon as a worker is
free. Outside of a request, push directly to a resolved queue instead:
await app.container.make(Queue).push(job).
Source code in src/zeython/queue.py
schedule ¶
In-app task scheduling: recurring jobs defined in code (and therefore version-controlled, reviewed, and deployed alongside the app) instead of scattered across a server's crontab where nobody remembers what runs and why.
Schedule holds a list of ScheduledEvents; zeython schedule run
-- meant to be invoked once a minute by a single cron entry (or a sidecar
loop container, see docs/scheduling.md) -- checks which are due this
minute and runs them. Nothing here polls or sleeps on its own: the actual
"once a minute" cadence is still driven by whatever calls zeython
schedule run, the same way Laravel's own schedule:run works.
ScheduledEvent ¶
ScheduledEvent(
name: str,
callback: Callable[..., Awaitable[None]],
*,
container: Container | None = None,
)
One recurring task: a callback plus a cron expression saying when
it's due. Built fluently off :meth:Schedule.call; every builder
method returns self so calls chain::
schedule.call(send_daily_digest).daily_at("07:00")
Source code in src/zeython/schedule.py
cron ¶
daily_at ¶
"HH:MM", 24-hour, e.g. daily_at("07:30").
weekly ¶
monthly ¶
without_overlapping ¶
Skip a run if a previous one started within the last
for_seconds -- for a task that occasionally runs longer than
its own interval, or one you never want two copies of running
against each other.
Implemented via the container's bound RateLimiter as a "run at
most once per window" gate, keyed by this event's name -- a
time-based window, same trade-off Laravel's own
withoutOverlapping() makes: the lock expires after
for_seconds regardless of whether the previous run actually
finished, it doesn't track "is it still running" directly.
Requires a shared RateLimiter backend (RedisRateLimiter)
to do anything in the normal case. zeython schedule run is a
fresh process every time cron invokes it -- the default
InMemoryRateLimiter's lock lives in that process's memory and
is gone the instant it exits, so back-to-back CLI invocations never
see each other's lock at all. See docs/scheduling.md.
Source code in src/zeython/schedule.py
Schedule ¶
The registry schedule.py builds against: schedule.call(fn).daily().
Source code in src/zeython/schedule.py
call ¶
Register callback (any async function; type-hinted params
beyond the ones you pass are autowired from the container, same as
a Job's handle()) and return the :class:ScheduledEvent to
set its frequency on.
Source code in src/zeython/schedule.py
run_due
async
¶
Run every event due at at (default: now), returning the
ones that were due. An event that raises is logged and doesn't
stop the rest from running -- what zeython schedule run calls.
Source code in src/zeython/schedule.py
ScheduleServiceProvider ¶
Bases: ServiceProvider
Binds a process-wide :class:Schedule singleton and imports
schedule.py from the project root for its side effect of
registering events on it -- the same convention
:class:~zeython.providers.RouteServiceProvider uses for
routes/web.py.
Not registered by default -- add it once you have scheduled tasks to define::
# main.py
app.register(ScheduleServiceProvider(app))
Source code in src/zeython/schedule.py
cron_matches ¶
Whether a standard 5-field cron expression (minute hour day month
weekday) matches at. Supports *, single values, comma lists
(1,3,5), ranges (1-5), and step values (*/15, 1-10/2).
The weekday field is 0-6 (0 = Sunday) only -- unlike some
cron implementations, 7 is not accepted as an alias for Sunday.
Source code in src/zeython/schedule.py
websockets ¶
Real-time WebSocket support, built directly on Starlette's ASGI-native WebSocket handling -- no separate server, no extra process.
Router.websocket(...)/Application.websocket(...) registers a
handler the same way @app.get(...) does for HTTP. :class:WebSocketHub
is the process-local "broadcast to everyone connected" registry a chat
window, a live dashboard, or any other push-to-many feature needs;
:class:RedisWebSocketHub is the same thing backed by Redis pub/sub, for
a broadcast to reach every worker process, not just this one.
WebSocketHub ¶
WebSocketHub(
*,
allowed_origins: Iterable[str] | None = None,
max_connections_per_ip: int | None = None,
)
Tracks connected WebSocket clients and broadcasts messages to them.
Process-local: a message only reaches clients connected to this
process. Fine for a single worker; running more than one means each
worker has its own, disjoint set of connections, so a broadcast only
reaches whichever fraction of clients happen to be on the same worker
-- back this with a pub/sub backend (Redis's PUBLISH/SUBSCRIBE
is the usual choice) once that matters. See docs/websockets.md.
A WebSocket handshake is a plain HTTP request that carries cookies
automatically -- without an origin check, any site can open a
connection here using a logged-in visitor's session (cross-site
WebSocket hijacking). Pass allowed_origins to guard against that;
left unset, every origin is accepted (matches every earlier release --
opt in once you actually serve browser clients over more than one
origin you don't control).
Nothing stops a single client from opening hundreds of connections --
each one costs a slot in this hub's memory and a slot in the pool of
connections a broadcast iterates, so a runaway or malicious client can
degrade the service for everyone else. Pass max_connections_per_ip
to cap it; left unset, there's no limit (matches every earlier
release).
Source code in src/zeython/websockets.py
connect
async
¶
Accept the handshake and start tracking this connection.
Returns False (after closing the connection, without ever
accepting it) if allowed_origins is configured and this
handshake's Origin header doesn't match one of them (close code
4403), or if max_connections_per_ip is configured and this
client already has that many connections open (close code 4429).
Check the return value and bail out if it's False -- proceeding
to receive_text()/etc. on a connection that was never accepted
raises::
if not await hub.connect(websocket):
return
Source code in src/zeython/websockets.py
disconnect ¶
Stop tracking a connection -- call this from a finally block
once its handler loop ends, however it ends.
Source code in src/zeython/websockets.py
broadcast
async
¶
Send message to every connected client except exclude
(typically the sender, when echoing a chat message back to everyone
else).
A send failing -- a client that's disconnected but hasn't reached
this hub's disconnect() yet -- doesn't stop the broadcast
reaching everyone else; that connection is just dropped from the
hub instead.
Source code in src/zeython/websockets.py
RedisWebSocketHub ¶
RedisWebSocketHub(
url: str,
*,
channel: str = "zeython:websockets:broadcast",
allowed_origins: Iterable[str] | None = None,
max_connections_per_ip: int | None = None,
)
Bases: WebSocketHub
A :class:WebSocketHub whose broadcasts reach every process, not
just this one -- the distributed backend the base class's docstring
names. Requires the redis extra (pip install zeython[redis]).
Every process running a RedisWebSocketHub against the same Redis
PUBLISHes each broadcast to a shared channel and SUBSCRIBEs to that
same channel, relaying whatever it receives to its own locally
connected clients -- so a message broadcast from any one worker
reaches clients connected to every worker, this one included, with no
special-casing needed (each published message is tagged with this
instance's own id so it doesn't relay its own broadcast back to
clients that already got it directly from :meth:broadcast).
The listener starts automatically on this hub's first :meth:connect
call -- there's no ASGI lifespan hook to start it any earlier, and
nothing needs the listener running before the first connection exists
anyway. Call :meth:stop to shut it down cleanly (mainly useful in
tests; a real process just exits, taking the task with it).
Doesn't attempt to reconnect if the Redis connection drops mid-stream -- the listener task logs the error and stops; broadcasts stop reaching other processes (and this process stops relaying theirs) until the process is restarted. The same accepted trade-off as the other Redis-backed classes here, none of which implement retry logic: simple and predictable beats a hand-rolled reconnect loop that becomes its own source of bugs.
Source code in src/zeython/websockets.py
stop
async
¶
Cancel the background listener task.
Source code in src/zeython/websockets.py
broadcast
async
¶
Deliver to this process's own connections immediately (respecting
exclude, which only ever refers to a connection on this
process -- another process can't have the same object), then
publish so every other process's listener relays it to theirs.
Source code in src/zeython/websockets.py
WebSocketHubServiceProvider ¶
Bases: ServiceProvider
Binds a process-local :class:WebSocketHub into the container.
WEBSOCKET_ALLOWED_ORIGINS -- comma-separated, e.g.
https://example.com,https://app.example.com -- restricts handshakes
to those origins (see :class:WebSocketHub's cross-site hijacking
note). Unset by default, matching every earlier release; set it once
real browser clients are involved and you're not deliberately serving
other origins too.
WEBSOCKET_MAX_CONNECTIONS_PER_IP -- caps concurrent connections
from a single client (see :class:WebSocketHub's resource-exhaustion
note). Unset by default, matching every earlier release.
For a broadcast that reaches every worker process/machine, not just
this one, bind :class:RedisWebSocketHub directly instead of
registering this provider::
app.container.singleton(WebSocketHub, lambda: RedisWebSocketHub(config.get("redis.url")))
See docs/redis.md.
Source code in src/zeython/providers.py
mail ¶
Outbound email: a small Mailer interface, a log-only default so
zeython new works with zero mail configuration, and an SMTP backend for
when you actually have credentials.
Message
dataclass
¶
Message(
to: str | list[str],
subject: str,
body: str,
html: str | None = None,
from_address: str | None = None,
)
An email to send. to accepts a single address or a list.
LogMailer ¶
Bases: Mailer
Writes the email to your app's logs instead of sending it.
The default (MAIL_DRIVER=log), so a fresh zeython new project
can dispatch mail-sending jobs immediately without SMTP credentials.
Switch to :class:SmtpMailer (MAIL_DRIVER=smtp) once you have real
ones. See docs/mail.md.
SmtpMailer ¶
SmtpMailer(
*,
host: str,
port: int,
username: str | None,
password: str | None,
use_tls: bool,
default_from: str,
)
Bases: Mailer
Sends real email over SMTP, via the stdlib (no third-party dependency).
smtplib is blocking, so :meth:send runs it on a worker thread
(asyncio.to_thread) rather than blocking the event loop.
Source code in src/zeython/mail.py
MailServiceProvider ¶
Bases: ServiceProvider
Binds a :class:Mailer into the container from .env.
MAIL_DRIVER—log(default) orsmtpMAIL_HOST,MAIL_PORT(default587)MAIL_USERNAME,MAIL_PASSWORDMAIL_ENCRYPTION—tls(default) ornoneMAIL_FROM_ADDRESS(defaultno-reply@example.com)