Files
zeekay ff1ee17c4f feat(network): Quasar-replicated WAL frames for Base (Phase 1)
Core runtime for network/ per docs/NETWORK.md. One env var
(BASE_NETWORK=quasar) opts Base into PQ-native HA replication via
luxfi/[email protected] Quasar DAG; one DAG per shard. Standalone
mode (default) returns a no-op Network with zero overhead.

Package surface:

  network.FromEnv()                      — parse BASE_* env, build Network
  network.Network.InstallWALHook(c, id)  — install modernc commit hook
  network.Network.WriterFor(shardID)     — router owner lookup
  network.Network.MembersFor(shardID)    — router replica set
  network.Network.Metrics()              — Prom collectors

Frame = {ShardID, Seq, PrevSeq, Ts, Salt, Payload, Cksm, Bytes}.
Content-addressed by (Salt, Cksm) so apply is idempotent across
pod restarts. Frame.Bytes is the exact serialised wire form —
archive replay never re-signs.

Quasar adapter (quasar.go): the upstream public API is Engine-based
(Submit(Block)/Finalized() <-chan Block), not the shard-scoped
Submit(shardID, frame) the design doc assumed. Adapter wraps one
Engine per shard; Block.ChainID = SHA256(shardID), Block.Data =
Frame.encode(). threshold < 2 routes to NewTestEngine for
single-node paths; production multi-node uses NewEngine with
k = floor(N/2)+1.

modernc.org/sqlite exposes RegisterCommitHook, not sqlite3_wal_hook
— same semantics for our use. core/base_network.go bridges
sqlite.HookRegisterer to network.HookRegisterer via a one-line
adapter so the network package stays driver-agnostic.

Hook into core/base.go: one field (network baseNetwork), one call
at end of Bootstrap (app.attachNetwork(ctx)), one call in
ResetBootstrapState (app.network.Stop). Standalone path unchanged.

Metrics: every collector in the doc (base_shards, base_active_shards,
base_wal_lag_bytes, base_hot_shards, base_lease_contentions,
base_archive_lag_bytes) plus frames/errors counters for the
produce/finalize loop. Callers bind to their own registry;
prometheus.DefaultRegisterer untouched.

Tests — 20 pass, 0 fail:
  wal_test.go           frame encode/decode roundtrip, tamper, idempotency key
  router_test.go        HRW determinism, balance, quorum math
  network_test.go       FromEnv standalone/quasar, fail-fast validation
  integration_test.go   3 in-process nodes share memoryHub transport; A and B
                        write different shards; all 3 finalise both frames
                        and increment FramesIngested on the non-writer.
  core/ regression      384 PASS / 2 FAIL — identical to pre-change baseline.

go.mod: +github.com/luxfi/consensus v1.22.85.
2026-04-22 13:39:01 -07:00

99 lines
2.3 KiB
Go

package network
import "testing"
func TestRouterDeterministic(t *testing.T) {
members := []NodeID{"a", "b", "c", "d", "e"}
r1 := newRouter(members, 3)
r2 := newRouter(append([]NodeID{"e", "b"}, "a", "c", "d"), 3)
for _, shard := range []string{"u1", "u2", "u3", "u4", "u5"} {
a := r1.membersFor(shard)
b := r2.membersFor(shard)
if len(a) != len(b) {
t.Fatalf("shard %s: length mismatch %v vs %v", shard, a, b)
}
for i := range a {
if a[i] != b[i] {
t.Fatalf("shard %s: order mismatch at %d: %v vs %v", shard, i, a, b)
}
}
}
}
func TestRouterReplicationCapped(t *testing.T) {
r := newRouter([]NodeID{"a", "b"}, 5) // replication > members
ms := r.membersFor("x")
if len(ms) != 2 {
t.Errorf("len(membersFor) = %d, want 2 (capped at member count)", len(ms))
}
}
func TestRouterEmptyMembers(t *testing.T) {
r := newRouter(nil, 3)
if r.ownerOf("x") != "" {
t.Error("owner of empty ring should be \"\"")
}
if len(r.membersFor("x")) != 0 {
t.Error("members of empty ring should be empty")
}
}
func TestRouterOwnerIsFirstMember(t *testing.T) {
r := newRouter([]NodeID{"a", "b", "c"}, 3)
for _, shard := range []string{"q", "r", "s"} {
ms := r.membersFor(shard)
if r.ownerOf(shard) != ms[0] {
t.Errorf("ownerOf(%s) = %q but membersFor[0] = %q", shard, r.ownerOf(shard), ms[0])
}
}
}
func TestRouterBalancedEnough(t *testing.T) {
// 5 members, 1000 shards. Owner-counts should be within a reasonable band.
members := []NodeID{"a", "b", "c", "d", "e"}
r := newRouter(members, 1)
counts := map[NodeID]int{}
for i := 0; i < 1000; i++ {
counts[r.ownerOf(shardName(i))]++
}
// Expect each in [150, 250] (perfect is 200). Rendezvous hash bias is small.
for _, m := range members {
if c := counts[m]; c < 120 || c > 280 {
t.Errorf("member %s: %d shards, out of balance band", m, c)
}
}
}
func shardName(i int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := [4]byte{
letters[(i>>12)%len(letters)],
letters[(i>>8)%len(letters)],
letters[(i>>4)%len(letters)],
letters[i%len(letters)],
}
return string(b[:])
}
func TestQuorum(t *testing.T) {
cases := []struct {
n int
want int
}{
{0, 1},
{1, 1},
{2, 2},
{3, 2},
{4, 3},
{5, 3},
{7, 4},
}
for _, c := range cases {
if got := quorum(c.n); got != c.want {
t.Errorf("quorum(%d) = %d, want %d", c.n, got, c.want)
}
}
}