Write spike clusters atomically so a crash cannot truncate spike_clusters.npy - #60
Merged
rossant merged 2 commits intoAug 9, 2026
Conversation
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.
adityasingh2400
force-pushed
the
fix-1249-atomic-save-spike-clusters
branch
from
August 9, 2026 11:02
551b5a8 to
6cf9e98
Compare
rossant
approved these changes
Aug 9, 2026
rossant
left a comment
Contributor
There was a problem hiding this comment.
Reviewed after merging #61 and #62. spike_clusters.npy now uses the shared atomic writer, preserves existing group-readable permissions, leaves the old assignments byte-identical on a simulated partial np.save failure, and cleans up temporary files. Focused tests and the full 280-test suite pass locally; changed files add no lint errors beyond the six pre-existing model.py findings.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This fixes cortex-lab/phy#1249. The issue was filed on phy, but the code responsible lives here, in
TemplateModel.save_spike_clusters().The reporter lost a nearly complete manual curation session. phy crashed during a save and
spike_clusters.npywas left empty. Every other.npyand.tsvfile in the directory was intact, so the only thing actually lost was the one file holding the cluster assignments.The cause is that
save_spike_clusters()callednp.save(path, spike_clusters)directly on the destination.np.saveopens that path for writing, which truncates it to zero length before a single byte of the new array is written. Between that truncation and the last write there is a window where the old assignments are already gone and the new ones are not yet on disk. Anything that interrupts the process in that window, a crash, a kill, a full disk, leaves the file empty or half written, and the previous contents are unrecoverable. The window is proportional to the size of the array, so it is widest exactly on the long curation sessions where the loss hurts most.The fix writes the array to a temporary file in the same directory as the destination, fsyncs it so the bytes are really on disk, and then moves it into place with
os.replace().os.replace()is atomic on POSIX and on Windows when both paths are on the same filesystem, which is why the temporary file is created in the destination's own directory rather than in the system temp directory. A reader therefore sees either the complete old file or the complete new one, never a partial one. If anything raises during the write, the temporary file is removed and the existing file is left exactly as it was.Two small details in the helper that are easy to miss on review. The temporary file is deliberately not named
*.npy, because a leftover*.npyfile in a phy data directory would be picked up by thespike_*.npyglob in_load_spike_attributes(). Andnp.saveis handed an already open file object rather than a filename, sincenp.saveappends.npyto a filename that lacks the extension but never to an open file object.The regression test replaces
np.savewith a stand in that opens whatever it is given for writing, writes a partial header, and then raises, which is what a full disk looks like from the caller's point of view. It asserts that the exception propagates, thatspike_clusters.npystill holds its original bytes, and that no temporary file is left behind. It also covers the happy path round trip. On master the test fails with the destination truncated to the 6 bytes written before the failure, which is the reported loss reproduced exactly.Full suite passes per the README instructions,
pytest phylibgives 274 passed. A CHANGELOG entry is added under Unreleased.flake8reports 7 errors inphylib/io/model.py, all confirmed pre-existing onorigin/masterat the same lines offset by the added line count, so this adds no new lint errors.One piece of follow up left out on purpose. Cluster metadata is written by
_write_tsv_simple()inphylib/utils/_misc.pyand has the same truncate on open exposure, andcluster_group.tsvis real curation data. Making that atomic needs a text mode context manager rather than this helper, in a different module, next towrite_tsv()andsave_json()andwrite_text()which all share the pattern. That felt wider than belongs in a fix for this issue, so I have left it and am happy to do it separately if you want it.Disclosure: this change was prepared with AI assistance. I have reviewed and tested it.