Documentation
¶
Overview ¶
Package replicaha is the high-availability control plane for go-volumes replicated block volumes. It drives a replica.Engine (the data plane that mirrors a volume across N synchronous replicas) under leader election so that exactly one node is the active writer for a volume at any instant — the safe single-active-writer guarantee that makes failover non-destructive.
The safety property ¶
A replicated volume is only safe if at most one writer is live at a time. If two nodes both believe they own the volume (split-brain — the classic outcome of a network partition where a stale leader keeps writing while a new leader is elected on the other side), they will mirror divergent writes into the same replicas and corrupt the volume irrecoverably. This package prevents that with fence-before-promote (STONITH): a node that wins leadership MUST fence the previous writer — prove it can no longer write — BEFORE it activates its own engine as the writer. If the fence fails or times out, the node refuses to activate and stays passive. The control plane never writes while a possibly-live old writer might also be writing.
Pieces ¶
- Coordinator is the coordination seam: lease-based leader election and membership. It is an interface so this core is dependency-free and fully testable with an in-memory fake; an etcd-backed implementation (mirroring weft-ha-postgresql's EtcdDCS) is a deferred follow-up that lives outside this package so the safety-critical state machine carries no etcd weight.
- Controller is the reconcile state machine. It campaigns for leadership, fences the prior writer before promoting, gates the engine between ACTIVE (writable) and PASSIVE (writes rejected), and demotes immediately when leadership is lost.
- ActiveDevice wraps the replica.Engine so that WriteAt/Sync return ErrNotLeader whenever the Controller is not the active writer. This is how "active writer" is modelled: the engine itself has no active/passive gate, so the gate lives here, in front of it, on the data path.
The control plane USES the engine and a replica.Fencer; it does not reimplement replication.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNotLeader is returned by the write path ([ActiveDevice.WriteAt], // [ActiveDevice.Sync]) when the [Controller] is not the active writer — it // is a follower, or a freshly-elected leader that has not yet confirmed the // prior writer is fenced. Reads are always permitted; only the write path is // gated. ErrNotLeader = errors.New("replica-ha: not the active writer (not leader)") // ErrFenceFailed is returned (and logged) when fence-before-promote could // not confirm the prior writer is fenced. On this error the Controller // refuses to activate and stays passive: it never promotes into a // maybe-live old writer. It wraps the underlying [replica.Fencer] error. ErrFenceFailed = errors.New("replica-ha: fence failed — refusing to promote") // ErrStopped is returned by [Controller.Run] when the controller has been // stopped via [Controller.Stop], and by operations attempted after Stop. ErrStopped = errors.New("replica-ha: controller stopped") // ErrNoEngine is returned by [New] when no [replica.Engine] is supplied. ErrNoEngine = errors.New("replica-ha: nil engine") // ErrNoCoordinator is returned by [New] when no [Coordinator] is supplied. ErrNoCoordinator = errors.New("replica-ha: nil coordinator") // ErrNoFencer is returned by [New] when no [replica.Fencer] is supplied. A // control plane with no way to fence cannot promote safely, so a nil fencer // is rejected at construction rather than discovered at the first failover. ErrNoFencer = errors.New("replica-ha: nil fencer") )
Functions ¶
func New ¶
func New(engine *replica.Engine, coord Coordinator, fencer replica.Fencer, log *slog.Logger) (*Controller, *ActiveDevice, error)
New builds a Controller over engine, coord and fencer. It returns the Controller and the ActiveDevice the data path must write through — that device starts PASSIVE and only becomes writable once the Controller has promoted (fenced the prior writer). All three dependencies are required.
If log is nil a discarding logger is used.
Types ¶
type ActiveDevice ¶
type ActiveDevice struct {
// contains filtered or unexported fields
}
ActiveDevice wraps a replica.Engine with an active/passive write gate. It is how this package models "active writer": the engine itself has no notion of leadership, so the gate lives in front of it. While the owning Controller is the active writer the gate is open and every call passes straight through to the engine; while it is passive the WRITE path (ActiveDevice.WriteAt, ActiveDevice.Sync) returns ErrNotLeader without touching any replica, and the READ path (ActiveDevice.ReadAt, ActiveDevice.Size) is always allowed (a follower may serve reads).
An ActiveDevice satisfies volume.Device, so a filesystem-format driver or an NBD server can write its on-disk image through it unchanged and will be transparently rejected with ErrNotLeader the instant this node stops being the leader — the data path needs no awareness of leadership beyond handling that error.
The gate is a single atomic bool: flipping it is wait-free and safe to call from the Controller's run loop while writes are in flight on other goroutines. A write that observes "active" and then races a demotion is the normal in-flight-write case; the lease's grace window plus the prior-writer fence on the NEXT promotion are what bound safety, not per-write locking.
func (*ActiveDevice) Close ¶
func (d *ActiveDevice) Close() error
Close releases the underlying engine. After Close the device must not be used again.
func (*ActiveDevice) IsActive ¶
func (d *ActiveDevice) IsActive() bool
IsActive reports whether the write gate is currently open.
func (*ActiveDevice) ReadAt ¶
func (d *ActiveDevice) ReadAt(p []byte, off int64) (int, error)
ReadAt always passes through to the engine: a follower may serve reads.
func (*ActiveDevice) Size ¶
func (d *ActiveDevice) Size() (int64, error)
Size reports the replicated volume size; always allowed.
func (*ActiveDevice) Sync ¶
func (d *ActiveDevice) Sync() error
Sync flushes the engine when active, and returns ErrNotLeader otherwise.
func (*ActiveDevice) WriteAt ¶
func (d *ActiveDevice) WriteAt(p []byte, off int64) (int, error)
WriteAt mirrors p at off to the engine when this node is the active writer, and returns (0, ErrNotLeader) otherwise without touching any replica.
type Controller ¶
type Controller struct {
// contains filtered or unexported fields
}
Controller is the per-node reconcile state machine that keeps exactly one node the active writer for a volume. It campaigns for leadership through a Coordinator, fences the prior writer through a replica.Fencer BEFORE activating its replica.Engine as the writer, and demotes immediately when leadership is lost. Construct it with New and drive it with Controller.Run.
func (*Controller) Device ¶
func (c *Controller) Device() *ActiveDevice
Device returns the gated ActiveDevice the data path writes through.
func (*Controller) Members ¶
func (c *Controller) Members(ctx context.Context) ([]string, error)
Members returns the currently-live node IDs from the Coordinator.
func (*Controller) Run ¶
func (c *Controller) Run(ctx context.Context) error
Run drives the control loop until ctx is cancelled or Controller.Stop is called. The shape:
- Subscribe to leadership changes via Coordinator.Observe.
- Campaign for leadership in the background; when it returns we believe we hold the lease, but we wait for the observed Leadership to confirm us before promoting — the observation is the authoritative signal.
- On each observed Leadership: - IsSelf true → promote: FENCE the prior writer, then (only on a confirmed fence) open the write gate. A failed/timed-out fence leaves us in RoleFencePending (not writing); the next observation retries. - IsSelf false → demote immediately: close the write gate so no further writes are accepted (lost lease / a peer won / partition).
Run returns ctx.Err() on cancellation, or ErrStopped after Stop.
func (*Controller) Status ¶
func (c *Controller) Status() Status
Status returns a snapshot of the controller's role, term, prior-writer identity, write-gate state, and the engine's replica status.
func (*Controller) Stop ¶
func (c *Controller) Stop(ctx context.Context) error
Stop deactivates the write gate and resigns leadership through the Coordinator. It is safe to call once; subsequent calls are no-ops that return nil. Stop does not cancel a running Controller.Run; cancel its context for that. Stop ensures that after it returns this node is not the active writer and has released its lease.
type Coordinator ¶
type Coordinator interface {
// Campaign blocks until this node becomes the leader (acquires and holds the
// write lease) or ctx is done. It returns nil once leadership is acquired,
// or ctx.Err() if ctx is cancelled first. It is not an error to call
// Campaign while already leader; implementations may return immediately.
Campaign(ctx context.Context) error
// Resign voluntarily gives up leadership (releases the lease), allowing a
// peer to win the next election. Resigning when not leader is a no-op and
// returns nil.
Resign(ctx context.Context) error
// Observe returns a channel that streams a [Leadership] on every leadership
// change for as long as ctx is live. The channel is closed when ctx is done
// (or the underlying session ends). A lost lease (expiry / partition) MUST
// be reported here as a Leadership with IsSelf == false so the Controller
// stops writing. Implementations should deliver the current leadership
// promptly after the call so a late subscriber is not left blind.
Observe(ctx context.Context) (<-chan Leadership, error)
// Members lists the node IDs that currently hold a live membership lease.
// A fenced or partitioned node drops out automatically as its lease expires,
// so the returned set reflects only reachable, live peers.
Members(ctx context.Context) ([]string, error)
// NodeID is this node's stable identity within the cluster. It matches the
// Leader field of a [Leadership] when this node is the leader, and the
// identity a peer passes to the [replica.Fencer] to fence this node.
NodeID() string
}
Coordinator is the coordination seam between the Controller and a lease-based consensus store (etcd, in the deferred production implementation). Keeping it an interface is what lets the safety-critical state machine in Controller stay dependency-free and exhaustively testable with an in-memory fake.
Lease semantics ¶
Leadership is backed by a time-bounded lease that the implementation renews in the background. The lease is the safety hinge of the whole design:
- While this node holds the lease it is the leader and MAY write (once it has fenced the prior writer — see Controller).
- If the node stops renewing the lease for any reason — process stall, network partition, the node being fenced by a peer — the lease EXPIRES at the store and leadership is lost. A lost lease MUST surface on the Coordinator.Observe channel as a Leadership whose IsSelf is false, so the Controller demotes and stops writing before the lease's grace window elapses. An implementation that cannot observe its own lease loss is unsafe and unusable here.
- Because the lease expires automatically, a partitioned-away old leader loses its claim without cooperating; the new leader still fences it (it cannot assume the old leader has already noticed) but the lease bounds the window of double ownership.
All methods must be safe for concurrent use and must honour ctx cancellation.
type Leadership ¶
type Leadership struct {
// Leader is the node ID of the current lease holder. It is empty when no
// node holds the lease (e.g. during an election after a leader's lease
// expired). An empty Leader with IsSelf false means "no leader right now".
Leader string
// IsSelf is true when Leader is this node ([Coordinator.NodeID]). When it
// becomes false after having been true, this node has LOST leadership and
// the [Controller] must stop writing immediately.
IsSelf bool
// Term is the monotonically non-decreasing election term (a.k.a. epoch or
// fencing token). Every successful campaign that changes the leader yields a
// strictly greater Term. The Controller records it so a new leader can be
// distinguished from a stale duplicate observation, and so a fencing token
// could be threaded to the data plane in a future revision.
Term int64
}
Leadership is a single observation of who currently holds the write lease for a volume. It is streamed by Coordinator.Observe on every leadership change.
type Role ¶
type Role int
Role is the Controller's role for a volume at a point in time.
const ( // RoleFollower means this node is not the leader: the write gate is closed // and [ActiveDevice.WriteAt] returns [ErrNotLeader]. RoleFollower Role = iota // RoleFencePending means this node has WON leadership but has not yet // confirmed the prior writer is fenced. It is NOT writing — this is the // safety state that prevents split-brain. It becomes [RoleLeader] only after // a successful fence, or falls back to follower behaviour (still not // writing) if the fence fails. RoleFencePending // RoleLeader means this node is the confirmed active writer: it holds the // lease AND has fenced the prior writer, so the write gate is open. RoleLeader )
type Status ¶
type Status struct {
// Role is the current control-plane role.
Role Role
// Term is the election term of the current (or most recent) leadership
// observation.
Term int64
// PrevWriter is the node ID this controller fenced (or attempted to fence)
// on its most recent promotion. Empty if it has never promoted.
PrevWriter string
// Active reports whether the write gate is currently open.
Active bool
// Replicas is the engine's per-replica replication state
// ([replica.Engine.Status]).
Replicas []replica.ReplicaStatus
}
Status is a snapshot of the Controller's state, returned by Controller.Status.