Idempotency and ordering
A distributed consumer can see the same work more than once. It may also see a later source before an earlier one, depending on the provider, subscriptions, and recovery path. Decide what identity establishes a duplicate and what ordering your calculation requires.
On this page
“Use the same correlation ID” is not a duplicate rule. Many distinct messages in one workflow share that ID. Likewise, EventEnvelope.Id identifies an actor, so all its events can have the same value.
Three identities at three boundaries
| Boundary | Identity to preserve |
|---|---|
| Command operation | Business request key and the selected route's verified operation/request identity. |
| Projection source | Consumer identity plus the provider's exact source stream and version/position. |
| External effect | Stable effect identity and the provider's durable idempotency key. |
Use diagnostic message IDs to investigate processing. Trace paths can derive delivery headers, so those IDs are not a substitute for a stable persisted source locator.
A projection source record
The example retains the source identity alongside the domain comparison hash:
public sealed record RegistrationCard(Guid RegistrationId, Guid WorkshopId,
Guid AttendeeId, string DisplayName, string Status);
public sealed record ProjectionSource(string Consumer, string Stream, ulong Version, string DomainHash);
public enum ApplyOutcome { Applied, Duplicate, OlderSnapshot }
public interface IRegistrationCardWriter
{
Task<ApplyOutcome> ApplyAsync(RegistrationCard card, ProjectionSource source,
CancellationToken cancellationToken);
}
A SQL writer commits the inbox and view changes together. If it crashes before commit, neither is durable and the source can be retried. If it crashes after commit, the retained inbox lets the retry return the completed outcome.
Compare content when the same source identity is reused. A different payload at the same stream/version should not silently overwrite the view. The sample uses a domain-only hash so a diagnostic header change does not create a false content conflict. Source authorization and exact persisted-byte validation belong to the selected route/provider where required.
Full-state snapshots and deltas need different rules
The registration card events contain all fields needed to represent the current card. The writer can keep the newer snapshot and record an older input as processed without replacing the current row.
A delta event such as “add one attendee to the total” is different. Skipping an older delta loses a contribution. An ordered delta consumer needs its preceding checkpoint and a verified way to recover missing inputs. Do not apply a “largest version wins” strategy to every event type.
Version arithmetic also depends on the source contract. A subscriber may filter out events, and framework creation events may not be published to the same listener. A numeric gap is therefore not universally evidence of a missing domain event. Define what a consumer expects to receive from that provider.
Prevent late resurrection in an actor workflow
An actor that removes an entry from a set can accept an old add again if it has forgotten that removal. The roster sample retains removed registration IDs and treats their cancellation as terminal.
public IEnumerable<Event> Handle(RemoveAttendee command)
{
if (!Guid.TryParse(Id, out Guid workshopId) || workshopId != command.WorkshopId)
throw new ArgumentException("Workshop key mismatch.");
return removed.Contains(command.RegistrationId)
? [] : [new AttendeeRemoved(command.Headers, command.RegistrationId)];
}
public void On(AttendeeAdded e) => attendees = attendees.Add(e.RegistrationId, e.AttendeeId);
public void On(AttendeeRemoved e)
{
attendees = attendees.Remove(e.RegistrationId);
removed = removed.Add(e.RegistrationId);
}
The example does not reuse a registration ID after cancellation. If your domain supports a new attempt, model its attempt ID or generation explicitly. Retain duplicate and lifecycle records for as long as delayed work can still arrive.
Reconcile uncertain command outcomes
A timeout can happen after events committed. Query the authoritative operation or actor state before deciding whether another command is safe. A new operation ID can represent another business action, so inventing one on every retry can create duplicate work.
The atomic commit contract compares the original request and frozen batch on retries. Durable no-op completion and effect completion have separate contextual requirements. Do not infer these from an ordinary empty event list.
Provider behavior still matters
Orleans stream subscription operations and event delivery guarantees depend on the chosen provider and configured path. Read the streaming documentation together with the provider contract, and prove the application's consumer behavior under redelivery and process loss.
Continue with recovery, projection catch-up, and external effects.