master
go 147 lines 4.84 KB
Raw
1 package cli
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "github.com/ipfs/kubo/test/cli/harness"
10 "github.com/stretchr/testify/require"
11 )
12
13 // TestBalancedDAGLayout verifies that kubo uses the "balanced" DAG layout
14 // (all leaves at same depth) rather than "balanced-packed" (varying leaf depths).
15 //
16 // DAG layout differences across implementations:
17 //
18 // - balanced: kubo, helia (all leaves at same depth, uniform traversal distance)
19 // - balanced-packed: singularity (trailing leaves may be at different depths)
20 // - trickle: kubo --trickle (varying depths, optimized for append-only/streaming)
21 //
22 // kubo does not implement balanced-packed. The trickle layout also produces
23 // non-uniform leaf depths but with different trade-offs: trickle is optimized
24 // for append-only and streaming reads (no seeking), while balanced-packed
25 // minimizes node count.
26 //
27 // IPIP-499 documents the balanced vs balanced-packed distinction. Files larger
28 // than dag_width × chunk_size will have different CIDs between implementations
29 // using different layouts.
30 //
31 // Set DAG_LAYOUT_CAR_OUTPUT environment variable to export CAR files.
32 // Example: DAG_LAYOUT_CAR_OUTPUT=/tmp/dag-layout go test -run TestBalancedDAGLayout -v
33 func TestBalancedDAGLayout(t *testing.T) {
34 t.Parallel()
35
36 carOutputDir := os.Getenv("DAG_LAYOUT_CAR_OUTPUT")
37 exportCARs := carOutputDir != ""
38 if exportCARs {
39 if err := os.MkdirAll(carOutputDir, 0755); err != nil {
40 t.Fatalf("failed to create CAR output directory: %v", err)
41 }
42 t.Logf("CAR export enabled, writing to: %s", carOutputDir)
43 }
44
45 t.Run("balanced layout has uniform leaf depth", func(t *testing.T) {
46 t.Parallel()
47 node := harness.NewT(t).NewNode().Init().StartDaemon()
48
49 // Create file that triggers multi-level DAG.
50 // For default v0: 175 chunks × 256KiB = ~44.8 MiB (just over 174 max links)
51 // This creates a 2-level DAG where balanced layout ensures uniform depth.
52 fileSize := "45MiB"
53 seed := "balanced-test"
54
55 cidStr := node.IPFSAddDeterministic(fileSize, seed)
56
57 // Collect leaf depths by walking DAG
58 depths := collectLeafDepths(t, node, cidStr, 0)
59
60 // All leaves must be at same depth for balanced layout
61 require.NotEmpty(t, depths, "expected at least one leaf node")
62 firstDepth := depths[0]
63 for i, d := range depths {
64 require.Equal(t, firstDepth, d,
65 "leaf %d at depth %d, expected %d (balanced layout requires uniform leaf depth)",
66 i, d, firstDepth)
67 }
68 t.Logf("verified %d leaves all at depth %d (CID: %s)", len(depths), firstDepth, cidStr)
69
70 if exportCARs {
71 carPath := filepath.Join(carOutputDir, "balanced_"+fileSize+".car")
72 require.NoError(t, node.IPFSDagExport(cidStr, carPath))
73 t.Logf("exported: %s -> %s", cidStr, carPath)
74 }
75 })
76
77 t.Run("trickle layout has varying leaf depth", func(t *testing.T) {
78 t.Parallel()
79 node := harness.NewT(t).NewNode().Init().StartDaemon()
80
81 fileSize := "45MiB"
82 seed := "trickle-test"
83
84 // Add with trickle layout (--trickle flag).
85 // Trickle produces non-uniform leaf depths, optimized for append-only
86 // and streaming reads (no seeking). This subtest validates the test
87 // logic by confirming we can detect varying depths.
88 cidStr := node.IPFSAddDeterministic(fileSize, seed, "--trickle")
89
90 depths := collectLeafDepths(t, node, cidStr, 0)
91
92 // Trickle layout should have varying depths
93 require.NotEmpty(t, depths, "expected at least one leaf node")
94 minDepth, maxDepth := depths[0], depths[0]
95 for _, d := range depths {
96 if d < minDepth {
97 minDepth = d
98 }
99 if d > maxDepth {
100 maxDepth = d
101 }
102 }
103 require.NotEqual(t, minDepth, maxDepth,
104 "trickle layout should have varying leaf depths, got uniform depth %d", minDepth)
105 t.Logf("verified %d leaves with depths ranging from %d to %d (CID: %s)", len(depths), minDepth, maxDepth, cidStr)
106
107 if exportCARs {
108 carPath := filepath.Join(carOutputDir, "trickle_"+fileSize+".car")
109 require.NoError(t, node.IPFSDagExport(cidStr, carPath))
110 t.Logf("exported: %s -> %s", cidStr, carPath)
111 }
112 })
113 }
114
115 // collectLeafDepths recursively walks DAG and returns depth of each leaf node.
116 // A node is a leaf if it's a raw block or a dag-pb node with no links.
117 func collectLeafDepths(t *testing.T, node *harness.Node, cid string, depth int) []int {
118 t.Helper()
119
120 // Check codec to see if this is a raw leaf
121 res := node.IPFS("cid", "format", "-f", "%c", cid)
122 codec := strings.TrimSpace(res.Stdout.String())
123 if codec == "raw" {
124 // Raw blocks are always leaves
125 return []int{depth}
126 }
127
128 // Try to inspect as dag-pb node
129 pbNode, err := node.InspectPBNode(cid)
130 if err != nil {
131 // Can't parse as dag-pb, treat as leaf
132 return []int{depth}
133 }
134
135 // No links = leaf node
136 if len(pbNode.Links) == 0 {
137 return []int{depth}
138 }
139
140 // Recurse into children
141 var depths []int
142 for _, link := range pbNode.Links {
143 childDepths := collectLeafDepths(t, node, link.Hash.Slash, depth+1)
144 depths = append(depths, childDepths...)
145 }
146 return depths
147 }