Skip to content

Stratara 4.4.0

Choose a tag to compare

@github-actions github-actions released this 27 Sep 14:49
· 57 commits to main since this release
d413368

A release about events a reader has no use for. Rebuilding an aggregate, and every projection and
saga read path, used to resolve and decrypt each event before asking whether anything handled it, so
an event nobody in the host took — one retired from an aggregate, a framework event another host
reads — could stop a rebuild, dead-letter a bundle, fail every replay or stall an Orleans partition
merely because its type was not registered. Such events are now left unread, while an event that
might be handled still fails loudly. Beside that, a projection can declare that it forgets a deleted
tenant, so facts recorded for the tenant after its deletion no longer fail it; that needs a migration
of the read context.

It also closes gaps in who owns an event and what an erasure reaches. A tenant's erasure left the
default-level and confidential fields of the tenant's events readable; it now shreds every key naming
the tenant, and an erasure finds a key even when the subject it shares it with is no longer in the
directory. A stream keeps the user it was created for in every later save, a Subject stated for one
event stays with that event, and a user's erasure reaches the snapshots of that user's aggregates. A
save that fails no longer leaves its events staged for a retry to write a second time.

And it makes sure work that committed is not run twice. A save whose events are committed but whose
bundle could not be handed on now says so, with CommittedEventsNotPublishedException, and nothing the
framework runs — the transports, the retry pipelines, the Orleans execution model's recorded commands,
store readers, timers and sagas — runs it again. A commit once begun runs to its end, so a committed
save is never reported as cancelled. Subscriptions stop without handing back or dead-lettering what
their handlers finished, and the host waits for them within its shutdown timeout. A snapshot captures
only committed events, and an append now also runs on a write context whose execution strategy
retries on failure.

Finally, it makes a long history cheap to read and closes two gaps in how a host is put together. A
long-lived stream no longer makes a replay or a commit-order backfill re-read its past in every batch,
so a long history is read and prepared in time that grows with it. A write context that filters by
tenant no longer hides the store from the framework's own work, which now reads its events, snapshots
and hash-chain anchors past every filter the write context declares; a handler that must refuse another
tenant's aggregate checks the owner of the aggregate it loaded. A registration made after the host was
built is refused instead of changing the running host.

