Retry data reader init when retention deletes an index file - #234
Open
lukebakken wants to merge 1 commit into
Open
Retry data reader init when retention deletes an index file#234lukebakken wants to merge 1 commit into
lukebakken wants to merge 1 commit into
Conversation
osiris_replica_reader:init/1 crashes with {bad_return_value,missing_file}
when the index file it needs is deleted by retention in the window
between the reader being asked to start and osiris_log:init_data_reader/2
opening that file. offset_idx_scan/3 opens the index through open/2,
which throws missing_file on enoent, and nothing on the data-reader path
catches it, so the throw escapes init/1 and gen_server reports a crash.
The reader is temporary, so the coordinator restarts it and the cluster
recovers, but every occurrence logs a full crasher report at [error].
Wrap init_data_reader/2 in a three-attempt retry that catches
missing_file, mirroring the existing init_offset_reader/3, and handle the
resulting {error, retries_exhausted} in osiris_replica_reader:init/1 as a
clean {stop, normal} like the sibling error clauses. The index files are
listed fresh on each attempt, so a retry sees the post-retention state.
Also close the index fd on every exit path in last_valid_idx_record/1.
It opens the fd and then calls file_size/1, which can throw missing_file
for the same retention reason; previously the throw crashed the process
and the fd was reclaimed, but now that init_data_reader catches
missing_file the process survives and the fd would leak, up to once per
retry and permanently if a later attempt succeeds. A try/after closes it
regardless of how the body exits.
|
Tick the box to add this pull request to the merge queue (same as
|
This was referenced Aug 12, 2026
Open
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.
Note
This PR was prepared by Claude (Anthropic's Claude Code) under the direction of @lukebakken, who reviewed the change before opening it. The fix was surfaced by a long-running high-throughput test. The code and analysis are AI-drafted and human-reviewed.
Problem
A 2-hour high-throughput test crashed
osiris_replica_readerwith{bad_return_value,missing_file}when the index file the reader needs is deleted by retention in the window between the reader being asked to start andosiris_log:init_data_reader/2opening that file.init_data_reader/2(viainit_data_reader_from/3->offset_idx_scan/3) opens the index file through the localopen/2helper, which throwsmissing_fileonenoent. Nothing on the data-reader path catches it, so the throw escapesinit/1andgen_serverturns a callback that neither returns a valid tuple nor exits cleanly into{bad_return_value,missing_file}. The reader istemporary, so the stream coordinator restarts it and the cluster recovers with no message loss, but every occurrence logs a full crasher report at[error], indistinguishable from a real fault to log-based monitoring.missing_fileis already treated as an expected, retention-driven condition everywhere else it can occur:init_offset_reader/3andresolve_offset_spec/3both wrap their work intry ... catch missing_file -> retry(with the comment "Retention policies are likely being applied, let's try again"), andosiris_replica:init/1handles amissing_filereturn from the writer as a clean stop. Only the data-reader path was unguarded.Full analysis in #233.
Solution
Wrap
init_data_reader/2in a three-attempt retry that catchesmissing_file, mirroring the existinginit_offset_reader/3, and handle the resulting{error, retries_exhausted}inosiris_replica_reader:init/1as a clean{stop, normal}, like the sibling error clauses.init_data_readerlists the index files fresh on each attempt (sorted_index_files(Dir)), so a retry sees the post-retention directory state.Also close the index fd on every exit path in
last_valid_idx_record/1. It opens the fd and then callsfile_size/1, which can throwmissing_filefor the same retention reason. Previously that throw crashed the process and the fd was reclaimed by the runtime; now thatinit_data_readercatchesmissing_file, the process survives, so the un-closed fd would leak, up to once per retry and permanently if a later attempt succeeds. Atry ... after file:close/1closes it regardless of how the body exits. This also fixes the latent version of the same leak reachable frominit_offset_reader, which already catches the throw.This is the same class of benign, race-driven
osiris_replica_readercrash as #230 (fixed by #231): one code path handles the condition gracefully and a sibling path does not. Both were surfaced by the same high-throughput test, both leave the cluster healthy via the reader'stemporaryrestart, and both are noisy only in the logs. This fix is independent of #231 and was verified on a run with the #231 sendfile guard already applied.On testing
I have not added a test. The crash is an inherent TOCTOU: to fire the real
throw(missing_file)the index file must be listed bysorted_index_files/1and then gone whenopen/2reads it within one synchronous call, so it is not reproducible on demand without mocking the file layer (andmeckis not currently a test dependency of this repo). The retry it mirrors,init_offset_reader/3, ships without a dedicated test for the same reason. Happy to add a mock-based test if you would prefer one.Two related points I considered and deliberately left alone, noted here so they are visible:
index_fileskey fromConfigthe wayinit_offset_reader/3does withmaps:remove(index_files, Conf). It does not need to, becauseinit_data_reader0lists viasorted_index_files(Dir)(the directory), notsorted_index_files(Config), so each attempt already rescans the directory. If a future change routes the reader context through the config-aware variant, this retry should adopt the samemaps:remove/2.init_offset_reader/3(which carries a standingTODOabout limiting retries); I kept the two consistent rather than diverging here.Closes #233