Rebuild and schema evolution

A projection’s read models are derived from its sources, so they can be derived again. This page covers what a rebuild does, how events and read models evolve, and when a rebuild is needed.

What a rebuild does

A rebuild re-derives a projection’s read models from its sources, with its listeners as they are now. It replays every source from its start, overwrites each read model as the replay reaches it, and then removes the read models the replay did not produce again.

The store is not emptied first. Until the replay reaches a read model, queries return it as it was, so while a rebuild runs a read model can show an earlier state and then move forward again.

A projection with a read-model topic writes each re-derived state to it again, and a tombstone for every read model the rebuild removes, so downstream projections and routes see the rebuild: a read model can go back to an earlier state and then forward again there too.

A rebuild from a source that no longer holds all of its history, such as a pruned capture topic, keeps the read models it did not re-create, logs a warning, and marks the projection as built from incomplete history; see Retention.

Start a rebuild on a running application with POST /actuator/consumers/projection/account-summary/rebuild, which needs management.endpoint.consumers.access=unrestricted: the consumers endpoint is read-only by default; see the consumers endpoint. From code, call ProjectionRebuild.start("account-summary"). An @InMemoryProjection is rebuilt on every start. See Rebuilding for how to follow a rebuild and what it refuses.

Schema evolution

A captured event evolves by upcasting. For an event no event-sourced aggregate stores, the capture topic is the only copy of its history, and its messages are never rewritten. Declare the current shape with @EventRevision, write upcasters for the older shapes, and test them with the same support an event-sourced event uses: the capture topic reads through the same upcasters.

@EventRevision(1)
@DomainEvent(namespace = "banking", name = "MoneyDeposited")
data class MoneyDeposited(
    @AggregateId val accountId: AccountId,
    val amount: Money,
    val reference: String,   // added at revision 1
)

See Upcasting for declaring revisions and writing upcasters, and Testing schema evolution for testing them.

A message with no upcaster path to its event’s current shape is never read into the wrong shape. Startup fails on a broken upcaster chain it can trace. A message the projection cannot read — one no upcaster brings to its current revision, one an upcaster fails on, or one whose payload cannot be deserialized — is treated like a message it cannot apply: it is retried under the delivery budget, then the projection’s error handler decides; one no upcaster brings to its current revision is not retried, since only a deploy can bring the upcaster (see Errors and halts). Under the default HaltErrorHandler, the projection halts and names the cause; deploy the missing upcaster and resume it, and the message is read again. After SKIP, the message’s effect is missing from the read models until the projection is rebuilt; after DEAD_LETTER_KEY or DEAD_LETTER, until an operator retries it. The projection cannot tell which key an unreadable message belongs to, so it holds back every later message of its partition until the message can be read or its error handler decides (on the event store, every later message of that source, the projection’s other topics going on): during a rolling deploy, an instance on the old code waits for a revision written by the new code instead of applying out of order; after DEAD_LETTER the later messages pass it, and it is applied late (see Error handlers).

A read model evolves by rebuild: change the read model and its listeners, then rebuild the projection, and it is derived again from its sources in the new shape.

When a rebuild is needed

At every start the framework compares each durable projection’s declaration with the declaration its read models were derived under. What the declaration covers is listed under When a projection’s declaration changes.

What a start does on a difference is set by spring.ddd.cqrs.projection.on-declaration-change, or per projection by spring.ddd.cqrs.projections.<name>.on-declaration-change; see When a projection’s declaration changes.

Not every change is noticed at start:

Change Noticed at start What to do

The event types a listener takes, the listeners' order or which ones delete; a @ReadModelKey; the read model’s class, properties or their types; the store or its table

Yes, under the policy above

Rebuild, or set the policy to rebuild

What a listener computes: its body

No

Rebuild

How an event is upcast

No

Rebuild if the read models should reflect the new shape

The values another projection’s read model gives a @ReadModelKey expression (a read model that references another, see A read model that references another)

No: a read model the expression no longer reaches keeps the copy it was last given

Rebuild the downstream projection

A queryable field of a query projection, whether it is unique included

Yes, and the startup fails: the store’s table no longer matches. A rebuild keeps the table, so it cannot help

Follow the startup failure, which says how to start the projection afresh

When a projection’s declaration changes

A listener’s body is not part of the declaration: a fixed listener changes no declaration, so rebuild the projection yourself after deploying one. What counts is the rest of what the projection is derived under:

  • its listeners: which types they handle, their @Order, and whether they delete and on what condition;

  • its @ReadModelKey declarations and its read model’s own id;

  • its read model’s class, with the properties it declares and its @InitReadModel;

  • its store, with the table or entity it writes.

A deploy that changes any of these is a declaration change.

On every start, a projection whose read models were derived under another declaration is handled as spring.ddd.cqrs.projection.on-declaration-change says, or its own spring.ddd.cqrs.projections.<name>.on-declaration-change:

warn, the default

A warning names the projection and what changed and advises a rebuild. The projection carries on from its checkpoints, and every start warns again until it is rebuilt.

rebuild

The projection is rebuilt once the projections have started; see Rebuilding. A rebuild that is refused, because the projection is paused say, or that fails is logged as a warning: the projection carries on, and the next start tries again. A rebuild that failed after it began leaves the projection REBUILDING until it is rebuilt again, as any interrupted rebuild.

halt

The projection is halted on every instance before it applies anything, and every other projection runs; see Declaration changed. Rebuild it, or resume it to carry on with its read models as they are. The halt holds until the projection is rebuilt or resumed, or until an instance whose declaration matches the stored one starts. Any other start halts it again while its declaration still differs. A paused or rebuilding projection stays so, with a warning.

fail

The startup fails before any projection starts, naming every changed projection under fail and what changed in each, and nothing is written. Set the projection’s on-declaration-change to rebuild, halt or warn to start.

A projection that has not applied anything yet takes the new declaration silently, and so does its first start. An @InMemoryProjection is rebuilt on every start, so none of this applies to it.

Whether the new declaration can read what is stored is another question: a query table whose columns no longer match the declaration fails the startup whatever the policy, with the remedy it names; under fail it fails first, with its own message.

During a rolling deploy, instances can run different declarations; see Caveats on Running on multiple instances.