Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Cancel On Event

cancelOn declares a list of cancel specs at function config time. Ironflow auto-cancels in-flight runs whose recorded match value equals an incoming event’s extracted JSON path value. No cancelRun() calls in handler code, no listener bookkeeping — the engine wires the listener at run start and tears it down at run end.

Use it when:

  • A user can cancel a long-running workflow by emitting a *.cancelled event (order checkout, deployment pipeline, batch job).
  • A downstream system signals “stop this work” with a domain event (shipment.aborted, tenant.suspended).
  • A run should abort the moment a sibling workflow publishes a “no longer applicable” signal.
emit order.placed{orderId: "ord-42"}
│
▼
┌─────────────────────────┐
│ run starts │ atomic insert:
│ cancel_on_specs persist │ - run row
└────────────┬────────────┘ - one cancel_on_spec per cancelOn entry
│
▼
step.run("charge"…)
step.run("ship"…)
│
┌────────────┴────────────────────┐
│ meanwhile… │
│ emit order.cancelled{orderId: │
│ "ord-42"} │
└────────────┬────────────────────┘
│
▼
scheduler matches event.orderId
against cancel_on_specs index
│
▼
run cancelled — cleanly,
at the next step boundary

At run start, the engine extracts each spec’s match value from the triggering event’s payload (e.g. event.data.orderId = "ord-42") and persists one cancel_on_specs row per spec, in the same transaction as the run row. When any future event in the same environment matches a spec’s (event_name, match_path, match_value) tuple, the scheduler fires CancelRunVersionedWithCause against the run via the same compare-and-swap path as imperative cancelRun(). Concurrent cancellers (parallel events, user cancel, scheduler match) all converge on a single terminal transition. A loser that hits ErrVersionConflict re-reads the run and retries; if the run is already terminal, the retry is a no-op.

Resuming a failed run. Resume restores cancelOn from the function version that created the run and resolves match values from the run’s input. A later function registration does not replace those rules. The restored rules are durable before the run becomes executable, so they survive restart. If publishing the resume fails, reverting that generation also removes its restored rules. A delayed publication failure cannot undo a later resume. Stale live matches, buffer replays and history rescans cannot cancel a newer generation. Repeated write contention is retried within a bounded budget and then repaired by durable reconciliation.

Restored rules accept only events with a durable storage timestamp strictly later than the resume transaction’s activation time. Equal timestamps are excluded; the clocks have finite precision. This compares timestamps, not commit order: an event stamped before activation remains ineligible even if it commits later. An unavailable timestamp is read from the stored event; a missing or zero timestamp cannot cancel the resumed generation. Original run-start rules keep their existing replay window.

OR semantics across specs. Multiple specs on the same function are independent triggers. Any matching event fires the cancel.

Tenant isolation. Spec rows carry environment_id. The match query is WHERE event_name = ? AND environment_id = ? AND match_path = ? AND match_value = ?. Tenant A’s order.cancelled cannot match Tenant B’s run.

import { ironflow } from "@ironflow/node";
const processOrder = ironflow.createFunction(
{
id: "process-order",
triggers: [{ event: "order.placed" }],
cancelOn: [
{ event: "order.cancelled", match: "orderId" },
],
},
async ({ event, step }) => {
await step.run("charge", () => chargeCard(event.data));
await step.run("ship", () => createShipment(event.data));
return { ok: true };
}
);
import "github.com/sahina/ironflow-go/ironflow"
var ProcessOrder = ironflow.CreateFunction(ironflow.FunctionConfig{
ID: "process-order",
Triggers: []ironflow.Trigger{{Event: "order.placed"}},
CancelOn: []ironflow.CancelOnConfig{
{Event: "order.cancelled", Match: "orderId"},
},
}, func(ctx ironflow.Context) (any, error) {
// ...
return nil, nil
})
from ironflow.worker import function
@function(
id="process-order",
triggers=[{"event": "order.placed"}],
cancel_on=[{"event": "order.cancelled", "match": "orderId"}],
)
async def process_order(ctx):
await ctx.step.run("charge", lambda: charge_card(ctx.event.data))
await ctx.step.run("ship", lambda: create_shipment(ctx.event.data))
return {"ok": True}

A processOrder run started by order.placed{orderId: "ord-42"} cancels when any subsequent order.cancelled event in the same environment carries orderId = "ord-42". A order.cancelled event with a different orderId (or no orderId) does nothing.

Each entry in the cancelOn array:

FieldTypeRequiredDescription
event (TS, Python) / Event (Go)stringyesEvent name to listen for. Must be non-empty. Case-sensitive, must match the emitted event name exactly.
match (TS, Python) / Match (Go)stringyesJSON path into the event’s data field. Resolved against the triggering event at run start, then matched against incoming candidates. Supports nested paths (order.id, customer.organization.tenantId). Same extraction rules as debounce.key. Unlike concurrency.key, a bare key gets no data. fallback.

