shithub: furgit

Download patch

ref: c3fe7af6cf267e6fafe5ab24cd2cc238e3ba3029
parent: d298ab6caba5f1e70c58155f4efa33ef2d16a1c2
author: Runxi Yu <runxiyu@umich.edu>
date: Mon Mar 30 14:06:05 EDT 2026

object/store/dual: Add a basic dual composr

--- /dev/null
+++ b/object/store/dual/doc.go
@@ -1,0 +1,8 @@
+// Package dual provides one logical object store backed by separate object-wise
+// and pack-wise stores.
+//
+// Dual composes a store that handles individual object writes with a store that
+// handles pack-wise writes, while exposing one mixed reader over both.
+// Coordinated quarantine operations span both stores, but quarantine promotion
+// is non-atomic.
+package dual
--- /dev/null
+++ b/object/store/dual/dual.go
@@ -1,0 +1,35 @@
+package dual
+
+import objectstore "codeberg.org/lindenii/furgit/object/store"
+
+type objectSide interface {
+	objectstore.Reader
+	objectstore.ObjectWriter
+	objectstore.ObjectQuarantiner
+}
+
+type packSide interface {
+	objectstore.Reader
+	objectstore.PackWriter
+	objectstore.PackQuarantiner
+}
+
+// Dual composes one object-wise store and one pack-wise store into one logical
+// object store.
+//
+// Reads are served from the combined object reader of both stores. Individual
+// object writes are routed to the object-wise store, and pack writes are routed
+// to the pack-wise store. Coordinated quarantines go across both stores.
+type Dual struct {
+	object objectSide
+	pack   packSide
+	reader objectstore.Reader
+}
+
+var (
+	_ objectstore.Reader            = (*Dual)(nil)
+	_ objectstore.ObjectWriter      = (*Dual)(nil)
+	_ objectstore.PackWriter        = (*Dual)(nil)
+	_ objectstore.ObjectQuarantiner = (*Dual)(nil)
+	_ objectstore.PackQuarantiner   = (*Dual)(nil)
+)
--- /dev/null
+++ b/object/store/dual/dual_test.go
@@ -1,0 +1,263 @@
+package dual_test
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+	objectstore "codeberg.org/lindenii/furgit/object/store"
+	"codeberg.org/lindenii/furgit/object/store/dual"
+	"codeberg.org/lindenii/furgit/object/store/loose"
+	"codeberg.org/lindenii/furgit/object/store/packed"
+	objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+func fixturePath(t *testing.T, algo objectid.Algorithm, name string) string {
+	t.Helper()
+
+	return filepath.Join("..", "packed", "internal", "ingest", "testdata", "fixtures", algo.String(), name)
+}
+
+func fixtureBytes(t *testing.T, algo objectid.Algorithm, name string) []byte {
+	t.Helper()
+
+	path := fixturePath(t, algo, name)
+	dir := filepath.Dir(path)
+	base := filepath.Base(path)
+
+	root, err := os.OpenRoot(dir)
+	if err != nil {
+		t.Fatalf("open fixture root %q: %v", dir, err)
+	}
+
+	defer func() {
+		err := root.Close()
+		if err != nil {
+			t.Fatalf("close fixture root %q: %v", dir, err)
+		}
+	}()
+
+	data, err := root.ReadFile(base)
+	if err != nil {
+		t.Fatalf("read fixture %q: %v", base, err)
+	}
+
+	return data
+}
+
+func fixtureMetadata(t *testing.T, algo objectid.Algorithm) map[string]string {
+	t.Helper()
+
+	data := fixtureBytes(t, algo, "METADATA.txt")
+	out := make(map[string]string)
+
+	for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+
+		key, value, ok := strings.Cut(line, "=")
+		if !ok {
+			t.Fatalf("invalid fixture metadata line %q", line)
+		}
+
+		out[strings.TrimSpace(key)] = strings.TrimSpace(value)
+	}
+
+	return out
+}
+
+func fixtureOID(t *testing.T, algo objectid.Algorithm, key string) objectid.ObjectID {
+	t.Helper()
+
+	meta := fixtureMetadata(t, algo)
+	hex, ok := meta[key]
+	if !ok {
+		t.Fatalf("missing fixture metadata key %q", key)
+	}
+
+	id, err := objectid.ParseHex(algo, hex)
+	if err != nil {
+		t.Fatalf("parse fixture metadata oid %q: %v", hex, err)
+	}
+
+	return id
+}
+
+func newDualStore(t *testing.T, repo *testgit.TestRepo, algo objectid.Algorithm) *dual.Dual {
+	t.Helper()
+
+	objectsRoot := repo.OpenObjectsRoot(t)
+	looseStore, err := loose.New(objectsRoot, algo)
+	if err != nil {
+		t.Fatalf("loose.New: %v", err)
+	}
+
+	packRoot := repo.OpenPackRoot(t)
+	packedStore, err := packed.New(packRoot, algo, packed.Options{WriteRev: true})
+	if err != nil {
+		t.Fatalf("packed.New: %v", err)
+	}
+
+	return dual.New(looseStore, packedStore)
+}
+
+func TestDualReadsWritesAndQuarantine(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		head := fixtureOID(t, algo, "head")
+		packBytes := fixtureBytes(t, algo, "nonthin.pack")
+
+		repo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+		store := newDualStore(t, repo, algo)
+
+		quarantiner, ok := any(store).(objectstore.PackQuarantiner)
+		if !ok {
+			t.Fatal("dual does not implement PackQuarantiner")
+		}
+
+		quarantine, err := quarantiner.BeginPackQuarantine(objectstore.PackQuarantineOptions{})
+		if err != nil {
+			t.Fatalf("BeginPackQuarantine: %v", err)
+		}
+
+		err = quarantine.WritePack(bytes.NewReader(packBytes), objectstore.PackWriteOptions{RequireTrailingEOF: true})
+		if err != nil {
+			t.Fatalf("quarantine.WritePack: %v", err)
+		}
+
+		objectQ, ok := any(quarantine).(objectstore.ObjectQuarantine)
+		if !ok {
+			t.Fatal("pack quarantine does not also implement ObjectQuarantine")
+		}
+
+		looseContent := []byte("dual quarantine loose object\n")
+		looseID, err := objectQ.WriteBytesContent(objecttype.TypeBlob, looseContent)
+		if err != nil {
+			t.Fatalf("quarantine.WriteBytesContent: %v", err)
+		}
+
+		ty, _, err := quarantine.ReadHeader(head)
+		if err != nil {
+			t.Fatalf("quarantine.ReadHeader(pack): %v", err)
+		}
+
+		if ty != objecttype.TypeCommit {
+			t.Fatalf("quarantine.ReadHeader(pack) type = %v, want commit", ty)
+		}
+
+		ty, got, err := quarantine.ReadBytesContent(looseID)
+		if err != nil {
+			t.Fatalf("quarantine.ReadBytesContent(loose): %v", err)
+		}
+
+		if ty != objecttype.TypeBlob {
+			t.Fatalf("quarantine.ReadBytesContent(loose) type = %v, want blob", ty)
+		}
+
+		if !bytes.Equal(got, looseContent) {
+			t.Fatal("quarantine.ReadBytesContent(loose) mismatch")
+		}
+
+		_, _, err = store.ReadHeader(head)
+		if err == nil {
+			t.Fatal("store.ReadHeader unexpectedly saw quarantined pack object before promote")
+		}
+
+		_, _, err = store.ReadBytesContent(looseID)
+		if err == nil {
+			t.Fatal("store.ReadBytesContent unexpectedly saw quarantined loose object before promote")
+		}
+
+		err = quarantine.Promote()
+		if err != nil {
+			t.Fatalf("quarantine.Promote: %v", err)
+		}
+
+		err = store.Refresh()
+		if err != nil {
+			t.Fatalf("store.Refresh: %v", err)
+		}
+
+		ty, _, err = store.ReadHeader(head)
+		if err != nil {
+			t.Fatalf("store.ReadHeader(pack): %v", err)
+		}
+
+		if ty != objecttype.TypeCommit {
+			t.Fatalf("store.ReadHeader(pack) type = %v, want commit", ty)
+		}
+
+		ty, got, err = store.ReadBytesContent(looseID)
+		if err != nil {
+			t.Fatalf("store.ReadBytesContent(loose): %v", err)
+		}
+
+		if ty != objecttype.TypeBlob {
+			t.Fatalf("store.ReadBytesContent(loose) type = %v, want blob", ty)
+		}
+
+		if !bytes.Equal(got, looseContent) {
+			t.Fatal("store.ReadBytesContent(loose) mismatch")
+		}
+	})
+}
+
+func TestDualQuarantineDiscardDropsBothHalves(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		head := fixtureOID(t, algo, "head")
+		packBytes := fixtureBytes(t, algo, "nonthin.pack")
+
+		repo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+		store := newDualStore(t, repo, algo)
+
+		quarantiner := any(store).(objectstore.ObjectQuarantiner)
+		quarantine, err := quarantiner.BeginObjectQuarantine(objectstore.ObjectQuarantineOptions{})
+		if err != nil {
+			t.Fatalf("BeginObjectQuarantine: %v", err)
+		}
+
+		packQ, ok := any(quarantine).(objectstore.PackQuarantine)
+		if !ok {
+			t.Fatal("object quarantine does not also implement PackQuarantine")
+		}
+
+		err = packQ.WritePack(bytes.NewReader(packBytes), objectstore.PackWriteOptions{RequireTrailingEOF: true})
+		if err != nil {
+			t.Fatalf("quarantine.WritePack: %v", err)
+		}
+
+		looseID, err := quarantine.WriteBytesContent(objecttype.TypeBlob, []byte("discarded dual object\n"))
+		if err != nil {
+			t.Fatalf("quarantine.WriteBytesContent: %v", err)
+		}
+
+		err = quarantine.Discard()
+		if err != nil {
+			t.Fatalf("quarantine.Discard: %v", err)
+		}
+
+		err = store.Refresh()
+		if err != nil {
+			t.Fatalf("store.Refresh: %v", err)
+		}
+
+		_, _, err = store.ReadHeader(head)
+		if err == nil {
+			t.Fatal("store.ReadHeader unexpectedly saw discarded pack object")
+		}
+
+		_, _, err = store.ReadBytesContent(looseID)
+		if err == nil {
+			t.Fatal("store.ReadBytesContent unexpectedly saw discarded loose object")
+		}
+	})
+}
--- /dev/null
+++ b/object/store/dual/new.go
@@ -1,0 +1,29 @@
+package dual
+
+import (
+	objectstore "codeberg.org/lindenii/furgit/object/store"
+	objectmix "codeberg.org/lindenii/furgit/object/store/mix"
+)
+
+// New creates one dual object store from borrowed object-wise and pack-wise
+// stores.
+//
+// Labels: Deps-Borrowed, Life-Parent.
+func New(
+	object interface {
+		objectstore.Reader
+		objectstore.ObjectWriter
+		objectstore.ObjectQuarantiner
+	},
+	pack interface {
+		objectstore.Reader
+		objectstore.PackWriter
+		objectstore.PackQuarantiner
+	},
+) *Dual {
+	return &Dual{
+		object: object,
+		pack:   pack,
+		reader: objectmix.New(object, pack),
+	}
+}
--- /dev/null
+++ b/object/store/dual/quarantine.go
@@ -1,0 +1,113 @@
+package dual
+
+import (
+	"io"
+
+	objectid "codeberg.org/lindenii/furgit/object/id"
+	objectstore "codeberg.org/lindenii/furgit/object/store"
+	objectmix "codeberg.org/lindenii/furgit/object/store/mix"
+	objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// quarantine is one coordinated dual quarantine over both stores.
+type quarantine struct {
+	objectQ objectstore.ObjectQuarantine
+	packQ   objectstore.PackQuarantine
+	reader  objectstore.Reader
+}
+
+var (
+	_ objectstore.ObjectQuarantine = (*quarantine)(nil)
+	_ objectstore.PackQuarantine   = (*quarantine)(nil)
+)
+
+func newQuarantine(
+	objectQ objectstore.ObjectQuarantine,
+	packQ objectstore.PackQuarantine,
+) *quarantine {
+	return &quarantine{
+		objectQ: objectQ,
+		packQ:   packQ,
+		reader:  objectmix.New(objectQ, packQ),
+	}
+}
+
+// ReadBytesFull reads a full serialized object as "type size\0content" from
+// either quarantined store.
+func (quarantine *quarantine) ReadBytesFull(id objectid.ObjectID) ([]byte, error) {
+	return quarantine.reader.ReadBytesFull(id)
+}
+
+// ReadBytesContent reads an object's type and content bytes from either
+// quarantined store.
+func (quarantine *quarantine) ReadBytesContent(id objectid.ObjectID) (objecttype.Type, []byte, error) {
+	return quarantine.reader.ReadBytesContent(id)
+}
+
+// ReadReaderFull reads a full serialized object stream as
+// "type size\0content" from either quarantined store.
+func (quarantine *quarantine) ReadReaderFull(id objectid.ObjectID) (io.ReadCloser, error) {
+	return quarantine.reader.ReadReaderFull(id)
+}
+
+// ReadReaderContent reads an object's type, declared content length, and
+// content stream from either quarantined store.
+func (quarantine *quarantine) ReadReaderContent(id objectid.ObjectID) (objecttype.Type, int64, io.ReadCloser, error) {
+	return quarantine.reader.ReadReaderContent(id)
+}
+
+// ReadSize reads an object's declared content length from either quarantined
+// store.
+func (quarantine *quarantine) ReadSize(id objectid.ObjectID) (int64, error) {
+	return quarantine.reader.ReadSize(id)
+}
+
+// ReadHeader reads an object's type and declared content length from either
+// quarantined store.
+func (quarantine *quarantine) ReadHeader(id objectid.ObjectID) (objecttype.Type, int64, error) {
+	return quarantine.reader.ReadHeader(id)
+}
+
+// Refresh refreshes both quarantined stores and the combined quarantined reader.
+func (quarantine *quarantine) Refresh() error {
+	err := quarantine.objectQ.Refresh()
+	if err != nil {
+		return err
+	}
+
+	err = quarantine.packQ.Refresh()
+	if err != nil {
+		return err
+	}
+
+	return quarantine.reader.Refresh()
+}
+
+// WriteReaderContent writes one typed object content stream to the quarantined
+// object-wise store.
+func (quarantine *quarantine) WriteReaderContent(ty objecttype.Type, size int64, src io.Reader) (objectid.ObjectID, error) {
+	return quarantine.objectQ.WriteReaderContent(ty, size, src)
+}
+
+// WriteReaderFull writes one full serialized object stream as
+// "type size\0content" to the quarantined object-wise store.
+func (quarantine *quarantine) WriteReaderFull(src io.Reader) (objectid.ObjectID, error) {
+	return quarantine.objectQ.WriteReaderFull(src)
+}
+
+// WriteBytesContent writes one typed object content byte slice to the
+// quarantined object-wise store.
+func (quarantine *quarantine) WriteBytesContent(ty objecttype.Type, content []byte) (objectid.ObjectID, error) {
+	return quarantine.objectQ.WriteBytesContent(ty, content)
+}
+
+// WriteBytesFull writes one full serialized object byte slice as
+// "type size\0content" to the quarantined object-wise store.
+func (quarantine *quarantine) WriteBytesFull(raw []byte) (objectid.ObjectID, error) {
+	return quarantine.objectQ.WriteBytesFull(raw)
+}
+
+// WritePack ingests one pack stream into the quarantined pack-wise store.
+func (quarantine *quarantine) WritePack(src io.Reader, opts objectstore.PackWriteOptions) error {
+	return quarantine.packQ.WritePack(src, opts)
+}
--- /dev/null
+++ b/object/store/dual/quarantine_begin.go
@@ -1,0 +1,47 @@
+package dual
+
+import objectstore "codeberg.org/lindenii/furgit/object/store"
+
+// TODO: This doesn't actually make sense. We need a combined quarantine.
+
+// BeginObjectQuarantine creates one coordinated dual quarantine spanning both
+// stores and returns it as an object-wise quarantine.
+//
+// Labels: Deps-Borrowed, Life-Parent, Close-No.
+func (dual *Dual) BeginObjectQuarantine(_ objectstore.ObjectQuarantineOptions) (objectstore.ObjectQuarantine, error) {
+	quarantine, err := dual.beginQuarantine()
+	if err != nil {
+		return nil, err
+	}
+
+	return quarantine, nil
+}
+
+// BeginPackQuarantine creates one coordinated dual quarantine spanning both
+// stores and returns it as a pack-wise quarantine.
+//
+// Labels: Deps-Borrowed, Life-Parent, Close-No.
+func (dual *Dual) BeginPackQuarantine(_ objectstore.PackQuarantineOptions) (objectstore.PackQuarantine, error) {
+	quarantine, err := dual.beginQuarantine()
+	if err != nil {
+		return nil, err
+	}
+
+	return quarantine, nil
+}
+
+func (dual *Dual) beginQuarantine() (*quarantine, error) {
+	objectQ, err := dual.object.BeginObjectQuarantine(objectstore.ObjectQuarantineOptions{})
+	if err != nil {
+		return nil, err
+	}
+
+	packQ, err := dual.pack.BeginPackQuarantine(objectstore.PackQuarantineOptions{})
+	if err != nil {
+		_ = objectQ.Discard()
+
+		return nil, err
+	}
+
+	return newQuarantine(objectQ, packQ), nil
+}
--- /dev/null
+++ b/object/store/dual/quarantine_discard.go
@@ -1,0 +1,11 @@
+package dual
+
+// Discard abandons both quarantine halves and invalidates the receiver.
+func (quarantine *quarantine) Discard() error {
+	err := quarantine.packQ.Discard()
+	if err != nil {
+		return err
+	}
+
+	return quarantine.objectQ.Discard()
+}
--- /dev/null
+++ b/object/store/dual/quarantine_promote.go
@@ -1,0 +1,13 @@
+package dual
+
+// Promote publishes both quarantine halves and invalidates the receiver.
+//
+// Promotion is coordinated and ordered, but not atomic.
+func (quarantine *quarantine) Promote() error {
+	err := quarantine.packQ.Promote()
+	if err != nil {
+		return err
+	}
+
+	return quarantine.objectQ.Promote()
+}
--- /dev/null
+++ b/object/store/dual/reader.go
@@ -1,0 +1,57 @@
+package dual
+
+import (
+	"io"
+
+	objectid "codeberg.org/lindenii/furgit/object/id"
+	objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// ReadBytesFull reads a full serialized object as "type size\0content" from
+// either store.
+func (dual *Dual) ReadBytesFull(id objectid.ObjectID) ([]byte, error) {
+	return dual.reader.ReadBytesFull(id)
+}
+
+// ReadBytesContent reads an object's type and content bytes from either store.
+func (dual *Dual) ReadBytesContent(id objectid.ObjectID) (objecttype.Type, []byte, error) {
+	return dual.reader.ReadBytesContent(id)
+}
+
+// ReadReaderFull reads a full serialized object stream as "type size\0content"
+// from either store.
+func (dual *Dual) ReadReaderFull(id objectid.ObjectID) (io.ReadCloser, error) {
+	return dual.reader.ReadReaderFull(id)
+}
+
+// ReadReaderContent reads an object's type, declared content length, and
+// content stream from either store.
+func (dual *Dual) ReadReaderContent(id objectid.ObjectID) (objecttype.Type, int64, io.ReadCloser, error) {
+	return dual.reader.ReadReaderContent(id)
+}
+
+// ReadSize reads an object's declared content length from either store.
+func (dual *Dual) ReadSize(id objectid.ObjectID) (int64, error) {
+	return dual.reader.ReadSize(id)
+}
+
+// ReadHeader reads an object's type and declared content length from either
+// store.
+func (dual *Dual) ReadHeader(id objectid.ObjectID) (objecttype.Type, int64, error) {
+	return dual.reader.ReadHeader(id)
+}
+
+// Refresh refreshes both underlying stores and the combined read view.
+func (dual *Dual) Refresh() error {
+	err := dual.object.Refresh()
+	if err != nil {
+		return err
+	}
+
+	err = dual.pack.Refresh()
+	if err != nil {
+		return err
+	}
+
+	return dual.reader.Refresh()
+}
--- /dev/null
+++ b/object/store/dual/writer_object.go
@@ -1,0 +1,32 @@
+package dual
+
+import (
+	"io"
+
+	objectid "codeberg.org/lindenii/furgit/object/id"
+	objecttype "codeberg.org/lindenii/furgit/object/type"
+)
+
+// WriteReaderContent writes one typed object content stream to the object-wise
+// store.
+func (dual *Dual) WriteReaderContent(ty objecttype.Type, size int64, src io.Reader) (objectid.ObjectID, error) {
+	return dual.object.WriteReaderContent(ty, size, src)
+}
+
+// WriteReaderFull writes one full serialized object stream as
+// "type size\0content" to the object-wise store.
+func (dual *Dual) WriteReaderFull(src io.Reader) (objectid.ObjectID, error) {
+	return dual.object.WriteReaderFull(src)
+}
+
+// WriteBytesContent writes one typed object content byte slice to the
+// object-wise store.
+func (dual *Dual) WriteBytesContent(ty objecttype.Type, content []byte) (objectid.ObjectID, error) {
+	return dual.object.WriteBytesContent(ty, content)
+}
+
+// WriteBytesFull writes one full serialized object byte slice as
+// "type size\0content" to the object-wise store.
+func (dual *Dual) WriteBytesFull(raw []byte) (objectid.ObjectID, error) {
+	return dual.object.WriteBytesFull(raw)
+}
--- /dev/null
+++ b/object/store/dual/writer_pack.go
@@ -1,0 +1,12 @@
+package dual
+
+import (
+	"io"
+
+	objectstore "codeberg.org/lindenii/furgit/object/store"
+)
+
+// WritePack ingests one pack stream into the pack-wise store.
+func (dual *Dual) WritePack(src io.Reader, opts objectstore.PackWriteOptions) error {
+	return dual.pack.WritePack(src, opts)
+}
--