feat(0003d): replicated blob store on NATS Object Store

Branch-by-abstraction for the blob store (issue 0003d): media ciphertext
can live in a replicated JetStream Object Store instead of local disk, so
a blob uploaded to one node survives a node loss and is reachable from
any node.

pkg/blobstore:
- Store is now an interface (Put/Get/Has). The filesystem backend is
  renamed diskStore and stays the default: New(dir) returns it.
- objectStore (new) implements Store over a NATS Object Store bucket with
  a configurable replication factor (R1..R5), matching the KV store's
  R1->R3 rollout. Content-addressing (sha256-hex) is identical, so the
  wire contract is unchanged.

pkg/membership:
- Server.blobs and NewServer take the blobstore.Store interface instead
  of the concrete type; no behavior change with the disk default.

Tests (DoD: golden + edge + contract):
- TestObjectStoreRoundTrip: put/get/has + content-addressed dedup.
- TestObjectStoreMissing: unknown hash is absent and unreadable.
- TestObjectStoreAddressMatchesDisk: the Object Store and disk backends
  address identical bytes to the IDENTICAL hash (portable blob refs).

Like the KV store (0003b), wiring membershipd to select the Object Store
is deferred to the decentralized boot path (flag off); disk stays default.
This commit is contained in:
agent
2026-06-07 15:12:45 +02:00
parent 94e7ced1ef
commit d6e668b984
4 changed files with 263 additions and 12 deletions
+27 -10
View File
@@ -1,9 +1,15 @@
// Package blobstore is a content-addressed object store on local disk.
// Package blobstore is a content-addressed object store for media ciphertext.
//
// The bus transports messages, not blobs. Media (images, files, large payloads)
// is encrypted by the client BEFORE being stored here, so the store only ever
// sees ciphertext. Objects are addressed by the sha256 hex of their (encrypted)
// bytes, which makes Put idempotent and deduplicating.
//
// Store is an interface (branch-by-abstraction, issue 0003d) with two backends:
// diskStore (the default, local filesystem) and objectStore (NATS Object Store
// on JetStream, replicated across the cluster so blobs survive a node loss and
// are reachable from any node). The wire contract (sha256-hex addressing) is
// identical, so a client cannot tell which backend a membershipd uses.
package blobstore
import (
@@ -14,27 +20,38 @@ import (
"path/filepath"
)
// Store is a directory-backed content-addressed blob store.
type Store struct {
// Store is a content-addressed blob store: Put returns the sha256-hex address of
// the stored bytes, Get fetches by that address, Has reports presence.
type Store interface {
Put(data []byte) (string, error)
Get(hash string) ([]byte, error)
Has(hash string) bool
}
// diskStore is a directory-backed content-addressed blob store (the default,
// single-node backend).
type diskStore struct {
dir string
}
// New creates a Store rooted at dir, creating the directory if needed.
func New(dir string) (*Store, error) {
// New creates a disk-backed Store rooted at dir, creating the directory if
// needed. It remains the default backend; the replicated NATS Object Store is
// constructed separately (NewObjectStore) when decentralization is enabled.
func New(dir string) (Store, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("blobstore: mkdir %q: %w", dir, err)
}
return &Store{dir: dir}, nil
return &diskStore{dir: dir}, nil
}
// path returns the on-disk path for a given content hash.
func (s *Store) path(hash string) string {
func (s *diskStore) path(hash string) string {
return filepath.Join(s.dir, hash)
}
// Put writes data to the store and returns its sha256 hex hash. If an object
// with the same content already exists, Put is a no-op and returns the hash.
func (s *Store) Put(data []byte) (string, error) {
func (s *diskStore) Put(data []byte) (string, error) {
sum := sha256.Sum256(data)
hash := hex.EncodeToString(sum[:])
p := s.path(hash)
@@ -66,7 +83,7 @@ func (s *Store) Put(data []byte) (string, error) {
}
// Get reads the object with the given hash.
func (s *Store) Get(hash string) ([]byte, error) {
func (s *diskStore) Get(hash string) ([]byte, error) {
data, err := os.ReadFile(s.path(hash))
if err != nil {
return nil, fmt.Errorf("blobstore: get %q: %w", hash, err)
@@ -75,7 +92,7 @@ func (s *Store) Get(hash string) ([]byte, error) {
}
// Has reports whether an object with the given hash exists.
func (s *Store) Has(hash string) bool {
func (s *diskStore) Has(hash string) bool {
_, err := os.Stat(s.path(hash))
return err == nil
}