periodic

periodic runs a task at a fixed interval with random jitter and a per-invocation timeout, stopping cleanly on context cancellation without leaking a goroutine.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/periodic"

Runs a function repeatedly on a schedule, in its own goroutine, with a deadline on each call.

p, err := periodic.New(
    30*time.Second,  // interval
    5*time.Second,   // jitter ceiling
    10*time.Second,  // per-call timeout
    myTask,
)

p.Start(ctx)
defer p.Stop()

What it adds to time.Ticker

A time.Ticker and a for loop is four lines. The four properties below are what the four lines leave out, and each one is a production incident in its own right.

The pause is interval + rand(0, jitter). Fifty replicas of a service started by the same rollout will otherwise run their refresh task at the same instant, forever, because they all started within a second of each other. Jitter spreads them. This is the same thundering herd problem that backoff solves on the retry path, at steady state rather than during recovery.

Each invocation gets its own deadline, derived from the parent context. Without it, one task that hangs stops the schedule permanently, and the symptom is a cache that stopped refreshing hours ago with nothing in the log.

Shutdown is clean. Cancelling the parent context or calling Stop ends the loop after the current invocation returns, with no goroutine left behind. A bare ticker loop typically has no exit path at all.

The first run is eager. By default the first call fires almost immediately rather than after a full interval. A task that populates a cache at startup should not leave the cache empty for the first thirty seconds. WithInitialJitter opts into jittering that first call too, which spreads the startup spike when a whole fleet restarts at once.

Interval, not rate

The pause is measured between the end of one invocation and the start of the next, so a task taking 10 seconds on a 30-second interval runs every 40 seconds or so, not every 30.

That is the safer of the two behaviours: invocations never overlap, and a slowing task cannot pile up concurrent copies of itself. It does mean the schedule drifts, so this is not the tool for anything that must happen at a wall-clock time.

Combined with the per-call timeout, the worst-case period is bounded: timeout + interval + jitter.

A single instance, not a cluster scheduler

Start runs the task in this process. Deploy three replicas and the task runs three times per interval.

That is correct for a cache refresh or a local metrics roll-up, where every instance needs the result. It is wrong for anything that must happen once across the fleet, such as sending a daily report or reclaiming expired rows. For those, take a lock first: mysqllock gives one, and the task body becomes acquire-then-work.

Errors

The task’s error is not returned anywhere; Start is asynchronous. Log it or count it inside the task, or a failing periodic job fails silently for as long as it takes someone to notice the downstream effect.

Dependencies

This package reaches no external module: it uses only the Go standard library.