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 ¶
- func Register(app core.App)
- type Heartbeat
- type QuasarConfig
- type QuasarWriter
- func (w *QuasarWriter) Close()
- func (w *QuasarWriter) HandleHeartbeat(rw http.ResponseWriter, r *http.Request)
- func (w *QuasarWriter) Ingest(h heartbeat)
- func (w *QuasarWriter) IsWriter(key string) bool
- func (w *QuasarWriter) Ready() <-chan struct{}
- func (w *QuasarWriter) RedirectTarget(key string) string
- func (w *QuasarWriter) SelfHeartbeat() Heartbeat
- type StaticWriter
- type WriterProvider
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Register ¶
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 QuasarConfig ¶
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 ¶
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.