ha

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 5, 2026 License: Apache-2.0 Imports: 3 Imported by: 0

README

ha

Hanzo's high-availability coordination primitive: single-writer election over a live membership set — no coordinator, no lock service, no Postgres, no Redis.

It answers one question, identically on every replica:

which replica is the single writer for key K right now?

via Rendezvous (Highest-Random-Weight) hashing. N stateless replicas agree on one owner per key and fail over deterministically, with no recomputation and nothing to coordinate.

Why it is its own package

ha decides who writes. It never touches how state is stored or replicated — that is hanzoai/vfs (per-org SQLite + object store) or any other backing. Keeping election separate means a cron tick, a queue consumer, or any singleton can be made exactly-once without importing a storage library to get it. One primitive, one home, every consumer.

ha            who is the single writer          ← this package (pure Go, zero deps)
vfs           how the SQLite state replicates    → composes ha for its writer gate
your service  what the single writer does        → cron, billing sweep, queue drain

Use

import "github.com/hanzoai/ha"

// The per-write hot path: only the elected owner of key proceeds.
if ha.IsOwner(orgID, self, members) {
    // ... this replica is the single writer for orgID; do the write.
}

members comes from a Membership — the injectable seam:

// Single process / local dev / tests: itself is the sole writer.
m := ha.Static(podName)

// Production: a cluster source (e.g. hanzoai/ha/k8s) lists live, ready peers.
self := m.Self()
members, err := m.Members(ctx)
if err != nil || len(members) == 0 {
    // FAIL CLOSED — unknown or empty membership has no safe owner; skip.
    return
}
if ha.IsOwner(orgID, self, members) { /* write */ }

API

Symbol Purpose
Member{ID, Addr} one replica; ID is the stable HRW identity, Addr for write-forwarding
Owner(key, members) (Member, bool) the elected single writer for key; ok=false on empty set (fail-closed)
IsOwner(key, self, members) bool the per-write gate every replica runs
Replicas(key, members, n) []Member owner first, then ordered failover successors (pre-warm order)
Membership seam: Self() + Members(ctx) — the live set election elects over
Static(id) single-process Membership (one member, itself, never an error)

Guarantees

  • Deterministic & order-independent — same (key, members) ⇒ same owner on every replica, regardless of set order (ties break on ID).
  • Fail-closed — empty membership yields no owner; a consumer that cannot read its peers must skip, never assume ownership.
  • Even spread — distinct keys distribute across replicas (HRW), not all pinned to one, so ownership (and its write load) balances.
  • Cheap — a per-write check is a handful of SHA-256s; see BenchmarkOwnerElection.

Apache-2.0.

Documentation

Overview

Package ha is Hanzo's high-availability coordination primitive: single-writer election over a live membership set, with no coordinator, no lock service, no Postgres, no Redis. It answers one question identically on every replica — "which replica is the single writer for key K right now?" — via Rendezvous (Highest-Random-Weight) hashing, so N stateless replicas agree on one owner per key and fail over deterministically with no recomputation.

ha decides WHO writes; it never touches HOW state is stored or replicated. That separation is the point: compose ha with hanzoai/vfs (per-org SQLite + object store) for HA storage, or with a cron tick, a queue consumer, or any singleton that must run exactly once across a multi-replica Deployment. The same election guards all of them.

The seam is Membership: election is a pure function of (key, []Member), and a Membership supplies the live set. Wire a cluster source (hanzoai/ha/k8s) in production, or Static for a single process and tests.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func IsOwner

func IsOwner(key, selfID string, members []Member) bool

IsOwner reports whether selfID owns the writer for key under members — the per-write hot-path check every replica runs.

Types

type Lease added in v0.1.1

type Lease struct {
	Key   string // the ownership unit this lease fences (e.g. an org id).
	Owner Member // the elected writer that holds the role.
	Round Round  // the monotone round the owner stamps onto its writes.
}

