Skip to content

Commit a59b01d

Browse files
frristclaude
andcommitted
feat: forge hardening + streaming blob I/O (arch phase 7)
- fix: skip re-uploading already-located blobs (forge dedup; was a 500 on re-PUT) - registry.LocalLocator: local-table read tier for the appliance topology (no indexer) - stream body blobs to disk with inline hashing — never held whole in RAM on read or write - drop unused Spool methods (PutBlock/Has/Remove); trim to Path/WriteBlob/OpenBlob/GetBlock - test: recover per-case names from the conformance suite Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f4d7604 commit a59b01d

19 files changed

Lines changed: 1052 additions & 188 deletions

blockstore/cache.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package blockstore
33
import (
44
"container/list"
55
"context"
6+
"io"
67
"sync"
78

89
block "github.com/ipfs/go-block-format"
910
"github.com/ipfs/go-cid"
11+
mh "github.com/multiformats/go-multihash"
1012
)
1113

1214
// Cached wraps a BlockReader with a bounded, in-memory LRU keyed by CID. Blocks
@@ -50,7 +52,21 @@ func NewCached(base BlockReader, maxBytes int64) BlockReader {
5052
}
5153
}
5254

53-
var _ BlockReader = (*Cached)(nil)
55+
var (
56+
_ BlockReader = (*Cached)(nil)
57+
_ BlobReader = (*Cached)(nil)
58+
)
59+
60+
// OpenBlob streams a body blob straight from the base reader, bypassing the LRU.
61+
// Body blobs are large and streamed; caching one whole would defeat streaming
62+
// (and a single blob can exceed the whole budget). Small catalog blocks still
63+
// cache through GetBlock.
64+
func (c *Cached) OpenBlob(ctx context.Context, digest mh.Multihash) (io.ReadCloser, error) {
65+
if br, ok := c.base.(BlobReader); ok {
66+
return br.OpenBlob(ctx, digest)
67+
}
68+
return nil, ErrNotFound
69+
}
5470

5571
// GetBlock returns the cached block if present, otherwise fetches it from the
5672
// base reader and caches it (subject to the byte budget).

blockstore/forge.go

Lines changed: 76 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/fil-forge/ucantone/ucan/invocation"
1919
block "github.com/ipfs/go-block-format"
2020
"github.com/ipfs/go-cid"
21+
mh "github.com/multiformats/go-multihash"
2122
"go.uber.org/zap"
2223

2324
"github.com/fil-forge/ingot/blockstore/locator"
@@ -47,13 +48,21 @@ type Forge struct {
4748
logger *zap.Logger
4849
}
4950

50-
var _ BlockReader = (*Forge)(nil)
51+
var (
52+
_ BlockReader = (*Forge)(nil)
53+
_ BlobReader = (*Forge)(nil)
54+
)
5155

