diff --git a/breaker.go b/breaker.go index 7800159..c8e95f0 100644 --- a/breaker.go +++ b/breaker.go @@ -144,7 +144,7 @@ type SlidingWindowBreaker struct { // State - currentStart atomic.Int64 // in unix microseconds + currentStart atomic.Int64 // monotonic nanoseconds since start (see nowNanos) currentSuccessCount atomic.Int64 currentFailureCount atomic.Int64 lastSuccessCount atomic.Int64 @@ -184,13 +184,13 @@ func (s *SlidingWindowBreaker) observe(halfOpen, failure bool) stateChange { return stateChangeClose } - currentStartMicros := s.currentStart.Load() - sinceStart := sinceMicros(currentStartMicros) - firstCallInNewWindow := s.currentStart.CompareAndSwap(currentStartMicros, time.Now().UnixMicro()) + currentStartNanos := s.currentStart.Load() + sinceStart := sinceNanos(currentStartNanos) - // The second condition ensures only one goroutine can swap the windows. Necessary since multiple swaps would - // overwrite the last counts to some near zero value. - if sinceStart > s.windowSize && firstCallInNewWindow { + // Rotate the windows once the current one has passed (or initialize it on the very first observation). The + // CompareAndSwap ensures only one goroutine swaps the windows; multiple swaps would overwrite the last counts to + // some near zero value. + if (currentStartNanos == 0 || sinceStart > s.windowSize) && s.currentStart.CompareAndSwap(currentStartNanos, nowNanos()) { sinceStart = 0 lastFailureCount = s.lastFailureCount.Swap(s.currentFailureCount.Swap(0)) lastSuccessCount = s.lastSuccessCount.Swap(s.currentSuccessCount.Swap(0)) @@ -234,10 +234,3 @@ func (s *SlidingWindowBreaker) apply(o *options) error { return nil } - -func sinceMicros(micros int64) time.Duration { - if micros == 0 { - return 0 - } - return time.Since(time.UnixMicro(micros)) -} diff --git a/breaker_test.go b/breaker_test.go index 3a4f9c0..c8d19da 100644 --- a/breaker_test.go +++ b/breaker_test.go @@ -193,6 +193,31 @@ func TestBreaker_Observe_State(t *testing.T) { } } +func TestSlidingWindowBreaker_window_start_is_stable_within_window(t *testing.T) { + b := NewSlidingWindowBreaker(time.Minute, 0.5) + + b.observe(false, false) // first observation initializes the window + windowStart := b.currentStart.Load() + assert.NotZero(t, windowStart) + + b.observe(false, false) + b.observe(false, true) + assert.Equal(t, windowStart, b.currentStart.Load(), "observations within the window must not move its start") +} + +func TestSlidingWindowBreaker_rotates_windows_after_windowSize(t *testing.T) { + b := NewSlidingWindowBreaker(time.Minute, 0.5) + + assert.Equal(t, stateChangeOpen, b.observe(false, true)) + + // simulate passage of time: pretend the current window started more than a windowSize ago + b.currentStart.Store(nowNanos() - int64(b.windowSize+time.Second)) + + b.observe(false, false) + assert.EqualValues(t, 1, b.lastFailureCount.Load(), "failures should have been rotated into the last window") + assert.EqualValues(t, 0, b.currentFailureCount.Load()) +} + // ignoreNone is a small helper to skip the "none" state change and only record the last "effective" state change. func ignoreNone(old, new stateChange) stateChange { if new == stateChangeNone { diff --git a/hoglet.go b/hoglet.go index 1c41742..d2bb3c7 100644 --- a/hoglet.go +++ b/hoglet.go @@ -18,7 +18,7 @@ type Circuit struct { // State - openedAt atomic.Int64 // unix microseconds + openedAt atomic.Int64 // monotonic nanoseconds since start (see [nowNanos]); 0 = closed } // options is a sub-struct to avoid requiring type parameters in the [Option] type. @@ -123,7 +123,7 @@ func (c *Circuit) State() State { return StateClosed } - if c.halfOpenDelay == 0 || time.Since(time.UnixMicro(oa)) < c.halfOpenDelay { + if c.halfOpenDelay == 0 || sinceNanos(oa) < c.halfOpenDelay { // open return StateOpen } @@ -151,12 +151,12 @@ func (c *Circuit) stateForCall() State { // It is safe for concurrent calls and only the first one will actually set opening time. func (c *Circuit) open() { // CompareAndSwap is needed to avoid clobbering another goroutine's openedAt value. - c.openedAt.CompareAndSwap(0, time.Now().UnixMicro()) + c.openedAt.CompareAndSwap(0, nowNanos()) } // reopen forcefully (re)marks the circuit as open, resetting the half-open time. func (c *Circuit) reopen() { - c.openedAt.Store(time.Now().UnixMicro()) + c.openedAt.Store(nowNanos()) } // close closes the circuit. @@ -164,6 +164,28 @@ func (c *Circuit) close() { c.openedAt.Store(0) } +// start is captured once at package load so the circuit and breakers can measure elapsed time using the monotonic +// clock, making time-based state transitions immune to wall-clock jumps (e.g. NTP steps). Timestamps derived from it +// (e.g. [Circuit.openedAt]) are stored as monotonic nanoseconds since start and are not wall-clock meaningful. +// It is backdated by one nanosecond so [nowNanos] is always > 0, even if called within the same clock tick on +// platforms with coarse timer resolution. +var start = time.Now().Add(-time.Nanosecond) + +// nowNanos returns the monotonic nanoseconds elapsed since package load. It is always > 0, so 0 remains usable as an +// "unset"/closed sentinel. +func nowNanos() int64 { + return int64(time.Since(start)) +} + +// sinceNanos returns the duration elapsed since the given monotonic-nanos timestamp (as produced by [nowNanos]). +// A zero timestamp is treated as "unset" and yields a zero duration. +func sinceNanos(nanos int64) time.Duration { + if nanos == 0 { + return 0 + } + return time.Since(start) - time.Duration(nanos) +} + // ObserverForCall returns an [Observer] for the incoming call. // It is called exactly once per call to [Circuit.Call], before calling the wrapped function. // If the breaker is open, it returns [ErrCircuitOpen] as an error and a nil [Observer]. diff --git a/hoglet_test.go b/hoglet_test.go index 315c5e6..fa6c96e 100644 --- a/hoglet_test.go +++ b/hoglet_test.go @@ -193,8 +193,8 @@ func TestHoglet_Do(t *testing.T) { require.NoError(t, err) for i, call := range tt.calls { if call.halfOpen { - // simulate passage of time - h.openedAt.Store(int64(time.Now().Add(-h.halfOpenDelay).UnixMicro())) + // simulate passage of time: mark the circuit as opened halfOpenDelay ago + h.openedAt.Store(nowNanos() - int64(h.halfOpenDelay)) } var err error