numinnex commented on code in PR #3828:
URL: https://github.com/apache/iggy/pull/3828#discussion_r3730089250


##########
core/partitions/src/iggy_partition.rs:
##########
@@ -3153,6 +3227,33 @@ where
         self.stats.zero_out_all();
         self.stats.increment_segments_count(1);
 
+        // Fence the resident journal instead of clearing it: entries are
+        // consensus history (backup commit walks, repair, retransmission), so
+        // they stay, but every journal-apply path no-ops ops at or below this
+        // floor (see `purge_floor_op`). The write lock held here is the same
+        // one appends take, and the pump is single-threaded, so no op can be
+        // assigned between reading the sequence and installing the floor.
+        self.purge_floor_op = self.consensus.sequencer().current_sequence();

Review Comment:
   **Critical.** The fence is derived from the *local* sequencer at *local 
apply* time, which is strictly above the purge's own position in the partition 
op order. The purge is asynchronous: `PurgeTopicRequest::apply` bumps the 
generation and replies Ok (`core/metadata/src/stm/stream.rs:1785`), the 
reconciler stages on a later pass (`partition_reconciler.rs:1096`), and the 
pump applies here (`shard/src/router.rs:664`). Every op the partition sequencer 
assigns in that window lands `<= purge_floor_op`.
   
   So a send the client issued *after* `purge_topic()` returned Ok is either 
already flushed and unlinked by the loop at `:3151-3166`, or resident and 
fenced at `:2032`, or fenced at `:2364` and answered as success. An 
acknowledged write is destroyed with no crash, on a single node. Legacy has no 
equivalent window: it purges inline before replying 
(`core/server/src/shard/execution.rs:348-354`).
   
   Second symptom from the same line: because each replica reads its own 
sequencer at its own reconciler timing, replicas fence *different* op sets. Ops 
in `(min_floor, max_floor]` flush on some and not others, so 
`offset`/`dirty_offset` diverge and the next live batch is stamped at a 
different `base_offset` per replica — the invariant documented at `:2463-2474` 
says that cannot happen. The `TODO` at `partition_reconciler.rs:1075` names the 
skew but not the acked-write loss.
   
   A replicated fence value fixes the divergence but not the wipe, since the 
unlink loop still runs at reconciler timing. Suggest routing the purge through 
the *partition* consensus log as a `PurgePartition` prepare, so its own op is 
the fence: ops below it deleted, ops above kept, identical on every replica, 
and ordered against repair by the same walk. Note a fence carried in the 
`PurgeTopic` body cannot work — that is one metadata-group op, while each 
partition has its own `VsrConsensus`, sequencer and primary 
(`bootstrap.rs:2156-2185`), so one number is wrong for N-1 partitions.



##########
core/partitions/src/iggy_partition.rs:
##########
@@ -3153,6 +3227,33 @@ where
         self.stats.zero_out_all();
         self.stats.increment_segments_count(1);
 
