clock

package
v0.17.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 3 Imported by: 0

README

clock

Time abstraction for the SDK kernel — reading time and waiting on it. Stdlib-only, no domain vocabulary, no error codes.

Two capabilities behind two interfaces, on purpose:

type Clock  interface { Now() time.Time; Since(time.Time) time.Duration }
type Waiter interface {
	After(time.Duration) <-chan time.Time
	NewTimer(time.Duration) Timer
	NewTicker(time.Duration) Ticker
	Sleep(time.Duration)
}
type Timed interface { Clock; Waiter }

Ask for the narrowest half you need. Clock is a published port — reachable downstream through pkg/v1/data/cache.Config.Clock — so it stays at two methods and new capabilities land on Waiter.

Production

import "github.com/kitsunium/sdk/internal/kernel/clock"

type poller struct{ clk clock.Timed }

func New(clk clock.Timed) *poller {
	if clk == nil {
		clk = clock.System // the wall clock
	}
	return &poller{clk: clk}
}

func (p *poller) run(stop <-chan struct{}) {
	tk := p.clk.NewTicker(time.Second)
	defer tk.Stop()
	for {
		select {
		case <-tk.C():
			// ... a unit of work ...
		case <-stop:
			return
		}
	}
}

Test

ManualClock is a Timed whose instant moves only when you move it. No sleeping, no wall-clock tolerance, and it works in an ordinary t.Parallel() table test.

m := clock.NewManualClock(time.Date(2031, 3, 7, 4, 5, 6, 789, time.UTC))
p := New(m)
go p.run(stop)

m.BlockUntil(1)        // wait until the ticker is armed — closes the wake race
m.Advance(time.Second) // exactly one tick, at exactly +1s
Method Contract
Advance(d) forward by d, firing due waits in deadline order; panics on a negative d
Set(t) to t; forward fires, backward only moves the reading (deadlines are absolute)
Pending() number of armed waits
BlockUntil(n) block until at least n waits are armed

A ticker delivers at most one tick per Advance — the channel holds one anyway. To observe N ticks, advance N times and drain between calls.

Non-positive durations

After / NewTimer / Sleep treat d <= 0 as already elapsed and deliver at once. NewTicker / Ticker.Reset panic — a ticker has no meaningful zero period, and any value the SDK substituted would be arbitrary (ADR 0031). The message is raised by this package and is identical on both implementations:

clock: NewTicker requires a period > 0, got 0s

Relationship to testing/synctest

Complementary, not redundant. Inside a synctest bubble clock.System is already fake, because it delegates to package time — nothing needs replacing. Reach for synctest for concurrent code you own with no real I/O; reach for ManualClock for a parallel table test, a test that also touches the filesystem or a socket, an epoch other than 2000-01-01, a clock that must move backwards, or anywhere a constructor demands a Clock value.

The full comparison — with every claim traced to go doc testing/synctest or to a test in synctest_external_test.go — is in CLAUDE.md.

Benchmarks: BENCH.md. Design notes and the "do not extend Clock" rationale: CLAUDE.md.

Documentation

Overview

Package clock abstracts the passage of time so higher layers can inject a deterministic time source in tests instead of coupling to the wall clock.

The package carries two independent capabilities behind two independent interfaces, and that separation is the load-bearing design decision:

  • Clock READS time (Now, Since). It is frozen at two methods on purpose. It is reachable from the published pkg module — pkg/v1/data/cache.Config is a type alias whose Clock field has this type — so every method added to it would break every hand-written double downstream, at compile time, with no deprecation window.
  • Waiter WAITS for time (After, NewTimer, NewTicker, Sleep). Code that only stamps records keeps asking for a Clock; code that schedules, retries, or times out asks for a Timed, the union of the two.

System is the production value: a Timed delegating to package time. ManualClock is the test double: a Timed whose time moves only when the test moves it, so a timeout, a retry backoff, or a ticker cadence can be asserted exactly, without sleeping and without a wall-clock tolerance.

Non-positive durations

After, NewTimer and Sleep accept a non-positive delay and read it as "already elapsed": the value is delivered at once, Sleep returns at once. That is the stdlib contract, it is total, and it is the only sensible reading of a delay that has no future — a clamp needing no explanation (ADR 0031).

NewTicker and Ticker.Reset REFUSE a non-positive period and panic. A ticker has no meaningful zero period, and any positive value this package invented instead would be arbitrary — the other half of ADR 0031. The panic is raised HERE, by requirePositivePeriod, with a message naming this package and the offending duration, and it is raised identically by System and by ManualClock: time.NewTicker's own panic never reaches a caller, so the two implementations stay interchangeable at the boundary that matters most.

Relationship to testing/synctest

