anomaly

package
v0.11.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: MIT Imports: 4 Imported by: 0

Documentation

Overview

Package anomaly classifies an observed transaction history against the standard isolation phenomena, so a sighting names a MECHANISM instead of starting a search.

Why it exists

When the DST and example batteries observe an isolation failure they report a domain symptom — "readers observed a torn total; first torn value X, expected Y" — and nothing else. That symptom is compatible with several distinct defects with different root causes, which is a direct cause of what rmp #2333 cost and of why rmp #2336 is still open: the evidence names what LOOKED wrong, not what was VIOLATED.

This package turns a recorded history into a named, classified violation.

The formalism, and its sources

The model is Adya's dependency-graph formulation, which is the basis both of the ANSI-critique literature and of Elle, the checker Jepsen uses:

  • Adya, Liskov & O'Neil, "Generalized Isolation Level Definitions", ICDE 2000 — the direct serialization graph (DSG) over read-, write- and anti-dependencies, and the phenomena G0, G1a, G1b, G1c, G2 and G2-item. Snapshot isolation is PL-SI there.
  • Berenson, Bernstein, Gray, Melton & O'Neil, "A Critique of ANSI SQL Isolation Levels", SIGMOD 1995 — P4 lost update and A5B write skew, and the observation that snapshot isolation permits the latter.
  • Kingsbury & Alvaro, "Elle: Inferring Isolation Anomalies from Experimental Observations", VLDB 2020, and its implementation (github.com/jepsen-io/elle, src/elle/consistency_model.clj, read 2026-08-08) — the machine-checked anomaly lattice this package's level boundaries were verified against rather than reconstructed from memory.
  • Cerone, Bernardi & Gotsman, "A Framework for Transactional Consistency Models with Atomic Visibility", CONCUR 2015 — the characterisation of generalized snapshot isolation that makes G-nonadjacent, not merely G-single, the right boundary. See SnapshotIsolation.

Concurrency

A History is plain data and safe to share once built. Recorder is safe for concurrent use; Check is a pure function of its input.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func BuildDSG

func BuildDSG(h *History) (*DSG, []Anomaly)

BuildDSG constructs the dependency graph, and reports the G1a and G1b phenomena it necessarily discovers on the way.

G1a and G1b are found HERE rather than by a cycle search because neither is a cycle: G1a is a read of a version an aborted transaction wrote, and G1b is a read of a version its writer later superseded within the same transaction. Both are properties of a single read, and both are invisible to any amount of graph traversal.

Types

type Anomaly

type Anomaly struct {
	// Detail explains the finding in the terms of the history: which
	// transactions, which keys, which versions.
	Detail string
	// Txns are the transactions involved, in cycle order where the anomaly is a
	// cycle.
	Txns []TxID
	// Cycle is the edge sequence, empty for the non-cycle phenomena.
	Cycle []Edge
	// Type names the phenomenon.
	Type Phenomenon
}

Anomaly is one classified finding.

func (Anomaly) String

func (a Anomaly) String() string

type DSG

type DSG struct {
	// Out[t] are the edges leaving transaction t.
	Out map[TxID][]Edge
	// Nodes are the committed transaction ids, sorted, so every traversal of the
	// graph is deterministic and two runs classify a history identically.
	Nodes []TxID
}

DSG is Adya's direct serialization graph over the COMMITTED transactions of a history.

Aborted transactions are deliberately absent: Adya's DSG is defined over committed transactions, and a read of an aborted transaction's write is not an edge but the separate phenomenon G1a. Including them would turn every aborted write into a spurious cycle.

type Dep

type Dep uint8

Dep is the kind of dependency an edge carries. Adya's three, and only his three: everything the checker concludes is a statement about paths made of these.

const (
	// WW is a write-dependency: Ti installs a version of x, and Tj installs the
	// version of x that immediately follows it in the version order.
	WW Dep = iota
	// WR is a read-dependency: Ti installs a version of x, and Tj reads it.
	WR
	// RW is an anti-dependency: Ti reads a version of x, and Tj installs the
	// version of x that immediately follows the one Ti read.
	RW
)