+        // Fence the resident journal instead of clearing it: entries are
+        // consensus history (backup commit walks, repair, retransmission), so
+        // they stay, but every journal-apply path no-ops ops at or below this
+        // floor (see `purge_floor_op`). The write lock held here is the same
+        // one appends take, and the pump is single-threaded, so no op can be
+        // assigned between reading the sequence and installing the floor.
+        self.purge_floor_op = self.consensus.sequencer().current_sequence();
+        // The journal's flush accounting and resident poll indexes describe
+        // pre-purge bytes; reset them so the flush threshold counts only
+        // post-purge appends and polls fall back to the (fresh, empty)
+        // segments instead of resolving purged resident entries.
+        self.log.journal_mut().info = JournalInfo::default();
+        self.log
+            .journal()
+            .inner
+            .clear_poll_index(self.purge_floor_op);
+
+        // Last durable step: record the applied generation before the
+        // in-memory marker advances. On a write failure the marker stays old,
+        // the error propagates, and the reconciler retries the whole purge
+        // (idempotent). The reverse order would ack a purge that a crash
+        // then silently undoes: restart would hydrate the old generation,
+        // yet the reconciler believes the purge applied.
+        if let Some(dir) = self.partition_dir() {
+            let path = format!("{dir}/{PURGE_GENERATION_FILE}");
+            persist_purge_generation(&path, generation).await?;

Review Comment:
   **Critical.** The comment above claims the retry is idempotent. It is not 
idempotent with respect to messages: on `Err` the segments are already wiped, 
the floor is installed, `write_lock` is released and `applied_purge_generation` 
is unchanged, so the partition goes on accepting and acking sends at offset 0 
until the next reconciler pass re-purges and deletes them.
   
   Worse, the loss ratchets. `reconcile_partition_purges` has no backoff gate 
(compare `reconcile_partition_teardowns` at `partition_reconciler.rs:800-803`) 
and `request_purge_partition` is a bare `try_send` 
(`shard/src/lib.rs:1537-1541`), so every ~1s pass re-runs the whole purge and 
re-reads `current_sequence()` at `:3236`. The fenced set grows one step per 
pass for as long as `persist_purge_generation` keeps failing, destroying 
whatever was acked since the previous attempt.
   
   Suggest refusing appends until the generation is durable, and routing 
retries through the existing `failure_state` backoff with an attempt cap and a 
`warn!`.
   
   Separately on durability ordering: `persist_purge_generation` only 
`sync_data()`s the file, and `purge()` fsyncs no directory. The dirent for a 
first `purge.gen` is therefore not durable, and the segment unlinks are not 
ordered before the applied mark. A power loss can leave `purge.gen = N` durable 
with the deletions lost, in which case boot hydrates `applied == committed`, 
skips the re-purge, and `load_persisted_segments` recovers the purged segments 
permanently. Write-then-mark needs the deletion durable first — suggest 
fsyncing the partition dir after the unlink loop and after 
`install_empty_segment`, before writing the generation.



##########
core/shard/src/lib.rs:
##########
@@ -3311,8 +3312,47 @@ where
         }
         let cluster = partition.consensus().cluster();
         let self_id = partition.consensus().replica();
+        // Purge convergence gate: while a committed purge is not yet locally
+        // applied, this journal still holds pre-purge entries with NO floor
+        // to fence them (the floor is installed by the purge itself), so
+        // serving now would hand a rejoiner batches the cluster purged.
+        // Defer instead: no RepairDone is sent, the rejoiner's stall retry
+        // re-asks, and the local purge (one reconciler wake away) installs
+        // the floor the fence below serves behind.
+        let namespace = IggyNamespace::from_raw(header.namespace);
+        let committed_purge = self
+            .plane
+            .metadata()
+            .mux_stm
+            .streams()
+            .partition_purge_generation(
+                namespace.stream_id(),
+                namespace.topic_id(),
+                namespace.partition_id(),
+            );
+        if committed_purge > partition.applied_purge_generation() {
+            tracing::debug!(
+                shard = self.id,
+                namespace_raw = header.namespace,
+                committed_purge,
+                applied_purge = partition.applied_purge_generation(),
+                "deferring repair serve until the committed purge applies 
locally"
+            );
+            return;
+        }
         let to_op = header.to_op.min(partition.consensus().commit_max());
