numinnex commented on code in PR #3946:
URL: https://github.com/apache/iggy/pull/3946#discussion_r3830604750
##########
core/server/src/segment_recovery.rs:
##########
@@ -92,53 +134,99 @@ pub async fn load_persisted_segments(
.system
.get_index_path(stream_id, topic_id, partition_id,
start_offset);
- let messages_size = file_len(&messages_path);
- let index_size = file_len(&index_path);
+ let raw_messages_size = file_len(&messages_path)?;
let bounds = recover_segment_bounds(
+ identity,
&index_path,
&messages_path,
start_offset,
- messages_size,
- stream_id,
- topic_id,
- partition_id,
+ raw_messages_size,
+ &mut scratch,
)
.await?;
- // `bounds == None` now means the log holds no whole BATCH either (the
- // index-less path above already tried walking the log), so there is
- // nothing to recover: zeroed sizes make the next append overwrite the
- // torn bytes, where counting them with `end_offset == start_offset`
- // would fabricate one phantom message for the bootstrap non-empty
- // filters and strand undecodable garbage inside the readable range.
- // Note this is NOT tail-only -- a torn index is reachable mid-chain on
- // the shipped `enforce_fsync = false`, which is why the walk above
- // exists rather than refusing the partition.
- let (start_timestamp, end_timestamp, end_offset,
effective_messages_size) =
- if let Some((start_timestamp, end_timestamp, end_offset,
walked_size)) = bounds {
- (start_timestamp, end_timestamp, end_offset, walked_size)
- } else {
- if messages_size > 0 {
- warn!(
- stream_id,
- topic_id,
- partition_id,
- start_offset,
- messages_size,
- "segment log holds bytes but its index holds no whole \
- entry (torn write); recovering the segment as empty"
- );
- }
- (0, 0, start_offset, 0)
- };
- let effective_index_size = if bounds.is_some() { index_size } else { 0
};
+ // `bounds == None` means the log holds no whole batch ANYWHERE: the
+ // index-less walk tried from byte 0 and the damage probe found no
+ // surviving batch deeper in the file. There is nothing to recover:
+ // zeroed sizes make the next append overwrite the torn bytes, where
+ // counting them with `end_offset == start_offset` would fabricate one
+ // phantom message for the bootstrap non-empty filters and strand
+ // undecodable garbage inside the readable range. Note this is NOT
+ // tail-only -- a torn index is reachable mid-chain on the shipped
+ // `enforce_fsync = false`, which is why the walk exists rather than
+ // refusing the partition.
+ let bounds = bounds.unwrap_or_else(|| {
Review Comment:
**Blocker: this arm now physically destroys the only durable copy of the
data.**
`set_len` and `truncate` appear nowhere in master's `segment_recovery.rs` —
master set the effective sizes to 0 and the bytes stayed on disk, so an
operator could still extract batches from the file. With pass C truncating to
these bounds, recover-as-empty becomes `set_len(0)` on both files with no
quarantine copy. That makes it the only "cannot interpret these bytes" verdict
in the module that destroys rather than preserves; every other one fences and
keeps the files byte-identical.
That asymmetry matters more than it might look:
- The partition journal is memory-only, so the `.log` is a node's only
durable copy.
- `server_error.rs` tells operators to "repair the quarantined files
offline" — files this arm has already deleted.
- The `warn!` in `truncate_to` says "discarding torn tail bytes" while this
route deletes the entire log, so the log line understates the event an operator
would need to page on.
- Any future batch-layout or checksum change makes existing segments fail to
decode, and a single-segment partition then loses its whole log on first boot.
Renaming aside into the existing `.fenced.N` scheme instead of truncating is
a few lines, and it is also *less* boot I/O than `set_len` plus `sync_all`.
##########
core/common/src/error/iggy_error.rs:
##########
@@ -425,6 +430,11 @@ pub enum IggyError {
InvalidOptionValue(String) = 4042,
#[error("Options block exceeds its limits: {0}")]
OptionsBlockTooLarge(String) = 4043,
+ /// The on-disk segment file length disagrees with the recovered bounds the
+ /// writer was seeded with; appending would corrupt the segment, so the
+ /// open fails instead. Field order: `(on_disk, expected)`.
+ #[error("Segment file size on disk: {0} does not match expected size:
{1}")]
+ SegmentSizeMismatchAtOpen(u64, u64) = 4044,
Review Comment:
**Blocker, and the only item in my review that cannot be corrected after
release.**
I could not find any in-tree path that trips `SegmentSizeMismatchAtOpen`:
- Pass C truncates the file to exactly the size it then hands
`SegmentStorage::new`, so the comparison there cannot fail.
- `hydrate_partition_log` reopens the same paths against the same
`Rc<AtomicU64>` obtained via `size_counter()`, with nothing in between that
mutates a segment, and preallocation uses `FALLOC_FL_KEEP_SIZE` so it cannot
change the logical length. That makes `hydrate_reopen_error`'s mismatch arm
unreachable.
- `state_transfer.rs:2267` matches by construction (`meta.size == entry.len
== bytes.len()`, and `File::create` truncates the staging file, so no oversized
abandoned attempt survives into the rename).
The guard itself is worth keeping — compare-and-refuse is the actual fix for
the clobber, and an assertion on a destructive path earns its keep even when
nothing in tree trips it. What I would push back on is the **discriminant
position**, because that is the part that can never be reclaimed: 4044 is
mirrored into two *generated* SDK tables and Go's is a typed error matched by
`errors.Is`, and by the analysis above this condition cannot cross the wire.
Suggestion: move the variant to 4102 (4101 is `InvalidReservedField`, so
that satisfies the previous mechanical "above the highest code in its range"
rule), regenerate `errors_gen.go`, update `error.code.ts`, and restore the
previous allocation comment. If the guard does not need to be wire-visible at
all, a `debug_assert!` plus a node-fatal generic I/O error would give the same
protection without spending a discriminant.
For the record, the SDK mechanics here are correct: Go and Node are the only
complete mirrors, `errors_gen.go` regenerates byte-identical from
`foreign/go/errors/generator`, skipping Java/C#/Python/C++ matches how prior
codes were added, and 4044 was never previously shipped or retired. My
objection is only to spending the slot.
##########
core/common/src/error/iggy_error.rs:
##########
@@ -22,11 +22,16 @@ use std::sync::Arc;
use strum::{EnumDiscriminants, FromRepr, IntoStaticStr};
use thiserror::Error;
-// A gap in the discriminants is a RETIRED code, not free space. Shipped SDKs
-// keep their own code tables (foreign/go/errors/errors.yaml,
-// foreign/node/src/wire/error.code.ts) that still map the old meaning, and
-// Go's is a typed error matched by errors.Is, so refilling a gap reroutes
-// caller control flow. Allocate above the highest code in its range.
+// Codes are allocated per semantic family: a new code goes one above its
Review Comment:
This rewritten rule does not hold up against the file it governs, and it is
what licenses placing the new code at 4044.
The worked example is wrong in both halves: 4041-4043 are topic-**option**
validation (`UnsupportedOptionKey`, `InvalidOptionValue`,
`OptionsBlockTooLarge`), not "extended message validation", and 4044 is segment
storage, so it is not "one above its family's highest code" for any family
present here. More structurally, the enum carries no family markers at all — no
section comments, no documented ranges — so a rule that requires knowing family
boundaries needs boundaries the file does not record.
The practical risk is the last clause: "the headroom below that base belongs
to the family under it" licenses a future contributor to refill 4045-4049,
which is exactly what the previous wording existed to prevent, and Go's table
matches by `errors.Is` so a refilled gap reroutes caller control flow.
The old rule ("Allocate above the highest code in its range") was mechanical
and checkable. I would restore it and take 4102.
##########
core/server/src/bootstrap.rs:
##########
@@ -1911,13 +1914,16 @@ async fn build_shard_for_thread(
{
Ok(partition) => partition,
// ONE damaged local chain must not take the node down. The shapes
- // this refuses are exactly what a failed state-transfer quarantine
- // leaves behind, so fence that group the same way the runtime path
- // does -- move its segment files aside, keeping the superblock so
it
- // cannot re-enter view 0 -- and materialise it fresh. The ordinary
- // rejoin path (repair, then state transfer on a refused floor)
- // recovers its data from a peer.
- Err(ServerError::PartitionChainRefused { dir, reason, .. }) => {
+ // this refuses are structural -- what a failed state-transfer
+ // quarantine leaves behind, or damage the recovery walk proved
+ // inside a segment -- so fence that group the same way the runtime
+ // path does -- move its segment files aside, keeping the
superblock
+ // so it cannot re-enter view 0 -- and materialise it fresh. The
+ // ordinary rejoin path (repair, then state transfer on a refused
+ // floor) recovers its data from a peer; a single-replica group has
+ // no peer, so it comes back EMPTY while every refused byte stays
+ // in the quarantine directory for the operator.
+ Err(ServerError::PartitionRecoveryRefused { dir, reason, .. }) => {
Review Comment:
**Blocker, scoped to one arm:** `IndexLogDivergence` reaching here is a
behaviour change from master, and at `replica_count = 1` it loses acked data
silently.
Master routed `RecoveredSegmentSizeDivergence` past both arms to `?`, i.e.
node-fatal and loud. This PR fences the partition and calls
`build_partition_fresh`, so with no peer to rebuild from the partition comes
back empty while the node reports healthy. The shape is reachable on the
shipped `enforce_fsync = false`, where nothing orders the message write against
the index write.
Documentation cannot close this one: the existing `TODO(hubcio)` at
`core/server/src/responses.rs:1378-1390` notes that a partition fenced for
rebuild and a freshly materialized one are observationally identical, because
the stats registry carries no materialization signal. So there is provably no
way for an operator or a client to tell the difference.
Tombstoning at `replica_count == 1` — as the superblock arm already does at
`:2012`, and `topology.replica_count` is in scope here — turns it into a signal
instead. An unroutable namespace is diagnosable; an empty served one is not.
I would scope this to the arm whose behaviour this PR changed. The
pre-existing `Hole` / `EmptyNonTailSegment` fence policy is separate debt and
does not need to be settled here.
Two smaller notes on this arm:
`PartitionRecoveryRefusal::StorageSizeMismatch` cannot actually reach it (see
my note on the error variant), and when `quarantine_segment_files` fails the
code tombstones without rebuilding, which the error Display does not admit.
##########
core/server/src/bootstrap.rs:
##########
@@ -2763,6 +2788,40 @@ async fn hydrate_partition_log(
Ok(())
}
+/// Routes a hydrate-reopen writer failure. The seed-vs-stat divergence guard
+/// (`SegmentSizeMismatchAtOpen`) is the same structural contradiction the
+/// recovery walk refuses on -- and the heal path for data directories an
Review Comment:
This rationale is attached to a branch that cannot execute, and it is what
the commit message's headline claim rests on.
By the time `hydrate_partition_log` runs, pass C has already truncated each
file to S and `SegmentStorage::new` has already compared S. Hydrate then
reopens the same paths against the same `Rc<AtomicU64>` returned by
`size_counter()`, and nothing between the two mutates a segment
(`hydrate_applied_purge_generation` only reads `purge.gen`); preallocation uses
`FALLOC_FL_KEEP_SIZE`, so it cannot change the logical length either. So
`SegmentSizeMismatchAtOpen` cannot arrive here.
That makes "the heal path for data directories an earlier size-counter bug
left with resurrected tails" inaccurate — the heal is `truncate_to`, which is
what actually cuts the resurrected tail off an existing data directory. The
commit body's "Nodes already stuck on this bug can boot again" is true, but
because of `truncate_to`, not because of this guard.
Worth correcting so the next person to touch recovery does not reason from a
rationale on dead code. Either delete the arm, or keep it as defence-in-depth
and say so plainly. If it stays, the only honest test for it is injecting an
external truncation between pass C and hydrate.
##########
core/server/src/server_error.rs:
##########
@@ -164,19 +164,23 @@ pub enum ServerError {
},
// Per-partition, not fatal: the boot path fences this one group
(quarantines
// its segment files and materialises it fresh) instead of taking the node
- // down for one damaged local chain. The shapes it reports are exactly
what a
- // failed state-transfer quarantine leaves behind, and the rebuild recovers
- // the data from a peer.
+ // down for one damaged local chain. Only STRUCTURAL refusals route here --
+ // shapes where the local files contradict themselves, so a retried boot
+ // cannot help. Transient recovery I/O failures (stat, open, read,
truncate,
+ // fsync) stay node-fatal on purpose: a retried boot can still serve that
+ // partition, while fencing it would quarantine healthy data.
#[error(
- "partition {stream_id}/{topic_id}/{partition_id} at {dir} recovered an
\
- unusable segment chain: {reason}"
+ "partition {stream_id}/{topic_id}/{partition_id} at {dir} refused
segment \
+ recovery: {reason}. The boot path quarantines this partition's
segment \
+ files beside its directory and rebuilds it empty for the rejoin path;
\
+ restore from a healthy replica, or repair the quarantined files
offline."
Review Comment:
I would delete the "repair the quarantined files offline" clause.
It prescribes a concrete operator action against artifacts this same code
path may have already mutated: pass C truncates every plan before
`SegmentStorage::new` can raise `StorageSizeMismatch`, and pass C is not atomic
across plans, so a refusal on plan N leaves plans 0..N-1 truncated and fsynced.
The recover-as-empty arm deletes its files outright. And there is no tooling to
do the repair with — no fsck or repair binary in `core/cli` or `core/tools`
(only `data-seeder-tool`), and the 24-byte index and batch record layouts are
documented nowhere outside the source.
Incomplete guidance costs an operator time. Guidance that prescribes a wrong
action against silently-altered evidence costs them the forensics the
quarantine exists for.
Two more things in this same message: it promises "rebuilds it empty for the
rejoin path" unconditionally, but `bootstrap.rs:1938-1966` tombstones without
rebuilding when `quarantine_segment_files` fails, and at `replica_count = 1`
there is no rejoin. And it never names `.fenced.N`, which is the one string an
operator needs in order to find the files — `config.toml` names it, this
message does not.
##########
core/server/src/server_error.rs:
##########
@@ -281,14 +273,16 @@ pub enum ServerError {
ShardJoinFailures { failures: Vec<ShardJoinFailure> },
}
-/// Why a recovered segment chain cannot be served.
+/// Why a partition's recovered segments cannot be served.
///
-/// Both shapes mean the same thing operationally -- the local files do not
form
-/// a chain this replica can serve -- but they are distinguished because they
-/// point at different causes: an empty non-tail segment is a failed rebuild's
-/// orphan pairing, a hole is a stray or half-unlinked file.
+/// Every shape here is structural -- the local files contradict themselves or
Review Comment:
This claim does not hold for `OffsetDiscontinuity`, and the mismatch
surfaces to operators as a corruption report for a non-corruption event.
That shape is reachable without any damage: `restore_offset_frontier`
(`iggy_partition.rs:761-780`) raises the counter to the superblock frontier,
which after a crash normally sits above the recovered end offset, and
`ensure_initial_segment` returns early because segments exist
(`partition_helpers.rs:311`). The next append then stamps `base_offset =
frontier` into the *existing* tail segment, producing an offset hole with no
byte hole. Those files are exactly what the writer wrote — they do not
contradict themselves or each other.
To be clear, I think the refusal itself is right and should not be relaxed:
a `break` there would let pass C delete the post-hole batches, which are acked
data. Refusing preserves them. The defect is upstream — boot should seal and
rotate when `restore_offset_frontier` raises the counter above the recovered
end, so no segment ever contains a hole — and that is follow-up work, not this
PR.
So this is just about the wording, plus a note that the enum's own doc
calling every variant structural is what made the upstream bug hard to spot.
##########
core/server/src/segment_recovery.rs:
##########
@@ -52,14 +74,23 @@ pub struct RecoveredSegment {
/// Loads every persisted segment for a partition, sorted by start offset.
///
/// Segment offsets and timestamps are recovered from the 24-byte sparse index
-/// (see module docs); segment byte size comes from the `.log` file. The last
-/// segment is left unsealed so it can accept further writes.
+/// (see module docs); segment byte size comes from walking the `.log` batch
+/// chain. Recovery runs in three passes: every segment is bounded READ-ONLY
+/// first, then the chain guard runs over those bounds, and only an accepted
+/// chain is made physical -- torn tails truncated, index-less indexes rebuilt
+/// -- before storage opens over it. A refusal at any point therefore leaves
+/// every file byte-identical to what boot found. The last segment is left
Review Comment:
This claim is not true for pass C, and the module relies on it elsewhere.
Pass C truncates each plan (log then index) *before* `SegmentStorage::new`
runs, and that open can raise `SegmentSizeMismatchAtOpen`, which is mapped to a
`StorageSizeMismatch` refusal. `hydrate_reopen_error` in `bootstrap.rs` fires
later still, after the whole chain has been truncated. Pass C is also not
atomic across plans: a refusal or an `EIO` on plan N leaves plans 0..N-1
truncated and fsynced.
The property does hold for passes A and B, which is the valuable part and
worth stating precisely — the holed-chain test does verify that a pass-B
refusal leaves even truncation candidates byte-identical. I would scope the
sentence to A and B.
This matters beyond wording because `server_error.rs` tells operators to
repair the quarantined files offline on the strength of it.
##########
core/server/config.toml:
##########
@@ -463,6 +463,14 @@ archive_expired = false
# Unsupported: setting this to `true` aborts boot.
recreate_missing_state = false
+# At boot, segment recovery walks each partition's segments: bytes after the
Review Comment:
Since this is the only operator-facing description of the new destructive
boot path and the repo has no `docs/` directory, I would either correct this
block or drop it from this PR rather than ship it as written.
Three things it overstates:
1. "bytes after the last verifiable batch" — the indexed arm only
header-decodes (`peek_header` → `BatchHeader::decode`, no checksum), per the
TODO in `recover_segment_bounds`. Only the index-less walk verifies checksums.
If the indexed arm gains a checksum this becomes accurate; until then "the last
decodable batch header" is what happens.
2. It does not mention that bytes *before* the last index entry are never
re-examined at boot at all, so at-rest damage inside `[0, last.position)` is
`validate_checksum`'s job on the poll path (already configured a few lines up),
not recovery's. That is a reasonable design, but it is the honest scope.
3. "the partition is rebuilt from replicas" omits `replica_count = 1`, where
there is no peer and the partition comes back EMPTY. `bootstrap.rs` states this
in a code comment; the operator-facing doc does not.
Also, "The metadata WAL is stricter and refuses boot instead" reads as
though the WAL never truncates. `prepare_journal.rs:240-247` does truncate a
torn WAL tail; only interior damage (or trailing bytes above `MAX_ENTRY_SIZE`)
refuses. "Interior WAL damage refuses boot" would be accurate — and that
`MAX_ENTRY_SIZE` refusal is the width-cap precedent worth reusing in
`probe_for_survivor`.
##########
core/partitions/src/messages_writer.rs:
##########
@@ -256,4 +271,40 @@ mod tests {
assert_eq!(writer.file.metadata().await.unwrap().len(), 0);
}
+
+ #[compio::test]
+ async fn
given_seeded_size_matching_disk_when_opening_existing_file_should_keep_counter()
{
Review Comment:
This test passes on master too, so it cannot fail in either direction.
Master's `messages_size_bytes.store(actual_messages_size, ...)` also leaves
the counter at 128 when the file is 128 bytes and the seed was 128, so the
assertion holds whether or not the fix is present. The divergence test right
below it is the one that pins the new behaviour.
In a PR whose purpose is regression-proofing this path, a test that cannot
fail is worth removing rather than keeping. (The other 15 new tests all look
sound to me — two of them, the clean-segment and second-recovery cases, also
pass on master but legitimately guard the new code against over-truncating,
which is different.)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]