5256
// ForgeConfig wires a read-only Forge block reader.
5357
type ForgeConfig struct {
54-
// IndexerEndpoint is the indexing-service URL.
58+
// Locator, when set, resolves blob locations instead of the indexing-service
59+
// — the appliance read tier (e.g. registry.LocalLocator over blob_locations).
60+
// When nil, an indexing-service-backed locator is built from
61+
// IndexerEndpoint/IndexerDID. Either way the retrieval path is identical.
62+
Locator locator.Locator
63+
// IndexerEndpoint is the indexing-service URL. Required only when Locator is nil.
5564
IndexerEndpoint string
56-
// IndexerDID is the indexing-service principal.
65+
// IndexerDID is the indexing-service principal. Required only when Locator is nil.
5766
IndexerDID string
5867
// Spaces scopes the locator queries; for ingot this is the single space it owns.
5968
Spaces []did.DID
@@ -69,15 +78,10 @@ type ForgeConfig struct {
6978
Logger *zap.Logger
7079
}
7180

72-
// NewForge constructs a Forge block reader, building an indexing-service client
73-
// wrapped by the index locator.
81+
// NewForge constructs a Forge block reader. It uses cfg.Locator when set (the
82+
// appliance read tier); otherwise it builds an indexing-service-backed locator
83+
// from IndexerEndpoint/IndexerDID.
7484
func NewForge(cfg ForgeConfig) (*Forge, error) {
75-
if cfg.IndexerEndpoint == "" {
76-
return nil, errors.New("forge blockstore: indexer endpoint is required")
77-
}
78-
if cfg.IndexerDID == "" {
79-
return nil, errors.New("forge blockstore: indexer DID is required")
80-
}
8185
if len(cfg.Spaces) == 0 {
8286
return nil, errors.New("forge blockstore: at least one space is required")
8387
}
@@ -88,15 +92,6 @@ func NewForge(cfg ForgeConfig) (*Forge, error) {
8892
return nil, errors.New("forge blockstore: space signer is required")
8993
}
9094

91-
endpointURL, err := url.Parse(cfg.IndexerEndpoint)
92-
if err != nil {
93-
return nil, fmt.Errorf("forge blockstore: parse indexer endpoint: %w", err)
94-
}
95-
indexerDID, err := did.Parse(cfg.IndexerDID)
96-
if err != nil {
97-
return nil, fmt.Errorf("forge blockstore: parse indexer DID: %w", err)
98-
}
99-
10095
httpc := cfg.HTTPClient
10196
if httpc == nil {
10297
httpc = http.DefaultClient
@@ -106,14 +101,30 @@ func NewForge(cfg ForgeConfig) (*Forge, error) {
106101
logger = zap.NewNop()
107102
}
108103

109-
idxClient, err := indexclient.New(indexerDID, *endpointURL, indexclient.WithHTTPClient(httpc))
110-
if err != nil {
111-
return nil, fmt.Errorf("forge blockstore: build indexing-service client: %w", err)
104+
loc := cfg.Locator
105+
if loc == nil {
106+
// No locator injected: resolve through the indexing-service.
107+
if cfg.IndexerEndpoint == "" {
108+
return nil, errors.New("forge blockstore: indexer endpoint is required (no locator injected)")
109+
}
110+
if cfg.IndexerDID == "" {
111+
return nil, errors.New("forge blockstore: indexer DID is required (no locator injected)")
112+
}
113+
endpointURL, err := url.Parse(cfg.IndexerEndpoint)
114+
if err != nil {
115+
return nil, fmt.Errorf("forge blockstore: parse indexer endpoint: %w", err)
116+
}
117+
indexerDID, err := did.Parse(cfg.IndexerDID)
118+
if err != nil {
119+
return nil, fmt.Errorf("forge blockstore: parse indexer DID: %w", err)
120+
}
121+
idxClient, err := indexclient.New(indexerDID, *endpointURL, indexclient.WithHTTPClient(httpc))
122+
if err != nil {
123+
return nil, fmt.Errorf("forge blockstore: build indexing-service client: %w", err)
124+
}
125+
loc = locator.NewIndexLocator(idxClient, newAuthorizeRetrieval(cfg.SpaceSigner, indexerDID))
112126
}
113127

114-
authFn := newAuthorizeRetrieval(cfg.SpaceSigner, indexerDID)
115-
loc := locator.NewIndexLocator(idxClient, authFn)
116-
117128
return &Forge{
118129
locator: loc,
119130
signer: cfg.Signer,
@@ -124,26 +135,29 @@ func NewForge(cfg ForgeConfig) (*Forge, error) {
124135
}, nil
125136
}
126137

127-
// GetBlock resolves the CID through the indexer and retrieves the underlying
128-
// bytes from piri via a UCAN-authorized /content/retrieve invocation, scoped to
129-
// the inner block's byte range within the containing CAR shard.
130-
func (f *Forge) GetBlock(ctx context.Context, c cid.Cid) (block.Block, error) {
138+
// retrieve resolves c through the locator and issues a UCAN-authorized
139+
// /content/retrieve to the piri node that holds it, returning the response body
140+
// stream and the expected byte length (the location Range is inclusive, so
141+
// End-Start+1). The caller owns the returned reader and must Close it. Shared by
142+
// GetBlock (which buffers small catalog blocks) and OpenBlob (which streams large
143+
// body blobs straight through).
144+
func (f *Forge) retrieve(ctx context.Context, c cid.Cid) (io.ReadCloser, int64, error) {
131145
locations, err := f.locator.Locate(ctx, f.spaces, c.Hash())
132146
if err != nil {
133147
var nf locator.NotFoundError
134148
if errors.As(err, &nf) {
135-
return nil, ErrNotFound
149+
return nil, 0, ErrNotFound
136150
}
137-
return nil, fmt.Errorf("forge: locate %s: %w", c, err)
151+
return nil, 0, fmt.Errorf("forge: locate %s: %w", c, err)
138152
}
139153
if len(locations) == 0 {
140-
return nil, ErrNotFound
154+
return nil, 0, ErrNotFound
141155
}
142156

143157
loc := locations[0]
144158
cm := loc.Commitment
145159
if len(cm.Location) == 0 {
146-
return nil, fmt.Errorf("forge: empty location URL set for %s", c)
160+
return nil, 0, fmt.Errorf("forge: empty location URL set for %s", c)
147161
}
148162
target := cm.Location[0]
149163

@@ -166,7 +180,7 @@ func (f *Forge) GetBlock(ctx context.Context, c cid.Cid) (block.Block, error) {
166180
delegation.WithExpiration(ucan.Now()+retrievalAuthTTL),
167181
)
168182
if err != nil {
169-
return nil, fmt.Errorf("forge: build retrieval proof: %w", err)
183+
return nil, 0, fmt.Errorf("forge: build retrieval proof: %w", err)
170184
}
171185

172186
inv, err := contentcmds.Retrieve.Invoke(
@@ -180,41 +194,59 @@ func (f *Forge) GetBlock(ctx context.Context, c cid.Cid) (block.Block, error) {
180194
invocation.WithProofs(retrievalProof.Link()),
181195
)
182196
if err != nil {
183-
return nil, fmt.Errorf("forge: build retrieve invocation: %w", err)
197+
return nil, 0, fmt.Errorf("forge: build retrieve invocation: %w", err)
184198
}
185199

186200
rclient, err := retrieval.NewClient(target.URL(), retrieval.WithHTTPClient(f.httpClient))
187201
if err != nil {
188-
return nil, fmt.Errorf("forge: build retrieval client: %w", err)
202+
return nil, 0, fmt.Errorf("forge: build retrieval client: %w", err)
189203
}
190204

191205
_, _, meta, err := ucanexec.Execute[*contentcmds.RetrieveOK](
192206
ctx, rclient, inv,
193207
execution.WithDelegations(retrievalProof),
194208
)
195209
if err != nil {
196-
return nil, fmt.Errorf("forge: retrieve %s: %w", c, err)
210+
return nil, 0, fmt.Errorf("forge: retrieve %s: %w", c, err)
197211
}
198212

199213
hcRes, ok := meta.(*retrieval.HTTPHeaderResponseContainer)
200214
if !ok {
201-
return nil, fmt.Errorf("forge: unexpected retrieval metadata type %T", meta)
215+
return nil, 0, fmt.Errorf("forge: unexpected retrieval metadata type %T", meta)
202216
}
203-
defer hcRes.Body.Close()
217+
// Range is inclusive, so the expected length is End - Start + 1.
218+
return hcRes.Body, loc.Range.End - loc.Range.Start + 1, nil
219+
}
204220

205-
body, err := io.ReadAll(hcRes.Body)
221+
// GetBlock resolves the CID through the locator and retrieves the bytes from piri
222+
// via a UCAN-authorized /content/retrieve. It buffers the whole block, so it is
223+
// for small catalog blocks; object-body blobs use the streaming OpenBlob.
224+
func (f *Forge) GetBlock(ctx context.Context, c cid.Cid) (block.Block, error) {
225+
rc, wantLen, err := f.retrieve(ctx, c)
226+
if err != nil {
227+
return nil, err
228+
}
229+
defer rc.Close()
230+
231+
body, err := io.ReadAll(rc)
206232
if err != nil {
207233
return nil, fmt.Errorf("forge: read retrieve body for %s: %w", c, err)
208234
}
209-
// Range is inclusive, so the expected length is End - Start + 1.
210-
wantLen := loc.Range.End - loc.Range.Start + 1
211235
if int64(len(body)) != wantLen {
212236
return nil, fmt.Errorf("forge: %s short read: got %d bytes, want %d", c, len(body), wantLen)
213237
}
214-
215238
return block.NewBlockWithCid(body, c)
216239
}
217240

241+
// OpenBlob streams an object-body blob from piri by digest, without buffering it
242+
// in memory — the network counterpart of Spool.OpenBlob. Bytes are served
243+
// straight off the /content/retrieve response; the caller owns the reader and
244+
// must Close it.
245+
func (f *Forge) OpenBlob(ctx context.Context, digest mh.Multihash) (io.ReadCloser, error) {
246+
rc, _, err := f.retrieve(ctx, cid.NewCidV1(cid.Raw, digest))
247+
return rc, err
248+
}
249+
218250
// newAuthorizeRetrieval returns the AuthorizeRetrievalFunc the IndexLocator
219251
// calls before each indexer query. The space signer (root authority) directly
220252
// authorizes the indexer to retrieve any blob in the space — the proof chain is

blockstore/layered.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package blockstore
33
import (
44
"context"
55
"errors"
6+
"io"
67

78
block "github.com/ipfs/go-block-format"
89
"github.com/ipfs/go-cid"
10+
mh "github.com/multiformats/go-multihash"
911
)
1012

1113
// Layered is the production ReadStore: a read-only seam that consults the local
@@ -76,6 +78,27 @@ func (l *Layered) GetBlock(ctx context.Context, c cid.Cid) (blk block.Block, ret
7678
return l.base.GetBlock(ctx, c)
7779
}
7880

81+
// OpenBlob streams an object-body blob by digest: the local spool first (the
82+
// read-after-write floor), then the network base (after eviction). The log is
83+
// skipped — it only ever holds catalog blocks, never body blobs. Tiers that
84+
// don't implement BlobReader are treated as a miss. Returns ErrNotFound if no
85+
// tier has the blob.
86+
func (l *Layered) OpenBlob(ctx context.Context, digest mh.Multihash) (io.ReadCloser, error) {
87+
if br, ok := l.spool.(BlobReader); ok {
88+
rc, err := br.OpenBlob(ctx, digest)
89+
if err == nil {
90+
return rc, nil
91+
}
92+
if !errors.Is(err, ErrNotFound) {
93+
return nil, err
94+
}
95+
}
96+
if br, ok := l.base.(BlobReader); ok {
97+
return br.OpenBlob(ctx, digest)
98+
}
99+
return nil, ErrNotFound
100+
}
101+
79102
// layeredAsBlockstore lifts Layered into a BaseStore for the
80103
// CborStore wrapper. Internal-only — exists so the CBOR decoder
81104
// reuses Layered's cache + fallthrough order rather than going

blockstore/openblob_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package blockstore
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"io"
8+
"testing"
9+
10+
block "github.com/ipfs/go-block-format"
11+
"github.com/ipfs/go-cid"
12+
mh "github.com/multiformats/go-multihash"
13+
)
14+
15+
// fakeBlobTier is a read tier that implements BlockReader (so it fits the
16+
// Layered/Cached constructors) and BlobReader. data == nil means a miss.
17+
type fakeBlobTier struct{ data []byte }
18+
19+
func (f fakeBlobTier) GetBlock(context.Context, cid.Cid) (block.Block, error) {
20+
return nil, ErrNotFound
21+
}
22+
23+
func (f fakeBlobTier) OpenBlob(context.Context, mh.Multihash) (io.ReadCloser, error) {
24+
if f.data == nil {
25+
return nil, ErrNotFound
26+
}
27+
return io.NopCloser(bytes.NewReader(f.data)), nil
28+
}
29+
30+
func readOpenBlob(t *testing.T, r BlobReader, digest mh.Multihash) (string, error) {
31+
t.Helper()
32+
rc, err := r.OpenBlob(context.Background(), digest)
33+
if err != nil {
34+
return "", err
35+
}
36+
defer rc.Close()
37+
b, err := io.ReadAll(rc)
38+
return string(b), err
39+
}
40+
41+
// TestLayered_OpenBlob_Tiering covers the body-blob read fallthrough: spool first
42+
// (read-after-write), then the network base after eviction, then ErrNotFound. The
43+
// log is never consulted (it holds no body blobs).
44+
func TestLayered_OpenBlob_Tiering(t *testing.T) {
45+
digest, _ := mh.Sum([]byte("digest"), mh.SHA2_256, -1)
46+
47+
// spool hit — base is not consulted.
48+
l := NewLayered(fakeBlobTier{data: []byte("from-spool")}, nil, fakeBlobTier{data: []byte("from-base")})
49+
if got, err := readOpenBlob(t, l, digest); err != nil || got != "from-spool" {
50+
t.Fatalf("spool hit: got %q err %v, want from-spool", got, err)
51+
}
52+
53+
// spool miss → base hit (the after-eviction path).
54+
l = NewLayered(fakeBlobTier{data: nil}, nil, fakeBlobTier{data: []byte("from-base")})
55+
if got, err := readOpenBlob(t, l, digest); err != nil || got != "from-base" {
56+
t.Fatalf("spool miss → base: got %q err %v, want from-base", got, err)
57+
}
58+
59+
// both miss → ErrNotFound.
60+
l = NewLayered(fakeBlobTier{data: nil}, nil, fakeBlobTier{data: nil})
61+
if _, err := l.OpenBlob(context.Background(), digest); !errors.Is(err, ErrNotFound) {
62+
t.Fatalf("both miss: err = %v, want ErrNotFound", err)
63+
}
64+
65+
// nil spool is skipped, not panicked.
66+
l = NewLayered(nil, nil, fakeBlobTier{data: []byte("from-base")})
67+
if got, err := readOpenBlob(t, l, digest); err != nil || got != "from-base" {
68+
t.Fatalf("nil spool → base: got %q err %v, want from-base", got, err)
69+
}
70+
}
71+
72+
// TestCached_OpenBlob_BypassesCache confirms OpenBlob streams straight from the
73+
// base reader (no LRU) — a large body blob must not be buffered into the cache.
74+
func TestCached_OpenBlob_BypassesCache(t *testing.T) {
75+
digest, _ := mh.Sum([]byte("digest"), mh.SHA2_256, -1)
76+
c := NewCached(fakeBlobTier{data: []byte("streamed")}, 1<<20).(*Cached)
77+
if got, err := readOpenBlob(t, c, digest); err != nil || got != "streamed" {
78+
t.Fatalf("cached OpenBlob: got %q err %v, want streamed", got, err)
79+
}
80+
}

0 commit comments

Comments
 (0)