Skip to main content
Table of Contents

Write a Projection

Derived page. The behaviour described here is specified by the projections capability under openspec/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 TenantDeleted and CustomerTenantsDeleted whether 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 PrecedingFactMissingException for 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 IRebuildableProjection on 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 AppReadDbContext maps is copied into a schema of its own (stratara_replay by 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 reads Failed with ReadModelsRestored = 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 IProjectionViewTruncator empties but the context does not map with AdditionalTables, and leave out mapped tables a replay does not touch with ExcludedTables, each as schema.table or as table in 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 CREATE on the database; create it beforehand and name it in Schema where 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