Skip to content

Commit a8f6db7

Browse files
committed
fix(store): relocate LocalStore temporary files to configurable tmp_dir
`LocalStore` now writes temporary files to a dedicated `tmp_dir` rather than directly inside the store path. Previously, `_atomic_write` wrote temp files ending in `.partial` inside the store which triggered `ZarrUserWarning` during concurrent reading and listing of the store. The location defaults to the system temporary directory (via `tempfile.gettempdir()`), and is overridable via the `tmp_dir` argument to `LocalStore` or globally via the `store.local.tmp_dir` config option (env var `ZARR_STORE__LOCAL__TMP_DIR`). The temporary directory must be on the same filesystem as the store, otherwise `_atomic_write` will raise `OSError` with `EXDEV`. The pre-existing try/except now also has a specific catch for `EXDEV` and re-raises, informing the user to change the `tmp_dir` value to a location on the same filesystem rather than emitting a raw `OSError`. Documented this new option in config.md and storage.md. Fixes #4161.
1 parent ecc2d77 commit a8f6db7

7 files changed

Lines changed: 110 additions & 17 deletions

File tree

changes/4173.bugfix.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
`LocalStore` now writes the temporary files used by atomic writes to a
2+
configurable location rather than alongside each chunk inside the store.
3+
Previously the temporary `.partial` files were placed in the store directory and
4+
would surface when listing or concurrently reading a store, producing spurious
5+
`ZarrUserWarning`s.
6+
7+
The temporary location defaults to the system temporary directory and can be
8+
overridden per store via the `tmp_dir` argument to `LocalStore`, or globally via
9+
the `store.local.tmp_dir` config option (environment variable
10+
`ZARR_STORE__LOCAL__TMP_DIR`). It must be on the same filesystem as the store.
11+
If it is not, a write fails with a clear error telling you to point `tmp_dir` at
12+
the store's filesystem, instead of a raw cross-device `OSError`.

docs/user-guide/config.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Configuration options include the following:
4444
- Selections of implementations of codecs, codec pipelines and buffers
4545
- Enabling GPU support with `zarr.config.enable_gpu()`. See [GPU support](gpu.md) for more.
4646
- Control request merging when reading multiple chunks from the same shard with `array.sharding_coalesce_max_gap_bytes` and `array.sharding_coalesce_max_bytes`. Reads of nearby chunks are coalesced into a single request to the store when separated by at most `sharding_coalesce_max_gap_bytes` and the resulting merged read is no larger than `sharding_coalesce_max_bytes`.
47+
- Set the temporary write location for `LocalStore` writes with `store.local.tmp_dir`.
4748

4849
For selecting custom implementations of codecs, pipelines, buffers and ndbuffers,
4950
first register the implementations in the registry and then select them in the config.

