Transaction Lifecycle, Thread Affinity & Pooling
The internals that make per-tick transaction churn cheap and misuse fail fast instead of corrupting state.
Status: โ Implemented ยท Visibility: Public ยท Level: ๐ฃ Advanced ยท Category: Transactions
๐ฏ What it solves
A game tick or request handler can create and dispose hundreds of Transaction objects per second; if each one were
a fresh heap allocation guarded by a global lock, that churn alone would dominate the tick budget. At the same time,
a Transaction is not thread-safe internally โ using one from two threads, or calling CRUD on one that already
committed, are both programmer errors that must surface immediately and loudly, not corrupt a chunk or silently
no-op. And because every transaction's lifetime overlaps with MVCC snapshot isolation, something has to track which
transaction is the oldest still active, so revision-chain garbage collection knows how far back it's safe to reclaim.
This feature is the machinery behind Transaction that resolves all three: cheap reuse, fail-fast misuse detection,
and the bookkeeping that drives MVCC GC.
โ๏ธ How it works (in brief)
Every Transaction instance is created on one thread and asserts ([Conditional("DEBUG")]) that every subsequent
call comes from that same thread โ AssertThreadAffinity() guards every public mutating method. A small state
machine (Created โ InProgress โ Committed/Rollbacked) backs every transaction; EnsureMutable() throws
InvalidOperationException if CRUD is attempted on an already-finished transaction, and TransitionTo() asserts
(Debug-only) that only legal transitions occur. Underneath, TransactionChain hands out and reclaims Transaction
instances: CreateTransaction allocates a monotonic TSN (Interlocked.Increment) and pushes the new transaction
onto a singly-linked chain via a lock-free CAS loop (PushHead) โ concurrent creators never block each other.
Dispose() removes the transaction under a brief exclusive lock (Remove), recycles it into a 16-instance pool, and
โ if the removed transaction was the chain's tail (the oldest active TSN) โ recomputes the new minimum TSN, which is
the horizon the deferred-cleanup GC uses to know which revisions no MVCC snapshot can still see.
๐ป Usage
[Component("Game.Position", 1, StorageMode = StorageMode.SingleVersion)]
struct Position { public float X, Y, Z; }
[Archetype]
partial class Unit : Archetype<Unit>
{
public static readonly Comp<Position> Pos = Register<Position>();
}
// Pooling is transparent: each iteration draws/returns a Transaction from TransactionChain's
// 16-instance pool โ no per-tick heap allocation as long as concurrency stays at or below 16.
for (int tick = 0; tick < 10_000; tick++)
{
using var tx = dbe.CreateQuickTransaction();
tx.Spawn<Unit>(Unit.Pos.Set(new Position { X = tick }));
tx.Commit();
} // Dispose() resets the instance and returns it to the pool
// The state machine fails fast: CRUD after Commit/Rollback throws instead of silently no-opping.
using var tx2 = dbe.CreateQuickTransaction();
tx2.Spawn<Unit>(Unit.Pos.Set(new Position { X = 1 }));
tx2.Commit(); // tx2.State == TransactionState.Committed
try
{
tx2.Spawn<Unit>(Unit.Pos.Set(new Position { X = 2 }));
}
catch (InvalidOperationException)
{
// "Cannot perform CRUD on a transaction in state Committed" โ programmer error, not a no-op (ADR-038)
}
โ ๏ธ Guarantees & limits
- Thread affinity is Debug-only โ
AssertThreadAffinity()is[Conditional("DEBUG")]; it is compiled out entirely in Release builds, so cross-thread use of oneTransactionis caught during development but is undefined behavior (not a runtime exception) in Release. OneTransactionper thread per tick is the supported pattern. - Fail-fast state machine, no silent sentinels โ
Created โ InProgress โ Committed/Rollbackedare the only legal transitions; CRUD on an already-finished transaction throwsInvalidOperationExceptionrather than returning-1/false(ADR-038).Commit()/Rollback()themselves are exceptions to this: calling either on an already-finished transaction returnsfalse, and on a still-Created(no-op) transaction returnstrue. - Lock-free creation, briefly-locked removal โ
PushHeadis a CAS loop with zero blocking between concurrentCreateTransactioncallers;Remove(triggered byDispose()) takes a short exclusive lock to unlink the node, scanning at mostMaxActiveTransactionsnodes (~500ns worst case at 128 active transactions). - Pooling is a perf optimization, not a hard cap โ up to 16
Transactioninstances are recycled via aConcurrentQueue; beyond that, new instances are heap-allocated. The engine separately caps total active transactions (maxActiveTransactions); exceeding it throwsResourceExhaustedExceptionrather than blocking. - MinTSN drives MVCC GC, not just diagnostics โ only the chain's tail (the transaction holding the oldest active
TSN) triggers
DeferredCleanupManager.ProcessDeferredCleanupsonDispose()/Commit()/Rollback(). A long-lived transaction at the tail stalls revision-chain reclamation engine-wide โ the same hazard PostgreSQL's vacuum has againstxmin. - TSN space is large but finite โ TSNs are assigned via
Interlocked.Increment; the engine logs a warning as the practical ceiling (1 << 46) approaches (no enforcement failure observed at realistic throughput). - Known scaling limit (not yet implemented) โ
Remove's exclusive lock and O(n) scan remain a serialization point projected to matter only past ~64-128 cores. A lock-freeTransactionRegistryredesign is drafted (see Related) but not built;TransactionChainis the shipping implementation.
๐งช Tests
- TransactionChainTests โ
MPSC_ConcurrentCreates_AllTSNsUnique(lock-freePushHeadunder concurrent creation),MinTSN_DisposeTail_Advances(MVCC-GC horizon on tail dispose),Stress_ConcurrentCreateDispose_ActiveCountZeroAtEnd; the file's second fixtureTransactionChainMaxActiveTestscovers themaxActiveTransactionscap andResourceExhaustedException - TransactionTests โ
TransitionTo_IllegalTransition_DebugAssertFails(state-machine legality),CrudAfterCommitOrRollback_ThrowsInvalidOperation,Dispose_Idempotent_SecondCallNoOp
๐ Related
- Related features: Unit of Work (the three-tier API these internals back), Transaction Creation Patterns, Commit / Rollback Pipeline