Skip to content

Commit 922f744

Browse files
authored
feat: blob removal — serve /blob/remove, /upload/remove, /blob/abort (FIL-522) (#33)
Serves the upload-service half of blob removal, aligned with the blob-removal RFC ([fil-one/RFC#13](fil-one/RFC#13)): - `/blob/remove` (subject = the space, args `{digest}`) — releases a space's claim on an accepted blob: recovers every provider holding it (primary via the registration's receipt chain, plus non-failed replicas), forwards a provider-rooted **`/blob/release`** `{space, digest}` to each (best-effort), and deregisters last so the receipt chain survives for a retry if every forward fails. - `/blob/abort` (subject = the space, args `{digest, cause}`) — abandons a parked (never-accepted) blob: the provider is recovered from the cause's receipt chain and the abort is forwarded as `/blob/reject`. A cause that doesn't resolve to a known `/blob/add` task fails with the named error `MissingCause`; a node refusing because the space accepted the blob has its `BlobAccepted` re-surfaced in the abort receipt, so clients can distinguish "use `/blob/remove`" from a retryable fault. No local state mutates, so aborts are safely retryable. - `/upload/remove` (subject = the space, args `{root}`) — deletes the upload's root→shards index entry only. Shard blobs are the client's per-digest `/blob/remove` decision (content addressing shares shards between uploads). All handlers are idempotent (unknown/already-removed → success). Named errors come from libforge, shared with piri. ## Landing order fil-forge/libforge#49 (`/blob/release` binding + named errors; pinned at its branch head, re-pin on merge) → fil-forge/piri#30 (piri serves `/blob/release` + `/blob/reject`) → **this** — the forwards here invoke piri capabilities that only exist on #30. Same chain: fil-forge/smelt#19 (devnet delegations), fil-forge/ingot#40 (S3 surface). Validated end-to-end by ingot's delete-finality and deferred-multipart itests against piri #30 + this branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 50cd990 commit 922f744

12 files changed

Lines changed: 1151 additions & 9 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ require (
1111
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3
1212
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
1313
github.com/docker/docker v28.5.2+incompatible
14-
github.com/fil-forge/libforge v0.0.0-20260724113901-7fc3b2cec1ef
14+
github.com/fil-forge/libforge v0.0.0-20260727220215-5e299c46f62f
1515
github.com/fil-forge/ucantone v0.0.0-20260713122829-2662bddc02ab
1616
github.com/google/uuid v1.6.0
1717
github.com/ipfs/go-cid v0.6.1

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/
9292
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
9393
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
9494
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
95-
github.com/fil-forge/libforge v0.0.0-20260724113901-7fc3b2cec1ef h1:xgkciShyWdCQ2Pyl2qEwP7JMbsdbHdECj/v6E5+fj6U=
96-
github.com/fil-forge/libforge v0.0.0-20260724113901-7fc3b2cec1ef/go.mod h1:0kXihIQ4L2uZ00nR5XrZ/Y8Db7Ht/qQNuiWslwMJ95M=
95+
github.com/fil-forge/libforge v0.0.0-20260727220215-5e299c46f62f h1:QzgMg8GIE4IhgOE/7DBHFU/4T5oU7J4vAKxbUPKJPGA=
96+
github.com/fil-forge/libforge v0.0.0-20260727220215-5e299c46f62f/go.mod h1:0kXihIQ4L2uZ00nR5XrZ/Y8Db7Ht/qQNuiWslwMJ95M=
9797
github.com/fil-forge/ucantone v0.0.0-20260713122829-2662bddc02ab h1:Kal5frXuFQiaeumu1DreTbaXmK6DXWfz7al4FFhAh4A=
9898
github.com/fil-forge/ucantone v0.0.0-20260713122829-2662bddc02ab/go.mod h1:oFY5BfD0bDeodGlbBHh3/nK99MAS93rGXjoQz7s5qgE=
9999
github.com/filecoin-project/go-data-segment v0.0.1 h1:1wmDxOG4ubWQm3ZC1XI5nCon5qgSq7Ra3Rb6Dbu10Gs=

internal/fx/service/handlers/provider.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ var Module = fx.Module("service-handlers",
5252
handlers.NewBlobListHandler,
5353
fx.ResultTags(`group:"ucan_handlers"`),
5454
),
55+
fx.Annotate(
56+
handlers.NewBlobRemoveHandler,
57+
fx.ResultTags(`group:"ucan_handlers"`),
58+
),
59+
fx.Annotate(
60+
handlers.NewBlobAbortHandler,
61+
fx.ResultTags(`group:"ucan_handlers"`),
62+
),
5563
// fx.Annotate(
5664
// handlers.NewBlobReplicateHandler,
5765
// fx.ResultTags(`group:"ucan_handlers"`),
@@ -76,6 +84,10 @@ var Module = fx.Module("service-handlers",
7684
handlers.NewUploadAddHandler,
7785
fx.ResultTags(`group:"ucan_handlers"`),
7886
),
87+
fx.Annotate(
88+
handlers.NewUploadRemoveHandler,
89+
fx.ResultTags(`group:"ucan_handlers"`),
90+
),
7991
fx.Annotate(
8092
handlers.NewUploadListHandler,
8193
fx.ResultTags(`group:"ucan_handlers"`),

pkg/piriclient/client.go

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

@@ -58,7 +59,7 @@ func NewWithClient(piriDID did.DID, issuer ucan.Issuer, client *client.HTTPClien
5859
// AllocateRequest contains the parameters for a /blob/allocate invocation.
5960
type AllocateRequest struct {
6061
Space did.DID
61-
Digest []byte
62+
Digest multihash.Multihash
6263
Size uint64
6364
Cause cid.Cid
6465
}
@@ -132,7 +133,7 @@ func (c *Client) PiriDID() did.DID {
132133
// AcceptRequest contains the parameters for a /blob/accept invocation.
133134
type AcceptRequest struct {
134135
Space did.DID
135-
Digest []byte
136+
Digest multihash.Multihash
136137
Size uint64
137138
Put cid.Cid // Link to the /http/put task that uploaded the blob
138139
}
@@ -196,10 +197,146 @@ func (c *Client) AcceptInvocation(ctx context.Context, req *AcceptRequest, proof
196197
return inv, prfs, nil
197198
}
198199

200+
// ReleaseRequest contains the parameters for a /blob/release invocation.
201+
type ReleaseRequest struct {
202+
Space did.DID
203+
Digest multihash.Multihash
204+
}
205+
206+
// Release sends a /blob/release invocation to the piri node, releasing the
207+
// space's claim on the blob. Returns the response data, the invocation that
208+
// was sent, and the receipt from piri. Piri's handler is idempotent, so
209+
// releasing an already-released blob succeeds.
210+
func (c *Client) Release(ctx context.Context, req *ReleaseRequest, proofStore ucanlib.ProofStore, options ...invocation.Option) (*blobcmds.ReleaseOK, ucan.Invocation, ucan.Receipt, error) {
211+
inv, prfs, err := c.ReleaseInvocation(ctx, req, proofStore, options...)
212+
if err != nil {
213+
return nil, nil, nil, fmt.Errorf("creating release invocation: %w", err)
214+
}
215+
216+
c.logger.Debug("RELEASE invocation created",
217+
zap.Stringer("issuer", inv.Issuer()),
218+
zap.Stringer("audience", inv.Audience()),
219+
zap.Int("proofs", len(prfs)),
220+
)
221+
222+
releaseOK, rcpt, _, err := ucan_client.Execute[*blobcmds.ReleaseOK](
223+
ctx,
224+
c.client,
225+
c.logger,
226+
inv,
227+
execution.WithDelegations(prfs...),
228+
)
229+
if err != nil {
230+
return nil, nil, nil, err
231+
}
232+
return releaseOK, inv, rcpt, nil
233+
}
234+
235+
// ReleaseInvocation returns the invocation for the release request.
236+
func (c *Client) ReleaseInvocation(ctx context.Context, req *ReleaseRequest, proofStore ucanlib.ProofStore, options ...invocation.Option) (ucan.Invocation, []ucan.Delegation, error) {
237+
// As with allocate/accept, the proof chain is rooted at the storage
238+
// provider, so the subject is the provider DID and the space travels in
239+
// the arguments.
240+
prfs, prfLinks, err := proofStore.ProofChain(ctx, c.issuer.DID(), blobcmds.Release.Command, c.piriDID)
241+
if err != nil {
242+
return nil, nil, fmt.Errorf("building proof chain: %w", err)
243+
}
244+
245+
options = slices.Clone(options)
246+
options = append(
247+
options,
248+
invocation.WithAudience(c.piriDID),
249+
invocation.WithProofs(prfLinks...),
250+
)
251+
252+
inv, err := blobcmds.Release.Invoke(
253+
c.issuer,
254+
c.piriDID,
255+
&blobcmds.ReleaseArguments{
256+
Space: req.Space,
257+
Digest: req.Digest,
258+
},
259+
options...,
260+
)
261+
if err != nil {
262+
return nil, nil, fmt.Errorf("creating release invocation: %w", err)
263+
}
264+
265+
return inv, prfs, nil
266+
}
267+
268+
// RejectRequest contains the parameters for a /blob/reject invocation.
269+
type RejectRequest struct {
270+
Space did.DID
271+
Digest multihash.Multihash
272+
}
273+
274+
// Reject sends a /blob/reject invocation to the piri node, retiring the
275+
// space's parked (never-accepted) blob. Piri refuses accepted blobs with a
276+
// BlobAccepted failure; otherwise the handler is idempotent.
277+
func (c *Client) Reject(ctx context.Context, req *RejectRequest, proofStore ucanlib.ProofStore, options ...invocation.Option) (*blobcmds.RejectOK, ucan.Invocation, ucan.Receipt, error) {
278+
inv, prfs, err := c.RejectInvocation(ctx, req, proofStore, options...)
279+
if err != nil {
280+
return nil, nil, nil, fmt.Errorf("creating reject invocation: %w", err)
281+
}
282+
283+
c.logger.Debug("REJECT invocation created",
284+
zap.Stringer("issuer", inv.Issuer()),
285+
zap.Stringer("audience", inv.Audience()),
286+
zap.Int("proofs", len(prfs)),
287+
)
288+
289+
rejectOK, rcpt, _, err := ucan_client.Execute[*blobcmds.RejectOK](
290+
ctx,
291+
c.client,
292+
c.logger,
293+
inv,
294+
execution.WithDelegations(prfs...),
295+
)
296+
if err != nil {
297+
return nil, nil, nil, err
298+
}
299+
return rejectOK, inv, rcpt, nil
300+
}
301+
302+
// RejectInvocation returns the invocation for the reject request.
303+
func (c *Client) RejectInvocation(ctx context.Context, req *RejectRequest, proofStore ucanlib.ProofStore, options ...invocation.Option) (ucan.Invocation, []ucan.Delegation, error) {
304+
// As with allocate/accept/release, the proof chain is rooted at the
305+
// storage provider, so the subject is the provider DID and the space
306+
// travels in the arguments. Cause is not forwarded — it is upload-service
307+
// routing metadata, meaningless to the node.
308+
prfs, prfLinks, err := proofStore.ProofChain(ctx, c.issuer.DID(), blobcmds.Reject.Command, c.piriDID)
309+
if err != nil {
310+
return nil, nil, fmt.Errorf("building proof chain: %w", err)
311+
}
312+
313+
options = slices.Clone(options)
314+
options = append(
315+
options,
316+
invocation.WithAudience(c.piriDID),
317+
invocation.WithProofs(prfLinks...),
318+
)
319+
320+
inv, err := blobcmds.Reject.Invoke(
321+
c.issuer,
322+
c.piriDID,
323+
&blobcmds.RejectArguments{
324+
Space: req.Space,
325+
Digest: req.Digest,
326+
},
327+
options...,
328+
)
329+
if err != nil {
330+
return nil, nil, fmt.Errorf("creating reject invocation: %w", err)
331+
}
332+
333+
return inv, prfs, nil
334+
}
335+
199336
// ReplicaAllocateRequest contains the parameters for a /blob/replica/allocate invocation.
200337
type ReplicaAllocateRequest struct {
201338
Space did.DID
202-
Digest []byte
339+
Digest multihash.Multihash
203340
Size uint64
204341
Site ucan.Invocation // Location commitment
205342
Cause cid.Cid

pkg/service/handlers/blob_abort.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package handlers
2+
3+
import (
4+
"fmt"
5+
6+
blobcmds "github.com/fil-forge/libforge/commands/blob"
7+
"github.com/fil-forge/libforge/digestutil"
8+
ucanlib "github.com/fil-forge/libforge/ucan"
9+
"github.com/fil-forge/sprue/pkg/piriclient"
10+
"github.com/fil-forge/sprue/pkg/routing"
11+
"github.com/fil-forge/sprue/pkg/store/agent"
12+
"github.com/fil-forge/ucantone/binding"
13+
"github.com/fil-forge/ucantone/errors"
14+
"github.com/fil-forge/ucantone/server"
15+
"github.com/fil-forge/ucantone/ucan"
16+
"go.uber.org/zap"
17+
)
18+
19+
// NewBlobAbortHandler abandons a space's in-flight upload of a parked
20+
// (never-accepted) blob: it recovers the storage node holding it from the
21+
// Cause receipt chain and forwards a /blob/reject there. Nothing is
22+
// deregistered — registration happens only at accept, which a parked blob
23+
// never reached.
24+
//
25+
// A cause that does not resolve to a known /blob/add task fails with
26+
// the named error MissingCause; a node refusing the translated reject
27+
// because the space has accepted the blob surfaces the node's BlobAccepted
28+
// as a named failure, so the client can distinguish "use /blob/remove"
29+
// from a retryable fault. Other forward errors are propagated as generic
30+
// receipt failures: abort mutates no local state, so the caller can simply
31+
// retry.
32+
func NewBlobAbortHandler(router *routing.Service, nodeProvider piriclient.Provider, agentStore agent.Store, logger *zap.Logger) server.Route {
33+
log := logger.With(zap.Stringer("handler", blobcmds.Abort))
34+
return blobcmds.Abort.Route(
35+
func(req *binding.Request[*blobcmds.AbortArguments], res *binding.Response[*blobcmds.AbortOK]) error {
36+
args := req.Task().Arguments()
37+
space := req.Invocation().Subject()
38+
log := log.With(
39+
zap.Stringer("space", space),
40+
zap.String("blob", digestutil.Format(args.Digest)),
41+
)
42+
log.Debug("aborting blob upload")
43+
44+
if !args.Cause.Defined() {
45+
return res.SetFailure(blobcmds.ErrMissingCause)
46+
}
47+
48+
provider, err := primaryProviderForBlob(req.Context(), agentStore, args.Cause)
49+
if err != nil {
50+
// An unknown cause — one whose receipt chain we don't hold —
51+
// cannot route to a node; per the RFC it is the named error
52+
// MissingCause, not a retryable execution fault.
53+
if errors.Is(err, agent.ErrReceiptNotFound) || errors.Is(err, agent.ErrInvocationNotFound) {
54+
log.Debug("cause does not resolve to a known blob add task", zap.Error(err))
55+
return res.SetFailure(errors.New(blobcmds.MissingCauseErrorName,
56+
"cause does not resolve to a known /blob/add task"))
57+
}
58+
log.Error("failed to recover provider from receipt chain", zap.Error(err))
59+
return fmt.Errorf("recovering provider for parked blob: %w", err)
60+
}
61+
62+
info, err := router.GetProviderInfo(req.Context(), provider)
63+
if err != nil {
64+
log.Error("failed to get provider info", zap.Error(err))
65+
return fmt.Errorf("getting provider info: %w", err)
66+
}
67+
client, err := nodeProvider.Client(info.ID, info.Endpoint)
68+
if err != nil {
69+
log.Error("failed to create piri client", zap.Error(err))
70+
return fmt.Errorf("creating piri client: %w", err)
71+
}
72+
73+
// The proof chain for /blob/reject comes from the proofs the
74+
// provider granted the upload service at registration.
75+
proofStore := ucanlib.NewContainerProofStore(info.Proofs)
76+
_, inv, rcpt, err := client.Reject(req.Context(), &piriclient.RejectRequest{
77+
Space: space,
78+
Digest: args.Digest,
79+
}, proofStore)
80+
if err != nil {
81+
// The node refuses to reject a blob this space has accepted.
82+
// Surface the named failure rather than a generic fault so
83+
// the client knows to use /blob/remove instead of retrying.
84+
var named errors.Named
85+
if errors.As(err, &named) && named.Name() == blobcmds.BlobAcceptedErrorName {
86+
log.Debug("provider refused reject: blob accepted by space",
87+
zap.Stringer("provider", provider))
88+
return res.SetFailure(named)
89+
}
90+
log.Error("failed to execute reject on provider",
91+
zap.Stringer("provider", provider), zap.Error(err))
92+
return fmt.Errorf("executing reject on provider: %w", err)
93+
}
94+
95+
if err := writeAgentMessage(req.Context(), agentStore, []ucan.Invocation{inv}, []ucan.Receipt{rcpt}); err != nil {
96+
log.Error("failed to write agent message", zap.Error(err))
97+
return fmt.Errorf("writing agent message: %w", err)
98+
}
99+
100+
return res.SetSuccess(&blobcmds.AbortOK{})
101+
},
102+
)
103+
}

0 commit comments

Comments
 (0)