Skip to content

Commit 2630b54

Browse files
committed
fix(examples): preserve concurrent prompt updates
1 parent 5b482ea commit 2630b54

2 files changed

Lines changed: 313 additions & 24 deletions

File tree

examples/optimization/eval_optimize_loop/eval_loop/writeback.py

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
"""Best-effort prompt CAS with per-file atomic replacement.
2+
3+
Hash checks narrow concurrency windows but cannot make a multi-file operation
4+
transactional. A non-cooperating writer can still race after the last check.
5+
"""
6+
17
from __future__ import annotations
28

39
import hashlib
@@ -62,8 +68,13 @@ def _current_hashes(snapshot: PromptSnapshot) -> dict[str, str]:
6268
return {name: _hash_bytes(prompt_file.path.read_bytes()) for name, prompt_file in snapshot.files.items()}
6369

6470

65-
def _atomic_replace_bytes(path: Path, content: bytes) -> None:
66-
"""Replace one file atomically after durably flushing a same-directory temp."""
71+
def _atomic_replace_bytes(
72+
path: Path,
73+
content: bytes,
74+
*,
75+
expected_sha256: str | None = None,
76+
) -> None:
77+
"""Replace one file atomically if its last observed hash is still expected."""
6778

6879
fd, temp_name = tempfile.mkstemp(
6980
dir=path.parent,
@@ -78,6 +89,10 @@ def _atomic_replace_bytes(path: Path, content: bytes) -> None:
7889
temp_file.write(content)
7990
temp_file.flush()
8091
os.fsync(temp_file.fileno())
92+
if expected_sha256 is not None:
93+
current_sha256 = _hash_bytes(path.read_bytes())
94+
if current_sha256 != expected_sha256:
95+
raise ConcurrentPromptUpdateError(f"source prompt file changed before replace: {path}")
8196
os.replace(temp_path, path)
8297
finally:
8398
active_exception = sys.exc_info()[0] is not None
@@ -98,13 +113,31 @@ def _atomic_replace_bytes(path: Path, content: bytes) -> None:
98113
raise cleanup_error
99114

100115

101-
def _restore_snapshot(snapshot: PromptSnapshot) -> list[str]:
116+
def _restore_snapshot(
117+
snapshot: PromptSnapshot,
118+
written_hashes: dict[str, str],
119+
) -> list[str]:
120+
"""Conditionally restore only files this operation successfully replaced."""
121+
102122
failures: list[str] = []
103-
for name, prompt_file in snapshot.files.items():
123+
for name, candidate_hash in written_hashes.items():
124+
prompt_file = snapshot.files[name]
104125
try:
105-
_atomic_replace_bytes(prompt_file.path, prompt_file.content)
126+
current_hash = _hash_bytes(prompt_file.path.read_bytes())
106127
except OSError as error:
107-
failures.append(f"{name}: {error}")
128+
failures.append(f"{name}: restore precondition failed: {error}")
129+
continue
130+
if current_hash != candidate_hash:
131+
failures.append(f"{name}: restore conflict; current hash changed from candidate")
132+
continue
133+
try:
134+
_atomic_replace_bytes(
135+
prompt_file.path,
136+
prompt_file.content,
137+
expected_sha256=candidate_hash,
138+
)
139+
except (OSError, ConcurrentPromptUpdateError) as error:
140+
failures.append(f"{name}: restore failed: {error}")
108141
return failures
109142

110143

@@ -143,15 +176,39 @@ def temporary_prompt_bundle(
143176
snapshot: PromptSnapshot,
144177
prompts: dict[str, str],
145178
) -> Iterator[None]:
146-
"""Temporarily install candidate prompts and always restore the snapshot."""
179+
"""Install candidates temporarily using best-effort CAS and conditional restore."""
147180

148181
encoded_prompts = _encode_prompt_bundle(snapshot, prompts)
182+
expected_hashes = snapshot.hashes()
183+
current_hashes = _current_hashes(snapshot)
184+
if current_hashes != expected_hashes:
185+
changed = [name for name, expected_hash in expected_hashes.items() if current_hashes[name] != expected_hash]
186+
raise ConcurrentPromptUpdateError(f"source prompt files changed since snapshot: {', '.join(changed)}")
187+
188+
candidate_hashes = {name: _hash_bytes(content) for name, content in encoded_prompts.items()}
189+
written_hashes: dict[str, str] = {}
149190
try:
150191
for name, prompt_file in snapshot.files.items():
151-
_atomic_replace_bytes(prompt_file.path, encoded_prompts[name])
192+
_atomic_replace_bytes(
193+
prompt_file.path,
194+
encoded_prompts[name],
195+
expected_sha256=expected_hashes[name],
196+
)
197+
written_hashes[name] = candidate_hashes[name]
152198
yield
153-
finally:
154-
failures = _restore_snapshot(snapshot)
199+
except BaseException as primary_error:
200+
failures = _restore_snapshot(snapshot, written_hashes)
201+
restoration_error = _restoration_error(snapshot, failures)
202+
if restoration_error is not None:
203+
diagnostic = f"failed to restore prompt snapshot: {restoration_error}"
204+
add_note = getattr(primary_error, "add_note", None)
205+
if add_note is not None:
206+
add_note(diagnostic)
207+
else:
208+
raise primary_error from RuntimeError(diagnostic)
209+
raise
210+
else:
211+
failures = _restore_snapshot(snapshot, written_hashes)
155212
restoration_error = _restoration_error(snapshot, failures)
156213
if restoration_error is not None:
157214
raise RuntimeError(f"failed to restore prompt snapshot: {restoration_error}")
@@ -161,7 +218,7 @@ def commit_prompt_bundle(
161218
snapshot: PromptSnapshot,
162219
prompts: dict[str, str],
163220
) -> WritebackResult:
164-
"""Apply a complete prompt bundle with CAS and compensating rollback."""
221+
"""Apply a bundle with best-effort CAS and conditional compensating rollback."""
165222

166223
before_hashes = _current_hashes(snapshot)
167224
expected_hashes = snapshot.hashes()
@@ -170,20 +227,37 @@ def commit_prompt_bundle(
170227
raise ConcurrentPromptUpdateError(f"source prompt files changed since snapshot: {', '.join(changed)}")
171228

172229
encoded_prompts = _encode_prompt_bundle(snapshot, prompts)
230+
candidate_hashes = {name: _hash_bytes(content) for name, content in encoded_prompts.items()}
231+
written_hashes: dict[str, str] = {}
173232
try:
174233
for name, prompt_file in snapshot.files.items():
175-
_atomic_replace_bytes(prompt_file.path, encoded_prompts[name])
234+
_atomic_replace_bytes(
235+
prompt_file.path,
236+
encoded_prompts[name],
237+
expected_sha256=expected_hashes[name],
238+
)
239+
written_hashes[name] = candidate_hashes[name]
176240
applied_hashes = _current_hashes(snapshot)
177-
except OSError as error:
178-
rollback_failures = _restore_snapshot(snapshot)
241+
candidate_mismatches = [
242+
name for name, candidate_hash in candidate_hashes.items() if applied_hashes[name] != candidate_hash
243+
]
244+
if candidate_mismatches:
245+
raise ConcurrentPromptUpdateError(
246+
"candidate prompt files changed before final verification: " f"{', '.join(candidate_mismatches)}"
247+
)
248+
except (OSError, ConcurrentPromptUpdateError) as error:
249+
if isinstance(error, ConcurrentPromptUpdateError) and not written_hashes:
250+
raise
251+
252+
rollback_failures = _restore_snapshot(snapshot, written_hashes)
179253
try:
180254
after_hashes = _current_hashes(snapshot)
181255
except OSError as hash_error:
182256
after_hashes = {}
183257
rollback_failures.append(f"hash verification: {hash_error}")
184258
else:
185259
rollback_failures.extend(
186-
f"{name}: restored hash differs from snapshot"
260+
f"{name}: final hash differs from snapshot"
187261
for name, expected_hash in expected_hashes.items()
188262
if after_hashes[name] != expected_hash
189263
)
@@ -192,7 +266,7 @@ def commit_prompt_bundle(
192266
if rollback_failures:
193267
error_message += f"; rollback failures: {'; '.join(rollback_failures)}"
194268
return WritebackResult(
195-
status="rollback_failed" if rollback_failures else "rolled_back",
269+
status="rolled_back" if after_hashes == expected_hashes else "rollback_failed",
196270
before_hashes=before_hashes,
197271
after_hashes=after_hashes,
198272
error=error_message,

0 commit comments

Comments
 (0)