Context
PR #420 (S3 memory-bloat fix) introduced the third instance of the same 30s-debounce throttle pattern in the codebase. Gemini review on that PR flagged the duplication and recommended centralizing into a shared utility. The PR pushed it out as scope-creep, but the cleanup is real and worth doing.
The three throttle sites
All three follow the same shape: `atomic.Int64` storing nanoseconds-since-process-start, monotonic clock anchor via `processStart = time.Now()`, `last == 0` sentinel for first-call eligibility.
Each repeats the same 8 lines of throttle bookkeeping plus its own anchor variables.
Proposed fix
A new `internal/throttle` package with a `Debouncer` (or similar) type:
```go
type Debouncer struct {
interval time.Duration
processStart time.Time
lastNanos atomic.Int64
}
func New(interval time.Duration) *Debouncer { ... }
// TryAcquire returns true if the caller may proceed; false if throttled.
// Includes the last==0 sentinel so the first call always succeeds.
func (d *Debouncer) TryAcquire() bool { ... }
// Remaining returns how much of the interval is left until the next
// TryAcquire would succeed; useful for HTTP 429 retry-after headers.
func (d *Debouncer) Remaining() time.Duration { ... }
```
Each call site collapses to a one-liner:
```go
var freeOSMemoryDebounce = throttle.New(30 * time.Second)
func freeOSMemoryThrottled() {
if freeOSMemoryDebounce.TryAcquire() {
go debug.FreeOSMemory()
}
}
```
Why a separate PR
The change touches files unrelated to any single feature (`delete.go`, `debug.go`, `memtrim_linux.go`) and benefits from a small focused diff with its own tests. Suggested scope:
- New `internal/throttle/throttle.go` + `throttle_test.go` (unit tests for the `last==0` first-call sentinel and concurrent CAS contention).
- Convert all three call sites in one commit.
- ~80 lines removed, ~60 added; net negative.
Origin
Filed as a follow-up to gemini-code-assist review on #420. The throttle pattern itself is correct in all three places — this issue is a maintainability cleanup, not a bug.
Context
PR #420 (S3 memory-bloat fix) introduced the third instance of the same 30s-debounce throttle pattern in the codebase. Gemini review on that PR flagged the duplication and recommended centralizing into a shared utility. The PR pushed it out as scope-creep, but the cleanup is real and worth doing.
The three throttle sites
All three follow the same shape: `atomic.Int64` storing nanoseconds-since-process-start, monotonic clock anchor via `processStart = time.Now()`, `last == 0` sentinel for first-call eligibility.
Each repeats the same 8 lines of throttle bookkeeping plus its own anchor variables.
Proposed fix
A new `internal/throttle` package with a `Debouncer` (or similar) type:
```go
type Debouncer struct {
interval time.Duration
processStart time.Time
lastNanos atomic.Int64
}
func New(interval time.Duration) *Debouncer { ... }
// TryAcquire returns true if the caller may proceed; false if throttled.
// Includes the last==0 sentinel so the first call always succeeds.
func (d *Debouncer) TryAcquire() bool { ... }
// Remaining returns how much of the interval is left until the next
// TryAcquire would succeed; useful for HTTP 429 retry-after headers.
func (d *Debouncer) Remaining() time.Duration { ... }
```
Each call site collapses to a one-liner:
```go
var freeOSMemoryDebounce = throttle.New(30 * time.Second)
func freeOSMemoryThrottled() {
if freeOSMemoryDebounce.TryAcquire() {
go debug.FreeOSMemory()
}
}
```
Why a separate PR
The change touches files unrelated to any single feature (`delete.go`, `debug.go`, `memtrim_linux.go`) and benefits from a small focused diff with its own tests. Suggested scope:
Origin
Filed as a follow-up to gemini-code-assist review on #420. The throttle pattern itself is correct in all three places — this issue is a maintainability cleanup, not a bug.