Files
alkstore/alkstore-mem/src/state.rs
T
glm-5.3-flash 1c3e96802f mem-engine-seam-tx — the wave's load-bearing seam lands: the complete Store/TxHandle trait surfaces over the guarded state core, with the tx overlay as the discipline centerpiece. Wider than the sqlite/pg seam twins by design (those rode pre-ported substrate ops and left mechanism methods stubbed; mem has no substrate, and the task's acceptance pins the full trait surfaces implemented over guarded state — a Rust trait impl is all-or-nothing per method): the seam task therefore realizes the mechanisms' committed-state effect layer and every mechanism handle itself, and the wave-6 mechanism tasks now own their acceptance-criteria unit pins against this foundation. Store surface: notify (codec-typed encode as the entry check only — the wake delivers the channel alone; PayloadTooLarge never produced, the ADR-016 §5 non-occurrence arm pinned at 2 MiB), listen (WakeReceiver bridge with the pinned recv forms; attach starts from now, one close path at engine drop), stream/queue/outbox/try_lock (validated constructors → boxed core-owned handle traits: the ADR-010 queue state machine with claims/reclaim-eats-attempt/uniform validity predicate/dead-letter/sweep + mem's third-owner equal-jitter curve — std-only RandomState hash jitter, no rand; ADR-015 log+offsets+trim+wake-driven subscribe; ADR-008 §7 TTL registry with renew-past-expiry refused; ADR-014 outbox helper with run_once ack/retry), schedule/unschedule/run_schedules (ADR-009 collapse: @every-only third-owner parser + register-table with pre-parsed interval and resolved stamps; the 64-cap bounded catch-up + grid-aligned skip-forward tick fired-and-advanced under one guarded op (schedules→queues lock order); leadership through the reserved __alkstore_scheduler lock with runner-unique owner, renew-before-tick, keep-awake sleep renewing on cadence, LeadershipLost / clean-stop-Ok returns). Tx overlay: begin_tx allocates the handle-private overlay; all eleven *_tx stage with entry-point validation before any effect, Codec-typed encode, one clock read, ADR-020 §3 stamp resolution (plain 300/3/5/none, outbox 60/5/5, the per-job max_attempts override via resolution.rs's stamps_with_override); tx reads merge overlay over committed (read-your-own-writes: queue-scoped + dead-visible get_job_tx, monotone staged saves, offset-ASC merged reads, extent guards clamping empty); commit consumes the handle, merges under one guarded op (queues→streams, the canonical lock order documented in state.rs), and fires the overlay's pending wakes AFTER the merge — wake-at-commit structural from birth (the pg-fix-tx-wake failure shape cannot exist here); drop (explicit, or through an unwinding panic — the overlay is plain local data, nothing runs on discard) = rollback with no ghosts, trivially. Stage-time id/offset allocation (the pg-shaped story — rolled-back allocations gap out; claims order id ASC, offsets immutable/never renumbered). Stream subscribers ride a reserved-prefix engine-internal wake channel (__alkstore_stream_wake:{stream}) unreachable by consumer entry points, attached before the stored-offset read; notify's transport stores nothing. 89 mem unit tests: the overlay discipline rows the task pins, the numeric-domain rows (extents/durations/boundaries/grammar/validation), and the mechanism smoke pins (the defect-class catch: ack/ack_batch originally deleted before checking the predicate — fixed; a refusal must leave the row exactly as it was). Manifest: serde_json = 1 joins (the trait signatures cross Value; both engine twins carry it; not a driver dep — wasm32 gate stays green). Task file: status completed, Notes (the whole-engine scope disposition + 11 decision-of-record entries incl. lock-renewal delta vs the sqlite substrate, ScheduleRow shape, runner lease mechanics, substrate predicate parity) + Summary filled. Verified: cargo build; workspace cargo test green (core 25, suite harness 3, mem 89, SQLite 191 lib + 25 suite, pg 124 lib + 25 suite + 9 schema); clippy --all-targets -D warnings clean; cargo fmt --check clean; cargo check --target wasm32-unknown-unknown -p alkstore-mem clean
2026-10-10 11:55:39 +00:00

263 lines
9.5 KiB
Rust

//! The guarded per-mechanism state core — the store instance *is* the
//! state (engine-mem.md §Architecture): per-mechanism tables owned by
//! the engine instance behind the family's locking discipline
//! (short-held synchronous internal locks; tokio primitives for
//! cross-await coordination — [`crate::wakes`]' channels and the
//! scheduler's boundary waits arrive with their tasks).
//!
//! The idiom: each mechanism's committed state sits behind one
//! short-held [`std::sync::Mutex`] section, accessed through
//! [`Guard::guard`]. Guards are never held across an `.await`, never
//! across user-provided code (delivery closures, payload callbacks) —
//! staging happens engine-side, so the mutex cannot observe a panic
//! while held and poisoning is structurally unreachable; the
//! [`Guard::guard`] fold into the inner value keeps the type honest
//! anyway.
//!
//! Section shapes are the commit-merge targets ([`crate::tx`]'s
//! overlay stages effects of exactly these rows). Row-level operations
//! live with their mechanisms (`crate::queue`, `crate::stream`,
//! `crate::lock`, `crate::scheduler`) — modules must take section
//! guards in the canonical order **schedules → queues → streams**
//! (locks and wakes depend on no other section; the commit-merge takes
//! queues → streams, the scheduler tick schedules → queues) so no path
//! can deadlock.
use std::collections::{BTreeMap, HashMap};
use std::sync::Mutex;
use std::sync::MutexGuard;
use std::sync::atomic::{AtomicI64, Ordering};
/// A short-held synchronous internal lock: `guard()` is the one access
/// idiom (see the module docs for the discipline).
pub(crate) trait Guard<T> {
fn guard(&self) -> MutexGuard<'_, T>;
}
impl<T> Guard<T> for Mutex<T> {
fn guard(&self) -> MutexGuard<'_, T> {
self.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
/// A live job row (ADR-010's state machine, minus the dead state —
/// dead rows move to [`QueueState::dead`] with their diagnosis). The
/// row carries the resolved enqueue fields and the §3a stamps; the
/// `pending` flag is the `pending`/`processing` split (absence = acked
/// or cancelled).
#[derive(Debug, Clone)]
pub(crate) struct JobRow {
pub(crate) id: i64,
pub(crate) queue: String,
pub(crate) payload: Vec<u8>,
pub(crate) pending: bool,
pub(crate) priority: i64,
pub(crate) run_at: i64,
pub(crate) worker_id: Option<String>,
pub(crate) claim_expires_at: Option<i64>,
pub(crate) claimed_at: Option<i64>,
pub(crate) attempts: i64,
pub(crate) max_attempts: i64,
pub(crate) created_at: i64,
pub(crate) expires_at: Option<i64>,
pub(crate) visibility_timeout_s: i64,
pub(crate) backoff_base_s: i64,
pub(crate) dead_letter_retention_s: Option<i64>,
}
/// A dead-lettered job row (ADR-010 §4): the live row's durable fields
/// plus the diagnosis (`last_error`, `died_at`) — `get_job` sees dead
/// rows with the stamps still attached. The claim identity
/// (`worker_id`/`claim_expires_at`) never rides a dead row.
#[derive(Debug, Clone)]
pub(crate) struct DeadRow {
pub(crate) id: i64,
pub(crate) queue: String,
pub(crate) payload: Vec<u8>,
pub(crate) priority: i64,
pub(crate) run_at: i64,
pub(crate) attempts: i64,
pub(crate) max_attempts: i64,
pub(crate) created_at: i64,
pub(crate) expires_at: Option<i64>,
pub(crate) claimed_at: Option<i64>,
pub(crate) visibility_timeout_s: i64,
pub(crate) backoff_base_s: i64,
pub(crate) dead_letter_retention_s: Option<i64>,
pub(crate) last_error: String,
pub(crate) died_at: i64,
}
/// A committed stream event (ADR-015 §3's shape, engine-internal) —
/// `offset` is the per-stream log key, immutable, never renumbered.
#[derive(Debug, Clone)]
pub(crate) struct EventRow {
pub(crate) offset: i64,
pub(crate) key: Option<String>,
pub(crate) payload: Vec<u8>,
pub(crate) created_at: i64,
}
/// A named TTL lock's registry entry (ADR-008 §7): owner identity +
/// deadline. Expiry is a clock check at the try/renew boundaries — no
/// ambient revoker exists.
#[derive(Debug, Clone)]
pub(crate) struct LockEntry {
pub(crate) owner: String,
pub(crate) expires_at: i64,
}
/// A registered schedule row (ADR-009): the collapsed scheduler's
/// table. The interval is parsed and the stamps are resolved at
/// register time (the fire path stamps rows from the registered
/// values, never the raw spec) — `next_fire_at` is the collapse
/// machinery's boundary state.
#[derive(Debug, Clone)]
pub(crate) struct ScheduleRow {
pub(crate) queue: String,
pub(crate) interval_s: i64,
pub(crate) payload: Vec<u8>,
pub(crate) priority: i64,
pub(crate) expires_s: Option<i64>,
pub(crate) max_attempts: i64,
pub(crate) visibility_timeout_s: i64,
pub(crate) backoff_base_s: i64,
pub(crate) dead_letter_retention_s: Option<i64>,
pub(crate) next_fire_at: i64,
}
/// The queue mechanism's committed section: the jobs table and the
/// dead-letter storage. `live` keys by job id (the enqueue-order
/// claim tiebreak reads the id directly); `dead` keys the same id
/// space.
#[derive(Debug, Default)]
pub(crate) struct QueueState {
pub(crate) live: BTreeMap<i64, JobRow>,
pub(crate) dead: BTreeMap<i64, DeadRow>,
}
/// The stream mechanism's committed section: per-stream event logs
/// (keyed by offset, ordered) and the per-(stream, consumer) offset
/// checkpoints.
#[derive(Debug, Default)]
pub(crate) struct StreamState {
pub(crate) logs: BTreeMap<String, BTreeMap<i64, EventRow>>,
pub(crate) offsets: HashMap<(String, String), i64>,
}
/// The named-locks mechanism's committed section: the TTL registry.
#[derive(Debug, Default)]
pub(crate) struct LockState {
pub(crate) entries: HashMap<String, LockEntry>,
}
/// The scheduler mechanism's committed section: the schedule table.
#[derive(Debug, Default)]
pub(crate) struct ScheduleState {
pub(crate) rows: BTreeMap<String, ScheduleRow>,
}
/// The store's committed state: one guarded section per mechanism, the
/// engine-wide id/offset allocators, and the clock read that every
/// timestamp in every section defers to.
///
/// `job_ids`/`event_offsets` are the allocators the tx overlay and the
/// auto-commit paths share: ids and offsets are assigned monotone at
/// allocation time (stage time for a tx, op time for auto-commit),
/// which is the pg-shaped story — an uncommitted allocation gaps out
/// when the tx rolls back (legal: ids are claim-order tiebreaks and
/// offsets are immutable, never renumbered; gaps are ordinary).
pub(crate) struct State {
pub(crate) queues: Mutex<QueueState>,
pub(crate) streams: Mutex<StreamState>,
pub(crate) locks: Mutex<LockState>,
pub(crate) schedules: Mutex<ScheduleState>,
pub(crate) job_ids: AtomicI64,
pub(crate) event_offsets: AtomicI64,
}
impl State {
pub(crate) fn new() -> Self {
Self {
queues: Mutex::new(QueueState::default()),
streams: Mutex::new(StreamState::default()),
locks: Mutex::new(LockState::default()),
schedules: Mutex::new(ScheduleState::default()),
job_ids: AtomicI64::new(0),
event_offsets: AtomicI64::new(0),
}
}
/// The next job id — monotone, never reused (a rolled-back tx's
/// staged allocation gaps).
pub(crate) fn allocate_job_id(&self) -> i64 {
self.job_ids.fetch_add(1, Ordering::Relaxed) + 1
}
/// The next event offset — monotone, immutable, never renumbered.
pub(crate) fn allocate_event_offset(&self) -> i64 {
self.event_offsets.fetch_add(1, Ordering::Relaxed) + 1
}
}
impl std::fmt::Debug for State {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("State").finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sections_are_per_mechanism_and_independent() {
let state = State::new();
let queued_ptr = {
let mut queues = state.queues.guard();
std::ptr::from_mut(&mut *queues)
};
let streams_ptr = {
let mut streams = state.streams.guard();
std::ptr::from_mut(&mut *streams)
};
let queues_ptr_again = {
let mut queues = state.queues.guard();
std::ptr::from_mut(&mut *queues)
};
assert_eq!(queued_ptr, queues_ptr_again, "the same section relocks");
assert_ne!(
queued_ptr.cast::<()>(),
streams_ptr.cast::<()>(),
"sections are distinct storage"
);
}
#[test]
fn sections_relock_after_guard_drop() {
let state = std::sync::Arc::new(State::new());
std::thread::scope(|scope| {
let held = scope.spawn(|| {
for _ in 0..100 {
let _guard = state.streams.guard();
std::thread::yield_now();
}
});
for _ in 0..100 {
let _guard = state.queues.guard();
std::thread::yield_now();
}
held.join().expect("the lock churn task never panics");
});
}
#[test]
fn allocators_are_monotone_and_distinct() {
let state = State::new();
let ids: Vec<i64> = (0..8).map(|_| state.allocate_job_id()).collect();
let offsets: Vec<i64> = (0..8).map(|_| state.allocate_event_offset()).collect();
assert_eq!(ids, (1..=8).collect::<Vec<_>>(), "job ids count from 1");
assert_eq!(offsets, (1..=8).collect::<Vec<_>>(), "offsets count from 1");
}
}