mirror of
https://github.com/hanzoai/vfs.git
synced 2026-10-30 18:18:21 +00:00
* replica: extract single-writer election to hanzoai/ha Election (Owner/IsOwner/Replicas/Member) was storage-agnostic pure Go with zero vfs-internal callers — it lived here only historically. It is now its own coordination primitive at github.com/hanzoai/ha so any singleton (cron, queue drain, billing sweep) gets exactly-once WITHOUT importing a SQLite library. vfs/replica keeps what it is: SQLite replication (Replicator, SQLiteDB, BackendStore). One concern per package. Consumers (visor, cloud/internal/org) migrate to hanzoai/ha in lockstep; they pin vfs v0.6.2 so this main-only removal does not disturb them until they bump. Removes owner.go + its two election tests; doc points at ha. * ci: enable cgo for the -race steps (pre-existing red) go test -race requires cgo, but the self-hosted hanzo-build-linux-amd64 runner has no C compiler and defaulted CGO_ENABLED=0 (vfs is CGO-free in production), so both race steps failed — first with 'go: -race requires cgo', then 'C compiler gcc not found'. Every main run has been red on this for many commits. Fix: install gcc + set CGO_ENABLED=1 on the two -race steps only; the production build stays CGO_ENABLED=0. Now the race detector actually runs (real coverage for the concurrent replicator/ship loops). Unrelated to the ha extraction but folded in so this PR lands green rather than override a known-bad check. --------- Co-authored-by: hanzo <[email protected]>
198 lines
7.2 KiB
Go
198 lines
7.2 KiB
Go
// Copyright 2025 Hanzo AI Inc. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
|
|
// Package replica is the ONE shared HA-SQLite substrate every Hanzo service uses
|
|
// (HIP-0107): per-org SQLite on the local disk for speed, its durable copy in
|
|
// hanzoai/vfs (SeaweedFS-backed object store), a deterministic single-writer
|
|
// election (Rendezvous/HRW over IAM membership — no coordinator, no lock service,
|
|
// no Postgres, no Redis), and per-org at-rest encryption via KMS.
|
|
//
|
|
// It was proven inside hanzoai/cloud (internal/org) and is promoted here so EVERY
|
|
// service — visor, iam, commerce, base, and every Base adopter — imports the SAME
|
|
// code instead of re-inventing durability. The adoption contract at each service's
|
|
// per-org store seam:
|
|
//
|
|
// hydrate-on-open : Pull() the latest snapshot before first use of an org DB.
|
|
// single-writer : ha.IsOwner(key, self, members) gates writes (github.com/hanzoai/ha,
|
|
// the election primitive); a non-owner serves stale-tolerant reads
|
|
// (Pull-then-read) and forwards writes.
|
|
// post-commit ship: the owner runs PushLoop (or Push after a write burst); the
|
|
// durable copy in vfs means a lost pod loses no committed data.
|
|
package replica
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"path"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Store is the object-store surface the Replicator needs — satisfied natively by
|
|
// hanzoai/vfs (SeaweedFS-backed, in-process). NO minio, NO external S3 SDK.
|
|
// version is a content hash so readers skip redundant pulls.
|
|
type Store interface {
|
|
Put(ctx context.Context, key string, data []byte) error
|
|
Get(ctx context.Context, key string) (data []byte, version string, err error)
|
|
Version(ctx context.Context, key string) (version string, err error)
|
|
}
|
|
|
|
// DB is the local per-org SQLite the owner writes and readers restore into.
|
|
// Snapshot returns a consistent (WAL-checkpointed) copy; Restore replaces the
|
|
// local bytes atomically. Satisfied by *SQLiteDB (sqlite.go).
|
|
type DB interface {
|
|
Snapshot(ctx context.Context) ([]byte, error)
|
|
Restore(ctx context.Context, data []byte) error
|
|
}
|
|
|
|
// Cipher seals/opens a snapshot with an org-bound key (envelope encryption via
|
|
// the KMS master). Optional — omit it for local dev (plaintext object).
|
|
type Cipher interface {
|
|
Seal(org string, plaintext []byte) ([]byte, error)
|
|
Open(org string, ciphertext []byte) ([]byte, error)
|
|
}
|
|
|
|
// DBPath is the canonical object-store location of one of an org's SQLite DBs
|
|
// (HIP-0302 layout). Every replica computes the same path for the same inputs.
|
|
//
|
|
// org root: DBPath("acme", "", "iam") -> orgs/acme/iam.db
|
|
// project: DBPath("acme", "projects/site", "base") -> orgs/acme/projects/site/base.db
|
|
// user: DBPath("acme", "users/dave", "kv") -> orgs/acme/users/dave/kv.db
|
|
func DBPath(orgID, scope, service string) string {
|
|
if scope == "" {
|
|
return path.Join("orgs", orgID, service+".db")
|
|
}
|
|
return path.Join("orgs", orgID, scope, service+".db")
|
|
}
|
|
|
|
// Replicator binds ONE per-org SQLite (at a DBPath key) to its object-store slot.
|
|
//
|
|
// owner replica (IsOwner true): Push() on a timer / after a write burst
|
|
// reader replica (IsOwner false): Pull() before a stale-tolerant read
|
|
//
|
|
// It does NOT decide ownership — that is Owner()/IsOwner(). Push/Get/Put are
|
|
// whole-object (a WAL-checkpointed snapshot), the proven HIP-0107 mechanism.
|
|
type Replicator struct {
|
|
key string
|
|
store Store
|
|
db DB
|
|
|
|
cipher Cipher
|
|
org string // org id — the AAD / key-derivation input for the cipher
|
|
|
|
mu sync.Mutex
|
|
lastVer string // last object version pushed or pulled — skip redundant pulls
|
|
}
|
|
|
|
// Option configures a Replicator.
|
|
type Option func(*Replicator)
|
|
|
|
// WithEncryption seals every pushed snapshot with the org's per-org key so the
|
|
// object is ciphertext; orgID is bound as AAD, so a blob cannot be replayed under
|
|
// another org. Omit it and the DB is stored in the clear (local dev only).
|
|
func WithEncryption(c Cipher, orgID string) Option {
|
|
return func(r *Replicator) { r.cipher, r.org = c, orgID }
|
|
}
|
|
|
|
// NewReplicator binds the DB at key (from DBPath) to its store slot.
|
|
func NewReplicator(key string, store Store, db DB, opts ...Option) *Replicator {
|
|
r := &Replicator{key: key, store: store, db: db}
|
|
for _, o := range opts {
|
|
o(r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Key is this replicator's object-store location.
|
|
func (r *Replicator) Key() string { return r.key }
|
|
|
|
// Push snapshots the local DB (WAL-checkpointed) and writes it to the object
|
|
// store. Called by the OWNER — the single writer. The durable copy lives in vfs,
|
|
// so a lost pod loses no committed data.
|
|
func (r *Replicator) Push(ctx context.Context) error {
|
|
data, err := r.db.Snapshot(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("replica: snapshot %s: %w", r.key, err)
|
|
}
|
|
if r.cipher != nil {
|
|
if data, err = r.cipher.Seal(r.org, data); err != nil {
|
|
return fmt.Errorf("replica: seal %s: %w", r.key, err)
|
|
}
|
|
}
|
|
if err := r.store.Put(ctx, r.key, data); err != nil {
|
|
return fmt.Errorf("replica: put %s: %w", r.key, err)
|
|
}
|
|
r.mu.Lock()
|
|
r.lastVer = version(data) // version over the STORED (sealed) bytes
|
|
r.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Pull downloads the latest snapshot and restores it into the local DB — called
|
|
// by a READER before serving stale-tolerant reads, or by a NEW OWNER on handover
|
|
// to load the last committed state. A no-op when the remote is unchanged since
|
|
// our last push/pull.
|
|
func (r *Replicator) Pull(ctx context.Context) (changed bool, err error) {
|
|
ver, err := r.store.Version(ctx, r.key)
|
|
if err != nil {
|
|
return false, fmt.Errorf("replica: version %s: %w", r.key, err)
|
|
}
|
|
r.mu.Lock()
|
|
current := ver != "" && ver == r.lastVer
|
|
r.mu.Unlock()
|
|
if current {
|
|
return false, nil
|
|
}
|
|
data, ver, err := r.store.Get(ctx, r.key)
|
|
if err != nil {
|
|
return false, fmt.Errorf("replica: get %s: %w", r.key, err)
|
|
}
|
|
if r.cipher != nil {
|
|
if data, err = r.cipher.Open(r.org, data); err != nil {
|
|
return false, fmt.Errorf("replica: open %s: %w", r.key, err)
|
|
}
|
|
}
|
|
if err := r.db.Restore(ctx, data); err != nil {
|
|
return false, fmt.Errorf("replica: restore %s: %w", r.key, err)
|
|
}
|
|
r.mu.Lock()
|
|
r.lastVer = ver
|
|
r.mu.Unlock()
|
|
return true, nil
|
|
}
|
|
|
|
// PushLoop runs Push on an interval until ctx cancel — the owner's background
|
|
// WAL-shipper. On error it keeps the last good remote copy and retries next tick
|
|
// (fire-and-forget durability; never blocks writes).
|
|
func (r *Replicator) PushLoop(ctx context.Context, every time.Duration) {
|
|
if every <= 0 {
|
|
every = 10 * time.Second
|
|
}
|
|
t := time.NewTicker(every)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
_ = r.Push(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Version is the canonical content version for a snapshot — SHA-256 of the bytes,
|
|
// matching hanzoai/vfs's content-addressable model. Exported so any Store impl
|
|
// (cloud's deps.VFS-backed store, the BackendStore) computes the SAME version the
|
|
// Replicator's skip-redundant-pull logic expects — one hash function, one place.
|
|
func Version(data []byte) string {
|
|
h := sha256.Sum256(data)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// version is the internal alias used across this package.
|
|
func version(data []byte) string { return Version(data) }
|