Write a Projection
Derived page. The behaviour described here is specified by the
projectionscapability underopenspec/specs/. That specification is the source; this page explains and illustrates it. Where the two disagree, the specification is right and this page is a bug.
A projection turns an event stream into a read model. Stratara.Projections discovers your
projections at startup, matches each incoming event bundle against the events a projection cares
about, and invokes only the matching methods.
IProjection (Stratara.Projections.Abstractions) is an empty marker — it declares nothing.
The contract is a naming convention: the runtime reflects over your class for HandleAsync methods
whose first parameter is the event payload or an IEvent<TEvent>. That method's event type is what makes a projection
"interested" in an event; events outside that set are skipped without invoking the projection.
The shape
using JetBrains.Annotations;
using Stratara.Abstractions.EventSourcing;
using Stratara.Projections.Abstractions;
public sealed class AccountBalanceProjection(IAccountBalanceStore store) : IProjection
{
[UsedImplicitly]
private Task HandleAsync(IEvent<AccountOpened> @event, CancellationToken ct) =>
store.UpsertAsync(@event.Data.AccountId, @event.Data.InitialBalance, ct);
[UsedImplicitly]
private Task HandleAsync(IEvent<AmountDeposited> @event, CancellationToken ct) =>
store.AddAsync(@event.Data.AccountId, @event.Data.Amount, ct);
[UsedImplicitly]
private Task HandleAsync(IEvent<AmountWithdrawn> @event, CancellationToken ct) =>
store.AddAsync(@event.Data.AccountId, -@event.Data.Amount, ct);
}
You write one HandleAsync(IEvent<TEvent>, CancellationToken) per event you care about — no manual
switch, no base-method override. The payload is @event.Data: IEvent<TEvent> re-declares Data
as the typed event, while the non-generic IEvent also carries StreamId, Version, TenantId
and UserId.
Payload or envelope
A handler may take the enveloped event, as above, or the payload itself. Take the payload when the event is all you need:
using JetBrains.Annotations;
using Stratara.Projections.Abstractions;
public sealed class OpeningBalanceProjection(IAccountBalanceStore store) : IProjection
{
[UsedImplicitly]
private Task HandleAsync(AccountOpened opened, CancellationToken ct) =>
store.UpsertAsync(opened.AccountId, opened.InitialBalance, ct);
}
Take IEvent<TEvent> when you need the metadata as well — the stream, the version, the owning
tenant and user. When a relevant event arrives, the handler taking its payload is invoked; the one
taking the envelope is invoked only where the projection declares no payload handler for that event.
Declare one or the other per event, not both. Registration treats the two shapes alike: the payload
type is what AddProjectionsFromAssemblyContaining<T>() adds to the trusted types either way.
Handlers may be private — discovery uses BindingFlags.NonPublic, so they stay off the
projection's public surface. Mark them [UsedImplicitly] so analyzers don't flag them; the runtime
is the only caller. Where you store the read model is your choice — a Postgres table via your own
DbContext, an in-memory dictionary, an Elasticsearch document. Stratara.Projections' own
TenantProjection is written exactly this way and is the canonical example.
Register it
builder.Services
.AddProjectionWorker(builder.Configuration) // runtime + hosted service
.AddProjectionsFromAssemblyContaining<AccountBalanceProjection>(); // your IProjection types
Both calls matter. AddProjectionsFromAssemblyContaining<T>() registers the projections and the
event types they consume (so payloads deserialize); AddProjectionWorker(IConfiguration) registers
the manager, the method invoker, and the hosted service that consumes the event-bundle subscription.
Most hosts take both from the AddEventProjectionWorkerServices() composite in
Stratara.EventSourcing.WorkerDefaults.
Events no projection handles are not read. A bundle, a replay or a store reader meets events
that no projection in the host has a use for — types retired from an aggregate, framework events
another host reads, facts of streams this host was never meant to understand. The worker, the replay
and the Orleans reader decide from the event's type, after upcasting, whether any projection in the
host handles it, and leave every other event unread: not resolved, not decrypted, and so not in need
of a registration. The one exception keeps a moved type loud: an event whose type does not resolve,
but whose name without namespace or assembly is the name of a type a projection handles, is read and
fails as an unregistered type does. A handled type renamed without an upcaster cannot be told
apart from an unrelated event, and is left unread — add the upcaster when you rename one. A host that
replaces IEventMapperFactory with its own keeps reading every event, as before.
Event-only hosts (no handler dependencies)
A projection host does not need the whole domain registered to survive events it ignores; see above.
If a host must deserialize events itself but should not wire the projection classes — a custom
subscriber over IEventMapperFactory, say — register only the event types:
services.AddDomainEventTypesFromAssemblyContaining<AccountOpened>();
This adds only the aggregates' Apply(SomeEvent) parameter types to the trusted-type allowlist — no
aggregate types, no handler classes. See the DI Extensions Cheatsheet.
Idempotency is your job
Event bundles are delivered at-least-once, so a projection may see the same event twice — after a retry or during a replay. Write handlers that converge rather than accumulate:
| Pattern | Safe on redelivery? |
|---|---|
store.UpsertAsync(id, absoluteValue) |
Yes — replays write the same value |
store.AddAsync(id, delta) |
No — a redelivery double-counts unless you guard on @event.Version |
The AddAsync lines above are the honest trade-off this example makes for brevity. In production,
either derive the absolute value, or record the last applied Version per stream and skip anything
already seen.
Order is per aggregate, and per process
Bundles are not delivered in order across consumers. The projection worker opens several consumers
on its subscription — one per processor unless Projections:DegreeOfParallelism says otherwise —
and the broker hands consecutive bundles to different ones. What the worker guarantees is narrower
and enough: bundles about one aggregate are applied one at a time within a process. Two bundles
about different aggregates run in parallel; two about the same one queue behind each other. Across
two processes consuming the same subscription there is no such promise.
The lock serialises; it does not order. Rarely, the follow-up fact takes the lock before the fact that created its entity, finds no row, and has to decide what that means. Say so, and the framework does the rest:
var entry = await repository.GetAsync(@event.StreamId, cancellationToken)
?? throw new PrecedingFactMissingException(@event.StreamId, nameof(EntryProcessingStepStarted));
PrecedingFactMissingException is the one exception the worker retries: six attempts, from 100 ms
doubling, about three seconds in all, with the aggregate lock released between attempts so the
creating fact can land in the gap. Any other exception fails the bundle on the first attempt, as
A failing projection stops the bundle requires. If the retries run out, the bundle fails the same
way, and the log names the stream and the event type — at that point the beginning is not late, it
is missing.
A failed bundle is not lost. The transport delivers it again — up to MessageRetry:MaxDeliveryAttempts
times (default 3) — and then moves it to the projection subscription's dead-letter destination
(<subscription>.dead-letter on RabbitMQ, the subscription's DLQ on Service Bus), logged as 108_110
and counted on messaging.dead_lettered. Fix the cause, return the message, and the read model
catches up; a full replay is the repair of last resort, not the only one. See
When a handler cannot take a message.
A host that needs every bundle applied in the order the transport delivers it sets
Projections:DegreeOfParallelism to 1. A value that is not a positive number means one consumer
per processor.
Within one commit, a stream's own order holds
A projection that reads the store in commit order — the Orleans execution model's way — receives the entries of one save with each stream's entries in version order, under either reader. A stream whose creating fact and its follow-up were committed together, which is the natural shape of "accepted, and a request raised", therefore arrives beginning first, and the follow-up handler finds its row.
Entries of different streams committed together may be interleaved in any order. Nothing promises
that stream A's facts all precede stream B's, so a projection that joins two aggregates still needs
PrecedingFactMissingException for the case where the other stream's beginning has not landed yet.
The two races, and the one that needs help
Two things happen routinely and are not faults. A row can vanish between your read and your write, because a cascading delete got there first. And a delete can conflict with a concurrent delete of the same row — the end state you wanted has simply been reached by someone else.
The first needs no helper. Load the row and return when it is not there:
var tenant = await repository.GetAsync(@event.StreamId, cancellationToken);
if (tenant is null) { return; }
The second is the one that is easy to get wrong, so the framework ships it:
await repository.DeleteAsync(@event.StreamId, cancellationToken);
await transaction.SaveChangesIdempotentAsync(
ct => repository.ExistsAsync(@event.StreamId, ct), cancellationToken);
The probe is the point. On a conflict the helper asks whether the target is still there. Gone
means a concurrent bundle reached the same end state, and the commit is treated as satisfied. Still
there means a second writer changed a live row — a real conflict, rethrown, and the bundle fails as
it must. Catching ConcurrencyConflictException broadly instead would turn "a failing projection
stops the bundle" into a guarantee that holds only where nobody used the helper.
A deleted tenant's late facts
A projection that removes a tenant's rows when the tenant is deleted — on TenantDeleted, or on the
CustomerTenantsDeleted cascade — will sooner or later meet a fact about that tenant's data that was
recorded after the deletion: work queued before it ran to its end. The row is gone, the handler
throws PrecedingFactMissingException, and the framework cannot tell "not applied yet" from "removed
on purpose". Live, the bundle is retried and dead-lettered; in a replay the batch fails on every
attempt, and the read models the replay emptied stay empty.
Declare IForgetsDeletedTenants on such a projection. The declaration is a promise: once either
deletion fact has been applied, the tenant's data is gone from this projection's read model — removed
by its own handlers, as below, or by anything else. A projection that keeps a tenant's rows after
TenantDeleted, the tenant's soft delete, must not declare it.
public sealed class KnowledgeEntryProjection(IKnowledgeEntryRepository entries) : IForgetsDeletedTenants
{
private async Task HandleAsync(IEvent<EntryIndexed> @event, CancellationToken cancellationToken)
{
var entry = await entries.FindAsync(@event.StreamId, cancellationToken)
?? throw new PrecedingFactMissingException(@event.StreamId, nameof(EntryIndexed));
await entries.MarkIndexedAsync(entry, cancellationToken);
}
private Task HandleAsync(IEvent<TenantDeleted> @event, CancellationToken cancellationToken) =>
entries.DeleteForTenantsAsync([@event.StreamId], cancellationToken);
private Task HandleAsync(CustomerTenantsDeleted @event, CancellationToken cancellationToken) =>
entries.DeleteForTenantsAsync(@event.TenantIds, cancellationToken);
}
The framework then does three things for this projection, and nothing else:
- It hands the projection
TenantDeletedandCustomerTenantsDeletedwhether or not it handles them, and after applying one records the tenants it deleted — for this projection alone, in the order the projection applies facts. What one projection has recorded never depends on how far another has got; the partitions of one projection on the Orleans execution model converge through the usual retry until each has applied the deletion. - When the projection throws
PrecedingFactMissingExceptionfor a fact whose owning tenant it has recorded, the fact is passed over: treated as applied, not retried, and logged at Information (104_014) with the projection, the stream, the fact's type and the tenant. - A replay empties the record of each declaring projection the host registers, just before it empties
the read models, and leaves another deployment's records in a shared read store alone; rebuilding
an
IRebuildableProjectionon its own on the Orleans execution model empties its record just before its read model. The record is rebuilt from the history, in order.
A fact of a deleted tenant that the projection applies without complaint — a late creation, say —
is applied as usual; if you want those dropped too, inject IForgottenTenantStore and ask it. A
missing prerequisite for any tenant not recorded — or one the store could not be asked about — is
retried and fails as before, and a projection that does not declare the interface is not affected at
all.
The record lives in the read store. The framework's read context declares the table
projection_forgotten_tenant, so upgrading needs a migration of your read context.
AddNpgsqlReadDbContextFactory<TContext>() registers the store; for a read context registered another
way, call AddStrataraForgottenTenants<TReadContext>(). A declaring projection in a host without the
store fails on the first fact it is handed, naming both; a host without a declaring projection never
touches the table. Deletions applied before the upgrade are not in the record until a replay records
them from the history — or, on the Orleans execution model, a rebuild of an IRebuildableProjection.
Discovery trusts the deletion facts for you. AddProjectionsFromAssemblyContaining<T>() adds
TenantDeleted and CustomerTenantsDeleted to the trusted types for a declaring projection; a
projection registered by hand needs AddTrustedType<TenantDeleted>() and
AddTrustedType<CustomerTenantsDeleted>(). A host that replaces IProjectionHandler gives the
behaviour up with the rest of the default handler.
What the framework does not do
On the bus path there is no checkpoint store. Projections are driven push-wise off the event bus;
Stratara does not track how far each projection has progressed, so there is no consumer-lag metric and
no resume-from-sequence. The observability you get is throughput and latency
(projection.events.processed, projection.bundle.duration). Under the
Orleans execution model the same projection reads the store
from a checkpoint per projection and partition, kept in the read store by
AddStrataraProjectionCheckpoints<TReadContext>(), and its lag is measured; the checkpoint is keyed by
the projection's name. Replay of the historical stream is coordinated separately, via the
IProjectionReplayState in Stratara.Outbox.RabbitMQ.
Where that state lives depends on one registration. With a Redis connection registered
(builder.AddCaching(), connection string redis) the replay marking, its progress and the
replay-request channel are shared by every host on that connection: a replay requested in the
projection worker suppresses publication in the API host too. Without one, the state is held in
process — a single host needs no Redis at all — and a replay requested in one host is neither seen
by nor suppresses anything in another. A host that falls back says so once at start-up with warning
104_012. A deployment of several hosts that needs a replay to suppress publication in all of them
registers the shared connection; the order of that call and the composites does not matter.
Replay is destructive, and it is all-or-nothing
A replay is not a repair tool you reach for casually. Five properties, in the order they will surprise you — and on PostgreSQL a registration that makes a failed replay leave the read models as it found them, described under Keep the read models a failed replay would leave behind:
It empties before it rebuilds. A replay marks itself active, truncates every registered read model, then replays the whole stream from the beginning in batches. The truncation is what makes it a rebuild rather than a second application of events on top of state that already reflects them — but it means the read side is empty from the instant the replay starts, and stays that way until the rebuild passes each row again.
There is no per-projection scope. You cannot replay one projection. Every read model registered in the host is emptied, including the ones that were fine.
It runs on request, with no confirmation step. The worker does not start one at host start-up —
it subscribes and waits. But when a request arrives it begins immediately, in the one host that
claims it — unless a replay is already running, in which case it starts nothing and logs 104_019
(see Watch a replay). There is no dry run, no "are you sure", and no built-in
guard on who may ask.
A batch that fails is retried before the replay gives up. Each batch — reading it from the
event store and applying it — runs under the ResilienceNames.ProjectionReplayBatch policy: five
attempts in all, exponential backoff from one second with jitter between them — about fifteen
seconds of waiting plus jitter before the last one — any exception except cancellation. A read-store timeout or a dropped connection mid-rebuild is retried;
each attempt after a failure logs 104_011 so you can see the replay struggling rather than merely
slow. A retried batch is applied again from its first entry, in a fresh scope — which is why the
converge-not-accumulate rule above is not optional. A failure that persists through every attempt
ends the replay exactly as an unretried one would.
Each stream is replayed in version order. A save does not number the entries it writes in
version order — the database hands out sequence numbers in the order the provider chose to insert
the rows — so the sequence number alone can put a stream's third fact before its first. The replay
reads each batch with every stream's entries in version order and the streams interleaved as their
sequence numbers interleave them, so a projection that stops on a missing preceding fact does not
stop on a sound stream. A batch that would end between two versions of one stream is extended
until it does not, so a batch can hold more entries than Projections:BatchSize. A store written
before this guarantee needs no migration; the order comes from reading, not from the rows.
And when it ends, it marks itself inactive whether it succeeded or not. Without
AddReadModelRestore (below), a replay that dies half-way leaves you with partially rebuilt read
models; its outcome, described under Watch a replay, reads Failed with the
message and how far it got. Treat such a failed replay as "run it again", not as "it stopped
safely". With the registration, a failed replay restores the state it started from and its outcome
reads ReadModelsRestored = true.
A replay is a maintenance operation. Run it in a window. The retry above covers a failure that passes; it does not make a deterministic one survivable. Without the registration below the framework does not keep the previous views for you, and a backup of the read store taken before the replay is the fallback while you look for the cause.
Keep the read models a failed replay would leave behind
On PostgreSQL the framework can keep them for you:
builder.Services
.AddNpgsqlReadDbContextFactory<AppReadDbContext>()
.AddReadModelRestore<AppReadDbContext>();
A replay on that host then ends in one of two states — the rebuilt read models, or exactly the ones it started from:
- Before anything is emptied, every table
AppReadDbContextmaps is copied into a schema of its own (stratara_replayby default), as one consistent snapshot: the read models, the projection checkpoints and each projection's record of forgotten tenants together. The copy is logged (104_021). If it cannot be taken, the replay fails before it empties anything. - A replay that fails after its batch retries writes the copy back in one transaction — readers
wait for it and then see the state from before the replay, never a mix — logs
104_022, and its outcome readsFailedwithReadModelsRestored = true. - A replay that succeeds drops the copy. If the copy cannot be dropped, the replay still succeeds
and logs
104_025; a host that starts later drops it where the coordination state still holds that outcome. On a host without the shared Redis state the outcome does not survive a restart, and that host restores the copy instead — which undoes the replay; run it again. - A replay whose host stops — killed, or shut down — leaves the copy. The next host with the
registration that starts while no replay is running writes it back and logs
104_023; hosts that start together do it once. A replay requested while such a copy is still there keeps it as the state to fall back to, rather than copying the half-built read models.
Readers still see the rebuild in progress while the replay runs; what changes is how it ends. What it costs and what to know:
- Time and disk. The copy reads the whole read store once more before the replay starts, and holds it twice while the replay runs. The lease need not outlast the copy: the replay renews its marking every second until its first batch, as it does on every host.
- A host that restarts within its own replay's lease finds that replay still marked active and leaves the copy alone; it checks again every thirty seconds and writes the copy back once the marking has lapsed.
- Deployments that share a read store give each its own
Schema: the copy, its record and the lock that serialises restores belong to the schema, so two deployments with the same one would restore each other's read models. - Which tables. Every table the context maps — views, keyless types and SQL queries excluded. Add
tables your
IProjectionViewTruncatorempties but the context does not map withAdditionalTables, and leave out mapped tables a replay does not touch withExcludedTables, each asschema.tableor astablein the context's default schema. A table outside the set that references one inside it fails the replay before anything is emptied: the write-back could not truncate the set. - Bundles already queued when the replay began are applied to the read models after the copy was taken; a write-back discards their effect. Publication is suppressed from the moment the replay is active, so this is the replay's first seconds — stop the projection consumers for the replay if even that is too much.
- A schema change between the copy and the write-back — a migration that ran during the replay —
makes the write-back refuse (
104_124, Error) and keep the copy, rather than write rows into the wrong shape. The same id reports any other write-back that fails, and a check at start that fails; the host keeps running and checks again every thirty seconds. - Permissions. The copy schema is created on first use, which needs
CREATEon the database; create it beforehand and name it inSchemawhere the read store's user lacks it.
The settings are read from the ProjectionReplay:Restore section:
{
"ProjectionReplay": {
"Restore": {
"Schema": "stratara_replay",
"ExcludedTables": [ "read_store.audit_log" ],
"AdditionalTables": [ "read_store.raw_counters" ],
"CommandTimeout": "00:30:00"
}
}
}
A host that is killed does not get to mark anything. Failing is an ending; being killed is not.
A SIGKILL, a container stop, an out-of-memory kill or a reboot leaves the replay with no chance to
clear its own marking — and while the marking stands, publication stays suppressed for the whole
host: commands are recorded instead of sent, the caller gets an identifier and a success response for
a command that will never run, and the outbox does not drain.
So the marking is held on a lease that the replay renews each time it reports progress, and every
second while it prepares — preserves, empties and counts — before its first report. Nobody renewing
it means nobody is replaying, and it lapses on its own. Set the lease longer than your slowest batch;
a renewal that fails while the replay prepares logs 104_026 once:
builder.Services.Configure<ProjectionReplayOptions>(o =>
{
o.LeaseSeconds = 600; // default 300
o.RefreshSeconds = 5; // default 5; see below
});
Or set it in the ProjectionReplay configuration section (ProjectionReplayOptions.SectionName).
AddProjectionReplayState() — which AddOutboxDispatcher() and the worker composites call — reads
the section from the host's configuration as it stands, with no code; a value configured in code after
that call takes precedence:
{
"ProjectionReplay": {
"LeaseSeconds": 600,
"RefreshSeconds": 5
}
}
A lease of zero or less is refused when the host starts, with an OptionsValidationException
naming ProjectionReplay:LeaseSeconds. Err long. Too long only delays the clearing of a marking whose replay already died; too short lets
the marking lapse while the replay is still running, which resumes suppressed publication against
half-rebuilt read models and tells nobody.
Whether a replay is active is answered from memory. Every dispatch, every publication, every store
reader's catch-up and every drain pass asks the state whether a replay is active, and none of them waits
on Redis for the answer — a host with few cores would otherwise spend its thread pool on a question whose
answer is "no" almost always. A host answers from what it last learned: a change it made itself at once;
a change made in another host when that host's announcement arrives over the shared connection, usually
within milliseconds; and in any case within RefreshSeconds (default 5), the period at which every host
re-reads the marking. The refresh is the safety net for an announcement that was lost or not yet
subscribed to, and the way a marking that lapsed is noticed. Set it in the same section; it is refused at
zero or below and at or above the lease, naming ProjectionReplay:RefreshSeconds. Until a host has first
read the marking it answers that no replay is active. A host that cannot reach Redis keeps its last
answer rather than failing the dispatch that asked, logs 104_015 once, and 104_016 when it can read
the marking again; a host that cannot subscribe to the announcements logs 104_017 once, sees a change
on the refresh period only until 104_018 says it is subscribed again, and keeps refreshing meanwhile.
A host on a release before 4.4.2 announces nothing, so a replay it starts is seen by a newer host at
the refresh period rather than at once.
One thing a version bump does not do for you: a marking that is already stuck from before you adopted the lease was written without an expiry and does not gain one. Clear it once — an explicit deactivation, or let the next replay's own completion clear it.
Watch a replay
A replay publishes how far it has got, records why it stopped when it fails, and keeps how it ended
after it ended, so an operator can tell "still running" from "stopped part way" — and a replay that
finished between two polls from one that never ran. IProjectionReplayState.GetProgress() returns a
ReplayProgress:
| Member | While running | After it ended | Before any replay |
|---|---|---|---|
IsActive |
true |
false |
false |
ProcessedEvents / TotalEvents |
events applied so far / events to replay | 0 / 0 |
0 / 0 |
Percentage |
0–100, derived from the two counts |
0 |
0 |
RequestId |
the request the replay runs | null |
null |
ErrorMessage |
null |
the failure's message after a failure, otherwise null |
null |
LastReplay |
the previous replay's outcome, if any | this replay's outcome | null |
LastReplay is a ReplayOutcome: the request's id, StartedAt and EndedAt by the replaying host's
clock, ReplayedEvents, Result — Succeeded, Failed, or Interrupted when the host stopped — and
the failure's ErrorMessage. It is kept until the next replay ends, so it is still readable while
that one runs. The counters read 0 once a replay has ended on either coordination state; how far it
got is LastReplay.ReplayedEvents.
The total is published once the read models are truncated, so a replay that is still truncating
reports 0 of 0. A total of zero yields a percentage of 0, never a division failure. A failure
message longer than 500 characters is truncated rather than stored whole, and ErrorMessage keeps it
until the next replay starts. A replay interrupted by host shutdown is not recorded as a failure —
shutdown is not a replay error — but its outcome says Interrupted, not Succeeded, and it logs
104_020.
One request, one replay. A request carries an identity: pass your own to
RequestReplay(Guid requestId) and look for it in RequestId and LastReplay.RequestId;
RequestReplay() draws one, and Guid.Empty is refused. Every host that runs the replay worker receives the request, but only the
first to claim it runs it, and a host that receives it after the replay has already ended finds it
claimed. A request that arrives while another replay is running starts nothing and logs 104_019: a
second replay would empty what the first is rebuilding. A replay that outlived its lease — its host
stalled long enough for another request to start — records its outcome when it ends and leaves the
other replay's marking and error message alone. A host that cannot claim a request because the
coordination state failed logs 104_127 and starts nothing; an outcome that cannot be recorded logs
104_121, and the marking then lapses with its lease.
During a rolling upgrade the guarantee holds only between upgraded hosts. A host on an earlier release claims nothing and runs every request it receives; and a request it publishes carries no identity, so upgraded hosts cannot tell their copies apart and one that receives it after the replay ended runs it again. A replay an earlier-release host runs records no outcome either, so a client polling for its request's id would wait in vain. Upgrade every host that runs the replay worker before relying on one request starting one replay.
Reading and requesting a replay from an admin endpoint takes a few lines; the guard on them is yours, because the framework has none:
app.MapGet("/admin/projections/replay", (IProjectionReplayState replay) => replay.GetProgress())
.RequireAuthorization("PlatformAdmin");
app.MapPost("/admin/projections/replay", (IProjectionReplayState replay) =>
{
var requestId = Guid.CreateVersion7();
replay.RequestReplay(requestId);
return Results.Accepted(value: new { requestId });
})
.RequireAuthorization("PlatformAdmin");
A client that polls after its request waits until RequestId or LastReplay.RequestId names its id;
the first means running, the second means ended, with Result saying how. If its first poll finds
another replay running — IsActive with a different RequestId — its request was not run (104_019
in the claiming host's log): request again once that replay has ended.
What the endpoint sees follows the registration described under What the framework does not do: with the shared Redis connection every host reads the same progress, without it each host reads only its own.
A replayed event runs under the session that recorded it
While replaying, each event is applied under the session context recorded with it — its owning
tenant and user, its actor, its correlation and causation ids — not under the replaying host's own.
A handler that reads ISessionContextProvider.Current during a replay sees the session of the
request that produced the event, event by event, even when one batch holds events from many tenants.
That is what keeps a rebuild faithful: without it, every rebuilt row would be attributed to whichever session happened to be ambient, and tenant-scoped writes would land in the wrong tenant. Write handlers that take the tenant from the event or from the session, and a replay puts each row back where it was.
See also
- Sample 2 — Event Sourced — an aggregate and its projection end to end.
- Write a Saga — the sibling pattern that reacts to events by issuing commands.