The SDK rejects empty event, empty match, and duplicate specs (same event + match pair) at createFunction time so misconfigurations fail fast at registration, not at runtime.

v1 is equality only. The extracted value from the cancel event must equal the value recorded at run start. Comparison is string-based: internal/eventpath.Extract uses gjson scalar string serialization (via gjson.GetBytes(...).String()) on both sides, so 42 (number) and "42" (string) collapse to the same "42" and match. Strings, numbers, booleans, and null all match by stringified value. If you need strict typing across the boundary, project the value to a canonical string at emit time on both sides (e.g. always emit orderId as a string).

Path missing. If the cancel event’s payload lacks the match path (e.g. spec is match: "orderId" but event.data has no orderId key), the event is ignored. No match, no cancel.

Path missing on the triggering event. If the event that starts the run has no scalar value at the match path, or its payload was redacted, the spec is stored without a match value and never fires for that run. The run still starts.

Nested paths. match: "order.id" traverses event.data.order.id. Missing intermediate object → no match.

Malformed JSON. Incoming events with unparseable data are ignored safely — no panic, no mismatch fired by accident.

Equality only by design. Regex, numeric ranges, and predicate matchers are out of scope for v1. If you need them, extract the matchable scalar at the emit site and put it on the event payload.

A cancel event can arrive in the millisecond window before cancel_on_specs rows commit. Ironflow closes this race with a per-process bounded replay window (D11): every event passing through Scheduler.HandleIncomingEvent is appended to an in-memory ring buffer. Immediately after CreateRunWithCancelOnSpecs, the engine replays the buffer against the new specs. A cancel event that landed up to 30 seconds before the run+specs commit still fires.

The buffer is per-process. In a multi-node cluster, a cancel event that lands on Node A while the run starts on Node B is not in Node B’s buffer. The reconcile pass below catches that case about 10 seconds later.

Tunables:

Env varDefaultDescription
IRONFLOW_CANCEL_ON_REPLAY_WINDOW_SECONDS30TTL for buffered events. Older entries are evicted on next append. The reconcile scan starts this far before the earliest applicable timestamp: run creation for original rules, activation for restored rules.
IRONFLOW_CANCEL_ON_REPLAY_BUFFER_SIZE10000Max entries in the ring. Oldest evicted when full.

Larger windows raise memory cost; shrink only when you measure pressure.

A database error can stop a matched cancel: a failed spec query, run read or cancel write. The engine then retries in place with the same matched spec identities, up to 3 more times at 50, 100 and 200 ms. If the cancel still fails, the engine logs an ERROR and processes the event as usual. The event is not redelivered.

A reconcile pass repairs the cancel from the stored event. It runs on its own scheduler goroutine every 10 scheduler ticks, about every 10 seconds with the default interval. The pass lists each environment and event name that has live cancelOn specs, and reads the stored events of each group once. For each page of events, it reads only the specs whose match value an event carries, so it avoids listing every live spec on every pass. It cancels a run on the first event that matches. It uses the same match rules as the live path:

  • It never matches an event from another environment.
  • It never matches the event that started the run. For events with the trigger’s own name, it also skips any event stored before the trigger. That covers a “supersede” setup, where trigger and cancel use the same event name and key: each new trigger cancels the earlier runs but not its own. Events with any other name are matched by the window rule alone. Storage time does not follow commit order, so a cancel event stamped just before the trigger can still arrive after the run started, and it still cancels. The replay buffer follows the same rule.
  • For restored rules, it only matches events stamped strictly after their activation time.
  • It never matches a redacted payload.
  • For each run, it never matches an event older than the replay window before the run started.
  • It skips events that the live match never saw. The engine writes cron fires, invoke triggers and step.invoke child events without the live match. A file event or a user emit is repaired, whatever its name.

Each node remembers how far it has read each group. After a complete read, the next pass starts 30 seconds plus the replay window (60 seconds with the default window) before the database clock time of that read. So an event that committed late is still read, and a run that committed late still sees the events of its own window. “Now” always comes from the database clock, not the node clock. One pass reads at most 5000 events. Groups take turns: the next pass starts at the group after the one where the limit ran out. A group that is cut short continues from its exact position on the next pass. If it was cut inside that last span, it reads again from the start of that span once it has caught up, so nothing that committed late is missed. After a restart, a node reads each group from its earliest applicable window start: activation for restored rules, or run creation for original rules, minus the replay window. Every node in a cluster runs the pass. The cancel is idempotent, so a run is cancelled once.

