Skip to content

Commit a0d2868

Browse files
authored
feat: per-chunk stamped putter for WebSocket chunk uploads (#5256)
1 parent a81f17a commit a0d2868

4 files changed

Lines changed: 331 additions & 30 deletions

File tree

openapi/Swarm.yaml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,18 @@ paths:
319319
- Chunk
320320
parameters:
321321
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmTagParameter"
322-
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageBatchId"
322+
- in: query
323+
name: swarm-tag
324+
schema:
325+
$ref: "SwarmCommon.yaml#/components/schemas/Uid"
326+
required: false
327+
description: "Associate upload with an existing Tag UID (use when WebSocket client cannot set custom headers)"
328+
- in: header
329+
name: swarm-postage-batch-id
330+
description: "ID of Postage Batch that is used to upload data with. Optional when chunks include pre-signed postage stamps."
331+
required: false
332+
schema:
333+
$ref: "SwarmCommon.yaml#/components/schemas/SwarmAddress"
323334
responses:
324335
"200":
325336
description: "Connection established"

pkg/api/api.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,18 @@ func (s *Service) newStampedPutter(ctx context.Context, opts putterOptions, stam
845845
return nil, errInvalidPostageBatch
846846
}
847847

848+
return s.newStampedPutterWithBatch(ctx, opts, stamp, storedBatch)
849+
}
850+
851+
// newStampedPutterWithBatch creates a stamped putter using a pre-fetched batch.
852+
// This avoids the database lookup when batch info is already cached.
853+
func (s *Service) newStampedPutterWithBatch(ctx context.Context, opts putterOptions, stamp *postage.Stamp, storedBatch *postage.Batch) (storer.PutterSession, error) {
854+
if !opts.Deferred && s.beeMode == DevMode {
855+
return nil, errUnsupportedDevNodeOperation
856+
}
857+
848858
var session storer.PutterSession
859+
var err error
849860
if opts.Deferred || opts.Pin {
850861
session, err = s.storer.Upload(ctx, opts.Pin, opts.TagID)
851862
if err != nil {

pkg/api/chunk_stream.go

Lines changed: 165 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"context"
99
"errors"
1010
"net/http"
11+
"strconv"
1112
"time"
1213

1314
"github.com/ethersphere/bee/v2/pkg/cac"
@@ -28,14 +29,27 @@ func (s *Service) chunkUploadStreamHandler(w http.ResponseWriter, r *http.Reques
2829
logger := s.logger.WithName("chunks_stream").Build()
2930

3031
headers := struct {
31-
BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"`
32+
BatchID []byte `map:"Swarm-Postage-Batch-Id"` // Optional: omit if caller provides pre-signed stamps per chunk
3233
SwarmTag uint64 `map:"Swarm-Tag"`
3334
}{}
3435
if response := s.mapStructure(r.Header, &headers); response != nil {
3536
response("invalid header params", logger, w)
3637
return
3738
}
3839

40+
// Fallback: read tag from query parameter (browser WebSocket can't set headers)
41+
if headers.SwarmTag == 0 {
42+
if qTag := r.URL.Query().Get("swarm-tag"); qTag != "" {
43+
parsed, err := strconv.ParseUint(qTag, 10, 64)
44+
if err != nil {
45+
logger.Debug("invalid swarm-tag query parameter", "value", qTag, "error", err)
46+
jsonhttp.BadRequest(w, "invalid swarm-tag query parameter")
47+
return
48+
}
49+
headers.SwarmTag = parsed
50+
}
51+
}
52+
3953
var (
4054
tag uint64
4155
err error
@@ -55,29 +69,36 @@ func (s *Service) chunkUploadStreamHandler(w http.ResponseWriter, r *http.Reques
5569
}
5670
}
5771

58-
// if tag not specified use direct upload
59-
// Using context.Background here because the putter's lifetime extends beyond that of the HTTP request.
60-
putter, err := s.newStamperPutter(context.Background(), putterOptions{
61-
BatchID: headers.BatchID,
62-
TagID: tag,
63-
Deferred: tag != 0,
64-
})
65-
if err != nil {
66-
logger.Debug("get putter failed", "error", err)
67-
logger.Error(nil, "get putter failed")
68-
switch {
69-
case errors.Is(err, errBatchUnusable) || errors.Is(err, postage.ErrNotUsable):
70-
jsonhttp.UnprocessableEntity(w, "batch not usable yet or does not exist")
71-
case errors.Is(err, postage.ErrNotFound):
72-
jsonhttp.NotFound(w, "batch with id not found")
73-
case errors.Is(err, errInvalidPostageBatch):
74-
jsonhttp.BadRequest(w, "invalid batch id")
75-
case errors.Is(err, errUnsupportedDevNodeOperation):
76-
jsonhttp.BadRequest(w, errUnsupportedDevNodeOperation)
77-
default:
78-
jsonhttp.BadRequest(w, nil)
72+
// Create connection-level putter only if BatchID is provided.
73+
// If BatchID is not provided, the API caller is expected to provide
74+
// pre-signed stamps with each chunk (and is also expected to keep
75+
// track of stamp state over time).
76+
var putter storer.PutterSession
77+
if len(headers.BatchID) > 0 {
78+
// if tag not specified use direct upload
79+
// Using context.Background here because the putter's lifetime extends beyond that of the HTTP request.
80+
putter, err = s.newStamperPutter(context.Background(), putterOptions{
81+
BatchID: headers.BatchID,
82+
TagID: tag,
83+
Deferred: tag != 0,
84+
})
85+
if err != nil {
86+
logger.Debug("get putter failed", "error", err)
87+
logger.Error(nil, "get putter failed")
88+
switch {
89+
case errors.Is(err, errBatchUnusable) || errors.Is(err, postage.ErrNotUsable):
90+
jsonhttp.UnprocessableEntity(w, "batch not usable yet or does not exist")
91+
case errors.Is(err, postage.ErrNotFound):
92+
jsonhttp.NotFound(w, "batch with id not found")
93+
case errors.Is(err, errInvalidPostageBatch):
94+
jsonhttp.BadRequest(w, "invalid batch id")
95+
case errors.Is(err, errUnsupportedDevNodeOperation):
96+
jsonhttp.BadRequest(w, errUnsupportedDevNodeOperation)
97+
default:
98+
jsonhttp.BadRequest(w, nil)
99+
}
100+
return
79101
}
80-
return
81102
}
82103

83104
upgrader := websocket.Upgrader{
@@ -95,13 +116,46 @@ func (s *Service) chunkUploadStreamHandler(w http.ResponseWriter, r *http.Reques
95116
}
96117

97118
s.wsWg.Add(1)
98-
go s.handleUploadStream(logger, wsConn, putter)
119+
var decode chunkDecoder
120+
if len(headers.BatchID) > 0 {
121+
decode = decodeChunkWithoutStamp
122+
} else {
123+
decode = decodeChunkWithStamp
124+
}
125+
go s.handleUploadStream(logger, wsConn, putter, tag, decode)
126+
}
127+
128+
// chunkDecoder extracts chunk data and optionally a stamp from a websocket message.
129+
// When BatchID is provided in headers, decodeChunkWithoutStamp is used (no stamp in message).
130+
// When BatchID is not provided, decodeChunkWithStamp is used (stamp prepended to chunk data).
131+
type chunkDecoder func(msg []byte) (chunkData []byte, stamp *postage.Stamp, err error)
132+
133+
// decodeChunkWithoutStamp returns the message as-is (used when BatchID provided in headers).
134+
func decodeChunkWithoutStamp(msg []byte) ([]byte, *postage.Stamp, error) {
135+
return msg, nil, nil
136+
}
137+
138+
// decodeChunkWithStamp extracts a stamp from the first 113 bytes of the message.
139+
// Returns an error if the message is too small or the stamp is invalid.
140+
func decodeChunkWithStamp(msg []byte) ([]byte, *postage.Stamp, error) {
141+
if len(msg) < postage.StampSize+swarm.SpanSize {
142+
return nil, nil, errors.New("message too small for stamp + chunk")
143+
}
144+
145+
stamp := &postage.Stamp{}
146+
if err := stamp.UnmarshalBinary(msg[:postage.StampSize]); err != nil {
147+
return nil, nil, errors.New("invalid stamp")
148+
}
149+
150+
return msg[postage.StampSize:], stamp, nil
99151
}
100152

101153
func (s *Service) handleUploadStream(
102154
logger log.Logger,
103155
conn *websocket.Conn,
104156
putter storer.PutterSession,
157+
tag uint64,
158+
decode chunkDecoder,
105159
) {
106160
defer s.wsWg.Done()
107161

@@ -111,11 +165,23 @@ func (s *Service) handleUploadStream(
111165
gone = make(chan struct{})
112166
err error
113167
)
168+
169+
// Cache for batch validation to avoid database lookups for every chunk
170+
// Key: batch ID, Value: stored batch info
171+
// This avoids the expensive batchStore.Get() call for each chunk
172+
batchCache := make(map[string]*postage.Batch)
173+
114174
defer func() {
115175
cancel()
116176
_ = conn.Close()
117-
if err = putter.Done(swarm.ZeroAddress); err != nil {
118-
logger.Error(err, "chunk upload stream: syncing chunks failed")
177+
178+
// No cleanup needed for batch cache - it's just metadata
179+
180+
// Only call Done on connection-level putter if it exists
181+
if putter != nil {
182+
if err = putter.Done(swarm.ZeroAddress); err != nil {
183+
logger.Error(err, "chunk upload stream: syncing chunks failed")
184+
}
119185
}
120186
}()
121187

@@ -190,17 +256,80 @@ func (s *Service) handleUploadStream(
190256
return
191257
}
192258

193-
chunk, err := cac.NewWithDataSpan(msg)
259+
// Decode the message using the appropriate decoder
260+
chunkData, stamp, err := decode(msg)
261+
if err != nil {
262+
logger.Debug("chunk upload stream: decode failed", "error", err)
263+
logger.Error(nil, "chunk upload stream: "+err.Error())
264+
sendErrorClose(websocket.CloseInternalServerErr, err.Error())
265+
return
266+
}
267+
268+
// Determine the putter to use
269+
var (
270+
chunk swarm.Chunk
271+
chunkPutter = putter
272+
)
273+
274+
// If stamp was extracted, create a per-chunk putter
275+
if stamp != nil {
276+
batchID := stamp.BatchID()
277+
batchIDKey := string(batchID)
278+
279+
storedBatch, exists := batchCache[batchIDKey]
280+
if !exists {
281+
storedBatch, err = s.batchStore.Get(batchID)
282+
if err != nil {
283+
logger.Debug("chunk upload stream: batch validation failed", "error", err)
284+
logger.Error(nil, "chunk upload stream: batch validation failed")
285+
if errors.Is(err, storage.ErrNotFound) {
286+
sendErrorClose(websocket.CloseInternalServerErr, "batch not found")
287+
} else {
288+
sendErrorClose(websocket.CloseInternalServerErr, "batch validation failed")
289+
}
290+
return
291+
}
292+
batchCache[batchIDKey] = storedBatch
293+
}
294+
295+
chunkPutter, err = s.newStampedPutterWithBatch(ctx, putterOptions{
296+
BatchID: batchID,
297+
TagID: tag,
298+
Deferred: tag != 0,
299+
}, stamp, storedBatch)
300+
if err != nil {
301+
logger.Debug("chunk upload stream: failed to create stamped putter", "error", err)
302+
logger.Error(nil, "chunk upload stream: failed to create stamped putter")
303+
switch {
304+
case errors.Is(err, errBatchUnusable) || errors.Is(err, postage.ErrNotUsable):
305+
sendErrorClose(websocket.CloseInternalServerErr, "batch not usable")
306+
case errors.Is(err, postage.ErrNotFound):
307+
sendErrorClose(websocket.CloseInternalServerErr, "batch not found")
308+
default:
309+
sendErrorClose(websocket.CloseInternalServerErr, "stamped putter creation failed")
310+
}
311+
return
312+
}
313+
}
314+
315+
chunk, err = cac.NewWithDataSpan(chunkData)
194316
if err != nil {
195-
logger.Debug("chunk upload stream: create chunk failed", "error", err)
317+
logger.Debug("chunk upload stream: create chunk failed", "error", err, "chunk_size", len(chunkData))
196318
logger.Error(nil, "chunk upload stream: create chunk failed")
319+
if chunkPutter != putter {
320+
_ = chunkPutter.Cleanup()
321+
}
322+
sendErrorClose(websocket.CloseInternalServerErr, "invalid chunk data")
197323
return
198324
}
199325

200-
err = putter.Put(ctx, chunk)
326+
err = chunkPutter.Put(ctx, chunk)
201327
if err != nil {
202328
logger.Debug("chunk upload stream: write chunk failed", "address", chunk.Address(), "error", err)
203329
logger.Error(nil, "chunk upload stream: write chunk failed")
330+
if chunkPutter != putter {
331+
_ = chunkPutter.Cleanup()
332+
}
204333
switch {
205334
case errors.Is(err, postage.ErrBucketFull):
206335
sendErrorClose(websocket.CloseInternalServerErr, "batch is overissued")
@@ -210,6 +339,13 @@ func (s *Service) handleUploadStream(
210339
return
211340
}
212341

342+
// Clean up per-chunk putter
343+
if chunkPutter != putter {
344+
if err := chunkPutter.Done(swarm.ZeroAddress); err != nil {
345+
logger.Error(err, "chunk upload stream: failed to finalize per-chunk putter")
346+
}
347+
}
348+
213349
err = sendMsg(websocket.BinaryMessage, successWsMsg)
214350
if err != nil {
215351
s.logger.Debug("chunk upload stream: sending success message failed", "error", err)

0 commit comments

Comments
 (0)