Coming from Inngest
This page maps the words you know onto Hyperspool’s, shows an event-driven workflow written here, and lists the behaviour you need to know before porting one.
The model
Section titled “The model”A workflow is a program the engine can run more than once: a bash
script calling spool commands, a Python script using the SDK, a YAML
pipeline or a graph. Each durable call (spool step, spool sleep,
spool wait event and the rest) is a checkpoint, recorded the first
time it completes. When a run resumes, the script runs again from the
top; calls with a checkpoint return their recorded result at once, and
execution carries on from the first call without one.
Events start runs through handlers. A handler is a workflow registered for an event type, and each matching event starts one run of it. You submit the workflow file and Hyperspool runs it on an agent: a managed environment we run, or an agent you run yourself. The run keeps its own copy of the file.
An example
Section titled “An example”An onboarding flow: welcome a new user, give them three days to create a project, then send tips or a nudge depending on whether they did.
email="$(python3 -c 'import json, os; print(json.load(open(os.environ["SPOOL_INPUT_FILE"]))["payload"]["email"])')"
spool step welcome -- echo "welcome mail to $email"spool sleep 1d
if spool wait event project.created --timeout 3d > /dev/null; then spool step tips -- echo "tips for new projects to $email"else spool step nudge -- echo "how to create a first project, to $email"fiRegister it for user.signup and send events:
spool handler set onboarding ./onboarding.sh --on user.signupspool event emit user.signup --key ada@example.com --payload '{"email":"ada@example.com"}'spool event emit user.signup --key bob@example.com --payload '{"email":"bob@example.com"}'
# a day later, Ada creates a projectspool event emit project.created --key ada@example.comThe --key is the event’s subject. A run takes the subject of the event
that started it, and its spool wait event then accepts only events with
that subject (or with none), so Ada’s project.created releases Ada’s
run and Bob’s run keeps waiting. With the durations shortened to
seconds, the two runs log:
welcome mail to ada@example.comtips for new projects to ada@example.comwelcome mail to bob@example.comerror: no "project.created" event arrived before the timeouthow to create a first project, to bob@example.comWhile a run sleeps or waits, its process has exited and it holds no
agent. Events and handlers covers handlers,
filters with --when, and subjects.
Vocabulary
Section titled “Vocabulary”| Inngest | Here | Notes |
|---|---|---|
| function | workflow | a script, a pipeline or a graph |
| event trigger | handler | spool handler set <name> <file> --on <type>; one run per matching event |
trigger expression (if) |
--when '<CEL>' |
filters before any run starts |
| event | event | a type, an optional subject (key) and a JSON payload |
event data |
payload | a script reads the event from $SPOOL_INPUT_FILE |
inngest.send() |
spool event emit |
from a terminal, a script or a graph step |
step.run() |
spool step |
step_fn in the Python SDK |
step.sleep() |
spool sleep <duration> |
holds no agent |
step.sleepUntil() |
spool sleep with a computed duration |
work out the number of seconds in a step, then sleep that long |
step.waitForEvent() |
spool wait event <type> --timeout <d> |
exits 124 on timeout; matched by subject. A pipeline’s wait_for_event: takes a when: filter |
step.invoke() |
spool call -w <name> |
runs a stored workflow as a child and prints its output |
step.sendEvent() |
spool event emit inside a run |
recorded, so a replay doesn’t publish twice |
| parallel steps | spool activity submit / activity wait |
or a graph’s fan_out; see fan out |
| fan-out to several functions | several handlers on one type | each starts its own run |
concurrency |
concurrency:, spool lock <group> |
one run, or one locked section, at a time per group; the group can be computed per key |
throttle |
throttle: |
limit, period, burst, and a key per tenant |
rateLimit |
throttle: with policy: drop |
runs over the rate are refused |
debounce |
a recipe | debounce a burst of events |
batchEvents |
a recipe | the batch example |
cancelOn |
cancel_on: |
on a pipeline or graph; matched by subject |
| cron trigger | schedule | spool schedule create --cron "0 0 9 * * MON": six fields, seconds first, --tz |
idempotency |
spool submit --idempotency-key, spool state add |
see handle each event once |
retries |
retry:, spool activity run --attempts |
see retry a flaky call |
NonRetriableError |
an exit code you don’t retry | exit_codes: on retry:, --retry-on-exit on an activity |
onFailure |
an if: failure() job, or a run.failed handler |
|
| run | run | <org>:<project>:run-<random> |
What behaves differently
Section titled “What behaves differently”A step is a command. spool step <name> -- <cmd> records the command’s
stdout and exit code. In Python, step_fn records a function’s return
value, which has to round-trip through JSON.
Step names repeat. A name is a place in the script, so a loop reaches
one name many times and each pass of the loop gets its own checkpoint.
Put the loop variable in --label for the run page. Three things stay
unique per run, because something refers back to them: an
activity submit name, a bare spool lock <group>, and a
state cas / state incr --op name.
The sequence of durable calls has to be the same on every pass. Code between them runs again each time, so read the time, random numbers and API responses inside a step and branch on what it recorded. A replay that reaches a different durable call than the first pass recorded at that position fails the run with a determinism error. When replay goes wrong shows how to trace one.
A failed step stays failed. Its failure is recorded like a success, and
a replay returns it. Nothing retries a single spool step for you: retry
inside the command, run the call as an activity with --attempts, or
put retry: on the job, which runs the whole job again with its
recorded steps cleared.
State reads aren’t recorded. spool state get reads the store on every
pass and sees other runs’ writes. spool state add, incr and cas
record their answers, which is what makes the debounce and claim recipes
work. See durable state.
A run keeps the definition it started with. Changing the workflow file or a handler leaves runs already in flight on the old code; new runs pick up the change. Commands the script calls on the agent are not pinned.
What we don’t have yet
Section titled “What we don’t have yet”- More than one running run per concurrency group. A group admits one run at a time.
- Debounce and batching as settings. Both are recipes built on state cells.
- Priority between queued runs. A concurrency queue is first in, first out.
- A published TypeScript SDK. Scripts in JavaScript call the
spoolcommands as subprocesses; see workflows in your language.
Durable patterns has more recipes: sagas, deadlines, approvals with escalation, polling.