- Event Sourcing & DDD
- Projections
Projections
Projections build read models from the event stream. A projection subscribes to a set of events, and a handler either maintains derived state (managed mode) or performs side effects (external mode). This document covers the two modes, then focuses on the contract every managed reducer must honor.
Projections read the global event log
Section titled “Projections read the global event log”A natural-but-incorrect intuition is “a projection reads from an entity stream, so entity snapshots should speed it up.” The actual model is different: projections subscribe to the global ordered event log (the events table in nats_seq order), usually filtered by event name, and fold across many entity streams into a cross-cutting view. Entity-stream snapshots are the wrong input artifact for projection rebuild — they answer a different question.
Events land on per-entity streams AND the global log
Section titled “Events land on per-entity streams AND the global log”Each write goes to two places: its entity stream (for aggregate rehydration) and the global events table (ordered by nats_seq).
Entity streams (write side) Global event log (events table)─────────────────────────── ────────────────────────────── nats_seq │ entity │ eventOrder-123 stream: ─────────┼─────────────┼────────────── v1 OrderPlaced 1 │ Order-123 │ OrderPlaced v2 ItemAdded 2 │ User-42 │ UserSignedUp v3 OrderShipped 3 │ Order-123 │ ItemAdded 4 │ Order-124 │ OrderPlacedOrder-124 stream: 5 │ Order-123 │ OrderShipped v1 OrderPlaced 6 │ Order-124 │ OrderShipped v2 OrderShipped 7 │ User-42 │ EmailChanged 8 │ Order-125 │ OrderPlacedUser-42 stream: ... v1 UserSignedUp v2 EmailChangedEntity-stream view = vertical slice per entity. Global log view = horizontal timeline of everything.
Aggregate load uses the entity stream (+ its snapshot)
Section titled “Aggregate load uses the entity stream (+ its snapshot)”Load Order-123 state: ┌──────────────────────────────┐ │ entity_stream_snapshots │ snapshot at v2 → { status:"open", items:[x] } │ Order-123 @ v2 │ ──┐ └──────────────────────────────┘ │ ▼ replay tail: v3 OrderShipped │ ▼ { status:"shipped", items:[x] }One entity. Its snapshot. Its events. Clean.
Projections read the global log, not one stream
Section titled “Projections read the global log, not one stream”A projection like OrdersByDay doesn’t know about Order-123 specifically. It subscribes to the global log, filtered by event name:
Global log in nats_seq order: 1 OrderPlaced (Order-123) ──┐ 2 UserSignedUp (User-42) │ skipped (filter: OrderPlaced only) 3 ItemAdded (Order-123) │ skipped 4 OrderPlaced (Order-124) ──┤ 5 OrderShipped (Order-123) │ skipped 6 OrderShipped (Order-124) │ skipped 7 EmailChanged (User-42) │ skipped 8 OrderPlaced (Order-125) ──┤ │ ▼ ┌─────────────────────────────┐ │ Projection: OrdersByDay │ │ reducer(state, OrderPlaced) │ │ → state[day]++ │ └─────────────────────────────┘ │ ▼ Map { "2026-04-20": 2, ← Order-123, Order-124 "2026-04-21": 1 ← Order-125 }The projection crossed THREE entity streams (Order-123, Order-124, Order-125) to produce one cross-cutting view. Its state is NOT any entity’s state.
Why entity snapshots don’t help projections
Section titled “Why entity snapshots don’t help projections”Order-123 snapshot: Projection state: { status:"shipped", Map<date, count> items:[x], "2026-04-20": 2 total:100 } "2026-04-21": 1
▲ ▲ │ │ └──────── DIFFERENT ─────────────┘ SHAPE DIFFERENT REDUCER DIFFERENT QUESTION ANSWEREDNo arithmetic on the snapshot produces the projection’s map. The snapshot doesn’t remember “which day this order was placed on” — it only remembers current status. The projection needs the raw OrderPlaced events, in order, across all orders.
Single-entity projections still need the log
Section titled “Single-entity projections still need the log”Even when a projection happens to scope to one entity (e.g. OrderHistoryView for Order-123):
Order-123 snapshot: { status:"shipped", items:[x], total:100 }
Projection "OrderHistoryView" wants: [ { at: "2026-04-20T10:00", what: "OrderPlaced" }, { at: "2026-04-20T10:05", what: "ItemAdded" }, { at: "2026-04-20T10:30", what: "OrderShipped"} ]
Snapshot has no history. It has one moment. Projection needs the timeline.Takeaway
Section titled “Takeaway”- Entity streams answer “what is the state of THIS entity right now” → use entity snapshots.
- Projections answer “what is the derived/aggregated view across MANY entities over TIME” → must read the event log. Entity snapshots are the wrong input artifact.
- This matches the canonical CQRS/ES pattern (EventStoreDB
$all, AxonTrackingEventProcessor, Marten, Kafka/Flink materialized views): commands → entity streams → global ordered log → projections subscribe + fold.
Managed vs external mode
Section titled “Managed vs external mode”Managed mode — the handler is a pure reducer: (state, event) -> newState. Ironflow stores the state, routes reads (getProjection, subscribeToProjection), and owns the cursor. Use managed mode for anything a UI or API reads.
External mode — the handler performs side effects (send email, call webhook, write to an external DB). Ironflow tracks the cursor but stores no state. Return value is void. Use external mode whenever the effect of processing an event is not “update some state I want Ironflow to hold.”
Mode is auto-detected from whether an initialState factory is provided, or set explicitly via mode: "managed" | "external".
Reducer Contract (managed mode)
Section titled “Reducer Contract (managed mode)”Ironflow applies managed reducers under at-least-once delivery. The engine never sends an event at or below the runner’s delivery position, so a rebuild replay (reading from the events table) that overlaps the live NATS tail does not apply an event twice. A runner that fails before it saves a batch still gets that batch again, and so does the next lease holder after a crash. Correctness depends on the reducer being deterministic and idempotent. No runtime enforcement catches a violation; it surfaces as read models that disagree between nodes, or that diverge from the event stream after a rebuild.
A managed reducer must satisfy all four rules below.
1. Deterministic
Section titled “1. Deterministic”Same (state, event) must produce the same newState, every invocation. Ironflow may call your reducer twice with identical arguments and expect identical output.
Banned inside a managed handler:
- Clocks —
time.Now(),Date.now(),new Date()with no args,performance.now(). The event carriesevent.timestamp; use that. - Randomness —
Math.random(),crypto.randomUUID(),uuid()in Go. If you need an ID, derive it from the event (event.id,event.data.orderId, etc.). - Environment reads —
os.Getenv,process.env.*, reading from a file, reading from a DB.
// WRONG — non-deterministic: rebuild produces different state than livehandler: (state, event) => ({ ...state, lastSeen: Date.now() })
// RIGHT — deterministic: derive from the eventhandler: (state, event) => ({ ...state, lastSeen: event.timestamp })2. Pure
Section titled “2. Pure”No network calls, no DB writes, no console.log side effects. If you need a side effect, split the projection: a managed projection for the state, an external projection for the effect.
// WRONG — side effect in a managed handlerhandler: async (state, event) => { await sendSlackNotification(event); return { ...state, count: state.count + 1 };}
// RIGHT — splitconst stats = createProjection({ /* managed, pure */ });const notifier = createProjection({ mode: "external", handler: async (e) => sendSlackNotification(e) });3. Aliasing-safe
Section titled “3. Aliasing-safe”Return a fresh state object. Do not rely on the caller’s copy being isolated from anything you retain. The Go SDK performs a JSON deep-copy of state before each invocation (#486 I3) — this neutralizes the sharpest aliasing hazards, but it does not make in-place mutation safe as a pattern, and it does not apply to the JS SDK.
// WRONG — mutates and returns the argument; leaks if runner ever retains the refhandler: (state, event) => { state.count += 1; return state; }
// RIGHT — return a new objecthandler: (state, event) => ({ ...state, count: state.count + 1 })4. Idempotent by construction
Section titled “4. Idempotent by construction”The same event may be applied multiple times: during the PG-rebuild vs. live-tail overlap window (#486), after a NATS re-delivery, after a node failover. Handler output must be invariant under replay.
- Counters: prefer
state.ordersByID[id] = order(set-like) overstate.count += 1(accumulator). The set-like form survives replay; the accumulator over-counts. - If you must accumulate, key the accumulation to the event:
if (!state.seen[event.id]) { state.count += 1; state.seen[event.id] = true }.
// WRONG — double-counts on replayhandler: (state, event) => ({ ...state, total: state.total + event.data.amount })
// RIGHT — idempotent via event IDhandler: (state, event) => { if (state.applied[event.id]) return state; return { ...state, applied: { ...state.applied, [event.id]: true }, total: state.total + event.data.amount, };}For most business cases the cleaner fix is a keyed map (state.orders[event.data.orderId] = ...) — replay then overwrites with the same value and is naturally idempotent.
Concurrent writers
Section titled “Concurrent writers”Multi-node deployments running the same projection on overlapping partitions can lose updates on projection_state without a single writer. Ironflow does not guard those writes with optimistic concurrency (CAS on a per-row version counter); the UPSERT only enforces a monotonic last_event_seq. Two nodes reading the same cursor and writing different events race like this:
node-A reads state(cursor=9)node-B reads state(cursor=9)node-A applies E10, writes state(cursor=10)node-B applies E11, writes state(cursor=11) ← E10's state contribution lostRule 4 (idempotency) does not prevent this race. Rule 4 keeps replay of the same event safe; the race above loses a different event (E10) by advancing last_event_seq past it, so E10 is never re-applied. Both reducer shapes are affected — projection_state is written as a full blob, so the final UPSERT overwrites everything the other node contributed (whichever event the final writer didn’t see is lost, keyed-map or accumulator alike).
Rule 4 is still required; it’s orthogonal to this race and covers replay and redelivery.
The runner lease prevents this race (ADR 0120). Each SDK projection runner must hold the lease of its projection. One replica holds it and the other replicas wait as standbys. The server refuses a pull, a save or an ack without the lease (failed_precondition, “projection lease required”), and from a runner whose lease it fenced (aborted, “projection lease lost”). The Go, Node and Python runners get the lease for you. A runner on a server older than v0.44.0 stops with SERVER_TOO_OLD, because that server has no lease RPCs. The runner stays stopped until its process restarts, and a proxy that answers 404 for the lease RPC causes the same stop.
To upgrade, first upgrade every runner replica to an SDK at v0.44.0 or later, then upgrade the server. A runner on an older SDK sends no lease, so a server at v0.51.0 or later refuses it.
External mode
Section titled “External mode”External handlers have side effects; they cannot be “pure” in the reducer sense. However, they still run under at-least-once delivery, so the side effect itself must be idempotent. Use idempotency keys on the external API (see Non-idempotent step code in the pitfalls page), or look up the external resource before writing.
Rebuild observability and tunables
Section titled “Rebuild observability and tunables”Rebuilds ship with metrics, trace spans, and environment-variable knobs so operators can watch progress and shed database load (issue #495).
Metrics (emitted only while a rebuild is in flight):
ironflow_projection_rebuild_active{projection, environment_name, environment_id}— gauge, 1 per projection currently rebuilding. Sourced from a scrape-time collector againstprojection_registry, so the gauge stays consistent across process crashes and cluster-wide handoffs.ironflow_projection_rebuild_events_applied_total{projection, environment_id, phase}— counter incremented after each successful PG-source batch. Userate()to compute events/sec.ironflow_projection_rebuild_duration_seconds{projection, environment_id, result}— histogram observed on terminal transition, labelled withresult ∈ {completed, cancelled, failed}.
A fourth metric, ironflow_projection_rebuild_collector_errors_total, is a counter without labels. It counts cumulative scrape-time failures of the query behind the _active gauge. An increase records a failed query, which omits the gauge from that scrape. The counter stays non-zero after recovery.
The first three carry environment_id. Group and join on that ID: environment names are unique only within a project. The scrape-time _active gauge also exposes the actual environment_name from the environment table. The counter and histogram omit names because deriving a suffix from an immutable ID does not reliably identify the current name. projection_registry is keyed by (name, environment_id), so equal projection names in different environments remain distinct.
Tunables (read once at startup — restart to change):
| Variable | SQLite default | Postgres default | Meaning |
|---|---|---|---|
IRONFLOW_REBUILD_BATCH_SIZE_SQLITE | 500 | — | Events per PG pull during rebuild |
IRONFLOW_REBUILD_BATCH_SIZE_POSTGRES | — | 1000 | Events per PG pull during rebuild |
IRONFLOW_REBUILD_BATCH_PAUSE_MS | 0 | 0 | Inter-batch sleep; only applied to full batches |
During rebuild, the server overrides the SDK-supplied batch_size with
the tunable so throughput stays deterministic (batch_size / pause_ms)
regardless of SDK configuration. Outside rebuild, the SDK value is used
as before.
On an unrecoverable PG error during a rebuild (e.g. ListEventsByNatsSeq
fails), the rebuild scan calls FailRebuild —
rebuild markers cleared, status flipped to error, error_message
populated. No retry loop; reads stay refused until the operator re-triggers
it once the underlying issue is fixed.
A rebuild reads nothing while its environment has an events outbox row from
before the rebuild start that is still pending, or an events dead letter whose
event has no nats_seq. Such an event may already be in NATS at or below the
rebuild target, and reading past it would drop it from the read model. If the
gate stays closed for 15 minutes, the rebuild fails with an outbox error. Check
NATS and the outbox worker, requeue the dead letter
(ironflow outbox dlq requeue <event-id>), then rebuild again.
Discarding such a dead letter does not open the gate. The discard fails every
rebuild in flight in that environment whose start could see the row: each goes
to error with a message that names the event and says to run the rebuild
again, the same way a gate timeout ends it. The discard itself succeeds, and the
event keeps no nats_seq, so no later rebuild reads it either, even when live
consumers saw it. Requeue an events dead letter you want in your read models;
discard it only when every projection can do without that event.
Rebuild has a horizon when event retention is on
Section titled “Rebuild has a horizon when event retention is on”IRONFLOW_EVENT_RETENTION_DAYS deletes non-entity events, which is exactly the
row set a projection filtered by event name replays. A deleted row leaves
nothing behind, so the prune records each deleted name with its highest event
sequence. A rebuild whose replay range reaches the record of a name its filter
matches is refused — including --dry-run — rather than replaying a history with
a missing prefix and reporting success. Changing the window does not lift the
refusal, because the events are still gone. A filter that has lost no event
rebuilds at any age. A rebuild with no --from starts at the beginning of
history, so it is refused once its filter has lost an event. The error names an
event to rebuild from when one exists, and --from <event-id> with it proceeds
against the shortened history.
A projection whose filter matches only entity events is not affected. A filter that matches a recorded name gets a full rebuild refused even when entity events survive. Deletes from before that record existed left no trace, so a filter that lost events only then is not refused, and its rebuild can report success with them missing. A prune that takes an event a running rebuild has not read fails that rebuild.
When the record shows a loss and the environment holds no published event,
--from has nothing to name, so
the question becomes what the rebuild would destroy. A projection with state, a
cursor above 0, a committed event or a recorded error is refused, because the
rebuild would discard its read model and report success. The error names the
recovery: delete the projection and register it again. That is not a clean
restart, because it reads the PUBSUB stream from its first retained event and can
still miss older history. A projection with none of those has nothing to lose and
completes with no work, whether the
environment never had an event or the projection was registered after the prune.
SQL projections
Section titled “SQL projections”The engine drives SQL projections. No worker polls for them. Every node keeps one consumer loop for each SQL projection, and the loop keeps a long-poll pull open on the projection’s durable consumer. A row appears soon after the event reaches the stream.
Ownership. SQL consumers use a NATS pinned-client group. NATS sends events to one loop only, and it moves the pin when that loop stops pulling. The consumer allows one unacknowledged message, so events apply in stream order. The transactional outbox keeps the order of one entity’s events on one node only. Plain Emit events, and events from different entities, can reach the stream in a different order than you emitted them. Write handler SQL that gives the same result in either order: let each event upsert only its own columns, for example paid_at for order.paid. An upsert that sets a shared column, such as a status, from the event still depends on arrival order.
Fence. A loop claims a new owner epoch in projection_registry after it confirms its pin. Each event applies in one transaction: epoch check, skip if the sequence is at or below the cursor, handler SQL, cursor update. A loop that lost its pin cannot write.
Failover. The pin lasts 15 s without a pull. An unacknowledged message returns after 30 s. A loop extends a held message for at most 2 minutes. Then it stops pulling, so another node can take over.
Stream binding. A SQL projection records the PUBSUB stream it was created against. When that stream is recreated, the new stream restarts its seqs at 1, and new events reuse seqs at or below the projection’s cursor. The driver therefore compares the recorded stream with the current one before it applies a message or a rebuild page, before it completes a rebuild, and on every reconcile tick, so it notices within 30 seconds even when no message arrives. On a mismatch the projection stops: the status becomes error, the message names the recovery, and nothing more is applied. A rebuild cannot recover this, and is refused. Run ironflow projection delete <name>, then ironflow projection create with the same definition. The new read model holds the current stream’s retained events only. A SQL projection created before this binding existed has no recorded stream and stops the same way, because nothing tells which stream its cursor counts.
Rebuild. A rebuild clears the table and claims a rebuild: token in one transaction. A loop on any node then reads the events from PostgreSQL in batches. A cancelled rebuild stops at its next batch and sets status error, because the read model is partial. Any node can cancel it, not only the node that started it. A rebuild that failed or was cancelled stops the projection: no live event applies, and queries answer failed_precondition with the failure until a new rebuild starts or the projection is deleted and created again. While a rebuild runs, queries answer from the table as it fills. The next rebuild scans at least as far as the one that ended, because its consumer has acked those events and NATS will not send them again. A projection marked error by a poison event keeps reading and applying. One node drains at a time, under a lease on the registry row that it renews from a heartbeat and with each batch; when that node is lost and no transaction blocks the registry row, the lease and reconcile timing make its drain eligible for takeover in about 90 seconds, plus any rebuild batch pause. Lease expiry does not release the row lock of an unfinished batch transaction. Recovery of that transaction depends on database or pooler lost-client detection, configured timeouts, or operator termination. A claim skips a locked row so it does not hold a slot needed by another projection. A failed read from PostgreSQL is retried. Pause, resume and cancel never rewrite the cursor. A rebuild that completes during a pause stays paused. If a handler failed during the rebuild, the status is error.
Metrics. Four counters track the driver. ironflow_sql_projection_events_applied_total counts applied events. ironflow_sql_projection_apply_errors_total{kind} counts errors by handler, parse, db or nats. ironflow_sql_projection_fenced_total counts writes that the fence rejected. ironflow_sql_projection_ownership_claims_total counts epoch claims. A fast climb of the last counter means the pin is flapping.
To create one, see SQL projections. See also ADR 0119.
Streaming mode and keepalives
Section titled “Streaming mode and keepalives”The SDK projection runner prefers the StreamProjectionEvents RPC
(long-lived SSE/ConnectRPC server stream) over PollProjectionEvents.
Streams can sit idle for minutes in quiet environments, which used to
cause intermediaries (proxies, LBs, or Node’s default fetch socket
idle timer) to close the connection — producing spurious TypeError: fetch failed reconnect logs every ~60–90s (issue #550).
When the client sets accept_heartbeats = true on the request (every
SDK runner does), the server emits ProjectionEvent frames with
kind = PROJECTION_EVENT_KIND_HEARTBEAT every ~15 seconds. They keep
the socket warm, and their seq carries the stream’s watermark (see
Delivery position). Clients that don’t opt in see
no heartbeats.
Delivery position
Section titled “Delivery position”Every poll and stream request on a managed or external projection carries
the runner’s delivery position (ADR 0164). Every event of the request’s
scope at or below it is accounted for under the current lease: the runner
saved or acknowledged it, or the server reported it as consumed without
delivery (another partition, or a malformed message). A request without a
position is failed_precondition.
The engine never sends an event at or below max(position, cursor), nor one
it already sent or consumed in the same stream. For a partitioned managed
projection, it also skips an event at or below its partition’s saved seq,
which keeps a partition rebuild from replaying the other partitions. It never
writes the cursor on the runner’s behalf: the cursor moves only by a Save or
an Ack, and a rebuild start lowers it.
The runner rule is short:
- Send
0on a new lease. - After a whole poll response is committed, raise the position to its
last_event_seq. An empty page still raises it. - On a stream, the first frame is
PROJECTION_EVENT_KIND_OPEN. Raise the position to each event’sseq, and to a heartbeat’sseq, only after every frame received before it is committed. - Never lower it. A failed batch leaves it where it was, so the next request gets the batch again.
A poll response without position, or a stream that does not open with an
OPEN frame, comes from a server older than 0.55.0. The SDK runners then stop
with SERVER_TOO_OLD before they apply anything.
Rebuilds. While a rebuild runs, the engine reads the events table above
the session’s floor. It completes the rebuild only when that scan is empty,
the outbox gate is open, and the runner has committed every event the session
sent. A stream sends each rebuild event once and does not wait for a save
between pages; completion waits instead. When a rebuild completes or is
cancelled, the engine moves the durable to the floor once, and NATS delivers
the rest of the rebuild range. A rebuild that fails refuses reads instead. A cancel skips nothing the PUBSUB
stream still holds; events older than its retention are not replayed, so the
read model is then partial and needs a new rebuild. The cursor ends at the last event
the runner committed, which can be below the rebuild target.
Partitions. A partition filter works on managed projections only. An
external projection has one cursor and no per-partition state, so a
partition-filtered poll or stream on one is failed_precondition, and so is a
partition rebuild.
Durable moves. At the start of every poll and stream, the engine recreates
the durable at the floor when it acknowledged events the runner does not hold,
when it has unacknowledged deliveries, or when it is far behind the floor. A
recreate that fails fails the request with unavailable, and the runner
retries.
A recreated stream. A NATS stream recreated beside a surviving database, as after a wiped embedded-NATS data dir, restarts its seqs at 1, and its new events reuse seqs that the database already counts. Seqs alone cannot show this: a cursor of 1000 on a stream at 2000 means “deliver from 1001” if this stream committed 1 to 1000, and “nothing here is safe” if a stream that no longer exists did.
- Each SDK projection is bound to one stream, the PUBSUB stream’s creation time when its registry row was inserted. Registering again, a rebuild and a restored durable never change it. NATS keeps the creation time across a restart on the same data dir and sets a new one on a recreate.
- Every page is checked after it is read: a projection bound to another
stream is refused with
failed_precondition, whichever session pulled the page. Managed and external projections are refused alike. An external projection’s cursor is the only record of what its side effects covered, and the old stream’s undelivered events are gone, so reading on would hide them. The engine delivers and saves nothing, and the SDK runners stop. - A projection whose floor or a page partition’s saved seq is above its own stream’s last seq is refused the same way, as after a restore from an older snapshot.
- A rebuild is refused before it deletes the state. The
eventstable keeps the old stream’s seqs beside the new stream’s, so its scan would mix them. Runironflow projection delete <name>and restart the runner, which registers the projection again, bound to the current stream. Its read model then holds the current stream’s events only. - A projection registered before the engine recorded the stream has no binding and is refused until it is deleted and registered again.
Rebuilds after a recovery. The events table still holds the old
stream’s rows after that recovery, and rows carry no stream identity. A
rebuild, of an SDK or a SQL projection, is refused before it deletes the state when two of the environment’s
events hold one seq, or an event holds a seq above the stream’s last seq: one
stream gives each event its own seq, so either shows another stream’s rows.
The projection keeps its read model and reads on from the current stream. To
replace the read model, delete the projection and restart its runner again,
or create the SQL projection again;
the new read model holds the current stream’s retained events only. An event
whose outbox mark is still pending has no seq yet, so the start cannot see the
seq it will share. The rebuild’s scan waits for that mark and then checks the
rows it reads, so a shared seq fails the rebuild instead of completing it: the
status becomes error, and by then the start has deleted the state, so do not
trust the read model. A
rebuild is not refused when an old row sits at a seq that the current stream
gave to a message with no row in this environment, such as another
environment’s event or a pub/sub message. That rebuild reads the old event
as the current stream’s.
A failed rebuild refuses reads. Whatever fails an SDK projection’s rebuild,
its start has already deleted the state, and what it failed on can sit behind
the cursor, where no later read reaches it. So every Poll and Stream answers
failed_precondition with the reason, across restarts, until a rebuild succeeds
or the projection is deleted, and the runners stop. A client’s read of a managed
projection’s state and every catch-up wait answer the same until a new rebuild
starts. The reason says which: a
stuck outbox needs the outbox fixed and another rebuild, and rows of two streams
need the recovery above. To rebuild, start the rebuild and then restart the
runner, because a rebuild runs through the runner’s reads. A rebuild with --to
or --partition is refused, and cancelling a rebuild brings the refusal back.
Other errors keep their behavior: a cancel with no failure behind it, or any error status that
no failed rebuild set, keeps reading. A SQL projection differs on the cancel: its consumer’s acked
events stay acked, so a cancel leaves a partial table and refuses queries like a failure.
A restore of the NATS data alone is unsupported. Back up and restore the
database and the NATS data together, from one point in time with the engine
stopped. A restore of the NATS data alone keeps the stream’s creation time,
so the binding still matches, and new events take the seqs of the events
the restore lost. The engine records the highest event each runner
committed, and on every page that holds events it checks that the stream
still holds that event at that seq. A projection whose runner committed an event the restore
lost is refused the same way, and recovers the same way. A SQL projection has
no runner, so it records no such event and the engine cannot see the restore. A projection whose
runner committed nothing past the backup is not refused, and the events the
restore lost are never sent to it: they stay in the events table and are
missing from the stream. The only recovery for those events is to restore the
database and the NATS data together from one backup.
External projections stay at-least-once to their side effects: a crash between the side effect and the Ack repeats the effect, so the handler must be idempotent.
Related
Section titled “Related”- PR #486 — PG-backed projection rebuild (the concurrency model that makes determinism load-bearing).
- Issue #495 — rebuild observability & tunables.
- Issue #550 — SSE keepalive pings on projection streams.
- Event sourcing — how entity streams relate to projections.
- Pitfalls: impure managed projections, non-deterministic managed reducers and mutating the state argument.
Definition history
Section titled “Definition history”When registration changes an existing projection definition, Ironflow archives the persisted old definition in the same database transaction as the update. Identical registration and initial creation add no revision. Actor and optional change reason identify the mutation that displaced the old definition.
ironflow projection history orders_by_customer --jsonironflow projection history orders_by_customer --revision 1 --jsonRevisions start at 1 and lists return newest first. Each environment and projection name has an independent history. Snapshots contain the stored version, events, partition key, mode, type, SQL DDL, SQL handlers, and description. Runtime status, cursors, and rebuild fields are excluded. SQL history preserves the stored text; it does not restore physical tables or capture SDK reducer source code.
History survives projection deletion and ordinary event retention. Deletion itself does not archive another definition. There is no backfill for earlier changes or a rollback command. See the CLI reference for pagination and exact revision reads.