cadrumo.adapters.persistence.storage.bucket.lockfile module

Per-bucket .lock concurrency primitive for the bucket directory model.

Each bucket carries a single PID-stamped lockfile at <bucket-dir>/.lock created via os.open with O_CREAT | O_EXCL | O_WRONLY; the O_EXCL flag is atomic on every POSIX kernel and on Windows NTFS, so a second-process unlock against a held bucket fails fast with adapters.persistence.storage.bucket.errors.BucketBusyError per the substrate locking contract.

The lockfile carries the holder’s PID. A stale lock (PID is no longer a live process) is reclaimed lazily by the acquiring process so an abnormal process exit (SIGKILL, OS crash, container OOM) does not permanently strand the bucket; the lazy reclaim is documented under the plan’s “Lockfile staleness detection” open question.

Every removal goes through unlink_lockfile(), because a waiting acquirer that opens the lockfile to read its PID blocks the holder’s unlink on Windows. On the release path that is a wedge rather than a delay – the record names a live process, so the lock is never reclaimed as stale – so release retries to a deadline while a stale reclaim stays single-attempt.

class BucketLockTarget(*args, **kwargs)[source]

Bases: Protocol

What the bucket lock actually needs: one directory to place .lock in.

The lock reads two fields – the directory it places .lock in, and the bucket identity its refusals name. Declaring exactly those, rather than demanding the whole BucketPaths record, is what lets a caller holding a narrowed view of a bucket delegate here without first re-widening it back to the concrete record – a round trip that had to be guarded by a runtime identity check standing in for a boundary this protocol states directly.

It is deliberately NOT the pattern for the custody records. Those ports are narrower than the substrate needs on purpose: they hide key material the application has no business reading, so widening a crypto signature onto one would either expose that material or fail late on a field the port omits. The lock is the opposite case – nothing is hidden, because nothing beyond a directory is used.

property bucket_dir: Path

The bucket directory whose .lock sidecar is the lock target.

property bucket_id: str

The bucket’s identity, carried on every lock refusal’s context.

Declared because the lock genuinely reads it, not for symmetry: a busy-or-unlockable refusal names the bucket it could not take, and a protocol that omitted it would either fail late here or silently degrade the operator’s refusal to an unattributed one.

lock_path(paths)[source]

Return the canonical lockfile path for the bucket.

Return type:

Path

Parameters:

paths (BucketLockTarget)

acquire_lock(paths, *, wait_seconds=0.0)[source]

Acquire the per-bucket lockfile or raise BucketBusyError.

Parameters:
  • paths (BucketLockTarget) – The bucket paths whose .lock to acquire.

  • wait_seconds (float) – Maximum time to wait for the lock to become free. Defaults to 0.0 (no wait); callers that want bounded waiting pass a positive value.

Raises:

BucketBusyError – When the lockfile is held by a live process and the wait window expires.

Return type:

None

release_lock(paths)[source]

Release the per-bucket lockfile owned by this process.

Removes the lockfile only when the recorded PID matches this process; a foreign lockfile is left alone so a stale-reclaim race cannot delete another process’s lock.

Raises:

BucketValidationError – When the lockfile is owned by this process but a peer’s open handle keeps the removal refused past the retry window, so the bucket would otherwise be wedged behind a lock stamped with a live PID.

Return type:

None

Parameters:

paths (BucketLockTarget)