Skip to content

Commit 946ed95

Browse files
Make DirectoryStore __setitem__ resilient against antivirus file locking (#698)
* Update storage.py * Resolve F841 warning, add comments * pep8 line length issue * resolve other f841 warning * Trigger new build, linux testing failed with http error * apply suggested changes from Josh Moore, provides test for new retry_call * add optional argument typehint * Resolve typehint issues * bug with typehint * unused import * bug with type hint * Simplify and add code coverage tests * Add PermissionError exception to retry_call Co-authored-by: jmoore <[email protected]> Co-authored-by: Josh Moore <[email protected]>
1 parent a4fc2c1 commit 946ed95

File tree

3 files changed

+63
-5
lines changed

3 files changed

+63
-5
lines changed

zarr/storage.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
from zarr.meta import encode_array_metadata, encode_group_metadata
5454
from zarr.util import (buffer_size, json_loads, nolock, normalize_chunks,
5555
normalize_dtype, normalize_fill_value, normalize_order,
56-
normalize_shape, normalize_storage_path)
56+
normalize_shape, normalize_storage_path, retry_call)
5757

5858
__doctest_requires__ = {
5959
('RedisStore', 'RedisStore.*'): ['redis'],
@@ -860,8 +860,10 @@ def __setitem__(self, key, value):
860860
try:
861861
self._tofile(value, temp_path)
862862

863-
# move temporary file into place
864-
os.replace(temp_path, file_path)
863+
# move temporary file into place;
864+
# make several attempts at writing the temporary file to get past
865+
# potential antivirus file locking issues
866+
retry_call(os.replace, (temp_path, file_path), exceptions=(PermissionError,))
865867

866868
finally:
867869
# clean up if temp file still exists for whatever reason

zarr/tests/test_util.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from zarr.util import (guess_chunks, human_readable_size, info_html_report,
88
info_text_report, is_total_slice, normalize_chunks,
99
normalize_fill_value, normalize_order,
10-
normalize_resize_args, normalize_shape,
10+
normalize_resize_args, normalize_shape, retry_call,
1111
tree_array_icon, tree_group_icon, tree_get_icon,
1212
tree_widget)
1313

@@ -175,3 +175,30 @@ def test_tree_widget_missing_ipytree():
175175
)
176176
with pytest.raises(ImportError, match=re.escape(pattern)):
177177
tree_widget(None, None, None)
178+
179+
180+
def test_retry_call():
181+
182+
class Fixture:
183+
184+
def __init__(self, pass_on=1):
185+
self.c = 0
186+
self.pass_on = pass_on
187+
188+
def __call__(self):
189+
self.c += 1
190+
if self.c != self.pass_on:
191+
raise PermissionError()
192+
193+
for x in range(1, 11):
194+
# Any number of failures less than 10 will be accepted.
195+
fixture = Fixture(pass_on=x)
196+
retry_call(fixture, exceptions=(PermissionError,), wait=0)
197+
assert fixture.c == x
198+
199+
def fail(x):
200+
# Failures after 10 will cause an error to be raised.
201+
retry_call(Fixture(pass_on=x), exceptions=(Exception,), wait=0)
202+
203+
for x in range(11, 15):
204+
pytest.raises(PermissionError, fail, x)

zarr/util.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import numbers
55
from textwrap import TextWrapper
66
import mmap
7+
import time
78

89
import numpy as np
910
from asciitree import BoxStyle, LeftAligned
@@ -12,7 +13,8 @@
1213
from numcodecs.registry import codec_registry
1314
from numcodecs.blosc import cbuffer_sizes, cbuffer_metainfo
1415

15-
from typing import Any, Dict, Tuple, Union
16+
from typing import Any, Callable, Dict, Tuple, Union
17+
1618

1719
# codecs to use for object dtype convenience API
1820
object_codecs = {
@@ -609,3 +611,30 @@ def read_part(self, start, nitems):
609611

610612
def read_full(self):
611613
return self.chunk_store[self.store_key]
614+
615+
616+
def retry_call(callabl: Callable,
617+
args=None,
618+
kwargs=None,
619+
exceptions: Tuple[Any, ...] = (),
620+
retries: int = 10,
621+
wait: float = 0.1) -> Any:
622+
"""
623+
Make several attempts to invoke the callable. If one of the given exceptions
624+
is raised, wait the given period of time and retry up to the given number of
625+
retries.
626+
"""
627+
628+
if args is None:
629+
args = ()
630+
if kwargs is None:
631+
kwargs = {}
632+
633+
for attempt in range(1, retries+1):
634+
try:
635+
return callabl(*args, **kwargs)
636+
except exceptions:
637+
if attempt < retries:
638+
time.sleep(wait)
639+
else:
640+
raise

0 commit comments

Comments
 (0)