feat(sessions): add durable observation archive and replay API
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
ACTIVE_SESSION_MARKER = ".current_session"
|
||||
|
||||
|
||||
class ActiveSessionLeaseError(RuntimeError):
|
||||
"""The evidence root already has an active writer or cannot be leased."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ActiveSessionLease:
|
||||
"""Cross-process lease that keeps an in-progress session out of replay.
|
||||
|
||||
The marker is intentionally written before the session directory is
|
||||
created. Discovery therefore observes either no candidate yet or a
|
||||
candidate protected by an already locked marker. A process crash releases
|
||||
the OS lock; startup recovery can then remove the stale marker while
|
||||
preserving the interrupted evidence directory.
|
||||
"""
|
||||
|
||||
sessions_root: Path
|
||||
session_root: Path
|
||||
_descriptor: int
|
||||
_marker_identity: tuple[int, int]
|
||||
_released: bool = False
|
||||
|
||||
@classmethod
|
||||
def acquire(cls, sessions_root: Path, session_root: Path) -> ActiveSessionLease:
|
||||
root = sessions_root.expanduser().resolve()
|
||||
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
target = session_root.expanduser().absolute()
|
||||
if target.parent.resolve() != root or not target.name:
|
||||
raise ActiveSessionLeaseError("active session must be a direct child of evidence root")
|
||||
|
||||
marker = root / ACTIVE_SESSION_MARKER
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(marker, flags, 0o600)
|
||||
except FileExistsError as exc:
|
||||
raise ActiveSessionLeaseError(
|
||||
"another observation session owns the evidence root"
|
||||
) from exc
|
||||
try:
|
||||
payload = f"{target.name}\n".encode()
|
||||
os.write(descriptor, payload)
|
||||
os.fsync(descriptor)
|
||||
_lock_descriptor(descriptor, blocking=False)
|
||||
_fsync_directory(root)
|
||||
metadata = os.fstat(descriptor)
|
||||
return cls(
|
||||
sessions_root=root,
|
||||
session_root=target,
|
||||
_descriptor=descriptor,
|
||||
_marker_identity=(metadata.st_dev, metadata.st_ino),
|
||||
)
|
||||
except BaseException:
|
||||
try:
|
||||
marker.unlink(missing_ok=True)
|
||||
_fsync_directory(root)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
def release(self) -> None:
|
||||
if self._released:
|
||||
return
|
||||
marker = self.sessions_root / ACTIVE_SESSION_MARKER
|
||||
try:
|
||||
try:
|
||||
metadata = marker.lstat()
|
||||
except FileNotFoundError:
|
||||
metadata = None
|
||||
if metadata is not None and (metadata.st_dev, metadata.st_ino) == self._marker_identity:
|
||||
marker.unlink()
|
||||
_fsync_directory(self.sessions_root)
|
||||
finally:
|
||||
_unlock_descriptor(self._descriptor)
|
||||
os.close(self._descriptor)
|
||||
self._released = True
|
||||
|
||||
def __enter__(self) -> ActiveSessionLease:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.release()
|
||||
|
||||
|
||||
def recover_stale_active_session_marker(sessions_root: Path) -> bool:
|
||||
"""Remove only an unlocked marker left by a terminated writer."""
|
||||
|
||||
root = sessions_root.expanduser().resolve()
|
||||
marker = root / ACTIVE_SESSION_MARKER
|
||||
try:
|
||||
marker_stat = marker.lstat()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
|
||||
return False
|
||||
try:
|
||||
descriptor = os.open(marker, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0))
|
||||
except OSError:
|
||||
return False
|
||||
locked = False
|
||||
try:
|
||||
try:
|
||||
_lock_descriptor(descriptor, blocking=False)
|
||||
locked = True
|
||||
except OSError:
|
||||
return False
|
||||
opened = os.fstat(descriptor)
|
||||
try:
|
||||
current = marker.lstat()
|
||||
except OSError:
|
||||
return False
|
||||
if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
|
||||
return False
|
||||
marker.unlink()
|
||||
_fsync_directory(root)
|
||||
return True
|
||||
finally:
|
||||
if locked:
|
||||
_unlock_descriptor(descriptor)
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _lock_descriptor(descriptor: int, *, blocking: bool) -> None:
|
||||
if os.name == "nt":
|
||||
msvcrt = cast(Any, importlib.import_module("msvcrt"))
|
||||
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
|
||||
msvcrt.locking(descriptor, mode, 1)
|
||||
return
|
||||
import fcntl
|
||||
|
||||
operation = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
|
||||
fcntl.flock(descriptor, operation)
|
||||
|
||||
|
||||
def _unlock_descriptor(descriptor: int) -> None:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
msvcrt = cast(Any, importlib.import_module("msvcrt"))
|
||||
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
|
||||
return
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
Reference in New Issue
Block a user