Help us shape the future of Go! Take the 2026 Go Developer Survey

tick

package
v0.6.7 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

Documentation

Overview

Package tick is one safe ticker lifecycle, and nothing else.

A package of its own rather than a file in `runtime/serve`, which is the other obvious home and where this started. `serve` is the process's lifecycle — it builds the pool, so it imports pgxpool — and the two modules that reach for a ticker, `notify` and `presence`, have no other reason to depend on the server framework. Putting sixty lines of `time.Ticker` there put `pgxpool` and `puddle` in both of their module graphs, and made `dbx.Pool`'s promise that a module taking one "never imports pgxpool" untrue for exactly the two modules it names.

So: a leaf over `context`, `sync` and `time`, which anything may depend on. It is the same argument github.com/simonjanss/rig/runtime/outbox makes for itself, and the reason both of these can exist at all.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	// Interval is how often Pass runs.
	//
	// Zero or negative starts nothing, and is not an error: it is how an operator
	// says a cron job owns this. A caller with a default of its own resolves it
	// before it gets here, because there is no interval a library can pick for
	// somebody else's work.
	Interval time.Duration

	// Pass is one run.
	//
	// Called from one goroutine and never concurrently with itself, so it needs
	// no lock of its own against another pass — only against whatever else in the
	// process touches the same rows. It must not panic: nothing here may take a
	// process down over a pass that failed.
	//
	// A nil Pass starts nothing, the same as a zero Interval.
	Pass func(ctx context.Context)

	// PassTimeout bounds one pass. Zero is unbounded.
	//
	// Unbounded is the default because it is the answer that cannot be wrong.
	// Work that already declares its own deadline — a claim lease longer than
	// this interval, most often — would be cut off mid-write by a timeout set
	// here, and cut off *before* the statement that gives the claims back. Set
	// this only where the pass has no deadline of its own, and then the interval
	// is usually the number: a pass that cannot reach the database should fail
	// and be retried on the next tick rather than hold the slot forever.
	PassTimeout time.Duration
}

Config is what a Ticker needs.

type Ticker

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

Ticker runs one pass on an interval and stops when it is told to.

The interesting part is not the ticker, which is six lines. It is the four properties around it, and every one of them is a bug somebody has already had in a hand-rolled version:

  • Ticker.Start is idempotent, because two goroutines both closing the same `done` channel on the way out is a panic, in a shutdown path, from a goroutine that nothing is recovering.
  • A Start after a Ticker.Close starts nothing, for the same reason.
  • A Close with no prior Start returns immediately. Waiting on a goroutine that does not exist holds shutdown open for the whole registered timeout and then reports a failure — which is what an operator who left the work to a cron job and kept the shutdown registration has.
  • A second Close, or two at once, is not a second close of a channel. The check and the close are under one lock, because as a check-then-act they are a race between two callers that both take the branch.

Close takes a context and returns an error so that it registers directly:

app.CloseWithin("notifications", 15*time.Second, engine.Close)

What it does not do is decide anything about the work. There is no error return from a pass, no backoff between passes, and no lease: a pass that fails is a pass, and the next one is coming. Anything that needs to be told apart from that belongs in Config.Pass, where the thing being done knows what it means.

Safe for concurrent use.

func New

func New(cfg Config) *Ticker

New builds one. It does nothing until Ticker.Start.

func (*Ticker) Close

func (t *Ticker) Close(ctx context.Context) error

Close stops the ticker and waits for a pass in flight, honouring ctx.

A ticker that was never started has nothing to wait for and says so at once. A ticker that was returns when the pass in flight does, or when ctx is done — whichever happens first, because a pass that cannot reach the database should not outlive the deadline its caller declared.

Idempotent. Calling it twice, or from two goroutines, is safe.

func (*Ticker) Nudge

func (t *Ticker) Nudge()

Nudge asks for a pass now.

An optimization, and nothing may be built on it. A dropped nudge — and they are dropped, because the channel holds one — costs at most one interval. A caller that never nudges pays nothing for this existing, and a nudge before Ticker.Start or after Ticker.Close is not an error.

func (*Ticker) Start

func (t *Ticker) Start()

Start begins the ticker. Idempotent, and safe on one already closed.

What it records is whether there is a goroutine for Ticker.Close to wait for. Starting nothing has to be told apart from starting something, because the difference is whether `done` will ever be closed.

Jump to

Keyboard shortcuts

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