-        let retained_from = 
partition.log.journal().inner.repair_retained_from();
+        // Purge fence: never serve entries at or below this replica's purge
+        // floor. The journal keeps them (own commit walk), but a rejoiner's
+        // floor died with its process, so served pre-purge batches would
+        // flush right back into its freshly reset segments. Reporting the
+        // floor as the retention start rides the normal `RangeEvicted` path:
+        // the rejoiner moves its commit floor to the purge point instead.
+        let purge_floor = partition.purge_floor_op();
+        let retained_from = match 
partition.log.journal().inner.repair_retained_from() {
+            Some(op) => Some(op.max(purge_floor + 1)),

Review Comment:
   **Critical — permanent rejoin wedge.** When a purge is the newest partition 
op, `retained_from` becomes `purge_floor + 1 > to_op` and the served window is 
empty.
   
   Full chain on a quiescent purged partition, where the serving peer's 
`purge_floor == sequencer == commit_max == to_op`: `handle_start_view` arms 
repair with `from_op=1, to_op=commit_max` (`:3040-3055`, `to_op` frozen there) 
→ this line raises `retained_from` → `RangeEvicted(purge_floor+1)` sets 
`repair.floor = Some(purge_floor)` (`:3613`, `saturating_sub(1)`) → the serve 
loop runs zero iterations so `RepairDone` follows → `complete_repair` clamps 
`floor.min(commit_max) == to_op` and `repaired_window_is_offsets_only` returns 
false on `floor >= to_op` (`core/partitions/src/iggy_partition.rs:3430`) → 
refused. `commit_min` never leaves 0.
   
   There is no exit. `complete_repair` returns at `:3390` with `self.repair` 
still armed; the stall sweep reuses the frozen `session.to_op`, never a 
refreshed `commit_max` (`:4811-4818`), so the identical window is re-asked 
every `repair_retry_ticks` forever; the session clears only on `commit_min >= 
session.to_op` (`:3406-3409`); and the partition `RangeEvicted` arm has no 
`arm_metadata_transfer` equivalent — that call is metadata-only (`:3571-3582`). 
Live traffic does not rescue it either, since the rejoiner's backup gap check 
drops op `to_op+1`. The cost is a group running one replica short indefinitely, 
so every commit's latency becomes the surviving backup's p99 with no hedging.
   
   The comment at `:3348-3350` says "the rejoiner moves its commit floor to the 
purge point instead" — the code refuses to.
   
   On the fix: please do not simply exempt the purge floor from the 
empty-window rule, since that deletes the guard at 
`iggy_partition.rs:3368-3375` and lets a rejoiner still holding pre-purge 
segments claim them as committed state and then serve them. Gate acceptance on 
the rejoiner having applied the purge itself (`applied_purge_generation >= 
committed`), which sets `recovered_durable_offset = None` and wipes the 
segments, making the empty window true rather than assumed. Also worth 
mirroring the metadata branch's explicit `from_op > to_op` reply (`:3234-3259`) 
— the partition branch reaching `RangeEvicted` + `RepairDone` here is 
incidental, not intended.



##########
core/partitions/src/iggy_partition.rs:
##########
@@ -2302,6 +2356,14 @@ where
                 if entry.header.operation != Operation::SendMessages {
                     return None;
                 }
+                // Purge floor: a pre-purge send committing after the purge
+                // reports no visible offsets ("send without confirmation", the
+                // established degradation), which also keeps
+                // `commit_partition_entry` from re-advancing the reset offset
+                // and stats with pre-purge values.
+                if entry.header.op <= self.purge_floor_op {

Review Comment:
   **Critical.** A fenced `SendMessages` returns the empty body at `:3592` 
(`Bytes::from_static(&[0,0,0,0])`), which `SendMessagesResponse::decode` turns 
into `confirmations: vec![]` 
(`core/binary_protocol/src/responses/messages/send_messages.rs:121-136`) — 
indistinguishable from success at the SDK. The client is told Ok for a write 
the server discarded.
   
   The root problem is a semantic overload: before this change an empty 
confirmation list meant "committed, offsets not resolvable" (`:2235-2239`), and 
the wire doc already flags the ambiguity (`send_messages.rs:94-96`). This adds 
"silently discarded" to the same shape.
   
   Suggest replying a nonzero reply-header `status = 
IggyError::TransientNotCommitted` (57) instead. It is compat-safe (status is 
pre-existing wire, not a body change), all three transports already replay it 
on the same connection with the same request id 
(`core/sdk/src/tcp/tcp_client.rs:976-990`, `quic_client.rs:776-790`, 
`websocket_client.rs:777`), and it converges because the replayed send gets a 
fresh op above the floor. There is no double-apply risk here: the fenced batch 
was never stored, so the replay is not a duplicate. If the floor is climbing 
(see the retry comment on `:3255`), the replay exhausts its budget into a 
visible timeout rather than a silent success, which is still the better failure.
   
   Note this closes the fenced-at-commit window only. The variant where the 
send committed and was answered with a real offset-bearing 
`SendMessagesConfirmationResponse` *before* the purge applied, whose bytes the 
unlink loop then deletes, cannot be reached by any reply-status change — that 
one needs the ordering fix on `:3236`.



##########
core/partitions/src/iggy_partition.rs:
##########
@@ -1984,6 +2025,13 @@ where
                         }
                         continue;
                     }