If one run’s cancel keeps failing, the pass does not stop there. It keeps that run for a direct retry on the next pass and reads the rest of the group, so other runs on the same event name are still repaired. The retry stops when the cancel succeeds, the run ends or is deleted, or the matched spec is removed. Before each cancellation attempt, the retry reads the run and checks that spec. It cancels only if the run still has the version it read. If concurrent writes exhaust its attempts, the retry stays for the next pass. Continuous contention can therefore delay a retry indefinitely. Each node keeps at most 10,000 such retries and drops the oldest with an ERROR log past that. After a restart, the pass finds the event again from the run’s window.

Two metrics show this path: ironflow_cancel_on_failures_total{stage} counts the store errors, and ironflow_cancel_on_reconciled_total counts the cancels the reconcile pass made. See Observability.

The pass cannot repair a cancel when the event is gone. This happens when the event was redacted or pruned by retention before the pass read it. A repaired cancel is late by about one reconcile period, and the run can do work in that time. Behind a large backlog of events on one name, the cancel is later, because each pass reads at most 5000 events. An event whose database transaction commits more than 30 seconds after it started can fall behind the read position and is not repaired. The same holds for a run whose start transaction commits more than 30 seconds late: the events in its window are not matched against it.

cancelOn integrates cleanly with the rest of the runtime — the cancel CAS path is the same one user-initiated cancels and platform cancels use, so behavior under sleep, pause, and parallel fanout is uniform:

  • Cancel during step.sleep. Run is waiting for wakeup. Cancel arrives, run flips to cancelled. When the sleep wakeup fires, resumeRun short-circuits because the run is no longer waiting. No resurrection.
  • Cancel during scoped-injection pause. Same path — resume after injection unwind sees terminal status, drops the resume.
  • Cancel during step.waitForEvent. Same. The wait-satisfaction path checks the run status and honors the terminal.
  • Cancel mid-parallel fanout. Children stop at the next step boundary. Already-completed children are not undone (compensation requires sagas — see Sagas & Compensation).
  • Cancel event arrives after run already terminal. Idempotent swallow. The CAS path filters on status, the late event matches no live spec (specs are deleted on terminal cleanup), and the late attempt no-ops cleanly.
  • Resuming a failed run restores its original cancelOn rules. The replacement rules and running state commit together, with new spec IDs and an activation timestamp. A paused run keeps its existing rules.
  • Rapid-fire identical cancel events. The CAS path dedups via the version guard, and a retry re-checks the terminal status. 100 identical cancels = exactly one terminal transition; the other 99 find the run already terminal and no-op.

Spec persistence is transactional with the run row. The match query hits a selective index on (event_name, env_id, match_path, match_value) so the hot path is O(matches) regardless of total spec count. The CAS cancel path is the P1 primitive — exactly-one-winner across nodes for any given run+version pair.

Cleanup runs on every terminal transition: user and cancelOn cancels, completed and failed runs in push and pull mode, runs failed by the scheduler, and child runs cancelled by step.invoke. Each one deletes the run’s spec rows through DeleteCancelOnSpecsByRunID. A failed delete is logged, not returned, and the rows stay until the run is deleted. A matching event that finds a run already over deletes that run’s rows then.

Cancellation triggers the same termination path as cancelRun(). Declarative cancel terminates the run cleanly and does not auto-run saga compensate steps; compensation runs automatically only on terminal failure. Automatic compensation on cancel is not supported; the proposal to add it (#571, runtime dispatch in pull workers) was closed as not planned.

What you get: declarative, zero-handler-code cancellation. Cancel intent lives at the function boundary where it can be seen, audited, and tested. The cancel CAS path is the same one used everywhere else — no new failure modes.

What you give up:

  • Equality only in v1. Need a regex match? Project the regex output to a scalar at emit time.
  • Late cancels after a miss. The replay buffer cancels at once only on the node that saw the event. A cross-node race or a store error is repaired by the reconcile pass, about 10 seconds later.
  • No mid-step cancellation. Step boundaries are the cancel points (same as imperative cancelRun()). A long-running step finishes its current call, then the boundary check honors the cancel. Both Node and Go SDKs receive the cancel signal at step boundaries only; mid-step pre-emption is not supported in either. Split long-running work into smaller steps so cancel boundaries land more often.

Run Detail shows the stored cancelling event, matched path and resolved value after active specs are removed. Open the event link to inspect the cancelling event. This is the event that won the cancellation transition, which can differ from the run’s trigger event.

REST returns optional cancellation_event fields event_id, event_name, match_path and match_value; Connect exposes the typed CancellationEvent message. An empty match value is valid. Legacy runs, missing durable source events and redacted sources have cause-only attribution. Redacting either the trigger event, cancelling event or run clears the entire snapshot. Event retention can remove the linked event while the saved match remains available.

Cancellation preserves completed side effects. It does not automatically execute registered saga compensations. Late event wakeups cannot resume a cancelled run.