func (Dep) String

func (d Dep) String() string

type Edge

type Edge struct {
	Key  string
	From TxID
	To   TxID
	Dep  Dep
}

Edge is one dependency between two committed transactions.

func (Edge) String

func (e Edge) String() string

type History

type History struct {
	Txns []Txn
}

History is a set of transactions observed from one execution.

func (*History) Validate

func (h *History) Validate() error

Validate reports whether the history is self-consistent enough to be checked.

It is not a formality. Every failure it catches would otherwise surface as a confident, wrong classification: two transactions claiming the same version of a key make the version order ambiguous, so the anti-dependency edges — and therefore every G2 verdict — would be drawn from a graph that does not describe any execution. A checker that answers from a malformed history is worse than no checker, because its answer is believed.

type Level

type Level int

Level is an isolation level, named by what it FORBIDS.

const (
	// ReadUncommitted is Adya's PL-1: forbids G0 only.
	ReadUncommitted Level = iota
	// ReadCommitted is Adya's PL-2: forbids G0 and G1 (= G1a ∪ G1b ∪ G1c).
	ReadCommitted
	// SnapshotIsolation is Adya's PL-SI, and it is the level GoGraph targets.
	//
	// It forbids G0, G1 and G-nonadjacent — and PERMITS G2-item cycles whose
	// anti-dependency edges are adjacent, which is exactly write skew.
	//
	// THE BOUNDARY IS G-NONADJACENT, NOT G-SINGLE, and getting that right is the
	// substance of this checker. Adya's PL-SI forbids G-SIb, "a cycle with
	// exactly one anti-dependency edge" — Elle's G-single. Cerone, Bernardi &
	// Gotsman's characterisation of GENERALIZED snapshot isolation is stronger:
	// it forbids any cycle whose anti-dependency edges are pairwise
	// non-adjacent, of which a single anti-dependency is the degenerate case.
	// Elle adopts the stronger form and says why, in
	// src/elle/consistency_model.clj (read 2026-08-08): "Chatting with Alexey
	// Gotsman about this confirms my suspicion: generalized SI forbids *any*
	// history where all rw edges are nonadjacent, not just G-single."
	//
	// The two classic anomalies fall on opposite sides of it, and that is the
	// check that matters:
	//
	//   - LOST UPDATE (P4): T1 -rw-> T2 -ww-> T1. One anti-dependency, so the
	//     cycle is G-single ⊂ G-nonadjacent ⇒ FORBIDDEN. Snapshot isolation
	//     prevents it by first-committer-wins on the shared key.
	//
	//   - WRITE SKEW (A5B): T1 -rw-> T2 -rw-> T1. Two anti-dependencies, and
	//     they are ADJACENT — each transaction is both entered and left by one —
	//     so the cycle is G2-item but NOT G-nonadjacent ⇒ PERMITTED. The two
	//     transactions write different keys, so nothing conflicts.
	//
	// A checker that flagged legal write skew would be worse than none, because
	// every clean run would carry a false violation and the real ones would stop
	// being read.
	SnapshotIsolation
	// Serializable is Adya's PL-3: forbids G0, G1 and G2 — any cycle at all.
	Serializable
)

func (Level) String

func (l Level) String() string

type Op

type Op struct {
	// Key is the object read or written.
	Key string
	// Ver is the version observed (for a Read) or installed (for a Write).
	Ver Version
	// Kind selects read or write.
	Kind OpKind
}

Op is one read or write of one key.

func (Op) String

func (o Op) String() string

type OpKind

type OpKind uint8

OpKind distinguishes a read from a write.

const (
	// Read observes a version of a key.
	Read OpKind = iota
	// Write installs a version of a key.
	Write
)

func (OpKind) String

func (k OpKind) String() string

type Phenomenon

type Phenomenon int

Phenomenon is a named isolation anomaly.

The names are Adya's (ICDE 2000) except where Elle's are now the common currency; both are given where they differ.

