Skip to content

Commit

Permalink
Merge pull request #5 from xmidt-org/feature/refactor-notifications
Browse files Browse the repository at this point in the history
Feature/refactor notifications
  • Loading branch information
johnabass authored Sep 29, 2021
2 parents a5787d9 + 8e42d01 commit 771834b
Show file tree
Hide file tree
Showing 19 changed files with 546 additions and 566 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]
- basic examples
- Sleeper can now be used to control sleeping goroutines
- FakeTimer and FakeTicker can now control timers and tickers
- Overhaul of notifications to better support driving goroutines from tests

## [v0.0.2]
- unit tests
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,17 @@
[![GitHub release](https://img.shields.io/github/release/xmidt-org/chronon.svg)](CHANGELOG.md)
[![PkgGoDev](https://pkg.go.dev/badge/github.com/xmidt-org/chronon)](https://pkg.go.dev/github.com/xmidt-org/chronon)

## Summary

`chronon` aims to make concurrent, time-related code in `golang` easier to test. The `Clock` abstraction is intended as a drop-in replacement for the `time` package.

## Table of Contents

- [Overview](#overview)
- [Code of Conduct](#code-of-conduct)
- [Install](#install)
- [Contributing](#contributing)

## Overview

`chronon` aims to make concurrent, time-related `golang` code easier to test. In particular, `chronon` avoids having package-level state or a "test mode" that unit tests use to drive timers, tickers, etc.

## Code of Conduct

This project and everyone participating in it are governed by the [XMiDT Code Of Conduct](https://xmidt.io/docs/community/code_of_conduct/).
Expand Down
33 changes: 29 additions & 4 deletions chronon_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,34 @@ func (suite *ChrononSuite) newFakeClock() *FakeClock {
return fc
}

// newSleeper spawns a goroutine that is blocked in a FakeClock.Sleep(d) call. The Sleeper,
// containing FakeClock, and a done channel are returned for tests to manipulate. The done
// channel is closed when Sleep returns.
func (suite *ChrononSuite) newSleeper(d time.Duration) (Sleeper, *FakeClock, <-chan struct{}) {
suite.T().Helper()

var (
fc = suite.newFakeClock()
onSleep = make(chan Sleeper)
done = make(chan struct{})
)

fc.NotifyOnSleep(onSleep)

go func() {
defer close(done)
fc.Sleep(d)
}()

s := suite.requireReceive(onSleep, WaitALittle).(Sleeper)
fc.StopOnSleep(onSleep)

return s, fc, done
}

// newFakeTimer creates a fake timer and a *FakeClock to control it.
// Standard assertions are run against both the clock and the timer.
func (suite *ChrononSuite) newFakeTimer(d time.Duration) (Timer, *FakeClock) {
func (suite *ChrononSuite) newFakeTimer(d time.Duration) (FakeTimer, *FakeClock) {
suite.T().Helper()
fc := suite.newFakeClock()

Expand All @@ -57,12 +82,12 @@ func (suite *ChrononSuite) newFakeTimer(d time.Duration) (Timer, *FakeClock) {
suite.requireSignal(t.C(), Immediate)
}

return t, fc
return t.(FakeTimer), fc
}

// newAfterFunc creates a delayed function using AfterFunc and runs standard assertions.
// The returned channel is signaled when the function is called.
func (suite *ChrononSuite) newAfterFunc(d time.Duration) (Timer, *FakeClock, <-chan struct{}) {
func (suite *ChrononSuite) newAfterFunc(d time.Duration) (FakeTimer, *FakeClock, <-chan struct{}) {
suite.T().Helper()
fc := suite.newFakeClock()

Expand All @@ -71,7 +96,7 @@ func (suite *ChrononSuite) newAfterFunc(d time.Duration) (Timer, *FakeClock, <-c
suite.Require().NotNil(t)
suite.Require().Nil(t.C())

return t, fc, called
return t.(FakeTimer), fc, called
}

// newFakeTicker creates a fake ticker and a fake clock to control it with.
Expand Down
34 changes: 25 additions & 9 deletions clock.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,31 @@ type Timer interface {
Stop() bool
}

// Time represents a notion of a local time value. This interface may be backed by
// the time package or by an artificial source of time, such as a FakeClock.
type Time interface {
// Clock represents a standard set of time operations. Implementations are
// drop-in replacements for the time package.
//
// A typical use case is to establish a Clock using SystemClock() in production
// code. Test code can then alter the injected instance to a *FakeClock after the fact:
//
// type MyService struct {
// clock chronon.Clock
// }
//
// func NewMyService() *MyService {
// return &MyService{
// clock: chronon.SystemClock(),
// }
// }
//
// func TestMyService(t *testing.T) {
// s := NewMyService()
// fc := chronon.NewFakeClock()
// s.clock = fc
//
// // continue with tests, updating the fake clock to drive
// // concurrent, time-dependent code
// }
type Clock interface {
// Now returns ths instance's notion of the current time.
Now() time.Time

Expand All @@ -53,12 +75,6 @@ type Time interface {
// Until returns the duration between this instance's current time and
// the given time.
Until(t time.Time) time.Duration
}

// Clock represents a standard set of time operations. Implementations are
// drop-in replacements for the time package.
type Clock interface {
Time

// Sleep blocks until this Clock believes that the given
// time duration has elapsed.
Expand Down
121 changes: 57 additions & 64 deletions fakeClock.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,21 @@ import (
"time"
)

// Adder represents a source of time values that can be modified
// by adding a duration. *FakeClock implements this interface.
type Adder interface {
// Add adjusts the current time by the given delta. The delta
// can be negative or 0. This method returns the new value
// of the current time.
Add(time.Duration) time.Time
}

// Setter represents a source of time values that can be updated
// using absolute time. *FakeClock implements this interface.
type Setter interface {
// Set adjusts the current time to the given value.
Set(time.Time)
}

// FakeClock is a Clock implementation that allows control over how
// the clock advances.
// the clock is updated. The Add and Set methods control a FakeClock's
// notion of the current time in addition to affecting the various objects
// created through this clock, e.g. Timer.
type FakeClock struct {
lock sync.RWMutex

now time.Time
listeners listeners
onSleep notifiers
onSleeper notifiers
onTimer notifiers
onTicker notifiers
}

var _ Clock = (*FakeClock)(nil)
var _ Adder = (*FakeClock)(nil)
var _ Setter = (*FakeClock)(nil)

// NewFakeClock creates a FakeClock that uses the given time as the
// initial current time.
Expand All @@ -53,25 +37,41 @@ func (fc *FakeClock) doWith(f func(time.Time, *listeners)) {
f(fc.now, &fc.listeners)
}

// Add satisfies the Adder interface. Updating this fake clock's
// time through this method is atomic with respect to all the other
// methods.
// Add updates this fake clock's current time. The duration can be nonpositive,
// in which case the clock moves backwards or is unaffected.
//
// Anytime a FakeClock's current time changes via this method or Set, any objects
// created through this clock are updated as appropriate.
func (fc *FakeClock) Add(d time.Duration) (now time.Time) {
fc.lock.Lock()
now = fc.now.Add(d)
fc.now = now
fc.listeners.onAdvance(now)
fc.listeners.onUpdate(now)
fc.lock.Unlock()

return
}

// Set is similar to Advance, except that it sets an absolute time instead
// Set is similar to Add, except that it sets an absolute time instead
// of moving this fake clock's time by a certain delta.
//
// A common use case is to force the firing of an object by passing its When value:
//
// fc := NewFakeClock(time.Now())
// onTimer := make(chan FakeTimer)
// fc.NotifyOnTimer(onTimer)
//
// // ... spawn goroutines that run production code
//
// // this blocks until production code obtains a timer
// t := <-onTimer
//
// // force the timer to fire by updating the clock
// fc.Set(t.When())
func (fc *FakeClock) Set(t time.Time) {
fc.lock.Lock()
fc.now = t
fc.listeners.onAdvance(t)
fc.listeners.onUpdate(t)
fc.lock.Unlock()
}

Expand Down Expand Up @@ -103,20 +103,22 @@ func (fc *FakeClock) Until(t time.Time) (d time.Duration) {

// Sleep blocks until this clock is advanced sufficiently so that
// the given duration elapses. If d is nonpositive, this function
// immediately returns exactly as with time.Sleep.
// immediately returns exactly as with time.Sleep. However, in all
// cases a Sleeper is dispatched to any channels registered with NotifyOnSleep.
//
// If d is positive, then any channel registered with NotifyOnSleep
// will receive d prior to blocking.
func (fc *FakeClock) Sleep(d time.Duration) {
if d <= 0 {
// consistent with time.Sleep
return
}

fc.lock.Lock()
sleeper := newSleeperAt(fc.now.Add(d))
fc.listeners.add(sleeper)
fc.onSleep.notify(d)
sleeper := newSleeperAt(fc, fc.now.Add(d))

// if the duration was nonpositive, the sleeper will immediately
// close its channel and won't be added as a listener. This makes
// sure that there is still a Sleeper dispatched to any waiting
// channels while preserving the behavior of time.Sleep.
fc.listeners.register(fc.now, sleeper)

fc.onSleeper.notify(sleeper)
fc.lock.Unlock()

sleeper.wait()
Expand All @@ -130,34 +132,31 @@ func (fc *FakeClock) Sleep(d time.Duration) {
// needs to block waiting for a sleeper before modifying this FakeClock's time.
// When used for this purpose, be sure to register a sleep channel before
// invoking Sleep, usually in test setup code.
func (fc *FakeClock) NotifyOnSleep(ch chan<- time.Duration) {
func (fc *FakeClock) NotifyOnSleep(ch chan<- Sleeper) {
fc.lock.Lock()
fc.onSleep.add(ch)
fc.onSleeper.add(ch)
fc.lock.Unlock()
}

// StopOnSleep removes a channel from the list of channels that receive notifications
// for Sleep. If the given channel is not present, this method does nothing.
func (fc *FakeClock) StopOnSleep(ch chan<- time.Duration) {
func (fc *FakeClock) StopOnSleep(ch chan<- Sleeper) {
fc.lock.Lock()
fc.onSleep.remove(ch)
fc.onSleeper.remove(ch)
fc.lock.Unlock()
}

// NewTimer creates a Timer that fires when this FakeClock has been advanced
// by at least the given duration. The returned timer can be stopped or reset in
// the usual fashion, which will affect what happens when the FakeClock is advanced.
//
// The Timer returned by this method can always be cast to a FakeTimer.
func (fc *FakeClock) NewTimer(d time.Duration) Timer {
fc.lock.Lock()
ft := newFakeTimer(fc, fc.now.Add(d))

// handle nonpositive durations consistently with normal triggering
if !ft.onAdvance(fc.now) {
fc.listeners.add(ft)
}

// always notify, even if the timer immediately fired
fc.onTimer.notify(d)
fc.listeners.register(fc.now, ft)
fc.onTimer.notify(ft)

fc.lock.Unlock()
return ft
Expand All @@ -172,17 +171,14 @@ func (fc *FakeClock) After(d time.Duration) <-chan time.Time {
// by at least the given duration. The returned Timer can be used to cancel the
// execution, as with time.AfterFunc. The returned Timer from this method is
// always a *FakeTimer, and its C() method always returns nil.
//
// The Timer returned by this method can always be cast to a FakeTimer.
func (fc *FakeClock) AfterFunc(d time.Duration, f func()) Timer {
fc.lock.Lock()
ft := newAfterFunc(fc, fc.now.Add(d), func(time.Time) { f() })

// handle nonpositive durations consistently
if !ft.onAdvance(fc.now) {
fc.listeners.add(ft)
}

// always notify, even if the timer immediately fired
fc.onTimer.notify(d)
fc.listeners.register(fc.now, ft)
fc.onTimer.notify(ft)

fc.lock.Unlock()
return ft
Expand All @@ -192,15 +188,15 @@ func (fc *FakeClock) AfterFunc(d time.Duration, f func()) Timer {
// through this fake clock. This includes implicit timers, such as with After and AfterFunc.
//
// Test code that uses this method can be notified when code under test creates timers.
func (fc *FakeClock) NotifyOnTimer(ch chan<- time.Duration) {
func (fc *FakeClock) NotifyOnTimer(ch chan<- FakeTimer) {
fc.lock.Lock()
fc.onTimer.add(ch)
fc.lock.Unlock()
}

// StopOnTimer removes a channel from the list of channels that receive notifications
// for timers. If the given channel is not present, this method does nothing.
func (fc *FakeClock) StopOnTimer(ch chan<- time.Duration) {
func (fc *FakeClock) StopOnTimer(ch chan<- FakeTimer) {
fc.lock.Lock()
fc.onTimer.remove(ch)
fc.lock.Unlock()
Expand All @@ -209,17 +205,14 @@ func (fc *FakeClock) StopOnTimer(ch chan<- time.Duration) {
// NewTicker creates a Ticker that fires when this FakeClock is advanced by
// increments of the given duration. The returned ticker can be stopped or
// reset in the usual fashion.
//
// The Ticker returned from this method can always be cast to a FakeTicker.
func (fc *FakeClock) NewTicker(d time.Duration) Ticker {
fc.lock.Lock()
ft := newFakeTicker(fc, d, fc.now)

// consistent logic with NewTimer, even though onAdvance
// always returns false (at least, right now)
if !ft.onAdvance(fc.now) {
fc.listeners.add(ft)
}

fc.onTicker.notify(d)
fc.listeners.register(fc.now, ft)
fc.onTicker.notify(ft)
fc.lock.Unlock()
return ft
}
Expand All @@ -233,15 +226,15 @@ func (fc *FakeClock) Tick(d time.Duration) <-chan time.Time {
// through this fake clock. This includes implicit tickers, such as Tick.
//
// Test code that uses this method can be notified when code under test creates tickers.
func (fc *FakeClock) NotifyOnTicker(ch chan<- time.Duration) {
func (fc *FakeClock) NotifyOnTicker(ch chan<- FakeTicker) {
fc.lock.Lock()
fc.onTicker.add(ch)
fc.lock.Unlock()
}

// StopOnTicker removes a channel from the list of channels that receive notifications
// for timers. If the given channel is not present, this method does nothing.
func (fc *FakeClock) StopOnTicker(ch chan<- time.Duration) {
func (fc *FakeClock) StopOnTicker(ch chan<- FakeTicker) {
fc.lock.Lock()
fc.onTicker.remove(ch)
fc.lock.Unlock()
Expand Down
Loading

0 comments on commit 771834b

Please sign in to comment.