+                    // Purge floor: a pre-purge batch committing after the
+                    // purge must not flush its (purged) bytes into the fresh
+                    // segment. It still counts into `chunk_len`, so it joins
+                    // the evictable prefix and commit_min advances normally.
+                    if peek_op(&entry) <= self.purge_floor_op {

Review Comment:
   **Critical (receiving side, distinct from the fence itself).** Pre-existing 
mechanism, newly reachable, and it is the state a rejoiner escapes into once a 
fresh view change lifts `to_op` above the purge floor.
   
   A replica that has not yet applied the purge boots with 
`recovered_durable_offset = Some(D)` for a large D 
(`core/server-ng/src/bootstrap.rs:2242-2249`). A peer that *has* purged serves 
only post-purge sends, whose `base_offset` restarts at 0, so `complete_repair` 
takes the `(Some(0), Some(D))` arm, `0 <= D+1` holds, and the floor is accepted 
(`:3366`). The flush loop just below then drops every one of those batches 
unwritten at `:2062-2066` (`batch_end <= recovered_durable_offset`), and 
`commit_partition_entry` skips the offset/stats advance at `:2422-2424`. 
Because `commit_min` has advanced past them, nothing ever refetches; when that 
replica finally purges, `recovered_durable_offset` becomes `None` and the new 
floor covers those ops, fencing them permanently.
   
   Net effect: committed post-purge messages never land on that replica, and 
until it purges it serves pre-purge data — including as primary. Suggest 
requiring local purge-apply before accepting a repair floor while a committed 
purge is outstanding, and naming this mechanism in the `TODO(hubcio)` at 
`partition_reconciler.rs:1075`, which currently covers only the ordering, not 
this loss path.



##########
core/partitions/src/iggy_partition.rs:
##########
@@ -143,6 +146,15 @@ where
     /// generation against this and resets only when it advances, so a 
redundant
     /// reconcile pass never re-wipes a partition already at this generation.
     applied_purge_generation: u64,
+    /// Highest consensus op assigned when the last purge ran. INVARIANT: every

Review Comment:
   The invariant stated here ("every journal-apply path must no-op entries with 
`op <= purge_floor_op`") does not hold: `append_repaired_send_messages` 
(`:3451`) is a journal-apply path with no guard. A repaired op at or below the 
floor still writes `dirty_offset`, `journal.info.current_offset` and 
`segment.current_position` from its *pre-purge* stamps, so post-purge appends 
resume at a stale high offset while `segment.start_offset == 0`.
   
   It is currently masked only externally — the `header_by_op` early return at 
`:3288` plus the peer-side fence at `shard/src/lib.rs:3352` — so it breaks the 
moment either changes. Suggest `if header.op <= self.purge_floor_op { return 
Ok(None); }` to match the four sites that did get the guard.
   
   This is also the reachable route into the resident-poll gap (see the comment 
on `journal.rs:693`): the unguarded append lands out of order, 
`append_with_meta`'s `op > poll_floor` check keeps it out of the index, but 
`resident_entries()` still hands it to `select_resident`. Fixing this line 
alone does not close that one, and vice versa.



##########
core/partitions/src/journal.rs:
##########
@@ -662,6 +679,25 @@ where
         offset_to_op.keys().next().copied()
     }
 
+    /// Seal the resident poll tier: clear the offset and timestamp poll
+    /// indexes ONLY, so `oldest_resident_offset` reads `None` and every poll
+    /// falls back to the on-disk segments. Called by a partition purge, which
+    /// wipes the segments but must KEEP the journal entries themselves:
+    /// headers, storage, `op_to_storage_offset` and the evicted ring are
+    /// consensus history that backups, repair and retransmission still walk.
+    /// Clearing those would wedge `commit_min` until a view change.
+    ///
+    /// `floor` (the purge's fence op) makes the seal survive eviction:
+    /// `evict_prefix` re-appends the retained tail, and without the floor
+    /// that re-append would re-index the pre-purge entries just cleared.
+    pub fn clear_poll_index(&self, floor: u64) {

Review Comment:
   The seal reaches `offset_to_op`/`timestamp_to_op` and the `append` path, but 
not the resident tier itself. `resident_entries()` (`:619`) returns fenced 
pre-purge entries, `select_resident` (`:969`) filters on the query only, and 
the `DiskReadOutcome::Empty` retention-recovery branch at 
`core/partitions/src/poll_plan.rs:283` runs with no `oldest_resident` gate at 
all. So a poll can serve purged bytes; today that is masked by tier selection 
rather than by the invariant, which is a thin thing to rest on.
   
   Suggest filtering in `resident_entries()` rather than in `select_resident`: 
the positionally-paired `headers` vec (length-lock invariant at `:531-537`, 
already relied on by `committed_prefix` at `:441`) lets the filter read 
`header.op` out of a dense `Vec<PrepareHeader>` with no payload touch, whereas 
filtering in `select_resident` costs a header cast plus a cold cache line into 
each ~1 MiB buffer per entry per poll. A `poll_floor == 0` short-circuit keeps 
the never-purged path byte-identical.
   
   Related, on this function's own contract: zeroing `journal.info` in 
`purge()` (`iggy_partition.rs:3241`) makes the `messages_count == 0` gate at 
`:1925` short-circuit before `evict_committed_prefix`, and the shutdown 
force-flush hits that same gate and only logs. The fenced entries are bounded 
in bytes but have no eviction driver in time, so an idle purged partition pins 
them until the process exits — on the operation users run to reclaim space. If 
you evict inside `purge()` to fix that, it must be 
`committed_prefix(commit_min).len()`, not `commit_max`: evicting ops in 
`(commit_min, commit_max]` clears their `headers` (`:477`) and holes the 
backup's `collect_committable_from_journal` walk 
(`iggy_partition.rs:1778-1788`), wedging `commit_min`. `header_by_op` is also 
what `RetransmitPrepares` resolves (`iggy_partition.rs:1822-1828`) and the 
evicted ring is invisible to it, so uncommitted fenced ops must stay resident.



##########
core/metadata/src/impls/metadata.rs:
##########
@@ -3394,30 +3381,10 @@ where
                     &body,
                 ))
             }
