Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions go/pkg/clock/cached_clock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package clock

import (
"sync/atomic"
"time"
)

// This clock implementation takes inspiration from
// https://github.com/agilira/go-timecache
// but works with our clock.Clock interface

// CachedClock implements the Clock interface using a cached atomic value
// to avoid the overhead of system calls from time.Now().
type CachedClock struct {
nanos atomic.Int64
resolution time.Duration
ticker *time.Ticker
close chan struct{}
}

// NewCachedClock creates a new CachedClock that uses the system time cached every [resolution].
//
// Example:
//
// clock := clock.NewCachedClock(time.Millisecond)
// currentTime := clock.Now()
func NewCachedClock(resolution time.Duration) *CachedClock {

close := make(chan struct{})
ticker := time.NewTicker(resolution)

c := &CachedClock{
nanos: atomic.Int64{},
resolution: resolution,
ticker: ticker,
close: close,
}

// Initialize with current time
c.nanos.Store(time.Now().UnixNano())

go func() {
for {
select {
case <-ticker.C:
c.nanos.Store(time.Now().UnixNano())
case <-close:
ticker.Stop()
return
}
}
}()

return c

}

// Ensure CachedClock implements the Clock interface
var _ Clock = &CachedClock{}

// Now returns the current system time.
// This implementation returns the cached time value.
func (c *CachedClock) Now() time.Time {
return time.Unix(0, c.nanos.Load())
}

// Close stops the background goroutine that updates the cached time.
// After calling Close, the clock will continue to return the last cached time
// but will no longer update. This method should be called to clean up resources
// when the CachedClock is no longer needed.
func (c *CachedClock) Close() {
close(c.close)
}
128 changes: 128 additions & 0 deletions go/pkg/clock/cached_clock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package clock

import (
"sync"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestCachedClock(t *testing.T) {
t.Run("NewCachedClock creates working clock", func(t *testing.T) {
resolution := 10 * time.Millisecond
clock := NewCachedClock(resolution)
defer clock.Close()

// Initial time should be set quickly
time.Sleep(2 * time.Millisecond)
now := clock.Now()

// Should be within reasonable bounds of current time
require.WithinDuration(t, time.Now(), now, 50*time.Millisecond)
})

t.Run("Now returns cached time within resolution", func(t *testing.T) {
resolution := 50 * time.Millisecond
clock := NewCachedClock(resolution)
defer clock.Close()

// Get initial time
time.Sleep(resolution + 5*time.Millisecond) // Ensure cache is populated
t1 := clock.Now()
realTime1 := time.Now()

// Wait less than resolution, time should be the same
time.Sleep(10 * time.Millisecond)
t2 := clock.Now()
realTime2 := time.Now()

// Cached time should be the same (within nanoseconds)
require.True(t, t1.Equal(t2), "cached time should not change within resolution")

// Real time should have advanced
require.True(t, realTime2.After(realTime1), "real time should advance")

// Wait for resolution to pass, cached time should update
time.Sleep(resolution + 5*time.Millisecond)
t3 := clock.Now()

require.True(t, t3.After(t2), "cached time should update after resolution")
})

t.Run("Concurrent access is safe", func(t *testing.T) {
resolution := 1 * time.Millisecond
clock := NewCachedClock(resolution)
defer clock.Close()

const numGoroutines = 100
const numCalls = 100

var wg sync.WaitGroup
wg.Add(numGoroutines)

for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < numCalls; j++ {
_ = clock.Now()
}
}()
}

wg.Wait()
// If we reach here without panic/race, concurrent access is safe
})

t.Run("Close stops background goroutine", func(t *testing.T) {
resolution := 10 * time.Millisecond
clock := NewCachedClock(resolution)

// Let it run for a bit
time.Sleep(resolution + 5*time.Millisecond)
t1 := clock.Now()

// Close the clock
clock.Close()

// Wait longer than resolution
time.Sleep(resolution * 3)

t2 := clock.Now()

// Time should be the same after close (no more updates)
require.True(t, t1.Equal(t2), "time should not change after Close()")
})

t.Run("Very small resolution works", func(t *testing.T) {
resolution := 100 * time.Microsecond
clock := NewCachedClock(resolution)
defer clock.Close()

time.Sleep(1 * time.Millisecond)
now := clock.Now()

require.WithinDuration(t, time.Now(), now, 5*time.Millisecond)
})

t.Run("Large resolution works", func(t *testing.T) {
resolution := 100 * time.Millisecond
clock := NewCachedClock(resolution)
defer clock.Close()

// Get initial time
time.Sleep(resolution + 10*time.Millisecond)
t1 := clock.Now()

// Wait less than resolution
time.Sleep(10 * time.Millisecond)
t2 := clock.Now()

// Should be the same cached value
require.True(t, t1.Equal(t2), "time should not change within large resolution")
})

t.Run("CachedClock implements Clock interface", func(t *testing.T) {
var _ Clock = &CachedClock{} // nolint:exhaustruct
})
}
40 changes: 40 additions & 0 deletions go/pkg/clock/clock_benchmarks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package clock

import (
"testing"
"time"
)

func BenchmarkCachedClockNow(b *testing.B) {
resolutions := []time.Duration{
time.Microsecond,
500 * time.Microsecond,
time.Millisecond,
10 * time.Millisecond,
}

for _, resolution := range resolutions {
b.Run(resolution.String(), func(b *testing.B) {
clock := NewCachedClock(resolution)
defer clock.Close()

b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = clock.Now()
}
})
})
}
}

func BenchmarkRealClockNow(b *testing.B) {
clock := New()

b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = clock.Now()
}
})
}
17 changes: 0 additions & 17 deletions go/pkg/clock/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,4 @@ type Clock interface {
// In test implementations, this returns a controlled time that
// can be manipulated for testing purposes.
Now() time.Time

// NewTicker returns a new Ticker containing a channel that will send
// the current time on the channel after each tick.
// In production implementations, this creates a real ticker.
// In test implementations, this creates a controllable ticker.
NewTicker(d time.Duration) Ticker
}

// Ticker represents a ticker that sends the current time at regular intervals.
// This interface abstracts the standard library's time.Ticker to enable
// deterministic testing of ticker-based code.
type Ticker interface {
// C returns the channel on which the ticks are delivered.
C() <-chan time.Time

// Stop turns off a ticker. After Stop, no more ticks will be sent.
Stop()
}
22 changes: 0 additions & 22 deletions go/pkg/clock/real_clock.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,3 @@ var _ Clock = &RealClock{}
func (c *RealClock) Now() time.Time {
return time.Now()
}

// NewTicker returns a new real ticker that sends the current time
// on the channel after each tick. This implementation delegates
// to time.NewTicker().
func (c *RealClock) NewTicker(d time.Duration) Ticker {
return &realTicker{ticker: time.NewTicker(d)}
}

// realTicker wraps time.Ticker to implement the Ticker interface
type realTicker struct {
ticker *time.Ticker
}

// C returns the channel on which the ticks are delivered.
func (t *realTicker) C() <-chan time.Time {
return t.ticker.C
}

// Stop turns off the ticker.
func (t *realTicker) Stop() {
t.ticker.Stop()
}
Loading