Skip to content

Commit 72f0200

Browse files
committed
Merge remote-tracking branch 'origin/feat/pullsync-soc-convergence' into feat/pullsync-soc-convergence
2 parents a21f9b0 + 91e51c8 commit 72f0200

1 file changed

Lines changed: 363 additions & 0 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
// Copyright 2026 The Swarm Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package reserve_test
6+
7+
import (
8+
"bytes"
9+
"context"
10+
"errors"
11+
"fmt"
12+
"os"
13+
"sort"
14+
"strings"
15+
"testing"
16+
17+
"github.com/ethersphere/bee/v2/pkg/cac"
18+
"github.com/ethersphere/bee/v2/pkg/crypto"
19+
"github.com/ethersphere/bee/v2/pkg/log"
20+
postagetesting "github.com/ethersphere/bee/v2/pkg/postage/testing"
21+
"github.com/ethersphere/bee/v2/pkg/soc"
22+
"github.com/ethersphere/bee/v2/pkg/storage"
23+
"github.com/ethersphere/bee/v2/pkg/storer/internal"
24+
"github.com/ethersphere/bee/v2/pkg/storer/internal/reserve"
25+
"github.com/ethersphere/bee/v2/pkg/storer/internal/transaction"
26+
"github.com/ethersphere/bee/v2/pkg/swarm"
27+
kademlia "github.com/ethersphere/bee/v2/pkg/topology/mock"
28+
)
29+
30+
// This file is a reusable conflict-testing harness for reserve.Put.
31+
//
32+
// The property under test is ARRIVAL-ORDER CONVERGENCE: for any set of
33+
// conflicting chunks, every permutation of arrivals must leave the reserve in
34+
// the same final state. Different nodes receive the same chunks in different
35+
// orders; any order-dependent outcome means neighborhoods that can never
36+
// agree, which breaks the redistribution game. The fingerprint deliberately
37+
// ignores bin IDs (they are order-dependent by design) and compares which
38+
// entries exist and which content each entry serves. Index integrity (the
39+
// chunk sum index in lockstep with the chunk bin index and the chunkstore
40+
// payloads) is asserted on every permutation as a side effect.
41+
//
42+
// To reuse against a reserve.Put refactor: keep the corner-case table, run
43+
// `go test ./pkg/storer/internal/reserve/ -run TestPutOrderConvergence -v`.
44+
// Cases documenting currently unresolved outcomes are marked in the table.
45+
46+
// benignPutErr reports errors that are legitimate per-chunk outcomes of Put
47+
// rather than failures: losing a tie-break or carrying an older timestamp.
48+
func benignPutErr(err error) bool {
49+
return errors.Is(err, storage.ErrOverwriteNewerChunk) ||
50+
errors.Is(err, storage.ErrDivergentChunkRejected)
51+
}
52+
53+
// reserveFingerprint canonicalizes the reserve state: one line per reserve
54+
// entry (batch, bin, address, stamp hash and the keccak of the payload it
55+
// currently serves) plus the full chunk sum index. While fingerprinting it
56+
// asserts the cross-index invariants.
57+
func reserveFingerprint(t *testing.T, st transaction.Storage) string {
58+
t.Helper()
59+
ctx := context.Background()
60+
61+
var lines []string
62+
63+
err := st.IndexStore().Iterate(
64+
storage.Query{Factory: func() storage.Item { return &reserve.BatchRadiusItem{} }},
65+
func(res storage.Result) (bool, error) {
66+
item := res.Entry.(*reserve.BatchRadiusItem)
67+
ch, err := st.ChunkStore().Get(ctx, item.Address)
68+
if err != nil {
69+
return false, fmt.Errorf("entry %s has no payload: %w", item.Address, err)
70+
}
71+
h := swarm.NewHasher()
72+
_, _ = h.Write(ch.Data())
73+
lines = append(lines, fmt.Sprintf("entry batch=%x addr=%x stamphash=%x content=%x",
74+
item.BatchID[:8], item.Address.Bytes()[:8], item.StampHash[:8], h.Sum(nil)[:8]))
75+
return false, nil
76+
},
77+
)
78+
if err != nil {
79+
t.Fatal(err)
80+
}
81+
82+
// chunk bin index: sums must match the payload actually served
83+
binSums := make(map[string]struct{})
84+
err = st.IndexStore().Iterate(
85+
storage.Query{Factory: func() storage.Item { return &reserve.ChunkBinItem{} }},
86+
func(res storage.Result) (bool, error) {
87+
cbi := res.Entry.(*reserve.ChunkBinItem)
88+
ch, err := st.ChunkStore().Get(ctx, cbi.Address)
89+
if err != nil {
90+
return false, fmt.Errorf("bin entry %s has no payload: %w", cbi.Address, err)
91+
}
92+
want, err := storage.ChunkSumFromParts(cbi.BatchID, cbi.StampHash, ch)
93+
if err != nil {
94+
return false, err
95+
}
96+
if !bytes.Equal(cbi.Sum, want) {
97+
return false, fmt.Errorf("stale sum on entry %s", cbi.Address)
98+
}
99+
binSums[cbi.Address.ByteString()+string(cbi.Sum)] = struct{}{}
100+
lines = append(lines, fmt.Sprintf("sum addr=%x sum=%x", cbi.Address.Bytes()[:8], cbi.Sum[:8]))
101+
return false, nil
102+
},
103+
)
104+
if err != nil {
105+
t.Fatal(err)
106+
}
107+
108+
// chunk sum index: exactly the live (address, sum) set
109+
err = st.IndexStore().Iterate(
110+
storage.Query{
111+
Factory: func() storage.Item { return &reserve.ChunkSumItem{} },
112+
ItemProperty: storage.QueryItemID,
113+
},
114+
func(res storage.Result) (bool, error) {
115+
if _, ok := binSums[res.ID]; !ok {
116+
return false, errors.New("orphaned chunk sum entry")
117+
}
118+
delete(binSums, res.ID)
119+
return false, nil
120+
},
121+
)
122+
if err != nil {
123+
t.Fatal(err)
124+
}
125+
if len(binSums) != 0 {
126+
t.Fatal("live entries missing from the chunk sum index")
127+
}
128+
129+
sort.Strings(lines)
130+
return strings.Join(lines, "\n")
131+
}
132+
133+
func permutations(n int) [][]int {
134+
if n == 1 {
135+
return [][]int{{0}}
136+
}
137+
var out [][]int
138+
for _, sub := range permutations(n - 1) {
139+
for pos := 0; pos <= len(sub); pos++ {
140+
p := make([]int, 0, n)
141+
p = append(p, sub[:pos]...)
142+
p = append(p, n-1)
143+
p = append(p, sub[pos:]...)
144+
out = append(out, p)
145+
}
146+
}
147+
return out
148+
}
149+
150+
// runOrder applies the chunks to a fresh reserve in the given order and
151+
// returns the state fingerprint.
152+
func runOrder(t *testing.T, chunks []swarm.Chunk, order []int) string {
153+
t.Helper()
154+
155+
baseAddr := swarm.NewAddress(make([]byte, swarm.HashSize)) // fixed base: bins irrelevant here
156+
st := internal.NewInmemStorage()
157+
r, err := reserve.New(baseAddr, st, 0, kademlia.NewTopologyDriver(), log.Noop)
158+
if err != nil {
159+
t.Fatal(err)
160+
}
161+
for _, i := range order {
162+
if err := r.Put(context.Background(), chunks[i]); err != nil && !benignPutErr(err) {
163+
t.Fatalf("order %v chunk %d: %v", order, i, err)
164+
}
165+
}
166+
return reserveFingerprint(t, st)
167+
}
168+
169+
// assertOrderConvergence checks every permutation. For cases marked
170+
// unresolved the divergence is reported without failing, so the harness
171+
// documents the open holes while keeping the suite green; set
172+
// RESERVE_STRICT_CONVERGENCE=1 to turn them into failures (useful while
173+
// refactoring reserve.Put toward full order independence). An unresolved case
174+
// that starts converging fails loudly so the marker gets removed.
175+
func assertOrderConvergence(t *testing.T, chunks []swarm.Chunk, unresolved bool) {
176+
t.Helper()
177+
178+
strict := os.Getenv("RESERVE_STRICT_CONVERGENCE") != ""
179+
perms := permutations(len(chunks))
180+
first := runOrder(t, chunks, perms[0])
181+
for _, p := range perms[1:] {
182+
fp := runOrder(t, chunks, p)
183+
if fp != first {
184+
msg := fmt.Sprintf("order-dependent outcome:\norder %v ends with:\n%s\n\norder %v ends with:\n%s",
185+
perms[0], first, p, fp)
186+
if unresolved && !strict {
187+
t.Logf("KNOWN UNRESOLVED (not failing, set RESERVE_STRICT_CONVERGENCE=1 to enforce):\n%s", msg)
188+
} else {
189+
t.Error(msg)
190+
}
191+
return
192+
}
193+
}
194+
if unresolved {
195+
t.Error("case marked unresolved now converges: remove the unresolved marker")
196+
}
197+
}
198+
199+
func newTestSOC(t *testing.T, signer crypto.Signer, id, payload []byte) swarm.Chunk {
200+
t.Helper()
201+
inner, err := cac.New(payload)
202+
if err != nil {
203+
t.Fatal(err)
204+
}
205+
ch, err := soc.New(id, inner).Sign(signer)
206+
if err != nil {
207+
t.Fatal(err)
208+
}
209+
return ch
210+
}
211+
212+
func newTestCAC(t *testing.T, payload []byte) swarm.Chunk {
213+
t.Helper()
214+
ch, err := cac.New(payload)
215+
if err != nil {
216+
t.Fatal(err)
217+
}
218+
return ch
219+
}
220+
221+
// TestPutOrderConvergence drives conflicting chunk sets through every arrival
222+
// order and requires an identical final state.
223+
func TestPutOrderConvergence(t *testing.T) {
224+
t.Parallel()
225+
226+
signer := getSigner(t)
227+
batchA := postagetesting.MustNewBatch()
228+
batchB := postagetesting.MustNewBatch()
229+
id1 := make([]byte, swarm.HashSize)
230+
id2 := bytes.Repeat([]byte{1}, swarm.HashSize)
231+
232+
for _, tc := range []struct {
233+
name string
234+
unresolved bool
235+
chunks func(t *testing.T) []swarm.Chunk
236+
}{
237+
{
238+
// Sofia's rule 4 core case: the tie-break must make this converge.
239+
name: "cac vs cac, same slot, equal timestamp",
240+
chunks: func(t *testing.T) []swarm.Chunk {
241+
t.Helper()
242+
return []swarm.Chunk{
243+
newTestCAC(t, []byte("cac payload one")).WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 7)),
244+
newTestCAC(t, []byte("cac payload two")).WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 7)),
245+
}
246+
},
247+
},
248+
{
249+
// Phase 2 core case: same SOC address, byte-identical stamp,
250+
// different payloads; resolveDivergence must make this converge.
251+
name: "divergent socs, identical stamp",
252+
chunks: func(t *testing.T) []swarm.Chunk {
253+
t.Helper()
254+
stamp := postagetesting.MustNewFields(batchA.ID, 0, 7)
255+
return []swarm.Chunk{
256+
newTestSOC(t, signer, id1, []byte("soc payload one")).WithStamp(stamp),
257+
newTestSOC(t, signer, id1, []byte("soc payload two")).WithStamp(stamp),
258+
}
259+
},
260+
},
261+
{
262+
// Newer timestamps must win regardless of order or type.
263+
name: "soc update, increasing timestamps",
264+
chunks: func(t *testing.T) []swarm.Chunk {
265+
t.Helper()
266+
return []swarm.Chunk{
267+
newTestSOC(t, signer, id1, []byte("soc v1")).WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 1)),
268+
newTestSOC(t, signer, id1, []byte("soc v2")).WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 2)),
269+
}
270+
},
271+
},
272+
{
273+
// UNRESOLVED on this branch: same SOC address, same slot and
274+
// timestamp, but separately stamped (distinct signatures, hence
275+
// distinct stamp hashes). Bypasses resolveDivergence (stamp hashes
276+
// differ) and the CAC tie-break (wrong type): last write wins.
277+
name: "divergent socs, equal timestamp, distinct stamps",
278+
unresolved: true,
279+
chunks: func(t *testing.T) []swarm.Chunk {
280+
t.Helper()
281+
return []swarm.Chunk{
282+
newTestSOC(t, signer, id1, []byte("soc payload one")).WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 7)),
283+
newTestSOC(t, signer, id1, []byte("soc payload two")).WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 7)),
284+
}
285+
},
286+
},
287+
{
288+
// UNRESOLVED on this branch: different SOC addresses in the same
289+
// slot at the same timestamp. Not content addressed, so the
290+
// tie-break is skipped: last write wins.
291+
name: "soc vs soc, different addresses, same slot, equal timestamp",
292+
unresolved: true,
293+
chunks: func(t *testing.T) []swarm.Chunk {
294+
t.Helper()
295+
return []swarm.Chunk{
296+
newTestSOC(t, signer, id1, []byte("soc payload one")).WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 7)),
297+
newTestSOC(t, signer, id2, []byte("soc payload two")).WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 7)),
298+
}
299+
},
300+
},
301+
{
302+
// UNRESOLVED on this branch: mixed types in the same slot at the
303+
// same timestamp. The tie-break fires only when the INCOMING chunk
304+
// is content addressed, so the two directions disagree whenever
305+
// the CAC has the lower address.
306+
name: "cac vs soc, same slot, equal timestamp, cac address lower",
307+
unresolved: true,
308+
chunks: func(t *testing.T) []swarm.Chunk {
309+
t.Helper()
310+
socCh := newTestSOC(t, signer, id1, []byte("soc payload"))
311+
// search a payload whose CAC address sorts below the SOC's
312+
for i := range 64 {
313+
cacCh := newTestCAC(t, fmt.Appendf(nil, "cac payload %d", i))
314+
if bytes.Compare(cacCh.Address().Bytes(), socCh.Address().Bytes()) < 0 {
315+
return []swarm.Chunk{
316+
socCh.WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 7)),
317+
cacCh.WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 7)),
318+
}
319+
}
320+
}
321+
t.Fatal("no lower cac address found")
322+
return nil
323+
},
324+
},
325+
{
326+
// UNRESOLVED on this branch: byte-identical CAC re-stamped in the
327+
// same slot at the same timestamp with a different signature. The
328+
// entries swap stamp hashes depending on order, so peers holding
329+
// different stampings keep exchanging and replacing forever.
330+
name: "identical cac, same slot, equal timestamp, distinct stamps",
331+
unresolved: true,
332+
chunks: func(t *testing.T) []swarm.Chunk {
333+
t.Helper()
334+
payload := []byte("identical cac payload")
335+
return []swarm.Chunk{
336+
newTestCAC(t, payload).WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 7)),
337+
newTestCAC(t, payload).WithStamp(postagetesting.MustNewFields(batchA.ID, 0, 7)),
338+
}
339+
},
340+
},
341+
{
342+
// Three-way conflict across batches: the batch B entry must end
343+
// serving whatever payload the batch A conflict settles on, with
344+
// its sum refreshed accordingly.
345+
name: "divergent socs with a sibling entry under another batch",
346+
unresolved: true,
347+
chunks: func(t *testing.T) []swarm.Chunk {
348+
t.Helper()
349+
stamp := postagetesting.MustNewFields(batchA.ID, 0, 7)
350+
return []swarm.Chunk{
351+
newTestSOC(t, signer, id1, []byte("soc payload one")).WithStamp(stamp),
352+
newTestSOC(t, signer, id1, []byte("soc payload two")).WithStamp(stamp),
353+
newTestSOC(t, signer, id1, []byte("soc payload one")).WithStamp(postagetesting.MustNewFields(batchB.ID, 0, 7)),
354+
}
355+
},
356+
},
357+
} {
358+
t.Run(tc.name, func(t *testing.T) {
359+
t.Parallel()
360+
assertOrderConvergence(t, tc.chunks(t), tc.unresolved)
361+
})
362+
}
363+
}

0 commit comments

Comments
 (0)