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

1"""Single-host resource claims backed by atomic ``mkdir``. 

2 

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""" 

8 

9from __future__ import annotations 

10 

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 

19 

20from . import workspace 

21 

22SCHEMA_VERSION = "keel.resource-claim.v1" 

23 

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>" 

31 

32 

33class LockError(RuntimeError): 

34 """Raised when the merge lock is already held.""" 

35 

36 

37@dataclass(frozen=True) 

38class ClaimResult: 

39 """Structured result for a single-host resource claim operation.""" 

40 

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 

49 

50 def as_dict(self) -> dict[str, Any]: 

51 """Return a JSON-compatible deterministic representation.""" 

52 return asdict(self) 

53 

54 

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 } 

69 

70 

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) 

75 

76 

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 ) 

92 

93 

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) 

103 

104 

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" 

112 

113 

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) 

125 

126 

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 ) 

154 

155 

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 ) 

207 

208 

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 ) 

214 

215 

216def _holder(path: Path) -> str: 

217 """The named owner of an **existing** claim, or :data:`UNKNOWN_HOLDER`. 

218 

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 

235 

236 

237def _clean(value: str | None, fallback: str) -> str: 

238 return value.strip() if isinstance(value, str) and value.strip() else fallback