A command cancels an order, creates its replacement and then checks the account's open-order limit. Each step reads state that the same command has just changed. Looking only at the stored records would count the cancelled order and miss the replacement. Updating the records immediately would make a later business rejection difficult to undo.
StateVec handles this through a stacked data view: a transaction reads its own changes over the accepted changes that precede it, with the node's applied state underneath. Record reads, unique-key lookups and canonical indexes all use this view. The handler can make a complete business decision while its effects remain provisional.
The important property is agreement between those access paths. If a record now says Cancelled, an index over open orders must stop counting it. If a new order is visible by its record ID, its unique key must resolve to that same record. A rejection must remove all of the command's effects together.
The state a command is allowed to see
The base is committed state already applied by the node, also called canonical state. Simulation adds layers above it without modifying that base.
| Layer, newest first | What it contains | When it becomes visible |
|---|---|---|
| Current command | Its provisional record writes, key changes and events | During that command's execution |
| Current batch | Accepted effects from earlier commands in the batch | After each earlier command succeeds |
| Earlier prepared groups, when present | An ordered prefix that continued simulation depends on | When that prefix is supplied as the simulation base |
| Applied base | Records and access paths already installed on the node | Before the new simulation begins |
Here, accepted means that a command passed its business rules. Cluster commitment is a separate step. An accepted command may supply state to later simulation while its changes are still absent from canonical state.
In the implementation, TxAccess owns the current command's writes. BatchOverlayView exposes the accepted changes in the current batch, and StackedBatchOverlayView can read through earlier prepared groups. These views compose over a borrowed base; they do not require a complete copy of the business state for each command.
Later commands are outside the view. Command 12 can see accepted effects from command 11, but it cannot see command 13. Every replica must reconstruct that same ordered prefix before evaluating command 12.
A record lookup has three answers at each layer
A layer must distinguish no entry, a value, and a deletion.
| What the layer says | What the read does |
|---|---|
| No entry for this record | Continue to the next older layer |
| Created or updated record | Return this version and stop |
| Deleted record | Return missing and stop |
A deletion is often called a tombstone. It is an explicit instruction to hide the older record. Treating it as a cache miss would make a deleted record reappear from the base.
For example, suppose the applied base contains account 7 with a balance of 100. An earlier accepted command in the batch changes it to 125. The current command stages a balance of 110. A read in that command returns 110. If the command is refused, its layer is discarded and the next command sees 125. If it stages a deletion instead, its own read returns missing even though older layers still contain the account.
The same precedence applies whether the handler reads a record repeatedly or reaches it through another access path. A lookup must not mix a newer identity mapping with stale record bytes.
Unique keys resolve identity through the same stack
A unique key, or UK, maps a business identity to an engine record ID. An order might use (account_id, client_order_id). The record kind and UK identifier select the appropriate mapping; the key's encoded fields identify one live record within it.
The engine stages these mappings alongside the record changes:
| Operation | Record view | Unique-key view |
|---|---|---|
| Create an order | New record is readable | Its key resolves to the new record |
| Update quantity or status | Updated record is readable | The existing identity still resolves to it |
| Delete the order | Record is hidden | Its key is explicitly removed from the visible mapping |
| Refuse the command | Its provisional changes disappear | Its provisional mappings disappear with them |
A unique-key lookup searches newer mappings first, then resolves the resulting record ID through the layered record view. Both steps matter. Finding the right ID is insufficient if the record read then bypasses a staged update or deletion.
Unique-key fields are immutable in the current schema contract. Changing an order's status updates its record and relevant indexes; it does not rewrite the order's identity. A cancelled order that remains stored therefore still occupies its unique key. Applications can use that retained identity to recognize duplicate requests according to their own business rules.
Uniqueness checks include the current command and the accepted prefix below it. Two creates cannot claim the same live identity simply because neither record has reached canonical state yet. Handlers should detect an expected duplicate and return a business refusal; an unchecked uniqueness violation is a host error.
An index update changes membership and order
A canonical index is a schema-defined access path used during execution. For an index over (account_id, status), cancelling an order changes where it belongs even though its unique key stays the same.
The engine derives index effects from the record's earlier and current values:
- A create adds an entry for the new indexed fields.
- A delete removes the entry for the earlier fields.
- An update removes the old entry and adds the new entry when the indexed key changes.
- A change to an unrelated field leaves index membership unchanged.
Entries are ordered by encoded index key, then record ID. The record ID supplies a deterministic tie-breaker when several records share the same indexed fields. Appending staged records in insertion order would not preserve that ordering.
A visible index is built by filtering out removed entries and merging in additions. With several layers, the effects are composed in order so that newer changes can replace earlier ones. This lets a record move between prefixes more than once during a batch without leaving duplicate or obsolete entries behind.
Limits apply to the visible result
Suppose a query needs the first two visible entries in that example. Taking only the first two base entries, 41 and 42, would be wrong: both are removed. Adding 43 would produce a single result and miss 45.
The engine must inspect enough older candidates to account for removals, then apply the output limit to the merged result. The implementation expands the lower-layer scan budget by the relevant removal count. Across stacked groups, it carries that requirement down toward the base and merges results back upward.
This also matters for capped counts. A cancelled order must not consume an open-order slot, and a newly accepted order must consume one immediately for the next command. Counting only the base, or adding staged inserts without subtracting removals, can accept too many orders or refuse a valid one.
A batch, command by command
Consider account 7 with two applied open orders, 41 and 42. Three commands run in one simulation batch:
| Command | View during execution | Outcome | Open orders visible to the next command |
|---|---|---|---|
| Cancel order 41 | Order 41 becomes Cancelled; it leaves the Open index prefix | Accepted | 42 |
| Create order 43 | Its record, unique key and Open index entry are added | Accepted | 42, 43 |
| Create order 44, then fail a risk check | The command can temporarily read its new record and index entry | Refused | 42, 43 |
The third command may have staged several writes before its final check fails. Finalization discards its record changes, unique-key mutations and provisional events. Its index effects disappear with those writes. The accepted effects of commands 1 and 2 remain available to the rest of the batch.
The applied base is still unchanged during simulation: it contains the original open orders 41 and 42. The batch overlay contains the candidate cancellation of 41 and creation of 43. No part of order 44 may remain in that candidate.
A refusal still has an outcome and a position in transaction history. Batching does not turn these commands into one business transaction whose rejection rolls back all its neighbors. Each command keeps its own success or refusal, even when their accepted changes are installed together later.
The batch can retain the latest staged version of a repeatedly changed record instead of copying every intermediate version into the final installation. That optimization must preserve each command's outcome and execution evidence.
Use bounded index questions in business logic
The typed plugin API exposes canonical indexes through count_index_prefix_capped. It answers a bounded business question such as “has this account reached its open-order limit?” The ordered scans discussed above explain how the engine constructs that answer internally.
For a generated Order index named by_account_status over (account_id, status), a handler can check a positive limit before creating another order. This fragment uses a u8 status value named OPEN and an application error type that converts host errors:
use statevec::CanonicalIndexCount;
let open_orders = tx.count_index_prefix_capped::<Order, _>(
Order::BY_ACCOUNT_STATUS_INDEX_ID,
Order::index_by_account_status(&account_id, &OPEN),
limit,
)?;
if matches!(open_orders, CanonicalIndexCount::AtLeast(_)) {
return Err(OrderError::OpenOrderLimit);
}Exact(n) means fewer than limit entries exist and the count is known. AtLeast(limit) means the threshold has been reached; it does not claim that the total is exactly limit. A handler enforcing a limit has enough information to decide without asking for a complete result set.
Composite-key order determines which prefixes are useful. With (account_id, status), the account prefix covers all orders for that account, while the full key selects one status within it. A status-only prefix does not select that status across every account. The generated key and prefix helpers use the schema's encoding, so handlers should use them consistently.
The current schema allows up to three fields in a canonical index. Choose them around bounded decisions the handler actually makes. Broader reporting and search belong in downstream views.
Simulation prepares a result; apply installs it
In Verify mode, the leader simulates commands against the layered view and followers repeat the execution from the corresponding starting state. The resulting changes and execution evidence can be compared before replication is acknowledged. The stack makes those candidate results available without installing them as live business state.
After commitment, a node may reuse its own prepared result when the starting state, inputs and required execution evidence match exactly. Otherwise it re-executes and checks. An overlay belongs to a particular base and ordered prefix; a cached result cannot be attached to a different base merely because the final command bytes look familiar.
In Performance mode, business execution begins at committed apply. The transaction and batch views still give handlers read-your-writes behavior and keep each business refusal from leaking partial changes. The difference is when execution occurs relative to commitment.
A panic or an execution failure has a separate path from a business refusal. The engine cannot continue a simulation whose result is undetermined as if it were an ordinary rejected order. An execution fault while applying committed work stops the node; recovery reconstructs state from the selected checkpoint and committed log.
What this design saves, and what it costs
The layered view avoids copying the complete state for every command. Unchanged records remain in the base; overlays hold changed records, key mappings, index additions and removals, and provisional results. Discarding a refused command removes its provisional work instead of requiring compensating writes to the live store.
That work still has a cost. Modified record bytes consume memory. Key lookups may search several layers. Index merges must account for hidden entries, and a prefix with many staged removals may require reading more base candidates than the result cap alone suggests. Retaining more prepared groups also retains more pending state.
Short handlers, bounded batches and appropriately capped queries keep those costs manageable. The engine can reuse scratch allocations and maintain accumulated index effects, but each optimization must preserve the same visible records, identities and ordered results.
The practical contract for a handler is precise: read its own writes, include the accepted commands before it, exclude later commands, and make every access path agree. Those are the conditions that let the same business logic run during simulation, verification and recovery and reach the same decision.
Further reading
- StateVec Overview connects the transaction view to the cluster and recovery model.
- Plugin contract in 5 minutes introduces typed handlers, records and business refusals.
- Power of Simulation follows a candidate result through verification and apply.
- Verify mode and Performance mode explains where execution happens relative to commitment.