ha

package
v1.5.17 Latest Latest
Warning

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

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package ha registers writer/replica HA for a Base app.

Quasar consensus is leaderless — all nodes are equal validators. SQLite's single-writer constraint is satisfied by deterministic writer-pinning: the lowest-sorted alive NodeID holds the write lock. All others are replicas that 307 mutating requests to the writer.

app := base.New()
ha.Register(app)
app.Start()

Config lives under the BASE_* env namespace. See the base-ha README.

HA is a no-op unless BASE_LOCAL_TARGET or BASE_STATIC_WRITER is set.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Register

func Register(app core.App)

Register wires HA into the given app.

Behavior depends on env:

  • BASE_STATIC_WRITER=http://... → all writes forward to that URL.
  • BASE_LOCAL_TARGET=http://... + BASE_PEERS=a,b → Quasar heartbeat writer-pin; lowest-sorted alive NodeID is the writer.
  • Neither set → plugin is inactive (standalone Base).

The full go-ha CDC pipeline (change-set capture over NATS-compatible JetStream) is provisioned by the base-ha binary at the SQL driver level. This plugin handles the HTTP surface: write-forwarding middleware and the /_ha/heartbeat endpoint.

Types

type Heartbeat

type Heartbeat struct {
	NodeID string `json:"node_id"`
	Target string `json:"target"`
}

type QuasarConfig

type QuasarConfig struct {
	NodeID            string
	LocalTarget       string   // this node's reachable URL
	Peers             []string // peer base URLs
	HeartbeatInterval time.Duration
	LeaseTimeout      time.Duration
}

type QuasarWriter

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

QuasarWriter pins ONE node per KEY as that key's SQLite writer, over a heartbeat-based live set.

Quasar consensus is leaderless — all nodes are equal validators — and SQLite's single-writer constraint is per FILE, not per process. So ownership is per key (store.Key.String() names one file) and comes from ha.Owner: Rendezvous (HRW) weight over the live members, computed identically on every node from the same membership without asking anyone. A node that does not own a key 307s mutating HTTP to the one that does, and applies change-sets via async replication.

It previously ranked the live set by NodeID and pinned the lowest-sorted as the writer for EVERYTHING. That is correct for SQLite and wrong for a fleet: every tenant's writes land on one node, and a rolling restart hands the whole write load to whoever sorts first next. HRW spreads ownership and moves only the keys a departing node owned, so losing one of N relocates 1/N of the tenants.

WHAT THIS DOES NOT YET DO. Heartbeat liveness cannot make a deposed or partitioned owner STOP writing, so two nodes with different views of the live set can each believe they own a key. store/multitenant.go documents that window honestly ("during an HPA rebalance a (short) single-writer window may overlap across pods; ops MUST drain before scaling"). Closing it needs ha.Leases: the owner stamps a monotone Round onto each write and the store refuses any round below the highest it has admitted, which makes a deposed writer harmless instead of merely unlikely. The election is now the shape that accepts it.

Transport: HTTP /_ha/heartbeat by default. Compose with plugins/zap for sub-ms ZAP transport (the ZAP plugin provides mDNS discovery + binary messaging for the fast path).

O(peers) memory, O(1) per heartbeat.

func NewQuasarWriter

func NewQuasarWriter(cfg QuasarConfig) (*QuasarWriter, error)

func (*QuasarWriter) Close

func (w *QuasarWriter) Close()

func (*QuasarWriter) HandleHeartbeat

func (w *QuasarWriter) HandleHeartbeat(rw http.ResponseWriter, r *http.Request)

HandleHeartbeat is the HTTP handler for /_ha/heartbeat.

func (*QuasarWriter) Ingest

func (w *QuasarWriter) Ingest(h heartbeat)

Ingest processes an incoming heartbeat (from any transport — HTTP or ZAP). Exported so the ZAP plugin can feed heartbeats in from the binary path.

func (*QuasarWriter) IsWriter

func (w *QuasarWriter) IsWriter(key string) bool

func (*QuasarWriter) Ready

func (w *QuasarWriter) Ready() <-chan struct{}

func (*QuasarWriter) RedirectTarget

func (w *QuasarWriter) RedirectTarget(key string) string

RedirectTarget is the owner's advertised URL for key — empty when no member owns it, which the caller MUST treat as "no writer" rather than "me".

func (*QuasarWriter) SelfHeartbeat

func (w *QuasarWriter) SelfHeartbeat() Heartbeat

SelfHeartbeat returns this node's identity for external transports.

type StaticWriter

type StaticWriter struct{ Target string }

StaticWriter always routes writes to a fixed URL.

func (*StaticWriter) IsWriter

func (s *StaticWriter) IsWriter(string) bool

func (*StaticWriter) RedirectTarget

func (s *StaticWriter) RedirectTarget(string) string

type WriterProvider

type WriterProvider interface {
	IsWriter(key string) bool
	RedirectTarget(key string) string
}

WriterProvider abstracts writer-pin strategies.

Both methods take the ownership KEY, because ownership is per tenant, not per process: store.Key.String() ("org/apps/a/projects/p") names one SQLite file, and SQLite's single-writer constraint is per FILE. A process-wide writer would pin every tenant's writes to one node — correct, and a bottleneck that grows with tenant count.

Jump to

Keyboard shortcuts

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