Files
alkstore/alkstore-mem/src/lock.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

328 lines
11 KiB
Rust

//! The named-locks mechanism's mem arm — the in-memory TTL registry
//! (engine-mem.md §Mapping the contract, locks row; ADR-008 §7's
//! guarantee row).
//!
//! - Registry entries carry owner + deadline; expiry is a clock check
//! at the try/renew boundaries (no ambient revoker exists). After
//! TTL expiry exclusion lapses *silently* — no revocation event —
//! and the name is re-acquirable; the holder may still release.
//! - Contention on one store instance is serialized by the guarded
//! section: first wins, the loser reads `None` cleanly (a value,
//! never an error). A re-`try_lock` by the *same* owner while held
//! grants (no second lease row exists to fight) without refreshing
//! the deadline — the SQL engines' `INSERT OR IGNORE` + owner-match
//! shape, mirrored.
//! - **The duration guard at entry** (ADR-023 §2, durations kind):
//! `ttl <= 0` rejects on `try_lock` *and* `renew` with the opaque
//! `Database` error — never a silent immediately-expired lease.
//! - Locks are not part of the tx seam (no `lock_tx` — contract
//! verbatim): the overlay neither sees nor stages locks. The
//! scheduler's leadership lock rides this registry through the
//! engine-internal acquire/renew path (a reserved name consumer
//! entry points can never collide with).
use std::sync::Arc;
use alkstore::{BoxedFuture, Error, Lock, Result, validate_local_name, validate_shared_name};
use crate::clock::Clock;
use crate::state::Guard;
use crate::state::{LockState, State};
/// The scheduler's leadership-lock name (ADR-009 §4 — the reserved-
/// prefix derived name in the lock namespace; engine-internal: every
/// consumer entry point rejects the same prefix, so a consumer can
/// never collide with it).
pub(crate) const SCHEDULER_LEADER_LOCK: &str = "__alkstore_scheduler";
fn duration_error(ttl: i64) -> Error {
Error::database(std::io::Error::other(format!(
"ttl must be a positive duration (seconds), got {ttl}"
)))
}
/// The registry acquire (the one owner of the row-level mechanical):
/// purge the name's expired entry, grant iff the name is absent or
/// already owned by this owner. `now` is the caller's clock read.
pub(crate) fn lock_acquire(l: &mut LockState, name: &str, owner: &str, ttl: i64, now: i64) -> bool {
if let Some(entry) = l.entries.get(name)
&& entry.expires_at <= now
{
l.entries.remove(name);
}
match l.entries.get(name) {
Some(entry) => entry.owner == owner,
None => {
l.entries.insert(
name.to_string(),
crate::state::LockEntry {
owner: owner.to_string(),
expires_at: now.saturating_add(ttl),
},
);
true
}
}
}
/// The registry renew: a new full TTL window from now. Refused
/// (`false`) when the name is held by another owner, or past expiry —
/// a renew can never revive a lapsed exclusion (the mem pin: renew
/// past expiry is refused, the holder's window is the TTL's).
pub(crate) fn lock_renew(l: &mut LockState, name: &str, owner: &str, ttl: i64, now: i64) -> bool {
let Some(entry) = l.entries.get_mut(name) else {
return false;
};
if entry.owner != owner || entry.expires_at <= now {
return false;
}
entry.expires_at = now.saturating_add(ttl);
true
}
/// The registry release: delete the owner's row only. `false` =
/// re-acquired elsewhere (a no-op, not an error). The holder may
/// release past expiry — exclusion lapsed silently; releasing the
/// lapsed row is still the holder's.
pub(crate) fn lock_release(l: &mut LockState, name: &str, owner: &str) -> bool {
match l.entries.get(name) {
Some(entry) if entry.owner == owner => {
l.entries.remove(name);
true
}
_ => false,
}
}
/// The consumer `try_lock` path (validation + duration guard + the
/// guarded acquire; `Ok(None)` is the held-by-someone-else arm).
pub(crate) fn try_lock(
state: Arc<State>,
clock: Arc<Clock>,
name: &str,
owner: &str,
ttl: i64,
) -> Result<Option<Box<dyn Lock>>> {
validate_shared_name(name)?;
validate_local_name(owner)?;
if ttl <= 0 {
return Err(duration_error(ttl));
}
let now = clock.unix_now();
let granted = {
let mut l = state.locks.guard();
lock_acquire(&mut l, name, owner, ttl, now)
};
if granted {
Ok(Some(Box::new(MemLock {
name: name.to_string(),
owner: owner.to_string(),
state,
clock,
})))
} else {
Ok(None)
}
}
/// The held named-lock handle (the repeatable `renew` case and the
/// consuming `release`).
pub struct MemLock {
name: String,
owner: String,
state: Arc<State>,
clock: Arc<Clock>,
}
impl std::fmt::Debug for MemLock {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MemLock")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
impl MemLock {
/// The engine-internal leadership lease (the scheduler's): the
/// same registry machinery without consumer validation (the name
/// is engine-derived and reserved).
pub(crate) fn new_internal(
name: &str,
owner: &str,
state: Arc<State>,
clock: Arc<Clock>,
) -> Self {
Self {
name: name.to_string(),
owner: owner.to_string(),
state,
clock,
}
}
pub(crate) fn acquire_now(&self, ttl: i64, now: i64) -> bool {
let mut l = self.state.locks.guard();
lock_acquire(&mut l, &self.name, &self.owner, ttl, now)
}
pub(crate) fn renew_at(&self, ttl: i64, now: i64) -> bool {
let mut l = self.state.locks.guard();
lock_renew(&mut l, &self.name, &self.owner, ttl, now)
}
pub(crate) fn release_now(&self) -> bool {
let mut l = self.state.locks.guard();
lock_release(&mut l, &self.name, &self.owner)
}
}
impl Lock for MemLock {
fn name(&self) -> &str {
&self.name
}
fn renew<'a>(&'a self, ttl: i64) -> BoxedFuture<'a, Result<bool>> {
if ttl <= 0 {
return Box::pin(async move { Err(duration_error(ttl)) });
}
let now = self.clock.unix_now();
let granted = self.renew_at(ttl, now);
Box::pin(async move { Ok(granted) })
}
fn release(self: Box<Self>) -> BoxedFuture<'static, Result<bool>> {
Box::pin(async move { Ok(self.release_now()) })
}
}
#[cfg(test)]
mod tests {
use crate::store::MemStore;
use alkstore::Store;
#[tokio::test(flavor = "current_thread")]
async fn contention_grants_once_and_the_loser_reads_none() {
let store = MemStore::new();
store.clock.freeze(1_000);
let first = store.try_lock("db", "w1", 60).await.unwrap();
assert!(first.is_some(), "the first acquire wins");
let loser = store.try_lock("db", "w2", 60).await.unwrap();
assert!(
loser.is_none(),
"the loser is a clean None (a value, never an error)"
);
let same_owner = store.try_lock("db", "w1", 60).await.unwrap();
assert!(
same_owner.is_some(),
"the owner re-grants (no second lease row)"
);
drop(first);
drop(loser);
if let Some(re) = same_owner {
assert!(re.renew(60).await.unwrap(), "renew is an absolute reset");
}
}
#[tokio::test(flavor = "current_thread")]
async fn duration_guards_reject_non_positive_ttls() {
let store = MemStore::new();
for ttl in [0i64, -1] {
match store.try_lock("db", "w", ttl).await {
Err(alkstore::Error::Database(_)) => {}
_other => panic!("try_lock guards a non-positive ttl"),
}
let granted = store.try_lock("db", "w", 60).await.unwrap().unwrap();
match granted.renew(ttl).await {
Err(alkstore::Error::Database(_)) => {}
_other => panic!("renew guards a non-positive ttl"),
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn expiry_lapses_silently_and_reacquires() {
let store = MemStore::new();
store.clock.freeze(1_000);
let holder = store.try_lock("db", "w1", 60).await.unwrap().unwrap();
assert!(store.try_lock("db", "w2", 60).await.unwrap().is_none());
store.clock.advance(59);
assert!(
store.try_lock("db", "w2", 60).await.unwrap().is_none(),
"still within TTL"
);
store.clock.advance(2);
assert!(
store.try_lock("db", "w2", 60).await.unwrap().is_some(),
"exclusion lapsed silently — re-acquirable, no revocation event"
);
assert!(
!holder.renew(60).await.unwrap(),
"the re-acquired lock is not the old holder's to renew"
);
}
#[tokio::test(flavor = "current_thread")]
async fn renew_past_expiry_is_refused() {
let store = MemStore::new();
store.clock.freeze(1_000);
let holder = store.try_lock("db", "w1", 60).await.unwrap().unwrap();
store.clock.advance(61);
assert!(
!holder.renew(60).await.unwrap(),
"a renew cannot revive a lapsed exclusion"
);
let re = store.try_lock("db", "w2", 60).await.unwrap().unwrap();
assert!(re.release().await.unwrap(), "the new holder releases");
}
#[tokio::test(flavor = "current_thread")]
async fn release_shapes() {
let store = MemStore::new();
store.clock.freeze(1_000);
let holder = store.try_lock("db", "w1", 60).await.unwrap().unwrap();
assert!(holder.release().await.unwrap(), "a held lock releases true");
let w1 = store.try_lock("db", "w1", 60).await.unwrap().unwrap();
store.clock.advance(120);
assert!(
w1.release().await.unwrap(),
"release past expiry stays the holder's — exclusion lapsed silently"
);
let a = store.try_lock("db", "w1", 60).await.unwrap().unwrap();
store.clock.advance(120);
store.try_lock("db", "w2", 60).await.unwrap().unwrap();
assert!(
!a.release().await.unwrap(),
"re-acquired elsewhere: the old holder's release is a no-op"
);
}
#[tokio::test(flavor = "current_thread")]
async fn entry_validation() {
let store = MemStore::new();
for bad in ["", " ", "__alkstore_scheduler"] {
match store.try_lock(bad, "w", 60).await {
Err(alkstore::Error::InvalidName { .. } | alkstore::Error::ReservedName { .. }) => {
}
_other => panic!("try_lock({bad:?}) rejects"),
}
}
match store.try_lock("db", "", 60).await {
Err(alkstore::Error::InvalidName { .. }) => {}
_other => panic!("an empty owner rejects"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn locks_are_not_tx_scoped() {
let store = MemStore::new();
store.clock.freeze(1_000);
assert!(store.try_lock("db", "w", 60).await.unwrap().is_some());
let tx = store.begin_tx().await.unwrap();
drop(tx);
assert!(
store.try_lock("db", "w2", 60).await.unwrap().is_none(),
"rollback or not — the overlay never sees or stages locks"
);
}
}