Upgrading:

  • Generate EF Core migrations for your read and write contexts. The read context declares a new
    table, projection_forgotten_tenant, for projections that declare IForgetsDeletedTenants; a host
    without such a projection never touches it, and a projection that declares it knows deletions applied
    before the upgrade only after a replay — or, on the Orleans execution model, a rebuild of an
    IRebuildableProjection. The write context's snapshot table gains a nullable user_id column.
  • Remove the snapshots an earlier release may have written wrongly. A snapshot of a user's aggregate
    was written under the tenant alone, and a snapshot could capture events that were never committed. The
    entries below give the statement that removes the first kind; to be sure of the second, delete the
    snapshots and let the next save the snapshot strategy approves write them again — a rebuild without
    one replays the events.
  • If an aggregate's stream holds an event the aggregate has no Apply for, upgrade. Rebuilding
    it no longer requires that event's type to be registered. A workaround — a no-op Apply or an
    explicit AddTrustedType<T>() — keeps working. A no-op Apply can go; an AddTrustedType<T>()
    keeps the new warning quiet and is still needed wherever a projection, a saga or the bus reads the
    type.
  • Watch for the new warning 102_004 (Rebuilding … skipped events of type …). It names an event
    whose type does not resolve in the host and that no Apply of the aggregate could take. Usually
    that is an event the aggregate ignores; if it is a handled type renamed without an upcaster, add the
    upcaster.
  • A projection or saga host no longer needs every domain event type registered. An event no
    handler in the host takes is left unread. AddDomainEventTypesFromAssemblyContaining<T>() calls that
    existed only for that can go. One trade: a handled type renamed without an upcaster used to fail the
    bundle or replay loudly and is now left unread — a type moved to another namespace or assembly still
    fails, because it keeps its name. Add the upcaster when you rename a handled event.
  • If you replaced a framework piece on the read path, it keeps today's behaviour: a projection or saga
    manager of your own still receives every event of a bundle, and a mapper of your own — or a decorator
    that forwards only the older MapToEventsAsync overloads — still maps everything through the new
    overloads' default implementations. A test double of IEventMapperFactory must set up the new
    overloads (with Moq: or CallBase = true).
  • If you erased a tenant before upgrading, erase it again. The earlier run left the keys of its
    default-level and confidential values that name no user; a second run shreds them and, where the key
    store can list its keys, the keys the tenant shared with its former members as well.
  • A key store of your own should implement IKeyStore.ListScopesAsync, and a decorator should
    forward it. Without it an erasure shreds the keys the directory names and warns that it could not
    reach the rest.
  • Retrying a failed SaveChangesAsync without appending again now writes nothing. A failed save
    discards what it staged. Append the events again, as after a ConcurrencyException. A save that
    fails with CommittedEventsNotPublishedException has recorded its events: do not append them again
    and do not retry it.
  • ErasureReport.Planes lists KeyMaterial before Memberships. The erasure sweeps the
    memberships last now, so that a second run still finds them.
  • A write or read context your host registers itself — the Orleans execution model on a store other
    than PostgreSQL — adds CommitCompletionInterceptor.Instance, last among its transaction interceptors,
    as the framework does on the contexts it registers. Without it the framework's unit of work saves on
    that context without the caller's token.
  • The Orleans execution model's registrations set MessagingOptions.WaitForCancellationAcknowledgement.
    A grain call of the host that carries a token now waits for its grain's answer — at the latest until
    the response timeout — instead of ending the moment the token fires.
  • A RabbitMQ subscription holds at most Messaging:PrefetchCount messages (default 16).
  • On a context whose execution strategy retries on failure, the framework's saves run as one
    retriable unit: an override of SaveChangesAsync(CancellationToken) alone is no longer called there,
    and SavedChanges handlers run before the commit.
  • During a rolling upgrade of an Orleans cluster, silos of the two versions cannot read each other's
    validation failures; such a call fails with a serialization failure until every silo is upgraded.
  • Register everything before the host is built. A framework registration that adds to what is already
    registered — the catalogs, the membership options, the trusted types, the Orleans roles and singleton
    works — throws InvalidOperationException when called on builder.Services after Build(), instead of
    changing the running host.
  • If your write context declares the tenant query filter (ApplyGlobalTenantQueryFilters), the framework
    now reads its store past it, so the write context behaves like one without the filter. A command handler
    given another tenant's aggregate id loads and appends to that aggregate; if yours must refuse that, check
    the loaded aggregate's owner against the session, as the tenant-isolation guide shows.
  • New types and members are additive; no existing public signature changes.

Added

  • EventRelevance and two IEventMapperFactory overloads that take it, one for stored entries and one
    for bus messages. The framework's mapper resolves and decrypts only the events the relevance accepts;
    the overloads' default implementations map everything and filter afterwards, so a consumer's own
    mapper compiles and behaves as before. EventRelevance.ForTypes, AnyResolvable and AnyResolvableWith
    describe what a reader takes. Warning 102_005 names an event a stateful saga process's reader skipped
    because its type does not resolve, once per host and event type.
  • A projection can forget a deleted tenant. A projection that removes a deleted tenant's rows met
    facts recorded for that tenant after its deletion — work queued before it ran to its end — with
    nothing to apply them to, and its PrecedingFactMissingException made the live bundle dead-letter and
    every replay fail, leaving the read models the replay had emptied empty. Declare
    IForgetsDeletedTenants on it — a promise that a deleted tenant's data is gone from its read model
    once either deletion fact is applied: the framework hands it TenantDeleted and CustomerTenantsDeleted
    whether or not it handles them, records per projection the tenants it deleted, and passes over a
    missing-prerequisite report for a fact owned by such a tenant, logged at Information (104_014).
    Nothing else changes. The record is kept by IForgottenTenantStore, which
    AddNpgsqlReadDbContextFactory<TContext>() registers over the new table, or
    AddStrataraForgottenTenants<TReadContext>() for a read context registered another way; a declaring
    projection without it fails on its first fact, naming both. A replay empties the record of each
    declaring projection it registers just before the read models — and no other deployment's — and a
    single-projection rebuild on the Orleans execution model empties that projection's just before its
    read model.
    AddProjectionsFromAssemblyContaining<T>() trusts the two deletion facts for a declaring projection.

