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:
python extensions/natsio-schedules/examples/basic.py
Metadata
Release files for natsio-schedules 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| natsio_schedules-0.1.1.tar.gz | 25.3 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| natsio_schedules-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 53.5 kB
Release files / natsio_schedules-0.1.1.tar.gz
| Download URL | natsio_schedules-0.1.1.tar.gz |
|---|---|
| Size | 25.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cc0b755c113267f093dd336611b864b0267da2e5cba9445a15c349abda4515da
|
|
BLAKE2b-256 checksum How to use checksums |
ed08b0ef5b098794a2d35f4ccc7efe0ff2cdea9c071ea20a5f4eae605fce8a6e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logRelease files / natsio_schedules-0.1.1-py3-none-any.whl
| Download URL | natsio_schedules-0.1.1-py3-none-any.whl |
|---|---|
| Size | 28.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2194c674ec041e167d62a76150436c08ea56b05221dcfbfeff5bc3b4326eb153
|
|
BLAKE2b-256 checksum How to use checksums |
fa2c1787e0ac6729f65b1051ca3dd0033c487086cc43f5b4f92f78efdadd483b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logSecurity for natsio-schedules 0.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for natsio_schedules-0.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.