Skip to main content

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}