The stdlib's testing/synctest replaces package time inside a bubble, so System already observes fake time there — the two mechanisms are complementary, not redundant. Which one to reach for is documented in CLAUDE.md §"clock vs testing/synctest", and both halves of that comparison are pinned by synctest_external_test.go rather than asserted in prose.

Package clock — hosts compile-time interface assertions — keeping them out of the production source so the runtime binary carries no diagnostic-only declarations.

Package clock — hosts ManualClock, the deterministic Timed whose instant only moves when a caller moves it.

Package clock — hosts manualTicker, the Ticker handle a ManualClock hands out for a repeating wait.

Package clock — hosts manualTimer, the Timer handle a ManualClock hands out for a one-shot wait.

Package clock — hosts manualWait, the record a ManualClock keeps for one armed wait, plus the three lock-free helpers that operate on it.

Package clock — hosts the production implementation of the time port: systemClock, a zero-sized value delegating every method to package time, and the System singleton every consumer defaults to.

Package clock — hosts the Ticker contract, the handle a Waiter returns for a repeating wake-up.

Package clock — hosts the Timer contract, the handle a Waiter returns for a one-shot wake-up.

Package clock — hosts the waiting half of the time port: the Waiter contract, plus the single guard both implementations funnel their non-positive-period refusal through.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Clock

type Clock interface {
	// Now returns the current instant.
	Now() time.Time
	// Since returns the elapsed duration between the given instant and Now.
	Since(t time.Time) time.Duration
}

Clock produces timestamps and durations. Implementations must be safe for concurrent use by multiple goroutines.

This interface is DELIBERATELY not extended: see the package comment. New time capabilities land on Waiter and are consumed through Timed.

type ManualClock added in v0.1.27

type ManualClock struct {
	// contains filtered or unexported fields
}

ManualClock is a Timed whose instant moves ONLY when the caller moves it, with ManualClock.Advance or ManualClock.Set. Every wait it hands out — After, NewTimer, NewTicker, Sleep — is due at an absolute instant on this clock, and fires exactly when the caller drives the clock past that instant. No wall-clock time passes, so a test asserts cadence and ordering instead of sleeping and hoping.

It is a concrete struct, not an interface, for the same reason recycler.Pool and snapshot.Value are: there is one implementation, and a single-impl interface would be over-abstraction. Always used behind the pointer returned by NewManualClock — the embedded mutex and cond must not be copied.

Safe for concurrent use: every field is guarded by mu, and each wait's channel is created once at registration and never reassigned.

Two hazards, both inherent to a clock that only a caller can move:

  • ManualClock.Sleep blocks until ANOTHER goroutine advances the clock. A test that sleeps on its own goroutine with nobody to advance hangs until the test binary's timeout. Sleep on the code under test, advance from the test.
  • Advance wakes a waiter but does not schedule it. When the assertion depends on the woken goroutine having run, synchronise on something the goroutine itself signals; ManualClock.BlockUntil closes the mirror race (advancing before the goroutine has registered its wait).

func NewManualClock added in v0.1.27

func NewManualClock(start time.Time) *ManualClock

NewManualClock returns a ManualClock reading start. Any instant is legal — before the Unix epoch, after 2038, in any location — which is the point: unlike testing/synctest's bubble clock, the origin is the caller's choice.

func (*ManualClock) Advance added in v0.1.27

func (m *ManualClock) Advance(d time.Duration)

Advance moves the clock forward by d, firing every wait that comes due, in deadline order. It panics on a negative d: rewinding is ManualClock.Set's job and would otherwise silently mean "un-fire the timers I already fired".

Each fired wait receives its own deadline, not the post-advance instant, so a receiver reads the moment it was scheduled for. A ticker delivers AT MOST ONE tick per Advance and re-arms past the new instant: the delivery channel holds one tick anyway, and the alternative — one send per elapsed period — makes Advance(time.Hour) on a nanosecond ticker an unbounded loop. To observe N ticks, call Advance(period) N times, draining between calls.

func (*ManualClock) After added in v0.1.27

func (m *ManualClock) After(d time.Duration) <-chan time.Time

After returns a channel delivering the fire instant once d has elapsed on this clock. A non-positive d delivers before After returns.

func (*ManualClock) BlockUntil added in v0.1.27

func (m *ManualClock) BlockUntil(n int)

BlockUntil blocks until at least n waits are armed. It closes the race a manual clock otherwise has with the code it drives: the test would advance before the goroutine under test had registered its timer, and the wake would be lost. It is the manual counterpart of synctest.Wait — coarser, because it counts registrations instead of detecting durable blocking, but usable in a plain parallel test.

It blocks forever if the count is never reached; bound the test with the binary's own timeout rather than an arbitrary one here.

func (*ManualClock) NewTicker added in v0.1.27

func (m *ManualClock) NewTicker(d time.Duration) Ticker

