From baf3af21e2cdd2f25ccfcfe9831bcb6d03832919 Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Tue, 18 Aug 2026 14:08:31 +0200 Subject: [PATCH 01/11] feat: add storage radius check functionality --- pkg/check/storageradius/storageradius.go | 815 +++++++++++++++++++++++ 1 file changed, 815 insertions(+) create mode 100644 pkg/check/storageradius/storageradius.go diff --git a/pkg/check/storageradius/storageradius.go b/pkg/check/storageradius/storageradius.go new file mode 100644 index 00000000..be2ea522 --- /dev/null +++ b/pkg/check/storageradius/storageradius.go @@ -0,0 +1,815 @@ +package storageradius + +import ( + "context" + crand "crypto/rand" + "errors" + "fmt" + "math" + "sync" + "time" + + "github.com/ethersphere/beekeeper/pkg/bee" + "github.com/ethersphere/beekeeper/pkg/bee/api" + "github.com/ethersphere/beekeeper/pkg/beekeeper" + "github.com/ethersphere/beekeeper/pkg/logging" + "github.com/ethersphere/beekeeper/pkg/orchestration" + "github.com/ethersphere/beekeeper/pkg/random" + "golang.org/x/sync/errgroup" +) + +const ( + // stablePollsBeforeGivingUp is how many consecutive unchanged reserve totals, mean the pushers have finished delivering. + stablePollsBeforeGivingUp = 5 +) + +type Options struct { + PollInterval time.Duration // wait between status polls + WaitForWarmup bool // wait for nodes to finish warming up + Seed int64 // seed for randomization + TargetFillPercent float64 // fraction of capacity to fill; above 1 overshoots + ChunksPerUpload int // chunks per /bytes request + ReserveCapacity int // per-node reserve capacity in chunks, for now we have patched bee docker img that contains 4000 chunks + PostageDepth uint64 // batch depth; must exceed bee's bucket depth of 16 + PostageAmount int64 // batch amount; must clear the minimum validity floor + PostageLabel string // batch label prefix + MinRadiusWait time.Duration // minimum time to watch for a radius increase + PushersIdleWait time.Duration // cap on waiting for the pusher backlog before diluting + DiluteDepth uint64 // depth to dilute batches to; must exceed PostageDepth + DiluteWait time.Duration // how long to wait for the radius to come back down + UploadWavePause time.Duration // pause between upload waves so the watcher can keep up + UploadTimeout time.Duration // timeout for each upload request + +} + +func NewDefaultOptions() Options { + return Options{ + PollInterval: 2 * time.Second, + WaitForWarmup: true, + Seed: 0, + TargetFillPercent: 1.2, + ChunksPerUpload: 512, + ReserveCapacity: 4000, + PostageDepth: 22, + PostageAmount: 2073600000, + PostageLabel: "storage-radius-check", + MinRadiusWait: 5 * time.Minute, + PushersIdleWait: 2 * time.Minute, + DiluteDepth: 32, + DiluteWait: 20 * time.Minute, + UploadWavePause: 5 * time.Second, + UploadTimeout: 5 * time.Minute, + } +} + +var _ beekeeper.Action = (*Check)(nil) + +type Check struct { + logger logging.Logger +} + +func NewCheck(logger logging.Logger) beekeeper.Action { + return &Check{logger: logger} +} + +func (c *Check) Run(ctx context.Context, cluster orchestration.Cluster, opts any) error { + o, ok := opts.(Options) + if !ok { + return errors.New("invalid options type") + } + + startedAt := time.Now() + + if o.WaitForWarmup { + if err := c.waitForWarmup(ctx, cluster, o); err != nil { + return fmt.Errorf("wait for warmup: %w", err) + } + } + + fullNodes, err := cluster.ShuffledFullNodeClients(ctx, random.PseudoGenerator(o.Seed)) + if err != nil { + return fmt.Errorf("get shuffled full node clients: %w", err) + } + if len(fullNodes) == 0 { + return errors.New("no full nodes available, fill-reserve requires at least one full node") + } + + status, err := fullNodes[0].Status(ctx) + if err != nil { + return fmt.Errorf("status: %w", err) + } + startStorageRadius := status.StorageRadius + + // TODO:Log chain state + + uploadPlan := newUploadPlan(startStorageRadius, o) + batchCount := min(o.ChunksPerUpload, len(fullNodes)) + c.logger.Infof("cluster: %d full nodes at storage radius %d => %.0f neighborhood(s)", + len(fullNodes), startStorageRadius, uploadPlan.neighborhoods) + c.logger.Infof("target %d chunks (%.0f%% of %d per neighborhood), %d chunks per upload", + uploadPlan.totalChunks, o.TargetFillPercent*100, o.ReserveCapacity, uploadPlan.chunksPerUpload) + + // This is needed for the case if there are some nodes that have some reserved chunks already, we need to get the cluster state before we start uploading chunks + // can we change name of fun to getClusterState ? and we may log if we want ? + chunksBefore, err := c.logClusterState(ctx, cluster, "before") + if err != nil { + return err + } + + batches, err := c.prepareBatches(ctx, fullNodes, batchCount, o) + if err != nil { + return err + } + + // A cluster filled by an earlier run can already hold, or have queued, more than this run needs. Uploading then adds chunks that are evicted on arrival, so skip straight to waiting for bee to react to what is already there. + uploadedChunks := 0 + if pending, enough := c.pipelineAlreadyFull(ctx, batches, uploadPlan, o); enough { + c.logger.Infof("pipeline already holds %d chunks, more than the %d needed, skipping uploads", + pending, uploadPlan.chunksNeeded(o)) + } else { + uploadedChunks, err = c.upload(ctx, batches, uploadPlan, o) + if err != nil { + return err + } + } + + storageRadius, err := c.waitForStorageRadiusIncrease(ctx, fullNodes, o) + if err != nil { + return err + } + + chunksAfter, err := c.logClusterState(ctx, cluster, "after") + if err != nil { + return err + } + + c.logger.Infof("uploaded %d chunks in %s, cluster reserves hold %d", + uploadedChunks, time.Since(startedAt).Round(time.Second), chunksAfter) + + if storageRadius == 0 { + return c.radiusUnchangedError(chunksBefore, chunksAfter, uploadedChunks, o) + } + c.logger.Infof("storage radius is %d (started at %d)", storageRadius, startStorageRadius) + + // Chunks still arriving could race the dilution, but only if the reserves have + // room left to accept them. Once every reserve is at capacity the backlog is + // surplus that bee evicts on arrival, so waiting for it to drain is pure delay. + if c.reservesIsAtCapacity(ctx, fullNodes, o) { + c.logger.Infof("reserves are at capacity, remaining backlog will be evicted on arrival, diluting now") + } else if err := c.waitForPushersIdle(ctx, fullNodes, o); err != nil { + return err + } + if err := c.dilute(ctx, fullNodes, batches, storageRadius, o); err != nil { + return err + } + + c.logger.Infof("storage-radius check finished in %s", time.Since(startedAt).Round(time.Second)) + + return nil +} + +func (c *Check) waitForWarmup(ctx context.Context, cluster orchestration.Cluster, o Options) error { + ticker := time.NewTicker(o.PollInterval) + defer ticker.Stop() + + for { + clients, err := cluster.NodesClients(ctx) + if err != nil { + return fmt.Errorf("get nodes clients: %w", err) + } + + warmingUp := 0 + for name, client := range clients { + status, err := client.Status(ctx) + if err != nil { + return fmt.Errorf("node %s: status: %w", name, err) + } + if status.IsWarmingUp { + warmingUp++ + } + } + + if warmingUp == 0 { + c.logger.Infof("all %d nodes finished warming up", len(clients)) + return nil + } + c.logger.Infof("waiting for %d/%d nodes to finish warming up", warmingUp, len(clients)) + + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for nodes to finish warming up: %w", ctx.Err()) + case <-ticker.C: + } + } +} + +// uploadPlan is the computed upload sizing for a cluster. +type uploadPlan struct { + neighborhoods float64 + totalChunks int + chunksPerUpload int +} + +// newUploadPlan sizes the upload to the neighborhoods the cluster spans +// (2^radius), not the node count: at radius 0 the whole cluster is one +// neighborhood, so roughly `capacity` chunks fill every node at once. +// TODO: consider the case where the cluster contains multiple neighborhoods +func newUploadPlan(radius uint8, o Options) uploadPlan { + neighborhoods := math.Pow(2, float64(radius)) + totalChunks := int(o.TargetFillPercent * float64(o.ReserveCapacity) * neighborhoods) + + return uploadPlan{ + neighborhoods: neighborhoods, + totalChunks: totalChunks, + chunksPerUpload: min(o.ChunksPerUpload, totalChunks), + } +} + +// logClusterState reports each node's radius and reserve size, returning the +// cluster-wide chunk total. +func (c *Check) logClusterState(ctx context.Context, cluster orchestration.Cluster, label string) (int, error) { + clients, err := cluster.NodesClients(ctx) + if err != nil { + return 0, fmt.Errorf("get nodes clients: %w", err) + } + + reserveTotal := 0 + c.logger.Infof("cluster state (%s):", label) + for name, client := range clients { + status, err := client.Status(ctx) + if err != nil { + c.logger.Infof(" %s: status unavailable: %v", name, err) + continue + } + reserveTotal += int(status.ReserveSize) + c.logger.Infof(" %s: radius %d, reserve %d (within radius %d), committed depth %d", + name, status.StorageRadius, status.ReserveSize, status.ReserveSizeWithinRadius, status.CommittedDepth) + } + + return reserveTotal, nil +} + +// chunksNeeded is how many chunks must reach a single reserve to fill it. +func (p uploadPlan) chunksNeeded(options Options) int { + return int(float64(options.ReserveCapacity) * options.TargetFillPercent) +} + +// nodeBatch pairs a postage batch with the node that owns it. +type nodeBatch struct { + batchID string + node *bee.Client +} + +// prepareBatches gets one usable batch per node, reusing an existing one where +// possible and buying the rest in parallel. +// +// A node that cannot provide a batch is skipped rather than failing the check, +// since buying can revert on-chain. This deliberately uses a WaitGroup, not an +// errgroup: an errgroup would cancel its siblings on the first error, and because +// CreatePostageBatch polls with a sleep loop that ignores cancellation, those +// siblings would keep polling for batches that were never created. +func (c *Check) prepareBatches(ctx context.Context, nodes orchestration.ClientList, batchCount int, o Options) ([]nodeBatch, error) { + c.logger.Infof("preparing %d postage batches in parallel (depth %d, amount %d)", + batchCount, o.PostageDepth, o.PostageAmount) + + startedAt := time.Now() + + var ( + mutex sync.Mutex + batches []nodeBatch + failedNodes []string + waitGroup sync.WaitGroup + ) + + for i := range batchCount { + node := nodes[i] + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + + batchID, reused, err := c.batchForNode(ctx, node, o) + + mutex.Lock() + defer mutex.Unlock() + if err != nil { + c.logger.Infof("%s: no usable batch: %v", node.Name(), err) + failedNodes = append(failedNodes, node.Name()) + return + } + if reused { + c.logger.Infof("%s: reusing batch %s", node.Name(), batchID) + } else { + c.logger.Infof("%s: bought batch %s", node.Name(), batchID) + } + batches = append(batches, nodeBatch{batchID: batchID, node: node}) + }() + } + waitGroup.Wait() + + if len(batches) == 0 { + return nil, fmt.Errorf("no usable postage batches: all %d nodes failed", batchCount) + } + if len(failedNodes) > 0 { + c.logger.Infof("continuing with %d/%d batches, failed on %v", len(batches), batchCount, failedNodes) + } + c.logger.Infof("%d batches ready in %s", len(batches), time.Since(startedAt).Round(time.Second)) + + return batches, nil +} + +// batchForNode returns a usable batch for the node, preferring one it already +// owns so repeat runs neither wait for confirmation nor spend the token allowance. +func (c *Check) batchForNode(ctx context.Context, node *bee.Client, o Options) (batchID string, reused bool, err error) { + label := fmt.Sprintf("%s-%s", o.PostageLabel, node.Name()) + + existingBatches, err := node.PostageBatches(ctx) + if err != nil { + return "", false, fmt.Errorf("list batches: %w", err) + } + + for _, batch := range existingBatches { + if !batch.Exists || batch.ImmutableFlag || !batch.Usable || batch.Label != label { + continue + } + if batch.BatchTTL == 0 { + continue // expired + } + if batch.Utilization >= 1<<(batch.Depth-batch.BucketDepth) { + continue // buckets full, cannot issue more stamps + } + return batch.BatchID, true, nil + } + + batchID, err = node.CreatePostageBatch(ctx, o.PostageAmount, o.PostageDepth, label, false) + if err != nil { + return "", false, err + } + return batchID, false, nil +} + +// waitForRadiusIncrease blocks until a node reports a non-zero storage radius, +// returning what it saw. It returns zero if the radius never rose. +// +// Uploads are deferred, so the pushers deliver them over the following minutes +// and only then can a reserve exceed capacity and provoke an increase. Quiet +// reserves are therefore not enough to conclude the radius will stay put, so the +// minimum wait applies even after the totals stop changing. +func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestration.ClientList, o Options) (uint8, error) { + ticker := time.NewTicker(o.PollInterval) + defer ticker.Stop() + + c.logger.Infof("waiting up to %s for the pushers to fill the reserves and the radius to rise", o.MinRadiusWait) + + startedAt := time.Now() + previousTotal, stablePolls := -1, 0 + + for { + select { + case <-ctx.Done(): + return 0, fmt.Errorf("timed out waiting for the storage radius to rise above 0: %w", ctx.Err()) + case <-ticker.C: + } + + reserveTotal, pendingChunks, highestRadius := c.pipelineState(ctx, nodes) + if highestRadius > 0 { + c.logger.Infof("storage radius is %d after %s (reserves at %d chunks)", + highestRadius, time.Since(startedAt).Round(time.Second), reserveTotal) + return highestRadius, nil + } + + elapsed := time.Since(startedAt) + if reserveTotal == previousTotal { + stablePolls++ + if stablePolls >= stablePollsBeforeGivingUp && elapsed >= o.MinRadiusWait { + c.logger.Infof("reserves settled at %d chunks and radius still 0 after %s", + reserveTotal, elapsed.Round(time.Second)) + return 0, nil + } + } else { + if previousTotal >= 0 { + c.logger.Infof("reserves at %d chunks (+%d), %d pending in the pushers, radius 0 (%s elapsed)", + reserveTotal, reserveTotal-previousTotal, pendingChunks, elapsed.Round(time.Second)) + } + stablePolls = 0 + } + previousTotal = reserveTotal + } +} + +// pipelineAlreadyFull reports whether the reserves and pusher backlogs already +// hold enough chunks to fill a reserve, which happens on a cluster an earlier run +// has filled. It returns the largest reserve plus its node's backlog. +func (c *Check) pipelineAlreadyFull(ctx context.Context, batches []nodeBatch, plan uploadPlan, options Options) (chunks int, full bool) { + chunksNeeded := plan.chunksNeeded(options) + + for _, batch := range batches { + status, err := batch.node.Status(ctx) + if err != nil { + continue + } + // A radius already above zero means bee has reacted; nothing to add. + if status.StorageRadius > 0 { + return int(status.ReserveSize), true + } + + inPipeline := int(status.ReserveSize) + if debugStore, err := batch.node.API().DebugStore.GetDebugStore(ctx); err == nil { + inPipeline += debugStore.Upload.PendingUpload + } + chunks = max(chunks, inPipeline) + } + + return chunks, chunks >= chunksNeeded +} + +// upload sends the planned random data across every batch concurrently, one +// request per node in flight, stopping early once enough chunks are in the +// pipeline. +func (c *Check) upload(ctx context.Context, batches []nodeBatch, plan uploadPlan, options Options) (int, error) { + totalUploads := plan.uploadCount() + + c.logger.Infof("uploading %d chunks in %d requests across %d nodes", + plan.totalChunks, totalUploads, len(batches)) + + var ( + mutex sync.Mutex + uploadedChunks int + completedCount int + ) + + enough := make(chan struct{}) + var stopOnce sync.Once + stopUploading := func() { stopOnce.Do(func() { close(enough) }) } + + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(len(batches)) + + watchCtx, cancelWatch := context.WithCancel(groupCtx) + defer cancelWatch() + go c.stopWhenPipelineFull(watchCtx, batches, plan, options, stopUploading) + + // Round-robin the requests over the batches so the load spreads across nodes + // without tying the request count to the batch count. + // + // Requests are released in waves of one per batch, pausing between them: a + // full poll of every node takes several seconds, so firing everything at once + // finishes before the watcher can see the pipeline and report that enough has + // been queued. The pause gives it that chance and keeps the overshoot small. + for i := range totalUploads { + if i > 0 && i%len(batches) == 0 { + select { + case <-enough: + case <-groupCtx.Done(): + case <-time.After(options.UploadWavePause): + } + } + + batch := batches[i%len(batches)] + group.Go(func() error { + select { + case <-enough: + return nil + default: + } + + // Fresh bytes per request: bee addresses chunks by content, so + // reusing them would collide instead of filling the reserve. + data := make([]byte, int64(plan.chunksPerUpload)*bee.MaxChunkSize) + if _, err := crand.Read(data); err != nil { + return fmt.Errorf("generate random data: %w", err) + } + + // Re-check after generating the data: with many goroutines queued + // behind the concurrency limit, the pipeline can fill while this + // one waits, and uploading anyway is what overshoots the target. + select { + case <-enough: + return nil + default: + } + + uploadCtx, cancel := context.WithTimeout(groupCtx, options.UploadTimeout) + address, err := batch.node.UploadBytes(uploadCtx, data, api.UploadOptions{BatchID: batch.batchID}) + cancel() + if err != nil { + return fmt.Errorf("upload to %s: %w", batch.node.Name(), err) + } + + mutex.Lock() + uploadedChunks += plan.chunksPerUpload + completedCount++ + completed, chunks := completedCount, uploadedChunks + mutex.Unlock() + + c.logger.Infof("upload %d/%d to %s: %s (%d/%d chunks)", + completed, totalUploads, batch.node.Name(), address, chunks, plan.totalChunks) + return nil + }) + } + + if err := group.Wait(); err != nil { + return uploadedChunks, err + } + + return uploadedChunks, nil +} + +// radiusUnchangedError explains why the radius never moved: either the chunks +// never reached a reserve, or they did but capacity was never exceeded. +func (c *Check) radiusUnchangedError(chunksBefore, chunksAfter, uploadedChunks int, options Options) error { + if chunksAfter <= chunksBefore { + return fmt.Errorf("storage radius is still 0 and the reserves did not grow (%d chunks before, %d after, %d uploaded): "+ + "bee accepted the uploads but the pushers are not delivering them", + chunksBefore, chunksAfter, uploadedChunks) + } + return fmt.Errorf("storage radius is still 0: reserves grew from %d to %d chunks, "+ + "but no node exceeded its %d-chunk capacity long enough to force an increase", + chunksBefore, chunksAfter, options.ReserveCapacity) +} + +// reservesAtCapacity reports whether every reachable node is holding a full +// reserve, meaning any chunks still in flight can only be evicted on arrival. +func (c *Check) reservesIsAtCapacity(ctx context.Context, nodes orchestration.ClientList, options Options) bool { + // Bee holds slightly under capacity while evicting, so allow a small margin. + full := options.ReserveCapacity * 95 / 100 + sawNode := false + + for _, node := range nodes { + status, err := node.Status(ctx) + if err != nil { + continue + } + sawNode = true + if int(status.ReserveSize) < full { + return false + } + } + + return sawNode +} + +// waitForPushersIdle waits for the pusher backlog to settle before diluting. +// +// It does not wait for zero. A cluster that has been filled repeatedly carries a +// backlog that drains at a few hundred chunks a minute and may never empty, and +// the radius has already risen by this point, so waiting it out is pure delay. +// Settling for a few polls is enough to know incoming chunks will not race the +// dilution, and PushersIdleWait caps the wait either way. +func (c *Check) waitForPushersIdle(ctx context.Context, nodes orchestration.ClientList, options Options) error { + ticker := time.NewTicker(options.PollInterval) + defer ticker.Stop() + + c.logger.Infof("waiting up to %s for the pusher backlog to settle", options.PushersIdleWait) + + startedAt := time.Now() + previousPending, stablePolls := -1, 0 + + for { + _, pendingChunks, _ := c.pipelineState(ctx, nodes) + elapsed := time.Since(startedAt) + + if pendingChunks == 0 { + c.logger.Infof("pushers idle after %s", elapsed.Round(time.Second)) + return nil + } + + // Treat a backlog that is no longer shrinking as settled: the pushers are + // as done as they are going to get. + if pendingChunks >= previousPending && previousPending >= 0 { + stablePolls++ + if stablePolls >= stablePollsBeforeGivingUp { + c.logger.Infof("pusher backlog stable at %d chunks after %s, continuing", pendingChunks, elapsed.Round(time.Second)) + return nil + } + } else { + stablePolls = 0 + } + + if elapsed >= options.PushersIdleWait { + c.logger.Infof("pusher backlog still %d chunks after %s, continuing anyway", pendingChunks, elapsed.Round(time.Second)) + return nil + } + + c.logger.Infof("%d chunks still pending in the pushers (%s elapsed)", pendingChunks, elapsed.Round(time.Second)) + previousPending = pendingChunks + + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for the pushers to drain, %d chunks still pending: %w", pendingChunks, ctx.Err()) + case <-ticker.C: + } + } +} + +// dilute widens every batch and waits for the storage radius to fall. +// +// Diluting multiplies a batch's chunk allowance without adding chunks, so the +// network's committed depth jumps and chunks fall outside each node's storage +// radius. The count within radius then drops below bee's threshold, which is the +// first of the three conditions bee requires before stepping the radius down; the +// others are that pullsync is idle and the radius is above its configured minimum. +func (c *Check) dilute(ctx context.Context, nodes orchestration.ClientList, batches []nodeBatch, startRadius uint8, options Options) error { + c.logger.Infof("diluting %d batches to depth %d to push chunks outside the storage radius", + len(batches), options.DiluteDepth) + + dilutedCount := 0 + for _, batch := range batches { + stamp, err := batch.node.PostageStamp(ctx, batch.batchID) + if err != nil { + c.logger.Infof("%s: cannot read batch %s: %v", batch.node.Name(), batch.batchID, err) + continue + } + // Dilution only ever increases depth; bee rejects a lower one. + if uint64(stamp.Depth) >= options.DiluteDepth { + c.logger.Infof("%s: batch already at depth %d, skipping", batch.node.Name(), stamp.Depth) + continue + } + + if err := batch.node.DilutePostageBatch(ctx, batch.batchID, options.DiluteDepth, ""); err != nil { + c.logger.Infof("%s: dilute to depth %d failed: %v", batch.node.Name(), options.DiluteDepth, err) + continue + } + dilutedCount++ + c.logger.Infof("%s: diluted batch from depth %d to %d", batch.node.Name(), stamp.Depth, options.DiluteDepth) + } + + if dilutedCount == 0 { + return errors.New("no batches were diluted, cannot provoke a radius decrease") + } + + return c.waitForStorageRadiusDecrease(ctx, nodes, startRadius, options) +} + +// waitForRadiusDecrease blocks until the storage radius drops below startRadius. +// +// Bee steps the radius down one bin per reserve-worker tick, which is 15 minutes +// on a stock node, so this is slow by design. +func (c *Check) waitForStorageRadiusDecrease(ctx context.Context, nodes orchestration.ClientList, startRadius uint8, options Options) error { + ticker := time.NewTicker(options.PollInterval) + defer ticker.Stop() + + c.logger.Infof("waiting up to %s for any node's storage radius to fall below %d", options.DiluteWait, startRadius) + + startedAt := time.Now() + decreaseThreshold := options.ReserveCapacity * 8 / 10 + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for the storage radius to decrease: %w", ctx.Err()) + case <-ticker.C: + } + + lowestRadius, chunksWithinRadius, pullsyncRate, reachable := c.radiusDecreaseState(ctx, nodes) + // One node stepping down is enough: the decrease has been demonstrated. + if reachable && lowestRadius < startRadius { + c.logger.Infof("storage radius decreased %d -> %d after %s", + startRadius, lowestRadius, time.Since(startedAt).Round(time.Second)) + return nil + } + + if !reachable { + c.logger.Infof("no node answered, retrying") + continue + } + + elapsed := time.Since(startedAt) + if elapsed >= options.DiluteWait { + return fmt.Errorf("storage radius stayed at %d after %s: %d chunks within radius (threshold %d), pullsync rate %.2f", + startRadius, options.DiluteWait, chunksWithinRadius, decreaseThreshold, pullsyncRate) + } + + c.logger.Infof("radius still %d, %d chunks within radius (threshold %d), pullsync %.2f (%s elapsed)", + lowestRadius, chunksWithinRadius, decreaseThreshold, pullsyncRate, elapsed.Round(time.Second)) + } +} + +// pipelineState sums reserve sizes and pending pusher backlogs across the cluster, +// and reports the highest storage radius seen. Unreachable nodes are skipped. +func (c *Check) pipelineState(ctx context.Context, nodes orchestration.ClientList) (reserveTotal, pendingChunks int, highestRadius uint8) { + for _, node := range nodes { + if status, err := node.Status(ctx); err == nil { + reserveTotal += int(status.ReserveSize) + highestRadius = max(highestRadius, status.StorageRadius) + } + if debugStore, err := node.API().DebugStore.GetDebugStore(ctx); err == nil { + pendingChunks += debugStore.Upload.PendingUpload + } + } + return reserveTotal, pendingChunks, highestRadius +} + +// uploadCount is how many requests are needed to cover the target, rounded up. +// +// This is a total rather than a per-batch figure: rounding up per batch would +// always schedule at least one upload for every batch, which on a cluster with +// more batches than needed requests overshoots the target several times over. +func (p uploadPlan) uploadCount() int { + return max((p.totalChunks+p.chunksPerUpload-1)/p.chunksPerUpload, 1) +} + +// stopWhenPipelineFull halts uploading once the radius has risen, a reserve is +// over capacity, or this run has put enough chunks into the pipeline. +// +// The last of those is normally the earliest signal. Uploads are deferred, so +// they sit in the upload store until the pushers deliver them; by the time a +// reserve looks full the pipeline holds far more than was needed, and anything +// uploaded beyond that is only evicted on arrival. +// +// Progress is measured against the reserve and backlog seen at the start, since a +// cluster that has been filled before begins with both already well above zero. +func (c *Check) stopWhenPipelineFull(ctx context.Context, batches []nodeBatch, plan uploadPlan, options Options, stopUploading func()) { + ticker := time.NewTicker(options.PollInterval) + defer ticker.Stop() + + chunksNeeded := plan.chunksNeeded(options) + baseReserve, basePending := c.pipelineBaseline(ctx, batches) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + + var ( + pendingChunks int + largestReserve int + sawDebugStore bool + ) + + for _, batch := range batches { + status, err := batch.node.Status(ctx) + if err != nil { + continue + } + + if status.StorageRadius > 0 { + c.logger.Infof("%s reports storage radius %d, stopping further uploads", + batch.node.Name(), status.StorageRadius) + stopUploading() + return + } + if int(status.ReserveSize) > options.ReserveCapacity { + c.logger.Infof("%s reached %d/%d chunks, stopping further uploads", + batch.node.Name(), status.ReserveSize, options.ReserveCapacity) + stopUploading() + return + } + largestReserve = max(largestReserve, int(status.ReserveSize)) + + if debugStore, err := batch.node.API().DebugStore.GetDebugStore(ctx); err == nil { + pendingChunks += debugStore.Upload.PendingUpload + sawDebugStore = true + } + } + + // A reserve already counts what the pipeline delivered, so the pending + // backlog adds to it rather than being counted separately. + delivered := largestReserve - baseReserve + queued := pendingChunks - basePending + if sawDebugStore && delivered+queued >= chunksNeeded { + c.logger.Infof("%d chunks added to the pipeline (%d delivered, %d queued) covers the %d needed, stopping further uploads", + delivered+queued, delivered, queued, chunksNeeded) + stopUploading() + return + } + } +} + + +// pipelineBaseline records the reserve size and pusher backlog before uploading, +// so progress can be measured as growth rather than absolute totals. +func (c *Check) pipelineBaseline(ctx context.Context, batches []nodeBatch) (reserve, pending int) { + for _, batch := range batches { + if status, err := batch.node.Status(ctx); err == nil { + reserve = max(reserve, int(status.ReserveSize)) + } + if debugStore, err := batch.node.API().DebugStore.GetDebugStore(ctx); err == nil { + pending += debugStore.Upload.PendingUpload + } + } + return reserve, pending +} + + +// radiusDecreaseState reports the inputs to bee's radius-decrease decision: the +// lowest radius in the cluster, chunks held within radius, and the pullsync rate. +// +// The lowest radius is what matters because one node stepping down is enough to +// call the decrease observed. It is reported as reachable=false when no node +// answered, so a cluster-wide outage is not mistaken for a decrease. +func (c *Check) radiusDecreaseState(ctx context.Context, nodes orchestration.ClientList) (lowestRadius uint8, chunksWithinRadius int, pullsyncRate float64, reachable bool) { + lowestRadius = math.MaxUint8 + for _, node := range nodes { + status, err := node.Status(ctx) + if err != nil { + continue + } + reachable = true + lowestRadius = min(lowestRadius, status.StorageRadius) + chunksWithinRadius += int(status.ReserveSizeWithinRadius) + pullsyncRate += status.PullsyncRate + } + return lowestRadius, chunksWithinRadius, pullsyncRate, reachable +} \ No newline at end of file From f0b1e44e912bb5b77a3f3c07c4b76acc7adcb2bc Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Tue, 18 Aug 2026 14:08:40 +0200 Subject: [PATCH 02/11] feat: add DebugStoreService and enhance DebugStore structure with detailed statistics --- pkg/bee/api/api.go | 2 ++ pkg/bee/api/debugstore.go | 44 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/pkg/bee/api/api.go b/pkg/bee/api/api.go index f943fc33..6346e47a 100644 --- a/pkg/bee/api/api.go +++ b/pkg/bee/api/api.go @@ -58,6 +58,7 @@ type Client struct { Status *StatusService Stewardship *StewardshipService Tags *TagsService + DebugStore *DebugStoreService } // NewClient constructs a new Client. @@ -108,6 +109,7 @@ func newClient(apiURL *url.URL, httpClient *http.Client) (c *Client) { c.Status = (*StatusService)(&c.service) c.Stewardship = (*StewardshipService)(&c.service) c.Tags = (*TagsService)(&c.service) + c.DebugStore = (*DebugStoreService)(&c.service) return c } diff --git a/pkg/bee/api/debugstore.go b/pkg/bee/api/debugstore.go index 66f6750c..fbe0e7e6 100644 --- a/pkg/bee/api/debugstore.go +++ b/pkg/bee/api/debugstore.go @@ -9,11 +9,49 @@ import ( type DebugStoreService service // DebugStore represents DebugStore's response -type DebugStore map[string]int +type DebugStore struct { + Upload UploadStat `json:"upload"` + Pinning PinningStat `json:"pinning"` + Cache CacheStat `json:"cache"` + Reserve ReserveStat `json:"reserve"` + ChunkStore ChunkStoreStat `json:"chunkStore"` +} + +// UploadStat reports the upload store, which holds chunks the pusher has not yet +// delivered to the network. PendingUpload is that undelivered backlog. +type UploadStat struct { + TotalUploaded int `json:"totalUploaded"` + TotalSynced int `json:"totalSynced"` + PendingUpload int `json:"pendingUpload"` +} + +type PinningStat struct { + TotalCollections int `json:"totalCollections"` + TotalChunks int `json:"totalChunks"` +} + +type CacheStat struct { + Size int `json:"size"` + Capacity int `json:"capacity"` +} + +type ReserveStat struct { + SizeWithinRadius int `json:"sizeWithinRadius"` + TotalSize int `json:"totalSize"` + Capacity int `json:"capacity"` + LastBinIDs []uint64 `json:"lastBinIDs"` + Epoch uint64 `json:"epoch"` +} + +type ChunkStoreStat struct { + TotalChunks int `json:"totalChunks"` + SharedSlots int `json:"sharedSlots"` + ReferenceCount int `json:"referenceCount"` +} // GetDebugStore gets db indices func (d *DebugStoreService) GetDebugStore(ctx context.Context) (DebugStore, error) { - resp := make(DebugStore) + var resp DebugStore err := d.client.requestJSON(ctx, http.MethodGet, "/debugstore", nil, &resp) return resp, err -} +} \ No newline at end of file From 5399a86a2f4b1b4f53ad85421dd0189231207f5c Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Tue, 18 Aug 2026 14:08:48 +0200 Subject: [PATCH 03/11] feat: add IsWarmingUp field to StatusResponse for enhanced status reporting --- pkg/bee/api/status.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/bee/api/status.go b/pkg/bee/api/status.go index 32e98a16..de0028f0 100644 --- a/pkg/bee/api/status.go +++ b/pkg/bee/api/status.go @@ -22,6 +22,7 @@ type StatusResponse struct { IsReachable bool `json:"isReachable"` LastSyncedBlock uint64 `json:"lastSyncedBlock"` CommittedDepth uint8 `json:"committedDepth"` + IsWarmingUp bool `json:"isWarmingUp"` } // Ping pings given node From c1f0ce6917360018176f13a1c045113d9e988523 Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Tue, 18 Aug 2026 14:40:56 +0200 Subject: [PATCH 04/11] feat: add payment-threshold to geth-playground config and implement pg-storage-radius checks --- config/testnet-bee-playground.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/config/testnet-bee-playground.yaml b/config/testnet-bee-playground.yaml index f0a3bc80..05e5eff6 100644 --- a/config/testnet-bee-playground.yaml +++ b/config/testnet-bee-playground.yaml @@ -57,6 +57,7 @@ bee-configs: network-id: 12345 p2p-addr: :1634 password: "beekeeper" + payment-threshold: 108000000 storage-incentives-enable: true swap-enable: true verbosity: 5 @@ -200,3 +201,21 @@ checks: duration: 12h timeout: 13h type: smoke + pg-storage-radius: + options: + reserve-capacity: 4000 + target-fill-percent: 1.03 + stamps: 16 + chunks-per-upload: 512 + postage-depth: 22 + postage-amount: 2073600000 + postage-label: storage-radius + upload-timeout: 10m + upload-wave-pause: 5s + poll-interval: 2s + min-radius-wait: 5m + pushers-idle-wait: 2m + dilute-depth: 32 + dilute-wait: 45m + timeout: 80m + type: storage-radius From 2d11609b3dfe0dc0423c4cb0ec6e1fed7040ade2 Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Wed, 19 Aug 2026 10:57:38 +0200 Subject: [PATCH 05/11] feat: increase node count in ng-bee-playground from 8 to 16 --- config/testnet-bee-playground.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/testnet-bee-playground.yaml b/config/testnet-bee-playground.yaml index 05e5eff6..28a4eae8 100644 --- a/config/testnet-bee-playground.yaml +++ b/config/testnet-bee-playground.yaml @@ -25,7 +25,7 @@ clusters: mode: node bee-config: geth-playground config: ng-bee-playground - count: 8 + count: 16 # node-groups defines node groups that can be registered in the cluster # node-groups may inherit it's configuration from already defined node-group and override specific fields from it From 8e03386d5534d1b19ef3f0f9829551259e0da50a Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Wed, 19 Aug 2026 10:58:08 +0200 Subject: [PATCH 06/11] feat: add storage-radius check in check.go --- pkg/config/check.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pkg/config/check.go b/pkg/config/check.go index 541afb7e..8e585797 100644 --- a/pkg/config/check.go +++ b/pkg/config/check.go @@ -35,6 +35,7 @@ import ( "github.com/ethersphere/beekeeper/pkg/check/smoke" "github.com/ethersphere/beekeeper/pkg/check/soc" "github.com/ethersphere/beekeeper/pkg/check/stake" + "github.com/ethersphere/beekeeper/pkg/check/storageradius" "github.com/ethersphere/beekeeper/pkg/check/withdraw" "github.com/ethersphere/beekeeper/pkg/logging" "github.com/ethersphere/beekeeper/pkg/random" @@ -734,6 +735,38 @@ var Checks = map[string]CheckType{ return nil, fmt.Errorf("applying options: %w", err) } + return opts, nil + }, + }, + "storage-radius": { + NewAction: storageradius.NewCheck, + NewOptions: func(checkGlobalConfig CheckGlobalConfig, check Check) (any, error) { + checkOpts := new(struct { + PollInterval *time.Duration `yaml:"poll-interval"` + WaitForWarmup *bool `yaml:"wait-for-warmup"` + Seed *int64 `yaml:"seed"` + TargetFillPercent *float64 `yaml:"target-fill-percent"` + ChunksPerUpload *int `yaml:"chunks-per-upload"` + ReserveCapacity *int `yaml:"reserve-capacity"` + PostageDepth *uint64 `yaml:"postage-depth"` + PostageAmount *int64 `yaml:"postage-amount"` + PostageLabel *string `yaml:"postage-label"` + MinRadiusWait *time.Duration `yaml:"min-radius-wait"` + PushersIdleWait *time.Duration `yaml:"pushers-idle-wait"` + DiluteDepth *uint64 `yaml:"dilute-depth"` + DiluteWait *time.Duration `yaml:"dilute-wait"` + UploadWavePause *time.Duration `yaml:"upload-wave-pause"` + UploadTimeout *time.Duration `yaml:"upload-timeout"` + }) + if err := check.Options.Decode(checkOpts); err != nil { + return nil, fmt.Errorf("decoding check %s options: %w", check.Type, err) + } + opts := storageradius.NewDefaultOptions() + + if err := applyCheckConfig(checkGlobalConfig, checkOpts, &opts); err != nil { + return nil, fmt.Errorf("applying options: %w", err) + } + return opts, nil }, }, From 53f1b45a5541700e40f2d6461541153a9f4d3ad6 Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Wed, 19 Aug 2026 13:19:53 +0200 Subject: [PATCH 07/11] refactor: options struct for clarity and streamline waitForWarmup logic --- pkg/check/storageradius/storageradius.go | 187 +++++++---------------- 1 file changed, 51 insertions(+), 136 deletions(-) diff --git a/pkg/check/storageradius/storageradius.go b/pkg/check/storageradius/storageradius.go index be2ea522..75ffb472 100644 --- a/pkg/check/storageradius/storageradius.go +++ b/pkg/check/storageradius/storageradius.go @@ -19,34 +19,28 @@ import ( ) const ( - // stablePollsBeforeGivingUp is how many consecutive unchanged reserve totals, mean the pushers have finished delivering. stablePollsBeforeGivingUp = 5 ) type Options struct { - PollInterval time.Duration // wait between status polls - WaitForWarmup bool // wait for nodes to finish warming up - Seed int64 // seed for randomization - TargetFillPercent float64 // fraction of capacity to fill; above 1 overshoots - ChunksPerUpload int // chunks per /bytes request - ReserveCapacity int // per-node reserve capacity in chunks, for now we have patched bee docker img that contains 4000 chunks - PostageDepth uint64 // batch depth; must exceed bee's bucket depth of 16 - PostageAmount int64 // batch amount; must clear the minimum validity floor - PostageLabel string // batch label prefix - MinRadiusWait time.Duration // minimum time to watch for a radius increase - PushersIdleWait time.Duration // cap on waiting for the pusher backlog before diluting - DiluteDepth uint64 // depth to dilute batches to; must exceed PostageDepth - DiluteWait time.Duration // how long to wait for the radius to come back down - UploadWavePause time.Duration // pause between upload waves so the watcher can keep up - UploadTimeout time.Duration // timeout for each upload request - + TargetFillPercent float64 // fraction of capacity to fill (>1 overshoots) + ReserveCapacity int // per-node reserve capacity + PollInterval time.Duration // how often to check node status + ChunksPerUpload int // bytes per upload request + MinRadiusWait time.Duration // min time watching for radius increase + PushersIdleWait time.Duration // max wait for backlog to settle + DiluteDepth uint64 // depth to dilute to (32 is max) + DiluteWait time.Duration // timeout for radius decrease + UploadWavePause time.Duration // pause between upload waves + UploadTimeout time.Duration // timeout per upload request + PostageAmount int64 + PostageLabel string + PostageDepth uint64 } func NewDefaultOptions() Options { return Options{ PollInterval: 2 * time.Second, - WaitForWarmup: true, - Seed: 0, TargetFillPercent: 1.2, ChunksPerUpload: 512, ReserveCapacity: 4000, @@ -80,37 +74,31 @@ func (c *Check) Run(ctx context.Context, cluster orchestration.Cluster, opts any startedAt := time.Now() - if o.WaitForWarmup { - if err := c.waitForWarmup(ctx, cluster, o); err != nil { - return fmt.Errorf("wait for warmup: %w", err) - } + if err := c.waitForWarmup(ctx, cluster, o); err != nil { + return fmt.Errorf("wait for warmup: %w", err) } - fullNodes, err := cluster.ShuffledFullNodeClients(ctx, random.PseudoGenerator(o.Seed)) + fullNodes, err := cluster.ShuffledFullNodeClients(ctx, random.PseudoGenerator(time.Now().UnixNano())) if err != nil { return fmt.Errorf("get shuffled full node clients: %w", err) } if len(fullNodes) == 0 { - return errors.New("no full nodes available, fill-reserve requires at least one full node") + return errors.New("no full nodes available, storage-radius check requires at least one full node") } status, err := fullNodes[0].Status(ctx) if err != nil { return fmt.Errorf("status: %w", err) } - startStorageRadius := status.StorageRadius - // TODO:Log chain state + initialStorageRadius := status.StorageRadius - uploadPlan := newUploadPlan(startStorageRadius, o) + uploadPlan := newUploadPlan(initialStorageRadius, o) batchCount := min(o.ChunksPerUpload, len(fullNodes)) - c.logger.Infof("cluster: %d full nodes at storage radius %d => %.0f neighborhood(s)", - len(fullNodes), startStorageRadius, uploadPlan.neighborhoods) - c.logger.Infof("target %d chunks (%.0f%% of %d per neighborhood), %d chunks per upload", - uploadPlan.totalChunks, o.TargetFillPercent*100, o.ReserveCapacity, uploadPlan.chunksPerUpload) - // This is needed for the case if there are some nodes that have some reserved chunks already, we need to get the cluster state before we start uploading chunks - // can we change name of fun to getClusterState ? and we may log if we want ? + c.logger.Infof("cluster: %d full nodes at storage radius %d => %.0f neighborhood(s)", len(fullNodes), initialStorageRadius, uploadPlan.neighborhoods) + c.logger.Infof("target %d chunks (%.0f%% of %d per neighborhood), %d chunks per upload", uploadPlan.totalChunks, o.TargetFillPercent*100, o.ReserveCapacity, uploadPlan.chunksPerUpload) + chunksBefore, err := c.logClusterState(ctx, cluster, "before") if err != nil { return err @@ -121,7 +109,6 @@ func (c *Check) Run(ctx context.Context, cluster orchestration.Cluster, opts any return err } - // A cluster filled by an earlier run can already hold, or have queued, more than this run needs. Uploading then adds chunks that are evicted on arrival, so skip straight to waiting for bee to react to what is already there. uploadedChunks := 0 if pending, enough := c.pipelineAlreadyFull(ctx, batches, uploadPlan, o); enough { c.logger.Infof("pipeline already holds %d chunks, more than the %d needed, skipping uploads", @@ -149,11 +136,9 @@ func (c *Check) Run(ctx context.Context, cluster orchestration.Cluster, opts any if storageRadius == 0 { return c.radiusUnchangedError(chunksBefore, chunksAfter, uploadedChunks, o) } - c.logger.Infof("storage radius is %d (started at %d)", storageRadius, startStorageRadius) - // Chunks still arriving could race the dilution, but only if the reserves have - // room left to accept them. Once every reserve is at capacity the backlog is - // surplus that bee evicts on arrival, so waiting for it to drain is pure delay. + c.logger.Infof("storage radius is %d (started at %d)", storageRadius, initialStorageRadius) + if c.reservesIsAtCapacity(ctx, fullNodes, o) { c.logger.Infof("reserves are at capacity, remaining backlog will be evicted on arrival, diluting now") } else if err := c.waitForPushersIdle(ctx, fullNodes, o); err != nil { @@ -210,10 +195,10 @@ type uploadPlan struct { chunksPerUpload int } -// newUploadPlan sizes the upload to the neighborhoods the cluster spans -// (2^radius), not the node count: at radius 0 the whole cluster is one -// neighborhood, so roughly `capacity` chunks fill every node at once. -// TODO: consider the case where the cluster contains multiple neighborhoods +// newUploadPlan calculates upload size based on cluster neighborhoods (2^radius). +// Chunks replicate across all nodes in a neighborhood, so we size by neighborhood count, not node count. +// Example: at radius=0 (1 neighborhood), all 8 nodes replicate the same chunks → upload ~4000 total. +// At radius=1 (2 neighborhoods), nodes split into 2 groups → upload ~8000 (4000 per neighborhood). func newUploadPlan(radius uint8, o Options) uploadPlan { neighborhoods := math.Pow(2, float64(radius)) totalChunks := int(o.TargetFillPercent * float64(o.ReserveCapacity) * neighborhoods) @@ -260,14 +245,8 @@ type nodeBatch struct { node *bee.Client } -// prepareBatches gets one usable batch per node, reusing an existing one where -// possible and buying the rest in parallel. -// -// A node that cannot provide a batch is skipped rather than failing the check, -// since buying can revert on-chain. This deliberately uses a WaitGroup, not an -// errgroup: an errgroup would cancel its siblings on the first error, and because -// CreatePostageBatch polls with a sleep loop that ignores cancellation, those -// siblings would keep polling for batches that were never created. +// prepareBatches buys postage batches for each node, reusing existing ones to save time and tokens. +// Uses WaitGroup instead of errgroup to avoid hanging 900s if a batch purchase reverts on-chain. func (c *Check) prepareBatches(ctx context.Context, nodes orchestration.ClientList, batchCount int, o Options) ([]nodeBatch, error) { c.logger.Infof("preparing %d postage batches in parallel (depth %d, amount %d)", batchCount, o.PostageDepth, o.PostageAmount) @@ -317,8 +296,7 @@ func (c *Check) prepareBatches(ctx context.Context, nodes orchestration.ClientLi return batches, nil } -// batchForNode returns a usable batch for the node, preferring one it already -// owns so repeat runs neither wait for confirmation nor spend the token allowance. +// batchForNode finds an existing usable batch or creates a new one. func (c *Check) batchForNode(ctx context.Context, node *bee.Client, o Options) (batchID string, reused bool, err error) { label := fmt.Sprintf("%s-%s", o.PostageLabel, node.Name()) @@ -347,13 +325,8 @@ func (c *Check) batchForNode(ctx context.Context, node *bee.Client, o Options) ( return batchID, false, nil } -// waitForRadiusIncrease blocks until a node reports a non-zero storage radius, -// returning what it saw. It returns zero if the radius never rose. -// -// Uploads are deferred, so the pushers deliver them over the following minutes -// and only then can a reserve exceed capacity and provoke an increase. Quiet -// reserves are therefore not enough to conclude the radius will stay put, so the -// minimum wait applies even after the totals stop changing. +// waitForStorageRadiusIncrease waits for any node's radius to climb above zero. +// Requires both stable reserves AND MinRadiusWait elapsed, since uploads are deferred. func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestration.ClientList, o Options) (uint8, error) { ticker := time.NewTicker(o.PollInterval) defer ticker.Stop() @@ -396,9 +369,7 @@ func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestr } } -// pipelineAlreadyFull reports whether the reserves and pusher backlogs already -// hold enough chunks to fill a reserve, which happens on a cluster an earlier run -// has filled. It returns the largest reserve plus its node's backlog. +// pipelineAlreadyFull checks if reserves and pusher backlog already hold enough chunks. func (c *Check) pipelineAlreadyFull(ctx context.Context, batches []nodeBatch, plan uploadPlan, options Options) (chunks int, full bool) { chunksNeeded := plan.chunksNeeded(options) @@ -422,9 +393,7 @@ func (c *Check) pipelineAlreadyFull(ctx context.Context, batches []nodeBatch, pl return chunks, chunks >= chunksNeeded } -// upload sends the planned random data across every batch concurrently, one -// request per node in flight, stopping early once enough chunks are in the -// pipeline. +// upload sends random data in parallel, stopping when pipeline holds enough chunks. func (c *Check) upload(ctx context.Context, batches []nodeBatch, plan uploadPlan, options Options) (int, error) { totalUploads := plan.uploadCount() @@ -448,15 +417,10 @@ func (c *Check) upload(ctx context.Context, batches []nodeBatch, plan uploadPlan defer cancelWatch() go c.stopWhenPipelineFull(watchCtx, batches, plan, options, stopUploading) - // Round-robin the requests over the batches so the load spreads across nodes - // without tying the request count to the batch count. - // - // Requests are released in waves of one per batch, pausing between them: a - // full poll of every node takes several seconds, so firing everything at once - // finishes before the watcher can see the pipeline and report that enough has - // been queued. The pause gives it that chance and keeps the overshoot small. for i := range totalUploads { if i > 0 && i%len(batches) == 0 { + // Pause between waves so the watcher can poll and detect when to stop. + // Without this, uploads finish before the watcher sees them. select { case <-enough: case <-groupCtx.Done(): @@ -472,16 +436,11 @@ func (c *Check) upload(ctx context.Context, batches []nodeBatch, plan uploadPlan default: } - // Fresh bytes per request: bee addresses chunks by content, so - // reusing them would collide instead of filling the reserve. data := make([]byte, int64(plan.chunksPerUpload)*bee.MaxChunkSize) if _, err := crand.Read(data); err != nil { return fmt.Errorf("generate random data: %w", err) } - // Re-check after generating the data: with many goroutines queued - // behind the concurrency limit, the pipeline can fill while this - // one waits, and uploading anyway is what overshoots the target. select { case <-enough: return nil @@ -514,8 +473,7 @@ func (c *Check) upload(ctx context.Context, batches []nodeBatch, plan uploadPlan return uploadedChunks, nil } -// radiusUnchangedError explains why the radius never moved: either the chunks -// never reached a reserve, or they did but capacity was never exceeded. +// radiusUnchangedError explains why the radius stayed at zero. func (c *Check) radiusUnchangedError(chunksBefore, chunksAfter, uploadedChunks int, options Options) error { if chunksAfter <= chunksBefore { return fmt.Errorf("storage radius is still 0 and the reserves did not grow (%d chunks before, %d after, %d uploaded): "+ @@ -527,10 +485,8 @@ func (c *Check) radiusUnchangedError(chunksBefore, chunksAfter, uploadedChunks i chunksBefore, chunksAfter, options.ReserveCapacity) } -// reservesAtCapacity reports whether every reachable node is holding a full -// reserve, meaning any chunks still in flight can only be evicted on arrival. +// reservesIsAtCapacity checks if all nodes have reserves at 95% or higher. func (c *Check) reservesIsAtCapacity(ctx context.Context, nodes orchestration.ClientList, options Options) bool { - // Bee holds slightly under capacity while evicting, so allow a small margin. full := options.ReserveCapacity * 95 / 100 sawNode := false @@ -548,13 +504,8 @@ func (c *Check) reservesIsAtCapacity(ctx context.Context, nodes orchestration.Cl return sawNode } -// waitForPushersIdle waits for the pusher backlog to settle before diluting. -// -// It does not wait for zero. A cluster that has been filled repeatedly carries a -// backlog that drains at a few hundred chunks a minute and may never empty, and -// the radius has already risen by this point, so waiting it out is pure delay. -// Settling for a few polls is enough to know incoming chunks will not race the -// dilution, and PushersIdleWait caps the wait either way. +// waitForPushersIdle waits for the pusher backlog to stop shrinking. +// Does not wait for zero, as it may never empty on a repeatedly-filled cluster. func (c *Check) waitForPushersIdle(ctx context.Context, nodes orchestration.ClientList, options Options) error { ticker := time.NewTicker(options.PollInterval) defer ticker.Stop() @@ -573,8 +524,6 @@ func (c *Check) waitForPushersIdle(ctx context.Context, nodes orchestration.Clie return nil } - // Treat a backlog that is no longer shrinking as settled: the pushers are - // as done as they are going to get. if pendingChunks >= previousPending && previousPending >= 0 { stablePolls++ if stablePolls >= stablePollsBeforeGivingUp { @@ -601,13 +550,7 @@ func (c *Check) waitForPushersIdle(ctx context.Context, nodes orchestration.Clie } } -// dilute widens every batch and waits for the storage radius to fall. -// -// Diluting multiplies a batch's chunk allowance without adding chunks, so the -// network's committed depth jumps and chunks fall outside each node's storage -// radius. The count within radius then drops below bee's threshold, which is the -// first of the three conditions bee requires before stepping the radius down; the -// others are that pullsync is idle and the radius is above its configured minimum. +// dilute increases batch depths to push chunks outside the storage radius. func (c *Check) dilute(ctx context.Context, nodes orchestration.ClientList, batches []nodeBatch, startRadius uint8, options Options) error { c.logger.Infof("diluting %d batches to depth %d to push chunks outside the storage radius", len(batches), options.DiluteDepth) @@ -619,7 +562,6 @@ func (c *Check) dilute(ctx context.Context, nodes orchestration.ClientList, batc c.logger.Infof("%s: cannot read batch %s: %v", batch.node.Name(), batch.batchID, err) continue } - // Dilution only ever increases depth; bee rejects a lower one. if uint64(stamp.Depth) >= options.DiluteDepth { c.logger.Infof("%s: batch already at depth %d, skipping", batch.node.Name(), stamp.Depth) continue @@ -640,10 +582,7 @@ func (c *Check) dilute(ctx context.Context, nodes orchestration.ClientList, batc return c.waitForStorageRadiusDecrease(ctx, nodes, startRadius, options) } -// waitForRadiusDecrease blocks until the storage radius drops below startRadius. -// -// Bee steps the radius down one bin per reserve-worker tick, which is 15 minutes -// on a stock node, so this is slow by design. +// waitForStorageRadiusDecrease waits for any node's radius to drop below the starting radius. func (c *Check) waitForStorageRadiusDecrease(ctx context.Context, nodes orchestration.ClientList, startRadius uint8, options Options) error { ticker := time.NewTicker(options.PollInterval) defer ticker.Stop() @@ -661,7 +600,6 @@ func (c *Check) waitForStorageRadiusDecrease(ctx context.Context, nodes orchestr } lowestRadius, chunksWithinRadius, pullsyncRate, reachable := c.radiusDecreaseState(ctx, nodes) - // One node stepping down is enough: the decrease has been demonstrated. if reachable && lowestRadius < startRadius { c.logger.Infof("storage radius decreased %d -> %d after %s", startRadius, lowestRadius, time.Since(startedAt).Round(time.Second)) @@ -684,8 +622,7 @@ func (c *Check) waitForStorageRadiusDecrease(ctx context.Context, nodes orchestr } } -// pipelineState sums reserve sizes and pending pusher backlogs across the cluster, -// and reports the highest storage radius seen. Unreachable nodes are skipped. +// pipelineState returns total reserves, pending chunks, and the highest radius in the cluster. func (c *Check) pipelineState(ctx context.Context, nodes orchestration.ClientList) (reserveTotal, pendingChunks int, highestRadius uint8) { for _, node := range nodes { if status, err := node.Status(ctx); err == nil { @@ -699,25 +636,13 @@ func (c *Check) pipelineState(ctx context.Context, nodes orchestration.ClientLis return reserveTotal, pendingChunks, highestRadius } -// uploadCount is how many requests are needed to cover the target, rounded up. -// -// This is a total rather than a per-batch figure: rounding up per batch would -// always schedule at least one upload for every batch, which on a cluster with -// more batches than needed requests overshoots the target several times over. +// uploadCount returns the total number of upload requests needed. func (p uploadPlan) uploadCount() int { return max((p.totalChunks+p.chunksPerUpload-1)/p.chunksPerUpload, 1) } -// stopWhenPipelineFull halts uploading once the radius has risen, a reserve is -// over capacity, or this run has put enough chunks into the pipeline. -// -// The last of those is normally the earliest signal. Uploads are deferred, so -// they sit in the upload store until the pushers deliver them; by the time a -// reserve looks full the pipeline holds far more than was needed, and anything -// uploaded beyond that is only evicted on arrival. -// -// Progress is measured against the reserve and backlog seen at the start, since a -// cluster that has been filled before begins with both already well above zero. +// stopWhenPipelineFull halts uploads once the pipeline holds enough chunks or radius rises. +// Measures growth from baseline to handle pre-filled clusters. func (c *Check) stopWhenPipelineFull(ctx context.Context, batches []nodeBatch, plan uploadPlan, options Options, stopUploading func()) { ticker := time.NewTicker(options.PollInterval) defer ticker.Stop() @@ -764,8 +689,6 @@ func (c *Check) stopWhenPipelineFull(ctx context.Context, batches []nodeBatch, p } } - // A reserve already counts what the pipeline delivered, so the pending - // backlog adds to it rather than being counted separately. delivered := largestReserve - baseReserve queued := pendingChunks - basePending if sawDebugStore && delivered+queued >= chunksNeeded { @@ -777,9 +700,7 @@ func (c *Check) stopWhenPipelineFull(ctx context.Context, batches []nodeBatch, p } } - -// pipelineBaseline records the reserve size and pusher backlog before uploading, -// so progress can be measured as growth rather than absolute totals. +// pipelineBaseline snapshots reserve size and pusher backlog at the start. func (c *Check) pipelineBaseline(ctx context.Context, batches []nodeBatch) (reserve, pending int) { for _, batch := range batches { if status, err := batch.node.Status(ctx); err == nil { @@ -792,15 +713,9 @@ func (c *Check) pipelineBaseline(ctx context.Context, batches []nodeBatch) (rese return reserve, pending } - -// radiusDecreaseState reports the inputs to bee's radius-decrease decision: the -// lowest radius in the cluster, chunks held within radius, and the pullsync rate. -// -// The lowest radius is what matters because one node stepping down is enough to -// call the decrease observed. It is reported as reachable=false when no node -// answered, so a cluster-wide outage is not mistaken for a decrease. +// radiusDecreaseState returns the lowest radius, chunks within it, and pullsync rate across the cluster. func (c *Check) radiusDecreaseState(ctx context.Context, nodes orchestration.ClientList) (lowestRadius uint8, chunksWithinRadius int, pullsyncRate float64, reachable bool) { - lowestRadius = math.MaxUint8 + lowestRadius = uint8(31) for _, node := range nodes { status, err := node.Status(ctx) if err != nil { @@ -812,4 +727,4 @@ func (c *Check) radiusDecreaseState(ctx context.Context, nodes orchestration.Cli pullsyncRate += status.PullsyncRate } return lowestRadius, chunksWithinRadius, pullsyncRate, reachable -} \ No newline at end of file +} From 1134770d248f1dc657be47a6ec543e05077b4812 Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Wed, 19 Aug 2026 14:40:43 +0200 Subject: [PATCH 08/11] refactor: disable persistence for ng-bee-playground and refactor upload plan logic --- config/testnet-bee-playground.yaml | 2 +- pkg/bee/api/debugstore.go | 2 +- pkg/check/storageradius/storageradius.go | 57 +++++++++++------------- pkg/config/check.go | 2 - 4 files changed, 29 insertions(+), 34 deletions(-) diff --git a/config/testnet-bee-playground.yaml b/config/testnet-bee-playground.yaml index 28a4eae8..83ba068e 100644 --- a/config/testnet-bee-playground.yaml +++ b/config/testnet-bee-playground.yaml @@ -32,7 +32,7 @@ clusters: node-groups: ng-bee-playground: _inherit: default - persistence-enabled: true + persistence-enabled: false image: ethersphere/bee:latest ingress-class: "nginx-oss" ingress-annotations: diff --git a/pkg/bee/api/debugstore.go b/pkg/bee/api/debugstore.go index fbe0e7e6..f81d58e0 100644 --- a/pkg/bee/api/debugstore.go +++ b/pkg/bee/api/debugstore.go @@ -54,4 +54,4 @@ func (d *DebugStoreService) GetDebugStore(ctx context.Context) (DebugStore, erro var resp DebugStore err := d.client.requestJSON(ctx, http.MethodGet, "/debugstore", nil, &resp) return resp, err -} \ No newline at end of file +} diff --git a/pkg/check/storageradius/storageradius.go b/pkg/check/storageradius/storageradius.go index 75ffb472..67c9e6e0 100644 --- a/pkg/check/storageradius/storageradius.go +++ b/pkg/check/storageradius/storageradius.go @@ -5,7 +5,6 @@ import ( crand "crypto/rand" "errors" "fmt" - "math" "sync" "time" @@ -93,18 +92,17 @@ func (c *Check) Run(ctx context.Context, cluster orchestration.Cluster, opts any initialStorageRadius := status.StorageRadius - uploadPlan := newUploadPlan(initialStorageRadius, o) - batchCount := min(o.ChunksPerUpload, len(fullNodes)) + uploadPlan := newUploadPlan(o) - c.logger.Infof("cluster: %d full nodes at storage radius %d => %.0f neighborhood(s)", len(fullNodes), initialStorageRadius, uploadPlan.neighborhoods) - c.logger.Infof("target %d chunks (%.0f%% of %d per neighborhood), %d chunks per upload", uploadPlan.totalChunks, o.TargetFillPercent*100, o.ReserveCapacity, uploadPlan.chunksPerUpload) + c.logger.Infof("cluster: %d full nodes, sizing upload to trigger radius increase", len(fullNodes)) + c.logger.Infof("target %d chunks (%.0f%% of %d), %d chunks per upload", uploadPlan.totalChunks, o.TargetFillPercent*100, o.ReserveCapacity, uploadPlan.chunksPerUpload) chunksBefore, err := c.logClusterState(ctx, cluster, "before") if err != nil { return err } - batches, err := c.prepareBatches(ctx, fullNodes, batchCount, o) + batches, err := c.prepareBatches(ctx, fullNodes, len(fullNodes), o) if err != nil { return err } @@ -158,16 +156,19 @@ func (c *Check) waitForWarmup(ctx context.Context, cluster orchestration.Cluster defer ticker.Stop() for { - clients, err := cluster.NodesClients(ctx) + clients, err := cluster.ShuffledFullNodeClients(ctx, random.PseudoGenerator(0)) if err != nil { - return fmt.Errorf("get nodes clients: %w", err) + return fmt.Errorf("get full node clients: %w", err) + } + if len(clients) == 0 { + return errors.New("no full nodes available") } warmingUp := 0 - for name, client := range clients { + for _, client := range clients { status, err := client.Status(ctx) if err != nil { - return fmt.Errorf("node %s: status: %w", name, err) + return fmt.Errorf("node %s: status: %w", client.Name(), err) } if status.IsWarmingUp { warmingUp++ @@ -175,14 +176,14 @@ func (c *Check) waitForWarmup(ctx context.Context, cluster orchestration.Cluster } if warmingUp == 0 { - c.logger.Infof("all %d nodes finished warming up", len(clients)) + c.logger.Infof("all %d full nodes finished warming up", len(clients)) return nil } - c.logger.Infof("waiting for %d/%d nodes to finish warming up", warmingUp, len(clients)) + c.logger.Infof("waiting for %d/%d full nodes to finish warming up", warmingUp, len(clients)) select { case <-ctx.Done(): - return fmt.Errorf("timed out waiting for nodes to finish warming up: %w", ctx.Err()) + return fmt.Errorf("timed out waiting for full nodes to finish warming up: %w", ctx.Err()) case <-ticker.C: } } @@ -190,21 +191,17 @@ func (c *Check) waitForWarmup(ctx context.Context, cluster orchestration.Cluster // uploadPlan is the computed upload sizing for a cluster. type uploadPlan struct { - neighborhoods float64 totalChunks int chunksPerUpload int } -// newUploadPlan calculates upload size based on cluster neighborhoods (2^radius). -// Chunks replicate across all nodes in a neighborhood, so we size by neighborhood count, not node count. -// Example: at radius=0 (1 neighborhood), all 8 nodes replicate the same chunks → upload ~4000 total. -// At radius=1 (2 neighborhoods), nodes split into 2 groups → upload ~8000 (4000 per neighborhood). -func newUploadPlan(radius uint8, o Options) uploadPlan { - neighborhoods := math.Pow(2, float64(radius)) - totalChunks := int(o.TargetFillPercent * float64(o.ReserveCapacity) * neighborhoods) +// newUploadPlan calculates upload size needed to trigger a radius increase. +// We size by reserve capacity with target fill percent; no need to multiply by +// neighborhoods since we're just overflowing reserves to trigger eviction/radius logic. +func newUploadPlan(o Options) uploadPlan { + totalChunks := int(o.TargetFillPercent * float64(o.ReserveCapacity)) return uploadPlan{ - neighborhoods: neighborhoods, totalChunks: totalChunks, chunksPerUpload: min(o.ChunksPerUpload, totalChunks), } @@ -334,7 +331,7 @@ func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestr c.logger.Infof("waiting up to %s for the pushers to fill the reserves and the radius to rise", o.MinRadiusWait) startedAt := time.Now() - previousTotal, stablePolls := -1, 0 + prevReserveSize, stablePolls := -1, 0 for { select { @@ -351,7 +348,7 @@ func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestr } elapsed := time.Since(startedAt) - if reserveTotal == previousTotal { + if reserveTotal == prevReserveSize { stablePolls++ if stablePolls >= stablePollsBeforeGivingUp && elapsed >= o.MinRadiusWait { c.logger.Infof("reserves settled at %d chunks and radius still 0 after %s", @@ -359,13 +356,13 @@ func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestr return 0, nil } } else { - if previousTotal >= 0 { + if prevReserveSize >= 0 { c.logger.Infof("reserves at %d chunks (+%d), %d pending in the pushers, radius 0 (%s elapsed)", - reserveTotal, reserveTotal-previousTotal, pendingChunks, elapsed.Round(time.Second)) + reserveTotal, reserveTotal-prevReserveSize, pendingChunks, elapsed.Round(time.Second)) } stablePolls = 0 } - previousTotal = reserveTotal + prevReserveSize = reserveTotal } } @@ -513,7 +510,7 @@ func (c *Check) waitForPushersIdle(ctx context.Context, nodes orchestration.Clie c.logger.Infof("waiting up to %s for the pusher backlog to settle", options.PushersIdleWait) startedAt := time.Now() - previousPending, stablePolls := -1, 0 + prevPendingChunks, stablePolls := -1, 0 for { _, pendingChunks, _ := c.pipelineState(ctx, nodes) @@ -524,7 +521,7 @@ func (c *Check) waitForPushersIdle(ctx context.Context, nodes orchestration.Clie return nil } - if pendingChunks >= previousPending && previousPending >= 0 { + if pendingChunks >= prevPendingChunks && prevPendingChunks >= 0 { stablePolls++ if stablePolls >= stablePollsBeforeGivingUp { c.logger.Infof("pusher backlog stable at %d chunks after %s, continuing", pendingChunks, elapsed.Round(time.Second)) @@ -540,7 +537,7 @@ func (c *Check) waitForPushersIdle(ctx context.Context, nodes orchestration.Clie } c.logger.Infof("%d chunks still pending in the pushers (%s elapsed)", pendingChunks, elapsed.Round(time.Second)) - previousPending = pendingChunks + prevPendingChunks = pendingChunks select { case <-ctx.Done(): diff --git a/pkg/config/check.go b/pkg/config/check.go index 8e585797..148271e2 100644 --- a/pkg/config/check.go +++ b/pkg/config/check.go @@ -743,8 +743,6 @@ var Checks = map[string]CheckType{ NewOptions: func(checkGlobalConfig CheckGlobalConfig, check Check) (any, error) { checkOpts := new(struct { PollInterval *time.Duration `yaml:"poll-interval"` - WaitForWarmup *bool `yaml:"wait-for-warmup"` - Seed *int64 `yaml:"seed"` TargetFillPercent *float64 `yaml:"target-fill-percent"` ChunksPerUpload *int `yaml:"chunks-per-upload"` ReserveCapacity *int `yaml:"reserve-capacity"` From e1a5f453696fafaa012f773378dad7e9b8bcb50d Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Wed, 19 Aug 2026 14:56:53 +0200 Subject: [PATCH 09/11] refactor: clarify comments and improve upload plan logic in storageradius --- pkg/check/storageradius/storageradius.go | 26 +++++++++++------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/pkg/check/storageradius/storageradius.go b/pkg/check/storageradius/storageradius.go index 67c9e6e0..bff7377b 100644 --- a/pkg/check/storageradius/storageradius.go +++ b/pkg/check/storageradius/storageradius.go @@ -25,7 +25,7 @@ type Options struct { TargetFillPercent float64 // fraction of capacity to fill (>1 overshoots) ReserveCapacity int // per-node reserve capacity PollInterval time.Duration // how often to check node status - ChunksPerUpload int // bytes per upload request + ChunksPerUpload int // chunks per upload request MinRadiusWait time.Duration // min time watching for radius increase PushersIdleWait time.Duration // max wait for backlog to settle DiluteDepth uint64 // depth to dilute to (32 is max) @@ -108,9 +108,9 @@ func (c *Check) Run(ctx context.Context, cluster orchestration.Cluster, opts any } uploadedChunks := 0 - if pending, enough := c.pipelineAlreadyFull(ctx, batches, uploadPlan, o); enough { + if pending, enough := c.pipelineAlreadyFull(ctx, batches, uploadPlan); enough { c.logger.Infof("pipeline already holds %d chunks, more than the %d needed, skipping uploads", - pending, uploadPlan.chunksNeeded(o)) + pending, uploadPlan.totalChunks) } else { uploadedChunks, err = c.upload(ctx, batches, uploadPlan, o) if err != nil { @@ -231,11 +231,6 @@ func (c *Check) logClusterState(ctx context.Context, cluster orchestration.Clust return reserveTotal, nil } -// chunksNeeded is how many chunks must reach a single reserve to fill it. -func (p uploadPlan) chunksNeeded(options Options) int { - return int(float64(options.ReserveCapacity) * options.TargetFillPercent) -} - // nodeBatch pairs a postage batch with the node that owns it. type nodeBatch struct { batchID string @@ -243,7 +238,9 @@ type nodeBatch struct { } // prepareBatches buys postage batches for each node, reusing existing ones to save time and tokens. -// Uses WaitGroup instead of errgroup to avoid hanging 900s if a batch purchase reverts on-chain. +// Uses WaitGroup rather than errgroup because a failed purchase must not fail the whole check: +// batch creation can revert on-chain per node, and the check only needs enough batches to fill +// one reserve, so failures are collected and reported while the usable batches are returned. func (c *Check) prepareBatches(ctx context.Context, nodes orchestration.ClientList, batchCount int, o Options) ([]nodeBatch, error) { c.logger.Infof("preparing %d postage batches in parallel (depth %d, amount %d)", batchCount, o.PostageDepth, o.PostageAmount) @@ -322,8 +319,9 @@ func (c *Check) batchForNode(ctx context.Context, node *bee.Client, o Options) ( return batchID, false, nil } -// waitForStorageRadiusIncrease waits for any node's radius to climb above zero. -// Requires both stable reserves AND MinRadiusWait elapsed, since uploads are deferred. +// waitForStorageRadiusIncrease waits for any node's radius to climb above zero, returning +// that radius as soon as one does. It gives up and returns 0 only once the reserves have +// stopped growing AND MinRadiusWait has elapsed, since uploads reach the reserves lazily. func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestration.ClientList, o Options) (uint8, error) { ticker := time.NewTicker(o.PollInterval) defer ticker.Stop() @@ -367,8 +365,8 @@ func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestr } // pipelineAlreadyFull checks if reserves and pusher backlog already hold enough chunks. -func (c *Check) pipelineAlreadyFull(ctx context.Context, batches []nodeBatch, plan uploadPlan, options Options) (chunks int, full bool) { - chunksNeeded := plan.chunksNeeded(options) +func (c *Check) pipelineAlreadyFull(ctx context.Context, batches []nodeBatch, plan uploadPlan) (chunks int, full bool) { + chunksNeeded := plan.totalChunks for _, batch := range batches { status, err := batch.node.Status(ctx) @@ -644,7 +642,7 @@ func (c *Check) stopWhenPipelineFull(ctx context.Context, batches []nodeBatch, p ticker := time.NewTicker(options.PollInterval) defer ticker.Stop() - chunksNeeded := plan.chunksNeeded(options) + chunksNeeded := plan.totalChunks baseReserve, basePending := c.pipelineBaseline(ctx, batches) for { From f02d50be6512c1033acc243f7bf0eb6bf0ce4852 Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Thu, 20 Aug 2026 12:58:10 +0200 Subject: [PATCH 10/11] refactor: update storage radius handling to return the node that triggered the increase --- config/testnet-bee-playground.yaml | 1 - pkg/check/storageradius/storageradius.go | 95 ++++++++++++------------ 2 files changed, 46 insertions(+), 50 deletions(-) diff --git a/config/testnet-bee-playground.yaml b/config/testnet-bee-playground.yaml index 83ba068e..c53beefc 100644 --- a/config/testnet-bee-playground.yaml +++ b/config/testnet-bee-playground.yaml @@ -205,7 +205,6 @@ checks: options: reserve-capacity: 4000 target-fill-percent: 1.03 - stamps: 16 chunks-per-upload: 512 postage-depth: 22 postage-amount: 2073600000 diff --git a/pkg/check/storageradius/storageradius.go b/pkg/check/storageradius/storageradius.go index bff7377b..7d2246a6 100644 --- a/pkg/check/storageradius/storageradius.go +++ b/pkg/check/storageradius/storageradius.go @@ -118,7 +118,7 @@ func (c *Check) Run(ctx context.Context, cluster orchestration.Cluster, opts any } } - storageRadius, err := c.waitForStorageRadiusIncrease(ctx, fullNodes, o) + risenNode, storageRadius, err := c.waitForStorageRadiusIncrease(ctx, fullNodes, o) if err != nil { return err } @@ -142,7 +142,7 @@ func (c *Check) Run(ctx context.Context, cluster orchestration.Cluster, opts any } else if err := c.waitForPushersIdle(ctx, fullNodes, o); err != nil { return err } - if err := c.dilute(ctx, fullNodes, batches, storageRadius, o); err != nil { + if err := c.dilute(ctx, risenNode, batches, storageRadius, o); err != nil { return err } @@ -320,9 +320,11 @@ func (c *Check) batchForNode(ctx context.Context, node *bee.Client, o Options) ( } // waitForStorageRadiusIncrease waits for any node's radius to climb above zero, returning -// that radius as soon as one does. It gives up and returns 0 only once the reserves have -// stopped growing AND MinRadiusWait has elapsed, since uploads reach the reserves lazily. -func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestration.ClientList, o Options) (uint8, error) { +// that node and its radius as soon as one does, so the caller can later check the same +// node's radius for the decrease rather than a different node that never moved. It gives +// up and returns a nil node with radius 0 only once the reserves have stopped growing AND +// MinRadiusWait has elapsed, since uploads reach the reserves lazily. +func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestration.ClientList, o Options) (*bee.Client, uint8, error) { ticker := time.NewTicker(o.PollInterval) defer ticker.Stop() @@ -334,15 +336,15 @@ func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestr for { select { case <-ctx.Done(): - return 0, fmt.Errorf("timed out waiting for the storage radius to rise above 0: %w", ctx.Err()) + return nil, 0, fmt.Errorf("timed out waiting for the storage radius to rise above 0: %w", ctx.Err()) case <-ticker.C: } - reserveTotal, pendingChunks, highestRadius := c.pipelineState(ctx, nodes) + reserveTotal, pendingChunks, risenNode, highestRadius := c.pipelineState(ctx, nodes) if highestRadius > 0 { c.logger.Infof("storage radius is %d after %s (reserves at %d chunks)", highestRadius, time.Since(startedAt).Round(time.Second), reserveTotal) - return highestRadius, nil + return risenNode, highestRadius, nil } elapsed := time.Since(startedAt) @@ -351,7 +353,7 @@ func (c *Check) waitForStorageRadiusIncrease(ctx context.Context, nodes orchestr if stablePolls >= stablePollsBeforeGivingUp && elapsed >= o.MinRadiusWait { c.logger.Infof("reserves settled at %d chunks and radius still 0 after %s", reserveTotal, elapsed.Round(time.Second)) - return 0, nil + return nil, 0, nil } } else { if prevReserveSize >= 0 { @@ -511,7 +513,7 @@ func (c *Check) waitForPushersIdle(ctx context.Context, nodes orchestration.Clie prevPendingChunks, stablePolls := -1, 0 for { - _, pendingChunks, _ := c.pipelineState(ctx, nodes) + _, pendingChunks, _, _ := c.pipelineState(ctx, nodes) elapsed := time.Since(startedAt) if pendingChunks == 0 { @@ -546,7 +548,7 @@ func (c *Check) waitForPushersIdle(ctx context.Context, nodes orchestration.Clie } // dilute increases batch depths to push chunks outside the storage radius. -func (c *Check) dilute(ctx context.Context, nodes orchestration.ClientList, batches []nodeBatch, startRadius uint8, options Options) error { +func (c *Check) dilute(ctx context.Context, risenNode *bee.Client, batches []nodeBatch, startRadius uint8, options Options) error { c.logger.Infof("diluting %d batches to depth %d to push chunks outside the storage radius", len(batches), options.DiluteDepth) @@ -574,18 +576,20 @@ func (c *Check) dilute(ctx context.Context, nodes orchestration.ClientList, batc return errors.New("no batches were diluted, cannot provoke a radius decrease") } - return c.waitForStorageRadiusDecrease(ctx, nodes, startRadius, options) + return c.waitForStorageRadiusDecrease(ctx, risenNode, startRadius, options) } -// waitForStorageRadiusDecrease waits for any node's radius to drop below the starting radius. -func (c *Check) waitForStorageRadiusDecrease(ctx context.Context, nodes orchestration.ClientList, startRadius uint8, options Options) error { +// waitForStorageRadiusDecrease waits for the node whose radius rose to drop back below +// startRadius. It checks the same node that triggered the increase rather than a +// cluster-wide extremum, since radius is decided locally per node and other nodes may +// never have left radius 0. +func (c *Check) waitForStorageRadiusDecrease(ctx context.Context, risenNode *bee.Client, startRadius uint8, options Options) error { ticker := time.NewTicker(options.PollInterval) defer ticker.Stop() - c.logger.Infof("waiting up to %s for any node's storage radius to fall below %d", options.DiluteWait, startRadius) + c.logger.Infof("waiting up to %s for %s's storage radius to fall below %d", options.DiluteWait, risenNode.Name(), startRadius) startedAt := time.Now() - decreaseThreshold := options.ReserveCapacity * 8 / 10 for { select { @@ -594,41 +598,50 @@ func (c *Check) waitForStorageRadiusDecrease(ctx context.Context, nodes orchestr case <-ticker.C: } - lowestRadius, chunksWithinRadius, pullsyncRate, reachable := c.radiusDecreaseState(ctx, nodes) - if reachable && lowestRadius < startRadius { - c.logger.Infof("storage radius decreased %d -> %d after %s", - startRadius, lowestRadius, time.Since(startedAt).Round(time.Second)) - return nil - } + elapsed := time.Since(startedAt) - if !reachable { - c.logger.Infof("no node answered, retrying") + status, err := risenNode.Status(ctx) + if err != nil { + if elapsed >= options.DiluteWait { + return fmt.Errorf("storage radius stayed at %d after %s: %s did not answer: %w", + startRadius, options.DiluteWait, risenNode.Name(), err) + } + c.logger.Infof("%s did not answer, retrying (%s elapsed)", risenNode.Name(), elapsed.Round(time.Second)) continue } - elapsed := time.Since(startedAt) + if status.StorageRadius < startRadius { + c.logger.Infof("%s's storage radius decreased %d -> %d after %s", + risenNode.Name(), startRadius, status.StorageRadius, elapsed.Round(time.Second)) + return nil + } + if elapsed >= options.DiluteWait { - return fmt.Errorf("storage radius stayed at %d after %s: %d chunks within radius (threshold %d), pullsync rate %.2f", - startRadius, options.DiluteWait, chunksWithinRadius, decreaseThreshold, pullsyncRate) + return fmt.Errorf("%s's storage radius stayed at %d after %s: %d chunks within radius, pullsync rate %.2f", + risenNode.Name(), startRadius, options.DiluteWait, status.ReserveSizeWithinRadius, status.PullsyncRate) } - c.logger.Infof("radius still %d, %d chunks within radius (threshold %d), pullsync %.2f (%s elapsed)", - lowestRadius, chunksWithinRadius, decreaseThreshold, pullsyncRate, elapsed.Round(time.Second)) + c.logger.Infof("%s's radius still %d, %d chunks within radius, pullsync %.2f (%s elapsed)", + risenNode.Name(), status.StorageRadius, status.ReserveSizeWithinRadius, status.PullsyncRate, elapsed.Round(time.Second)) } } -// pipelineState returns total reserves, pending chunks, and the highest radius in the cluster. -func (c *Check) pipelineState(ctx context.Context, nodes orchestration.ClientList) (reserveTotal, pendingChunks int, highestRadius uint8) { +// pipelineState returns total reserves, pending chunks, the highest radius in the cluster, +// and the node that reported it. +func (c *Check) pipelineState(ctx context.Context, nodes orchestration.ClientList) (reserveTotal, pendingChunks int, highestRadiusNode *bee.Client, highestRadius uint8) { for _, node := range nodes { if status, err := node.Status(ctx); err == nil { reserveTotal += int(status.ReserveSize) - highestRadius = max(highestRadius, status.StorageRadius) + if status.StorageRadius > highestRadius || highestRadiusNode == nil { + highestRadius = status.StorageRadius + highestRadiusNode = node + } } if debugStore, err := node.API().DebugStore.GetDebugStore(ctx); err == nil { pendingChunks += debugStore.Upload.PendingUpload } } - return reserveTotal, pendingChunks, highestRadius + return reserveTotal, pendingChunks, highestRadiusNode, highestRadius } // uploadCount returns the total number of upload requests needed. @@ -707,19 +720,3 @@ func (c *Check) pipelineBaseline(ctx context.Context, batches []nodeBatch) (rese } return reserve, pending } - -// radiusDecreaseState returns the lowest radius, chunks within it, and pullsync rate across the cluster. -func (c *Check) radiusDecreaseState(ctx context.Context, nodes orchestration.ClientList) (lowestRadius uint8, chunksWithinRadius int, pullsyncRate float64, reachable bool) { - lowestRadius = uint8(31) - for _, node := range nodes { - status, err := node.Status(ctx) - if err != nil { - continue - } - reachable = true - lowestRadius = min(lowestRadius, status.StorageRadius) - chunksWithinRadius += int(status.ReserveSizeWithinRadius) - pullsyncRate += status.PullsyncRate - } - return lowestRadius, chunksWithinRadius, pullsyncRate, reachable -} From c4fc4bce4fe660368956fbcb62f06525dfa81c41 Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Thu, 20 Aug 2026 14:10:06 +0200 Subject: [PATCH 11/11] refactor: enhance clarity in Options struct and improve upload logic comments --- pkg/check/storageradius/storageradius.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pkg/check/storageradius/storageradius.go b/pkg/check/storageradius/storageradius.go index 7d2246a6..41012d69 100644 --- a/pkg/check/storageradius/storageradius.go +++ b/pkg/check/storageradius/storageradius.go @@ -30,7 +30,7 @@ type Options struct { PushersIdleWait time.Duration // max wait for backlog to settle DiluteDepth uint64 // depth to dilute to (32 is max) DiluteWait time.Duration // timeout for radius decrease - UploadWavePause time.Duration // pause between upload waves + UploadWavePause time.Duration // pause between upload dispatches, so the watcher can catch up UploadTimeout time.Duration // timeout per upload request PostageAmount int64 PostageLabel string @@ -375,15 +375,17 @@ func (c *Check) pipelineAlreadyFull(ctx context.Context, batches []nodeBatch, pl if err != nil { continue } - // A radius already above zero means bee has reacted; nothing to add. - if status.StorageRadius > 0 { - return int(status.ReserveSize), true - } inPipeline := int(status.ReserveSize) if debugStore, err := batch.node.API().DebugStore.GetDebugStore(ctx); err == nil { inPipeline += debugStore.Upload.PendingUpload } + + // A radius already above 0 means bee has reached the goal + if status.StorageRadius > 0 { + return inPipeline, true + } + chunks = max(chunks, inPipeline) } @@ -416,8 +418,7 @@ func (c *Check) upload(ctx context.Context, batches []nodeBatch, plan uploadPlan for i := range totalUploads { if i > 0 && i%len(batches) == 0 { - // Pause between waves so the watcher can poll and detect when to stop. - // Without this, uploads finish before the watcher sees them. + // Pause between waves so the watcher's independent poll loop gets a chance to observe pipeline growth and call stopUploading before the next wave fires off. Without this, a fast cluster can spin up all uploads within a single PollInterval and the watcher never sees them. select { case <-enough: case <-groupCtx.Done(): @@ -483,6 +484,7 @@ func (c *Check) radiusUnchangedError(chunksBefore, chunksAfter, uploadedChunks i } // reservesIsAtCapacity checks if all nodes have reserves at 95% or higher. +// An unreachable node counts as not at capacity, since we can't confirm it. func (c *Check) reservesIsAtCapacity(ctx context.Context, nodes orchestration.ClientList, options Options) bool { full := options.ReserveCapacity * 95 / 100 sawNode := false @@ -490,7 +492,7 @@ func (c *Check) reservesIsAtCapacity(ctx context.Context, nodes orchestration.Cl for _, node := range nodes { status, err := node.Status(ctx) if err != nil { - continue + return false } sawNode = true if int(status.ReserveSize) < full {