Skip to content

Commit 6f2cd97

Browse files
authored
Merge pull request #28 from KadenMc/feat/workpool-retry-max-attempts
feat(workpool): bounded retry-on-failure (max_attempts + on_exhausted)
2 parents 3d57330 + fc7b8a5 commit 6f2cd97

4 files changed

Lines changed: 269 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
(not imported at package init), pure-stdlib (no new dependency). Validated by an
2727
N-process spawn hammer (`tests/test_workpool.py`) asserting completeness + single
2828
durable record + bounded duplicates + liveness under simulated NFS attribute-cache lag.
29-
See `docs/workpool.md` (incl. a `workpool` vs `aexp.queue` disambiguation).
29+
Optional **bounded retry** (`max_attempts > 1`, default `1` = off): a retryable
30+
`process` failure writes no output, so the item is reclaimed and re-run -- by *any*
31+
worker, so retry spans a heterogeneous fleet -- and after `max_attempts` failures a
32+
required `on_exhausted(item)` makes the item terminal (must set `is_done` true durably),
33+
which also fixes the tail-livelock a deterministic failure would otherwise cause.
34+
Attempts are counted durably on the shared FS; worker death stays orthogonal
35+
(stale-reclaim, never counted). See `docs/workpool.md` (incl. a `workpool` vs
36+
`aexp.queue` disambiguation).
3037

3138
## [0.6.1] - 2026-05-26
3239

docs/workpool.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,44 @@ failing item doesn't kill the sweep. Advanced callers can drive `claim_next()` /
9393
- `heartbeat` defaults to `ttl/5` (tolerate ~4 missed beats before a peer judges a lease
9494
stale). Set both larger if your items hold the GIL for long C calls.
9595

96+
### Retry on failure (`max_attempts`)
97+
98+
By default (`max_attempts=1`) a `process` exception routes straight to `on_error` and the
99+
item is left as today — unchanged behavior. Set `max_attempts > 1` to **bound-retry** a
100+
failing item instead:
101+
102+
- A retryable exception writes **no output**, so `is_done` stays false and the item is
103+
reclaimed and re-run — **by any worker**, so retry spans the fleet (a heavy item that
104+
OOMs a small GPU can be re-run on a bigger one). Attempts are counted durably on the
105+
shared filesystem (`_pool/_attempts/<item>/`), so the bound holds across workers.
106+
- After `max_attempts` failures the pool calls **`on_exhausted(item)`****required when
107+
`max_attempts > 1`**. It MUST make `is_done(item)` true durably (e.g. write an
108+
excluded/void output); that is what stops the item being reclaimed forever and lets the
109+
pool terminate — the same role a successful output plays. Make it idempotent (a rare
110+
double-exhaust under lag must be safe), like `process`.
111+
- `retryable=<ExcType>` (or a tuple of types) restricts which exceptions bound-retry;
112+
anything else falls through to `on_error` as usual. Default `None` = all `Exception`s.
113+
- Worker **death** (no exception — walltime/VPN) is orthogonal: always reclaimed via the
114+
stale lease, never counted as an attempt.
115+
116+
```python
117+
WorkPool(
118+
item_ids=items, is_done=is_done, lease_dir=OUT / "_pool" / "leases",
119+
max_attempts=3,
120+
on_exhausted=lambda item: atomic_write(OUT / f"{item}.json", EXCLUDED), # makes is_done true
121+
).run(process)
122+
```
123+
124+
**Heterogeneous-pool caveat.** Retry recovers a failure only if a *later* attempt can
125+
succeed. When failures are **capacity-bound** — an item too big for a small worker, so it
126+
fails *deterministically* on that worker — a heterogeneous pool can *false-exhaust* the item:
127+
undersized workers burn its attempts before a bigger one claims it. Keep the bound uniform
128+
(do **not** special-case it by worker size — that pushes root-cause awareness into the
129+
primitive); handle this operationally (size the pool so the work fits everywhere) or, as a
130+
future general extension, by capacity-aware routing (prefer re-running an item on a worker
131+
advertising more free memory — which stays uniform: an item that already exhausted the
132+
biggest worker correctly gives up).
133+
96134
## `workpool` vs `aexp.queue` — which one?
97135

98136
They are **orthogonal and compose**; pick by granularity.

