pacer.go 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. package pacer
  2. import (
  3. "context"
  4. "time"
  5. )
  6. // Pacer produces a tick channel that controls send rate.
  7. // It supports an optional linear ramp from 0 to target rate
  8. // over a configurable warm-up window.
  9. type Pacer struct {
  10. // Target alerts per second after ramp-up completes.
  11. TargetPerSec int
  12. // Ramp-up duration. 0 = instant (use full target rate immediately).
  13. RampDuration time.Duration
  14. // Clock is injectable for testing.
  15. Clock func() time.Time
  16. }
  17. // New returns a Pacer configured for targetPerSec sustained rate
  18. // with an optional rampDuration warm-up window.
  19. func New(targetPerSec int, rampDuration time.Duration) *Pacer {
  20. return &Pacer{
  21. TargetPerSec: targetPerSec,
  22. RampDuration: rampDuration,
  23. Clock: time.Now,
  24. }
  25. }
  26. // Tick returns a channel that fires at the appropriate interval
  27. // for the current phase (ramp or steady-state).
  28. // The returned stop function blocks until the pacer goroutine exits.
  29. // Callers must drain the returned channel to avoid blocking the
  30. // pacer's internal goroutine.
  31. func (p *Pacer) Tick(ctx context.Context) (<-chan struct{}, func()) {
  32. stopped := make(chan struct{})
  33. done := make(chan struct{})
  34. go func() {
  35. defer close(done)
  36. now := p.Clock()
  37. rampStart := now
  38. if p.TargetPerSec <= 0 || p.RampDuration == 0 {
  39. // Steady-state only: fixed interval.
  40. ticker := time.NewTicker(intervalFor(p.TargetPerSec))
  41. defer ticker.Stop()
  42. for {
  43. select {
  44. case <-ctx.Done():
  45. close(stopped)
  46. return
  47. case <-ticker.C:
  48. select {
  49. case stopped <- struct{}{}:
  50. default:
  51. }
  52. }
  53. }
  54. }
  55. // Ramp-up phase: rate increases linearly from 0 to TargetPerSec.
  56. // We recalculate the next tick interval after each tick.
  57. for {
  58. elapsed := p.Clock().Sub(rampStart)
  59. if elapsed >= p.RampDuration {
  60. break // fall through to steady-state
  61. }
  62. // Linear interpolation: fraction of ramp completed.
  63. fraction := float64(elapsed) / float64(p.RampDuration)
  64. currentRate := int(float64(p.TargetPerSec) * fraction)
  65. if currentRate <= 0 {
  66. currentRate = 1
  67. }
  68. tickAfter := intervalFor(currentRate)
  69. select {
  70. case <-ctx.Done():
  71. close(stopped)
  72. return
  73. case <-time.After(tickAfter):
  74. select {
  75. case stopped <- struct{}{}:
  76. default:
  77. }
  78. }
  79. }
  80. // Steady-state: fixed interval at full target rate.
  81. steady := time.NewTicker(intervalFor(p.TargetPerSec))
  82. defer steady.Stop()
  83. for {
  84. select {
  85. case <-ctx.Done():
  86. close(stopped)
  87. return
  88. case <-steady.C:
  89. select {
  90. case stopped <- struct{}{}:
  91. default:
  92. }
  93. }
  94. }
  95. }()
  96. return stopped, func() {
  97. <-done
  98. }
  99. }
  100. // intervalFor returns the inter-tick interval for a given rate.
  101. // rate=0 returns a very slow ticker (will never fire in practice).
  102. func intervalFor(rate int) time.Duration {
  103. if rate <= 0 {
  104. return time.Hour // effectively stopped
  105. }
  106. return time.Second / time.Duration(rate)
  107. }