scheduler

package
v0.4.0 Latest Latest
Warning

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

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

Documentation

Overview

Package scheduler implements a per-agent cron scheduler for standalone "agent API" services that take their schedule list from a central controller but fire it locally.

Operating model

The agent owns its own cron scheduling — the controller is NOT in the firing path, so schedules keep firing while it is unreachable. Each agent runs:

  1. A 1-minute fire loop that walks the in-memory schedule cache and calls the agent-provided FireFunc for any schedule whose cron expression matches the current minute.
  2. A 5-minute reconcile loop that re-pulls the schedule list from the controller (GET <PullURL>) and replaces the local cache, preserving LastFiredAt entries for surviving schedule IDs.
  3. A persistent on-disk cache (atomic write to a JSON file) so a pod restart does not (a) re-fire a schedule that already fired this minute, or (b) flood with missed-run recovery for windows that were already covered.

The agent provides:

  • The PullURL (e.g. "https://controller.example.com/api/schedules?node=node-a")
  • A FireFunc callback that runs the actual job (a CLI invocation, a backup, whatever the agent does)
  • A NodeName used only for logging
  • A CachePath for disk persistence (typically under CONFIG_DIR)
  • Optional: HTTP client (defaults to http.DefaultClient — usually you want to pass a client that already has bearer-auth + traefik dial plumbing installed)

Cron semantics

Matches the controller's own matcher, so the JSON shape and matching behaviour are identical whether a schedule fires centrally or on the agent. Days use the 3-letter lowercase weekday names (mon, tue, ...). Hours and Minute are numeric strings. "*" wildcards both days and hours. IntervalMinutes overrides the days/hours/minute fields when set.

All matching happens in America/New_York by default — override via Config.Timezone.

MissPolicy

  • MissSkip: never replay missed windows.
  • MissRunOnceWithin24h: on Start, after the first successful pull, check if a fire window in the last 24h was missed (lastFired before the last matching window) and fire ONCE as catch-up.
  • MissRunAll: currently treated identically to MissRunOnceWithin24h to avoid a missed-prune storm. Logged when truncated.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Matches

func Matches(s Schedule, now time.Time, tz *time.Location) bool

Matches returns true if `now` (interpreted in `tz`) falls inside one of `s`'s configured fire windows.

Mirrors the controller's scheduler's matcher verbatim so the JSON Schedule shape behaves identically whether the controller or an agent is evaluating it.

