mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Phase 1 of the Base Network archive tier: witness-only validator
appends quasar-finalised WAL frames to object storage as per-shard
.lbn segments. PITR replay recovers SQLite state at any txseq.
Scope:
- network/archive.go Archive interface + URL dispatch
(s3://…|gs://…|off)
- network/segment.go .lbn encoder/decoder. Magic "LBN1",
CRC32-IEEE footer. Forwards-compat via
magic bump on any format change.
- network/archive_writer.go Goroutine-per-shard buffer with
size+time flush, exponential-backoff
retry against RetryDeadline, and
never-drop re-buffering on timeout.
- network/archive_s3.go minio-go/v7 backend. Works against AWS
S3 and any MinIO-protocol endpoint —
hanzoai/s3 self-hosted is one such.
Static creds from AWS_ACCESS_KEY_ID /
SECRET, else IAM/IRSA via
credentials.NewIAM().
- network/archive_gcs.go cloud.google.com/go/storage backend.
ADC by default; Workload Identity in
k8s, GOOGLE_APPLICATION_CREDENTIALS
for local dev.
- hack/pitr-restore.go CLI replay tool. Usage:
pitr-restore --archive gs://…/svc \
--shard <id> --to-seq <n> \
--out file.db
Writes a SQLite file with one row per
frame; Base's real apply path replays
into the schema.
Segment layout: "LBN1" | shardIDLen u16 | shardID | startSeq u64 |
frameCount u32 | (frameLen u32 | frameBytes){N} | crc32 footer.
PQ signature on each frame is preserved byte-for-byte — the
archive never re-signs.
Storage key layout per docs/NETWORK.md:
<svc>/<shard>/<16-digit-seq-prefix>/<20-digit-start-seq>.lbn
Config knobs (defaults): SegmentTargetBytes=8MiB, FlushInterval=10s,
RetryDeadline=5m. lagBytes gauge reflects backlog awaiting flush.
Metrics binding: BindArchiveMetrics(*Metrics) wires archive callbacks
onto the core Metrics.ArchiveLagBytes gauge + ApplyErrors counter.
Archive stays decoupled from prometheus/client_golang.
Tests: 12 archive-specific tests pass alongside the 20 network tests
from Phase 1 (32/32 total). Integration tests (`//go:build integration`)
cover 1000-frame S3 round-trip against local MinIO and PITR CLI
restore of 100 frames at cut-off seq 50 producing a SQLite file
with exactly 50 rows.
170 lines
4.8 KiB
Go
170 lines
4.8 KiB
Go
package network
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"iter"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/minio/minio-go/v7"
|
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
)
|
|
|
|
// s3Archive is the S3/MinIO-protocol backend. It works against AWS S3,
|
|
// hanzoai/s3 self-hosted, and any other MinIO-compatible endpoint.
|
|
//
|
|
// Credential resolution order (first match wins):
|
|
// 1. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY static creds.
|
|
// 2. IAM / IRSA / Workload Identity chain (credentials.IAM).
|
|
//
|
|
// Endpoint resolution:
|
|
// - AWS_ENDPOINT_URL set → that endpoint (MinIO / hanzoai/s3).
|
|
// - AWS_ENDPOINT_URL unset → s3.<region>.amazonaws.com default.
|
|
//
|
|
// Both paths honour AWS_REGION (defaults to us-east-1).
|
|
type s3Archive struct {
|
|
*archiveWriter
|
|
client *minio.Client
|
|
bucket string
|
|
}
|
|
|
|
type s3Upload struct {
|
|
client *minio.Client
|
|
bucket string
|
|
}
|
|
|
|
func newS3Archive(ctx context.Context, bucket, svcPrefix string, cfg ArchiveConfig, m *ArchiveMetrics) (*s3Archive, error) {
|
|
endpoint, secure, err := resolveS3Endpoint()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
region := os.Getenv("AWS_REGION")
|
|
if region == "" {
|
|
region = "us-east-1"
|
|
}
|
|
creds, err := resolveS3Creds()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := minio.New(endpoint, &minio.Options{
|
|
Creds: creds,
|
|
Secure: secure,
|
|
Region: region,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("archive s3: new client: %w", err)
|
|
}
|
|
ok, err := client.BucketExists(ctx, bucket)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("archive s3: bucket check %s: %w", bucket, err)
|
|
}
|
|
if !ok {
|
|
return nil, fmt.Errorf("archive s3: bucket %q does not exist", bucket)
|
|
}
|
|
up := &s3Upload{client: client, bucket: bucket}
|
|
return &s3Archive{
|
|
archiveWriter: newArchiveWriter(up, svcPrefix, cfg, m),
|
|
client: client,
|
|
bucket: bucket,
|
|
}, nil
|
|
}
|
|
|
|
// Append satisfies Archive.
|
|
func (a *s3Archive) Append(ctx context.Context, shardID string, seq uint64, frame []byte) error {
|
|
return a.archiveWriter.Append(ctx, shardID, seq, frame)
|
|
}
|
|
|
|
// Range satisfies Archive.
|
|
func (a *s3Archive) Range(ctx context.Context, shardID string, fromSeq, toSeq uint64) (iter.Seq2[Frame, error], error) {
|
|
return a.archiveWriter.Range(ctx, shardID, fromSeq, toSeq)
|
|
}
|
|
|
|
// Close satisfies Archive.
|
|
func (a *s3Archive) Close() error { return a.archiveWriter.Close() }
|
|
|
|
// --- uploader impl ---
|
|
|
|
func (u *s3Upload) put(ctx context.Context, key string, body []byte) error {
|
|
_, err := u.client.PutObject(ctx, u.bucket, key, bytes.NewReader(body), int64(len(body)), minio.PutObjectOptions{
|
|
ContentType: "application/octet-stream",
|
|
SendContentMd5: true,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("s3 put %s/%s: %w", u.bucket, key, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (u *s3Upload) get(ctx context.Context, key string) ([]byte, error) {
|
|
obj, err := u.client.GetObject(ctx, u.bucket, key, minio.GetObjectOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("s3 get %s/%s: %w", u.bucket, key, err)
|
|
}
|
|
defer obj.Close()
|
|
b, err := io.ReadAll(obj)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("s3 read %s/%s: %w", u.bucket, key, err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (u *s3Upload) list(ctx context.Context, prefix string) ([]string, error) {
|
|
var out []string
|
|
for info := range u.client.ListObjects(ctx, u.bucket, minio.ListObjectsOptions{
|
|
Prefix: prefix,
|
|
Recursive: true,
|
|
}) {
|
|
if info.Err != nil {
|
|
return nil, fmt.Errorf("s3 list %s/%s: %w", u.bucket, prefix, info.Err)
|
|
}
|
|
out = append(out, info.Key)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (u *s3Upload) close() error { return nil }
|
|
|
|
func (u *s3Upload) scheme() string { return "s3" }
|
|
|
|
// resolveS3Endpoint picks the endpoint host:port and TLS flag.
|
|
// AWS_ENDPOINT_URL lets operators point at hanzoai/s3, MinIO, or any
|
|
// other S3-compatible endpoint; without it, we talk to AWS.
|
|
func resolveS3Endpoint() (string, bool, error) {
|
|
raw := os.Getenv("AWS_ENDPOINT_URL")
|
|
if raw == "" {
|
|
// AWS default.
|
|
region := os.Getenv("AWS_REGION")
|
|
if region == "" {
|
|
region = "us-east-1"
|
|
}
|
|
return fmt.Sprintf("s3.%s.amazonaws.com", region), true, nil
|
|
}
|
|
// Accept both "host:port" and full URLs.
|
|
if !strings.Contains(raw, "://") {
|
|
return raw, true, nil
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("archive s3: parse AWS_ENDPOINT_URL %q: %w", raw, err)
|
|
}
|
|
return u.Host, u.Scheme == "https", nil
|
|
}
|
|
|
|
// resolveS3Creds returns a provider chain. Static creds win if both
|
|
// env vars are set; otherwise we delegate to IAM / IRSA / Workload
|
|
// Identity via the minio-go credentials.IAM provider.
|
|
func resolveS3Creds() (*credentials.Credentials, error) {
|
|
ak := os.Getenv("AWS_ACCESS_KEY_ID")
|
|
sk := os.Getenv("AWS_SECRET_ACCESS_KEY")
|
|
token := os.Getenv("AWS_SESSION_TOKEN")
|
|
if ak != "" && sk != "" {
|
|
return credentials.NewStaticV4(ak, sk, token), nil
|
|
}
|
|
// IAM chain: EKS/IRSA via WebIdentity, EC2 metadata, ECS task role.
|
|
return credentials.NewIAM(""), nil
|
|
}
|