Table of Contents

Class AppendCondition

Namespace
Opossum.Core
Assembly
Opossum.dll

Specifies an optimistic concurrency guard evaluated atomically during AppendAsync(NewEvent[], AppendCondition?, CancellationToken).

public class AppendCondition
Inheritance
AppendCondition
Inherited Members

Remarks

Purpose — preventing the lost-update problem
In an event-sourced system multiple writers can read the same events, build independent decision models and then try to append new events. Without a guard, the second writer silently overwrites a state that was already changed by the first writer (a classic Time-Of-Check / Time-Of-Use race). AppendCondition closes this gap by letting the writer declare which events would have invalidated its decision; the event store enforces the check atomically and throws ConcurrencyException when a conflict is detected.

The DCB read → decide → append pattern
Dynamic Consistency Boundaries (DCB) replace the traditional single-aggregate lock with a query-scoped lock that spans exactly the events relevant to one business decision — nothing more, nothing less. The pattern has three steps:

  1. Read – query the event store for all events relevant to the decision (e.g. all events for a specific course and student). Record the highest Position seen; that becomes AfterSequencePosition.
  2. Decide – fold the events into a decision model, validate business invariants (capacity, duplicate enrollments, etc.) and produce the events to append.
  3. Append – call AppendAsync(NewEvent[], AppendCondition?, CancellationToken) with an AppendCondition whose FailIfEventsMatch is the same query used in step 1. The event store atomically re-runs the query restricted to positions > AfterSequencePosition and aborts with ConcurrencyException if any such events exist, because the decision model would have been different had those events been visible.

Example — enrol a student in a course

// Step 1 – Read
var query = Query.FromItems(
    new QueryItem
    {
        EventTypes = [nameof(CourseCreatedEvent), nameof(StudentEnrolledToCourseEvent)],
        Tags       = [new Tag("courseId", courseId.ToString())]
    },
    new QueryItem
    {
        EventTypes = [nameof(StudentRegisteredEvent)],
        Tags       = [new Tag("studentId", studentId.ToString())]
    });

var events = await eventStore.ReadAsync(query, ReadOption.None); var lastKnownPosition = events.Length > 0 ? events.Max(e => e.Position) : (long?)null;

// Step 2 – Decide (apply business rules, build aggregate, etc.)

// Step 3 – Append var condition = new AppendCondition { FailIfEventsMatch = query, // same query as step 1 AfterSequencePosition = lastKnownPosition // watermark from step 1 }; await eventStore.AppendAsync([enrollmentEvent], condition); // Throws ConcurrencyException if any matching event appeared after lastKnownPosition.

Properties

AfterSequencePosition

The highest sequence position the client was aware of when building the decision model, or null if no events were observed.

public long? AfterSequencePosition { get; set; }

Property Value

long?

Remarks

The event store uses this value as an exclusive lower bound: events at positions less than or equal to this number are invisible to the conflict check. Only events stored after this position are tested against FailIfEventsMatch.

Important: this value is the watermark over all events returned by the read query, not just the last event of a specific type. It may therefore be higher than the position of the most recent event that directly affected the decision.

Two distinct behaviours:

  • Populated — the append is rejected only when a new conflicting event appeared since the read. Concurrent writes that do not touch the same query domain are allowed through, keeping the consistency boundary as narrow as possible. This guarantee only holds when FailIfEventsMatch carries a scoped query — see the warning below.
  • null — no events have ever been seen for this query; the append is rejected if any matching event exists anywhere in the store. Use this when appending the very first event for a new entity to guarantee uniqueness.

Warning — do not combine with Query.All()
When FailIfEventsMatch has no query items (i.e. Query.All()), the query itself is bypassed entirely. The check degenerates to a raw ledger-position comparison: the append fails if any event at all was written by anyone since the watermark, regardless of type or tags. This creates an implicit global write lock and defeats the entire purpose of DCB. There is no valid domain use case for this combination; if you need global write serialization, use an infrastructure-level primitive (a SemaphoreSlim, a file lock, etc.) instead.

FailIfEventsMatch

The query that identifies events which would invalidate the current decision model.

public required Query FailIfEventsMatch { get; set; }

Property Value

Query

Remarks

This should be the same Query that was used to read events when building the decision model. By reusing it, you guarantee that the decision model is still valid at the moment of the append: if any event matching this query appeared since you last read, your decision could be wrong and the append is aborted.

The conflict check is the conjunction of both properties
A conflict is only raised when an event satisfies both conditions at once:

  1. its position is > AfterSequencePosition, and
  2. it matches this query.

This is the key difference between DCB and naive position-based optimistic concurrency control. A CourseRenamedEvent or a StudentAddressChangedEvent that was appended after AfterSequencePosition will not cause a conflict, because it does not match the enrollment query. The consistency boundary is defined entirely by what this query selects — only events that are semantically relevant to the decision can invalidate it. Unrelated writes are allowed through, keeping the boundary as narrow as the business decision requires.

Scope of the check depends on AfterSequencePosition:

  • When AfterSequencePosition is set, only events stored after that position are checked. This is the standard DCB pattern.
  • When AfterSequencePosition is null, all events in the store are checked. This is useful for bootstrapping uniqueness invariants, e.g. "this StudentRegistered event must be the very first event for this student id — reject if any already exists".