const (
	// G1a is an ABORTED READ: a transaction read a version written by a
	// transaction that aborted.
	G1a Phenomenon = iota
	// G1b is an INTERMEDIATE READ: a transaction read a version of a key that
	// its writer superseded before committing, so no committed state ever held
	// it.
	G1b
	// G0 is a WRITE CYCLE: a cycle in the DSG made entirely of write-
	// dependencies. It means two transactions' writes to different keys were
	// applied in opposite orders.
	G0
	// G1c is CIRCULAR INFORMATION FLOW: a cycle made of write- and read-
	// dependencies. Each transaction in the cycle depends on information the
	// next one produced.
	G1c
	// GSingle (Elle's G-single; Adya's G-SIb, "missed effects") is a cycle with
	// EXACTLY ONE anti-dependency edge. Lost update has this shape.
	GSingle
	// GNonadjacent is a cycle whose anti-dependency edges are never adjacent to
	// one another. It generalises GSingle and is the boundary snapshot
	// isolation actually draws; see [SnapshotIsolation].
	GNonadjacent
	// G2Item is an ANTI-DEPENDENCY CYCLE over individual items: any cycle
	// carrying at least one anti-dependency edge. Write skew has this shape, and
	// snapshot isolation PERMITS it.
	G2Item
	// Unwritten is not one of Adya's phenomena. It reports a read of a version
	// no transaction in the history wrote, which means the history is
	// INCOMPLETE. It is surfaced rather than skipped because an incomplete
	// history silently produces a clean verdict, and a clean verdict from
	// missing data is the worst output this package could give.
	Unwritten
)

func (Phenomenon) String

func (p Phenomenon) String() string

type Recorder

type Recorder struct {
	// contains filtered or unexported fields
}

Recorder accumulates a history from a running workload.

