Skip to content

Commit 5c015f6

Browse files
committed
fix: cancel remaining resize deletions after the first failure
With gather, a failed deletion raised out of resize while the other workers kept deleting in the background, racing any retry. A TaskGroup cancels them; the first exception is unwrapped from the ExceptionGroup so resize keeps raising the store's own error. Assisted-by: ClaudeCode:claude-opus-5-5
1 parent 2656ad7 commit 5c015f6

2 files changed

Lines changed: 36 additions & 2 deletions

File tree

‎src/zarr/core/array.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import copy
44
import math
55
import warnings
6-
from asyncio import gather
6+
from asyncio import TaskGroup, gather
77
from collections.abc import Iterable, Mapping, Sequence
88
from dataclasses import dataclass, field, replace
99
from itertools import starmap
@@ -5940,7 +5940,15 @@ async def _delete_worker() -> None:
59405940
# Bound the worker count even when the user has disabled the I/O
59415941
# concurrency limit with None.
59425942
concurrency = zarr_config.get("async.concurrency") or 1000
5943-
await gather(*(_delete_worker() for _ in range(concurrency)))
5943+
# A TaskGroup cancels the remaining workers on the first failure, so no
5944+
# deletions continue after resize has raised.
5945+
try:
5946+
async with TaskGroup() as tg:
5947+
for _ in range(concurrency):
5948+
tg.create_task(_delete_worker())
5949+
except ExceptionGroup as eg:
5950+
# Keep resize's contract of raising the store's own exception.
5951+
raise eg.exceptions[0] from None
59445952

59455953
# Write new metadata
59465954
await save_metadata(array.store_path, new_metadata)

‎tests/test_resize.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,29 @@ async def test_resize_delete_failure_preserves_metadata() -> None:
155155
assert metadata_before.to_bytes() == metadata_after.to_bytes()
156156
assert array.shape == (4,)
157157
assert array._chunk_grid is grid_before
158+
159+
160+
async def test_resize_delete_failure_stops_remaining_deletions() -> None:
161+
store = MemoryStore()
162+
array = await create_array(store, shape=(3000,), chunks=(1,), dtype="uint8")
163+
calls = 0
164+
165+
async def delete(key: str) -> None:
166+
nonlocal calls
167+
calls += 1
168+
call = calls
169+
await asyncio.sleep(0)
170+
if call == 10:
171+
raise OSError("delete failed")
172+
173+
with (
174+
config.set({"async.concurrency": 4}),
175+
mock.patch.object(store, "delete", delete),
176+
):
177+
with pytest.raises(OSError, match="delete failed"):
178+
await array.resize((0,))
179+
calls_at_raise = calls
180+
# Give any surviving workers a chance to run.
181+
for _ in range(10):
182+
await asyncio.sleep(0)
183+
assert calls == calls_at_raise < 3000

0 commit comments

Comments
 (0)