Files
base/network/archive_writer.go
zeekayandHanzo Dev b70807b237 the suite is read twice, and the second reading is what a skip means
The adversarial suite states its own contract — pass when the defence is
there, fail when it regresses, skip for one reason while it is being built —
and nothing read it, so a skip meant whatever anyone assumed. It runs as a
gate now, and what the gate enforces is that the tests declaring themselves
blocked are exactly the tests that skipped.

Both directions matter. A count cannot tell a defence landing from another
regressing, because those net to zero. The direction that earns it is the
other one: a test written to skip only when it notices the defence missing is
green the day it is written, and fails here immediately instead of becoming
the thing that hides a regression. Six were that shape.

The declaration is read out of the same file the tests live in, so landing a
defence means deleting its marker and nothing else.

Ten cases went, and the reason is what they were: three named a package that
does not exist and an environment name read by nothing; two grepped another
repository's source, which does not belong in this one; one leaked through
its own fixture rather than through the code; four duplicated the suite from
a second file, and the two bounds only they carried were folded in. One had
been passing because it could not reach the internet, and would have started
skipping on a runner that could. A cross-shard replay is refused now, and
that one is an assertion.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:25:01 -07:00

511 lines
16 KiB
Go

package network
import (
"context"
"crypto/ed25519"
"fmt"
"iter"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
luxlog "github.com/luxfi/log"
)
// uploader is the minimal put/get/list surface every backend must
// expose to the shared writer loop. S3 implements it in its own file;
// the loop itself knows nothing about the underlying SDK.
type uploader interface {
put(ctx context.Context, key string, body []byte) error
get(ctx context.Context, key string) ([]byte, error)
list(ctx context.Context, prefix string) ([]string, error)
close() error
scheme() string
}
// shardQueue holds the in-progress segment for one shard plus any
// backlog of previously-encoded segments whose upload is still being
// retried. Backlog preserves ordering: items flush oldest-first.
type shardQueue struct {
shardID string
seg *segmentBuffer
backlog []segmentPending
// flushDeadline is when the current seg must flush even if under
// the size target. Reset on every new segment.
flushDeadline time.Time
}
// segmentPending is an encoded segment waiting to be uploaded. The
// nanos field is the monotonic suffix that goes into the object key —
// assigned once on rotate, never changed after, so re-buffered items
// don't collide with fresh encodes of the same seq range.
type segmentPending struct {
nanos int64
startSeq uint64
data []byte
}
// archiveWriter is the shared goroutine-per-shard pipeline that the S3
// backend sits behind.
type archiveWriter struct {
up uploader
cfg ArchiveConfig
// svcPrefix is the storage-side prefix: "<bucket-prefix>/<svc>"
// (already composed by NewArchive). Used unmodified in objectKey.
svcPrefix string
m *ArchiveMetrics
// signer produces per-segment Ed25519 signatures. Every segment we
// write is signed; without a signer the writer refuses to encode.
signer *segmentSigner
// verifier is the trust policy for segments we read back. Defaults
// to accepting only the local signer's key. Callers needing to
// trust a rotated-out key (PITR) inject additional keys via
// cfg.TrustedSegmentKeys.
verifier *segmentVerifier
mu sync.Mutex
shards map[string]*shardQueue
// lagBytes is the running size of buffered+backlog data across
// every shard. Refreshed whenever append/flush changes it.
lagBytes atomic.Int64
stopCh chan struct{}
stopped atomic.Bool
wg sync.WaitGroup
}
func newArchiveWriter(up uploader, svcPrefix string, cfg ArchiveConfig, m *ArchiveMetrics) *archiveWriter {
cfg = cfg.withDefaults()
signer, verifier := resolveSegmentCrypto(&cfg)
w := &archiveWriter{
up: up,
cfg: cfg,
svcPrefix: svcPrefix,
m: m,
signer: signer,
verifier: verifier,
shards: make(map[string]*shardQueue),
stopCh: make(chan struct{}),
}
w.wg.Add(1)
go w.flushLoop()
return w
}
// resolveSegmentCrypto picks the signing key and builds the verifier
// trust set. An unconfigured cfg auto-generates a transient key so
// tests and dev runs Just Work — production callers are expected to
// inject a KMS-held key via cfg.SigningKey and the matching public
// key(s) via cfg.TrustedSegmentKeys.
func resolveSegmentCrypto(cfg *ArchiveConfig) (*segmentSigner, *segmentVerifier) {
priv := cfg.SigningKey
if len(priv) != ed25519.PrivateKeySize {
// No key supplied — auto-generate. Sized so archives read
// within a single process (tests, dev) can be verified by the
// same writer's verifier.
pub, gen, err := ed25519.GenerateKey(nil)
if err != nil {
// Ed25519.GenerateKey only fails on a catastrophically
// broken crypto/rand — return a disabled pair so the
// writer fails closed on encode.
_ = pub
return nil, newSegmentVerifier()
}
priv = gen
}
s := newSegmentSigner(priv)
// Trust the local signer's key plus any explicitly trusted keys.
trust := []ed25519.PublicKey{s.pub}
trust = append(trust, cfg.TrustedSegmentKeys...)
return s, newSegmentVerifier(trust...)
}
// Append buffers a frame. It never blocks on the network; the flush
// loop handles upload. Fast-path returns without allocations when the
// shard queue already exists.
//
// lagBytes tracks backlog only (sealed, encoded segments awaiting
// upload). In-progress segments are deliberately excluded so the
// metric reflects "bytes pending flush to storage", which is the
// signal an operator cares about.
func (w *archiveWriter) Append(ctx context.Context, shardID string, seq uint64, frame []byte) error {
if w.stopped.Load() {
return fmt.Errorf("archive: writer closed")
}
if err := ctx.Err(); err != nil {
return err
}
w.mu.Lock()
defer w.mu.Unlock()
q, ok := w.shards[shardID]
if !ok {
q = &shardQueue{
shardID: shardID,
seg: newSegmentBuffer(shardID, seq),
flushDeadline: time.Now().Add(w.cfg.FlushInterval),
}
w.shards[shardID] = q
}
if err := q.seg.append(seq, frame); err != nil {
return err
}
// Size-based flush: move current seg to backlog, reset buffer.
if q.seg.sizeBytes() >= w.cfg.SegmentTargetBytes {
if err := w.rotateLocked(q); err != nil {
return err
}
}
w.enforceBacklogCapLocked(q)
w.reportLag()
return nil
}
// rotateLocked encodes the current segment, pushes it onto backlog,
// and starts a new empty segment. Caller must hold w.mu. Adds the
// encoded size to lagBytes so the metric reflects the backlog.
func (w *archiveWriter) rotateLocked(q *shardQueue) error {
if q.seg.len() == 0 {
// Empty segment: just reset the deadline, no upload.
q.flushDeadline = time.Now().Add(w.cfg.FlushInterval)
return nil
}
enc, err := q.seg.encode(w.signer)
if err != nil {
return fmt.Errorf("archive: encode segment: %w", err)
}
pending := segmentPending{
nanos: time.Now().UnixNano(),
startSeq: q.seg.startSeq,
data: enc,
}
q.backlog = append(q.backlog, pending)
w.lagBytes.Add(int64(len(enc)))
q.seg = newSegmentBuffer(q.shardID, q.seg.nextSeq)
q.flushDeadline = time.Now().Add(w.cfg.FlushInterval)
return nil
}
// enforceBacklogCapLocked sheds the oldest segments once the shard's
// backlog exceeds either the byte cap or the segment-count cap. Drops
// are counted on the IncDrops metric so operators get paged; this is
// strictly a liveness-over-durability trade that fires only under a
// hostile / broken backend (retries exhausted, uploads never
// succeeding). The alternative is OOM, which drops everything.
func (w *archiveWriter) enforceBacklogCapLocked(q *shardQueue) {
if w.cfg.BacklogMaxBytes <= 0 && w.cfg.BacklogMaxSegments <= 0 {
return
}
for {
total := 0
for _, p := range q.backlog {
total += len(p.data)
}
overBytes := w.cfg.BacklogMaxBytes > 0 && total > w.cfg.BacklogMaxBytes
overCount := w.cfg.BacklogMaxSegments > 0 && len(q.backlog) > w.cfg.BacklogMaxSegments
if !overBytes && !overCount {
return
}
if len(q.backlog) == 0 {
return
}
dropped := q.backlog[0]
q.backlog = q.backlog[1:]
w.lagBytes.Add(-int64(len(dropped.data)))
luxlog.Error("archive: backlog cap exceeded, dropping oldest segment",
"shard", q.shardID,
"dropped_seq", dropped.startSeq,
"dropped_bytes", len(dropped.data),
"backlog_segments_after", len(q.backlog),
"backlog_bytes_after", total-len(dropped.data),
)
if w.m != nil && w.m.IncDrops != nil {
w.m.IncDrops()
}
}
}
// flushLoop ticks at half the configured interval (so worst-case lag
// between deadline and flush is FlushInterval/2). Each tick scans
// shards for ones past deadline or with backlog, and uploads.
func (w *archiveWriter) flushLoop() {
defer w.wg.Done()
tick := w.cfg.FlushInterval / 2
if tick <= 0 {
tick = time.Second
}
t := time.NewTicker(tick)
defer t.Stop()
for {
select {
case <-w.stopCh:
// Drain: flush every remaining shard unconditionally.
w.drain()
return
case <-t.C:
w.flushReady()
}
}
}
// flushReady rotates any shard past its deadline, then drains backlog
// for every shard.
func (w *archiveWriter) flushReady() {
now := time.Now()
w.mu.Lock()
for _, q := range w.shards {
if !now.Before(q.flushDeadline) && q.seg.len() > 0 {
_ = w.rotateLocked(q) // rotateLocked only fails on encoder overflow
}
}
// Snapshot backlog keys so we can upload without holding the lock.
type item struct {
q *shardQueue
pending segmentPending
}
var pending []item
for _, q := range w.shards {
for _, seg := range q.backlog {
pending = append(pending, item{q: q, pending: seg})
}
q.backlog = nil
}
w.mu.Unlock()
for _, it := range pending {
w.uploadWithRetry(it.q, it.pending)
}
w.reportLag()
}
// drain is flushReady with current seg also rotated regardless of
// deadline, called on Close.
func (w *archiveWriter) drain() {
w.mu.Lock()
for _, q := range w.shards {
if q.seg.len() > 0 {
_ = w.rotateLocked(q)
}
}
type item struct {
q *shardQueue
pending segmentPending
}
var pending []item
for _, q := range w.shards {
for _, seg := range q.backlog {
pending = append(pending, item{q: q, pending: seg})
}
q.backlog = nil
}
w.mu.Unlock()
// On close, give each segment one full retry window to drain.
ctx, cancel := context.WithTimeout(context.Background(), w.cfg.RetryDeadline)
defer cancel()
for _, it := range pending {
if err := w.uploadOnce(ctx, it.q, it.pending); err != nil {
// Re-buffer the ones we couldn't ship so Close doesn't drop data;
// callers that care can poll until lag reaches zero.
w.mu.Lock()
it.q.backlog = append(it.q.backlog, it.pending)
w.enforceBacklogCapLocked(it.q)
w.mu.Unlock()
luxlog.Error("archive: flush on close failed", "shard", it.q.shardID, "err", err)
if w.m != nil && w.m.IncFailures != nil {
w.m.IncFailures()
}
}
}
w.reportLag()
}
// uploadWithRetry retries exponentially until RetryDeadline expires.
// On deadline, it re-buffers the segment (never silent data loss) and
// increments the failure counter. The backlog cap is enforced again on
// re-buffer, so a persistent hostile backend still bounds memory.
func (w *archiveWriter) uploadWithRetry(q *shardQueue, p segmentPending) {
ctx, cancel := context.WithTimeout(context.Background(), w.cfg.RetryDeadline)
defer cancel()
backoff := 250 * time.Millisecond
for {
err := w.uploadOnce(ctx, q, p)
if err == nil {
return
}
luxlog.Warn("archive: upload failed, will retry",
"shard", q.shardID, "bytes", len(p.data), "err", err)
select {
case <-w.stopCh:
w.reBuffer(q, p)
return
case <-ctx.Done():
luxlog.Error("archive: upload retry deadline exceeded, re-buffering",
"shard", q.shardID, "bytes", len(p.data))
if w.m != nil && w.m.IncFailures != nil {
w.m.IncFailures()
}
w.reBuffer(q, p)
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
}
}
func (w *archiveWriter) uploadOnce(ctx context.Context, q *shardQueue, p segmentPending) error {
// Sanity round-trip: refuse to ship anything we can't decode.
if _, err := decodeSegment(p.data, w.verifier); err != nil {
return fmt.Errorf("archive: refusing to upload unverifiable segment: %w", err)
}
key := objectKey(w.svcPrefix, q.shardID, p.startSeq, p.nanos)
if err := w.up.put(ctx, key, p.data); err != nil {
return err
}
w.lagBytes.Add(-int64(len(p.data)))
w.reportLag()
return nil
}
// reBuffer returns an unshipped segment to the shard's backlog so the
// next flush cycle tries again. The head-insert preserves oldest-first
// ordering; the cap check ensures memory is still bounded.
func (w *archiveWriter) reBuffer(q *shardQueue, p segmentPending) {
w.mu.Lock()
defer w.mu.Unlock()
q.backlog = append([]segmentPending{p}, q.backlog...)
w.lagBytes.Add(int64(len(p.data)))
w.enforceBacklogCapLocked(q)
}
func (w *archiveWriter) reportLag() {
if w.m != nil && w.m.SetLagBytes != nil {
w.m.SetLagBytes(w.lagBytes.Load())
}
}
// Close stops the flush loop and waits for in-flight uploads to
// complete. Segments that don't ship during the final drain are
// retained in-memory; a follow-on Close call or process restart will
// re-attempt. The error reflects the backend close, not upload state.
func (w *archiveWriter) Close() error {
if w.stopped.Swap(true) {
return nil
}
close(w.stopCh)
w.wg.Wait()
return w.up.close()
}
// Range implements Archive.Range by listing objects under the shard
// prefix, filtering by seq range, downloading each overlapping
// segment, and yielding frames in order.
//
// Keys contain a (startSeq, nanos) pair so multiple flushes of the
// same seq range don't overwrite. The iterator sorts by (startSeq,
// nanos) and dedupes frames by (startSeq + index) — the latest nanos
// for a given startSeq wins when two segments cover the same range.
func (w *archiveWriter) Range(ctx context.Context, shardID string, fromSeq, toSeq uint64) (iter.Seq2[Frame, error], error) {
if toSeq < fromSeq {
return nil, fmt.Errorf("archive: range toSeq %d < fromSeq %d", toSeq, fromSeq)
}
prefix := fmt.Sprintf("%s/%s/", w.svcPrefix, shardID)
keys, err := w.up.list(ctx, prefix)
if err != nil {
return nil, fmt.Errorf("archive: list %s: %w", prefix, err)
}
type segMeta struct {
key string
startSeq uint64
nanos int64
}
metas := make([]segMeta, 0, len(keys))
for _, k := range keys {
if !strings.HasSuffix(k, ".lbn") {
continue
}
startSeq, nanos, ok := parseObjectKey(k)
if !ok {
continue // ignore unrelated objects under this prefix
}
metas = append(metas, segMeta{key: k, startSeq: startSeq, nanos: nanos})
}
// Sort primarily by startSeq, secondarily by nanos (older first)
// so a later flush of overlapping ranges wins on dedupe below.
sort.Slice(metas, func(i, j int) bool {
if metas[i].startSeq != metas[j].startSeq {
return metas[i].startSeq < metas[j].startSeq
}
return metas[i].nanos < metas[j].nanos
})
return func(yield func(Frame, error) bool) {
// Track the next seq we haven't yielded yet; we skip frames we've
// already emitted when two segments overlap.
nextSeq := fromSeq
for i, m := range metas {
if m.startSeq > toSeq {
return
}
// Skip segments that end before fromSeq. We can only know the
// end by reading or the next segment's startSeq.
if i+1 < len(metas) && metas[i+1].startSeq <= fromSeq && metas[i+1].startSeq != m.startSeq {
continue
}
data, err := w.up.get(ctx, m.key)
if err != nil {
yield(Frame{ShardID: shardID}, fmt.Errorf("archive: get %s: %w", m.key, err))
return
}
dec, err := decodeSegment(data, w.verifier)
if err == nil && dec.ShardID != shardID {
// A segment names its own shard inside the signature, and the
// object key names one too. A restore is for one shard, so the
// two have to agree: whoever laid the object out is not who
// signed it, and only the signed half counts.
err = fmt.Errorf("segment is %q, read under %q", dec.ShardID, shardID)
}
if err != nil {
// R3: forged / truncated / foreign-signed segments are
// SILENTLY SKIPPED. Halting PITR on a single poisoned
// segment hands the attacker a DoS against restore;
// surfacing the skip as a metric keeps it visible.
luxlog.Warn("archive: skipping unverifiable segment",
"shard", shardID, "key", m.key, "err", err)
if w.m != nil && w.m.IncFailures != nil {
w.m.IncFailures()
}
continue
}
for idx, raw := range dec.Frames {
seq := dec.StartSeq + uint64(idx)
if seq < nextSeq {
// Already yielded from an earlier segment covering
// this range; skip the duplicate copy.
continue
}
if seq > toSeq {
return
}
// The stored bytes are the output of Frame.encode() —
// decode back so callers get a validated struct.
f, err := decodeFrame(raw)
if err != nil {
yield(Frame{ShardID: shardID, Seq: seq}, fmt.Errorf("archive: decode frame seq %d in %s: %w", seq, m.key, err))
return
}
if !yield(f, nil) {
return
}
nextSeq = seq + 1
}
}
}, nil
}