Skip to main content
Version: 0.14.2

Projection Rebuild

The durable event log in PostgreSQL or SQLite is the source of truth; every read model is derived from it. That means any projection can be rebuilt from scratch at any time, including after a bug fix, a schema change in the read model, or a corrupted store. You do not depend on JetStream retention for correctness; you depend on the log. See Delivery Guarantees.

When to rebuild​

  • you fixed a bug in the projector and need the corrected read model,
  • you added a new read model to an existing boundary,
  • a projection store was corrupted or lost,
  • you changed the shape of the read model and want a clean recompute.

The rebuild loop​

A rebuild is a catch-up subscription that starts from the beginning position and re-applies every event to a fresh target:

  1. Stop or divert the live projector so it is not writing while you rebuild.
  2. Reset the target by truncating or dropping the read model so re-applied events rebuild cleanly.
  3. Subscribe from the start with after_position {0, 0}. This is the beginning cursor for reads and subscriptions; see Positions.
  4. Apply idempotently because delivery is at least once, and a rebuild may revisit events. Deduplicate by event_id.
  5. Checkpoint after each side effect is durable, then transition to live delivery. The subscription continues reading the backend as new events arrive, triggered by boundary hints and subscription-owned idle watchdog hints.

Example​

Use a distinct subscriber_name for the rebuild (or stop the old one first) so its subscriber-name lease does not collide with the live projector. Store rebuild checkpoints separately in your own projection storage. Stop delivery on any apply or checkpoint failure and reconnect from the last durable checkpoint. The Go and Node SDKs otherwise continue after handler errors; the examples below cancel explicitly. Commit the read-model update, deduplication record, and checkpoint atomically where possible.

// 1. Reset the target read model out of band (e.g. TRUNCATE, drop+recreate).
// 2. Rebuild from the beginning, applying each event idempotently.

subCtx, cancel := context.WithCancel(ctx)
defer cancel()

handler := orisun.NewSimpleEventHandler().
WithOnEvent(func(event *eventstore.Event) error {
if err := subCtx.Err(); err != nil { return err }
// Apply idempotently: upsert keyed by event.eventId, or check a
// processed-events table. Redelivery must not double-apply.
if alreadyProcessed(event.EventId) {
return nil
}
if err := applyToReadModel(event); err != nil {
cancel()
return err
}
// Checkpoint AFTER the side effect is durable.
if err := saveCheckpoint(event.Position); err != nil {
cancel()
return err
}
return nil
}).
WithOnError(func(err error) {
log.Printf("rebuild stopped: %v", err)
})

sub, err := client.SubscribeToEvents(subCtx, &eventstore.CatchUpSubscribeToEventStoreRequest{
Boundary: "accounts",
SubscriberName: "balance-projector-rebuild",
AfterPosition: &eventstore.Position{CommitPosition: 0, PreparePosition: 0},
}, handler)
if err != nil {
return err
}
defer sub.Close()
// Catch-up replays all history, then the stream switches to live delivery.

Cut over​

Before switching readers, capture a target position or an application marker and verify the rebuild has durably processed it. The subscription has no separate catch-up-complete message; an empty read is not proof that concurrent writes have stopped. Once it reaches your chosen cutover point, switch readers to the rebuilt model. If you used a separate subscriber_name, redirect reads to the new store and retire the old projector. If you reset in place, the live projector simply continues from its new checkpoint.

Speed up large rebuilds​

  • Index the filter fields. A filtered rebuild (for example only OrderPlaced events) scans the boundary table without an index. Create a partial index on the filtered field first.
  • Rebuild in parallel by boundary. Boundaries are independent, so rebuild each on its own subscriber.
  • Page instead of stream if you want bounded concurrency: use GetEvents with increasing from_position and apply page by page, then start the live subscription from the last position.

Summary​

StepWhy
Reset the target firstSo re-applied events rebuild, not append
Start from {0, 0}Beginning cursor that replays the whole log
Apply idempotentlyRedelivery must not double-apply
Checkpoint after durabilityResume safely; replay between side effects and checkpoints remains possible
Index filter fieldsAvoid full-table scans on filtered rebuilds