Lease binds an elected Owner to the Round at which it holds the single-writer role for Key. It is a value, not a held lock: possession is never enforced by carrying a Lease but by the store admitting a write stamped with its Round. A Lease whose Round has been superseded is inert — the store rejects it — which is exactly what makes a still-running deposed writer harmless.

type Leases added in v0.2.0

type Leases interface {
	// Acquire returns the caller's Lease for key: itself as Owner, bound to the
	// round at which it currently holds the writer role. Implementations advance
	// the round when the role changes hands TO the caller and keep it on renewal,
	// so repeated Acquire calls by a stable owner return a stable round while a
	// takeover returns a strictly higher one. It returns an error when the caller
	// is not — or can no longer be — the writer for key; the caller then does not
	// write (and typically forwards to, or steps aside for, the true owner).
	Acquire(ctx context.Context, key string) (Lease, error)
}

Leases issues the caller's current Lease for a key from a linearizable, monotone round source. It is the seam a consensus-backed round drops into with no change at call sites; the interim implementation reads and advances a round in a single linearizable store. This package defines the seam and the value only — never the source.

A caller MUST fail CLOSED on an error: no Lease means no established round, and writing without a fresh round risks two writers at one epoch. A nil error is the only signal it is safe to write under.

func StaticLeases added in v0.2.0

func StaticLeases(self string) Leases

StaticLeases is the single-process Leases: the sole process is the sole writer, so every key is held by self at a fixed Round of 1 — exactly-once by construction, with no round source to consult. It is the correct Leases for local dev, a standalone binary, and tests, and the safe default when no linearizable source is wired — mirroring Static for Membership. A single process cannot have a deposed second writer, so a constant round is sound; the moment there are two writers, a real (linearizable) Leases is required.

type Member

type Member struct {
	ID   string
	Addr string
}

Member is one replica in the live membership set. ID must be stable for the life of the replica (e.g. its pod name / a persistent node id): HRW weights derive from it. Addr is the reachable address for write-forwarding (host:port).

func Owner

func Owner(key string, members []Member) (Member, bool)

Owner returns the replica that owns the writer for key, or ok=false when members is empty (fail-closed — never a wrong writer). Deterministic: the same (key, members) yields the same Owner on every replica, independent of order.

func Replicas

func Replicas(key string, members []Member, n int) []Member

Replicas returns key's owner first, then ordered failover successors. On owner loss the next replica becomes owner with no recomputation, so a reader can pre-warm the state for the keys it is next-in-line to own.

type Membership

type Membership interface {
	// Self is this replica's stable ID — the value HRW weights derive from. It must
	// be stable for the replica's life (e.g. its pod name / a persistent node id).
	Self() string
	// Members is the live writer-eligible set, or an error the caller fails closed
	// on. A nil error with a non-empty set is the only signal safe to proceed on.
	Members(ctx context.Context) ([]Member, error)
}

Membership is the seam election elects over: the live, writer-eligible replica set plus this replica's own stable ID. It is the ONE injectable source every consumer shares — production wires a cluster source (e.g. hanzoai/ha/k8s), tests and single-process mode wire Static.

A consumer MUST fail CLOSED on a Members error or an empty set: election over an unknown or empty membership has no safe owner, so the caller skips rather than risk two writers. A nil error with a non-empty set is the only "proceed" signal.

func Static

func Static(id string) Membership

Static is the single-process Membership: one member, itself, never an error — the sole process is the sole writer. It is the correct source for local dev, a standalone binary, and tests, and the safe default when no cluster source is wired (a single process is exactly-once by construction). id is this member's ID.

type Round added in v0.1.1

type Round uint64

Round is a monotone fencing epoch for a key. It is the SOLE boundary value between coordination (this package) and storage (the fenced object store): the store knows only that rounds increase and that a lower round is stale — nothing about members, leases, elections, or how the round was decided.

Jump to

Keyboard shortcuts

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