dispatch

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Nov 6, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package dispatch sends evidence about your scheduled jobs to Hallproof, read from a github.com/xraph/dispatch engine. The one you'll care about most is proof that your backups ran.

Collectors

dispatch.scheduled-jobs is a population of every cron entry: its name, cron ID, schedule, the job it starts, queue, whether it's enabled, its app and org scope, when it was created, and expected_every_seconds, the gap between fires. That gap is only sent for schedules that fire at a fixed interval, and it's measured from a fixed date, so a weekday-only or monthly schedule goes without it and the item doesn't change from one day to the next. It runs hourly.

dispatch.job-runs is a population with one summary per scheduled job: when the schedule last fired, when the job last succeeded and last failed (with those jobs' IDs), how many runs succeeded or failed in the last 24 hours, and window_truncated, which is true when the scan budget ran out (see Options). It runs hourly, and since the counts move all the time, nearly every run sends new evidence. Keep it at an hour or longer.

last_fired_at is when dispatch enqueued the job, not when it succeeded. The success time comes from the newest completed job. Dispatch doesn't store a failure time, so last_failed_at is the failed job's last update.

dispatch.backup-runs has the same shape, but only for the jobs you name in BackupJobs, by job name or cron entry name. It supports the backup-recent test. A name that matches no cron entry still gets an item, built from the jobs with that exact name, so a backup that has no schedule, or has never run, shows up as exactly that. With no names set the collector isn't registered at all, because dispatch has no way to tell a backup job from any other.

dispatch.schedule-changes is an event proof of staff enabling, disabling, deleting or triggering a cron entry, from the platform stream. It's off until you turn on event proofs, and it only proves something when your app records dispatch's audit_hook events to the chain witness proves from.

Never sent: job and cron payloads, LastError (free text that often echoes arguments, SQL or customer data), artifact bindings, input hashes and sizes, worker IDs (a cron entry's LockedBy included), resource sets and lease fields. A cron entry's next run, lock times and update time aren't sent either, because they change on every fire without telling you anything about the schedule.

Scope

By default you get staff evidence only. A cron entry or job is staff when its app scope is empty or your platform's own app (config app_id, which defaults to the witness scope's AppID), and its org scope is empty or an org you name in StaffOrgs (config staff_orgs). Both have to hold. Authsome gives a session with no org an app-only scope, so a job a customer app enqueues can have no org and still not be yours; checking the app keeps it out. Dispatch's own cron scheduler enqueues jobs with no scope, and RegisterCron never sets one, so this covers the schedules your app registers. Dispatch reads an empty org filter as "every org", so the adapter can't ask the store for unscoped jobs. It reads them all and drops the rows that aren't staff itself.

One case it can't tell apart: a background worker that fans out one job per customer, with no scope on the job and the customer only in the payload. Those jobs count as staff, and a customer's run of that job name counts toward the staff item. So list in backup_jobs only jobs the platform runs for itself, like a database backup, never a per-customer export that shares a name across customers.

AllTenants (config all_tenants, or the witness scope's AllTenants) adds every org's and app's cron entries and jobs, and the collectors' descriptions say so. A customer org's runs of a job get their own item, id "<cron name>@<org ID>", and another app's runs with no org get "<cron name>@app:<app ID>", so they're kept apart from the staff item. Even then only counts, times and IDs go, never payloads or errors. When all_tenants comes from the adapter's own config, the adapter tells the witness host that it widened its scope, so Hallproof can show it.

A scope with more than push.MaxItems items fails the run with ErrTooMany, and the heartbeat reports the collector unhealthy.

Options

In a Forge app these come from extensions.witness.adapters.dispatch:

backup_jobs: [db.backup]   # job or cron entry names; empty turns backup-runs off
app_id: ""                 # your platform's app; defaults to the witness scope's AppID
staff_orgs: []             # orgs that count as staff, on top of no org
all_tenants: false
scan_budget: 5000          # job rows read per job name and outcome per run
event_proofs: false
proof_actions: []          # defaults to DefaultProofActions

Outside Forge the same settings are Options: WithBackupJobs, WithAppID, WithStaffOrgs, WithAllTenants, WithScanBudget and WithEventProofs. Outside Forge there's no witness scope to default from, so without WithAppID only rows with no app scope count as staff.

The scan budget is there because finding a job's runs is expensive (see the gaps below). For each job name the adapter reads completed jobs, then failed jobs, newest first, 200 at a time, each call capped at 30 seconds. It stops once it has read past the 24 hour window and found the latest staff run, or at the end of the list, or after scan_budget rows. Hitting the budget sets window_truncated: the counts may then be low, and a last success or failure further back may be missing. A list call that errors or times out fails the run.

Using it

With Forge, import the package for its side effect:

import _ "github.com/hallproof/witness/dispatch"

The adapter reads the engine through dispatch's own Forge extension (named "dispatch") and its Engine getter. It never asks the container for the engine when that extension runs, because the extension registers its engine with a constructor, and the container starts whatever a constructor builds the first time anyone asks for it. Reading it there would start a second worker pool, cron scheduler and listener on an engine that's already running, and your app's shutdown would hang. If the extension is there but hasn't built its engine, the adapter logs it and reports dispatch absent.

Only when your app doesn't run dispatch's extension does the adapter look for *engine.Engine in the container. If you build the engine yourself, provide the finished value there (vessel.ProvideValue or forge.ProvideValue), not a constructor.

Without Forge, build the adapter and register it before you start witness:

a := dispatch.New(eng, dispatch.WithBackupJobs("db.backup"))
if err := a.Register(w); err != nil {
	return err
}

Library gaps

These are things dispatch (v1.9.3 here) doesn't do yet that would make this adapter cheaper or more accurate:

  • A job doesn't record the cron entry that enqueued it. The adapter matches runs to schedules by job name, so a manual enqueue of the same job counts as a run. A cron name on job.Job, filterable in ListJobsOpts, would fix it. Today the exact link is only in the cron.fired audit event.
  • ListJobs filters names with no index on Postgres, SQLite or Mongo, and dispatch never deletes finished jobs, so a rare job in a busy table can mean walking most of it. An index on (name, id) would help; a retention setting would help more.
  • ListJobs loads whole rows, payloads included. Nothing from them is sent, but the adapter pays to read them.
  • A failed job has no failure time. CompletedAt is only set on success, so the adapter uses UpdatedAt.
  • ListJobsOpts treats an empty org as every org, so there's no way to ask for unscoped jobs only.
  • Dispatch's audit_hook is opt-in, and its Forge extension doesn't wire it to Chronicle, so operator.cron_* events reach the chain only when your app adds it with dispatchext.WithExtension.
  • Citing backup runs from the chain (cron.fired plus job.completed for the backup job) would need witness to filter event proofs by metadata such as job_name. Without that it would cite every job.completed on the stream, so the backup evidence comes from state.

Index

Constants

View Source
const (
	KeyScheduledJobs   = "dispatch.scheduled-jobs"
	KeyJobRuns         = "dispatch.job-runs"
	KeyBackupRuns      = "dispatch.backup-runs"
	KeyScheduleChanges = "dispatch.schedule-changes"
)

Collector keys.

View Source
const DefaultScanBudget = 5000

DefaultScanBudget is how many job rows the run collectors read, at most, for one job name and one outcome (succeeded or failed) in one run.

View Source
const Module = "github.com/xraph/dispatch"

Module is the library this adapter reads.

View Source
const Name = "dispatch"

Name is the adapter's name: its config key and log name.

View Source
const Window = 24 * time.Hour

Window is how far back the run collectors count succeeded and failed runs.

Variables

View Source
var DefaultProofActions = []string{
	"operator.cron_enabled",
	"operator.cron_disabled",
	"operator.cron_deleted",
	"operator.cron_triggered",
}

DefaultProofActions are the audit actions the schedule change proof cites when you turn event proofs on without naming any. Dispatch's audit_hook extension records them when an operator changes a cron entry.

View Source
var ErrNoLister = errors.New("witness/dispatch: the dispatch store can't list jobs")

ErrNoLister is what Register returns when the engine's store can't list jobs (it doesn't implement job.Lister). Every store dispatch ships does.

View Source
var ErrTooMany = errors.New("witness/dispatch: more items in scope than one population can carry")

ErrTooMany is what a collector returns when the scope holds more items than one population can carry (push.MaxItems). The run fails, and its heartbeat says so, rather than sending a list that looks complete but isn't.

Functions

This section is empty.

Types

type Adapter

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

Adapter collects dispatch evidence from one engine.

func New

func New(eng *engine.Engine, opts ...Option) *Adapter

New returns an adapter that reads eng's cron entries and jobs.

func (*Adapter) Register

func (a *Adapter) Register(w *witness.Witness) error

Register adds the adapter's collectors to w. It adds them all or none: when one fails, the ones added before it are taken out again.

type Option

type Option func(*Options)

Option changes an adapter's Options.

func WithAllTenants

func WithAllTenants() Option

WithAllTenants includes every org's entries and jobs, customers' included.

func WithAppID

func WithAppID(appID string) Option

WithAppID sets the platform's own app. Cron entries and jobs scoped to another app aren't staff.

func WithBackupJobs

func WithBackupJobs(names ...string) Option

WithBackupJobs names your backup jobs, by job name or cron entry name.

func WithEventProofs

func WithEventProofs(actions ...string) Option

WithEventProofs turns on the schedule change event proof, citing actions, or DefaultProofActions when you name none.

func WithScanBudget

func WithScanBudget(n int) Option

WithScanBudget sets the most job rows read for one job name and outcome in one run.

func WithStaffOrgs

func WithStaffOrgs(orgIDs ...string) Option

WithStaffOrgs adds org IDs whose entries and jobs count as staff.

type Options

type Options struct {
	// BackupJobs are the job names, or cron entry names, of your backup
	// jobs. They select what dispatch.backup-runs reports. Empty means that
	// collector isn't registered, because dispatch can't tell a backup job
	// from any other.
	BackupJobs []string `json:"backup_jobs"`
	// AppID is the platform's own app. A cron entry or job counts as staff
	// only when its app scope is empty or this app. In a Forge app it
	// defaults to the witness scope's AppID.
	AppID string `json:"app_id"`
	// StaffOrgs are org IDs whose cron entries and jobs count as staff, on
	// top of the ones with no org at all. Usually empty.
	StaffOrgs []string `json:"staff_orgs"`
	// AllTenants includes cron entries and jobs of every org, your
	// customers' included. In a Forge app the witness scope's AllTenants
	// turns it on too.
	AllTenants bool `json:"all_tenants"`
	// ScanBudget is the most job rows read for one job name and outcome in
	// one run. Zero means DefaultScanBudget.
	ScanBudget int `json:"scan_budget"`
	// EventProofs turns on the schedule change event proof. Off by
	// default.
	EventProofs bool `json:"event_proofs"`
	// ProofActions are the actions the event proof cites. Empty means
	// DefaultProofActions.
	ProofActions []string `json:"proof_actions"`
}

Options is the adapter's config. In a Forge app it is read from extensions.witness.adapters.dispatch.

Jump to

Keyboard shortcuts

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