bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! Locking capability trait. See `plan/ecosystem/02-capabilities.md §Locking`.
//!
//! A `LockingPlugin` provides **distributed mutual exclusion** keyed on a
//! string. The canonical use case is "ensure this scheduled job fires once
//! across the cluster, not once per instance" — hence the E2 pairing with
//! the Scheduled retrofit — but the same shape is useful for leader
//! election, exactly-once webhook delivery, and coordinating expensive
//! cache rebuilds.
//!
//! # Design notes
//!
//! - **Contention is not failure.** `try_lock` returns `Ok(None)` when the
//!   lock is held by someone else and `Err(..)` when the backend itself is
//!   broken (Redis unreachable, Postgres connection dropped). Callers
//!   almost always want to branch on that distinction — retry a moment
//!   later on contention, alarm an operator on backend failure — so
//!   encoding it in the type beats a single `LockError` enum with a
//!   `Held` variant. This is the load-bearing shape choice and is the
//!   reason the trait does not use a typed error enum.
//! - **Everything else is `Result<_, String>`.** `renew` and `release`
//!   cannot meaningfully distinguish "lock expired out from under us"
//!   from "backend failure" for a caller: in both cases the correct
//!   response is "log and move on, you lost the lock." Folding them into
//!   `Err(String)` keeps the trait lean and WASM-ABI friendly (matches
//!   the convention established by [`crate::scheduled::ScheduledPlugin`]
//!   and [`crate::lifecycle::LifecyclePlugin`]).
//! - **Opaque `LockHandle`.** The handle carries a `lock_id` (a random
//!   token minted by the plugin when the lock was acquired) plus the
//!   `key` it was taken on. Backends use the token to implement CAS-style
//!   release — Redlock SETs with `NX PX` and a random value, then the
//!   release script checks the value before `DEL` — so a renew or
//!   release from a stale caller cannot stomp on a newer lock holder.
//!   The token shape is a `String` so WASM guests can marshal it through
//!   the JSON ABI unchanged.
//! - **Sync trait.** Matches the convention in
//!   [`crate::scheduled::ScheduledPlugin`] and
//!   [`crate::session::SessionPlugin`]. Backends that need an async
//!   client drive their own runtime inside the call (same approach as
//!   `bext-session-redis` and `bext-tracer-otlp`).
//! - **No vendor leaks.** The trait has no `redis_url`, no
//!   `advisory_lock_key`, no `ttl_ms_override_for_etcd`. Configuration
//!   lives on the concrete plugin's constructor; the trait is one shape
//!   across memory / Redis / Postgres / etcd.
//!
//! # Backends
//!
//! Three reference backends ship alongside this trait in `crates/bext-impls/`:
//!
//! - `bext-locking-memory` — single-node fallback, `Mutex<HashMap>`.
//! - `bext-locking-redis` — Redlock-style `SET NX PX` + Lua CAS release.
//! - `bext-locking-pg` — Postgres `pg_try_advisory_lock(hashtext(key))`.
//!
//! # Use by the Scheduled capability
//!
//! When a [`crate::scheduled::ScheduledPlugin`] declares a
//! [`LockingHint::RequireGlobal`] schedule, the host-owned scheduler in
//! `bext-core::scheduler` acquires a `LockingPlugin` lock keyed on the
//! schedule id before invoking
//! [`ScheduledPlugin::run`](crate::scheduled::ScheduledPlugin::run). On
//! contention (`Ok(None)`) the scheduler skips this tick — another node
//! is running it. On backend failure (`Err(..)`) the scheduler logs and
//! skips, matching the existing "lost the lock" semantics.
//!
//! [`LockingHint::RequireGlobal`]: crate::scheduled::LockingHint::RequireGlobal

use serde::{Deserialize, Serialize};

