Skip to content

Commit 16aed47

Browse files
authored
cortexkit-lease: shared-mode leases (acquire_shared) (#1)
* cortexkit-lease: shared-mode leases (acquire_shared) Shared holders coexist with each other and block the exclusive writer (and vice versa) — reader-side protection for shared resources like the cross-module model cache: a reader takes a shared lease on a blob digest while validating/mmap-ing so GC (exclusive) can never delete under it. Shared handles do not bump the fence epoch (they are not writers); they report the last persisted writer epoch for observability. Semantics proven in tests: shared+shared coexist, shared blocks exclusive until the LAST shared holder drops, exclusive blocks shared, epoch neutrality, and a cross-process shared-vs-exclusive check (unix: python-fcntl child; Windows LockFileEx semantics are exercised by the same-process tests since its locks are per-handle). try_lock_shared is called via the fs2 trait fully-qualified: std 1.89+ added an inherent File::try_lock_shared that would shadow it. * fmt
1 parent f6df77e commit 16aed47

1 file changed

Lines changed: 213 additions & 1 deletion

File tree

  • crates/cortexkit-lease/src

crates/cortexkit-lease/src/lib.rs

Lines changed: 213 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,22 @@ pub trait LeaseStore: Send + Sync {
118118
/// Acquire the lease, or `Err(Held)` if a live writer holds it. The returned
119119
/// handle must outlive the writer; dropping it releases the lease.
120120
fn acquire(&self, key: &LeaseKey) -> Result<Box<dyn LeaseHandle>, LeaseError>;
121+
122+
/// Acquire the lease in SHARED mode: any number of shared holders may
123+
/// coexist, but a shared holder blocks [`LeaseStore::acquire`] (exclusive)
124+
/// and an exclusive holder blocks shared acquisition.
125+
///
126+
/// Use for reader-side protection of shared resources: e.g. a model-cache
127+
/// consumer takes a shared lease on a blob's digest while validating or
128+
/// mmap-ing it, so a GC (exclusive holder) can never delete the file out
129+
/// from under a live reader, while concurrent readers never serialize each
130+
/// other.
131+
///
132+
/// Shared handles do NOT bump the fence epoch (they are not writers; the
133+
/// epoch fences durable writes). [`LeaseHandle::epoch`] on a shared handle
134+
/// returns the last persisted writer epoch at acquisition time, for
135+
/// observability only — never use it as a write fence.
136+
fn acquire_shared(&self, key: &LeaseKey) -> Result<Box<dyn LeaseHandle>, LeaseError>;
121137
}
122138

123139
/// File-based lease store: one lock file per key under `base_dir`. The OS advisory
@@ -143,7 +159,8 @@ impl FileLeaseStore {
143159
}
144160
}
145161

146-
/// A file-backed held lease: holds the OS advisory lock for its lifetime.
162+
/// A file-backed held lease: holds the OS advisory lock (exclusive or shared)
163+
/// for its lifetime.
147164
#[derive(Debug)]
148165
struct FileLeaseHandle {
149166
epoch: u64,
@@ -202,6 +219,45 @@ impl LeaseStore for FileLeaseStore {
202219
key: key.clone(),
203220
}))
204221
}
222+
223+
fn acquire_shared(&self, key: &LeaseKey) -> Result<Box<dyn LeaseHandle>, LeaseError> {
224+
std::fs::create_dir_all(&self.base_dir).map_err(LeaseError::Io)?;
225+
let path = self.lease_path(key);
226+
let mut file = OpenOptions::new()
227+
.read(true)
228+
.write(true)
229+
.create(true)
230+
.truncate(false)
231+
.open(&path)
232+
.map_err(LeaseError::Io)?;
233+
234+
// Shared liveness gate: only an exclusive holder contends; other shared
235+
// holders coexist. On unix this is flock(LOCK_SH|LOCK_NB); on Windows,
236+
// LockFileEx without LOCKFILE_EXCLUSIVE_LOCK (both via fs2).
237+
// Fully-qualified call: std >= 1.89 has an inherent File::try_lock_shared
238+
// (returning TryLockError) that would otherwise shadow the fs2 trait
239+
// method this crate's error handling is built around.
240+
match FileExt::try_lock_shared(&file) {
241+
Ok(()) => {}
242+
Err(e) if is_lock_contended(&e) => {
243+
return Err(LeaseError::Held { key: key.clone() });
244+
}
245+
Err(e) => return Err(LeaseError::Io(e)),
246+
}
247+
248+
// Read-only peek at the persisted writer epoch: shared holders are not
249+
// writers, so the epoch is NOT bumped (it fences durable writes only).
250+
let epoch = read_epoch(&mut file).map_err(|e| {
251+
let _ = file.unlock();
252+
LeaseError::Io(e)
253+
})?;
254+
255+
Ok(Box::new(FileLeaseHandle {
256+
epoch,
257+
file,
258+
key: key.clone(),
259+
}))
260+
}
205261
}
206262

