This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
The following commit(s) were added to refs/heads/main by this push:
new 6f7d712f4 fix(storage): prevent DiskMonitor.Stop from hanging when
forced cleanup is active, leaking the whole server on shutdown (#1216)
6f7d712f4 is described below
commit 6f7d712f4f78445b531a4cdbd76efe4a51f7e0c5
Author: Gao Hongtao <[email protected]>
AuthorDate: Mon Jul 13 15:52:25 2026 +0800
fix(storage): prevent DiskMonitor.Stop from hanging when forced cleanup is
active, leaking the whole server on shutdown (#1216)
---
banyand/internal/storage/disk_monitor.go | 42 +++++++++++++++---
banyand/internal/storage/disk_monitor_test.go | 63 +++++++++++++++++++++++++++
banyand/measure/metadata.go | 1 +
banyand/measure/metadata_test.go | 36 ++++++++++++++-
4 files changed, 135 insertions(+), 7 deletions(-)
diff --git a/banyand/internal/storage/disk_monitor.go
b/banyand/internal/storage/disk_monitor.go
index 58a8debde..7a3b8abc3 100644
--- a/banyand/internal/storage/disk_monitor.go
+++ b/banyand/internal/storage/disk_monitor.go
@@ -75,10 +75,12 @@ type DiskMonitor struct {
logger *logger.Logger
ticker *time.Ticker
stopCh chan struct{}
+ doneCh chan struct{}
metrics *diskMonitorMetrics
config RetentionConfig
snapshotMaxAge time.Duration
isActive atomic.Bool
+ started atomic.Bool
}
type diskMonitorMetrics struct {
@@ -136,6 +138,7 @@ func NewDiskMonitor(service RetentionService, config
RetentionConfig, omr observ
config: config,
logger: logger,
stopCh: make(chan struct{}),
+ doneCh: make(chan struct{}),
metrics: metrics,
snapshotMaxAge: 24 * time.Hour, // Always keep snapshots newer
than 24h
}
@@ -143,8 +146,14 @@ func NewDiskMonitor(service RetentionService, config
RetentionConfig, omr observ
// Start begins monitoring disk usage and starts the forced retention process.
func (dm *DiskMonitor) Start() {
+ // Mark the monitor as started so Stop() knows a doneCh close is
guaranteed
+ // (either from the disabled path below or from monitorLoop on exit).
+ dm.started.Store(true)
+
if dm.config.CheckInterval <= 0 {
dm.logger.Warn().Msg("disk monitor check interval is 0 or
negative, monitor disabled")
+ // No monitor loop will run, so release Stop()'s wait
immediately.
+ close(dm.doneCh)
return
}
@@ -164,20 +173,37 @@ func (dm *DiskMonitor) Start() {
// Stop stops the disk monitor gracefully.
func (dm *DiskMonitor) Stop() {
+ // If Start() was never called, no monitor loop exists and doneCh will
never
+ // be closed, so there is nothing to stop or wait for. Returning here
keeps
+ // Stop() safe to call on a constructed-but-unstarted monitor.
+ if !dm.started.Load() {
+ dm.logger.Info().Msg("disk monitor stopped")
+ return
+ }
+
if dm.ticker != nil {
dm.ticker.Stop()
}
close(dm.stopCh)
- // Wait for any active cleanup to finish
- for dm.isActive.Load() {
- time.Sleep(100 * time.Millisecond)
- }
+ // Wait for the monitor loop (including any in-flight cleanup cycle) to
+ // exit. The loop clears isActive on exit, so we must not poll isActive
+ // here: once stopCh is closed the loop may return without ever running
+ // another cleanup cycle, leaving isActive set and hanging Stop()
forever.
+ <-dm.doneCh
dm.logger.Info().Msg("disk monitor stopped")
}
func (dm *DiskMonitor) monitorLoop(serviceName string) {
+ defer func() {
+ // Deactivate on exit so a forced cleanup that is still active
when the
+ // monitor stops does not leave the monitor marked active, and
to unblock
+ // Stop() which waits on doneCh.
+ dm.isActive.Store(false)
+ dm.metrics.forcedRetentionActive.Set(0, serviceName)
+ close(dm.doneCh)
+ }()
for {
select {
case <-dm.stopCh:
@@ -296,9 +322,13 @@ func (dm *DiskMonitor) runForcedCleanup(serviceName
string, _ int) {
return
}
- // Sleep for cooldown period
+ // Sleep for cooldown period, aborting early if the monitor is
stopping
+ // so shutdown is not delayed by a full cooldown interval.
dm.logger.Debug().Dur("cooldown",
dm.config.Cooldown).Msg("cooling down between deletions")
- time.Sleep(dm.config.Cooldown)
+ select {
+ case <-time.After(dm.config.Cooldown):
+ case <-dm.stopCh:
+ }
} else {
// No more segments to delete, stop cleanup
dm.logger.Warn().Msg("no more segments available for deletion,
stopping forced cleanup")
diff --git a/banyand/internal/storage/disk_monitor_test.go
b/banyand/internal/storage/disk_monitor_test.go
index aad31c3d2..1d8418cf2 100644
--- a/banyand/internal/storage/disk_monitor_test.go
+++ b/banyand/internal/storage/disk_monitor_test.go
@@ -244,6 +244,69 @@ func TestDiskMonitor_StartStop(t *testing.T) {
assert.False(t, dm.isActive.Load())
}
+func TestDiskMonitor_StopDoesNotHangWhenActive(t *testing.T) {
+ service := NewMockRetentionService()
+ config := RetentionConfig{
+ HighWatermark: 80.0,
+ LowWatermark: 60.0,
+ CheckInterval: time.Hour, // long interval so no cleanup
cycle races the test
+ Cooldown: time.Millisecond,
+ ForceCleanupEnabled: true,
+ }
+ registry := createMockMetricsRegistry()
+
+ dm := NewDiskMonitor(service, config, registry)
+ dm.Start()
+
+ // Simulate a forced cleanup that is still active when the monitor is
+ // stopped. Previously Stop() polled isActive, which the monitor loop
only
+ // clears from within a cleanup cycle; closing stopCh made the loop exit
+ // without clearing it, hanging Stop() forever.
+ dm.isActive.Store(true)
+
+ stopped := make(chan struct{})
+ go func() {
+ dm.Stop()
+ close(stopped)
+ }()
+
+ select {
+ case <-stopped:
+ case <-time.After(5 * time.Second):
+ t.Fatal("Stop() hung while a forced cleanup was active")
+ }
+ assert.False(t, dm.isActive.Load())
+}
+
+func TestDiskMonitor_StopBeforeStartDoesNotHang(t *testing.T) {
+ service := NewMockRetentionService()
+ config := RetentionConfig{
+ HighWatermark: 80.0,
+ LowWatermark: 60.0,
+ CheckInterval: time.Hour,
+ Cooldown: time.Millisecond,
+ ForceCleanupEnabled: true,
+ }
+ registry := createMockMetricsRegistry()
+
+ dm := NewDiskMonitor(service, config, registry)
+
+ // Stop() must be safe on a constructed-but-unstarted monitor. Stop()
waits
+ // on doneCh, which only Start()/monitorLoop close, so calling it before
+ // Start() previously blocked forever.
+ stopped := make(chan struct{})
+ go func() {
+ dm.Stop()
+ close(stopped)
+ }()
+
+ select {
+ case <-stopped:
+ case <-time.After(5 * time.Second):
+ t.Fatal("Stop() hung when called before Start()")
+ }
+}
+
func TestDiskMonitor_StartWithZeroInterval(t *testing.T) {
service := NewMockRetentionService()
config := RetentionConfig{
diff --git a/banyand/measure/metadata.go b/banyand/measure/metadata.go
index 9db478549..550c697c6 100644
--- a/banyand/measure/metadata.go
+++ b/banyand/measure/metadata.go
@@ -524,6 +524,7 @@ func (sr *schemaRepo) collectShardInfo(table any, shardID
uint32) *databasev1.Sh
PartCount: int64(partCount),
InvertedIndexInfo: &databasev1.InvertedIndexInfo{},
SidxInfo: &databasev1.SIDXInfo{},
+ FilePartCount: int64(filePartCount),
}
}
diff --git a/banyand/measure/metadata_test.go b/banyand/measure/metadata_test.go
index ef30f0c1a..a9d9a6baf 100644
--- a/banyand/measure/metadata_test.go
+++ b/banyand/measure/metadata_test.go
@@ -512,16 +512,33 @@ var _ = Describe("Schema Change", func() {
env := setupSchemaChangeMeasure(svcs, measureName,
measureSetupOptions{withExtraTag: true})
writeSchemaChangeMeasureData(svcs, measureName,
now.Add(-2*time.Hour), 5,
measureWriteDataOptions{withExtraTag: true})
+ filePartCountAfterFirstBatch, filePartCountErr :=
getMeasureFilePartCount(svcs, groupName)
+ Expect(filePartCountErr).ShouldNot(HaveOccurred())
changeExtraMeasureTagType(svcs, measureName)
writeSchemaChangeMeasureData(svcs, measureName,
now.Add(-1*time.Hour), 3,
measureWriteDataOptions{withExtraTagString:
true, entityIDPrefix: "entity_new_"})
+ // Wait for the second batch to flush to disk, creating
additional
+ // file parts that the merge loop can pick up. Without
this gate the
+ // merge Eventually below also has to absorb flush
latency, which can
+ // exceed 30 s on resource-constrained CI runners with
-race.
+ Eventually(func(innerGm Gomega) int64 {
+ currentFilePartCount, currentFilePartCountErr
:= getMeasureFilePartCount(svcs, groupName)
+
innerGm.Expect(currentFilePartCountErr).ShouldNot(HaveOccurred())
+ return currentFilePartCount
+ }, flags.EventuallyTimeout).Should(BeNumerically(">",
filePartCountAfterFirstBatch))
partCountBeforeMerge, partCountErr :=
getTotalMeasurePartCount(svcs, groupName)
Expect(partCountErr).ShouldNot(HaveOccurred())
+ // The background merge runs asynchronously; under the
full parallel -race
+ // suite on CI its goroutine competes for CPU and
snapshot locks with every
+ // other test. Poll on a relaxed interval (rather than
Gomega's ~10ms
+ // default) so this wait does not starve the merge it
is waiting for, with a
+ // generous, environment-scaled budget for merge
latency. The post-merge
+ // part count is stable (no further writes), so slow
polling never misses it.
Eventually(func(innerGm Gomega) int64 {
currentPartCount, currentPartCountErr :=
getTotalMeasurePartCount(svcs, groupName)
innerGm.Expect(currentPartCountErr).ShouldNot(HaveOccurred())
return currentPartCount
- }, flags.EventuallyTimeout).Should(BeNumerically("<",
partCountBeforeMerge))
+ }, 10*flags.EventuallyTimeout,
500*time.Millisecond).Should(BeNumerically("<", partCountBeforeMerge))
Eventually(func(innerGm Gomega) {
dataPoints :=
querySchemaChangeMeasureData(svcs, measureName,
@@ -1108,3 +1125,20 @@ func getTotalMeasurePartCount(svcs *services, group
string) (int64, error) {
}
return total, nil
}
+
+func getMeasureFilePartCount(svcs *services, group string) (int64, error) {
+ dataInfo, err := svcs.measure.CollectDataInfo(context.TODO(), group)
+ if err != nil {
+ return 0, fmt.Errorf("collect measure data info: %w", err)
+ }
+ if dataInfo == nil {
+ return 0, errors.New("measure data info is nil")
+ }
+ var total int64
+ for _, seg := range dataInfo.SegmentInfo {
+ for _, shard := range seg.ShardInfo {
+ total += shard.FilePartCount
+ }
+ }
+ return total, nil
+}