NewTicker returns a repeating Ticker of period d, panicking on a non-positive d with the same message System uses.

func (*ManualClock) NewTimer added in v0.1.27

func (m *ManualClock) NewTimer(d time.Duration) Timer

NewTimer returns a one-shot Timer due d from the clock's current instant. A non-positive d is already elapsed and fires before NewTimer returns.

func (*ManualClock) Now added in v0.1.27

func (m *ManualClock) Now() time.Time

Now returns the clock's current instant.

func (*ManualClock) Pending added in v0.1.27

func (m *ManualClock) Pending() int

Pending returns the number of armed waits. A fired one-shot timer is no longer pending; a stopped timer or ticker is not pending; a live ticker is.

func (*ManualClock) Set added in v0.1.27

func (m *ManualClock) Set(t time.Time)

Set moves the clock to t. Forward, it behaves exactly like Advance and fires every wait that comes due. Backward, it moves the instant and fires nothing: deadlines are absolute, so a rewound clock re-reaches them and the waits fire then. That models a wall-clock jump — an NTP step, a VM restore — which is something testing/synctest's monotonic bubble clock cannot express.

func (*ManualClock) Since added in v0.1.27

func (m *ManualClock) Since(t time.Time) time.Duration

Since returns the duration between t and the clock's current instant. It can be negative when t is in the clock's future, exactly like time.Since.

func (*ManualClock) Sleep added in v0.1.27

func (m *ManualClock) Sleep(d time.Duration)

Sleep blocks the calling goroutine until d has elapsed on this clock — which means until another goroutine advances it. A non-positive d returns at once.

type Ticker added in v0.1.27

type Ticker interface {
	// C returns the channel on which each tick instant is delivered. The same
	// channel is returned by every call; it is never closed.
	C() <-chan time.Time
	// Stop halts the Ticker. It does NOT close the channel, so a concurrent
	// receiver never sees a spurious zero value, and after Stop returns no
	// tick from before the call is received — the guarantee time.Ticker.Stop
	// makes since Go 1.23, whose channel is synchronous.
	Stop()
	// Reset halts the Ticker and restarts it with period d: no tick from
	// before the call is received after it, and the next arrives once d has
	// elapsed. It PANICS on a non-positive d; see the package comment.
	Reset(d time.Duration)
}

Ticker is a repeating wake-up scheduled on some clock. It mirrors the usable surface of *time.Ticker — including its drop-on-slow-receiver contract: the delivery channel holds at most one pending tick, and ticks that cannot be delivered are discarded rather than queued.

Implementations must be safe for concurrent use by multiple goroutines.

type Timed added in v0.1.27

type Timed interface {
	Clock
	Waiter
}

Timed is a Clock that also drives waiting. It is the interface production code asks for when it needs both halves — a scheduler, a timeout, a retry backoff — and the one both System and ManualClock satisfy.

var System Timed = systemClock{}

System is the default Timed using the package time wall clock. It is typed Timed rather than Clock so callers that need to wait do not have to type-assert; assigning it to a Clock field still compiles unchanged.

type Timer added in v0.1.27

type Timer interface {
	// C returns the channel on which the fire instant is delivered. The same
	// channel is returned by every call; it is never closed.
	C() <-chan time.Time
	// Stop prevents a not-yet-fired Timer from firing and reports whether the
	// Timer was still armed. After Stop returns, no value from a previous
	// arming is left pending on C.
	Stop() bool
	// Reset re-arms the Timer to fire d from the clock's current instant and
	// reports whether it was still armed. After Reset returns, no value from a
	// previous arming is left pending on C. A non-positive d fires at once.
	Reset(d time.Duration) bool
}

Timer is a one-shot wake-up scheduled on some clock. It mirrors the usable surface of *time.Timer, with one unavoidable difference: the stdlib exposes the delivery channel as a FIELD (t.C), and no interface can require a field, so it is a method here.

Implementations must be safe for concurrent use by multiple goroutines.

type Waiter added in v0.1.27

type Waiter interface {
	// After returns a channel that delivers the fire instant once d has
	// elapsed. A non-positive d delivers at once.
	After(d time.Duration) <-chan time.Time
	// NewTimer returns a one-shot [Timer] due d from now. A non-positive d
	// fires at once.
	NewTimer(d time.Duration) Timer
	// NewTicker returns a repeating [Ticker] of period d. It PANICS on a
	// non-positive d; see the package comment.
	NewTicker(d time.Duration) Ticker
	// Sleep blocks the calling goroutine until d has elapsed on this clock.
	// A non-positive d returns at once.
	Sleep(d time.Duration)
}

Waiter suspends a caller until a duration has elapsed on some clock. It is the half of the time abstraction that Clock deliberately does not carry.

Implementations must be safe for concurrent use by multiple goroutines.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL