twopc

package
v1.7.4 Latest Latest
Warning

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

Go to latest
Published: Sep 2, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package twopc coordinates two-phase commit across sinks.

The guarantee is that either every participant applied a batch or none did. Everything here exists to hold that across a crash, because a coordinator that only works while the process is alive is not a coordinator — it is an ordering convention.

Semantics

Presumed abort. A transaction is committed on recovery only if the log says the decision was reached; anything else aborts. Committing on doubt would apply a batch the coordinator never agreed to.

The hazard this package is mostly about

A participant that has prepared is *in doubt*: it holds its changes and its locks until someone resolves it. On PostgreSQL a prepared transaction also blocks VACUUM for the whole cluster and survives restarts — an unresolved one is an outage in waiting, not an untidy row. So:

  • nothing is prepared before the intent is durable (Run refuses otherwise);
  • Recover resolves in-doubt transactions on every start; and
  • Reap rolls back anything that has been prepared for too long.

Read the "Operational hazard" section of README.md before enabling this.

Index

Constants

View Source
const DefaultMaxPreparedAge = 15 * time.Minute

DefaultMaxPreparedAge bounds how long a transaction may sit in doubt before Reap rolls it back. Generous enough to survive a slow commit round, short enough that a forgotten prepared transaction does not quietly wreck autovacuum.

Variables

View Source
var ErrNotFound = errors.New("twopc: record not found")

ErrNotFound is what a Store returns for a missing key.

Functions

This section is empty.

Types

type Coordinator

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

Coordinator drives two-phase commit over a set of participants.

func New

func New(opts Options) (*Coordinator, error)

New builds a Coordinator.

func (*Coordinator) InDoubt

func (c *Coordinator) InDoubt(ctx context.Context) (int, error)

InDoubt reports the transactions currently awaiting resolution. Intended for operators and metrics: a number that does not return to zero means locks are being held.

func (*Coordinator) Reap

func (c *Coordinator) Reap(ctx context.Context, sinks map[string]hermod.TwoPhaseCommit) (int, error)

Reap rolls back transactions that have been prepared for longer than MaxPreparedAge, returning how many it resolved.

This is the safety valve for the PostgreSQL hazard: a prepared transaction holds its locks and blocks VACUUM cluster-wide until someone resolves it. A coordinator that dies mid-round and never comes back would otherwise leave that forever. Call it periodically.

It only ever rolls back. Committing on a timer would apply a batch nobody decided to commit.

func (*Coordinator) Recover

func (c *Coordinator) Recover(ctx context.Context, sinks map[string]hermod.TwoPhaseCommit) error

Recover resolves every transaction left in doubt by a previous run. Call it once at start-up, before the pipeline writes anything.

sinks maps participant ID to the live sink. A record naming a participant that is not present cannot be resolved; Recover reports that and leaves the record alone rather than deleting evidence of a transaction that still holds locks.

func (*Coordinator) Run

func (c *Coordinator) Run(ctx context.Context, participants []Participant, work func(context.Context) error) error

Run executes work inside a distributed transaction across participants.

work performs the actual writes; it runs *before* the vote, because a participant can only prepare changes it already has. If work fails, nothing is prepared and every participant is rolled back.

Run is safe to call concurrently but serialises internally.

func (*Coordinator) WorkflowID

func (c *Coordinator) WorkflowID() string

WorkflowID reports the workflow this coordinator's transaction log is scoped to. It labels the metrics the reaper publishes, so an in-doubt transaction can be traced back to the workflow that left it.

type IndexedStore

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

IndexedStore adapts a plain hermod.StateStore into a twopc.Store.

The base interface is Get/Set/Delete with no enumeration, but recovery has to answer "what was in flight when we died?" — so this keeps a separate index key listing the live record keys, and List reads through it.

The index is the weak point and worth being explicit about: it is a second write, so a crash between writing a record and indexing it leaves a record that List cannot see. The order below is chosen so that failure is the safe one — index first, then the record. An index entry with no record is harmless (List skips it and prunes it); a record with no index entry would be invisible to recovery, which is the outcome that strands prepared transactions.

A backend that can enumerate natively should implement Store directly and skip this.

func NewIndexedStore

func NewIndexedStore(base hermod.StateStore, indexKey string) *IndexedStore

NewIndexedStore wraps base. indexKey must be unique per coordinator scope.

func (*IndexedStore) Delete

func (s *IndexedStore) Delete(ctx context.Context, key string) error

Delete removes the record first and the index entry second. The reverse order could drop the index entry and then fail, leaving an orphaned record that List never returns.

func (*IndexedStore) Get

func (s *IndexedStore) Get(ctx context.Context, key string) ([]byte, error)

func (*IndexedStore) List

func (s *IndexedStore) List(ctx context.Context, prefix string) (map[string][]byte, error)

List returns every indexed record under prefix, pruning index entries whose record has gone.

func (*IndexedStore) Set

func (s *IndexedStore) Set(ctx context.Context, key string, value []byte) error

Set indexes the key before writing the record, so a crash between the two cannot hide an in-doubt transaction from recovery.

type Options

type Options struct {
	// Store is the durable log. Required: without it there is no recovery, and
	// a coordinator that cannot recover is worse than none.
	Store Store
	// WorkflowID scopes log keys so coordinators do not see each other's
	// transactions.
	WorkflowID string
	// MaxPreparedAge overrides DefaultMaxPreparedAge.
	MaxPreparedAge time.Duration
	// Logger is optional.
	Logger hermod.Logger
	// Now is injectable for tests.
	Now func() time.Time
}

Options configures a Coordinator.

type Participant

type Participant struct {
	ID   string
	Sink hermod.TwoPhaseCommit
}

Participant is one sink taking part in a transaction. ID must be stable across restarts: recovery matches log records to live sinks by it.

type Store

type Store interface {
	Get(ctx context.Context, key string) ([]byte, error)
	Set(ctx context.Context, key string, value []byte) error
	Delete(ctx context.Context, key string) error
	List(ctx context.Context, prefix string) (map[string][]byte, error)
}

Store is the coordinator's durable log.

It is hermod.StateStore plus List, because recovery has to enumerate what was in flight and the base interface cannot. Wrap a plain StateStore with NewIndexedStore to get one.

Jump to

Keyboard shortcuts

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