Skip to main content

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 republish configured). 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.Durationtimedelta.
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-flightScheduleExpressionError, ScheduleTargetError, ScheduleSourceError, ScheduleTimeZoneError, ScheduleTTLError, SchedulesNotEnabledError. All also subclass natsio.errors.ConfigError, so they are ValueErrors. (ScheduleNotFoundError covers "no definition there".)
  • Server-reported — every ADR-51 err_code, bound to a typed error through the core's register_error hook (registration happens on import natsio.schedules). They are also plain APIErrors, so existing except APIError code 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-Next is stamped in the schedule's own time zone when Nats-Schedule-Time-Zone is set (2026-07-23T02:56:46+05:30), not in UTC. delivery_info keeps the offset, so ScheduleDelivery.next_run is an aware datetime in that zone — compare instants, not wall clocks.
  • ScheduleEntry.headers holds the stored block only. Direct Get adds Nats-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 from get() and list() and its headers can be handed straight back to create().

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-Next on 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-6 day-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 empty Nats-Schedule is 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 an APIError rather 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_schedules on an existing stream is a core update_stream call; this package will not do it implicitly.
  • list() requires allow_direct (set by create_schedule_stream). It is one batch Direct Get over the stream's subjects, filtered to messages that actually carry Nats-Schedule. A single request cannot answer more than MAX_SUBJECTS_PER_BATCH (1024) matching subjects — the server refuses the whole thing with 413 Too Many Results — so above that list() reads the matching subjects from STREAM.INFO and 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 / Interest caveats (a consumer ack can silently delete a schedule) are documented in the ADR, not enforced here; ScheduleStreamConfig defaults to Limits, 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

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

natsio_schedules-0.1.0.tar.gz (23.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

natsio_schedules-0.1.0-py3-none-any.whl (28.2 kB view details)

Uploaded Python 3

File details

Details for the file natsio_schedules-0.1.0.tar.gz.

File metadata

  • Download URL: natsio_schedules-0.1.0.tar.gz
  • Upload date:
  • Size: 23.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for natsio_schedules-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f6aff78a416feeb5d4da175c0f940ee4c7080da92f3afe8cca4ca3184568a0d1
MD5 4bfe6db82c7869bb9ff4b0196818200b
BLAKE2b-256 5e05c082a6f373dab9e956dae1fd4813ebac4360aea6c587f8fa320b36e08d56

See more details on using hashes here.

Provenance

The following attestation bundles were made for natsio_schedules-0.1.0.tar.gz:

Publisher: release-extension.yml on corruptmane/natsio

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file natsio_schedules-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for natsio_schedules-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 298f7074937f1220ec086b0612563067395a5b92c273caf43e0dc26fffef6ed8
MD5 557aadc9d498cf5289e9426b241ab30c
BLAKE2b-256 ef65a02aaf404ae5564602f73200b4a29bdbbc3bea9fd03d37d708f257f5f739

See more details on using hashes here.

Provenance

The following attestation bundles were made for natsio_schedules-0.1.0-py3-none-any.whl:

Publisher: release-extension.yml on corruptmane/natsio

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page