Skip to content

Commit 1fcfef4

Browse files
sbackend123martinconicakrem-chabchoubdarkobas2claude
authored
feat: chunks convergence (#5557)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Calin Martinconi <martinconic@gmail.com> Co-authored-by: Akrem Chabchoub <121046693+akrem-chabchoub@users.noreply.github.com> Co-authored-by: Not Darko <93942788+darkobas2@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Ljubiša Gačević <35105035+gacevicljubisa@users.noreply.github.com> Co-authored-by: Janoš Guljaš <janos@users.noreply.github.com> Co-authored-by: acud <12988138+acud@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1 parent 5b10033 commit 1fcfef4

11 files changed

Lines changed: 1909 additions & 160 deletions

File tree

.github/workflows/beekeeper.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ env:
1919
SETUP_CONTRACT_IMAGE: "ethersphere/bee-localchain"
2020
SETUP_CONTRACT_IMAGE_TAG: "0.9.4"
2121
BEELOCAL_BRANCH: "main"
22-
BEEKEEPER_BRANCH: "master"
22+
BEEKEEPER_BRANCH: "feat/pullsync-chuns-convergence"
2323
BEEKEEPER_METRICS_ENABLED: false
2424
REACHABILITY_OVERRIDE_PUBLIC: true
2525
BATCHFACTOR_OVERRIDE_PUBLIC: 2
@@ -151,12 +151,16 @@ jobs:
151151
- name: Test gsoc
152152
id: gsoc
153153
run: timeout ${TIMEOUT} beekeeper check --cluster-name local-dns --checks=ci-gsoc
154+
- name: Test socmatrix
155+
id: socmatrix
156+
# Check allows up to 60m; wall clock is typically ~20–30m (16 scenarios + sync-wait).
157+
run: timeout 60m beekeeper check --cluster-name local-dns --checks=ci-socmatrix
154158
- name: Test pushsync (chunks)
155159
id: pushsync-chunks-1
156-
run: timeout ${TIMEOUT} beekeeper check --cluster-name local-dns --checks=ci-pushsync-chunks
160+
run: timeout ${TIMEOUT} bash -c 'until beekeeper check --cluster-name local-dns --checks=ci-pushsync-chunks; do echo "waiting for pushsync-chunks..."; sleep .3; done'
157161
- name: Test pushsync (light mode chunks)
158162
id: pushsync-chunks-2
159-
run: timeout ${TIMEOUT} beekeeper check --cluster-name local-dns --checks=ci-pushsync-light-chunks
163+
run: timeout ${TIMEOUT} bash -c 'until beekeeper check --cluster-name local-dns --checks=ci-pushsync-light-chunks; do echo "waiting for pushsync-light-chunks..."; sleep .3; done'
160164
- name: Test retrieval
161165
id: retrieval
162166
run: timeout ${TIMEOUT} beekeeper check --cluster-name local-dns --checks=ci-retrieval
@@ -198,6 +202,7 @@ jobs:
198202
if ${{ steps.pss.outcome=='failure' }}; then FAILED=pss; fi
199203
if ${{ steps.soc.outcome=='failure' }}; then FAILED=soc; fi
200204
if ${{ steps.gsoc.outcome=='failure' }}; then FAILED=gsoc; fi
205+
if ${{ steps.socmatrix.outcome=='failure' }}; then FAILED=socmatrix; fi
201206
if ${{ steps.pushsync-chunks-1.outcome=='failure' }}; then FAILED=pushsync-chunks-1; fi
202207
if ${{ steps.pushsync-chunks-2.outcome=='failure' }}; then FAILED=pushsync-chunks-2; fi
203208
if ${{ steps.retrieval.outcome=='failure' }}; then FAILED=retrieval; fi

pkg/pullsync/metrics.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type metrics struct {
1515
MissingChunks prometheus.Counter // number of reserve get errs
1616
ReceivedZeroAddress prometheus.Counter // number of delivered chunks with invalid address
1717
ReceivedInvalidChunk prometheus.Counter // number of delivered chunks with invalid address
18+
DivergentRejected prometheus.Counter // number of delivered chunks that lost the divergence tie-break
1819
Delivered prometheus.Counter // number of chunk deliveries
1920
SentOffered prometheus.Counter // number of chunks offered
2021
SentWanted prometheus.Counter // number of chunks wanted
@@ -57,6 +58,12 @@ func newMetrics() metrics {
5758
Name: "received_invalid_chunks",
5859
Help: "Total invalid chunks delivered.",
5960
}),
61+
DivergentRejected: prometheus.NewCounter(prometheus.CounterOpts{
62+
Namespace: m.Namespace,
63+
Subsystem: subsystem,
64+
Name: "divergent_rejected",
65+
Help: "Total delivered chunks discarded for losing the divergence tie-break.",
66+
}),
6067
Delivered: prometheus.NewCounter(prometheus.CounterOpts{
6168
Namespace: m.Namespace,
6269
Subsystem: subsystem,

pkg/pullsync/pullsync.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,13 @@ func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start
394394
chunkErr = errors.Join(chunkErr, err)
395395
continue
396396
}
397+
// the chunk diverged from the one already stored and lost the
398+
// tie-break. The neighborhood converges on the stored chunk, so
399+
// this is an expected outcome rather than a sync error.
400+
if errors.Is(err, storage.ErrDivergentChunkRejected) {
401+
s.metrics.DivergentRejected.Inc()
402+
continue
403+
}
397404
return 0, 0, errors.Join(chunkErr, err)
398405
}
399406
chunksPut++
@@ -457,7 +464,7 @@ func (s *Syncer) collectAddrs(ctx context.Context, bin uint8, start uint64) ([]*
457464
break LOOP // The stream has been closed.
458465
}
459466

460-
chs = append(chs, &storer.BinC{Address: c.Address, BatchID: c.BatchID, StampHash: c.StampHash, Sum: c.Sum})
467+
chs = append(chs, &storer.BinC{Address: c.Address, BinID: c.BinID, BatchID: c.BatchID, StampHash: c.StampHash, Sum: c.Sum})
461468
if c.BinID > topmost {
462469
topmost = c.BinID
463470
}

pkg/storage/storage.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package storage
66

77
import (
8+
"bytes"
89
"context"
910
"errors"
1011
"fmt"
@@ -19,6 +20,12 @@ import (
1920
var (
2021
ErrOverwriteNewerChunk = errors.New("overwriting chunk with newer timestamp")
2122
ErrUnknownChunkType = errors.New("unknown chunk type")
23+
24+
// ErrDivergentChunkRejected is returned when a chunk that diverges from an
25+
// already stored one at the same address, batch and stamp loses the
26+
// deterministic tie-break and is therefore not stored. It is not a failure:
27+
// the node already holds the chunk the whole neighborhood converges on.
28+
ErrDivergentChunkRejected = errors.New("divergent chunk rejected by tie-break")
2229
)
2330

2431
// Result represents the item returned by the read operation, which returns
@@ -336,6 +343,39 @@ func ChunkSumFromParts(batchID, stampHash []byte, ch swarm.Chunk) ([]byte, error
336343
return h.Sum(nil)[:ChunkSumSize], nil
337344
}
338345

346+
// DivergentSocChunkWins reports whether the incoming chunk should replace the
347+
// stored one when the two share an address, batch and stamp but wrap different
348+
// content. Both chunks must be single owner chunks; a content addressed chunk
349+
// cannot diverge, since its address is the hash of its own payload.
350+
//
351+
// The winner is the chunk wrapping the lexicographically lower CAC address.
352+
// The rule depends on nothing but the two payloads, so every node in the
353+
// neighborhood converges on the same chunk regardless of the order in which
354+
// they arrive.
355+
func DivergentSocChunkWins(stored, incoming swarm.Chunk) (bool, error) {
356+
storedAddr, err := wrappedAddress(stored)
357+
if err != nil {
358+
return false, fmt.Errorf("stored chunk: %w", err)
359+
}
360+
incomingAddr, err := wrappedAddress(incoming)
361+
if err != nil {
362+
return false, fmt.Errorf("incoming chunk: %w", err)
363+
}
364+
return bytes.Compare(incomingAddr.Bytes(), storedAddr.Bytes()) < 0, nil
365+
}
366+
367+
// wrappedAddress returns the address of the CAC wrapped by a single owner chunk.
368+
func wrappedAddress(ch swarm.Chunk) (swarm.Address, error) {
369+
if !soc.Valid(ch) {
370+
return swarm.ZeroAddress, fmt.Errorf("%w: not a single owner chunk", ErrUnknownChunkType)
371+
}
372+
s, err := soc.FromChunk(ch)
373+
if err != nil {
374+
return swarm.ZeroAddress, fmt.Errorf("soc from chunk: %w", err)
375+
}
376+
return s.WrappedChunk().Address(), nil
377+
}
378+
339379
// IdentityAddress returns the internally used address for the chunk
340380
// since the single owner chunk address is not a unique identifier for the chunk,
341381
// but hashing the soc address and the wrapped chunk address is.

pkg/storage/storage_test.go

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package storage_test
66
import (
77
"bytes"
88
"encoding/hex"
9+
"errors"
910
"testing"
1011

1112
"github.com/ethereum/go-ethereum/common"
@@ -69,7 +70,7 @@ func TestIdentityAddress(t *testing.T) {
6970
data := []byte("data")
7071
cacChunk, err := cac.New(data)
7172
if err != nil {
72-
t.Fatalf("failed to create content addressed chunk: %v", err)
73+
t.Fatalf("create content addressed chunk: %v", err)
7374
}
7475

7576
// Call IdentityAddress with the CAC
@@ -234,3 +235,88 @@ func FuzzChunkSum(f *testing.F) {
234235
}
235236
})
236237
}
238+
239+
func TestDivergentSocChunkWins(t *testing.T) {
240+
t.Parallel()
241+
242+
privKey, err := crypto.GenerateSecp256k1Key()
243+
if err != nil {
244+
t.Fatal(err)
245+
}
246+
signer := crypto.NewDefaultSigner(privKey)
247+
id := make([]byte, swarm.HashSize)
248+
249+
newSOC := func(data string) swarm.Chunk {
250+
t.Helper()
251+
inner, err := cac.New([]byte(data))
252+
if err != nil {
253+
t.Fatal(err)
254+
}
255+
ch, err := soc.New(id, inner).Sign(signer)
256+
if err != nil {
257+
t.Fatal(err)
258+
}
259+
return ch.WithStamp(postagetesting.MustNewStamp())
260+
}
261+
262+
lower, higher := newSOC("content-one"), newSOC("content-two")
263+
lowerInner, err := soc.UnwrapCAC(lower)
264+
if err != nil {
265+
t.Fatal(err)
266+
}
267+
higherInner, err := soc.UnwrapCAC(higher)
268+
if err != nil {
269+
t.Fatal(err)
270+
}
271+
if bytes.Compare(lowerInner.Address().Bytes(), higherInner.Address().Bytes()) > 0 {
272+
lower, higher = higher, lower
273+
}
274+
275+
t.Run("lower wrapped address wins", func(t *testing.T) {
276+
t.Parallel()
277+
278+
wins, err := storage.DivergentSocChunkWins(higher, lower)
279+
if err != nil {
280+
t.Fatal(err)
281+
}
282+
if !wins {
283+
t.Fatal("expected the chunk wrapping the lower cac address to win")
284+
}
285+
})
286+
287+
t.Run("tie-break is antisymmetric", func(t *testing.T) {
288+
t.Parallel()
289+
290+
wins, err := storage.DivergentSocChunkWins(lower, higher)
291+
if err != nil {
292+
t.Fatal(err)
293+
}
294+
if wins {
295+
t.Fatal("expected the chunk wrapping the higher cac address to lose")
296+
}
297+
})
298+
299+
t.Run("a chunk does not displace itself", func(t *testing.T) {
300+
t.Parallel()
301+
302+
wins, err := storage.DivergentSocChunkWins(lower, lower)
303+
if err != nil {
304+
t.Fatal(err)
305+
}
306+
if wins {
307+
t.Fatal("expected an identical chunk not to win")
308+
}
309+
})
310+
311+
t.Run("content addressed chunks cannot diverge", func(t *testing.T) {
312+
t.Parallel()
313+
314+
cac := testingc.GenerateTestRandomChunk()
315+
if _, err := storage.DivergentSocChunkWins(cac, lower); !errors.Is(err, storage.ErrUnknownChunkType) {
316+
t.Fatalf("expected ErrUnknownChunkType, got %v", err)
317+
}
318+
if _, err := storage.DivergentSocChunkWins(lower, cac); !errors.Is(err, storage.ErrUnknownChunkType) {
319+
t.Fatalf("expected ErrUnknownChunkType, got %v", err)
320+
}
321+
})
322+
}

0 commit comments

Comments
 (0)