-            Operation::UpdateTopic => {
-                let mut request = WireUpdateTopicRequest::decode_from(body)
-                    .map_err(|_| IggyError::InvalidCommand)?;
-                // Same `ServerDefault` resolution as `CreateTopic` above; 
rebuild
-                // the prepare only if a sentinel actually needs stamping, else
-                // project the untouched buffer zero-copy.
-                let needs_rewrite = request.max_topic_size == 0 || 
request.message_expiry == 0;
-                if request.max_topic_size == 0 {
-                    request.max_topic_size = self.default_max_topic_size.get();
-                }
-                if request.message_expiry == 0 {
-                    request.message_expiry = self.default_message_expiry.get();
-                }
-                if needs_rewrite {
-                    let body = request.to_bytes();
-                    return Ok(build_prepare_message(
-                        consensus,
-                        &header,
-                        Operation::UpdateTopic,
-                        &body,
-                    ));
-                }
-                Ok(message.project(consensus))
-            }
+            // `UpdateTopic` deliberately takes the default arm: unlike create,

Review Comment:
   Dropping this arm is right for the wire echo but it silently disables 
retention enforcement. With the sentinel now stored, 
`update_topic(ServerDefault)` leaves `core/server-ng/src/segment_cleaner.rs:88` 
mapping it to no size cap, while `CreateTopic` still stamps the node default at 
`:3320-3325`. The same topic therefore gets enforced retention if the value 
came from create and unbounded growth if it came from an update, and the config 
validator proves these are real values, not sentinels 
(`server_ng_config/validators.rs:108-128`).
   
   For the record on legacy, since the commit message frames this as parity: 
