Skip to content

Commit 0f78527

Browse files
Write TSV, JSON and text files atomically (#62)
save_json(), write_python(), write_text(), write_tsv() and _write_tsv_simple() all opened the destination in 'w' mode, which truncates it before a single byte of the new content is written. A crash, a full disk or a kill in that window leaves the file empty or half written and the previous contents are gone. cluster_group.tsv is the one that hurts, since it holds manual curation labels that cannot be recomputed. Add _atomic_open(), a context manager that writes to a temporary file in the destination directory and moves it into place with os.replace() on a clean exit, and route all five writers through it. The temporary file is given the permissions of the file it replaces, or the permissions open() would have used for a new file, since tempfile creates its files with 0600. Co-authored-by: Cyrille Rossant <rossant@users.noreply.github.com>
1 parent 97118ca commit 0f78527

3 files changed

Lines changed: 167 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Development checkouts now report a development version with a Git commit suffix.
1515
- Template datasets without waveform templates can be reloaded after cluster assignments change,
1616
and stored spike-waveform subsets provide their waveform sample count.
17+
- TSV, JSON, text and `params.py` files are written atomically, so a crash during a save no longer
18+
truncates the file it was replacing, for instance `cluster_group.tsv`.
1719
- #57 a blank `dat_path` in `params.py` is now read as "no raw data file" instead of resolving to
1820
the dataset directory.
1921

‎phylib/utils/_misc.py‎

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
#------------------------------------------------------------------------------
99

1010
import base64
11+
from contextlib import contextmanager
1112
import csv
1213
from importlib import import_module
1314
import json
1415
import logging
1516
import os
1617
from pathlib import Path
18+
import secrets
19+
import stat
1720
import subprocess
1821
from textwrap import dedent
1922

@@ -24,6 +27,62 @@
2427
logger = logging.getLogger(__name__)
2528

2629

30+
#------------------------------------------------------------------------------
31+
# Atomic file writing
32+
#------------------------------------------------------------------------------
33+
34+
def _copy_file_mode(src, dst):
35+
"""Give `src` the permissions of `dst` when `dst` exists."""
36+
try:
37+
mode = stat.S_IMODE(os.stat(dst).st_mode)
38+
except FileNotFoundError:
39+
# The exclusive open used for a new temporary file already applied the process umask.
40+
return
41+
os.chmod(src, mode)
42+
43+
44+
@contextmanager
45+
def _atomic_open(path, mode='w', **kwargs):
46+
"""Open a file for writing without ever truncating the file it replaces.
47+
48+
The contents are written to a temporary file in the same directory as the destination, and
49+
that file is moved into place with `os.replace()` once the `with` block completes, which is
50+
atomic as long as both paths are on the same filesystem. A reader therefore sees either the
51+
old file or the new one, never a half-written one. If the block raises, the temporary file
52+
is removed and any existing file is left untouched.
53+
54+
"""
55+
path = Path(path)
56+
ensure_dir_exists(path.parent)
57+
if not mode.startswith('w'):
58+
raise ValueError("_atomic_open() only supports write modes")
59+
exclusive_mode = 'x' + mode[1:]
60+
# NOTE: the temporary file is hidden and does not end in a data file extension, so that a
61+
# file left behind by a hard kill is not picked up by the globs used to discover datasets.
62+
# Opening it with `x` both prevents a name collision and lets the OS apply the current umask,
63+
# without temporarily changing that process-global setting.
64+
while True:
65+
tmp = path.parent / ('.%s.%s.tmp' % (path.name, secrets.token_hex(16)))
66+
try:
67+
f = open(tmp, exclusive_mode, **kwargs)
68+
break
69+
except FileExistsError: # pragma: no cover - cryptographically unlikely
70+
continue
71+
try:
72+
with f:
73+
yield f
74+
f.flush()
75+
os.fsync(f.fileno())
76+
_copy_file_mode(tmp, path)
77+
os.replace(tmp, path)
78+
except BaseException:
79+
try:
80+
os.remove(tmp)
81+
except OSError: # pragma: no cover
82+
pass
83+
raise
84+
85+
2786
#------------------------------------------------------------------------------
2887
# JSON utility functions
2988
#------------------------------------------------------------------------------
@@ -128,9 +187,7 @@ def save_json(path, data):
128187
"""
129188
assert isinstance(data, dict)
130189
data = _stringify_keys(data)
131-
path = Path(path)
132-
ensure_dir_exists(path.parent)
133-
with path.open('w') as f:
190+
with _atomic_open(path) as f:
134191
json.dump(data, f, cls=_CustomEncoder, indent=2, sort_keys=True)
135192

136193

@@ -190,7 +247,7 @@ def write_python(path, data):
190247
-------
191248
192249
"""
193-
with open(path, 'w') as f:
250+
with _atomic_open(path) as f:
194251
for k, v in data.items():
195252
if isinstance(v, str):
196253
v = '"%s"' % v
@@ -206,9 +263,8 @@ def read_text(path):
206263
def write_text(path, contents):
207264
"""Write a text file."""
208265
contents = dedent(contents)
209-
path = Path(path)
210-
ensure_dir_exists(path.parent)
211-
path.write_text(contents)
266+
with _atomic_open(path) as f:
267+
f.write(contents)
212268

213269

214270
def _try_make_number(value):
@@ -266,9 +322,8 @@ def write_tsv(path, data, first_field=None, exclude_fields=(), n_significant_fig
266322
267323
"""
268324
path = Path(path)
269-
ensure_dir_exists(path.parent)
270325
delimiter = '\t' if path.suffix == '.tsv' else ','
271-
with path.open('w', newline='') as f:
326+
with _atomic_open(path, newline='') as f:
272327
if not data:
273328
logger.info("Data was empty when writing %s.", path)
274329
return
@@ -327,9 +382,8 @@ def _write_tsv_simple(path, field_name, data):
327382
328383
"""
329384
path = Path(path)
330-
ensure_dir_exists(path.parent)
331385
delimiter = '\t' if path.suffix == '.tsv' else ','
332-
with path.open('w', newline='') as f:
386+
with _atomic_open(path, newline='') as f:
333387
writer = csv.writer(f, delimiter=delimiter)
334388
writer.writerow(['cluster_id', field_name])
335389
writer.writerows([(cluster_id, data[cluster_id]) for cluster_id in sorted(data)])

‎phylib/utils/tests/test_misc.py‎

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@
66
# Imports
77
#------------------------------------------------------------------------------
88

9+
import csv
10+
import json
11+
import os
912
import re
13+
import stat
1014

1115
import numpy as np
1216
from numpy.testing import assert_array_equal as ae
1317
from pytest import raises, mark
1418

1519
from .._misc import (
16-
_git_version, load_json, save_json, load_pickle, save_pickle, read_python, write_python,
17-
read_text, write_text, _read_tsv_simple, _write_tsv_simple, read_tsv, write_tsv,
20+
_atomic_open, _git_version, load_json, save_json, load_pickle, save_pickle, read_python,
21+
write_python, read_text, write_text, _read_tsv_simple, _write_tsv_simple, read_tsv, write_tsv,
1822
_pretty_floats, _encode_qbytearray, _decode_qbytearray, _fullname, _load_from_fullname)
1923

2024

@@ -137,6 +141,100 @@ def test_write_tsv(tempdir):
137141
assert read_tsv(path) == data
138142

139143

144+
class _Boom(Exception):
145+
pass
146+
147+
148+
def test_atomic_open(tempdir):
149+
path = tempdir / 'test_dir/test.txt'
150+
151+
with _atomic_open(path) as f:
152+
f.write('hello')
153+
assert read_text(path) == 'hello'
154+
# The temporary file must not be left behind.
155+
assert sorted(p.name for p in path.parent.iterdir()) == ['test.txt']
156+
157+
# A new file gets the permissions open() would have given it, without reading or changing
158+
# the process-global umask. Compare it with an ordinary file created in the same directory.
159+
control = path.parent / 'control.txt'
160+
control.write_text('control')
161+
assert stat.S_IMODE(path.stat().st_mode) == stat.S_IMODE(control.stat().st_mode)
162+
control.unlink()
163+
164+
# The permissions of an existing file are preserved.
165+
expected_mode = stat.S_IMODE(path.stat().st_mode)
166+
if os.name != 'nt':
167+
path.chmod(0o640)
168+
expected_mode = 0o640
169+
with _atomic_open(path) as f:
170+
f.write('world')
171+
assert read_text(path) == 'world'
172+
assert stat.S_IMODE(path.stat().st_mode) == expected_mode
173+
174+
# A failure half-way through leaves the previous file untouched and no leftovers.
175+
with raises(_Boom):
176+
with _atomic_open(path) as f:
177+
f.write('this should never be visible')
178+
raise _Boom()
179+
assert read_text(path) == 'world'
180+
assert sorted(p.name for p in path.parent.iterdir()) == ['test.txt']
181+
182+
# Binary writes use the same atomic and cleanup path, ready for numpy saves.
183+
with _atomic_open(path, mode='wb') as f:
184+
f.write(b'bytes')
185+
assert path.read_bytes() == b'bytes'
186+
187+
188+
@mark.parametrize('writer', ['write_tsv', '_write_tsv_simple'])
189+
def test_write_tsv_atomic(tempdir, monkeypatch, writer):
190+
# Curation data such as cluster_group.tsv must survive a crash during a save.
191+
path = tempdir / 'cluster_group.tsv'
192+
if writer == 'write_tsv':
193+
def _write(value):
194+
write_tsv(path, [{'cluster_id': 2, 'group': value}], first_field='cluster_id')
195+
else:
196+
def _write(value):
197+
_write_tsv_simple(path, 'group', {2: value})
198+
199+
_write('good')
200+
before = path.read_bytes()
201+
assert before
202+
files_before = sorted(p.name for p in tempdir.iterdir())
203+
204+
real_writer = csv.writer
205+
206+
def _failing_writer(f, **kwargs):
207+
# Mimic a crash after part of the file has been written.
208+
real_writer(f, **kwargs).writerow(['cluster_id', 'group'])
209+
raise _Boom()
210+
211+
monkeypatch.setattr(csv, 'writer', _failing_writer)
212+
with raises(_Boom):
213+
_write('mua')
214+
215+
assert path.read_bytes() == before
216+
assert sorted(p.name for p in tempdir.iterdir()) == files_before
217+
218+
219+
def test_save_json_atomic(tempdir, monkeypatch):
220+
path = tempdir / 'test.json'
221+
save_json(path, {'a': 1})
222+
before = path.read_bytes()
223+
assert before
224+
files_before = sorted(p.name for p in tempdir.iterdir())
225+
226+
def _failing_dump(data, f, **kwargs):
227+
f.write('{"a":')
228+
raise _Boom()
229+
230+
monkeypatch.setattr(json, 'dump', _failing_dump)
231+
with raises(_Boom):
232+
save_json(path, {'a': 2})
233+
234+
assert path.read_bytes() == before
235+
assert sorted(p.name for p in tempdir.iterdir()) == files_before
236+
237+
140238
def test_git_version():
141239
v = _git_version()
142240
assert re.match(r'^\+git\.[0-9a-f]{8}(?:\.dirty)?$', v)

0 commit comments

Comments
 (0)