admin: restore the maintenance scan cadence after an error backoff

scanLoop shortens its ticker to the error backoff delay after a failed
scan, but it decided whether to replace the ticker by comparing the
target interval against the configured scan interval instead of against
the interval the ticker was actually running at. Once the errors stopped,
getScanInterval returned the configured interval again, the comparison
came out false, and the ticker was left at the backoff delay - so a
single transient scan failure pinned the scanner to one scan per second
for the rest of the process lifetime. That is the ~1/second cadence in
issue #10874: 658 KB/s of "Cancelled N stale pending balance tasks
before re-detection" and 193k orphaned task files over two days.

Track the interval the ticker is running at and compare against that, so
both entering the backoff and returning to the normal cadence replace the
ticker.

While in here:

- defer ticker.Stop() bound the ticker that was current when the defer
  was registered, so every replacement ticker leaked on return. Wrap it
  in a closure.
- running was written by Start/Stop and read by all three background
  loops without synchronisation. Guard it with the existing mutex, fold
  the running check in triggerScanInternal into the lock it already
  takes, and make Stop a no-op when not running so a second call cannot
  close the stop channel twice.

Refs #10874
This commit is contained in:
Junker der Provinz
2026-08-23 05:34:05 +02:00
parent 2d5ffecc1f
commit e0f07c762a
2 changed files with 211 additions and 11 deletions
+43 -11
View File
@@ -141,7 +141,9 @@ func (mm *MaintenanceManager) Start() error {
return fmt.Errorf("invalid maintenance configuration: %w", err)
}
mm.mutex.Lock()
mm.running = true
mm.mutex.Unlock()
// Start background processes
go mm.scanLoop()
@@ -187,30 +189,54 @@ func (mm *MaintenanceManager) validateConfig() error {
return nil
}
// IsRunning returns whether the maintenance manager is currently running
// IsRunning returns whether the maintenance manager is currently running.
// running is guarded by mm.mutex because the background loops read it on every
// iteration while Start and Stop are called from the admin server's goroutines.
func (mm *MaintenanceManager) IsRunning() bool {
mm.mutex.RLock()
defer mm.mutex.RUnlock()
return mm.running
}
// Stop terminates the maintenance manager
// Stop terminates the maintenance manager. It is a no-op when the manager is not
// running, so a second call cannot close the already closed stop channel.
func (mm *MaintenanceManager) Stop() {
mm.mutex.Lock()
if !mm.running {
mm.mutex.Unlock()
return
}
mm.running = false
close(mm.stopChan)
mm.mutex.Unlock()
glog.Infof("Maintenance manager stopped")
}
// scanLoop periodically scans for maintenance tasks with adaptive timing
func (mm *MaintenanceManager) scanLoop() {
scanInterval := time.Duration(mm.config.ScanIntervalSeconds) * time.Second
ticker := time.NewTicker(scanInterval)
defer ticker.Stop()
for mm.running {
// activeInterval is the interval the ticker is actually running at right now. It has
// to be tracked separately from the configured scanInterval, because the error backoff
// replaces the ticker with a much shorter one. Comparing the target against the
// configured interval instead never restores the normal cadence: once the errors stop,
// getScanInterval returns scanInterval again, the comparison comes out false, and the
// ticker is left at the backoff delay. A single transient scan failure therefore pinned
// the scanner to one scan per second forever - see issue #10874, where that produced a
// ~658 KB/s log flood and 193k orphaned task files.
activeInterval := scanInterval
ticker := time.NewTicker(activeInterval)
// Wrapped in a closure so the replacement ticker is stopped, not the one that happened
// to be current when the defer was registered.
defer func() { ticker.Stop() }()
for mm.IsRunning() {
select {
case <-mm.stopChan:
return
case <-ticker.C:
glog.V(1).Infof("Performing maintenance scan every %v", scanInterval)
glog.V(1).Infof("Performing maintenance scan every %v", activeInterval)
// Use the same synchronization as TriggerScan to prevent concurrent scans
if err := mm.triggerScanInternal(false); err != nil {
@@ -220,10 +246,13 @@ func (mm *MaintenanceManager) scanLoop() {
// Adjust ticker interval based on error state (read error state safely)
currentInterval := mm.getScanInterval(scanInterval)
// Reset ticker with new interval if needed
if currentInterval != scanInterval {
// Reset ticker whenever the target differs from what the ticker is running at,
// which covers both entering the backoff and returning to the normal cadence.
if currentInterval != activeInterval {
ticker.Stop()
ticker = time.NewTicker(currentInterval)
glog.V(1).Infof("Maintenance scan cadence changed from %v to %v", activeInterval, currentInterval)
activeInterval = currentInterval
}
}
}
@@ -255,7 +284,7 @@ func (mm *MaintenanceManager) cleanupLoop() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for mm.running {
for mm.IsRunning() {
select {
case <-mm.stopChan:
return
@@ -272,7 +301,7 @@ func (mm *MaintenanceManager) topologyStatusLoop() {
ticker := time.NewTicker(statusInterval)
defer ticker.Stop()
for mm.running {
for mm.IsRunning() {
select {
case <-mm.stopChan:
return
@@ -541,12 +570,15 @@ func (mm *MaintenanceManager) TriggerScan() error {
// triggerScanInternal handles both manual and automatic scan triggers
func (mm *MaintenanceManager) triggerScanInternal(isManual bool) error {
// running and scanInProgress are checked under one lock so a Stop that lands
// between the two checks cannot leave a scan running after shutdown.
mm.mutex.Lock()
if !mm.running {
mm.mutex.Unlock()
return fmt.Errorf("maintenance manager is not running")
}
// Prevent multiple concurrent scans
mm.mutex.Lock()
if mm.scanInProgress {
mm.mutex.Unlock()
if isManual {
@@ -0,0 +1,168 @@
package maintenance
import (
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
// scanCadenceClient is a minimal AdminClient whose first call blocks until the test
// releases it. Blocking the first scan is what makes the cadence test deterministic:
// the scan loop evaluates the error state right after triggering a scan, and that scan
// runs in its own goroutine, so without the block the loop could observe the error
// counter either before or after the scan finished.
type scanCadenceClient struct {
mu sync.Mutex
calls int
release chan struct{}
}
func (c *scanCadenceClient) WithMasterClient(fn func(client master_pb.SeaweedClient) error) error {
c.mu.Lock()
c.calls++
first := c.calls == 1
c.mu.Unlock()
if first {
<-c.release
}
// Returning nil without invoking fn yields an empty, successful scan.
return nil
}
func (c *scanCadenceClient) callCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.calls
}
// TestScanLoopRestoresIntervalAfterBackoff is the regression test for the scan flood in
// https://github.com/seaweedfs/seaweedfs/issues/10874. The loop shortens its ticker to the
// error backoff delay after a failed scan. It used to compare the target interval against
// the *configured* scan interval rather than against the interval the ticker was actually
// running at, so once the errors stopped the comparison came out false and the ticker was
// never restored: one transient failure pinned the scanner to one scan per second for as
// long as the process lived.
//
// The test seeds the error state, lets the loop drop into the 1s backoff, then lets a scan
// succeed and asserts the loop goes back to the configured 3s cadence instead of keeping
// the 1s one.
func TestScanLoopRestoresIntervalAfterBackoff(t *testing.T) {
const baseInterval = 3 * time.Second
client := &scanCadenceClient{release: make(chan struct{})}
config := DefaultMaintenanceConfig()
config.ScanIntervalSeconds = int32(baseInterval / time.Second)
manager := NewMaintenanceManager(client, config, nil)
// Seed a failed scan so the very first cadence decision drops to the backoff delay.
// backoffDelay is what getScanInterval returns while errorCount > 0.
manager.mutex.Lock()
manager.errorCount = 1
manager.backoffDelay = time.Second
manager.running = true
manager.mutex.Unlock()
go manager.scanLoop()
defer manager.Stop()
// First tick at ~3s: the scan blocks in the client, so the loop still sees errorCount == 1
// and switches the ticker to the 1s backoff.
deadline := time.Now().Add(baseInterval + 2*time.Second)
for client.callCount() == 0 {
if time.Now().After(deadline) {
t.Fatal("scan loop never triggered its first scan")
}
time.Sleep(20 * time.Millisecond)
}
// Let the blocked scan complete successfully, which clears the error state.
close(client.release)
// Wait for the error state to clear so the next cadence decision is unambiguous.
deadline = time.Now().Add(2 * time.Second)
for {
errorCount, _, _ := manager.GetErrorState()
if errorCount == 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("error tracking never reset, errorCount=%d", errorCount)
}
time.Sleep(20 * time.Millisecond)
}
// The ticker is at 1s now. Wait for the next tick, where the loop must notice the
// recovery and restore the 3s cadence.
time.Sleep(1500 * time.Millisecond)
before := client.callCount()
// Observe a window that a 1s cadence would fill with scans and a 3s cadence would not.
const window = 5 * time.Second
time.Sleep(window)
scansInWindow := client.callCount() - before
// 3s cadence: at most 2 scans in 5s. 1s cadence: about 5.
if scansInWindow > 2 {
t.Errorf("scan loop ran %d scans in %v after recovering from a failed scan; "+
"the ticker was left at the %v backoff instead of returning to the configured %v",
scansInWindow, window, time.Second, baseInterval)
}
if scansInWindow == 0 {
t.Errorf("scan loop ran no scans in %v, expected the %v cadence to fire at least once", window, baseInterval)
}
}
// TestScanLoopEntersBackoffOnError checks the other half of the cadence logic: a failing
// scan still has to shorten the ticker, so the fix above did not simply pin the loop to
// the configured interval.
func TestScanLoopEntersBackoffOnError(t *testing.T) {
config := DefaultMaintenanceConfig()
config.ScanIntervalSeconds = 600
manager := NewMaintenanceManager(nil, config, nil)
baseInterval := time.Duration(config.ScanIntervalSeconds) * time.Second
if got := manager.getScanInterval(baseInterval); got != baseInterval {
t.Errorf("healthy scan interval = %v, want the configured %v", got, baseInterval)
}
manager.mutex.Lock()
manager.errorCount = 1
manager.backoffDelay = time.Second
manager.mutex.Unlock()
if got := manager.getScanInterval(baseInterval); got != time.Second {
t.Errorf("scan interval while failing = %v, want the %v backoff", got, time.Second)
}
manager.mutex.Lock()
manager.resetErrorTracking()
manager.mutex.Unlock()
if got := manager.getScanInterval(baseInterval); got != baseInterval {
t.Errorf("scan interval after recovery = %v, want the configured %v", got, baseInterval)
}
}
// TestStopIsIdempotent guards the stop channel against a double close, which used to panic
// when StopMaintenanceManager ran twice (for example on a shutdown path that also runs on
// a signal handler).
func TestStopIsIdempotent(t *testing.T) {
manager := NewMaintenanceManager(nil, DefaultMaintenanceConfig(), nil)
manager.mutex.Lock()
manager.running = true
manager.mutex.Unlock()
manager.Stop()
manager.Stop()
if manager.IsRunning() {
t.Error("manager still reports running after Stop")
}
}