Skip to content

Replace VERSION instead of truncating it - #851

Merged
sehkone merged 1 commit into
mainfrom
sehkone/issue-849
Aug 12, 2026
Merged

Replace VERSION instead of truncating it#851
sehkone merged 1 commit into
mainfrom
sehkone/issue-849

Conversation

@sehkone

@sehkone sehkone commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #849.

create_version_file truncated VERSION in place and flushed nothing, against the atomic-write rule in AGENTS.md. It now writes VERSION.tmp in the same directory, sync_alls it, renames it over VERSION, and sync_alls the containing directory.

What this is and is not worth

The exposure is narrow, and the change is sized to match. Only two of the three call sites can truncate anything — retrieve_or_create_version writes into a directory it has just found empty — and those two run once per deployment per release that changes the database format. A process crash cannot produce the bad state either, since the truncate and the write both sit in the page cache; it takes a power loss or a kernel panic landing in a window of microseconds.

What justifies the twenty lines is the cost when it does happen: an empty VERSION fails Version::parse on the next start, and there is no automated way back from that — someone edits a file in a production data directory while the database will not open.

Decisions worth reviewing

  • Fixed temporary name (VERSION.tmp) rather than the timestamped name ClassifierFs::store_classifier uses. migrate_data_dir runs once at startup with exclusive access, so there is no concurrent writer to collide with, and a fixed name bounds what an interrupted write can leave behind to one file the next start recognizes.
  • No explicit mode, unlike store_classifier's 0o600. VERSION records a format version, not a secret. Since rename installs the temporary's inode, File::create keeps exactly the umask-derived permissions VERSION has today — so nothing an operator can observe changes, and no CHANGELOG entry is due. (The entry Set the classifier temp file's mode and flush it before rename #844 added was for the permission change it made, not for the flush.)
  • Both flushes included. At most four round trips per migration, on a path that has just rewritten the whole database. File::open(dir) followed by sync_all was verified to work on macOS as well as Linux before being relied on.
  • retrieve_or_create_version forgives only VERSION.tmp when testing whether a data directory is empty. A leftover from an interrupted first write would otherwise make a fresh directory look populated, so VERSION would never be created and the start would fail on "cannot open VERSION" instead. The test was not replaced with a check for VERSION's existence: a populated directory without VERSION stays an error, because creating one there would stamp the current version onto an existing tree and skip its migration. populated_dir_without_version_is_rejected pins that.

Deliberately not addressed

Both are named in the issue; neither is created or worsened here.

  • The migration is still not atomic with the VERSION write. Event migrations commit in batches, so a crash partway through leaves partially migrated data under the old version — the same re-run condition, reached without any help from this bug.
  • backup and data are two files in two directories. A crash between the two writes leaves them disagreeing and the next start stops on "mismatched database version". Per-file atomicity cannot close that.

Test plan

  • cargo fmt -- --check --config group_imports=StdExternalCrate
  • cargo clippy --bins --tests --all-features -- -D warnings
  • cargo test --all-features — 510 passed, 9 ignored (pre-existing), 0 failed
  • New: version_file_is_replaced, stale_version_temp_does_not_block_creation, populated_dir_without_version_is_rejected

No test here proves crash safety. The ordering the flushes buy cannot be observed without fault injection, which is out of proportion to this change; the tests cover the states that are reachable, and the doc comment on version_file_is_replaced says so rather than letting the name imply more.

`create_version_file` opened VERSION with `File::create`, so the
previous contents were gone before the new ones were written, and
nothing was flushed afterwards. The two writes that matter run once a
migration chain has already rewritten the database, and the next start
parses this file to decide whether to migrate again. A power loss
around the write leaves it empty or partial, and the database then
fails to open on an error naming nothing but the symptom, with no way
back except editing a file in the data directory by hand.

Staging through a temporary in the same directory and renaming makes
the replacement atomic. Flushing the temporary and then the directory
keeps the rename from reaching disk ahead of the bytes it publishes,
which is what would otherwise leave a migrated database recorded as the
version it held before the migration. The temporary asks for no
particular mode, so VERSION keeps the umask-derived permissions it has
always had.

The temporary's name is fixed rather than unique because
`migrate_data_dir` runs once at startup with exclusive access to the
directory, so nothing can collide with it, and a single recognizable
leftover is one `retrieve_or_create_version` can forgive when deciding
whether a data directory is empty. Without that, an interrupted first
write would make a fresh directory look populated and VERSION would
never be created at all.

Closes #849
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.36170% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.70%. Comparing base (3625a53) to head (8a5286b).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
src/migration.rs 89.36% 5 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #851   +/-   ##
=======================================
  Coverage   82.69%   82.70%           
=======================================
  Files          88       88           
  Lines       33042    33083   +41     
=======================================
+ Hits        27325    27361   +36     
- Misses       5717     5722    +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sehkone
sehkone merged commit 7c99229 into main Aug 12, 2026
10 checks passed
@sehkone
sehkone deleted the sehkone/issue-849 branch August 12, 2026 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VERSION is truncated in place, and it is what a restart reads

1 participant