207263
/// Whether a `try_lock_exclusive` error means "another live holder owns the lock"
@@ -215,6 +271,16 @@ fn is_lock_contended(e: &std::io::Error) -> bool {
215271
e.raw_os_error() == fs2::lock_contended_error().raw_os_error()
216272
}
217273

274+
/// Read the persisted epoch without modifying it (0 if new/empty). Called while
275+
/// holding a shared OS lock; must not write (concurrent shared holders read the
276+
/// same file).
277+
fn read_epoch(file: &mut File) -> std::io::Result<u64> {
278+
let mut buf = String::new();
279+
file.seek(SeekFrom::Start(0))?;
280+
file.read_to_string(&mut buf)?;
281+
Ok(buf.trim().parse().unwrap_or(0))
282+
}
283+
218284
/// Read the persisted epoch (0 if new/empty), increment, write it back, return the
219285
/// new value. Called while holding the OS lock.
220286
fn bump_epoch(file: &mut File) -> std::io::Result<u64> {
@@ -314,6 +380,152 @@ mod tests {
314380
let _ = std::fs::remove_dir_all(dir);
315381
}
316382

383+
#[test]
384+
fn shared_holders_coexist_but_block_exclusive() {
385+
let (store, dir) = tmp_store();
386+
let k = key("shared");
387+
388+
let s1 = store.acquire_shared(&k).expect("first shared");
389+
let s2 = store
390+
.acquire_shared(&k)
391+
.expect("second shared holder coexists");
392+
393+
// A shared holder blocks the exclusive writer — this is the property
394+
// the model-cache GC relies on (never delete under a live reader).
395+
match store.acquire(&k) {
396+
Err(LeaseError::Held { key }) => assert_eq!(key.scope_key, "shared"),
397+
other => panic!("exclusive must be Held while shared holders live, got {other:?}"),
398+
}
399+
400+
drop(s1);
401+
// Still one shared holder alive: exclusive must STILL be blocked.
402+
match store.acquire(&k) {
403+
Err(LeaseError::Held { .. }) => {}
404+
other => {
405+
panic!("exclusive must stay Held until the last shared holder drops, got {other:?}")
406+
}
407+
}
408+
409+
drop(s2);
410+
let g = store
411+
.acquire(&k)
412+
.expect("exclusive after all shared holders released");
413+
drop(g);
414+
let _ = std::fs::remove_dir_all(dir);
415+
}
416+
417+
#[test]
418+
fn exclusive_holder_blocks_shared() {
419+
let (store, dir) = tmp_store();
420+
let k = key("excl-blocks-shared");
421+
422+
let g = store.acquire(&k).expect("exclusive");
423+
match store.acquire_shared(&k) {
424+
Err(LeaseError::Held { key }) => assert_eq!(key.scope_key, "excl-blocks-shared"),
425+
other => panic!("shared must be Held while exclusive holder lives, got {other:?}"),
426+
}
427+
drop(g);
428+
let s = store
429+
.acquire_shared(&k)
430+
.expect("shared after exclusive released");
431+
drop(s);
432+
let _ = std::fs::remove_dir_all(dir);
433+
}
434+
435+
#[test]
436+
fn shared_acquisition_does_not_bump_the_write_epoch() {
437+
let (store, dir) = tmp_store();
438+
let k = key("epoch-neutral");
439+
440+
let g = store.acquire(&k).expect("writer");
441+
assert_eq!(g.epoch(), 1);
442+
drop(g);
443+
444+
// Shared holders observe the persisted epoch but never advance it.
445+
let s1 = store.acquire_shared(&k).expect("shared");
446+
assert_eq!(s1.epoch(), 1, "shared handle reports last writer epoch");
447+
drop(s1);
448+
let s2 = store.acquire_shared(&k).expect("shared again");
449+
assert_eq!(s2.epoch(), 1);
450+
drop(s2);
451+
452+
let g2 = store.acquire(&k).expect("writer again");
453+
assert_eq!(
454+
g2.epoch(),
455+
2,
456+
"writer epoch continues from 1: shared holders did not consume epochs"
457+
);
458+
drop(g2);
459+
let _ = std::fs::remove_dir_all(dir);
460+
}
461+
462+
// Unix-only: the child uses fcntl.flock. On Windows the same-process tests
463+
// above still exercise the real LockFileEx shared/exclusive semantics via
464+
// fs2, because LockFileEx locks are per-handle (two handles in one process
465+
// behave like two processes for contention purposes).
466+
#[cfg(unix)]
467+
#[test]
468+
fn shared_lease_across_processes_blocks_exclusive() {
469+
// Cross-PROCESS proof (not just same-process flock semantics): a child
470+
// process holds a shared lease while the parent tries exclusive.
471+
// flock/LockFileEx semantics are per-open-file-description, so the
472+
// same-process tests above could in principle pass with per-fd
473+
// semantics that differ across processes; this pins the real contract.
474+
let (store, dir) = tmp_store();
475+
let k = key("xproc");
476+
477+
// Learn the exact lock file path by acquiring+releasing once (also
478+
// seeds the epoch file).
479+
let g = store.acquire(&k).expect("seed");
480+
drop(g);
481+
let lock_path = {
482+
let mut entries = std::fs::read_dir(&dir).expect("lease dir");
483+
let entry = entries.next().expect("one lease file").expect("dir entry");
484+
entry.path()
485+
};
486+
487+
// Child: hold a SHARED flock on the lease file for 2 seconds.
488+
// `flock(1)` from util-linux is absent on macOS, so use a tiny python
489+
// child — python is available on every dev/CI platform we run.
490+
let mut child = std::process::Command::new("python3")
491+
.arg("-c")
492+
.arg(format!(
493+
"import fcntl,time\nf=open({lock_path:?},'r+')\nfcntl.flock(f,fcntl.LOCK_SH)\nprint('held',flush=True)\ntime.sleep(2)",
494+
))
495+
.stdout(std::process::Stdio::piped())
496+
.spawn()
497+
.expect("spawn shared-holder child");
498+
499+
// Wait until the child confirms it holds the shared lock.
500+
{
501+
use std::io::BufRead;
502+
let stdout = child.stdout.take().expect("child stdout");
503+
let mut line = String::new();
504+
std::io::BufReader::new(stdout)
505+
.read_line(&mut line)
506+
.expect("child readiness line");
507+
assert_eq!(line.trim(), "held");
508+
}
509+
510+
// Parent: exclusive must be Held while the child's shared lock lives.
511+
match store.acquire(&k) {
512+
Err(LeaseError::Held { .. }) => {}
513+
other => {
514+
panic!("exclusive must be Held under cross-process shared lock, got {other:?}")
515+
}
516+
}
517+
// Shared, however, coexists with the child's shared lock.
518+
let s = store
519+
.acquire_shared(&k)
520+
.expect("shared coexists with cross-process shared holder");
521+
drop(s);
522+
523+
child.wait().expect("child exit");
524+
let g = store.acquire(&k).expect("exclusive after child released");
525+
drop(g);
526+
let _ = std::fs::remove_dir_all(dir);
527+
}
528+
317529
#[test]
318530
fn epoch_persists_across_store_instances() {
319531
let (store, dir) = tmp_store();

0 commit comments

Comments
 (0)