Files
replicate/sharded_prefix_test.go
zeekayandGitHub f3e1dd3d6f peer: ZAP-based LTX streaming for W=2 hot path (#4)
* peer: add ReplicaClient + Server for ZAP-based LTX streaming

ZAP-native peer-to-peer LTX frame streaming for W=2 hot path. Owner
pushes one frame, standby fsyncs + ACKs same-AZ in ~200us. Replaces
S3 PUT in PlaceOrder ACK (50ms ceiling) with peer ACK (~1ms p99).

Wire protocol (4 opcodes in caller-supplied namespace):
  0xA020 OpWALFrame      [8B mkt][4B txid][4B len][N body]
  0xA021 OpWALFrameAck   [1B code][8B mkt][4B txid]
  0xA022 OpSnapshotReq   [8B mkt][8B from_txid]
  0xA023 OpSnapshotResp  [1B code][8B mkt][8B txid][N body]

ReplicaClient satisfies replicate.ReplicaClient so the existing
DB/Replica plumbing routes a peer replica identically to S3. LTXFiles
+ OpenLTXFile + DeleteLTXFiles are no-ops by design: peer is the live
channel, not the backfill source. New-pod cold-start uses Snapshot
+ then tails Sevenframes; that path stays on S3.

Server registers OpWALFrame + OpSnapshotReq handlers via an injected
Dispatcher interface so the peer package is namespace-agnostic. ATS's
atsrpc.Dispatcher satisfies the contract.

6 unit tests (race-clean):
- TestReplicaClient_WriteAck — happy path, body integrity
- TestReplicaClient_ApplyFailureSurfacesAsError — fsync error -> AckError
- TestReplicaClient_PeerCloseRejects — closed server returns ErrCodeNotStandby
- TestReplicaClient_SnapshotResp — OpSnapshotReq round trip
- TestRegister_RejectsDuplicate — last-write-wins is rejected
- TestWireRoundTrip — encode/decode symmetry

* sync_policy: SyncPolicy enum for W=2 quorum semantics

Type-level expression of the W=2 quorum contract. SyncQuorumW2 means:
return after local fsync + one peer ACK; remaining replicas (S3) lag
async. SyncAll is the historical single-or-multi replica behavior;
SyncLocalOnly is dev-mode only.

Caller (ATS Journal) consumes the enum to choose its push strategy.
The replicate.DB doesn't need to interpret it directly — the journal
sequences peer push before triggering S3 sync — so we keep DB as the
single-replica abstraction and let the journal own quorum logic.

1 unit test covering String() across all values + the unknown branch.

* sharded_prefix: 256-shard helper for S3 cold-tier writes

AWS S3 throttles at ~3500 PUT/s per prefix. 12K markets at 100 PUT/s
each (cold tier) exceeds a single-prefix budget. ShardedPrefix(symbol)
returns one of 256 stable 2-hex shards (blake3-keyed) so the cluster
spreads cold-tier writes across the partition space:

  256 shards x 3500 PUT/s = ~900K PUT/s aggregate cluster-wide.

ShardedKey is the layout-canonical builder:
  ShardedKey('markets', 'AAPL', '0123.ltx')
  => 'markets/a3/AAPL/0123.ltx'

5 unit tests:
- Deterministic (same input -> same shard across runs)
- Distribution (>200 of 256 buckets covered over 4000 symbols)
- Layout (4-segment shape)
- NoRoot / NoSuffix edge cases

* deps: bump Go to 1.26.4 across go.mod, Dockerfiles, GH Actions

Workspace-wide sync. luxfi/node already shipped on 1.26.4 in v1.30.6
(commit 121aca1fa9); this is the cross-repo catch-up.

* replicate: sync.Pool for LTX frame buffers

WriteLTXFile previously did io.ReadAll(r) per push — at NYSE-class
throughput (~80M pushes/sec aggregate across a 16-pod cluster), that
allocates O(frame_size_bytes) on every Push and churns the allocator.

readPooled reuses up to 64KB byte slices via sync.Pool. L0 frames
(typical 4-32KB) fit in one pooled buffer; oversize compaction
outputs (L1+) bypass the pool with a single io.ReadAll fallback so
the pool's working set stays bounded.

Steady-state allocations per Push drop from O(frame_size) to O(1).
GC pressure drops linearly with batch size (one frame per batch in
the new ATS batched-accept path, not per order).

64KB ceiling matches the L0 frame cap baked into the SQLite WAL → LTX
pipeline (4KB page × ~16 pages per checkpointed WAL frame).

* peer: panic recovery + pool oversize drop (F-22 F-05)

F-22 CRITICAL — wrap onWALFrame and onSnapshotReq in defer/recover.
A panic inside FrameSink.Apply (e.g. nil-deref while decoding a
malformed LTX body) previously propagated up through the ZAP
dispatch goroutine and DoS'd the standby's peer receive surface.
Now: panic is logged with full stack and the handler returns
ErrCodeServerInternal — owner retries, standby keeps serving.

F-05 HIGH — readPooled grew the cached buffer past ltxPoolCap on
the first outlier frame (>64KB compaction output). Subsequent
Get/Put cycles preserved the inflated cap, putting every steady-
state push back through the outlier branch. Now: putPooled drops
any buffer with cap > ltxPoolCap; the pool's working-set stays
bounded.

Tests: TestPeerServer_PanicIn{WALFrame,Snapshot}HandlerDoesNotKillServer,
TestPeerFramePool_DropsOversizeBuffers, TestPutPooled_DropsOversize.
All pass under -race.
2026-06-07 14:06:05 -07:00

88 lines
2.2 KiB
Go

package replicate
import (
"regexp"
"strings"
"testing"
)
var hexShard = regexp.MustCompile(`^[0-9a-f]{2}$`)
func TestShardedPrefix_Deterministic(t *testing.T) {
for _, sym := range []string{"AAPL", "MSFT", "NVDA", "BTC", "ETH"} {
a := ShardedPrefix(sym)
b := ShardedPrefix(sym)
if a != b {
t.Errorf("%s: prefix not stable %q vs %q", sym, a, b)
}
if !hexShard.MatchString(a) {
t.Errorf("%s: prefix %q not 2-hex", sym, a)
}
}
}
func TestShardedPrefix_Distribution(t *testing.T) {
// Synthetic load: hit 4000 unique symbols, count shards. Each of
// the 256 buckets should appear at least once on a uniformly-keyed
// hash; we don't enforce uniform spread (the test would be flaky)
// but we do enforce coverage of >200 buckets so we know the hash
// is actually mixing.
buckets := map[string]int{}
for i := 0; i < 4000; i++ {
buckets[ShardedPrefix(symbolize(i))]++
}
if len(buckets) < 200 {
t.Errorf("expected >200 unique shard buckets, got %d", len(buckets))
}
}
func TestShardedKey_Layout(t *testing.T) {
key := ShardedKey("markets", "AAPL", "000000000000007b.ltx")
parts := strings.Split(key, "/")
if len(parts) != 4 {
t.Fatalf("expected 4 segments, got %d (%v)", len(parts), parts)
}
if parts[0] != "markets" {
t.Errorf("segment[0]: %q", parts[0])
}
if !hexShard.MatchString(parts[1]) {
t.Errorf("segment[1] not 2-hex: %q", parts[1])
}
if parts[2] != "AAPL" {
t.Errorf("segment[2]: %q", parts[2])
}
if parts[3] != "000000000000007b.ltx" {
t.Errorf("segment[3]: %q", parts[3])
}
}
func TestShardedKey_NoRoot(t *testing.T) {
key := ShardedKey("", "AAPL", "file.ltx")
if strings.HasPrefix(key, "/") {
t.Errorf("unexpected leading slash: %q", key)
}
if !strings.HasSuffix(key, "/AAPL/file.ltx") {
t.Errorf("layout: %q", key)
}
}
func TestShardedKey_NoSuffix(t *testing.T) {
key := ShardedKey("markets", "AAPL", "")
if strings.HasSuffix(key, "/") {
t.Errorf("trailing slash: %q", key)
}
if !strings.HasSuffix(key, "/AAPL") {
t.Errorf("layout: %q", key)
}
}
func symbolize(i int) string {
// Generate distinct 5-char codes: covers ~11.8M for the 4000 used.
var b [5]byte
for k := 0; k < 5; k++ {
b[k] = byte('A' + (i % 26))
i /= 26
}
return string(b[:])
}