Skip to content

Costs and limits

Use this page to judge whether a guarded rule is worth its cost and to find what stays outside it. The mechanisms are in Choosing how a rule holds. Each limit below is stated where it applies.

A decision read does not use the stored read model. For each attempt the client:

  1. Asks the kernel for the log’s tail and for the tail of the key’s model event types, in parallel.
  2. Folds the key’s events in a fresh session and starts a session cleanup request without waiting for it.
  3. Retries, up to three attempts in total, if the fold stopped short of the second tail or ran past the first.

The first read of each model type in a process makes two more calls to list definitions. At commit the kernel runs one filtered tail query for each guarded key, in parallel. An append with no guard of your own still gets the default optimistic check: the client reads a tail when the append is made (the first append into a scope is unchecked unless configured otherwise) and the kernel validates it at commit. That protects only the short window between that read and the commit, not the decision you made earlier.

MechanismRound tripsWork per readGrows with
ConstraintNone extraAn index lookup inside the appendNot expected to grow with history
Explicit scopeOne tail call to capture the positionOne filtered tail query at commitNot measured
Materialized plain read1Reads the stored instanceNothing
Passive plain read1Every read folds the key’s history from the start; nothing is kept between readsHistory, on every read
Decision readAbout 4 per attempt, up to 3 attempts, plus 2 on first use of a typeFolds the key’s whole history of the model’s event types, every timeHistory times reads

A decision read is linear in the key’s history and nothing caches it. Reads of one model type are expected to queue behind each other in the kernel, across keys. That has not been measured.

One run, on a loaded machine, so read the ratios and not the milliseconds.

Operation (median ms)10 events on the key100 events on the key
Materialized GetInstanceById2.15.3
Passive, instance already in memory (19.54.0 only; later versions fold fresh on every read)3.78.7
Passive, fresh fold8.113.9
Decision read (GetDetached)12.722.6
Tail call, then fetch the key’s events and fold them yourself5.319.2
  • Environment: the cratis/chronicle:19.54.0-development container with the Cratis.Chronicle 19.54.0 client, on an Apple M4 Pro with 12 cores and a load average of 80 to 100. The log held 20 other event sources with 500 events each. Each cell is the median of 20 samples. It is one run, and it was not repeated on later versions.
  • Result: a decision read took about 1.6 times a fresh passive fold and 4 to 6 times a materialized read. It added roughly 10 to 20 ms over a materialized read.
  • Guarded append: the extra cost of the guarded commit was lost in the noise. A read plus guarded append totalled 31.7 ms at 10 events and 19.9 ms at 100 events. The drop is noise, not a trend.
  • Not measured: 1,000 and 10,000 events on the key, concurrent writers, the SQL storage provider, and the kernel queueing described above. The cost at those sizes follows the measured growth from 10 to 100 events (the decision read roughly doubled for ten times the events) and the linear fold. Measure your own model before you rely on it.

An append condition compares the events appended after a position with a filter. It says nothing about changes that are not appended events matching that filter.

  • Revisions and redactions. Revising or redacting an earlier event does not advance the position. A decision made before the change can still commit. Constraints do not check history mutation either. An atomic check for this is not available today.
  • Other event sequences. A decision read and its guard apply to the event log. One unit of work cannot span event sequences.
  • Data outside the event log. Nothing in the log can invalidate a value that lives elsewhere, such as an external service, a clock or another store. Record the fact as an event first.
  • Advisory reads. A plain read, or a read under an Advisory allowance, guards nothing. A [Unprotected] read in Arc is the same.
  • Writers that decide without a guard. A guard detects a matching event whoever appended it. It protects only the decisions that carry it.

A conflict report names the read model and the key that changed. When the kernel reports it, it also names the type and sequence number of the first event that matched the guard after the read, which can belong to another read merged into the same guard.

In a host that uses read protection, the frame belongs to one unit of work and follows one asynchronous flow. Enrollment on another unit inside it is refused with ReadProtectionUnitMismatch.

“Silent” means the rule can fail with no error and no failed result.

SituationWhat happensSilent or loudWhat to do
You ignore the AppendResultA constraint or concurrency rejection is a result, not an exception. The write is lostSilentAlways check IsSuccess, then the specific violations
You commit the request’s unit of work after the responseThe ASP.NET Core middleware commits after the action returns. It does not report a concurrency conflict to the callerSilent to the callerCall IUnitOfWorkCompletionFeature.CommitAsync() before you respond
You take a decision read through the unit of work, then call plain eventLog.AppendThe append is immediate and takes no part in the unit of work, so the guard never appliesSilentStage with eventLog.Transactional.Append, or use GetDetached with AppendMany(guardedBy:)
A scope has no sequence number and no ExpectingNoMatchingEvent()The kernel skips itSilent. ConcurrencyCheckPerformed is falseBuild scopes with a position, and assert ConcurrencyCheckPerformed in tests
You capture the tail after you read the factsEvents appended in between are not guardedSilentCapture the tail first, or use a decision read
The first append into a scopeNot checked by default, so two concurrent creators both passSilentUse a constraint, ExpectingNoMatchingEvent(), or a decision read: it guards an absent model too. See the first append
You use a plain read for the decisionIt guards nothingSilentSee the table above
The decision-read model has no Id property, or its shape is refusedDecisionReadRefused, with a reasonLoudFix the model. Check with Admit<T>() in a test
The read model is keyed on a different event source than the one where the facts are writtenThe model is empty or partial, and the guard watches the wrong sourceSilentAppend every fact the rule needs on the source you read
New keys can join the set the rule counts overA key nobody read is in nobody’s guardSilentModel the rule on one key that carries the facts
Revise, redact, a migration, another event sequence, or a projection definition changed elsewhereA decision read does not detect them. A revision or redaction does not advance the log position that the guard comparesSilentPause the affected command producers and let in-flight decisions finish before you revise or redact, then resume with fresh reads. See Concurrency
A writer decides on the same rule without a guardA guard detects its event whoever wrote it, but the unguarded writer’s own decision is not protected and can break the ruleSilentGuard every writer that decides on the rule, or use a constraint, which the kernel checks for every append
A conflictThe append is rejected and nothing is written. Chronicle does not retryLoudRead again and resubmit
A constraint’s claim cannot be recorded after the event is durableThe event stays. A later duplicate can pass until you reindex the constraintSilent, loggedWatch for the logged failure, and reindex
A constraint across namespaces or event sequencesConstraints do not span themSilentDesign the unique value per namespace
Non-.NET writers append GUID event source IDs in another casing or formatThe decision read’s key no longer matchesSilentUse lowercase canonical form in every writer
A long or hot keyReads get slower and conflicts more likely. Results are not wrongSlowShorten the key’s history
A repair reactor triggers an irreversible side effectA compensating event cannot undo itSilentRepair with events only, and make the repair safe to run twice
Your tests never interleave a competing appendThe race is never exercisedSilentAppend a competing event between the read and the guarded append, and assert the conflict. For Arc commands see the pitfalls

For the retry inside the kernel after a duplicate sequence number: the kernel renumbers the append and validates concurrency scopes again before it retries, so decision-read guards keep working. It does not run constraints again on that path.