natsio-schedules¶
JetStream message schedules
(ADR-51)
for natsio: schedule-expression
builders, a scheduled-publish helper, and read/cancel operations over the
definitions a stream holds. Distribution natsio-schedules, imported as
natsio.schedules. Zero runtime dependencies beyond natsio. Pre-1.0, no
API-stability promises.
Requires nats-server 2.12+ for the feature itself, 2.14+ for cron time
zones and Nats-Schedule-Rollup. Verified against the pinned 2.14.3.
What it is¶
A stream created with allow_msg_schedules can hold schedule definitions:
ordinary stored messages carrying a Nats-Schedule header. The server
republishes each definition's body to its Nats-Schedule-Target on the
schedule — a one-shot delayed publish, a repeating interval, a cron expression,
or a periodic sample of another subject's latest message.
A definition's subject is its identity. The server stores it as a
Nats-Rollup: sub message, so there is exactly one schedule per subject and
re-publishing replaces it.
Generated messages are published inside the stream. A plain core-NATS subscription on the target subject will not see them (unless the stream has
republishconfigured). Consume them with a JetStream consumer.
Usage¶
from datetime import timedelta
import natsio
from natsio.schedules import (
HOURLY,
ScheduleStreamConfig,
after,
create_schedule_stream,
cron,
delivery_info,
every,
)
nc = await natsio.connect("nats://localhost")
js = nc.jetstream()
# `subjects` must cover the schedule subjects AND every target subject.
sched = await create_schedule_stream(
js, ScheduleStreamConfig(name="SCHED", subjects=["schedules.>", "orders.>"])
)
# One-shot: publish `orders.reminder` five minutes from now, then self-destruct.
await sched.create(
"schedules.orders.r1", after(timedelta(minutes=5)), target="orders.reminder", payload=b"ping"
)
# Repeating, with a TTL on each generated message.
await sched.create(
"schedules.heartbeat", every(timedelta(seconds=30)), target="orders.tick", ttl="5m"
)
# Cron (6 fields: sec min hour dom month dow), evaluated in a named zone.
await sched.create(
"schedules.report", cron("0 0 5 * * *"), target="orders.report", time_zone="Europe/Amsterdam"
)
await sched.create("schedules.hourly", HOURLY, target="orders.hourly")
# Subject sampling: republish the latest `sensors.raw` reading every minute.
await sched.create(
"schedules.sample", every("1m"), target="orders.sampled", source="sensors.raw"
)
# Inspect / enumerate / cancel.
entry = await sched.get("schedules.heartbeat")
print(entry.schedule, entry.target, entry.interval)
async for entry in sched.list("schedules.>"):
print(entry.subject, entry.schedule)
await sched.cancel("schedules.heartbeat")
On the consuming side:
consumer = await sched.stream.create_consumer(ConsumerConfig(filter_subject="orders.>"))
msg = await consumer.next()
info = delivery_info(msg) # None if this wasn't produced by a schedule
if info is not None:
print(info.scheduler) # "schedules.heartbeat" — the definition's subject
print(info.next_run) # next firing, or None ...
print(info.final) # ... when this was the last one (`Nats-Schedule-Next: purge`)
Cancelling atomically¶
ADR-51's "advanced" stop: remove the schedule only if a message on a different subject is persisted — the way to fire a delayed publish early without letting the schedule also fire it, or to record a cancellation where consumers can see it. CAS-gated on the definition's sequence, so a schedule that already fired is never "cancelled" twice.
entry = await sched.get("schedules.orders.r1")
await sched.stop_and_publish(
"schedules.orders.r1",
publish_to="orders.reminder", # or any other subject except the schedule's own
payload=b"sent early",
expected_schedule_seq=entry.sequence,
)
Without an explicit expected_schedule_seq the definition is read first and
its sequence used as the gate, so stopping a schedule that already fired (or
never existed) raises ScheduleNotFoundError instead of publishing the message
and cancelling nothing — the server accepts that no-op happily. Pass
require_existing=False for a one-round-trip, fire-and-forget stop.
cancel() is the plain version: it reads the definition, refuses subjects that
hold something other than a schedule, and purges only up to the message it
read. cancel_many() is not guarded — it is a raw wildcard purge and will
happily delete generated messages and ordinary traffic under the filter.
API¶
| Symbol | Purpose |
|---|---|
at(datetime) |
@at <RFC3339> — one-shot at an instant (tz-aware, UTC, whole seconds). |
after(timedelta) |
at(now + delay). |
every(timedelta \| str) |
@every <go-duration> — repeating, minimum 1s. |
cron(str) |
6-field cron, or a predefined @ alias. |
YEARLY / ANNUALLY / MONTHLY / WEEKLY / DAILY / MIDNIGHT / HOURLY |
The predefined aliases as Schedules. |
parse_schedule(str \| Schedule) |
Validate/classify an already-formed expression. |
format_go_duration / parse_go_duration |
Go time.Duration ↔ timedelta. |
create_schedule_stream(js, config) |
Create a schedule-capable stream, return its handle. |
schedules(js, name) |
Bind an existing one by name. |
schedules_from_stream(js, stream) |
Wrap an already-fetched Stream (no I/O; await optional). |
Schedules.create(subject, schedule, *, target, ...) |
Store/replace a definition. |
Schedules.get(subject) |
The stored ScheduleEntry. |
Schedules.list(subjects=None) |
Enumerate definitions (batch Direct Get, paged past the server's subject cap). |
Schedules.cancel(subject) |
Purge one schedule; loud if the subject held no definition. |
Schedules.cancel_many(filter) |
Unconditional wildcard purge — anything under the filter, schedule or not. |
Schedules.cancel_by_sequence(seq) |
Delete the definition by stream sequence. |
Schedules.stop_and_publish(...) |
Atomic stop + publish, CAS-gated on the definition by default. |
publish_schedule(js, ...) |
The same publish, without a handle. |
build_schedule_headers(...) |
Just the Nats-Schedule* header dict. |
delivery_info(msg) / is_scheduled(msg) |
Read the server's stamps off a delivered message. |
ScheduleEntry, ScheduleDelivery, ScheduleStreamConfig, Schedule |
Entities. |
natsio.schedules.headers |
Every ADR-51 header name as a constant. |
Errors¶
Everything roots at ScheduleError (a natsio.jetstream.JetStreamError), in
two branches:
- Local, pre-flight —
ScheduleExpressionError,ScheduleTargetError,ScheduleSourceError,ScheduleTimeZoneError,ScheduleTTLError,SchedulesNotEnabledError. All also subclassnatsio.errors.ConfigError, so they areValueErrors. (ScheduleNotFoundErrorcovers "no definition there".) - Server-reported — every ADR-51
err_code, bound to a typed error through the core'sregister_errorhook (registration happens onimport natsio.schedules). They are also plainAPIErrors, so existingexcept APIErrorcode keeps working.
err_code |
Error |
|---|---|
| 10186 | MirrorWithMsgSchedulesError |
| 10187 | SourceWithMsgSchedulesError |
| 10188 | MessageSchedulesDisabledError |
| 10189 | SchedulePatternInvalidError |
| 10190 | ScheduleTargetInvalidError |
| 10191 | ScheduleTTLInvalidError |
| 10192 | ScheduleRollupInvalidError |
| 10203 | ScheduleSourceInvalidError |
| 10212 | SchedulerInvalidError |
| 10223 | ScheduleTimeZoneInvalidError |
Wire contract¶
| Header | Set by | Value |
|---|---|---|
Nats-Schedule |
client | @at <RFC3339>, @every <duration>, 6-field cron, or an @ alias |
Nats-Schedule-Target |
client | concrete subject in the same stream (required) |
Nats-Schedule-Source |
client | concrete subject to sample; no wildcards |
Nats-Schedule-TTL |
client | Go duration or never; needs allow_msg_ttl |
Nats-Schedule-Time-Zone |
client | IANA name / UTC / Local; cron only |
Nats-Schedule-Rollup |
client | sub only |
Nats-Scheduler |
server | the schedule's subject |
Nats-Schedule-Next |
server | next firing (RFC3339), or purge |
Nats-TTL, Nats-Rollup |
server | mirrored from the Nats-Schedule-* pair |
Two things the table cannot show:
Nats-Schedule-Nextis stamped in the schedule's own time zone whenNats-Schedule-Time-Zoneis set (2026-07-23T02:56:46+05:30), not in UTC.delivery_infokeeps the offset, soScheduleDelivery.next_runis an awaredatetimein that zone — compare instants, not wall clocks.ScheduleEntry.headersholds the stored block only. Direct Get addsNats-Stream/Nats-Subject/Nats-Sequence/Nats-Time-Stamp(and, on batch replies,Nats-Num-Pending/Nats-Last-Sequence); those are stripped (TRANSPORT_HEADERS), so an entry reads the same fromget()andlist()and its headers can be handed straight back tocreate().
Pinned against nats.go jetstream/message.go (header constants),
jetstream/jetstream_options.go (WithScheduleAt / WithScheduleEvery /
WithScheduleCron / WithScheduleTTL / WithScheduleTimeZone value
formatting), jetstream/errors.go (err_codes) and ADR-51 itself; every
accept/reject boundary is additionally probed against the pinned 2.14.3 server
in TestGrammarParity. Note that Nats-Schedule-Rollup (ADR-51 rev 4) and the
@annually / @midnight aliases exist on the 2.14.3 server but not yet in
nats.go — for those the ADR and the server are the oracle.
Scope limits¶
- No local next-fire calculation. Cron/interval evaluation lives in the
server; this package never predicts when a schedule will run. Read the
server's own answer from
Nats-Schedule-Nexton a delivered message. - The cron validator is a pre-flight, not a re-implementation. It matches
the server on everything the parity suite covers (field count, ranges, names,
steps,
?,@aliases,0-6day-of-week, a leading*/?swallowing the rest of a range, and day-of-month/month pairs that can never occur) and is stricter in exactly one place: an emptyNats-Scheduleis rejected here, while the server accepts it and simply stores a normal message. Two known gaps in the other direction — cases the server rejects and this validator lets through, so they come back as anAPIErrorrather than a local one: a cron expression whose only firing is further out than the server's search horizon (e.g.0 0 0 29 2 *evaluated in the late 2090s), and anything else its evaluator dislikes. The server stays the authority. - No stream-lifecycle management beyond creation. Enabling
allow_msg_scheduleson an existing stream is a coreupdate_streamcall; this package will not do it implicitly. list()requiresallow_direct(set bycreate_schedule_stream). It is one batch Direct Get over the stream's subjects, filtered to messages that actually carryNats-Schedule. A single request cannot answer more thanMAX_SUBJECTS_PER_BATCH(1024) matching subjects — the server refuses the whole thing with413 Too Many Results— so above thatlist()reads the matching subjects fromSTREAM.INFOand fetches them page by page. That is transparent but not free: narrow the filter (list("schedules.>")) on streams that also carry high-cardinality target subjects to stay on the single-request path. Enumeration is complete or it raises; a truncated read is never returned as if it were the whole set.- Retention interactions are the operator's problem. ADR-51's
WorkQueue/Interestcaveats (a consumer ack can silently delete a schedule) are documented in the ADR, not enforced here;ScheduleStreamConfigdefaults toLimits, which is the recommended policy.
Example¶
A runnable script is at examples/basic.py — start a server with just server, then: