Skip to content

Commit 0ccd690

Browse files
Write spike clusters atomically to avoid truncation on crash (#60)
np.save() opens the destination for writing and truncates it immediately, so a process that dies, is killed, or fills the disk part way through save_spike_clusters() destroys the existing spike_clusters.npy and leaves an incomplete one in its place. That is unrecoverable loss of a manual curation session, as reported in cortex-lab/phy#1249. Write the array to a temporary file in the same directory as the destination, fsync it, and move it into place with os.replace(), which is atomic when both paths are on the same filesystem. If the write fails the temporary file is removed and the existing file is untouched. Co-authored-by: Cyrille Rossant <rossant@users.noreply.github.com>
1 parent 0f78527 commit 0ccd690

3 files changed

Lines changed: 66 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ 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+
- Cluster assignments are written atomically, so a crash during a save no longer truncates `spike_clusters.npy`.
1718
- TSV, JSON, text and `params.py` files are written atomically, so a crash during a save no longer
1819
truncates the file it was replacing, for instance `cluster_group.tsv`.
1920
- #57 a blank `dat_path` in `params.py` is now read as "no raw data file" instead of resolving to

‎phylib/io/model.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
get_ephys_reader, RandomEphysReader, extract_waveforms,
2525
get_spike_waveforms, export_waveforms)
2626
from phylib.utils import Bunch
27-
from phylib.utils._misc import _write_tsv_simple, read_tsv, read_python
27+
from phylib.utils._misc import _atomic_open, _write_tsv_simple, read_tsv, read_python
2828
from phylib.utils.geometry import linear_positions
2929

3030
logger = logging.getLogger(__name__)
@@ -62,6 +62,22 @@ def write_array(name, arr):
6262
np.save(name, arr)
6363

6464

65+
def _save_npy_atomic(path, arr):
66+
"""Save an array to a npy file without ever truncating the existing file.
67+
68+
The array is written to a temporary file in the same directory as the destination, and only
69+
then moved into place with `os.replace()`, which is atomic as long as both paths are on the
70+
same filesystem. If anything goes wrong while writing, the temporary file is removed and the
71+
existing file is left untouched.
72+
73+
"""
74+
# NOTE: np.save() appends `.npy` to a file *name* that lacks it, but not to an already-open
75+
# file object. The shared writer also preserves existing permissions and removes its hidden
76+
# sibling temporary file if np.save() raises.
77+
with _atomic_open(path, mode='wb') as f:
78+
np.save(f, arr)
79+
80+
6581
def from_sparse(data, cols, channel_ids):
6682
"""Convert a sparse structure into a dense one.
6783
@@ -1354,7 +1370,7 @@ def save_spike_clusters(self, spike_clusters):
13541370
"""Save the spike clusters."""
13551371
path = self._find_path('spike_clusters.npy', 'spikes.clusters.npy', multiple_ok=False)
13561372
logger.debug("Save spike clusters to `%s`.", path)
1357-
np.save(path, spike_clusters)
1373+
_save_npy_atomic(path, spike_clusters)
13581374

13591375
def save_spikes_subset_waveforms(self, max_n_spikes_per_template=None, max_n_channels=None,
13601376
sample2unit=1.):

‎phylib/io/tests/test_model.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@
77
#------------------------------------------------------------------------------
88

99
import logging
10+
import os
11+
import stat
1012

1113
import numpy as np
1214
from numpy.testing import assert_equal as ae
1315
from pytest import raises
1416

1517
# from phylib.utils import Bunch
1618
from phylib.utils.testing import captured_output
19+
from .. import model as model_module
1720
from ..model import from_sparse, get_template_params, load_model
1821

1922
logger = logging.getLogger(__name__)
@@ -135,6 +138,50 @@ def test_model_save(template_model_full):
135138
m.save_spike_clusters(m.spike_clusters)
136139

137140

141+
def test_model_save_spike_clusters_atomic(template_model, monkeypatch):
142+
# Regression test for cortex-lab/phy#1249: a save that dies half-way through must not
143+
# leave spike_clusters.npy truncated or empty.
144+
m = template_model
145+
path = m.dir_path / 'spike_clusters.npy'
146+
147+
# Happy path: a successful save round-trips and does not create `spike_clusters.npy.npy`.
148+
original = m.spike_clusters.copy()
149+
if os.name != 'nt':
150+
path.chmod(0o640)
151+
m.save_spike_clusters(original)
152+
assert not (m.dir_path / 'spike_clusters.npy.npy').exists()
153+
ae(np.load(path), original)
154+
if os.name != 'nt':
155+
# Shared lab datasets must not become private to the user who saved them.
156+
assert stat.S_IMODE(path.stat().st_mode) == 0o640
157+
158+
original_bytes = path.read_bytes()
159+
assert original_bytes
160+
files_before = sorted(p.name for p in m.dir_path.iterdir())
161+
162+
def _partial_save(file, arr, *args, **kwargs):
163+
# Mimic np.save() dying half-way through: whatever it was given is opened for writing
164+
# (which truncates it) and a few bytes are written before the failure.
165+
if hasattr(file, 'write'):
166+
file.write(b'\x93NUMPY')
167+
else:
168+
with open(str(file), 'wb') as f:
169+
f.write(b'\x93NUMPY')
170+
raise IOError("No space left on device")
171+
172+
monkeypatch.setattr(model_module.np, 'save', _partial_save)
173+
with raises(IOError):
174+
m.save_spike_clusters(original + 1)
175+
176+
# The original file must still be there, byte for byte.
177+
assert path.exists()
178+
assert path.read_bytes() == original_bytes
179+
ae(np.load(path), original)
180+
181+
# No temporary file must be left behind.
182+
assert sorted(p.name for p in m.dir_path.iterdir()) == files_before
183+
184+
138185
def _set_dat_path(params_path, value):
139186
"""Replace the `dat_path` assignment of a `params.py` file by a literal value."""
140187
lines = params_path.read_text().splitlines()

0 commit comments

Comments
 (0)