src/aexp/workpool.py

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,26 @@ class WorkPool:
9191
is currently claimable but the work is not globally done. Default ``(1.0, 30.0)``.
9292
log : callable, optional
9393
``log(message: str)`` ASCII-only sink for progress/diagnostics. Defaults silent.
94+
max_attempts : int, optional
95+
Bounded retry-on-failure (default ``1`` = off, i.e. today's behavior exactly:
96+
a ``process`` exception routes straight to ``on_error``). When ``> 1``, a
97+
retryable exception (see ``retryable``) writes no output, so ``is_done`` stays
98+
false and the item is reclaimed and retried -- by any worker, so retry spans the
99+
fleet (a heavy item that OOMs a small GPU can be re-run on a bigger one). After
100+
``max_attempts`` failures the item is exhausted (see ``on_exhausted``). Worker
101+
*death* (no exception) is orthogonal -- always reclaimed via the stale lease,
102+
never counted as an attempt.
103+
on_exhausted : callable, optional
104+
``on_exhausted(item_id) -> None``, called once an item has failed
105+
``max_attempts`` times. **Required when ``max_attempts > 1``** and MUST make
106+
``is_done(item_id)`` true durably (e.g. write an excluded/void output) -- that is
107+
what stops the item being reclaimed forever and lets the pool terminate (the same
108+
role a successful ``process`` output plays). Should be idempotent (a rare
109+
double-exhaust under lag must be safe), like ``process``.
110+
retryable : exception type or tuple of types, optional
111+
Restrict bounded retry to these exception types (default ``None`` = all
112+
``Exception``\\ s count once ``max_attempts > 1``). A non-retryable exception
113+
falls through to ``on_error`` as usual.
94114
95115
Notes
96116
-----
@@ -108,6 +128,9 @@ def __init__(
108128
heartbeat: float | None = None,
109129
backoff: tuple[float, float] = (1.0, 30.0),
110130
log: Callable[[str], None] | None = None,
131+
max_attempts: int = 1,
132+
on_exhausted: Callable[[str], None] | None = None,
133+
retryable: type[BaseException] | tuple[type[BaseException], ...] | None = None,
111134
) -> None:
112135
self._item_ids: list[str] = list(item_ids)
113136
self._validate_ids(self._item_ids)
@@ -119,6 +142,25 @@ def __init__(
119142
self._backoff_min, self._backoff_max = backoff
120143
self._log = log
121144

145+
# Retry-on-failure (opt-in; max_attempts == 1 reproduces today exactly -- no
146+
# attempt counting, no exhaustion, an exception routes straight to on_error).
147+
if max_attempts < 1:
148+
raise ValueError(f"max_attempts must be >= 1, got {max_attempts}")
149+
if max_attempts > 1 and on_exhausted is None:
150+
raise ValueError(
151+
"max_attempts > 1 enables retry-on-failure, so on_exhausted is "
152+
"required: when an item has failed max_attempts times the pool calls "
153+
"on_exhausted(item), which MUST make is_done(item) true durably (e.g. "
154+
"write an excluded/void output). Without it a permanently-failing item "
155+
"would be reclaimed forever and the pool would never terminate."
156+
)
157+
self._max_attempts = max_attempts
158+
self._on_exhausted = on_exhausted
159+
self._retryable = retryable
160+
# Per-item attempt tally lives beside the leases (its own dir so it never
161+
# collides with <item>.lease). Created lazily on the first failure.
162+
self._attempts_dir = Path(lease_dir).parent / "_attempts"
163+
122164
self._active_item: str | None = None
123165
self._lock = threading.Lock() # guards _active_item across the heartbeat thread
124166
self._stop = threading.Event()
@@ -202,15 +244,72 @@ def run(
202244
while (item := self.claim_next()) is not None:
203245
try:
204246
process(item)
205-
except Exception as exc: # noqa: BLE001 -- routed to on_error by contract
206-
if on_error is None:
247+
except Exception as exc: # noqa: BLE001 -- routed by contract below
248+
if not self._handle_failure(item, exc, on_error):
207249
raise
208-
on_error(item, exc)
209250
finally:
210251
self.mark_done(item)
211252
worker_done += 1
212253
self._log_progress(worker_done, total, t0)
213254

255+
def _handle_failure(
256+
self,
257+
item_id: str,
258+
exc: Exception,
259+
on_error: Callable[[str, Exception], None] | None,
260+
) -> bool:
261+
"""Route a ``process`` failure. Returns True if handled, False to propagate.
262+
263+
A *retryable* failure (see :meth:`_is_retryable`) writes NO output, so
264+
``is_done`` stays false and the item is reclaimed -- by this worker or a peer,
265+
so retry is cross-worker for free. After ``max_attempts`` failures
266+
``on_exhausted`` makes the item terminal. A non-retryable failure goes to the
267+
caller's ``on_error`` (or propagates if none).
268+
"""
269+
if self._is_retryable(exc):
270+
n = self._record_attempt(item_id)
271+
if n >= self._max_attempts:
272+
self._emit(
273+
f"workpool: {item_id} exhausted after {n} attempts "
274+
f"({type(exc).__name__}); calling on_exhausted"
275+
)
276+
assert self._on_exhausted is not None # guaranteed by __init__
277+
self._on_exhausted(item_id)
278+
else:
279+
self._emit(
280+
f"workpool: {item_id} attempt {n}/{self._max_attempts} failed "
281+
f"({type(exc).__name__}); will retry"
282+
)
283+
return True
284+
if on_error is not None:
285+
on_error(item_id, exc)
286+
return True
287+
return False
288+
289+
def _emit(self, msg: str) -> None:
290+
if self._log is not None:
291+
self._log(msg)
292+
293+
def _is_retryable(self, exc: Exception) -> bool:
294+
"""True iff retry is enabled (max_attempts > 1) and ``exc`` is in scope."""
295+
if self._max_attempts <= 1:
296+
return False
297+
if self._retryable is None:
298+
return True
299+
return isinstance(exc, self._retryable)
300+
301+
def _record_attempt(self, item_id: str) -> int:
302+
"""Record one failed attempt and return the running count (cross-worker durable).
303+
304+
Each attempt is a uniquely-named marker under ``_attempts/<item_id>/`` -- so
305+
counting is lag-tolerant and needs no read-modify-write (matching the pool's
306+
NFS-safety model). Only reached when retry is enabled (max_attempts > 1).
307+
"""
308+
d = self._attempts_dir / item_id
309+
d.mkdir(parents=True, exist_ok=True)
310+
(d / uuid.uuid4().hex).write_text(self._owner_id, encoding="ascii")
311+
return sum(1 for _ in d.iterdir())
312+
214313
def _log_progress(self, worker_done: int, total: int, t0: float) -> None:
215314
if self._log is None:
216315
return

tests/test_workpool.py

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,128 @@ def test_exactly_one_completion_under_contention(tmp_path):
226226
p_lag=0.15, p_die=0.5, p_linkfail=0.05,
227227
)
228228
# Soft per-seed rate ceiling: total re-processes stay a small multiple of the item
229-
# count even under aggressive lag + 50%-death (calibrated -- observed ~13-14 with
230-
# max_dup==2; a ping-pong regression would blow far past this and the hard cap).
231-
assert total_dups <= 2 * n_items, f"seed {s}: dup rate too high ({total_dups})"
229+
# count even under aggressive lag + 50%-death. This is a loose sanity heuristic
230+
# (the per-item hard cap above is the real guard) so it must tolerate platform
231+
# multiprocessing-scheduling variance: calibrated ~13-14 locally, but ubuntu-3.13
232+
# CI observed 33, so the bound is 4x the item count -- still orders of magnitude
233+
# below a genuine ping-pong regression (which the hard cap also catches).
234+
assert total_dups <= 4 * n_items, f"seed {s}: dup rate too high ({total_dups})"
232235
ensemble_dups += total_dups
233236
# Over the ensemble, the lag/contention path MUST have fired at least once (else the
234237
# test is trivially passing because no concurrency happened).
235238
assert ensemble_dups >= 1, "no duplicates anywhere -> contention/lag was not exercised"
239+
240+
241+
# ------------------------------------------------------------------ retry-on-failure
242+
# Single-process, deterministic: exercise the max_attempts / on_exhausted / retryable
243+
# surface directly (the cross-process reclaim path is covered by the hammer above).
244+
def _retry_dirs(tmp_path: Path) -> tuple[Path, Path]:
245+
out = tmp_path / "out"
246+
lease_dir = tmp_path / "_pool" / "leases"
247+
out.mkdir(parents=True, exist_ok=True)
248+
lease_dir.mkdir(parents=True, exist_ok=True)
249+
return out, lease_dir
250+
251+
252+
def test_max_attempts_gt_one_requires_on_exhausted(tmp_path):
253+
"""Retry (max_attempts > 1) demands on_exhausted; max_attempts < 1 is rejected."""
254+
_, lease_dir = _retry_dirs(tmp_path)
255+
with pytest.raises(ValueError, match="on_exhausted"):
256+
WorkPool(item_ids=["a"], is_done=lambda i: False,
257+
lease_dir=str(lease_dir), max_attempts=3)
258+
with pytest.raises(ValueError, match="max_attempts"):
259+
WorkPool(item_ids=["a"], is_done=lambda i: False,
260+
lease_dir=str(lease_dir), max_attempts=0,
261+
on_exhausted=lambda i: None)
262+
263+
264+
def test_retry_recovers_across_transient_failures(tmp_path):
265+
"""A transient failure re-runs (same worker here) and succeeds within the budget."""
266+
out, lease_dir = _retry_dirs(tmp_path)
267+
items = ["a", "b", "c"]
268+
calls: dict[str, int] = {}
269+
exhausted: list[str] = []
270+
271+
def is_done(i: str) -> bool:
272+
return (out / f"{i}.json").exists()
273+
274+
def process(i: str) -> None:
275+
calls[i] = calls.get(i, 0) + 1
276+
if i == "b" and calls[i] <= 2: # fails twice, succeeds on the 3rd attempt
277+
raise RuntimeError("transient")
278+
atomic_write(out / f"{i}.json", json.dumps({"item": i}))
279+
280+
pool = WorkPool(
281+
item_ids=items, is_done=is_done, lease_dir=str(lease_dir),
282+
ttl=5.0, heartbeat=1.0, backoff=(0.01, 0.05),
283+
max_attempts=3, on_exhausted=exhausted.append,
284+
)
285+
pool.run(process)
286+
287+
assert all(is_done(i) for i in items) # all completed, incl. the retried one
288+
assert calls["b"] == 3 # 2 failures + 1 success
289+
assert exhausted == [] # never gave up
290+
# 2 failure markers recorded for b (the 3rd attempt succeeded -> no marker).
291+
assert len(list((tmp_path / "_pool" / "_attempts" / "b").iterdir())) == 2
292+
293+
294+
def test_exhausts_and_terminates_on_permanent_failure(tmp_path):
295+
"""A permanently-failing item exhausts after max_attempts; on_exhausted ends it
296+
(no livelock: run() returns because on_exhausted makes is_done true)."""
297+
out, lease_dir = _retry_dirs(tmp_path)
298+
items = ["a", "b"]
299+
exhausted: list[str] = []
300+
301+
def is_done(i: str) -> bool:
302+
return (out / f"{i}.json").exists()
303+
304+
def process(i: str) -> None:
305+
if i == "b":
306+
raise RuntimeError("permanent")
307+
atomic_write(out / f"{i}.json", json.dumps({"item": i}))
308+
309+
def on_exhausted(i: str) -> None:
310+
exhausted.append(i)
311+
atomic_write(out / f"{i}.json", json.dumps({"item": i, "excluded": True}))
312+
313+
pool = WorkPool(
314+
item_ids=items, is_done=is_done, lease_dir=str(lease_dir),
315+
ttl=5.0, heartbeat=1.0, backoff=(0.01, 0.05),
316+
max_attempts=2, on_exhausted=on_exhausted,
317+
)
318+
pool.run(process) # must terminate
319+
320+
assert is_done("a") and is_done("b") # a succeeded; b terminal via on_exhausted
321+
assert exhausted == ["b"] # gave up exactly once
322+
assert len(list((tmp_path / "_pool" / "_attempts" / "b").iterdir())) == 2 # bounded
323+
324+
325+
def test_non_retryable_exception_routes_to_on_error(tmp_path):
326+
"""With `retryable` set, an out-of-scope exception goes to on_error, not the retry
327+
path (attempt count untouched)."""
328+
out, lease_dir = _retry_dirs(tmp_path)
329+
330+
class Transient(Exception):
331+
pass
332+
333+
errors: list[tuple[str, str]] = []
334+
335+
def is_done(i: str) -> bool:
336+
return (out / f"{i}.json").exists()
337+
338+
def process(i: str) -> None:
339+
raise ValueError("fatal, not retryable")
340+
341+
def on_error(i: str, exc: Exception) -> None:
342+
errors.append((i, type(exc).__name__))
343+
atomic_write(out / f"{i}.json", json.dumps({"item": i, "errored": True}))
344+
345+
pool = WorkPool(
346+
item_ids=["a"], is_done=is_done, lease_dir=str(lease_dir),
347+
ttl=5.0, heartbeat=1.0, backoff=(0.01, 0.05),
348+
max_attempts=3, on_exhausted=lambda i: None, retryable=Transient,
349+
)
350+
pool.run(process, on_error=on_error)
351+
352+
assert errors == [("a", "ValueError")] # routed to on_error
353+
assert not (tmp_path / "_pool" / "_attempts").exists() # retry path never touched

0 commit comments

Comments
 (0)