package pacer import ( "context" "time" ) // Pacer produces a tick channel that controls send rate. // It supports an optional linear ramp from 0 to target rate // over a configurable warm-up window. type Pacer struct { // Target alerts per second after ramp-up completes. TargetPerSec int // Ramp-up duration. 0 = instant (use full target rate immediately). RampDuration time.Duration // Clock is injectable for testing. Clock func() time.Time } // New returns a Pacer configured for targetPerSec sustained rate // with an optional rampDuration warm-up window. func New(targetPerSec int, rampDuration time.Duration) *Pacer { return &Pacer{ TargetPerSec: targetPerSec, RampDuration: rampDuration, Clock: time.Now, } } // Tick returns a channel that fires at the appropriate interval // for the current phase (ramp or steady-state). // The returned stop function blocks until the pacer goroutine exits. // Callers must drain the returned channel to avoid blocking the // pacer's internal goroutine. func (p *Pacer) Tick(ctx context.Context) (<-chan struct{}, func()) { stopped := make(chan struct{}) done := make(chan struct{}) go func() { defer close(done) now := p.Clock() rampStart := now if p.TargetPerSec <= 0 || p.RampDuration == 0 { // Steady-state only: fixed interval. ticker := time.NewTicker(intervalFor(p.TargetPerSec)) defer ticker.Stop() for { select { case <-ctx.Done(): close(stopped) return case <-ticker.C: select { case stopped <- struct{}{}: default: } } } } // Ramp-up phase: rate increases linearly from 0 to TargetPerSec. // We recalculate the next tick interval after each tick. for { elapsed := p.Clock().Sub(rampStart) if elapsed >= p.RampDuration { break // fall through to steady-state } // Linear interpolation: fraction of ramp completed. fraction := float64(elapsed) / float64(p.RampDuration) currentRate := int(float64(p.TargetPerSec) * fraction) if currentRate <= 0 { currentRate = 1 } tickAfter := intervalFor(currentRate) select { case <-ctx.Done(): close(stopped) return case <-time.After(tickAfter): select { case stopped <- struct{}{}: default: } } } // Steady-state: fixed interval at full target rate. steady := time.NewTicker(intervalFor(p.TargetPerSec)) defer steady.Stop() for { select { case <-ctx.Done(): close(stopped) return case <-steady.C: select { case stopped <- struct{}{}: default: } } } }() return stopped, func() { <-done } } // intervalFor returns the inter-tick interval for a given rate. // rate=0 returns a very slow ticker (will never fire in practice). func intervalFor(rate int) time.Duration { if rate <= 0 { return time.Hour // effectively stopped } return time.Second / time.Duration(rate) }