/// Handle returned by a successful [`LockingPlugin::try_lock`].
///
/// The handle is **opaque** to callers — they pass it back to
/// [`LockingPlugin::renew`] and [`LockingPlugin::release`] unchanged. The
/// plugin uses the embedded `lock_id` token to detect stale operations
/// (see module docs).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockHandle {
    /// The key the lock was acquired on. Matches the `key` argument
    /// passed to [`LockingPlugin::try_lock`]. Useful for logging and
    /// metrics; the plugin also needs it on release / renew.
    pub key: String,
    /// Random token minted by the plugin at acquisition time. Backends
    /// use it to implement check-and-set release so a stale caller
    /// cannot release a lock it no longer owns. Format is
    /// plugin-defined (UUIDv4 is typical) and callers must treat it as
    /// opaque.
    pub lock_id: String,
    /// The TTL the lock was acquired with, in milliseconds. Stored on
    /// the handle so `renew` can re-apply the same TTL without the
    /// caller having to remember it.
    pub ttl_ms: u64,
}

/// A plugin that provides distributed mutual exclusion.
///
/// **Compile-time and WASM execution.** All methods use JSON-friendly
/// POD types so the trait is ABI-compatible with WASM guest plugins
/// (matches the convention used by the other capability traits in this
/// crate). Backends that need a real async client run their own runtime
/// inside the call.
///
/// Concurrency: implementations MUST be safe to call from multiple
/// threads simultaneously. The host may contend on the same key from
/// different request-handling threads.
pub trait LockingPlugin: Send + Sync {
    /// Unique plugin name (e.g., `"memory"`, `"redis"`, `"pg"`). Used
    /// by the dev dashboard, metrics labels, and `cap_conformance`.
    fn name(&self) -> &str;

    /// Attempt to acquire the lock identified by `key` for at most
    /// `ttl_ms` milliseconds.
    ///
    /// # Return shape
    ///
    /// - `Ok(Some(handle))` — the lock was acquired. The caller owns
    ///   it for `ttl_ms` and may renew or release.
    /// - `Ok(None)` — **contention**. Another owner holds the lock.
    ///   The caller typically backs off and retries later, or simply
    ///   skips this run (the Scheduled capability skips).
    /// - `Err(message)` — backend failure (network, protocol,
    ///   configuration). The caller cannot meaningfully retry without
    ///   operator intervention.
    ///
    /// The `Ok(None)` vs `Err(..)` distinction is the reason this trait
    /// does not use a typed `LockError` enum — it is the only branch
    /// that matters to 99% of callers, and encoding it in the outer
    /// `Result` is cleaner than a `LockError::Held` variant they would
    /// have to match every single call site.
    fn try_lock(&self, key: &str, ttl_ms: u64) -> Result<Option<LockHandle>, String>;

    /// Extend the lifetime of an existing lock.
    ///
    /// On success the lock's TTL is reset to `handle.ttl_ms`. On
    /// failure — either because the lock has already expired or the
    /// backend is broken — returns `Err(message)`. Callers treat the
    /// failure uniformly: "you no longer own this lock; stop holding
    /// it." The default retry policy is to abandon the work and try
    /// `try_lock` again from scratch on the next opportunity.
    fn renew(&self, handle: &LockHandle) -> Result<(), String>;

    /// Release the lock.
    ///
    /// Implementations MUST verify that the lock is still held under
    /// the caller's `handle.lock_id` before releasing (check-and-set),
    /// so a stale call after the TTL expired cannot clobber a newer
    /// holder. Releasing a lock the caller does not own is a silent
    /// no-op, not an error.
    fn release(&self, handle: LockHandle) -> Result<(), String>;

    /// Called before the plugin is unloaded. Release backend resources
    /// (Redis connection, Postgres pool). Default: no-op.
    fn cleanup(&self) -> Result<(), String> {
        Ok(())
    }
}

/// Fuel budgets for WASM locking plugin calls. Matches the convention
/// in [`crate::scheduled::fuel`].
pub mod fuel {
    /// Fuel for a single [`super::LockingPlugin::try_lock`] call.
    pub const TRY_LOCK: u64 = 50_000_000;
    /// Fuel for a single [`super::LockingPlugin::renew`] call.
    pub const RENEW: u64 = 50_000_000;
    /// Fuel for a single [`super::LockingPlugin::release`] call.
    pub const RELEASE: u64 = 50_000_000;
    /// Fuel for [`super::LockingPlugin::cleanup`].
    pub const CLEANUP: u64 = 100_000_000;
}