Skip to content

Commit a7684d7

Browse files
frristclaudealanshaw
authored
fix(read): resolve retention-retired catalog blocks via local shard inclusions (#44)
* fix(read): resolve retention-retired catalog blocks via local shard inclusions Catalog retention (runRetention, Retain default 6) deletes shipped segment CARs from disk and DB, but the appliance read tier could not resolve the blocks inside them: LocalLocator answers only whole-blob rows from blob_locations, keyed by a blob's own digest — and a manifest / MST node is an interior slice of a shipped CAR, not a stored blob. Once a bucket rolled past the retain window, any object whose manifest lived only in a retired segment became unreadable: GETs and undelimited listings failed with "blockstore: not found". (Regressed in the phase-7 hardening, which swapped the indexer-backed IndexLocator for LocalLocator; the ship path never stopped publishing the per-block index — the read side just stopped consulting anything that could use it. architecture.md §8's main text had this right; its R0/R1 parenthetical did not.) Mirror the indexing-service contract locally — locations AND inclusions: - shard_inclusions (migration 00006): block digest → shard digest + inclusive byte range, one row per block of every shipped catalog CAR. - The flush path records the shard's blob_locations row and its inclusions BEFORE the segment is marked shipped, so retention can never retire blocks the read tier can't resolve. SubmitShard now returns the shipped CAR's location commitment to make that possible. - LocalLocator falls through location-miss → inclusion → shard location, emitting the same Location{shard commitment, inner range} shape IndexLocator produces; the ranged /content/retrieve path is unchanged, and an external indexer remains a locator-swap away. Also fixed en route: - SubmitShard issued /index/add without the ship proof store, so every catalog index publication went out proofless and was rejected — segments never marked shipped, so retention never ran at all. - Index publication (index blob + /index/add) is now best-effort: the CAR is durable on piri and local inclusions serve ingot's reads, so an indexer-side failure logs loudly instead of wedging retention. TODO: queue failed publications for retry. - itest harness boots piri with Postgres (piri:main's Curio PDP pipeline no longer supports sqlite). TestForgeReadAfterCatalogRetention proves the path end-to-end: roll a bucket past the retain window, verify the early segments physically retire, then GET + undelimited-list objects whose manifests exist only in retired segments. Note: currently red in a full run for an upstream reason — piri cannot verify did:plc-issued proofs (empty verification- method list), which fails ALL hilt-era /content/retrieve; the pre-existing TestForgeReadAfterEviction fails identically on main. This test should go green with no further changes once that piri fix lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * deps: bump hilt, libforge, ucantone to current mains hilt ba71f84 carries the durable-delegation expiry fix (#33) and its libforge alignment (#34). ucantone ccb7705 carries the did:plc verification fix (fil-forge/ucantone#42): undeclared verification relationships now default to all of a document's methods, which is what makes hilt-tenant (did:plc-issued) proofs verifiable — piri consumes the same fix via fil-forge/piri#43. Aligning ingot to the same versions keeps the cross-service skew down. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(itest): add INGOT_ITEST_PIRI_BINARY override Mounts a locally-built static piri binary over the image's /usr/bin/piri (stack.WithPiriBinary) — same escape hatch as the image-override env vars, one step earlier in the pipeline. Used to validate the ucantone did:plc verification fix end-to-end before any piri image carried it: TestForgeReadAfterCatalogRetention and TestForgeReadAfterEviction both passed against piri@939de00 + the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(itest): skip never-passing CORS subtest pending versitygw support The subtest (added in #42/#43) asserts preflight behavior the pinned versitygw does not implement, and merged while the itest suite could not boot a stack — so it has never passed in any environment. Skip with a pointer to #45 rather than leave the suite red for a failure that belongs to the CORS feature, not whichever branch runs it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: expected preflight status code (#46) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: ash <alan138@gmail.com>
1 parent c57186b commit a7684d7

19 files changed

Lines changed: 684 additions & 126 deletions

docs/architecture.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,14 @@ index (stock-tooling plaintext recovery)** — a deliberate trade, not settled h
502502
The **catalog** is different — many tiny MST/manifest blocks share a CAR — so catalog blocks resolve
503503
via the indexer's index-claim / sharded-dag-index path (block CID → byte range in its shard). That path
504504
is retained for the catalog regardless. *(In the R0/R1 appliance topology, catalog-block lookup is
505-
served from the local Postgres location table rather than the indexing-service.)*
505+
served from the local Postgres mirror of that contract: `shard_inclusions` (block digest → shard
506+
digest + byte range, recorded by the flush path before a segment is marked shipped) joined to the
507+
shard's `blob_locations` row. A whole-blob location table alone cannot serve catalog blocks — they
508+
are interior slices of shipped CARs, not stored blobs — which is exactly what breaks
509+
retention-retired catalog reads without the inclusion table. Because the local mirror is what
510+
ingot's own reads depend on, the network index publication (`/index/add` at ship time) is
511+
best-effort: its failure is logged, not a ship failure — a wedged indexer must not wedge
512+
retention. A retry queue for failed publications is a TODO.)*
506513
507514
Consuming a bare location commitment is a capability Ingot's locator must gain: today it surfaces a
508515
location only via the inclusion → shard → commitment path and never returns a stored bare
@@ -780,9 +787,12 @@ These are intentional simplifications of the target topology, not bugs:
780787
Regime-A/B compaction, and subroots ([§6](#6-the-forgechain-layer)) are Piri-side concerns. From Ingot's side a delete is
781788
just `remove(digest)`; Piri decides the on-chain regime. `max_blob_size` is the only size knob Ingot
782789
carries.
783-
- **Local location table instead of the indexer (R0/R1 appliance reduction).** Body-blob locations
784-
are recorded in a local Postgres `blob_locations` table behind a `Locator` seam, rather than read
785-
back through the indexing-service ([§5](#5-the-data-layer), [§8](#8-retrieval-addressing-when-bodies-need-a-sharded-dag-index)). The indexer-backed `Locator` is an `indexer-ready` swap-in.
790+
- **Local location + inclusion tables instead of the indexer (R0/R1 appliance reduction).** Body-blob
791+
and shipped-shard locations are recorded in a local Postgres `blob_locations` table, and each
792+
shipped catalog shard's inner-block byte ranges in `shard_inclusions`, behind a `Locator` seam,
793+
rather than read back through the indexing-service ([§5](#5-the-data-layer), [§8](#8-retrieval-addressing-when-bodies-need-a-sharded-dag-index)). The two tables mirror the
794+
indexing-service contract (location commitments + inclusions), so the indexer-backed `Locator`
795+
remains an `indexer-ready` swap-in.
786796
787797
### Object lifecycle (not implemented)
788798

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ require (
77
github.com/aws/aws-sdk-go-v2/config v1.32.26
88
github.com/aws/aws-sdk-go-v2/credentials v1.19.25
99
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1
10-
github.com/fil-forge/hilt v0.0.1-0.20260716084626-7ddddf09ecc0
10+
github.com/fil-forge/hilt v0.0.1-0.20260724134448-ba71f843f6a4
1111
github.com/fil-forge/indexing-service v1.13.5-0.20260619142411-efe3f5fab717
12-
github.com/fil-forge/libforge v0.0.0-20260713100115-a3aa293b990c
12+
github.com/fil-forge/libforge v0.0.0-20260724113901-7fc3b2cec1ef
1313
github.com/fil-forge/smelt v0.0.0-20260720130429-63116166a06c
14-
github.com/fil-forge/ucantone v0.0.0-20260706102443-79141c5cc52e
14+
github.com/fil-forge/ucantone v0.0.0-20260727203046-ccb77059de44
1515
github.com/fil-forge/versitygw v0.0.0-20260716095011-7a65883d595a
1616
github.com/fxamacker/cbor/v2 v2.9.2
1717
github.com/go-jose/go-jose/v4 v4.1.4

go.sum

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,16 +246,16 @@ github.com/fil-forge/go-ipni-tools v0.0.0-20260519194815-545b9421aec0 h1:HAfXUPv
246246
github.com/fil-forge/go-ipni-tools v0.0.0-20260519194815-545b9421aec0/go.mod h1:3NRV/7wc4/0uzzrGdI7NoN/yeF1UvqKRwMyjBqGc5s0=
247247
github.com/fil-forge/go-ucanto v0.0.0-20260507172450-5cb5d073f8ab h1:2J2cDThqTKP6/0k3SfdlSxfyPa3aLqjTYnmvbEcryfg=
248248
github.com/fil-forge/go-ucanto v0.0.0-20260507172450-5cb5d073f8ab/go.mod h1:lZF3UXZ2hGLKYmXdquG50JqI9pRlUrV6lubGtgOYfwc=
249-
github.com/fil-forge/hilt v0.0.1-0.20260716084626-7ddddf09ecc0 h1:dma5d9PcBPyvMqZTOmoGUeZBKvnZztdko4hWqBpX7ds=
250-
github.com/fil-forge/hilt v0.0.1-0.20260716084626-7ddddf09ecc0/go.mod h1:zzPrQQ/VhgShDIi9IsfeFdiNXJftACRXxUaJI6opW9s=
249+
github.com/fil-forge/hilt v0.0.1-0.20260724134448-ba71f843f6a4 h1:pDDN87a4dMuH8mqqEZZOnrCZ+Myc1ArEjJxg0ZNpQzA=
250+
github.com/fil-forge/hilt v0.0.1-0.20260724134448-ba71f843f6a4/go.mod h1:AO/+NYsz//BoqdHjVfik0fbxjiq+H+86qzGBt2/2uxQ=
251251
github.com/fil-forge/indexing-service v1.13.5-0.20260619142411-efe3f5fab717 h1:Wke8qgaDgy7DGaIS28VpHip+YHSdQP1hGNEZTrXXzb4=
252252
github.com/fil-forge/indexing-service v1.13.5-0.20260619142411-efe3f5fab717/go.mod h1:wFcakLohOqpMRkJzWRdGFGHRFpGB+PpEMCm9wkt2cqU=
253-
github.com/fil-forge/libforge v0.0.0-20260713100115-a3aa293b990c h1:ElgAxd+08QifEpnhO1vH58A/CD+i/Hdic6B+k3W/QPY=
254-
github.com/fil-forge/libforge v0.0.0-20260713100115-a3aa293b990c/go.mod h1:0kXihIQ4L2uZ00nR5XrZ/Y8Db7Ht/qQNuiWslwMJ95M=
253+
github.com/fil-forge/libforge v0.0.0-20260724113901-7fc3b2cec1ef h1:xgkciShyWdCQ2Pyl2qEwP7JMbsdbHdECj/v6E5+fj6U=
254+
github.com/fil-forge/libforge v0.0.0-20260724113901-7fc3b2cec1ef/go.mod h1:0kXihIQ4L2uZ00nR5XrZ/Y8Db7Ht/qQNuiWslwMJ95M=
255255
github.com/fil-forge/smelt v0.0.0-20260720130429-63116166a06c h1:WHvsleEU6ZiNYDFgLx6KtorXulD+IuLiorMgmp4Th8s=
256256
github.com/fil-forge/smelt v0.0.0-20260720130429-63116166a06c/go.mod h1:NM/mk/XiP1Kzsy9HWGQeLsosPUvVY3bIrkUzqswThpU=
257-
github.com/fil-forge/ucantone v0.0.0-20260706102443-79141c5cc52e h1:di/SseJVEO6bznSH/UEnAE2nSwwp/gqgnPLgE9/x2Zg=
258-
github.com/fil-forge/ucantone v0.0.0-20260706102443-79141c5cc52e/go.mod h1:oFY5BfD0bDeodGlbBHh3/nK99MAS93rGXjoQz7s5qgE=
257+
github.com/fil-forge/ucantone v0.0.0-20260727203046-ccb77059de44 h1:ofvb2Qq7++VPRelGsLbtnd1ZMKVT4n4QGoa79BhJ6VQ=
258+
github.com/fil-forge/ucantone v0.0.0-20260727203046-ccb77059de44/go.mod h1:oFY5BfD0bDeodGlbBHh3/nK99MAS93rGXjoQz7s5qgE=
259259
github.com/fil-forge/versitygw v0.0.0-20260716095011-7a65883d595a h1:lDwnNmF4LNbevx/YYNCC0CazMiL4eIe38MvfqbRR3RA=
260260
github.com/fil-forge/versitygw v0.0.0-20260716095011-7a65883d595a/go.mod h1:t73Wa2xqpT0NdY+SzRZDiO+tQD13b3UEZRoe2wYFiIE=
261261
github.com/filecoin-project/go-data-segment v0.0.1 h1:1wmDxOG4ubWQm3ZC1XI5nCon5qgSq7Ra3Rb6Dbu10Gs=

inmem/store.go

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,13 @@ type MemStore struct {
5454
// The architecture's relational surface (docs/architecture.md §5–§7),
5555
// mirroring the Postgres tables so the in-process suite exercises the
5656
// same code paths. See stores.go for the methods over these.
57-
blobRefs map[claimKey]registry.BlobClaim
58-
intents map[string]registry.UploadIntent // keyed by string(digest)
59-
locations map[locKey]registry.BlobLocation // keyed by (space, digest)
60-
sessions map[string]registry.MultipartSession // keyed by uploadID
61-
parts map[string]map[int]registry.MultipartPart // uploadID -> partNumber -> part
62-
gcCands map[string]struct{} // keyed by string(cid)
57+
blobRefs map[claimKey]registry.BlobClaim
58+
intents map[string]registry.UploadIntent // keyed by string(digest)
59+
locations map[locKey]registry.BlobLocation // keyed by (space, digest)
60+
inclusions map[locKey]registry.BlobInclusion // keyed by (space, digest)
61+
sessions map[string]registry.MultipartSession // keyed by uploadID
62+
parts map[string]map[int]registry.MultipartPart // uploadID -> partNumber -> part
63+
gcCands map[string]struct{} // keyed by string(cid)
6364
}
6465

6566
// claimKey / locKey are the composite map keys for the blob_refs and
@@ -76,14 +77,15 @@ type locKey struct {
7677
// NewMemStore returns an empty MemStore.
7778
func NewMemStore() *MemStore {
7879
return &MemStore{
79-
buckets: map[string]*registry.State{},
80-
segments: map[uint64]*logstore.SegmentMeta{},
81-
blobRefs: map[claimKey]registry.BlobClaim{},
82-
intents: map[string]registry.UploadIntent{},
83-
locations: map[locKey]registry.BlobLocation{},
84-
sessions: map[string]registry.MultipartSession{},
85-
parts: map[string]map[int]registry.MultipartPart{},
86-
gcCands: map[string]struct{}{},
80+
buckets: map[string]*registry.State{},
81+
segments: map[uint64]*logstore.SegmentMeta{},
82+
blobRefs: map[claimKey]registry.BlobClaim{},
83+
intents: map[string]registry.UploadIntent{},
84+
locations: map[locKey]registry.BlobLocation{},
85+
inclusions: map[locKey]registry.BlobInclusion{},
86+
sessions: map[string]registry.MultipartSession{},
87+
parts: map[string]map[int]registry.MultipartPart{},
88+
gcCands: map[string]struct{}{},
8789
}
8890
}
8991

@@ -339,8 +341,8 @@ func (NopBaseReader) OpenBlob(_ context.Context, _ did.DID, _ multihash.Multihas
339341
// network, so the spool's local copy serves all reads.
340342
type NopUploader struct{}
341343

342-
func (NopUploader) SubmitShard(_ context.Context, _ blockstore.Plane, _ did.DID, _ uploader.CARShard) error {
343-
return nil
344+
func (NopUploader) SubmitShard(_ context.Context, _ blockstore.Plane, _ did.DID, _ uploader.CARShard) (uploader.BlobLocation, error) {
345+
return uploader.BlobLocation{}, nil
344346
}
345347

346348
func (NopUploader) UploadBlob(_ context.Context, _ did.DID, _ multihash.Multihash, size int64, _ string) (uploader.BlobLocation, error) {

inmem/stores.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ var (
1717
_ registry.BlobRefStore = (*MemStore)(nil)
1818
_ registry.IntentStore = (*MemStore)(nil)
1919
_ registry.LocationStore = (*MemStore)(nil)
20+
_ registry.InclusionStore = (*MemStore)(nil)
2021
_ registry.MultipartStore = (*MemStore)(nil)
2122
_ registry.GCStore = (*MemStore)(nil)
2223
)
@@ -147,6 +148,33 @@ func (m *MemStore) DeleteLocation(_ context.Context, space did.DID, digest []byt
147148
return nil
148149
}
149150

151+
// InclusionStore =============================================================
152+
153+
func (m *MemStore) PutInclusions(_ context.Context, incs []registry.BlobInclusion) error {
154+
m.mu.Lock()
155+
defer m.mu.Unlock()
156+
for _, inc := range incs {
157+
cp := inc
158+
cp.Digest = cloneBytes(inc.Digest)
159+
cp.ShardDigest = cloneBytes(inc.ShardDigest)
160+
m.inclusions[locKey{inc.Space, string(inc.Digest)}] = cp
161+
}
162+
return nil
163+
}
164+
165+
func (m *MemStore) GetInclusion(_ context.Context, space did.DID, digest []byte) (*registry.BlobInclusion, error) {
166+
m.mu.Lock()
167+
defer m.mu.Unlock()
168+
inc, ok := m.inclusions[locKey{space, string(digest)}]
169+
if !ok {
170+
return nil, registry.ErrNotFound
171+
}
172+
cp := inc
173+
cp.Digest = cloneBytes(inc.Digest)
174+
cp.ShardDigest = cloneBytes(inc.ShardDigest)
175+
return &cp, nil
176+
}
177+
150178
// MultipartStore =============================================================
151179

152180
func (m *MemStore) CreateSession(_ context.Context, s registry.MultipartSession) error {

itest/forge_retention_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//go:build itest
2+
3+
package itest
4+
5+
import (
6+
"bytes"
7+
"fmt"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
ingottest "github.com/fil-forge/ingot/testing"
13+
"github.com/fil-forge/smelt/pkg/stack"
14+
)
15+
16+
// TestForgeReadAfterCatalogRetention proves catalog retention behaves as a
17+
// cache, not an availability cliff: after a shipped catalog segment is retired
18+
// off local disk, its blocks (object manifests, MST nodes) must still resolve
19+
// through the fallthrough read tier — the shard_inclusions row (block → shard
20+
// CAR + byte range, recorded at ship) joined to the shard's blob_locations
21+
// row, retrieved as a ranged /content/retrieve against piri.
22+
//
23+
// The config seals the catalog every 1s, retains ONE shipped segment, and
24+
// disables the read cache. An early object is written, then later writes roll
25+
// the catalog past the retain window until the early object's segment is
26+
// retired; the early object must then still GET (its manifest is only in the
27+
// retired segment) and the bucket must still list without a delimiter (the
28+
// walk fetches every leaf's manifest).
29+
//
30+
// go test -tags itest ./itest -run TestForgeReadAfterCatalogRetention -v -timeout 900s
31+
func TestForgeReadAfterCatalogRetention(t *testing.T) {
32+
ctx := t.Context()
33+
34+
s, ingotEndpoint := forgeStack(t, stack.WithServiceConfig("ingot", "testdata/config-retention.yaml"))
35+
accessKey, secretKey := hiltProvisionTenant(t, ctx, s, "retention")
36+
cfg := forgeConfig(ingotEndpoint, accessKey, secretKey)
37+
38+
const bucket = "retention-bucket"
39+
if err := ingottest.CreateBucket(ctx, cfg, bucket); err != nil {
40+
t.Fatalf("create bucket: %v", err)
41+
}
42+
43+
// The early object: its manifest lands in the first catalog segment(s),
44+
// which the later writes will push out of the retain window.
45+
early := patternBytes(64 << 10)
46+
if err := ingottest.PutBytes(ctx, cfg, bucket, "early-obj", early); err != nil {
47+
t.Fatalf("put early object: %v", err)
48+
}
49+
50+
// Count the segment CARs currently on disk; the early manifest lives in
51+
// one of these. Retirement is proven when every one of them is gone.
52+
initialSegs := catalogSegments(t, s, bucket)
53+
if len(initialSegs) == 0 {
54+
// The first segment may not have sealed yet; wait for it so we have
55+
// a concrete set to watch retire.
56+
waitFor(t, time.Minute, "first catalog segment to seal", func() bool {
57+
initialSegs = catalogSegments(t, s, bucket)
58+
return len(initialSegs) > 0
59+
})
60+
}
61+
t.Logf("early object's manifest is in segment(s): %v", initialSegs)
62+
63+
// Roll the catalog: spaced writes each seal (1s seal_age) and ship a new
64+
// segment; retain=1 retires everything older. Keep writing until every
65+
// initial segment file is gone from the container.
66+
waitFor(t, 5*time.Minute, "initial catalog segments to retire", func() bool {
67+
key := fmt.Sprintf("filler/obj-%d", time.Now().UnixNano())
68+
if err := ingottest.PutBytes(ctx, cfg, bucket, key, patternBytes(4<<10)); err != nil {
69+
t.Fatalf("put filler object: %v", err)
70+
}
71+
// Slower than seal_age so each segment seals and ships with headroom
72+
// (no flush-queue overflow → no re-ships through the dedup path).
73+
time.Sleep(3 * time.Second)
74+
remaining := catalogSegments(t, s, bucket)
75+
for _, seg := range initialSegs {
76+
for _, r := range remaining {
77+
if seg == r {
78+
return false
79+
}
80+
}
81+
}
82+
return true
83+
})
84+
t.Logf("initial segments retired; segments now on disk: %v", catalogSegments(t, s, bucket))
85+
86+
// GET the early object: its manifest exists only in a retired segment, so
87+
// this read MUST come through inclusion → shard → ranged piri retrieval.
88+
got, err := ingottest.GetBytes(ctx, cfg, bucket, "early-obj")
89+
if err != nil {
90+
t.Fatalf("get early object after its catalog segment retired: %v", err)
91+
}
92+
if !bytes.Equal(got, early) {
93+
t.Fatalf("early object mismatch after retention: got %d bytes, want %d", len(got), len(early))
94+
}
95+
96+
// Undelimited list walks every leaf and fetches every manifest — the
97+
// regression that motivated this test (root listing failed with
98+
// "blockstore: not found" once a manifest's segment was retired).
99+
keys, err := ingottest.ListKeys(ctx, cfg, bucket)
100+
if err != nil {
101+
t.Fatalf("list bucket after retention: %v", err)
102+
}
103+
found := false
104+
for _, k := range keys {
105+
if k == "early-obj" {
106+
found = true
107+
break
108+
}
109+
}
110+
if !found {
111+
t.Fatalf("early-obj missing from listing: %v", keys)
112+
}
113+
t.Logf("read-after-retention OK: %d bytes via ranged shard retrieval; %d keys listed", len(got), len(keys))
114+
}
115+
116+
// catalogSegments lists the catalog-plane CAR files currently on the ingot
117+
// container's disk for bucket (segments live under
118+
// /data/segments/<bucket>/catalog/).
119+
func catalogSegments(t *testing.T, s *stack.Stack, bucket string) []string {
120+
t.Helper()
121+
out, _, err := s.Exec(t.Context(), "ingot", "sh", "-c",
122+
"ls /data/segments/"+bucket+"/catalog/ 2>/dev/null | grep '\\.car$' || true")
123+
if err != nil {
124+
t.Fatalf("list catalog segments: %v", err)
125+
}
126+
fields := strings.Fields(strings.TrimSpace(out))
127+
return fields
128+
}
129+
130+
// waitFor polls cond (which may do work per attempt) until true or fatal after
131+
// timeout.
132+
func waitFor(t *testing.T, timeout time.Duration, what string, cond func() bool) {
133+
t.Helper()
134+
deadline := time.Now().Add(timeout)
135+
for time.Now().Before(deadline) {
136+
if cond() {
137+
return
138+
}
139+
}
140+
t.Fatalf("timed out after %s waiting for %s", timeout, what)
141+
}

itest/scenarios_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,9 @@ func TestForgeScenarios(t *testing.T) {
291291
}
292292

293293
resp := preflight(t, origin)
294-
if resp.StatusCode != http.StatusNoContent {
295-
t.Errorf("preflight status = %d, want 204", resp.StatusCode)
294+
// CORS preflight spec allows any "ok status" (200-299).
295+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
296+
t.Errorf("preflight status = %d, want 2xx", resp.StatusCode)
296297
}
297298
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
298299
t.Errorf("preflight Allow-Origin = %q, want the request origin echoed", got)

itest/stack_test.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ func forgeStack(t *testing.T, extra ...stack.Option) (*stack.Stack, string) {
104104
t.Helper()
105105
t.Logf("booting the smelt Forge stack (~1-2 min; first run also compiles ingot and pulls images)")
106106
opts := []stack.Option{
107-
stack.WithPiriNodes(stack.PiriNodeConfig{}),
107+
// Postgres-backed piri: piri:main's curio PDP pipeline refuses
108+
// sqlite ("curio PDP pipeline requires Postgres") as of 2026-07-24.
109+
stack.WithPiriNodes(stack.PiriNodeConfig{Postgres: true}),
108110
stack.WithServiceBinary("ingot", localIngotBinary(t)),
109111
}
110112
// Local-dev escape hatches: run against upload-service (sprue) / piri
@@ -122,6 +124,13 @@ func forgeStack(t *testing.T, extra ...stack.Option) (*stack.Stack, string) {
122124
t.Logf("using piri image override: %s", img)
123125
opts = append(opts, stack.WithPiriImage(img))
124126
}
127+
// Same idea one step earlier in the pipeline: mount a locally-built piri
128+
// binary (linux, static) over the image's /usr/bin/piri — validates an
129+
// unreleased piri/ucantone change with no image build at all.
130+
if bin := os.Getenv("INGOT_ITEST_PIRI_BINARY"); bin != "" {
131+
t.Logf("using piri binary override: %s", bin)
132+
opts = append(opts, stack.WithPiriBinary(bin))
133+
}
125134
opts = append(opts, extra...)
126135
s := stack.MustNewStack(t, opts...)
127136
endpoint := s.IngotEndpoint()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Ingot daemon config for the catalog-retention fallthrough itest — smelt's
2+
# default forge-mode config (systems/ingot/config/config.yaml) with the
3+
# catalog plane tuned to seal fast and retain only one shipped segment, and
4+
# the read cache disabled so every fallthrough read exercises the locator +
5+
# ranged /content/retrieve path rather than a warm cache.
6+
# Mounted over /etc/ingot/config.yaml via stack.WithServiceConfig.
7+
8+
addr: "0.0.0.0:9000"
9+
region: us-west-1
10+
data_dir: /data
11+
12+
root_access: ingot
13+
root_secret: ingotsecret
14+
15+
identity:
16+
key_file: /keys/ingot.pem
17+
18+
postgres_dsn: "postgres://ingot:ingot@ingot-postgres:5432/ingot?sslmode=disable"
19+
20+
upload_service_url: "http://upload:80"
21+
upload_service_did: "did:web:upload"
22+
upload_receipts_url: "http://upload:80/receipt"
23+
24+
indexer_endpoint: "http://indexer:80"
25+
indexer_did: "did:web:indexer"
26+
27+
# S3 authorization service (hilt); the DID is used verbatim (no resolution)
28+
auth_service_url: "http://hilt:80"
29+
auth_service_did: "did:web:hilt"
30+
# hilt → ingot delegations for /s3/request/authorize and /s3/bucket/*
31+
auth_service_proofs: "/proofs/hilt-ingot-s3-proof.txt"
32+
33+
# The point of this config: roll catalog segments fast and retire aggressively,
34+
# with no read cache masking the fallthrough. seal_age is fast but leaves the
35+
# flusher headroom per ship (a 1s seal outruns the ship pipeline, overflowing
36+
# the flush queue and forcing re-ships through sprue's dedup path).
37+
read_cache_bytes: -1
38+
catalog_plane:
39+
seal_age: 2s
40+
retain: 1

0 commit comments

Comments
 (0)