legacy does not resolve at update either — `get_topic_config` returns the raw 
stored pair (`core/server/src/metadata/reader.rs:722-734`), resolution is 
create-only (`core/server/src/shard/system/topics.rs:49-50`). But its cleaner 
then gates on `!matches!(max_topic_size, Unlimited)` 
(`core/server/src/shard/system/segments.rs:65`), so a stored `ServerDefault` 
enters the sized branch with `max_bytes = 0` 
(`core/common/src/utils/topic_size.rs:52`), `threshold = 0`, and `current_size 
< 0` is never true — it trims *every* sealed segment. So the wire echo is 
genuinely legacy-identical, but enforcement now diverges in the opposite 
direction from legacy and neither side honors `system.topic.max_size`. The 
expiry half is a real parity win (`core/common/src/types/segment.rs:74` and 
`segment_cleaner.rs:76` agree).
   
   Suggest resolving `ServerDefault` against node config in 
`topic_retention_config`/the cleaner and keeping the verbatim wire echo — 
read-back shape and enforcement are separate concerns. Please also split the 
"matching the legacy cleaner" comment at `segment_cleaner.rs:71-73`, which is 
true for expiry and false for size. One caveat to accept explicitly: a resolved 
sentinel makes local GC config-dependent where a stored `Custom` is not, so 
replicas would need matching `system.topic.max_size`.
   
   Also, this behavior change lives in metadata but its only guard is a TCP 
integration test. There is a create-side sibling unit test at `:4379`; a mirror 
asserting the `UpdateTopic` prepare now carries the sentinel verbatim would be 
worth adding.