Exported so tests (and the kit's own MissedRunCheck) can re-use the same predicate — callers normally do not need to invoke it directly.

func ParamBool

func ParamBool(m map[string]interface{}, k string) bool

ParamBool returns m[k] coerced to bool. False on miss or wrong type.

func ParamInt

func ParamInt(m map[string]interface{}, k string) int

ParamInt returns m[k] coerced to int. Handles both float64 (JSON's default for numbers) and native int. Zero on miss.

func ParamString

func ParamString(m map[string]interface{}, k string) string

ParamString returns m[k] coerced to string, or "" if missing or wrong type.

func ParamStrings

func ParamStrings(m map[string]interface{}, k string) []string

ParamStrings returns m[k] coerced to []string. Empty slice on miss or wrong type. Skips non-string elements without erroring.

Types

type Cache

type Cache struct {
	Schedules     []LocalSchedule      `json:"schedules"`
	LastFiredAt   map[string]time.Time `json:"last_fired_at"`
	LastFetchedAt time.Time            `json:"last_fetched_at"`
}

Cache is the disk-persisted shape — exported so an agent's debug endpoint can render it directly.

type Chain

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

Chain turns an agent's job-completion stream into ordered, event-triggered schedule fires. It is the generic primitive behind "run the maintenance jobs after the nightly backup wave finishes": cron-fired backup/copy schedules land their data, and when the agent has no more of those in flight the Chain fires the next stage's On-schedules (e.g. prune), then the stage after that (e.g. check) once the prune jobs drain — strictly one stage per day.

The Chain is deliberately agent-agnostic:

  • It does not know about duplicacy actions. The agent supplies a Spec whose callbacks classify a completed job (is it part of the wave? which stage's work just finished?) and report whether any of a stage's jobs are still active.
  • Firing a stage means "FireMatching the schedules this stage owns". The agent's Spec provides the predicate (typically: Schedule.On == sentinel && Action == "<stage action>").

Idempotency is per calendar day (agent-local timezone). Each stage records the date it last fired in an atomic on-disk marker file, so an agent restart mid-morning neither re-fires a stage that already ran today nor skips a stage whose turn had come. First boot with no marker does NOT retroactively fire a wave that completed before the process started — OnJobComplete only ever acts on transitions it personally witnesses.

func NewChain

func NewChain(s *Scheduler, spec Spec) *Chain

NewChain builds a Chain. tz defaults to the scheduler's configured timezone when nil. Loads the on-disk marker (missing file ⇒ empty, first-boot safe).

func (*Chain) OnJobComplete

func (c *Chain) OnJobComplete(ctx context.Context, action string)

OnJobComplete is the agent's hook into the Chain. Call it from the job-completion path with the action of the job that just reached a terminal state. Cheap and safe to call on every terminal event (it filters internally). The provided ctx is used for any schedule fires it triggers.

type Config

type Config struct {
	// PullURL is the full URL the scheduler GETs to refresh the local
	// schedule list, e.g.
	// "https://controller.example.com/api/schedules?node=node-a".
	// The kit does not format this — callers substitute their own node.
	PullURL string

	// NodeName identifies this agent in log lines. Not used in the
	// PullURL — that's the caller's responsibility.
	NodeName string

	// CachePath is the disk path for the JSON cache file. The kit writes
	// atomically (path + ".tmp" → rename) so a crash mid-write doesn't
	// corrupt the file. Parent directory must exist.
	CachePath string

	// HTTPClient does the pull. Defaults to http.DefaultClient. Pass a
	// pre-configured client when the call needs bearer auth, a custom
	// dial path (e.g. through Traefik), or non-default timeouts.
	HTTPClient *http.Client

	// Fire is the per-schedule callback. Required.
	Fire FireFunc

	// Timezone for cron evaluation. Defaults to America/New_York to
	// match the rest of the stack.
	Timezone *time.Location

	// FireInterval is the cron tick. Defaults to 1 * time.Minute. Tests
	// can lower this; production should not.
	FireInterval time.Duration

	// PullInterval is the reconcile tick. Defaults to 5 * time.Minute.
	PullInterval time.Duration
}

Config configures the scheduler. PullURL, NodeName, CachePath, and Fire are required; the rest have safe defaults.

type FireFunc

type FireFunc func(ctx context.Context, sch LocalSchedule, triggerKey string, missedRecovery bool) error

FireFunc is the agent-supplied callback invoked when a schedule matches the current minute. The kit only handles the bookkeeping — the agent is responsible for the actual work (CLI invocation, API call, etc).

`triggerKey` is set by the kit:

  • "schedule" for a normal fire from the 1-minute tick.
  • "schedule-recovery" for a missed-run catch-up fire from Start.

`missedRecovery` mirrors triggerKey for convenience and matches the agents' existing telemetry shape.

Returning an error does NOT stop the scheduler — it is logged and the next tick proceeds normally. A non-nil error means the fire did NOT happen: the kit leaves LastFiredAt un-stamped so the window is retried (next matching minute and by the periodic missedRunCheck). Return nil ONLY when the work was actually dispatched; return an error for any transient failure (secret vend timeout, repo not yet loaded, job failed to start) so it gets another attempt rather than being silently lost.

type LocalSchedule

type LocalSchedule struct {
	ID         string                 `json:"id"`
	Name       string                 `json:"name"`
	RepoID     string                 `json:"repo_id"`
	Action     string                 `json:"action"`
	Storage    string                 `json:"storage,omitempty"`
	Schedule   Schedule               `json:"schedule"`
	MissPolicy MissPolicy             `json:"miss_policy"`
	Enabled    bool                   `json:"enabled"`
	Params     map[string]interface{} `json:"params,omitempty"`
	Version    int64                  `json:"version"`
}

LocalSchedule is one row pulled from the controller plus the agent-side bookkeeping the kit maintains.

Action is an opaque string — each agent defines its own action vocabulary ("backup" / "check" / "prune" for duplicacy; "backup" / "prune" for gdrive). The kit does not interpret it.

Params is a free-form map that the agent's FireFunc decodes. Use the Param* helpers in this package to extract typed values without panicking on JSON-decoded interface{} variations.

type MissPolicy

type MissPolicy string

MissPolicy controls catch-up behaviour when an agent boots after missing a fire window.

const (
	// MissSkip: never replay missed windows.
	MissSkip MissPolicy = "skip"
	// MissRunOnceWithin24 fires once on boot if the most recent window
	// within the last 24 h was missed (lastFired before that window).
	MissRunOnceWithin24 MissPolicy = "run-once-within-24h"
	// MissRunAll is currently treated identically to MissRunOnceWithin24
	// to avoid a missed-prune storm; the truncation is logged.
	MissRunAll MissPolicy = "run-all"
)

type Schedule

type Schedule struct {
	Days            []string `json:"days"`
	Hours           []string `json:"hours"`
	Minute          string   `json:"minute"`
	IntervalMinutes *int     `json:"interval_minutes,omitempty"`
	On              string   `json:"on,omitempty"`
}

Schedule is the cron-like shape pulled from the controller. Mirrors the controller's scheduler so the JSON wire format is identical whether the schedule fires from the controller or from an agent.

On is a non-cron trigger sentinel: when set (e.g. {"on":"after_wave"}), the schedule NEVER fires on the clock — Matches always returns false and missed-run recovery skips it. Such rows are fired on demand by an agent's chain controller via FireSchedule when an upstream condition is met (e.g. "the nightly backup+copy wave finished"). Days/Hours/Minute are ignored when On is set. Empty On means a normal cron schedule.

type Scheduler

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

Scheduler is the running scheduler. Create with New, drive with Start + Stop, accept early-reconcile pokes via Pull (intended for an HTTP /schedules/refresh handler).

func New

func New(cfg Config) (*Scheduler, error)

New builds a Scheduler. Loads the on-disk cache (if any). Does NOT start any goroutines — call Start.

func (*Scheduler) Cache

func (s *Scheduler) Cache() Cache

Cache returns a defensive copy of the full cache including LastFiredAt. Intended for an agent's debug endpoint.

func (*Scheduler) FireMatching

func (s *Scheduler) FireMatching(ctx context.Context, triggerKey string, pred func(LocalSchedule) bool) int

FireMatching dispatches every enabled schedule for which pred returns true, labelling each fire with triggerKey. Intended for event-triggered schedules (Schedule.On != "") fired on demand by a Chain or other orchestrator — NOT by the cron tick. Returns the number of schedules dispatched.

dispatch still stamps LastFiredAt + persists the cache (harmless for On-schedules, whose LastFiredAt is never consulted by Matches/missed-run recovery — both short-circuit on On != ""). The Chain keeps its own per-day markers for idempotency; LastFiredAt is not relied on here.

func (*Scheduler) Pull

func (s *Scheduler) Pull(ctx context.Context) error

Pull re-fetches the schedule list from the controller and replaces the local cache. Exported so an HTTP /schedules/refresh handler can nudge an early reconcile after a CRUD write.

Preserves LastFiredAt entries for schedule IDs that survive the diff so a freshly-pulled cache does not accidentally re-fire something that already fired this minute.

func (*Scheduler) Schedules

func (s *Scheduler) Schedules() []LocalSchedule

Schedules returns a defensive copy of the current cache, sorted by Name. Intended for an agent's GET /schedules list handler.

func (*Scheduler) Start

func (s *Scheduler) Start(ctx context.Context)

Start launches the 1-min fire loop, the 5-min reconcile loop, and a best-effort initial pull followed by a MissedRunCheck. Returns immediately.

func (*Scheduler) Stop

func (s *Scheduler) Stop()

Stop signals the loops to exit and waits for them to drain. Safe to call multiple times.

type Spec

type Spec struct {
	// Stages run in order. Stage[i+1] fires once Stage[i] has fired today AND
	// Stage[i].Active() reports false (its jobs drained). Stage[0] fires once
	// the wave is complete (see WaveComplete).
	Stages []Stage

	// IsWaveJob reports whether a completed job is part of the nightly wave
	// (backup/copy) that gates Stage[0]. Completions of non-wave jobs (e.g.
	// the chain's own prune/check) still drive stage advancement but do not
	// re-arm the wave.
	IsWaveJob func(action string) bool

	// WaveComplete reports whether the wave has fully drained: at least one
	// wave job ran since arming and none are now running/pending. The agent
	// implements this against its job registry (e.g. "no backup/copy job is
	// running or pending AND we saw ≥1 finish"). The Chain calls it only on a
	// wave-job completion.
	WaveComplete func() bool

	// MarkerPath is the on-disk file where per-stage last-fired dates persist
	// (atomic write). Parent dir must exist. Required.
	MarkerPath string
}

Spec configures a Chain for a specific agent.

type Stage

type Stage struct {
	// Name labels the stage in logs and is the key under which its
	// last-fired date is persisted. Stable across releases — changing it
	// resets that stage's idempotency marker.
	Name string

	// TriggerKey labels the jobs this stage starts (e.g. "chain-prune").
	// Surfaced on the job row so operators can see a job was chain-fired.
	TriggerKey string

	// Match selects the schedules this stage fires. Called for every cached
	// schedule; return true for the ones this stage owns (typically
	// sch.Schedule.On == "after_wave" && sch.Action == "<stage action>").
	Match func(LocalSchedule) bool

	// Active reports whether any job belonging to THIS stage is still
	// running or pending. The Chain advances to the next stage only when this
	// returns false after the stage has fired. For a stage that fires
	// "prune", Active is "any prune job running/pending?".
	Active func() bool
}

Stage is one step in the chain, fired after the previous stage's work drains. Stage 0's "previous" is the backup+copy wave itself.

Jump to

Keyboard shortcuts

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