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.
Declaring a schedule
Section titled “Declaring a schedule”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: trueAnd 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.
What runs, and when
Section titled “What runs, and when”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.
What an operator sees
Section titled “What an operator sees”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.
Supervision applies
Section titled “Supervision applies”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.
Failure statuses
Section titled “Failure statuses”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.
Each run gets its own database session
Section titled “Each run gets its own database session”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 permittedThat 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.
Writing a good scheduled method
Section titled “Writing a good scheduled method”- Respect your own
enabledsetting. 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-syncwrites into somebody’s external system of record, so its schedule declaresenabled_by_default: falseand its handler honours thedry_runsetting, which defaults on. Two deliberate acts stand between installing it and it writing on a timer. - Page with
iter_all(), notlist(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.
What this does not do
Section titled “What this does not do”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.