It is SHARDED AND PRE-SIZED, and neither is a micro-optimisation. Measured (rmp #2341 AC5, interleaved arms, 30 repetitions of a 6-writer/6-reader defective workload, ratio of anomaly rate with recorder to without):

one mutex-guarded slice   0.877 0.844 0.817 0.814 0.937 0.898  mean 0.865
one shard per goroutine   0.993 1.021 0.854 0.841 0.776 0.993  mean 0.913
sharded and pre-sized     1.159 1.163 0.937 0.821 0.881 1.002  mean 0.994

The first row is a REAL 13% suppression, not noise: every one of the six runs is below 1.0. A lock taken at the end of every transaction serialises the writers just enough to reduce their overlap, and reduced overlap is reduced tearing — the module's recorded lesson happening again in miniature, a probe that quietens the defect it was added to find. Sharding removed the shared word; pre-sizing removed the reallocation traffic that was left; the third row straddles 1.0 symmetrically and there is no effect left to find.

It took an INTERLEAVED design to see any of this. Run as twelve recorded repetitions followed by twelve unrecorded ones, the same shared-lock recorder measured 0.95, 0.74, 1.27, 0.73, 0.86 — a spread wide enough to look like noise, because the ordering drift was larger than the effect.

Recorder is safe for concurrent use. A Shard is NOT: it belongs to one goroutine, which is what makes it lock-free.

func (*Recorder) History

func (r *Recorder) History() History

History returns the accumulated history, with transactions in a deterministic order (by commit instant, then by id) so that two runs of the same execution yield the same history and therefore the same classification.

Call it after the workload has finished: it reads every shard without synchronising against its owner, because the point of a shard is that nothing synchronises on the recording path.

func (*Recorder) Len

func (r *Recorder) Len() int

Len reports how many transactions have been recorded.

func (*Recorder) Record

func (r *Recorder) Record(t Txn)

Record appends one finished transaction directly, taking the registry lock.

It exists for the callers that record from a single goroutine — a fixture build, a sequential replay — where a shard would be ceremony. Anything on a concurrent path must use Recorder.Shard instead; see the type doc for what the shared lock measured.

func (*Recorder) Shard

func (r *Recorder) Shard(hint int) *Shard

Shard returns a fresh private buffer belonging to the calling goroutine, pre-sized for hint transactions.

The hint matters for the same reason the sharding does: a growing append reallocates and copies, and that memory traffic is the residual perturbation left after the shared lock was removed. Pass the number of transactions the goroutine will record when it is known; pass 0 and the buffer grows as usual.

Safe for concurrent use: only the registration is shared, and it happens once per goroutine rather than once per transaction.

type Report

type Report struct {
	// Level is the level the history was judged against.
	Level Level
	// Violations are the anomalies that level forbids.
	Violations []Anomaly
	// Permitted are anomalies that were present but are LEGAL at this level —
	// write skew under snapshot isolation, above all. They are reported, not
	// hidden, because "your engine exhibits write skew and that is allowed here"
	// is information, and because silently discarding them would make it
	// impossible to tell a checker that found nothing from one that found
	// something and swallowed it.
	Permitted []Anomaly
	// Truncated records that the cycle search hit its bound.
	//
	// IT INVALIDATES A CLEAN VERDICT, NOT A VIOLATION. "No violation found"
	// under truncation means "none found within the bound" and is worth nothing;
	// the violations that WERE found are real, because each one is a cycle that
	// exists in the graph. A caller expecting a violation may therefore trust
	// what is reported and ignore this flag; a caller concluding cleanliness
	// must not. [Report.Clean] encodes that asymmetry.
	//
	// Never silently true: the rendered report leads with INCONCLUSIVE.
	Truncated bool
	// Txns is how many transactions were examined.
	Txns int
	// Edges is how many dependency edges the DSG carried.
	Edges int
}

Report is the outcome of checking a history.

func Check

func Check(h *History, level Level) (*Report, error)

Check classifies a history against an isolation level.

The classification is DETERMINISTIC: the graph's nodes and each node's edges are sorted at construction, and the search visits them in that order, so the same history always yields the same report. That matters because the report is the evidence a future sighting is compared against.

func (*Report) Clean

func (r *Report) Clean() bool

Clean reports whether the history is known to satisfy the level.

A truncated search is never clean, however few violations it found: absence of evidence under a bound is not evidence of absence. See Report.Truncated for why the converse does not hold.

func (*Report) String

func (r *Report) String() string

String renders the report for a test failure or a log line.

type Shard

type Shard struct {
	// contains filtered or unexported fields
}

Shard is one goroutine's private append buffer.

Obtain one per producing goroutine with Recorder.Shard and use it for that goroutine's whole life. Recording through it touches no shared memory, so it cannot perturb the timing of what it observes.

Shard is NOT safe for concurrent use.

func (*Shard) Record

func (s *Shard) Record(t Txn)

Record appends one finished transaction. No lock, no atomic, no shared write.

type TxID

type TxID uint64

TxID identifies a transaction within one history.

type Txn

type Txn struct {
	// Ops are this transaction's operations in the order it issued them. Order
	// matters: an intermediate read (G1b) is defined by which of a transaction's
	// writes to a key was its LAST one.
	Ops []Op
	// ID identifies the transaction.
	ID TxID
	// Start is the instant the transaction took its snapshot.
	Start uint64
	// Commit is the instant the transaction became visible, or 0 when it aborted
	// or never finished.
	Commit uint64
	// Aborted records that the transaction did not commit. An aborted
	// transaction is NOT a node of the dependency graph — Adya's DSG is over
	// committed transactions — but its writes are still what a G1a aborted read
	// observes, which is why it stays in the history.
	Aborted bool
}

Txn is one transaction's observed history: what it read, what it wrote, when it began, and how it ended.

type Version

type Version uint64

Version identifies one version of one key. Versions of a key are totally ordered by their numeric value, which is what makes GoGraph's MVCC commit timestamp usable directly: the version order Adya's model requires is the commit order the engine already maintains.

const InitVersion Version = 0

InitVersion is the version every key holds before any transaction in the history writes it. It is written by no transaction, so a read of it creates no read-dependency — which is correct: nothing in the history produced it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL