Table of Contents

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.

Typhon error model — throw, don't retry
The two-channel error model: routine non-success outcomes (not-found, not-visible) return a zero-cost Result<TValue, TStatus>; exceptional conditions throw via ThrowHelperTyphonException (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:

  • TyphonTimeoutException does not inherit from System.TimeoutException. Single-inheritance forces a choice; Typhon picks structured TyphonException over BCL TimeoutException so the error-code / transience contract is uniform. Catch TyphonTimeoutException for all engine timeouts.
  • No #nullable enable. Typhon doesn't use C# nullable reference types — exception properties are plain reference types. null checks 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: WalBackPressureTimeoutException is a timeout, not a durability error — it lives under TyphonTimeoutException, not under DurabilityException. 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; carries ExpectedCrc / 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 .lock file is held by another process; carries OwnerPid, OwnerMachine, StartedAt. The message instructs to close the other process or delete the .lock file if it crashed.

Durability family — DurabilityException

Errors/public/DurabilityException.cs

Things that go wrong below the commit boundary.

  • WalWriteExceptionfail-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; carries RequestedBytes and BufferCapacity. The claim can never succeed without reconfiguring the buffer — not transient.
  • WalSegmentException — segment file operation failed (creation, rotation, header validation); carries SegmentPath.

Resource exhaustion

Resources/public/ResourceExhaustedException.csdirect 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 full SchemaDiff for programmatic inspection (which field changed, what type, what attribute). See 04-schema.
  • SchemaMigrationException — one or more entities failed during a schema migration. Carries ComponentName and IReadOnlyList<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: SchemaDowngradeException reuses TyphonErrorCode.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:

  • LockTimeout lives in the 6xxx Resource range, not 1xxx — locks are resource contention, not transaction logic.
  • SchemaDowngradeException reuses SchemaValidation (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

Errors/public/Result.cs

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 a call instruction.
  • [DoesNotReturn] — informs the C# nullability flow analysis and the JIT that control doesn't return. Lets callers omit return default after ThrowXxx without 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-foundationWaitContext, Deadline. All TyphonTimeoutException instances originate at a deadline expiry detected here.
  • 02-storagePageCorruptionException and CorruptionException are thrown from the page-cache CRC verification path.
  • 04-schema — the schema exception family (SchemaValidationException, SchemaMigrationException, SchemaDowngradeException) and the SchemaDiff / migration model.
  • 06-ecsUniqueConstraintViolationException propagates here from index inserts during Spawn / OpenMut.
  • 08-transactionsTransactionTimeoutException and the conflict-handler model (the reserved TransactionConflictException slot in §8).
  • 11-durabilityWalWriteException (fail-fast, per ADR) and WalClaimTooLargeException semantics.
  • 13-resourcesResourceExhaustedException and ExhaustionPolicy.