14 — Errors
Code: src/Typhon.Engine/Errors/ (+ ResourceExhaustedException under Resources/, two status enums cited in §5)
Errors is the smallest subsystem in Typhon — one folder, a couple dozen files — but every other subsystem terminates here. This chapter documents the contract: the exception hierarchy, the numeric error codes, the zero-allocation Result<,> pattern for hot paths, and a few invariants you'll want to know before you write a catch block.
The two-channel error model: routine non-success outcomes (not-found, not-visible) return a zero-cost
Result<TValue, TStatus>; exceptional conditions throw via ThrowHelper → TyphonException (carrying ErrorCode + IsTransient). The engine never retries — callers route on IsTransient (transient → backoff/retry/drop; terminal → log/give up; WalWriteException fail-fast (per ADR) → restart).
1. Overview — throw, don't retry
The engine layer follows one rule: throw, never retry. When a Typhon operation fails — a lock timed out, the page cache is full, the WAL fsync threw IOException — the engine propagates the exception to the caller and stops. There is no automatic backoff, no transparent transaction restart, no internal retry loop disguised as a successful return.
Why: retry policy is a caller concern. A game server treats a LockTimeoutException as "skip this tick"; a batch job treats it as "wait and try again"; a test asserts it never happens. None of those policies belong inside the engine.
To make caller-side retry decisions cheap, every Typhon exception carries a transience hint — a virtual bool IsTransient property defaulting to false. Subclasses opt in:
| Marker | Subclasses | Meaning |
|---|---|---|
IsTransient => false (default) |
TyphonException, StorageException, CorruptionException, WalWriteException, WalClaimTooLargeException, … |
Terminal — retrying without action will produce the same result. |
IsTransient => true |
TyphonTimeoutException (and all four timeout subclasses), ResourceExhaustedException |
The resource may free up. Retry is meaningful — though still the caller's choice. |
try
{
using var tx = uow.CreateTransaction();
tx.Spawn<Ant>(/* ... */);
tx.Commit();
}
catch (TyphonException ex) when (ex.IsTransient)
{
// backoff, retry, or drop — caller's policy
}
catch (TyphonException ex)
{
// terminal — log, alert, give up
logger.LogError(ex, "Engine error {Code}", ex.ErrorCode);
throw;
}
Two more rules worth knowing up front:
TyphonTimeoutExceptiondoes not inherit fromSystem.TimeoutException. Single-inheritance forces a choice; Typhon picks structuredTyphonExceptionover BCLTimeoutExceptionso the error-code / transience contract is uniform. CatchTyphonTimeoutExceptionfor all engine timeouts.- No
#nullable enable. Typhon doesn't use C# nullable reference types — exception properties are plain reference types.nullchecks happen at construction (ArgumentNullException.ThrowIfNull) where they matter.
2. Exception hierarchy
All public exception types live in Errors/public/ (one exception: ResourceExhaustedException lives under Resources/public/ because it ships with the resource graph it describes).
System.Exception
└─ TyphonException (base; ErrorCode, virtual IsTransient => false)
├─ TyphonTimeoutException (WaitDuration; IsTransient => true)
│ ├─ LockTimeoutException (ResourceName)
│ ├─ TransactionTimeoutException (TransactionId)
│ ├─ PageCacheBackpressureTimeoutException (DirtyPageCount, EpochProtectedCount)
│ └─ WalBackPressureTimeoutException (RequestedBytes) ← NOT under DurabilityException
├─ SnapshotExpiredException (SnapshotTsn, RetainedMinTsn)
├─ StorageException
│ ├─ CorruptionException (ComponentName, PageIndex)
│ │ └─ PageCorruptionException (ExpectedCrc, ComputedCrc)
│ └─ DatabaseLockedException (OwnerPid, OwnerMachine, StartedAt)
├─ DurabilityException
│ ├─ WalWriteException (fail-fast per ADR; engine stops accepting durable commits)
│ ├─ WalClaimTooLargeException (RequestedBytes, BufferCapacity)
│ └─ WalSegmentException (SegmentPath)
├─ ResourceExhaustedException (direct subclass; IsTransient => true)
├─ UniqueConstraintViolationException (parameterless ctor only — currently)
├─ SchemaValidationException (Diff: SchemaDiff)
├─ SchemaMigrationException (ComponentName, IReadOnlyList<MigrationFailure>)
├─ SchemaDowngradeException (ComponentName, PersistedRevision, RuntimeRevision)
└─ InvalidAccessException (sealed; DEBUG-only declared-access enforcement)
Base — TyphonException
Errors/public/TyphonException.cs
public class TyphonException : Exception
{
public TyphonErrorCode ErrorCode { get; }
public virtual bool IsTransient => false; // subclasses opt in
}
Every engine exception derives from this and carries an ErrorCode. The numeric code lets logs and telemetry classify failures without string-matching message text.
Timeout family — TyphonTimeoutException
Errors/public/TyphonTimeoutException.cs
WaitDuration is on the base TyphonTimeoutException (not redeclared on subclasses). IsTransient => true once, inherited by every subclass.
| Subclass | Extra fields | Thrown when |
|---|---|---|
LockTimeoutException |
string ResourceName |
A reader/writer/access-control entry exceeded its deadline — see 01-foundation §1. |
TransactionTimeoutException |
long TransactionId |
A transaction exceeded its overall deadline. |
PageCacheBackpressureTimeoutException |
int DirtyPageCount, int EpochProtectedCount |
Page-cache allocation timed out waiting for dirty pages to flush — see 02-storage. |
WalBackPressureTimeoutException |
int RequestedBytes |
WAL claim ring is full; producer waited past its deadline. |
Note:
WalBackPressureTimeoutExceptionis a timeout, not a durability error — it lives underTyphonTimeoutException, not underDurabilityException. The hierarchy classification follows "what does the caller want to do?", and the answer here is "retry later", which is the timeout-family contract.
MVCC snapshot — SnapshotExpiredException
Errors/public/SnapshotExpiredException.cs
Direct subclass of TyphonException (ErrorCode = SnapshotExpired = 1003, IsTransient => false). Thrown when a PointInTimeAccessor reads a Versioned component whose revisions have already been reclaimed by MVCC cleanup. A PTA does not register in the transaction chain, so the cleanup watermark can advance past the snapshot TSN; rather than return silently wrong (all-zero) data, the engine fails fast. Callers that need a snapshot to survive concurrent commits should use a read-only Transaction instead, which does register in the chain and therefore holds back the reclaim watermark. Carries SnapshotTsn (the TSN the read was attempted at) and RetainedMinTsn (the oldest TSN whose revisions are still guaranteed to be retained).
Storage family — StorageException
Errors/public/StorageException.cs
I/O errors, page faults, segment-level problems.
CorruptionException— generic integrity violation (ComponentName,PageIndex). Never transient.PageCorruptionException— CRC32C mismatch on a data page; carriesExpectedCrc/ComputedCrc. Thrown on on-load verification failure during normal operation. During recovery a torn page is instead recorded suspect and either healed by rebuild (derived structures) or fails the open loudly (primary data, RB-04) — there is no FPI repair. See 02-storage §7 for CRC checks and 11-durability §6 for torn-page safety.DatabaseLockedException— the.lockfile is held by another process; carriesOwnerPid,OwnerMachine,StartedAt. The message instructs to close the other process or delete the.lockfile if it crashed.
Durability family — DurabilityException
Errors/public/DurabilityException.cs
Things that go wrong below the commit boundary.
WalWriteException— fail-fast (per ADR). A fatal WAL write I/O failure. After this exception, the engine cannot accept durable commits; an engine restart is required. Not transient.WalClaimTooLargeException— a single claim exceeds the entire WAL ring capacity; carriesRequestedBytesandBufferCapacity. The claim can never succeed without reconfiguring the buffer — not transient.WalSegmentException— segment file operation failed (creation, rotation, header validation); carriesSegmentPath.
Resource exhaustion
Resources/public/ResourceExhaustedException.cs — direct subclass of TyphonException, not under any "Resource" family. Carries ResourcePath, ResourceType, CurrentUsage, Limit, and a computed Utilization ratio. IsTransient => true — the resource may self-heal via eviction or pool drain. Thrown by components configured with ExhaustionPolicy.FailFast — see 13-resources.
Index — UniqueConstraintViolationException
Errors/public/UniqueConstraintViolationException.cs
Thrown when an insert / update would create a duplicate key in a unique secondary index. Parameterless constructor only at present — there's no IndexName / Key / EntityId payload yet. Adding context properties is on the roadmap; for now the call site is responsible for logging the context.
Schema family
Three exceptions, all direct subclasses of TyphonException:
SchemaValidationException— the runtime struct definition is incompatible with what's persisted. Carries the fullSchemaDifffor programmatic inspection (which field changed, what type, what attribute). See 04-schema.SchemaMigrationException— one or more entities failed during a schema migration. CarriesComponentNameandIReadOnlyList<MigrationFailure>(see §6). Old segments remain untouched — the user can fix the migration function and re-run.SchemaDowngradeException— the database was written by a newer application version (PersistedRevision > RuntimeRevision). The engine refuses to open it to prevent corruption.
Worth calling out:
SchemaDowngradeExceptionreusesTyphonErrorCode.SchemaValidation(3001), not a dedicated downgrade code. If you're routing on error code, downgrade and runtime-vs-persisted mismatches look identical at the wire level — disambiguate by the exception type.
InvalidAccessException
Errors/public/InvalidAccessException.cs
sealed class. Thrown when a system tries to mutate a component it didn't declare via SystemBuilder.Writes<T>() / SideWrites<T>(). DEBUG builds only — the SystemAccessValidator compiles out in RELEASE. Indicates declaration drift; fix by adding the missing Writes<T> call. See 10-runtime for the access-declaration model.
3. Error codes
Errors/public/TyphonErrorCode.cs
A flat enum TyphonErrorCode organized into numeric ranges by subsystem. Codes are assigned sequentially within a range; gaps are intentional so codes can be inserted later without renumbering.
| Range | Subsystem | Codes |
|---|---|---|
| 0 | Unspecified | Unspecified = 0 |
| 1xxx | Transaction | TransactionTimeout = 1002, SnapshotExpired = 1003 |
| 2xxx | Storage | DataCorruption = 2003, StorageCapacityExceeded = 2004, PageChecksumMismatch = 2005, PageCacheBackpressureTimeout = 2006, DatabaseLocked = 2007 |
| 3xxx | Schema / Component | SchemaValidation = 3001, SchemaMigration = 3002 |
| 4xxx | Index | UniqueConstraintViolation = 4001 |
| 5xxx | Query | (reserved) |
| 6xxx | Resource | ResourceExhausted = 6001, LockTimeout = 6003 |
| 7xxx | Durability | WalBackPressureTimeout = 7001, WalClaimTooLarge = 7002, WalWriteFailure = 7003, WalSegmentError = 7004 |
| 8xxx | Runtime / Scheduler | InvalidSystemAccess = 8001 |
Notes:
LockTimeoutlives in the 6xxx Resource range, not 1xxx — locks are resource contention, not transaction logic.SchemaDowngradeExceptionreusesSchemaValidation(3001) — see §2.- Only Tier 1 codes are defined; reserved tiers (§8) extend the enum without renumbering existing values.
4. Result pattern — hot-path success / failure
Exceptions are appropriate for failures, but in the engine's hottest loops — B+Tree lookups, MVCC revision-chain reads — "not found" or "not visible at this snapshot" aren't failures, they're routine outcomes. Throwing on them would burn cycles on stack unwinding and frame allocation for what should be a register-level branch.
For those paths, Typhon uses Result<TValue, TStatus>:
[StructLayout(LayoutKind.Sequential)]
public readonly struct Result<TValue, TStatus>
where TValue : unmanaged
where TStatus : unmanaged, Enum
{
public readonly TValue Value;
public readonly TStatus Status;
public bool IsSuccess
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => Unsafe.As<TStatus, byte>(ref Unsafe.AsRef(in Status)) == 0;
}
}
Three constructors:
| Constructor | Use |
|---|---|
Result(TValue value) |
Successful result with default (zero) status. |
Result(TStatus status) |
Failure with default value. |
Result(TValue value, TStatus status) |
Both fields set — useful for cases like Deleted that carry revision metadata alongside a non-success status. |
The IsSuccess trick. Every status enum in Typhon follows the convention Success = 0. So IsSuccess reduces to: reinterpret the first byte of Status as a byte, compare to zero. No boxing, no virtual dispatch, no Enum.Equals — one movzx and one test on x64. The JIT inlines it everywhere.
Usage:
var r = btree.Lookup(key);
if (r.IsSuccess) { /* use r.Value */ }
else { /* r.Status == NotFound */ }
5. Status enums
Two status enums are exposed publicly today. Both are byte-backed (smallest possible) and both follow the Success = 0 convention.
BTreeLookupStatus
Indexing/public/BTree.LookupStatus.cs
public enum BTreeLookupStatus : byte
{
Success = 0,
NotFound = 1,
}
Lives in the Indexing/public/ folder — not Data/Index/... (an older path that no longer exists). Two values: the key was either found or it wasn't.
RevisionReadStatus
Revision/public/RevisionReadStatus.cs
public enum RevisionReadStatus : byte
{
Success = 0,
NotFound = 1, // entity has no chain at all
SnapshotInvisible = 2, // chain exists, but no element visible at reader's TSN
Deleted = 3, // tombstoned at or before reader's snapshot tick
}
Lives in Revision/public/ (again, not the old Data/Revision/ path). Four values reflect the four cases that arise from MVCC visibility — see 05-revision for the visibility predicate. Deleted is particularly useful: a caller often wants to distinguish "never existed" from "existed but tombstoned" without comparing BornTSN / DeadTSN themselves.
6. MigrationFailure — per-entity migration diagnostics
Errors/public/SchemaMigrationException.cs (same file)
When a SchemaMigrationException is thrown, the Failures array describes which entities failed and why:
public readonly struct MigrationFailure
{
public int ChunkId { get; init; } // logical entity identifier
public string OldDataHex { get; init; } // hex dump of the pre-migration bytes
public Exception Exception { get; init; } // the migration function's exception
}
SchemaMigrationException.Failures is the full list; the formatted message includes the first 10 entries and a ... and N more tail. The hex dump is what lets you reproduce the failure offline — feed it back through the migration function in a unit test.
7. ThrowHelper — keeping the throw out of the hot path
Errors/internals/ThrowHelper.cs
internal static class ThrowHelper is the engine's throw-call delegating layer. Every method follows the same shape:
[MethodImpl(MethodImplOptions.NoInlining)]
[DoesNotReturn]
public static void ThrowLockTimeout(string resourceName, TimeSpan waitDuration)
=> throw new LockTimeoutException(resourceName, waitDuration);
Two attributes matter:
[MethodImpl(MethodImplOptions.NoInlining)]— the JIT is told not to inline this. Why: if the throw site were inlined into the caller, the hot path would carry the object construction, the throw machinery, and (often) the cold message-formatting branch — bloating the caller's IL and pushing useful code out of the L1i cache. Keeping the throw in its own method puts the cold code in cold memory; the hot caller is left with just acallinstruction.[DoesNotReturn]— informs the C# nullability flow analysis and the JIT that control doesn't return. Lets callers omitreturn defaultafterThrowXxxwithout compiler complaints.
Pattern in practice:
public void TryAcquire(ref WaitContext ctx)
{
if (!Lock(ref ctx)) ThrowHelper.ThrowLockTimeout(_name, ctx.WaitDuration);
// hot code below — JIT'd in the same method, no throw machinery in sight
}
The hot method's IL stays compact; the throw lives in ThrowHelper's body, never inlined.
ThrowHelper currently has helpers for every Tier 1 exception listed in §2 plus a couple of ArgumentException / InvalidOperationException wrappers (e.g., the EnumerateRange API-misuse helpers for B+Trees). New throw sites should add a helper here rather than throwing inline.
8. Reserved tier — declared but not implemented
The error model was designed in tiers. Tier 1 is what ships today, and §2 lists every type that exists in Errors/public/. Several exceptions named in the original design — Tier 2 / Tier 3 — are reserved in the documentation but not present in code yet:
| Reserved exception | Intended for |
|---|---|
ComponentNotFoundException |
Reading a component slot that isn't registered. |
TransactionConflictException |
MVCC write-write conflict on commit. |
CapacityExceededException |
Bounded data structure over its hard cap (distinct from ResourceExhaustedException's soft policy). |
ComponentSchemaException |
Schema-related errors that don't fit SchemaValidation / SchemaMigration / SchemaDowngrade. |
EpochVoidedException |
An epoch-protected operation outlived its epoch. |
If your code path conceptually wants one of these, throw a TyphonException with an appropriate TyphonErrorCode for now (or add the type and update this doc). Don't catch them speculatively — the catch will be dead code until the type ships.
See also
- 01-foundation —
WaitContext,Deadline. AllTyphonTimeoutExceptioninstances originate at a deadline expiry detected here. - 02-storage —
PageCorruptionExceptionandCorruptionExceptionare thrown from the page-cache CRC verification path. - 04-schema — the schema exception family (
SchemaValidationException,SchemaMigrationException,SchemaDowngradeException) and theSchemaDiff/ migration model. - 06-ecs —
UniqueConstraintViolationExceptionpropagates here from index inserts duringSpawn/OpenMut. - 08-transactions —
TransactionTimeoutExceptionand the conflict-handler model (the reservedTransactionConflictExceptionslot in §8). - 11-durability —
WalWriteException(fail-fast, per ADR) andWalClaimTooLargeExceptionsemantics. - 13-resources —
ResourceExhaustedExceptionandExhaustionPolicy.