docs/user-guide/storage.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ group = zarr.open_group(store=store, mode='r')
113113
print(group)
114114
```
115115

116+
By default, `LocalStore` writes the temporary files used by atomic writes to the system temporary
117+
directory. Set a different location with the `tmp_dir` argument or globally via the
118+
`store.local.tmp_dir` config option (`ZARR_STORE__LOCAL__TMP_DIR`). The temporary directory
119+
should be on the same filesystem as the store, or writes may fail with a cross-device error.
120+
116121
### Zip Store
117122

118123
The [`zarr.storage.ZipStore`][] stores the contents of a Zarr hierarchy in a single

src/zarr/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ def enable_gpu(self) -> ConfigSet:
152152
},
153153
"buffer": "zarr.buffer.cpu.Buffer",
154154
"ndbuffer": "zarr.buffer.cpu.NDBuffer",
155+
"store": {"local": {"tmp_dir": None}},
155156
}
156157
],
157158
deprecations=deprecations,

src/zarr/storage/_local.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import asyncio
44
import contextlib
5+
import errno
56
import io
67
import os
78
import shutil
89
import sys
910
import uuid
1011
from pathlib import Path
12+
from tempfile import gettempdir
1113
from typing import TYPE_CHECKING, BinaryIO, Literal, Self
1214

1315
from zarr.abc.store import (
@@ -20,6 +22,7 @@
2022
from zarr.core.buffer import Buffer
2123
from zarr.core.buffer.core import default_buffer_prototype
2224
from zarr.core.common import AccessModeLiteral, concurrent_map
25+
from zarr.core.config import config as zarr_config
2326

2427
if TYPE_CHECKING:
2528
from collections.abc import AsyncIterator, Iterable, Iterator
@@ -62,26 +65,36 @@ def _safe_move(src: Path, dst: Path) -> None:
6265
def _atomic_write(
6366
path: Path,
6467
mode: Literal["r+b", "wb"],
68+
tmp_dir: Path,
6569
exclusive: bool = False,
6670
) -> Iterator[BinaryIO]:
67-
tmp_path = path.with_suffix(f".{uuid.uuid4().hex}.partial")
71+
tmp_path = tmp_dir / f"{uuid.uuid4().hex}.partial"
6872
try:
6973
with tmp_path.open(mode) as f:
7074
yield f
7175
if exclusive:
7276
_safe_move(tmp_path, path)
7377
else:
7478
tmp_path.replace(path)
75-
except Exception:
79+
except Exception as e:
7680
tmp_path.unlink(missing_ok=True)
81+
if isinstance(e, OSError) and e.errno == errno.EXDEV:
82+
msg = (
83+
f"Cannot finalize atomic write {path}: tmp dir {tmp_dir} and the "
84+
"store location are not on the same filesystem. Set tmp "
85+
"location to a location on the same filesystem as the store "
86+
"using store.local.tmp_dir config or ZARR_STORE__LOCAL__TMP_DIR."
87+
)
88+
raise OSError(errno.EXDEV, msg) from e
7789
raise
7890

7991

80-
def _put(path: Path, value: Buffer, exclusive: bool = False) -> int:
92+
def _put(path: Path, value: Buffer, tmp_dir: Path, exclusive: bool = False) -> int:
8193
path.parent.mkdir(parents=True, exist_ok=True)
94+
tmp_dir.mkdir(parents=True, exist_ok=True)
8295
# write takes any object supporting the buffer protocol
8396
view = value.as_buffer_like()
84-
with _atomic_write(path, "wb", exclusive=exclusive) as f:
97+
with _atomic_write(path, "wb", tmp_dir, exclusive=exclusive) as f:
8598
return f.write(view)
8699

87100

@@ -95,6 +108,9 @@ class LocalStore(Store):
95108
Directory to use as root of store.
96109
read_only : bool
97110
Whether the store is read-only
111+
tmp_dir : str or Path, optional
112+
Where to write the store's temporary files during atomic write.
113+
`None` defaults to value of `tempfile.gettempdir()`.
98114
99115
Attributes
100116
----------
@@ -110,7 +126,9 @@ class LocalStore(Store):
110126

111127
root: Path
112128

113-
def __init__(self, root: Path | str, *, read_only: bool = False) -> None:
129+
def __init__(
130+
self, root: Path | str, *, read_only: bool = False, tmp_dir: Path | str | None = None
131+
) -> None:
114132
super().__init__(read_only=read_only)
115133
if isinstance(root, str):
116134
root = Path(root)
@@ -119,17 +137,28 @@ def __init__(self, root: Path | str, *, read_only: bool = False) -> None:
119137
f"'root' must be a string or Path instance. Got an instance of {type(root)} instead."
120138
)
121139
self.root = root
140+
self._tmp_dir = tmp_dir
141+
142+
def _resolve_tmp_dir(self) -> Path:
143+
value = self._tmp_dir
144+
if value is None:
145+
value = zarr_config.get("store.local.tmp_dir", None)
146+
if value is None:
147+
value = gettempdir()
148+
return Path(value)
122149

123150
def with_read_only(self, read_only: bool = False) -> Self:
124151
# docstring inherited
125-
return type(self)(
126-
root=self.root,
127-
read_only=read_only,
128-
)
152+
return type(self)(root=self.root, read_only=read_only, tmp_dir=self._tmp_dir)
129153

130154
@classmethod
131155
async def open(
132-
cls, root: Path | str, *, read_only: bool = False, mode: AccessModeLiteral | None = None
156+
cls,
157+
root: Path | str,
158+
*,
159+
read_only: bool = False,
160+
mode: AccessModeLiteral | None = None,
161+
tmp_dir: Path | str | None = None,
133162
) -> Self:
134163
"""
135164
Create and open the store.
@@ -144,6 +173,9 @@ async def open(
144173
Mode in which to create the store. This only affects opening the store,
145174
and the final read-only state of the store is controlled through the
146175
read_only parameter.
176+
tmp_dir : str or Path, optional
177+
Directory for the temporary files used by atomic writes. Must be on
178+
the same filesystem as root. Defaults to system temporary directory.
147179
148180
Returns
149181
-------
@@ -156,7 +188,7 @@ async def open(
156188
read_only_creation = mode in ["r", "r+"]
157189
else:
158190
read_only_creation = read_only
159-
store = cls(root, read_only=read_only_creation)
191+
store = cls(root, read_only=read_only_creation, tmp_dir=tmp_dir)
160192
await store._open()
161193

162194
# Set read_only state
@@ -226,7 +258,7 @@ def set_sync(self, key: str, value: Buffer) -> None:
226258
f"Got an instance of {type(value)} instead."
227259
)
228260
path = self.root / key
229-
_put(path, value)
261+
_put(path, value, self._resolve_tmp_dir())
230262

231263
def delete_sync(self, key: str) -> None:
232264
self._ensure_open_sync()
@@ -290,7 +322,7 @@ async def _set(self, key: str, value: Buffer, exclusive: bool = False) -> None:
290322
f"LocalStore.set(): `value` must be a Buffer instance. Got an instance of {type(value)} instead."
291323
)
292324
path = self.root / key
293-
await asyncio.to_thread(_put, path, value, exclusive=exclusive)
325+
await asyncio.to_thread(_put, path, value, self._resolve_tmp_dir(), exclusive=exclusive)
294326

295327
async def delete(self, key: str) -> None:
296328
"""

tests/test_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def test_config_defaults_set() -> None:
103103
},
104104
"buffer": "zarr.buffer.cpu.Buffer",
105105
"ndbuffer": "zarr.buffer.cpu.NDBuffer",
106+
"store": {"local": {"tmp_dir": None}},
106107
}
107108
]
108109
)

tests/test_store/test_local.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

3+
import errno
4+
import os
35
import pathlib
46
import re
7+
from tempfile import gettempdir
58

69
import numpy as np
710
import pytest
@@ -164,7 +167,7 @@ async def test_move(
164167
@pytest.mark.parametrize("exclusive", [True, False])
165168
def test_atomic_write_successful(tmp_path: pathlib.Path, exclusive: bool) -> None:
166169
path = tmp_path / "data"
167-
with _atomic_write(path, "wb", exclusive=exclusive) as f:
170+
with _atomic_write(path, "wb", tmp_path, exclusive=exclusive) as f:
168171
f.write(b"abc")
169172
assert path.read_bytes() == b"abc"
170173
assert list(path.parent.iterdir()) == [path] # no temp files
@@ -174,7 +177,7 @@ def test_atomic_write_successful(tmp_path: pathlib.Path, exclusive: bool) -> Non
174177
def test_atomic_write_incomplete(tmp_path: pathlib.Path, exclusive: bool) -> None:
175178
path = tmp_path / "data"
176179
with pytest.raises(RuntimeError): # noqa: PT012
177-
with _atomic_write(path, "wb", exclusive=exclusive) as f:
180+
with _atomic_write(path, "wb", tmp_path, exclusive=exclusive) as f:
178181
f.write(b"a")
179182
raise RuntimeError
180183
assert not path.exists()
@@ -186,7 +189,7 @@ def test_atomic_write_non_exclusive_preexisting(tmp_path: pathlib.Path) -> None:
186189
with path.open("wb") as f:
187190
f.write(b"xyz")
188191
assert path.read_bytes() == b"xyz"
189-
with _atomic_write(path, "wb", exclusive=False) as f:
192+
with _atomic_write(path, "wb", tmp_path, exclusive=False) as f:
190193
f.write(b"abc")
191194
assert path.read_bytes() == b"abc"
192195
assert list(path.parent.iterdir()) == [path] # no temp files
@@ -198,7 +201,45 @@ def test_atomic_write_exclusive_preexisting(tmp_path: pathlib.Path) -> None:
198201
f.write(b"xyz")
199202
assert path.read_bytes() == b"xyz"
200203
with pytest.raises(FileExistsError):
201-
with _atomic_write(path, "wb", exclusive=True) as f:
204+
with _atomic_write(path, "wb", tmp_path, exclusive=True) as f:
202205
f.write(b"abc")
203206
assert path.read_bytes() == b"xyz"
204207
assert list(path.parent.iterdir()) == [path] # no temp files
208+
209+
210+
def test_tmp_dir_arg(tmp_path: pathlib.Path) -> None:
211+
store = LocalStore(tmp_path, tmp_dir=tmp_path / "scratch")
212+
assert store._resolve_tmp_dir() == tmp_path / "scratch"
213+
214+
215+
def test_tmp_dir_from_config(tmp_path: pathlib.Path) -> None:
216+
with zarr.config.set({"store.local.tmp_dir": str(tmp_path / "cfg")}):
217+
store = LocalStore(tmp_path)
218+
assert store._resolve_tmp_dir() == tmp_path / "cfg"
219+
220+
221+
def test_tmp_dir_default(tmp_path: pathlib.Path) -> None:
222+
store = LocalStore(tmp_path)
223+
assert store._resolve_tmp_dir() == pathlib.Path(gettempdir())
224+
225+
226+
@pytest.mark.parametrize("exclusive", [True, False])
227+
def test_atomic_write_cross_device_raises(
228+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, exclusive: bool
229+
) -> None:
230+
def _raise_exdev(*args: object, **kwargs: object) -> None:
231+
raise OSError(errno.EXDEV, "Invalid cross-device link")
232+
233+
if exclusive:
234+
monkeypatch.setattr("zarr.storage._local._safe_move", _raise_exdev)
235+
else:
236+
monkeypatch.setattr(os, "replace", _raise_exdev)
237+
238+
path = tmp_path / "data"
239+
with pytest.raises(OSError, match="same filesystem") as excinfo:
240+
with _atomic_write(path, "wb", tmp_path, exclusive=exclusive) as f:
241+
f.write(b"abc")
242+
243+
assert excinfo.value.errno == errno.EXDEV # errno preserved
244+
assert not path.exists() # target never got created
245+
assert list(tmp_path.iterdir()) == [] # tmp cleans up

0 commit comments

Comments
 (0)