Coverage for src/keel/lock.py: 100%
94 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""Single-host resource claims backed by atomic ``mkdir``.
3Every merge goes through the merge lock (a keel invariant) so concurrent ``ship``
4runs on the same checkout cannot race the branch tip. The merge lock is now one
5consumer of the generalized resource-claim primitive below: ``mkdir`` is atomic,
6so a resource directory is either created for one owner or already held.
7"""
9from __future__ import annotations
11import hashlib
12import json
13import re
14from collections.abc import Iterator
15from contextlib import contextmanager
16from dataclasses import asdict, dataclass
17from pathlib import Path
18from typing import Any
20from . import workspace
22SCHEMA_VERSION = "keel.resource-claim.v1"
24#: Holder of a claim whose owner cannot be read — ``owner.json`` missing, corrupt,
25#: unreadable, or the wrong shape. Deliberately *not* ``None``: the claim directory
26#: exists, so the resource **is** held; we simply cannot name by whom. The window is
27#: ordinary rather than exotic, because :func:`_claim_path` creates the directory
28#: before it writes the owner file, so any crash, kill, or container teardown in
29#: between leaves the lock held but ownerless.
30UNKNOWN_HOLDER = "<unknown>"
33class LockError(RuntimeError):
34 """Raised when the merge lock is already held."""
37@dataclass(frozen=True)
38class ClaimResult:
39 """Structured result for a single-host resource claim operation."""
41 schema_version: str
42 resource: str
43 owner: str
44 path: str
45 granted: bool
46 status: str
47 reason: str
48 holder: str | None = None
50 def as_dict(self) -> dict[str, Any]:
51 """Return a JSON-compatible deterministic representation."""
52 return asdict(self)
55def contract_as_dict() -> dict[str, Any]:
56 """Return the stable resource-claim contract."""
57 return {
58 "schema_version": SCHEMA_VERSION,
59 "consumer_neutral": True,
60 "deterministic": True,
61 "stdlib_only": True,
62 "scope": "single-host",
63 "primitive": "mkdir",
64 "deny_mode": "structured-feedback",
65 "statuses": ["granted", "denied", "released", "missing", "not-owner"],
66 "merge_lock_consumer": True,
67 "stale_recovery": "caller-owned",
68 }
71def claim_resource(root: str | Path, resource: str, *, owner: str) -> ClaimResult:
72 """Claim a named resource under ``root`` for exactly one owner."""
73 path = resource_path(root, resource)
74 return _claim_path(path, resource=_clean(resource, "unknown-resource"), owner=owner)
77def release_resource(
78 root: str | Path,
79 resource: str,
80 *,
81 owner: str | None = None,
82 best_effort: bool = False,
83) -> ClaimResult:
84 """Release a named resource claim, optionally requiring the owner to match."""
85 path = resource_path(root, resource)
86 return _release_path(
87 path,
88 resource=_clean(resource, "unknown-resource"),
89 owner=owner,
90 best_effort=best_effort,
91 )
94@contextmanager
95def resource_claim(root: str | Path, resource: str, *, owner: str) -> Iterator[ClaimResult]:
96 """Context manager that yields a structured claim result and releases on success."""
97 result = claim_resource(root, resource, owner=owner)
98 try:
99 yield result
100 finally:
101 if result.granted:
102 release_resource(root, resource, owner=owner)
105def resource_path(root: str | Path, resource: str) -> Path:
106 """Return the deterministic lock directory path for ``resource``."""
107 name = _clean(resource, "unknown-resource")
108 slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", name).strip(".-").lower()
109 slug = slug or "resource"
110 digest = hashlib.sha256(name.encode("utf-8")).hexdigest()[:12]
111 return Path(root) / f"{slug}-{digest}.lock"
114@contextmanager
115def merge_lock(lock_dir: str | Path) -> Iterator[Path]:
116 """Acquire the merge lock for the duration of the ``with`` block."""
117 path = Path(lock_dir)
118 result = _claim_path(path, resource="merge", owner="merge-lock")
119 if not result.granted:
120 raise LockError(f"merge lock already held: {path}")
121 try:
122 yield path
123 finally:
124 _release_path(path, resource="merge", owner="merge-lock", best_effort=True)
127def _claim_path(path: Path, *, resource: str, owner: str) -> ClaimResult:
128 clean_owner = _clean(owner, "unknown-owner")
129 try:
130 path.mkdir(parents=True)
131 except FileExistsError:
132 return ClaimResult(
133 schema_version=SCHEMA_VERSION,
134 resource=resource,
135 owner=clean_owner,
136 path=str(path),
137 granted=False,
138 status="denied",
139 reason="resource-already-claimed",
140 holder=_holder(path),
141 )
142 workspace.ensure_runtime_gitignore_for(path)
143 _write_owner(path, clean_owner)
144 return ClaimResult(
145 schema_version=SCHEMA_VERSION,
146 resource=resource,
147 owner=clean_owner,
148 path=str(path),
149 granted=True,
150 status="granted",
151 reason="claim-acquired",
152 holder=clean_owner,
153 )
156def _release_path(
157 path: Path,
158 *,
159 resource: str,
160 owner: str | None,
161 best_effort: bool = False,
162) -> ClaimResult:
163 clean_owner = _clean(owner, "unknown-owner") if owner is not None else "any-owner"
164 if not path.exists():
165 return ClaimResult(
166 schema_version=SCHEMA_VERSION,
167 resource=resource,
168 owner=clean_owner,
169 path=str(path),
170 granted=False,
171 status="missing",
172 reason="resource-not-claimed",
173 )
174 holder = _holder(path)
175 # An unidentifiable holder refuses a *named* release, exactly as a differently
176 # named one does. Releasing with `owner=None` stays the deliberate any-owner
177 # escape for clearing a stuck claim.
178 if owner is not None and holder != clean_owner:
179 return ClaimResult(
180 schema_version=SCHEMA_VERSION,
181 resource=resource,
182 owner=clean_owner,
183 path=str(path),
184 granted=False,
185 status="not-owner",
186 reason="resource-held-by-different-owner",
187 holder=holder,
188 )
189 try:
190 owner_file = path / "owner.json"
191 if owner_file.exists():
192 owner_file.unlink()
193 path.rmdir()
194 except OSError:
195 if not best_effort:
196 raise
197 return ClaimResult(
198 schema_version=SCHEMA_VERSION,
199 resource=resource,
200 owner=clean_owner,
201 path=str(path),
202 granted=False,
203 status="released",
204 reason="claim-released",
205 holder=holder,
206 )
209def _write_owner(path: Path, owner: str) -> None:
210 (path / "owner.json").write_text(
211 json.dumps({"owner": owner}, sort_keys=True) + "\n",
212 encoding="utf-8",
213 )
216def _holder(path: Path) -> str:
217 """The named owner of an **existing** claim, or :data:`UNKNOWN_HOLDER`.
219 Every caller reaches here only with the claim directory present, so there is no
220 "unheld" answer to give. Each way of failing to read the name — file missing,
221 corrupt JSON, unreadable, wrong shape — means the resource is held by someone we
222 cannot identify, not that it is free. Collapsing those to ``None`` made the
223 ownership guard *vanish* rather than fail closed, letting a second run release a
224 live merge claim and take the lock (#631).
225 """
226 owner_file = path / "owner.json"
227 if not owner_file.exists():
228 return UNKNOWN_HOLDER
229 try:
230 data = json.loads(owner_file.read_text(encoding="utf-8"))
231 except (OSError, json.JSONDecodeError):
232 return UNKNOWN_HOLDER
233 owner = data.get("owner") if isinstance(data, dict) else None
234 return owner if isinstance(owner, str) and owner.strip() else UNKNOWN_HOLDER
237def _clean(value: str | None, fallback: str) -> str:
238 return value.strip() if isinstance(value, str) and value.strip() else fallback