Changed

  • A write context that filters by tenant no longer changes what the framework does with its store. The
    framework reads its events, snapshots and hash-chain anchors past every query filter the write context
    declares, so such a write context now behaves like one that declares none. A command handler given another
    tenant's aggregate id loads that aggregate and appends to it, recording the event for the aggregate's owner
    with the session's actor, as it always has on an unfiltered write context. Before, the filter made that load
    come back empty and the append fail with a version conflict. Neither the tenant-isolation guard nor the
    filter checks a stream's owner against the session. A handler that must refuse another tenant's aggregate
    checks the owner of the aggregate it loaded; the tenant-isolation guide shows how.
    IEventStreamRepository, ISnapshotRepository and IEventChainRepository return every tenant's rows to a
    caller that uses them directly.

Fixed

  • A framework exception keeps its type between silos. Orleans carries an exception from one silo
    to another with its type only for namespaces it supports, and a chain with one exception it refuses
    does not cross at all. A ConcurrencyException from a handler on another silo therefore did not reach
    the caller as one — its inner failure is a database exception — and a bus worker running the
    forwarding handler counted a conflict against the delivery bound instead of the conflict bound; a
    CommittedEventsNotPublishedException would have been delivered again. The execution model's
    registrations (AddStrataraOrleans, AddStrataraOrleansCommandDispatcher,
    AddStrataraAggregateGrains) now let every exception type cross, alongside a filter the host set
    itself. The type, message, stack trace and inner exceptions cross; the properties of the framework's
    exceptions — ConcurrencyException, CommittedEventsNotPublishedException,
    PrecedingFactMissingException, ErasureIncompleteException, AuthorizationException,
    PermissionAuthorizationException — read empty on the far side rather than null.

  • A stopping subscription lets its handlers finish, and the host waits for it. A RabbitMQ
    subscription closed its channel the moment it was cancelled, while a handler could still be running,
    so the handler's acknowledgement failed and the message was delivered again — a handler that completed
    during a shutdown ran twice; deliveries the client had already fetched then ran on the closing channel
    too. A Service Bus subscription did not stop its processor at all. A stopping subscription now stops
    taking messages, hands fetched but unhandled ones back to the queue, lets the running handler settle
    — as long as the host's shutdown timeout allows from the moment the application starts stopping,
    twenty seconds otherwise — and then closes; the host waits for that when it stops, before anything is
    disposed. A handler that gives up because its subscription stops has its message put back rather than
    counted as a failure, which on its last allowed delivery would have dead-lettered it; the broker still
    counts the delivery. A RabbitMQ handler that never returned kept its channel from closing, and the
    bus's disposal — and with it the process — waited forever; the close is now bounded too. Both
    transports settle a handler's outcome whatever the subscription's own cancellation says.

  • A RabbitMQ subscription holds at most Messaging:PrefetchCount messages (default 16, 1 to 65535,
    validated at start-up). It held every message the broker would push: on a stop they all went back to
    the queue, and the broker counted each as a delivery — enough restarts could dead-letter a message
    whose handler never ran. What a subscription holds beyond the running handler's message still goes
    back counted.

  • A validation failure keeps its fields between silos. A StrataraValidationException thrown by a
    handler on another silo reaches the caller with its Failures — each field, message and code, never
    the attempted value — so the problem-details handler still answers with the fields to correct. Its
    message stays generic: a failure's message may quote the input, and exception messages are logged.
    During a rolling upgrade, silos of the two versions cannot read each other's validation failures; such
    a call fails with a serialization failure until every silo is upgraded.

  • An append runs under a retrying execution strategy. A host whose write context retries on failure
    (EnableRetryOnFailure, on by default in Aspire's EF integrations) and that keeps the partition
    positions for the portable commit-order reader could not append at all: PartitionCounterInterceptor
    began a transaction outside the strategy, which EF refuses. The framework's unit of work now runs a save
    on such a context as one retriable unit through the strategy — a transaction of its own, the changes
    accepted only after the commit — so the interceptor finds the transaction and a transient failure runs
    the append again whole. This applies to every framework save on a context whose strategy retries —
    write, read or projection: an override of SaveChangesAsync(CancellationToken) alone is no longer
    called there (override the (bool, CancellationToken) overload), SavedChanges handlers run before the
    commit, and a single-statement save runs in a transaction. A host's own save on such a context with the
    partition counter uses the same unit; the migration guide shows it. A context whose strategy does not
    retry saves as before.

  • A commit once begun runs to its end. A database driver told to cancel while it waits for a commit to
    be acknowledged may report the cancellation after the database committed, so a stop that landed
    there had a committed save look cancelled — and a transport, a store reader or a resumed command then
    ran it again and recorded the same facts twice. The new CommitCompletionInterceptor lets a commit
    run to its end whatever the cancellation says, while a cancellation during the writes still leaves none
    of them committed, and gives a single-statement save a transaction so it has a commit to protect. The
    framework adds it to every context it registers. On a context a host registered itself without it —
    write or read — the framework's unit of work saves without the caller's token, so the save runs to its
    end whole, bounded by the connection's pool wait and command timeout rather than the caller. Such a
    host adds the interceptor — the Orleans execution model on a store other than PostgreSQL, to its write
    and read contexts, beside PartitionCounterInterceptor, last among the transaction interceptors. The
    recorded-command store runs every statement whose outcome it acts on to its end, so a recorded command is never recorded twice and
    a claim never spends an attempt on a hand-over that did not happen. The Orleans commit-order interceptor
    commits without the token and releases its transaction when a save is cancelled (a cancelled append left
    it open, and the context's next save ran inside it); a durable timer whose handler finished as its silo
    stopped is unregistered rather than fired again, and a store reader whose batch finished as it stopped
    records its checkpoint rather than applying the whole batch again; the execution model's registrations
    set MessagingOptions.WaitForCancellationAcknowledgement, so a grain call cancelled while its callee
    committed — a saga step called by a stopping timer or reader — reports the commit instead of a
    cancellation that ran the step again. The setting applies to every grain call of the host that carries
    a token: such a call now waits for its grain's answer — at the latest until the response timeout — instead
    of ending the moment its token fires; a saga step no longer fails after its save committed — a failure to reread its state or
    cancel its timers is logged (LogEvents.Orleans.SagaStepAftermathFailed, 117_130); and the
    event-bundle dispatcher records a bundle whose publication a cancellation cut short instead of losing
    it.

  • A save that committed but could not publish says so, and nothing runs it again. On a host
    without durable bundles, SaveChangesAsync hands the committed events' bundle to the outbox after
    the commit. When both the bus and the outbox's own table failed, the save threw the outbox's
    exception although the events were recorded. Everything that runs work again on a failure then ran
    it again and recorded the same facts a second time: the transports delivered the message again, the
    Orleans execution model resumed a recorded command, a store reader retried the entry, a durable timer
    fired again, and a pipeline that retries on any exception retried. The save now throws the new
    CommittedEventsNotPublishedException, naming the committed streams, with the handover's failure as
    the inner exception, a cancellation after the commit included. Each of those places treats it as
    done and logs an error:

    • the RabbitMQ and Azure Service Bus transports acknowledge the message, whatever their own
      cancellation says (LogEvents.Messaging.CommittedEventsNotPublished, 108_113);
    • on the Orleans execution model a recorded command is completed
      (LogEvents.Orleans.IntentCommittedNotPublished, 117_127), a store reader counts the entry as
      applied (EntryCommittedNotPublished, 117_128), and a durable timer counts as fired
      (TimerCommittedNotPublished, 117_129);
    • ResilienceNames.CommandDispatcher, EventBundleDispatcher, MessageBus and
      ProjectionReplayBatch do not retry it.

    The bundle itself is lost: replay the projections that consume it; a saga that reacts to bundles has
    missed it. A host that cannot lose a bundle stores bundles with the commit, and such a host no longer
    fails a save whose handover fails after the commit, since its bundle is recorded. A caller that caught
    the outbox's own exception type, or OperationCanceledException, after a save now finds it as the
    inner exception.

  • A save with nothing staged no longer publishes an empty bundle. It still requires a session, and
    it stores and publishes nothing.

  • A snapshot captures only committed events. A snapshot was written in a transaction of its own,
    before the events it captured were committed. A save that then lost a concurrency race left a
    snapshot of events that were never recorded, and every later rebuild started from it: the aggregate
    silently carried state from a write that did not happen. A clash on the snapshot's own version also
    surfaced as the provider's raw exception instead of a concurrency conflict. The event source now
    writes the snapshot after the events are committed and their bundle handed on, from the committed
    stream up to the save's highest version. A failure to write it, a cancellation included, is logged
    (LogEvents.EventStore.SnapshotFailed, 102_006) and no longer fails a save whose events are
    recorded. A snapshot that an earlier release wrote this way stays in the store and keeps serving
    rebuilds. The framework has no API to find it, so to be sure none is left, delete the snapshots
    (DELETE FROM snapshot; on the write store) and let the strategy write them again. Until each stream
    is snapshotted again, its rebuilds replay the whole stream.

  • An erasure finds every key that names the subject, not only the ones the directory names. A
    key is named by level, tenant and user together, and the eraser computed those names from the
    current memberships. A key shared with someone outside the directory was found by neither erasure
    and stayed readable. That is a user who had left the tenant, or an operator acting in a tenant from
    outside it: commands and intents are encrypted under the acting user. A second run after an
    earlier erasure had already removed the memberships missed these keys too. IKeyStore gains
    ListScopesAsync, and the file-backed and in-memory key stores implement it. An erasure now also
    shreds every listed key it covers: a key of any level naming the tenant, or a user-level key naming
    the user. A key store of your own keeps compiling: the default implementation throws
    NotSupportedException, and the erasure then falls back to the directory's keys as before,
    logging a warning (LogEvents.KeyManagement.KeyScopesNotListable, 112_008).
    Implement it to close the gap; a decorator around a key store has to forward it. Both erasures now
    also refuse an empty id: with the listing, erasing it would have shredded what the system actor and
    data without a tenant are keyed by, in every tenant.

  • A user's erasure reaches the snapshots of that user's aggregates. A snapshot holds an aggregate's
    whole state, protected fields included. It was encrypted under the stream's tenant alone and
    recorded no user. After a user's erasure, rebuilding that user's aggregate therefore started from a
    snapshot that still read their data, although every event behind it had become unreadable. A
    snapshot is now protected under the stream's recorded owner, tenant and user, and records the user
    in a new nullable Snapshot.UserId. It also took its tenant from the first event of the batch that
    triggered it. When that event was appended on behalf of another subject, the snapshot landed under
    that subject. It now takes the owner from the stream's first event. A snapshot written before this
    change records no user and is still read under its tenant alone.

    The write context's snapshot table gains a column: generate an EF Core migration for it and apply
    it before the new version runs.
    Every read of a snapshot selects the column, so without it
    rebuilding an aggregate fails.

    Snapshots written before the upgrade keep serving rebuilds, including rebuilds bounded to an earlier
    version, because snapshots are never pruned. To let a user's erasure reach them, delete the snapshots
    whose owner differs from their stream's. They are a cache: a rebuild without one replays the events,
    and the next threshold writes a new one. With the Npgsql registration's snake_case names:

    DELETE FROM snapshot s
    USING event_stream_entry e
    WHERE e.stream_id = s.stream_id AND e.version = 1
      AND (s.tenant_id <> e.tenant_id OR s.user_id IS DISTINCT FROM e.user_id);
  • A tenant's erasure shreds every key naming the tenant. A key is named by level, tenant and user
    together, and the serializer binds every value to a tenant. ISubjectEraser.EraseTenantAsync
    shredded only the tenant-level key with no user and its members' user-level keys. It left three
    kinds of value readable after the tenant was erased:

    • a value at the default [EncryptData] level, which is user-level, written for the tenant with no
      user. That is every such field of an event appended from an ordinary request, where the session's
      data-owner user is not set;
    • a tenant-level value written for a member;
    • a Confidential value written for the tenant, whose key names the tenant like any other.

    A tenant's erasure now shreds every key naming the tenant, at every level, alone or with each of
    its members. A user's erasure is unchanged: it shreds the user's user-level keys, and tenant-level
    and confidential values written for the user stay the tenant's.

    Memberships are now swept after the key material, so an erasure run again after its key sweep
    failed still finds the tenants and members whose keys it has to shred. Before, they were already
    gone and the second run shredded less. ErasureReport.Planes now reads ApiKeys, Settings,
    KeyMaterial, Memberships.

    ISubjectEraser and the membership guide state what remains out of reach:

    • a key shared with a user who is not a member when the erasure runs, unless the key store lists
      its keys (see the entry on listing keys above);
    • on a user's erasure, the snapshot of an aggregate that user owns.
  • A stream keeps the user it was created for. A later event takes its owner from the stream's
    first event rather than from the session: since 4.0.0 for every aggregate, and before that for
    aggregates implementing ITenantAggregate. But only the tenant was taken over. A stream whose
    first event was recorded for a user, from the session's data-owner user or from a stated Subject,
    recorded every event of a later save with no user. Their protected fields were encrypted under
    keys without that user, so erasing the user did not reach them. The save that created the stream
    had kept the user, so the owner depended on how events were batched.

    A later event now carries the whole owner recorded on the stream's first event, tenant and user. A
    stream whose first event names no user is still given none, whatever user the session names. The
    user now also appears where the event's owner is passed on: in the bundle published for the save,
    and in the session a projection handles the event under.

    Events already recorded are not rewritten. An event a later save recorded without the stream's
    user stays encrypted under its tenant's keys: a tenant's erasure reaches it, the user's does not.

  • A save that fails discards what it staged. A failed save is specified to discard the whole
    staged batch, but only a successful save and a concurrency conflict did so. A save that failed for
    any other reason kept its events staged, whether the store was briefly unavailable or the save was
    refused for a missing causation id. If the handler ran again in the same scope, it staged its
    events a second time on top of them. A pipeline that retries on any exception does exactly that,
    such as ResilienceNames.CommandDispatcher, and so does a consumer's own retry. The next
    successful save then wrote both, putting the same facts in the stream twice. After a refused save,
    every later save in that scope was refused again. Behaviour change: calling SaveChangesAsync
    again after a failure, without appending again, used to retry the staged events and now writes
    nothing. Append the events again, as after a ConcurrencyException.

  • A failed AppendOnBehalfOfAsync no longer leaves its Subject behind. When the append failed
    before its event was staged, for example with no session or a failing serializer, the stated owner
    stayed attached to the event object. Appending the same instance again without a stated owner then
    recorded it under that owner anyway.

  • A Subject stated with AppendOnBehalfOfAsync no longer carries over to the next append in the
    same batch.
    Within one SaveChangesAsync batch the store remembers the owner it resolved for
    each stream, and it remembered a stated one too. So after AppendOnBehalfOfAsync, an ordinary
    AppendAsync to the same stream in the same batch recorded its event under the stated owner
    instead of the stream's. That event was then encrypted under the wrong tenant's keys, was missed
    by the stream owner's erasure, and was erased with a tenant it did not belong to. A stated Subject
    now applies to its own event only, as specified. A later batch was never affected. On a stream's
    first event the stated Subject is the owner the stream records, so the rest of that batch keeps
    it, as every later batch does.

  • IEventSource no longer says an event's owner comes from the session. Its XML doc said
    every create and append takes the tenant in the session, and AppendOnBehalfOfAsync and
    EventSubject spoke of overriding "the session's Subject". Since 4.0.0 a stream keeps the owner
    it was created with, whatever session appends to it later. The XML docs of IEventSource,
    EventSubject and IAggregateCreationEvent now state the order the store applies, and that an
    aggregate created for another tenant than the session's states that tenant on its creation
    event.

  • An event no projection or saga in the host handles no longer fails the host. The projection worker,
    the projection replay, the saga worker and the Orleans projection and saga readers mapped every event of
    a bundle or entry before filtering, so an event of a type the host never registered — one retired from an
    aggregate, a framework event only another host handles — dead-lettered the bundle with the events that
    mattered in it, failed every replay after the read models were emptied, or stalled an Orleans partition,
    with Type '…' is not registered in the trusted-type resolver. Now only events a handler in the host takes
    are resolved and decrypted. An unresolvable event carrying the name of a handled type still fails loudly.
    A stateful process's reader reads every resolvable event and skips an unresolvable one with warning
    102_005.

  • Rebuilding an aggregate skips an event it has no Apply for without reading it. The rebuild used
    to resolve and decrypt every event in the stream before looking for a handler, so an unhandled event
    whose type the host never registered failed it with Type '…' is not registered in the trusted-type
    resolver
    . AddAggregatesFromAssemblyContaining<T>() registers only the types an Apply takes, so
    the event an aggregate ignores was the one most likely to be unregistered: CustomerTenantsDeleted on
    a consumer's own customer stream, or an event type retired from the aggregate by deleting its
    Apply — the case the snapshot guide describes as simply passed over. A host that also ran a
    projection over the same event did not fail, because the projection's registration covered it. The
    failure came after the fact: the append committed, and only the next rebuild refused the stream. If
    a snapshot was due at the append, the save itself failed. An unhandled event encrypted as a whole
    under a key that had since been erased failed the rebuild with Event data could not be
    deserialized
    . Now whether an Apply takes an event is decided on its type after upcasting, by the
    same binding the rebuild applies it with, and an event no Apply takes is neither resolved nor
    decrypted. An event whose type does not resolve is still read — and still fails loudly — where the
    aggregate could apply it: when it has the name of a handled type in another namespace or assembly,
    or when an Apply takes an interface, an abstract class, object or a generic type. Any other
    unresolvable event is skipped with warning 102_004, once per host, aggregate type and event type. A
    host that replaces IEventMapperFactory keeps reading every event.

  • Discovery trusts the payload of an Apply(IEvent<TEvent>). AddAggregatesFromAssemblyContaining<T>()
    and AddDomainEventTypesFromAssemblyContaining<T>() registered IEvent<TEvent>, a type no recorded
    event names, so an aggregate whose only handler for an event took the envelope could not rebuild a
    stream holding it unless something else registered TEvent. They now register TEvent.

  • A long-lived stream no longer makes a replay or a commit-order backfill quadratic. Before a batch of
    history ends, every stream in it is checked for a lower version beyond the batch. That check read the
    stream's whole past, so a stream present in every batch was re-read by every batch. On two million
    entries with one stream holding a tenth of them, a replay spent 13 s and the native reader's backfill
    79 s; a store with millions of events in one stream risked the driver's command timeout. The check now
    reads only the versions that can still lie beyond the batch. Where both backfills end a batch is now
    found from the event table's key alone, so a database that misestimates how much history is left no
    longer rescans the rest of the table for every batch. Measured again on the same store: replay 9.7 s,
    native backfill 19.8 s, portable backfill 49 s against 52 s. Neither backfill asks the index of the column
    it fills for the entries still to prepare any more: once its statistics showed part of the store prepared,
    PostgreSQL read all of them through that index for every batch — always in the portable backfill, whose
    column the migration adds with its index, and in the native one where the index was created before it ran.
    A replay and a backfill run as documented return and prepare exactly what they did.
    IEventStreamRepository.GetManyAfterSequenceInStreamOrderAsync now documents that its order holds for a
    walk from the start: from 0, and from there after the highest sequence number of each result.

  • The portable reader's backfill positions every entry, whatever query filters the write context
    declares.
    It read the store through the context's filters, so on a write context with tenant query
    filters (ApplyGlobalTenantQueryFilters) and no session it positioned nothing. Under a session it
    decided where each batch ended from that tenant's entries alone.

  • A registration made after the host was built is refused instead of changing the running host.
    AddPermissionCatalog, AddSettingCatalog, AddMembershipAuthorization,
    AddMembershipAuthorizationOptions, the trusted-type registrations (AddTrustedTypeResolver,
    AddTrustedType<T>, the assembly scans for aggregates, events, command handlers, projections and sagas),
    AddStrataraAggregateGrains, AddStrataraProjectionGrains, AddStrataraSagaGrains,
    AddStrataraDurableTimers and AddStrataraSingletonWork add to an instance registered earlier. Called
    on builder.Services after Build(), the ones that found their instance changed what the running host
    was already reading, racing its readers; the others failed with the service collection's generic
    read-only error. They now fail with an InvalidOperationException that names the registration; a call
    that would change nothing still succeeds. GetOrAddResolver has an overload that takes the name of the
    registration calling it.

  • The framework's work on its store sees every tenant when the write context filters by tenant.
    Event entries, snapshots, hash-chain anchors and the command log are tenant-scoped, so
    ApplyGlobalTenantQueryFilters on the write context filtered them too. Work that runs without a session
    then saw nothing: the commit-order readers returned no entries, the portable reader's start check passed
    over unpositioned entries, a replay emptied the read models and applied nothing, the hash chain hashed
    nothing, and a saga process's liveness check found no process and dropped its timers. A stream holding
    events of two owners was half read under one owner's session, snapshotted wrong, and refused that
    owner's next append. Every read the framework makes of its store now goes past the write context's
    query filters; a filter declared there applies to the consumer's own queries only.