Documentation
¶
Overview ¶
Package txnproof detects non-atomic SQL execution: multiple write statements that run inside one logical boundary (a use case, a request, a job) without being wrapped in a single database transaction.
It works as a database/sql driver middleware, so the same detector serves three modes: pure unit tests (via NewNullDB), tests against a real database, and continuous production monitoring (via pluggable Reporters).
Index ¶
- Constants
- func AllowNonAtomicHere(ctx context.Context, reason string, exactWriteUnits ...int)
- func SlogAttrs(attrs []BoundaryAttr) []slog.Attr
- type Allowlist
- type Baseline
- type BaselineReporter
- func (r *BaselineReporter) Report(ctx context.Context, v Violation)
- func (r *BaselineReporter) ReportNestedBoundary(ctx context.Context, n NestedBoundary)
- func (r *BaselineReporter) ReportStaleAllow(ctx context.Context, s StaleAllow)
- func (r *BaselineReporter) ReportUnboundedWrite(ctx context.Context, s StatementRecord)
- type Boundary
- type BoundaryAttr
- type BoundaryOption
- type Classifier
- type CollectingReporter
- func (r *CollectingReporter) NestedBoundaries() []NestedBoundary
- func (r *CollectingReporter) Report(_ context.Context, v Violation)
- func (r *CollectingReporter) ReportNestedBoundary(_ context.Context, n NestedBoundary)
- func (r *CollectingReporter) ReportStaleAllow(_ context.Context, s StaleAllow)
- func (r *CollectingReporter) ReportUnboundedWrite(_ context.Context, s StatementRecord)
- func (r *CollectingReporter) RequireNoNestedBoundaries(t TestingT)
- func (r *CollectingReporter) RequireNoStaleAllows(t TestingT)
- func (r *CollectingReporter) RequireNoUnboundedWrites(t TestingT)
- func (r *CollectingReporter) RequireNoViolations(t TestingT)
- func (r *CollectingReporter) Reset()
- func (r *CollectingReporter) StaleAllows() []StaleAllow
- func (r *CollectingReporter) UnboundedWrites() []StatementRecord
- func (r *CollectingReporter) Violations() []Violation
- type Detector
- func (d *Detector) InBoundary(ctx context.Context, name string, f func(context.Context) error, ...) error
- func (d *Detector) NewNullDB() *sql.DB
- func (d *Detector) NewSession() *Session
- func (d *Detector) StartBoundary(ctx context.Context, name string, opts ...BoundaryOption) (context.Context, *Boundary)
- func (d *Detector) Wrap(drv driver.Driver) driver.Driver
- func (d *Detector) WrapConnector(c driver.Connector) driver.Connector
- type NestedBoundary
- type NestedBoundaryReporter
- type Option
- func WithAllowlist(a *Allowlist) Option
- func WithBoundaryAttrsFunc(f func(ctx context.Context) []BoundaryAttr) Option
- func WithClassifier(c Classifier) Option
- func WithMaxRecordedStatements(n int) Option
- func WithNestedBoundaryDetection() Option
- func WithReporter(rs ...Reporter) Option
- func WithUnboundedWriteDetection() Option
- type Reporter
- type ReporterFunc
- type Session
- type SlogReporter
- func (r *SlogReporter) Report(ctx context.Context, v Violation)
- func (r *SlogReporter) ReportNestedBoundary(ctx context.Context, n NestedBoundary)
- func (r *SlogReporter) ReportStaleAllow(ctx context.Context, s StaleAllow)
- func (r *SlogReporter) ReportUnboundedWrite(ctx context.Context, s StatementRecord)
- type StaleAllow
- type StaleAllowReporter
- type StatementKind
- type StatementRecord
- type TestingT
- type ThrottlingReporter
- func (r *ThrottlingReporter) Report(ctx context.Context, v Violation)
- func (r *ThrottlingReporter) ReportNestedBoundary(ctx context.Context, n NestedBoundary)
- func (r *ThrottlingReporter) ReportStaleAllow(ctx context.Context, s StaleAllow)
- func (r *ThrottlingReporter) ReportUnboundedWrite(ctx context.Context, s StatementRecord)
- func (r *ThrottlingReporter) SuppressedNestedBoundaries() map[string]int
- func (r *ThrottlingReporter) SuppressedStaleAllows() map[string]int
- func (r *ThrottlingReporter) SuppressedUnboundedWrites() map[string]int
- func (r *ThrottlingReporter) SuppressedViolations() map[string]int
- type UnboundedWriteReporter
- type Violation
Constants ¶
const Version = "0.3.0"
Version is the released version of go-txnproof. It is kept in sync with the latest git tag by tagpr (https://github.com/Songmu/tagpr); do not edit it by hand.
Variables ¶
This section is empty.
Functions ¶
func AllowNonAtomicHere ¶ added in v0.2.0
AllowNonAtomicHere marks the boundary in ctx as intentionally non-atomic, suppressing its Violation exactly like the AllowNonAtomic boundary option: the two differ only in where the exemption lives, never in what it can express. The reason and the optional exactWriteUnits mean the same thing there, down to falling through to the central Allowlist when the boundary finishes with a count the mark does not cover.
It exists because a boundary is usually started far away from the code that makes it non-atomic — in a middleware or a use-case entry point, while the reason for the extra write is at the extra write. Marking it there keeps the explanation next to the code it explains, and keeps that explanation running code rather than a comment:
// The audit row is written outside the domain transaction on purpose, so a // failing audit sink cannot roll back the business change (TICKET-123). txnproof.AllowNonAtomicHere(ctx, "audit write is best-effort (TICKET-123)", 2) _, err := db.ExecContext(ctx, "INSERT INTO audit ...")
The mark applies to the innermost boundary in ctx, and it does not matter when during the boundary's life it is called: the evaluation happens at Finish. The last mark wins, replacing any earlier one (including one made by the AllowNonAtomic option). Calling it with no boundary in ctx, or after the boundary has finished, does nothing — the same way statements executed outside any boundary are ignored. Missing boundary plumbing is caught by WithUnboundedWriteDetection, which reports the write this call precedes.
Rot prevention is unchanged: an allowed boundary that finishes with fewer than 2 write units notifies StaleAllowReporter. Marking at the write site tends to be the more durable of the two, since a mark on a conditional path exists only on the executions that reach it.
func SlogAttrs ¶
func SlogAttrs(attrs []BoundaryAttr) []slog.Attr
SlogAttrs converts boundary attrs to log/slog attrs, for reporters built on slog. SlogReporter already applies it to the attrs it receives.
Types ¶
type Allowlist ¶
type Allowlist struct {
// contains filtered or unexported fields
}
Allowlist suppresses violations for boundaries that are intentionally non-atomic (e.g. best-effort audit writes, writes spanning databases that a single transaction cannot cover).
To keep the list from rotting, every entry tracks whether it actually suppressed a violation; check UnusedEntries in CI and fail when an entry no longer matches anything (the same discipline as unused //nolint directives).
func (*Allowlist) Add ¶
Add registers a boundary name as intentionally non-atomic. The reason should say why and reference a ticket. Returns the Allowlist for chaining.
The optional exactWriteUnits pin how much non-atomicity the entry covers, exactly as for the in-code AllowNonAtomic mark: the entry then suppresses only boundaries finishing with one of the given write-unit counts, and any other count is reported as a Violation. Pass several counts for a boundary whose write count legitimately differs per code path. A write unit is one transaction that contained at least one write, or one auto-commit write (the same number reported as Violation.WriteUnits), so counts below 2 can never match a violation and leave the entry permanently unused.
func (*Allowlist) UnusedEntries ¶
UnusedEntries returns the boundary names that never suppressed a violation, sorted. A non-empty result in CI means the allowlist has stale entries that should be removed — or, for an entry constrained to exact write-unit counts, that the boundary now violates with a count the entry does not cover (it is then reported as a Violation as well, and the entry needs reviewing rather than deleting).
type Baseline ¶
type Baseline struct {
// contains filtered or unexported fields
}
Baseline is the ratchet helper for adopting txnproof on an existing codebase: capture the current violations once (BaselineFromViolations + Save), commit the file, and from then on only new violations fail — baselined boundaries are tolerated until fixed.
Entries are keyed on the boundary name alone. Write-unit counts and statement text vary by data and code path, so they would make the baseline unstable across runs; the boundary name is the stable identifier.
To keep the ratchet going down, every entry tracks whether it actually suppressed a violation; check UnusedEntries in CI and fail when an entry no longer matches anything — the same discipline as Allowlist.UnusedEntries.
func BaselineFromViolations ¶
BaselineFromViolations builds a Baseline from the boundary names of the given violations (typically CollectingReporter.Violations after a full run without any baseline installed). Duplicate boundaries collapse into one entry.
func LoadBaseline ¶
LoadBaseline reads a baseline file written by Save. A missing file is an error (check with errors.Is against fs.ErrNotExist): creating the baseline must stay a deliberate Save call, not a silent fallback.
func (*Baseline) Add ¶
Add registers a boundary name in the baseline. Returns the Baseline for chaining. Prefer BaselineFromViolations + Save for the normal adoption flow; Add exists for programmatic construction.
func (*Baseline) Boundaries ¶
Boundaries returns the baselined boundary names, sorted.
func (*Baseline) Save ¶
Save writes the baseline to path as deterministic, human-readable JSON: indented, boundaries sorted, with a comment field explaining the file, so diffs stay clean. Call it deliberately — on first adoption and on intentional regeneration — never on every run.
func (*Baseline) UnusedEntries ¶
UnusedEntries returns the baselined boundary names that never suppressed a violation, sorted. A non-empty result in CI means those boundaries are fixed: remove their entries from the baseline file so the ratchet keeps going down.
type BaselineReporter ¶
type BaselineReporter struct {
// contains filtered or unexported fields
}
BaselineReporter filters violations through a Baseline before forwarding them to the wrapped Reporter: violations of baselined boundaries are swallowed (marking the entry used), everything else passes through. Unbounded-write and stale-allow reports are never baselined and are forwarded unchanged when the wrapped Reporter implements the corresponding interfaces.
func NewBaselineReporter ¶
func NewBaselineReporter(baseline *Baseline, next Reporter) *BaselineReporter
NewBaselineReporter wraps next so that violations of boundaries in baseline are suppressed. A nil baseline suppresses nothing.
func (*BaselineReporter) Report ¶
func (r *BaselineReporter) Report(ctx context.Context, v Violation)
func (*BaselineReporter) ReportNestedBoundary ¶
func (r *BaselineReporter) ReportNestedBoundary(ctx context.Context, n NestedBoundary)
func (*BaselineReporter) ReportStaleAllow ¶
func (r *BaselineReporter) ReportStaleAllow(ctx context.Context, s StaleAllow)
func (*BaselineReporter) ReportUnboundedWrite ¶
func (r *BaselineReporter) ReportUnboundedWrite(ctx context.Context, s StatementRecord)
type Boundary ¶ added in v0.1.0
type Boundary struct {
// contains filtered or unexported fields
}
Boundary is a live logical boundary (a use case, a request, a job): it accumulates the statement timeline of one execution and is finished by calling Finish. StartBoundary returns it both as the context to propagate and as the handle to finish.
It implements context.Context itself so that StartBoundary can return the boundary directly as the context node instead of wrapping it in a separate context.WithValue allocation: the boundary doubles as its own value carrier, exactly as the standard library's *valueCtx stores its parent. parent is the context the boundary was started on.
type BoundaryAttr ¶
BoundaryAttr is one string-keyed contextual value attached to a boundary and carried into every Violation the boundary produces. Use it to tie a report back to the execution that produced it (trace ID, request ID, user ID).
type BoundaryOption ¶
type BoundaryOption func(*Boundary)
BoundaryOption configures a single boundary at StartBoundary / InBoundary.
func AllowNonAtomic ¶
func AllowNonAtomic(reason string, exactWriteUnits ...int) BoundaryOption
AllowNonAtomic marks the boundary as intentionally non-atomic, suppressing its Violation at the call site — the in-code alternative to a central Allowlist entry. The reason should say why and reference a ticket.
The optional exactWriteUnits pin how much non-atomicity the mark covers: the allow then applies only when the boundary finishes with exactly one of the given write-unit counts, and any other count is reported as a Violation as if the boundary carried no mark at all (the central Allowlist is still consulted afterwards). It keeps a reviewed exemption from silently growing as the boundary accumulates writes:
// exactly the domain write plus the audit write, nothing more
txnproof.AllowNonAtomic("audit writes are best-effort (TICKET-123)", 2)
Passing several counts allows each of them, for a boundary whose write count legitimately differs per code path. A write unit is one transaction that contained at least one write, or one auto-commit write — the same number reported as Violation.WriteUnits, not the number of transactions — so counts below 2 can never match a violation and make the mark permanently stale.
Rot prevention works per execution instead of per entry: when an allowed boundary finishes with fewer than 2 write units (i.e. the allow suppressed nothing), reporters that implement StaleAllowReporter are notified — the same discipline as unused //nolint directives. A count the mark does not cover needs no such signal: it surfaces as the Violation itself.
AllowNonAtomicHere marks the same thing from the site of the write instead of from the boundary start.
func WithBoundaryAttrs ¶
func WithBoundaryAttrs(attrs ...BoundaryAttr) BoundaryOption
WithBoundaryAttrs attaches static attrs to a single boundary at StartBoundary / InBoundary — for values the caller already has at hand:
ctx, b := detector.StartBoundary(ctx, "CreateUser",
txnproof.WithBoundaryAttrs(txnproof.Attr("user_id", userID)))
They are appended after any attrs produced by WithBoundaryAttrsFunc. Duplicate keys are kept in order, never deduplicated.
type Classifier ¶
type Classifier func(query string) StatementKind
Classifier decides the StatementKind of a raw SQL string.
type CollectingReporter ¶
type CollectingReporter struct {
// contains filtered or unexported fields
}
CollectingReporter accumulates violations in memory. Intended for tests.
func NewCollectingReporter ¶
func NewCollectingReporter() *CollectingReporter
NewCollectingReporter creates an empty CollectingReporter.
func (*CollectingReporter) NestedBoundaries ¶
func (r *CollectingReporter) NestedBoundaries() []NestedBoundary
NestedBoundaries returns a copy of the collected nested-boundary occurrences.
func (*CollectingReporter) Report ¶
func (r *CollectingReporter) Report(_ context.Context, v Violation)
func (*CollectingReporter) ReportNestedBoundary ¶
func (r *CollectingReporter) ReportNestedBoundary(_ context.Context, n NestedBoundary)
func (*CollectingReporter) ReportStaleAllow ¶
func (r *CollectingReporter) ReportStaleAllow(_ context.Context, s StaleAllow)
func (*CollectingReporter) ReportUnboundedWrite ¶
func (r *CollectingReporter) ReportUnboundedWrite(_ context.Context, s StatementRecord)
func (*CollectingReporter) RequireNoNestedBoundaries ¶
func (r *CollectingReporter) RequireNoNestedBoundaries(t TestingT)
RequireNoNestedBoundaries fails the test with one error per collected nested-boundary occurrence, enforcing that instrumentation layers do not overlap (requires WithNestedBoundaryDetection).
func (*CollectingReporter) RequireNoStaleAllows ¶
func (r *CollectingReporter) RequireNoStaleAllows(t TestingT)
RequireNoStaleAllows fails the test with one error per stale AllowNonAtomic mark, keeping in-code allows subject to the same rot discipline as Allowlist.UnusedEntries.
func (*CollectingReporter) RequireNoUnboundedWrites ¶
func (r *CollectingReporter) RequireNoUnboundedWrites(t TestingT)
RequireNoUnboundedWrites fails the test with one error per collected unbounded write, enforcing that every write in the exercised code ran with a boundary in its context (requires WithUnboundedWriteDetection).
func (*CollectingReporter) RequireNoViolations ¶
func (r *CollectingReporter) RequireNoViolations(t TestingT)
RequireNoViolations fails the test with one error per collected violation.
func (*CollectingReporter) Reset ¶
func (r *CollectingReporter) Reset()
Reset clears everything collected so far.
func (*CollectingReporter) StaleAllows ¶
func (r *CollectingReporter) StaleAllows() []StaleAllow
StaleAllows returns a copy of the collected stale AllowNonAtomic reports.
func (*CollectingReporter) UnboundedWrites ¶
func (r *CollectingReporter) UnboundedWrites() []StatementRecord
UnboundedWrites returns a copy of the collected unbounded write statements.
func (*CollectingReporter) Violations ¶
func (r *CollectingReporter) Violations() []Violation
Violations returns a copy of the collected violations.
type Detector ¶
type Detector struct {
// contains filtered or unexported fields
}
Detector is the core of txnproof. Wrap a driver (or connector) with it, mark logical boundaries with StartBoundary / InBoundary, and it reports a Violation whenever a boundary executes two or more write statements that do not share a single transaction.
func (*Detector) InBoundary ¶
func (d *Detector) InBoundary(ctx context.Context, name string, f func(context.Context) error, opts ...BoundaryOption) error
InBoundary runs f inside a boundary and finishes it when f returns.
func (*Detector) NewNullDB ¶
NewNullDB returns a *sql.DB backed by an in-memory no-op driver wrapped by the Detector. Every statement succeeds and returns no rows; only the statement/transaction timeline is observed.
This is the sqlmock-free way to unit-test atomicity: inject the returned DB where your code expects a *sql.DB, run the use case inside a boundary, and assert no violations were reported. Unlike sqlmock, no expectations need to be declared.
func (*Detector) NewSession ¶ added in v0.3.0
NewSession creates a Session bound to the Detector. One Session per connection; see the type comment for the contract.
func (*Detector) StartBoundary ¶
func (d *Detector) StartBoundary(ctx context.Context, name string, opts ...BoundaryOption) (context.Context, *Boundary)
StartBoundary marks the beginning of a logical boundary (a use case, a request handler, a job) on the context. Every statement executed through a wrapped driver with the returned context is attributed to this boundary.
It returns the boundary both as the context to propagate and as the *Boundary handle to finish. Call Boundary.Finish exactly when the boundary ends (typically via defer) to evaluate it and report a Violation if its writes span two or more atomic units; Finish is idempotent.
Starting a boundary on a context that already carries one shadows the outer boundary for statements executed with the new context (reported when WithNestedBoundaryDetection is on).
type NestedBoundary ¶
type NestedBoundary struct {
// Outer is the name of the boundary that was already on the context.
Outer string
// Inner is the name of the newly started, shadowing boundary.
Inner string
// Time is when the inner boundary was started.
Time time.Time
}
NestedBoundary is reported when a boundary is started on a context that already carries one (requires WithNestedBoundaryDetection). The shadow semantics are unchanged — statements attribute to the inner boundary only — so a nesting occurrence is not a Violation but a coverage signal: it usually means two instrumentation layers overlap (e.g. a resolver middleware and a use-case middleware both start boundaries).
type NestedBoundaryReporter ¶
type NestedBoundaryReporter interface {
ReportNestedBoundary(ctx context.Context, n NestedBoundary)
}
NestedBoundaryReporter is an optional extension a Reporter can implement to also receive nested-boundary occurrences (requires WithNestedBoundaryDetection).
type Option ¶
type Option func(*Detector)
Option configures a Detector.
func WithAllowlist ¶
WithAllowlist installs an allowlist of boundary names whose violations are intentionally suppressed.
func WithBoundaryAttrsFunc ¶
func WithBoundaryAttrsFunc(f func(ctx context.Context) []BoundaryAttr) Option
WithBoundaryAttrsFunc installs a detector-level extractor that derives attrs from the context — the middleware-friendly way to stamp every boundary with trace/request IDs: set it up once and every Violation carries them for free.
f is evaluated once per boundary at StartBoundary (never per statement), with the context StartBoundary received; its attrs come first, followed by any per-boundary WithBoundaryAttrs. When unbounded-write detection is on, f is also evaluated once per unbounded write at record time (with the statement's context) and the result is delivered on the StatementRecord.
func WithClassifier ¶
func WithClassifier(c Classifier) Option
WithClassifier replaces DefaultClassifier for statement classification. The classifier must be a pure function of the query text: for statements executed through a prepared statement it is evaluated once at Prepare and the result is reused for every execution.
func WithMaxRecordedStatements ¶
WithMaxRecordedStatements caps how many statements are kept per boundary for violation reports (write-unit counting itself is never truncated). The default is 200.
func WithNestedBoundaryDetection ¶
func WithNestedBoundaryDetection() Option
WithNestedBoundaryDetection makes the detector notify reporters that implement NestedBoundaryReporter whenever a boundary is started on a context that already carries one. The shadow semantics are unchanged — statements still attribute to the inner boundary only — this option merely makes the nesting itself observable, so accidental double instrumentation (e.g. middleware at two layers) does not go unnoticed.
func WithReporter ¶
WithReporter appends reporters that receive detected violations.
func WithUnboundedWriteDetection ¶
func WithUnboundedWriteDetection() Option
WithUnboundedWriteDetection makes the detector notify reporters that implement UnboundedWriteReporter about write statements executed with no boundary in their context (e.g. writes from detached goroutines).
type Reporter ¶
Reporter receives detected violations. Implementations decide what to do: fail a test, log, emit a metric, notify an error tracker.
type ReporterFunc ¶
ReporterFunc adapts a function to the Reporter interface.
type Session ¶ added in v0.3.0
type Session struct {
// contains filtered or unexported fields
}
Session is the observation surface for one database connection that does not go through the database/sql driver stack: a native driver (pgx, a ClickHouse native client, ...) or an ORM hook that exposes statement text. The driver middleware itself is built on it, so both paths share one transaction-attribution state machine and one set of counting rules.
Create exactly one Session per underlying connection and use it the way the connection itself must be used: serially. That mirrors the guarantee database/sql gives a driver.Conn; a Session has no locking of its own. Statements from different connections must go to different Sessions — transaction attribution is per connection, and mixing connections in one Session would merge unrelated transactions into one unit.
Observe every statement the connection executes, at most once, with the context that carried it (that context is what attributes the statement to a boundary). Transaction control that the driver executes as statement text ("BEGIN"/"COMMIT"/"ROLLBACK" — pgx's Begin()/Commit() do exactly that) is tracked from the text alone; BeginTx/EndTx exist for transaction transitions that never surface as text.
Like the driver middleware, a Session observes submitted statements whether or not they later succeed: a failed write still proves a partial- write path structurally exists in the boundary. The one exception the middleware makes — ErrBadConn, where database/sql re-runs the statement on a fresh connection — is the integration's to make: skip observing a statement only when something else will observe its retry.
func (*Session) BeginTx ¶ added in v0.3.0
func (s *Session) BeginTx()
BeginTx marks a transaction start that does not surface as statement text. Two callers need it: driver-API-level transactions (database/sql's ConnBeginTx, mirrored by the driver middleware), and protocol-level implicit transactions — a pgx batch is pipelined up to a single Sync, so PostgreSQL runs it as one implicit transaction even though no BEGIN is ever written; the integration brackets the batch with BeginTx/EndTx to count it as the single unit it is. Statements observed before the matching EndTx are attributed to this transaction.
Unlike a textual "BEGIN" (which is a no-op inside a transaction, matching the server's behavior), BeginTx trusts the caller and starts a new unit unconditionally — do not call it when the connection may already be in a transaction (a batch sent inside an explicit transaction belongs to that transaction; bracketing it would split the outer unit in two).
type SlogReporter ¶
SlogReporter reports violations through a *slog.Logger. Intended for production monitoring.
func NewSlogReporter ¶
func NewSlogReporter(l *slog.Logger) *SlogReporter
NewSlogReporter creates a SlogReporter. A nil logger means slog.Default().
func (*SlogReporter) ReportNestedBoundary ¶
func (r *SlogReporter) ReportNestedBoundary(ctx context.Context, n NestedBoundary)
func (*SlogReporter) ReportStaleAllow ¶
func (r *SlogReporter) ReportStaleAllow(ctx context.Context, s StaleAllow)
func (*SlogReporter) ReportUnboundedWrite ¶
func (r *SlogReporter) ReportUnboundedWrite(ctx context.Context, s StatementRecord)
type StaleAllow ¶
type StaleAllow struct {
// Boundary is the name given to StartBoundary.
Boundary string
// Reason is the reason given to AllowNonAtomic.
Reason string
// WriteUnits is the number of atomic units the boundary actually used
// (0 or 1).
WriteUnits int
}
StaleAllow is reported when a boundary marked with AllowNonAtomic finishes without a violation to suppress: the allow did nothing for this execution. Note that this is per execution — a boundary whose write count varies by code path can legitimately produce both violations-suppressed and StaleAllow reports.
type StaleAllowReporter ¶
type StaleAllowReporter interface {
ReportStaleAllow(ctx context.Context, s StaleAllow)
}
StaleAllowReporter is an optional extension a Reporter can implement to receive stale AllowNonAtomic marks (see StaleAllow).
type StatementKind ¶
type StatementKind int
StatementKind is the coarse classification of a SQL statement that txnproof cares about for atomicity tracking.
const ( // KindOther is a statement that is neither a read, a write, nor a // transaction-control statement (e.g. SET, SAVEPOINT, LOCK). KindOther StatementKind = iota // KindRead is a statement that does not modify data. KindRead // KindWrite is a statement that modifies data. KindWrite // KindBegin starts a transaction (textual BEGIN / START TRANSACTION). KindBegin // KindCommit commits a transaction (textual COMMIT / END). KindCommit // KindRollback rolls back a transaction (textual ROLLBACK / ABORT). KindRollback )
func DefaultClassifier ¶
func DefaultClassifier(query string) StatementKind
DefaultClassifier classifies a statement by its leading keyword, skipping leading whitespace and SQL comments. It is a heuristic:
- DML (INSERT/UPDATE/DELETE/MERGE/...), DDL (CREATE/ALTER/DROP/...), and procedure calls (CALL/DO) are treated as writes. Procedure calls are classified conservatively because their body is opaque.
- WITH-prefixed statements are scanned for embedded write keywords so that data-modifying CTEs (WITH ... INSERT/UPDATE/DELETE) count as writes. The scan is token-based and may misfire on write keywords inside string literals; override with WithClassifier if this matters for your queries.
- EXPLAIN is treated as a read even though EXPLAIN ANALYZE executes the inner statement.
func (StatementKind) String ¶
func (k StatementKind) String() string
type StatementRecord ¶
type StatementRecord struct {
Query string
Kind StatementKind
// TxID identifies the driver-level transaction the statement ran in.
// 0 means the statement ran in auto-commit mode. IDs are process-local
// sequence numbers, not database transaction IDs.
TxID uint64
Time time.Time
// Attrs is populated only on records delivered to
// UnboundedWriteReporter, with the result of WithBoundaryAttrsFunc
// evaluated against the statement's context at record time. Records in
// Violation.Statements leave it nil — the boundary's attrs live on the
// Violation itself.
Attrs []BoundaryAttr
}
StatementRecord is one SQL statement observed inside a boundary.
type ThrottlingReporter ¶
type ThrottlingReporter struct {
// contains filtered or unexported fields
}
ThrottlingReporter wraps another Reporter and deduplicates repeated reports, so that a violating boundary on a hot path does not fire the wrapped reporter on every request. Intended for production monitoring.
Per boundary name, the first Violation is forwarded to the wrapped reporter immediately; subsequent Violations for the same boundary within the configured interval are suppressed; once the interval has elapsed, the next Violation is forwarded again and a new interval starts.
The optional reporter extensions are throttled with the same interval but with their own keys and independent windows:
- Unbounded writes (UnboundedWriteReporter) are throttled per statement, keyed by the whitespace-normalized query text (truncated, see unboundedWriteKeyLen) — a hot-path unbounded write repeats the same statement text, so the statement is the natural dedup unit.
- Stale AllowNonAtomic marks (StaleAllowReporter) are throttled per boundary name, independently of that boundary's Violation window — stale-allow reports are per execution and therefore just as noisy on a hot path as violations.
- Nested boundaries (NestedBoundaryReporter) are throttled per outer/inner name pair — overlapping instrumentation layers repeat the same pair on every request.
Each extension is forwarded only when the wrapped reporter implements the corresponding interface, so wrapping neither swallows nor fabricates those signals.
Suppressed reports are not silently lost: cumulative per-key suppression counts are available via SuppressedViolations, SuppressedUnboundedWrites, and SuppressedStaleAllows, meant to be polled periodically (e.g. logged or exported as metrics on a ticker) to recover the true report volume.
Memory stays bounded: the two boundary-keyed maps grow with the set of boundary names, which is code-defined and small in practice; the statement-keyed map is capped at maxUnboundedWriteKeys.
func NewThrottlingReporter ¶
func NewThrottlingReporter(next Reporter, interval time.Duration) *ThrottlingReporter
NewThrottlingReporter wraps next so that repeated reports for the same key (boundary name for violations and stale allows, statement text for unbounded writes) are forwarded at most once per interval. A non-positive interval disables throttling: every report is forwarded.
func (*ThrottlingReporter) Report ¶
func (r *ThrottlingReporter) Report(ctx context.Context, v Violation)
Report forwards the first Violation per boundary immediately and at most one more per interval afterwards; the rest are counted as suppressed.
func (*ThrottlingReporter) ReportNestedBoundary ¶
func (r *ThrottlingReporter) ReportNestedBoundary(ctx context.Context, n NestedBoundary)
ReportNestedBoundary forwards nested-boundary occurrences throttled per outer/inner name pair. It is a no-op when the wrapped reporter does not implement NestedBoundaryReporter.
func (*ThrottlingReporter) ReportStaleAllow ¶
func (r *ThrottlingReporter) ReportStaleAllow(ctx context.Context, s StaleAllow)
ReportStaleAllow forwards stale AllowNonAtomic reports throttled per boundary name (independently of the boundary's Violation window). It is a no-op when the wrapped reporter does not implement StaleAllowReporter.
func (*ThrottlingReporter) ReportUnboundedWrite ¶
func (r *ThrottlingReporter) ReportUnboundedWrite(ctx context.Context, s StatementRecord)
ReportUnboundedWrite forwards unbounded write reports throttled per statement text. It is a no-op when the wrapped reporter does not implement UnboundedWriteReporter.
func (*ThrottlingReporter) SuppressedNestedBoundaries ¶
func (r *ThrottlingReporter) SuppressedNestedBoundaries() map[string]int
SuppressedNestedBoundaries returns the cumulative number of suppressed nested-boundary reports per "outer\x00inner" name pair.
func (*ThrottlingReporter) SuppressedStaleAllows ¶
func (r *ThrottlingReporter) SuppressedStaleAllows() map[string]int
SuppressedStaleAllows returns the cumulative number of suppressed stale AllowNonAtomic reports per boundary name since the reporter was created.
func (*ThrottlingReporter) SuppressedUnboundedWrites ¶
func (r *ThrottlingReporter) SuppressedUnboundedWrites() map[string]int
SuppressedUnboundedWrites returns the cumulative number of suppressed unbounded write reports per statement key (whitespace-normalized, possibly truncated query text) since the reporter was created.
func (*ThrottlingReporter) SuppressedViolations ¶
func (r *ThrottlingReporter) SuppressedViolations() map[string]int
SuppressedViolations returns the cumulative number of suppressed Violations per boundary name since the reporter was created. Counts only grow; boundaries with zero suppressions are omitted. Poll it periodically to recover the true violation volume behind the throttled stream.
type UnboundedWriteReporter ¶
type UnboundedWriteReporter interface {
ReportUnboundedWrite(ctx context.Context, s StatementRecord)
}
UnboundedWriteReporter is an optional extension a Reporter can implement to also receive write statements executed with no boundary in their context (requires WithUnboundedWriteDetection).
type Violation ¶
type Violation struct {
// Boundary is the name given to StartBoundary.
Boundary string
// WriteUnits is the number of distinct atomic units that contained
// writes. Atomic execution means WriteUnits == 1.
WriteUnits int
// Statements is the recorded statement timeline of the boundary
// (reads included), capped by WithMaxRecordedStatements.
Statements []StatementRecord
// TruncatedStatements is how many statements were dropped from
// Statements due to the cap.
TruncatedStatements int
// AllowedWriteUnits are the exact write-unit counts an AllowNonAtomic
// mark (or Allowlist entry) covered for this boundary, set only when the
// boundary was marked but finished with a count outside them — i.e. this
// violation is reported *because* the reviewed count no longer matches.
// It is nil for an ordinary, unmarked violation.
AllowedWriteUnits []int
// Attrs is the contextual metadata attached to the boundary (trace ID,
// request ID, ...): the result of WithBoundaryAttrsFunc evaluated at
// boundary start, followed by any WithBoundaryAttrs entries. Duplicate
// keys are kept in order.
Attrs []BoundaryAttr
}
Violation is reported when a boundary's write statements span two or more atomic units (distinct transactions and/or auto-commit statements), meaning the boundary is not atomic: a crash between units leaves partial state.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package crosscheck verifies a scenario's atomicity from a database server's own record of execution: given the statements the server logged while a test scenario ran, each annotated with the server-side transaction it ran in, it checks that all write statements shared one transaction.
|
Package crosscheck verifies a scenario's atomicity from a database server's own record of execution: given the statements the server logged while a test scenario ran, each annotated with the server-side transaction it ran in, it checks that all write statements shared one transaction. |
|
Package mycheck is the MySQL adapter for the crosscheck package: it parses the general query log a MySQL server produced while a test scenario ran, reconstructs which transaction each logged statement ran in, and delegates to crosscheck to verify that all write statements shared one transaction.
|
Package mycheck is the MySQL adapter for the crosscheck package: it parses the general query log a MySQL server produced while a test scenario ran, reconstructs which transaction each logged statement ran in, and delegates to crosscheck to verify that all write statements shared one transaction. |
|
Package pgcheck is the PostgreSQL adapter for the crosscheck package: it parses the log lines a PostgreSQL server produced while a test scenario ran, maps each logged statement to the server-side transaction it ran in, and delegates to crosscheck to verify that all write statements shared one transaction.
|
Package pgcheck is the PostgreSQL adapter for the crosscheck package: it parses the log lines a PostgreSQL server produced while a test scenario ran, maps each logged statement to the server-side transaction it ran in, and delegates to crosscheck to verify that all write statements shared one transaction. |