##########
core/server-ng/src/bootstrap.rs:
##########
@@ -3465,6 +3475,9 @@ const fn operation_triggers_partition_reconcile(op: 
Operation) -> bool {
             | Operation::DeleteTopic
             | Operation::DeleteStream
             | Operation::DeletePartitions
+            | Operation::PurgeStream
+            | Operation::PurgeTopic
+            | Operation::TruncatePartition

Review Comment:
   Adding `TruncatePartition` here shortens the fuse on a pre-existing 
data-loss path. `PurgeTopic`/`PurgeStream` bump `purge_generation` but never 
reset `deleted_up_to_offset` (`core/metadata/src/stm/stream.rs:1778-1781`), and 
`reconcile_segment_truncations` re-stages `request_truncate_partition(ns, 
watermark)` on every pass whenever `watermark != 0`, with no applied-guard 
(`partition_reconciler.rs:1044-1065`) — unlike purge, which at least has a 
generation guard.
   
   So after a `delete_segments` followed by a `purge`, post-purge offsets 
restart at 0 and the first post-purge segment to seal below the stale pre-purge 
watermark is removed by `remove_sealed_segments_up_to`. Its only brake is 
`min_committed_offset()` (`iggy_partition.rs:2944`, `:2951-2956`), which the 
purge just cleared. Waking the reconciler on the commit tick plus 
`purges_pending` keeping passes running both make this land sooner.
   
   Fix looks small and self-contained: zero `deleted_up_to_offset` in both 
purge applies. It is a deterministic STM mutation on every replica, cannot 
un-delete anything, and is semantically forced — a watermark in the pre-purge 
offset space is meaningless after the reset.



##########
core/server-ng/src/partition_reconciler.rs:
##########
@@ -1077,6 +1094,7 @@ fn reconcile_partition_purges(ctx: &ReconcilerCtx) {
             .map_or(0, partitions::IggyPartition::applied_purge_generation);
         if committed > applied {
             ctx.shard.request_purge_partition(namespace, committed);
+            counters.purges_pending += 1;

Review Comment:
   `purges_pending` correctly disarms the fast-skip, but there is no backoff 
behind it, unlike `reconcile_partition_teardowns` at `:800-803`. With a 
persistently failing purge (ENOSPC/EROFS/EIO on `persist_purge_generation`) 
this restages a frame per stuck partition on every ~1s pass, and each retry 
re-runs the entire `purge()` on the pump: unlink every segment file, two file 
creates in `install_empty_segment`, fsync. On a 1000-partition topic that is a 
data-plane stall on a single-threaded pump.
   
   It also re-reads `current_sequence()` at `iggy_partition.rs:3236` on every 
attempt, so `purge_floor_op` climbs monotonically and the fenced set ratchets 
over sends acked since the previous attempt. That turns the cross-replica 
divergence from a one-shot skew bounded by reconciler timing into a window that 
grows for the duration of the fault, since a failing replica's floor climbs 
while healthy replicas' floors stay put.
   
   Please keep the counter — it is the only self-heal for a stranded purge, and 
before this PR a failed purge armed the fast-skip and stayed stranded until an 
unrelated commit. The ask is `failure_state` backoff plus an attempt cap and a 
`warn!`.
   
   Two related gaps while here: `purges_pending` and the pre-existing 
`trims_pending` feed `total()` but are absent from the pass-complete `debug!` 
(`:473-486`), so an operator cannot see why passes stopped fast-skipping. And a 
failing purge makes the repair-serve defer at `shard/src/lib.rs:3333` drop 
every repair request silently and forever — it is `debug!`-only with no attempt 
counter or metric, so the cluster loses redundancy invisibly.



##########
core/server-ng/src/partition_helpers.rs:
##########
@@ -536,6 +536,11 @@ pub async fn build_partition_fresh(
         topic_id,
         partition_id,
     ));
+    // Fresh dirs read generation 0; a dir surviving from a crashed process
+    // (this "fresh" build races repair re-materialization) reads the last
+    // durably-applied purge so the reconciler does not re-wipe messages
+    // appended after it.
+    partition.hydrate_applied_purge_generation().await?;

Review Comment:
   Two problems with hydrating here.
   
   First, this sets `applied_purge_generation = N` while `purge_floor_op` stays 
0, so the replica claims the purge is applied yet carries no fence. Its 
repair-serve then skips the defer at `shard/src/lib.rs:3333` and raises no 
floor, which is exactly the state the fence was added to prevent.
   
   Second, a `purge.gen` that survives a failed teardown 
(`partition_reconciler.rs:817-836`, with the delete-then-recreate race already 
named at `:782`) gets hydrated by a partition recreated on the reused slab ids, 
whose `purge_generation` restarts at 0 (`core/metadata/src/stm/stream.rs:1615`, 
`:1853`). Then `committed > applied` is false at 
`partition_reconciler.rs:1095`, the wipe never runs, and the client still gets 
Ok for the purge.
   
   On the fix: unlinking `purge.gen` on a "fresh" build is not safe, because 
this function cannot distinguish a true fresh create from a restart 
materialization — its only signal is `restarted = dir exists && replica_count > 
1` (`:470-476`), which is unconditionally false single-node, so unlinking would 
re-arm the re-purge on a genuine restart and destroy post-purge data. Comparing 
`committed != applied` is the minimal safe change, but it must be applied at 
*both* sites: the router's own gate is `partition.applied_purge_generation() < 
generation` (`core/shard/src/router.rs:662`), and `N < 1` is false, so changing 
only the reconciler leaves the purge still swallowed. Keying the file on 
`Partition::created_revision` (`stm/stream.rs:1849`) is the durable version.



-- 
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]

Reply via email to