Skip to content

Scheduling

A plugin can declare methods for FreeSDN to run on a timer. Until this existed a plugin acted only when an event arrived or an operator pressed a button, which left the periodic plugins - drift scans, inventory syncs, sweeps - unable to do the thing they exist for.

In plugin.yaml, next to the permissions:

schedules:
- name: drift_scan
method: scheduled_scan
interval_seconds: 900
description: >-
Run every enabled drift check and raise or resolve alerts. Read-only
against your gear; it inspects the inventory FreeSDN already holds.
enabled_by_default: true

And the method on your plugin class - async, no arguments:

async def scheduled_scan(self) -> None:
if not self.ctx or not await self.ctx.settings.get("enabled", True):
return
await self._scan(raise_alerts=True)

It belongs in the manifest rather than being registered from code, for the same reason permissions do: an operator deciding whether to install your plugin should be able to see that it will run unattended on their network, and how often, before they say yes.

That is not only the consent dialog. Schedules travel in the signed catalogue, so a plugin’s page on the registry shows what it will run and whether each schedule starts on its own, before anybody installs anything. The catalogue copy is descriptive: the loader reads the real schedules from the installed manifest, so a catalogue that lied about them could misinform a browse page and change nothing that runs.

Field Meaning
name Unique within the plugin. Appears in health output and log lines, so name it for what the run does.
method An async method on your plugin class, called with no arguments. Cannot be a lifecycle hook or a private _name.
interval_seconds Between 60 and 86400.
description Shown to the operator. Write it for someone deciding whether they want this running while they sleep.
enabled_by_default Whether it runs on install, or waits to be switched on. Default true.

Maximum ten schedules per plugin.

One runtime per organization means one run per organization. A schedule declared once fires separately for each organization the plugin is active in, each with its own context, settings and credentials.

The scheduler does not keep timers. Every 15 seconds it computes the current slot for each schedule - floor((now + offset) / interval) - and claims it in Redis with SET NX. Whichever worker wins runs it. Three properties fall out of that, and they are the reasons for the design:

  • At most once per slot, across every worker and replica. No shared timer state, nothing to reconcile after a failover.
  • No catch-up. If the platform was down for six hours, only the current slot can be claimed, so a 15-minute scan runs once on return rather than twenty-four times. The answer a monitoring scan would have given six hours ago is not an answer anyone wants.
  • Spread, not synchronised. The offset is derived from the schedule’s identity, so twelve organizations do not all sweep their inventory on the same quarter hour. It is a hash rather than a random number because every worker has to agree on where the slot boundary falls.

A schedule fires within 15 seconds of its slot boundary, which is why the interval floor is 60 seconds: a 15-second tick cannot meaningfully pace anything faster.

Plugins, expand a plugin. Each declared schedule shows one line:

drift_scan every 15 min [ok] (on)
Run every enabled drift check and raise or resolve alerts...
next in 4m last 11m ago · 19ms 1 runs, 0 failed (this worker)

The switch turns it off for your organization. A schedule that has never run says so in place of “last”, which is what a broken one looks like from outside.

The same thing over the API:

GET /api/v1/plugins/{plugin_id}/schedules
[
{
"name": "drift_scan",
"method": "scheduled_scan",
"interval_seconds": 900,
"description": "Run every enabled drift check...",
"enabled": true,
"next_run_at": 1787418074.0,
"last_run": {
"status": "ok",
"started_at": 1787418024.44,
"duration_ms": 22.2,
"error": null
},
"runs_this_worker": 3,
"failures_this_worker": 0
}
]

last_run is the point. A schedule an operator can see declared but cannot see running is indistinguishable from one that is quietly failing, and unattended work that fails quietly is the whole reason supervision exists. The record is kept in Redis for seven days so it is the same on every worker - without that, an operator on a two-worker deployment would see “never run” half the times they refresh, for a schedule running perfectly well on the other worker.

The two _this_worker counters are honestly named: they are per process, and under multiple workers the real totals are higher.

Turning one off is the switch in that panel, or:

PUT /api/v1/plugins/{plugin_id}/schedules/{name}
{"enabled": false}

Per organization rather than per plugin, because on a shared appliance one tenant wanting a quiet inventory sync must not stop another tenant’s drift scan.

A scheduled run counts against your plugin’s error budget exactly like an event or a route call, and a quarantined plugin is skipped rather than run again every interval. A plugin failing unattended every 60 seconds forever is precisely what the budget exists to stop, and scheduling is the feature most likely to produce it.

Each run is also bounded by its own interval (capped at one hour): a run that outlives its own interval can never keep up.

last_run.status is one of:

Status Meaning
ok Ran and returned.
error Your method raised. The exception is in last_run.error.
timeout Exceeded the run timeout.
quarantined The plugin was quarantined; the run was refused.
missing_method The manifest names a method your class does not have.

missing_method deserves a note. The manifest validator never imports your plugin class, so it cannot catch a typo in method at install time. Rather than log into the void, the scheduler records it as a failed run - so an operator looking at a schedule that never produces anything is told why.

You do not have to do anything for this, but it is worth knowing why it is there. Every SDK object used to hold the session captured at on_start, shared across your routes, your event handlers and everything else. asyncpg permits one operation in flight per connection, so two overlapping calls into one plugin raced and one of them failed with:

InvalidRequestError: This session is provisioning a new connection;
concurrent operations are not permitted

That stayed rare only because nothing ran plugin code unattended. Scheduling changes it: a scan that takes a minute overlaps anything an operator does in that minute. Each entry point - a route call, an event, a scheduled run - now binds its own session for its duration.

  • Respect your own enabled setting. One switch should turn the plugin off, not two. The event path already checks it; the scheduled path should too.
  • Ship destructive work disabled. netbox-sync writes into somebody’s external system of record, so its schedule declares enabled_by_default: false and its handler honours the dry_run setting, which defaults on. Two deliberate acts stand between installing it and it writing on a timer.
  • Page with iter_all(), not list(limit=N). A full scan on a timer is exactly where a silent truncation at 500 devices does the most damage: right above the cap the report is wrong, right below it is correct, and nothing says which one you are looking at.
  • Cap your alerting. The first scheduled sweep after an install can legitimately find hundreds of unrecognised devices. Hundreds of alerts is one piece of information delivered in the way most likely to be ignored.

No cron expressions. An interval is unambiguous; a cron field is not, and a half-built cron parser with unstated timezone behaviour would hand an operator a schedule they misread - one that runs at the wrong hour and looks correct. Interval only, until someone needs more and can say what the timezone rules should be.

Nothing runs in a Celery worker. Schedules run in the API process, where the per-organization runtimes already live. A worker would have to load plugin code to run it, which is a new place for third-party code to execute and a second lifecycle to keep in step with the first.

No per-run history. Only the last run is kept. Anything worth keeping longer belongs in an alert or a log line your plugin emits itself.

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.