bext_plugin_api/scheduled.rs
1//! Scheduled capability trait and types for cron-style background work.
2//!
3//! Plugins declare a set of [`Schedule`]s at init time; the host-owned
4//! scheduler ticks, decides which schedules are due, and calls
5//! [`ScheduledPlugin::run`] for each one. The plugin returns a result and
6//! execution continues on the next tick.
7//!
8//! Schedules are **declarative**, not dynamic. A plugin returns its schedule
9//! list once from [`ScheduledPlugin::schedules`]; there is no runtime
10//! `register_schedule` host function. This mirrors the existing built-in
11//! scheduler in `bext-core::scheduler` (driven by `[[tasks]]` in
12//! `bext.config.toml`) and keeps the shape introspectable at install time —
13//! the registry resolver, the dev dashboard, and `cap_conformance` can all
14//! enumerate a plugin's schedules without running it.
15//!
16//! Data types are plain POD with `String`/`u64`/enum fields so the trait
17//! stays ABI-compatible with the WASM guest crate (matches the convention
18//! used by [`crate::lifecycle::LifecyclePlugin`] — JSON strings, `Result<_,
19//! String>`, no framework types). The `bext-core` side owns the rich
20//! `scheduler::Schedule` enum and converts to/from this POD shape at the
21//! host boundary.
22
23/// How the scheduler should behave when it discovers a schedule that was
24/// due to fire while the host was down, restarting, or otherwise not
25/// ticking.
26///
27/// This is distinct from "a previous run of this schedule is still
28/// executing" — in that case the scheduler always skips (see
29/// `is_due` in `bext-core::scheduler`).
30#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum MissedRunPolicy {
33 /// Run once to catch up, then resume normal cadence. Sensible default
34 /// for idempotent jobs like cache warming or GC sweeps.
35 RunOnce,
36 /// Skip missed runs entirely and wait for the next scheduled tick.
37 /// Use for jobs where a delayed run is worse than no run (e.g.
38 /// time-sensitive notifications).
39 Skip,
40 /// Run every missed occurrence back to back. Use sparingly — only
41 /// makes sense for accounting-style jobs that must not drop any tick.
42 RunAll,
43}
44
45impl Default for MissedRunPolicy {
46 fn default() -> Self {
47 Self::RunOnce
48 }
49}
50
51/// Hint to the scheduler about distributed-locking requirements.
52///
53/// Single-node deployments can ignore this field. Multi-node deployments
54/// use it, in combination with a configured [`crate::locking::LockingPlugin`],
55/// to decide which instance fires each schedule.
56///
57/// # How the host uses the hint
58///
59/// The Locking capability shipped in E2 as
60/// [`crate::locking::LockingPlugin`]. The host-owned scheduler in
61/// `bext-core::scheduler` consults this field at each tick:
62///
63/// - [`LockingHint::NodeLocal`] — skip locking entirely; every node
64/// may fire its own copy.
65/// - [`LockingHint::RequireGlobal`] — call
66/// [`LockingPlugin::try_lock`](crate::locking::LockingPlugin::try_lock)
67/// keyed on `schedule_id` before invoking
68/// [`ScheduledPlugin::run`]. On `Ok(None)` another node holds the
69/// lock; skip this tick. On `Err(..)` log and skip.
70/// - [`LockingHint::PreferGlobal`] — try the lock as above, but if no
71/// `LockingPlugin` is configured fall back to `NodeLocal` behavior
72/// rather than failing the install.
73///
74/// Performing the acquisition lives in `bext-core::scheduler` and is
75/// tracked separately from this trait-level doc. The shape, however, is
76/// stable: plugins declare the hint once, the host does the rest.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum LockingHint {
80 /// No coordination needed — every node may fire its own copy.
81 /// Use for per-node maintenance like local cache GC.
82 NodeLocal,
83 /// The scheduler should hold a cluster-wide lock for the duration of
84 /// the run. Only one node fires per scheduled tick. Default for most
85 /// business logic.
86 RequireGlobal,
87 /// Best-effort cluster coordination — try to acquire a global lock,
88 /// fall back to node-local if the Locking capability is not
89 /// configured. Lets a plugin work correctly in both single-node and
90 /// multi-node deployments without hard-failing the install.
91 PreferGlobal,
92}
93
94impl Default for LockingHint {
95 fn default() -> Self {
96 Self::RequireGlobal
97 }
98}
99
100/// A single declared schedule.
101///
102/// The `cron` field accepts the same syntax as `bext-core::scheduler::Schedule::parse`:
103/// - simple intervals: `"30s"`, `"5m"`, `"2h"`
104/// - cron `*/N` subset: `"*/5 * * * *"`, `"0 */3 * * *"`
105/// - shortcuts: `"@hourly"`, `"@daily"`
106///
107/// Invalid expressions are rejected at plugin load time (the host parses
108/// before registering), so a plugin that returns a bad expression fails
109/// to start with a clear error.
110#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
111pub struct Schedule {
112 /// Stable identifier passed back to [`ScheduledPlugin::run`]. Must be
113 /// unique within a plugin. Used as the locking key and as the
114 /// dashboard / metrics label.
115 pub id: String,
116 /// Human-readable description for the dev dashboard. Optional.
117 #[serde(default)]
118 pub description: String,
119 /// Schedule expression. See module docs for accepted syntax.
120 pub cron: String,
121 /// Maximum random delay applied to each tick, in milliseconds. Use
122 /// non-zero jitter for jobs that several plugins might fire
123 /// simultaneously (e.g., a 60s job with 5000ms jitter spreads load
124 /// across a 5-second window). Zero means no jitter.
125 #[serde(default)]
126 pub jitter_ms: u64,
127 /// Behavior when the scheduler detects a missed run.
128 #[serde(default)]
129 pub missed_run_policy: MissedRunPolicy,
130 /// Multi-instance coordination hint. Ignored until `Locking` lands
131 /// in E2.
132 #[serde(default)]
133 pub locking: LockingHint,
134 /// Opaque per-schedule config the plugin can use to parameterize its
135 /// run logic. Encoded as a JSON string for WASM ABI compatibility
136 /// (matches the convention in [`crate::lifecycle::LifecyclePlugin`]).
137 /// Empty string means "no config".
138 #[serde(default)]
139 pub config_json: String,
140}
141
142/// Context supplied to a single invocation of [`ScheduledPlugin::run`].
143///
144/// Plain-data only — no framework types. The host populates this from its
145/// own clock and scheduler state; the plugin reads it to decide how to
146/// behave on catch-up runs, to emit tracing, etc.
147#[derive(Debug, Clone)]
148pub struct ScheduledRunCtx {
149 /// The `Schedule::id` being invoked. The plugin dispatches on this
150 /// when it owns more than one schedule.
151 pub schedule_id: String,
152 /// Wall-clock time the scheduler decided to fire this run,
153 /// milliseconds since Unix epoch. May differ from "now" when the run
154 /// is a catch-up under `MissedRunPolicy::RunAll`.
155 pub scheduled_at_ms: u64,
156 /// Monotonic run counter since the scheduler started. Lets the
157 /// plugin detect "first run after restart" (run_count == 1) without
158 /// storing its own state.
159 pub run_count: u64,
160 /// `true` if this run is catching up for a tick missed while the
161 /// host was down. `false` for normal on-time runs. Lets idempotent
162 /// jobs skip expensive work on catch-up.
163 pub is_catchup: bool,
164 /// Tenant id the run is scoped to, if the plugin is installed under
165 /// a tenant. Matches the `tenant_id` on
166 /// [`crate::middleware::RequestContext`] for consistency.
167 pub tenant_id: Option<String>,
168 /// Site id the run is scoped to, if the plugin is installed under a
169 /// site.
170 pub site_id: Option<String>,
171}
172
173/// A plugin that declares cron-style background work.
174///
175/// **Compile-time and WASM execution.** All methods receive and return
176/// JSON-friendly or POD data for ABI compatibility with WASM guests.
177/// Runs are fire-and-forget from the request path's point of view — the
178/// scheduler ticks on a dedicated task, so [`run`] must not block any
179/// foreground work and is allowed to take its time (subject to the fuel
180/// budget in [`fuel::RUN`]).
181///
182/// Concurrency: the host guarantees at most one concurrent invocation of
183/// `run()` per `schedule_id` per node. Across nodes, at-most-one is
184/// provided by the [`LockingHint`] in conjunction with a configured
185/// [`crate::locking::LockingPlugin`] — the E2 Locking capability. When
186/// no `LockingPlugin` is installed, multi-node deployments may
187/// double-fire `RequireGlobal` schedules and should be configured with a
188/// single scheduler node; the scheduler logs a warning at start when it
189/// detects this condition.
190///
191/// [`run`]: ScheduledPlugin::run
192/// [`fuel::RUN`]: fuel::RUN
193pub trait ScheduledPlugin: Send + Sync {
194 /// Unique identifier (e.g., `"cron"`, `"daily-reports"`). Must be
195 /// stable across restarts — used as part of the locking key and the
196 /// metrics label.
197 fn name(&self) -> &str;
198
199 /// Execution order among scheduled plugins when more than one has a
200 /// schedule firing in the same tick. Lower runs first. Default:
201 /// `1000`. The number matches the priority convention used by
202 /// [`crate::lifecycle::LifecyclePlugin`] since scheduled jobs are
203 /// conceptually in the same "background" family.
204 fn priority(&self) -> u32 {
205 1000
206 }
207
208 /// Declare the set of schedules this plugin owns.
209 ///
210 /// Called once at plugin init (server start, or plugin hot-reload).
211 /// The returned list is cached by the host and passed to
212 /// [`crate::types::PluginManifest::provides_capabilities`] discovery,
213 /// the dev dashboard, and the `cap_conformance` harness. Re-reading
214 /// is cheap but not required — plugins are expected to return the
215 /// same list for the lifetime of a process.
216 ///
217 /// Returning an empty list is legal: a plugin may implement
218 /// `ScheduledPlugin` just so it can be installed by configuration
219 /// and then receive dynamic schedules from elsewhere (future
220 /// extension point, not used in E1).
221 fn schedules(&self) -> Result<Vec<Schedule>, String>;
222
223 /// Execute one scheduled run.
224 ///
225 /// The host calls this when a declared schedule is due. Returns
226 /// `Ok(())` on success, `Err(message)` on failure; the scheduler
227 /// records the failure in [`bext-core::scheduler::TaskState::error_count`]
228 /// and logs the message. Plugins must not panic — panic inside a
229 /// WASM run counts as an error and is caught by the host.
230 ///
231 /// The method is synchronous in the trait to stay WASM-ABI friendly
232 /// (like [`crate::lifecycle::LifecyclePlugin::on_request_complete`]).
233 /// Native plugins that need async work should drive their own
234 /// runtime inside `run()`; WASM plugins that need async I/O should
235 /// use the host-function bridge documented in
236 /// `bext-core::host_fns::scheduled`.
237 fn run(&self, ctx: &ScheduledRunCtx) -> Result<(), String>;
238
239 /// Called before the plugin is unloaded. Release resources (open
240 /// files, database handles, etc.) here. Default: no-op.
241 fn cleanup(&self) -> Result<(), String> {
242 Ok(())
243 }
244}
245
246/// Fuel budgets for WASM scheduled plugin calls.
247///
248/// Matches the shape in [`crate::types::fuel`]. The `RUN` budget is
249/// deliberately generous — scheduled jobs routinely do real work
250/// (database sweeps, report generation) and the host gates them via the
251/// scheduler's tick interval rather than per-call fuel.
252pub mod fuel {
253 /// Fuel for a single [`super::ScheduledPlugin::run`] call.
254 pub const RUN: u64 = 1_000_000_000;
255 /// Fuel for [`super::ScheduledPlugin::schedules`] (called once at init).
256 /// Small because the list is static.
257 pub const SCHEDULES: u64 = 10_000_000;
258 /// Fuel for [`super::ScheduledPlugin::cleanup`].
259 pub const CLEANUP: u64 = 100_000_000;
260}