Skip to content

A crash partway through an append stopped the metaserver coming back - #527

Open
bjmeetsfo wants to merge 3 commits into
mainfrom
oss/a-torn-last-record-does-not-stop-startup
Open

A crash partway through an append stopped the metaserver coming back#527
bjmeetsfo wants to merge 3 commits into
mainfrom
oss/a-torn-last-record-does-not-stop-startup

Conversation

@bjmeetsfo

Copy link
Copy Markdown
Collaborator

The metadata log is append-only text, one record per line. A process that dies partway through writing one leaves a partial line at the end. That is what a crash looks like, not corruption.

Reading the log refused the whole file on any line that did not parse:

let record = serde_json::from_str::<MetaMutationRecord>(&line).map_err(io::Error::other)?;

and starting a metaserver passes that straight up (with_mutation_log -> log.load()? -> .transpose()? in the backend). So the process failed to start. A metaserver whose metadata was entirely durable except for a fraction of the last record would not come up at all.

Why dropping the last record is safe

It was never acknowledged. A writer only returns once a sync covering its bytes has completed, so nothing was ever promised to anybody about the record still being written. Dropping it loses nothing that was reported durable, and everything before it is kept.

Why the middle is different

A line that does not parse with records after it is not a torn tail. Those later records were acknowledged, and stopping at the damage would discard them silently. That is still refused, and the error says why it is not simply a crash:

meta mutation log <path>: line 2 does not parse and 1 more line(s) follow it,
so it is not a torn tail: <parse error>

The distinction is the whole point: the tail is expected, the middle is not.

Not silent either way

The dropped partial record is logged with the file, the line number, how many records were recovered, and what failed to parse. A metaserver that came back one record short says so.

Testing

  • A log cut off mid-record brings back every shard and server that was acknowledged before the crash.
  • A log damaged in the middle is refused rather than half-read, and the error names why.

Both were checked against a mutation:

mutation which test fails
refuse any bad line (the old behaviour) the torn-tail test, on "a torn last record must not stop the metaserver starting"
skip any bad line wherever it sits the middle-damage test, on "damage with acknowledged records after it must be refused"

Suites: 328 metadata tests, 47 metaserver binary tests, cargo check --all-targets clean.

The metadata log is append-only text, one record per line. A process that dies
partway through writing one leaves a partial line at the end. That is what a
crash looks like, not corruption.

Reading the log refused the whole file on any line that did not parse, and
starting a metaserver passes that straight up, so the process failed to start. A
metaserver whose metadata was entirely durable except for a fraction of the last
record would not come up at all.

A partial last record was never acknowledged. A writer only returns once a sync
covering its bytes has completed, so nothing was ever promised to anybody about
the record still being written. Dropping it loses nothing that was reported
durable, and everything before it is kept.

A line that does not parse with records AFTER it is a different thing. Those
later records were acknowledged, and stopping at the damage would discard them
silently. That is still refused, and the error says why it is not simply a
crash.

The partial record is not dropped quietly either: it is logged with the file,
the line, how many records were recovered, and what failed to parse.

Two tests: a log cut off mid-record brings every acknowledged shard and server
back, and a log damaged in the middle is refused rather than half-read.

Both were checked against a mutation. Refusing any bad line -- the old
behaviour -- fails the first. Skipping any bad line wherever it sits fails the
second. Each fails with the message that names what went wrong.
@bjmeetsfo
bjmeetsfo requested a review from superhaiou as a code owner August 31, 2026 17:33
Dropping the partial record on read lets the metaserver start, but leaves the
fragment in the file. Appends open for append and write at the end -- so the
next record is written immediately after those bytes, with no newline between
them, and the fragment is spliced onto the front of it:

    {"at_ms":9,"mutation":{"RegisterSha{"at_ms":178...,"kind":"register_shard"...

That line no longer parses. It is also the LAST line, so the next restart reads
it as a torn tail and drops it -- and the record it swallowed was acknowledged.
The writer had been told it was durable.

So skipping the fragment on its own turned a loud refusal into the silent loss
of a write that was promised. Measured before this change: register a shard
after recovering, restart, and the shard is gone while the metaserver starts
happily.

Recovery now truncates the file to the end of the last record that parsed, and
syncs that -- the cut has to survive the crash that follows it, or the fragment
comes back and the next append splices onto it again. The following append then
starts on a record boundary.

Test: a shard registered after recovering from a crash is still there after the
next restart, and every line of the recovered log parses, so nothing is left
waiting to swallow a record.

Checked against a mutation: leave the fragment in place -- which is what the
first version of this did -- and the test fails, saying the acknowledged write
was lost.
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.

2 participants