It was Fencer, which named the wrong actor. Fencing is what the STORE does when it refuses a stale round; an interface that hands back a lease fences nothing. The value it yields is a Lease, so it is Leases -- the same shape as db.Namespaces resolving a namespace to a handle. Recorded in the doc why it is not the other candidates, because each is tempting and each is wrong in an instructive way: Writer has no write method and already means something you write TO; Consensus names one possible source when StaticLeases returns Round 1 from a single process and CAS consults a single register, neither reaching agreement; Leader implies one leader when Owner is per-KEY, so a thousand keys have a thousand owners. Prose still says fencing where it describes the store refusing a stale round. That is the accurate use, and keeping it is what makes the rename legible. Identifiers only -- no behaviour change. Breaking for importers, so this is a minor bump and cloud moves on its own commit. Co-authored-by: Hanzo Dev <[email protected]>
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
Kright 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 onID). - 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.