Skip to content

Commit 5219ca9

Browse files
authored
feat(gsoc): fine grained API (#5497)
1 parent ae1ad4e commit 5219ca9

9 files changed

Lines changed: 587 additions & 21 deletions

File tree

openapi/Swarm.yaml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
openapi: 3.0.3
22

33
info:
4-
version: 8.1.1
4+
version: 8.2.0
55
title: Bee API
66
description: "API endpoints for interacting with the Swarm network, supporting file operations, messaging, and node management"
77

@@ -971,9 +971,17 @@ paths:
971971
$ref: "SwarmCommon.yaml#/components/schemas/SwarmAddress"
972972
required: true
973973
description: "Single Owner Chunk address (which may have multiple payloads)"
974+
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmSocFieldsParameter"
975+
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmCacheWrappedChunkParameter"
974976
responses:
975977
"200":
976-
description: Establishes a WebSocket subscription for incoming messages on the Single Owner Chunk address
978+
description: >
979+
Establishes a WebSocket subscription for incoming messages on the
980+
Single Owner Chunk address. Each message is the binary serialization
981+
of the Single Owner Chunk fields requested through the
982+
swarm-soc-fields header (defaults to the wrapped chunk payload).
983+
"400":
984+
$ref: "SwarmCommon.yaml#/components/responses/400"
977985
"500":
978986
$ref: "SwarmCommon.yaml#/components/responses/500"
979987
default:

openapi/SwarmCommon.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,32 @@ components:
11441144
required: false
11451145
description: Associate upload with an existing Tag UID
11461146

1147+
SwarmSocFieldsParameter:
1148+
in: header
1149+
name: swarm-soc-fields
1150+
schema:
1151+
type: string
1152+
default: "payload"
1153+
required: false
1154+
description: >
1155+
Comma separated list of Single Owner Chunk fields to be serialized and
1156+
channeled on every incoming GSOC message, in the given order. Allowed
1157+
values are: address, recoveredPubKey, identifier, signature,
1158+
wrappedAddress, span, payload. When omitted it defaults to "payload".
1159+
In order to have random access on the response bytes define payload
1160+
as the last field in the list since it has variable length.
1161+
1162+
SwarmCacheWrappedChunkParameter:
1163+
in: header
1164+
name: swarm-cache-wrapped-chunk
1165+
schema:
1166+
type: boolean
1167+
required: false
1168+
description: >
1169+
Indicates whether the wrapped chunk of every incoming GSOC message should
1170+
be cached locally so that it can be resolved through the bytes endpoint
1171+
(useful when the single owner chunk wraps a root chunk larger than 4KB).
1172+
11471173
SwarmPinParameter:
11481174
in: header
11491175
name: swarm-pin

pkg/api/api.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ const (
9696
SwarmActTimestampHeader = "Swarm-Act-Timestamp"
9797
SwarmActPublisherHeader = "Swarm-Act-Publisher"
9898
SwarmActHistoryAddressHeader = "Swarm-Act-History-Address"
99+
SwarmSocFieldsHeader = "Swarm-Soc-Fields"
100+
SwarmCacheWrappedChunkHeader = "Swarm-Cache-Wrapped-Chunk"
99101

100102
ImmutableHeader = "Immutable"
101103
GasPriceHeader = "Gas-Price"
@@ -607,6 +609,7 @@ func (s *Service) corsHandler(h http.Handler) http.Handler {
607609
SwarmRedundancyStrategyHeader, SwarmRedundancyFallbackModeHeader, SwarmChunkRetrievalTimeoutHeader, SwarmLookAheadBufferSizeHeader,
608610
SwarmFeedIndexHeader, SwarmFeedIndexNextHeader, SwarmSocSignatureHeader, SwarmOnlyRootChunk, GasPriceHeader, GasLimitHeader, ImmutableHeader,
609611
SwarmActHeader, SwarmActTimestampHeader, SwarmActPublisherHeader, SwarmActHistoryAddressHeader,
612+
SwarmSocFieldsHeader, SwarmCacheWrappedChunkHeader,
610613
}
611614
allowedHeadersStr := strings.Join(allowedHeaders, ", ")
612615

pkg/api/api_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ type testServerOptions struct {
137137
ChequebookDisabled bool
138138
SwapDisabled bool
139139
Erc20ServiceNil bool
140+
// ServiceOut, when set, receives the constructed *api.Service so tests
141+
// can drive it directly (e.g. via a custom net.Listener) instead of
142+
// through the httptest.Server this function also sets up.
143+
ServiceOut **api.Service
140144
}
141145

142146
func newTestServer(t *testing.T, o testServerOptions) (*http.Client, *websocket.Conn, string, *chanStorer) {
@@ -251,6 +255,10 @@ func newTestServer(t *testing.T, o testServerOptions) (*http.Client, *websocket.
251255
s.EnableFullAPI()
252256
}
253257

258+
if o.ServiceOut != nil {
259+
*o.ServiceOut = s
260+
}
261+
254262
if o.DirectUpload {
255263
chanStore = newChanStore(o.Storer.PusherFeed())
256264
t.Cleanup(chanStore.stop)

pkg/api/gsoc.go

Lines changed: 167 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,116 @@
55
package api
66

77
import (
8+
"bytes"
9+
"context"
10+
"fmt"
811
"net/http"
12+
"slices"
13+
"strings"
14+
"sync"
915
"time"
1016

1117
"github.com/ethersphere/bee/v2/pkg/jsonhttp"
18+
"github.com/ethersphere/bee/v2/pkg/soc"
1219
"github.com/ethersphere/bee/v2/pkg/swarm"
1320
"github.com/gorilla/mux"
1421
"github.com/gorilla/websocket"
1522
)
1623

24+
// SOC field identifiers that can be requested through the SwarmSocFieldsHeader
25+
// to be serialized and channeled on every incoming GSOC chunk.
26+
const (
27+
socFieldAddress = "address"
28+
socFieldRecoveredPubKey = "recoveredpubkey"
29+
socFieldIdentifier = "identifier"
30+
socFieldSignature = "signature"
31+
socFieldWrappedAddress = "wrappedaddress"
32+
socFieldSpan = "span"
33+
socFieldPayload = "payload"
34+
)
35+
36+
var validSocFields = []string{
37+
socFieldAddress,
38+
socFieldRecoveredPubKey,
39+
socFieldIdentifier,
40+
socFieldSignature,
41+
socFieldWrappedAddress,
42+
socFieldSpan,
43+
socFieldPayload,
44+
}
45+
46+
// maxSocFieldsSize is the maximum size of a serialized SOC fields message when
47+
// every field is requested: the whole single owner chunk (identifier +
48+
// signature + span + payload, i.e. SocMaxChunkSize) plus the derived metadata
49+
// fields that are not part of the chunk on the wire (soc address, recovered
50+
// public key and wrapped chunk address).
51+
const maxSocFieldsSize = swarm.SocMaxChunkSize +
52+
swarm.HashSize + // soc address
53+
soc.OwnerPubKeySize + // recovered public key
54+
swarm.HashSize // wrapped chunk address
55+
56+
// parseSocFields parses the SwarmSocFieldsHeader value into a list of SOC field
57+
// identifiers. When the header is empty it defaults to the payload field only,
58+
// which preserves backward compatibility. Duplicate fields are dropped, keeping
59+
// the first occurrence, so the returned slice never exceeds len(validSocFields)
60+
// entries regardless of how many times a field is repeated in the header.
61+
func parseSocFields(header string) ([]string, error) {
62+
if strings.TrimSpace(header) == "" {
63+
return []string{socFieldPayload}, nil
64+
}
65+
66+
seen := make(map[string]bool, len(validSocFields))
67+
parts := strings.Split(header, ",")
68+
fields := make([]string, 0, len(validSocFields))
69+
for _, p := range parts {
70+
f := strings.ToLower(strings.TrimSpace(p))
71+
if f == "" {
72+
continue
73+
}
74+
if !slices.Contains(validSocFields, f) {
75+
return nil, fmt.Errorf("unknown soc field: %q", p)
76+
}
77+
if seen[f] {
78+
continue
79+
}
80+
seen[f] = true
81+
fields = append(fields, f)
82+
}
83+
if len(fields) == 0 {
84+
return []string{socFieldPayload}, nil
85+
}
86+
return fields, nil
87+
}
88+
89+
// socFieldsBytes serializes the requested SOC fields in the same order as they
90+
// were provided in the header.
91+
func socFieldsBytes(c *soc.SOC, fields []string) ([]byte, error) {
92+
buf := bytes.NewBuffer(nil)
93+
for _, f := range fields {
94+
switch f {
95+
case socFieldAddress:
96+
addr, err := c.Address()
97+
if err != nil {
98+
return nil, fmt.Errorf("soc address: %w", err)
99+
}
100+
buf.Write(addr.Bytes())
101+
case socFieldRecoveredPubKey:
102+
buf.Write(c.OwnerPubKey())
103+
case socFieldIdentifier:
104+
buf.Write(c.ID())
105+
case socFieldSignature:
106+
buf.Write(c.Signature())
107+
case socFieldWrappedAddress:
108+
buf.Write(c.WrappedChunk().Address().Bytes())
109+
case socFieldSpan:
110+
buf.Write(c.WrappedChunk().Data()[:swarm.SpanSize])
111+
case socFieldPayload:
112+
buf.Write(c.WrappedChunk().Data()[swarm.SpanSize:])
113+
}
114+
}
115+
return buf.Bytes(), nil
116+
}
117+
17118
func (s *Service) gsocWsHandler(w http.ResponseWriter, r *http.Request) {
18119
logger := s.logger.WithName("gsoc_subscribe").Build()
19120

@@ -26,9 +127,31 @@ func (s *Service) gsocWsHandler(w http.ResponseWriter, r *http.Request) {
26127
return
27128
}
28129

130+
headers := struct {
131+
SocFields string `map:"Swarm-Soc-Fields"`
132+
CacheWrappedChunk bool `map:"Swarm-Cache-Wrapped-Chunk"`
133+
}{}
134+
if response := s.mapStructure(r.Header, &headers); response != nil {
135+
response("invalid header params", logger, w)
136+
return
137+
}
138+
139+
fields, err := parseSocFields(headers.SocFields)
140+
if err != nil {
141+
logger.Debug("invalid soc fields header", "error", err)
142+
logger.Error(nil, "invalid soc fields header")
143+
jsonhttp.BadRequest(w, "invalid soc fields header")
144+
return
145+
}
146+
29147
upgrader := websocket.Upgrader{
30-
ReadBufferSize: swarm.ChunkSize,
31-
WriteBufferSize: swarm.ChunkSize,
148+
ReadBufferSize: swarm.SocMaxChunkSize,
149+
// WriteBufferSize is only an I/O buffer hint; it does not cap the
150+
// message size. The serialized output can be the whole single owner
151+
// chunk plus the derived metadata fields (soc address, recovered public
152+
// key, wrapped chunk address), so size it to that maximum to avoid split
153+
// writes.
154+
WriteBufferSize: maxSocFieldsSize,
32155
CheckOrigin: s.checkOrigin,
33156
}
34157

@@ -41,29 +164,51 @@ func (s *Service) gsocWsHandler(w http.ResponseWriter, r *http.Request) {
41164
}
42165

43166
s.wsWg.Add(1)
44-
go s.gsocListeningWs(conn, paths.Address)
167+
go s.gsocListeningWs(conn, paths.Address, fields, headers.CacheWrappedChunk)
45168
}
46169

47-
func (s *Service) gsocListeningWs(conn *websocket.Conn, socAddress swarm.Address) {
170+
func (s *Service) gsocListeningWs(conn *websocket.Conn, socAddress swarm.Address, fields []string, cacheWrappedChunk bool) {
48171
defer s.wsWg.Done()
49172

50173
var (
51-
dataC = make(chan []byte)
52-
gone = make(chan struct{})
53-
ticker = time.NewTicker(s.WsPingPeriod)
54-
err error
174+
dataC = make(chan []byte, 2) // small buffer to decouple producer/consumer
175+
gone = make(chan struct{})
176+
slow = make(chan struct{})
177+
slowOnce sync.Once
178+
ticker = time.NewTicker(s.WsPingPeriod)
179+
err error
55180
)
56181
defer func() {
57182
ticker.Stop()
58183
_ = conn.Close()
59184
}()
60-
cleanup := s.gsoc.Subscribe(socAddress, func(m []byte) {
185+
cleanup := s.gsoc.Subscribe(socAddress, func(c *soc.SOC) {
186+
if cacheWrappedChunk {
187+
// Caching is a node-local side effect independent of this
188+
// subscriber's connection, so it must not be aborted just
189+
// because the websocket closes mid-write.
190+
if err := s.storer.Cache().Put(context.Background(), c.WrappedChunk()); err != nil {
191+
s.logger.Debug("gsoc ws: cache wrapped chunk failed", "error", err)
192+
}
193+
}
194+
195+
b, err := socFieldsBytes(c, fields)
196+
if err != nil {
197+
s.logger.Warning("gsoc ws: serialize soc fields failed", "error", err)
198+
return
199+
}
200+
61201
select {
62-
case dataC <- m:
202+
case dataC <- b:
63203
case <-gone:
64-
return
204+
case <-slow:
65205
case <-s.quit:
66-
return
206+
default:
207+
// The connection writer is single-threaded in the main loop below;
208+
// only signal it here instead of writing/closing the conn from this
209+
// callback goroutine, which can run concurrently with the writer.
210+
s.logger.Warning("gsoc ws: slow consumer, closing connection")
211+
slowOnce.Do(func() { close(slow) })
67212
}
68213
})
69214

@@ -105,6 +250,16 @@ func (s *Service) gsocListeningWs(conn *websocket.Conn, socAddress swarm.Address
105250
case <-gone:
106251
// client gone
107252
return
253+
case <-slow:
254+
err = conn.SetWriteDeadline(time.Now().Add(writeDeadline))
255+
if err != nil {
256+
s.logger.Debug("gsoc ws: set write deadline failed", "error", err)
257+
return
258+
}
259+
_ = conn.WriteControl(websocket.CloseMessage,
260+
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "slow consumer"),
261+
time.Now().Add(writeDeadline))
262+
return
108263
case <-ticker.C:
109264
err = conn.SetWriteDeadline(time.Now().Add(writeDeadline))
110265
if err != nil {

0 commit comments

Comments
 (0)