Skip to content

Commit 1f1af34

Browse files
hamersawclaude
andauthored
fix(mem_wal): stop WAL replay from re-loading already-compacted entries (lance-format#6767)
## Summary After a writer flushed a memtable to L0 and an external compactor merged that generation into the base table — legitimately draining `flushed_generations` to empty — a subsequent restart re-replayed the original WAL entries into the new active memtable, duplicating rows on read. Two bugs were interacting: 1. **Disambiguation:** `replay_memtable_from_wal` distinguished "fresh shard" from "flushed and compacted" via `flushed_generations.is_empty()`. That works in a closed-world deployment but breaks the moment an external compactor enters the picture — and the compactor is the *intended* consumer that drains that vector, so the signal is structurally broken under OSS-WAL. 2. **Cursor never advanced:** `MemTableFlusher::flush` read `covered_wal_entry_position` from `memtable.last_flushed_wal_entry_position()`, but that field is only set by the `mark_wal_flushed` test helper. In production it stayed at 0, so `replay_after_wal_entry_position` never advanced past 0. Under 0-based WAL positions this masked bug #1 — both "fresh" and "post-flush-of-0" produced cursor=0. ## Fix - **WAL positions are now 1-based** (`FIRST_WAL_ENTRY_POSITION = 1`). A cursor of `0` unambiguously means "no flush has stamped this shard," so replay collapses to `cursor.saturating_add(1)` without consulting `flushed_generations`. - **`WalFlushHandler::handle`** writes the just-appended position back into `state.last_flushed_wal_entry_position` under the state lock before signalling the completion cell. - **`MemTableFlusher::flush` / `flush_with_indexes`** now take an explicit `covered_wal_entry_position` arg. The production caller derives it per-memtable from the `WalFlushResult` carried in the completion cell — authoritative under concurrent flushes — falling back to `memtable.frozen_at_wal_entry_position()` when freeze did not trigger a flush. - **State seed at open** uses the post-replay WAL tip, not `manifest.wal_entry_position_last_seen` (the latter is bumped on every tailer read and can sit above any flushed generation). - Proto field docs on `ShardManifest.replay_after_wal_entry_position` / `wal_entry_position_last_seen` updated to spell out the 1-based convention and what default-0 means. ## Test plan - [x] Added `test_memtable_replay_skips_entries_after_external_compaction` in `rust/lance/src/dataset/mem_wal/write.rs`: open writer, put rows, close (flush), simulate the compactor by directly committing a manifest with empty `flushed_generations`, reopen, assert the memtable is empty. Fails on the pre-fix code; passes now. - [x] `cargo test -p lance --lib dataset::mem_wal` — 236/236 pass - [x] `cargo test -p lance --lib` — 1600/1600 pass - [x] `cargo test -p lance-index --lib` — 302/302 pass - [x] `cargo clippy --all --tests --benches -- -D warnings` — clean - [x] `cargo fmt --all -- --check` — clean ## Compatibility WAL position numbering changes from 0-based to 1-based. Existing on-disk manifests / WAL files written by the prior `oss-wal-multiplex` code are not migrated — coordinated with downstream consumers (sophon) to start fresh. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 477d6c2 commit 1f1af34

6 files changed

Lines changed: 254 additions & 60 deletions

File tree

protos/table.proto

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -547,12 +547,16 @@ message ShardManifest {
547547
// A writer must increment this when claiming the shard.
548548
uint64 writer_epoch = 2;
549549

550-
// The most recent WAL entry position (0-based) that has been flushed to a MemTable.
550+
// The most recent WAL entry position that has been flushed to a MemTable.
551551
// During recovery, replay starts from replay_after_wal_entry_position + 1.
552+
// WAL positions are 1-based, so the default value 0 unambiguously means
553+
// "no flush has ever stamped this shard" and recovery replays from 1.
552554
uint64 replay_after_wal_entry_position = 3;
553555

554-
// The most recent WAL entry position (0-based) at the time manifest was updated.
555-
// This is a hint, not authoritative - recovery must list files to find actual state.
556+
// The most recent WAL entry position observed at the time the manifest was
557+
// updated. WAL positions are 1-based; default 0 means no entry has been
558+
// written yet. This is a hint, not authoritative - recovery must list
559+
// files to find actual state.
556560
uint64 wal_entry_position_last_seen = 4;
557561

558562
// Next generation ID to create (incremented after each MemTable flush).

rust/lance-index/src/mem_wal.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,15 @@ pub struct ShardManifest {
159159
/// ShardField from the ShardSpec determines how to interpret each value.
160160
pub shard_field_values: HashMap<String, Vec<u8>>,
161161
pub writer_epoch: u64,
162-
/// The most recent WAL entry position (0-based) flushed to a MemTable.
163-
/// Recovery replays from `replay_after_wal_entry_position + 1`.
162+
/// The most recent WAL entry position flushed to a MemTable.
163+
/// Recovery replays from `replay_after_wal_entry_position + 1`. The
164+
/// default value 0 means "no flush has ever stamped this shard" — WAL
165+
/// positions themselves are 1-based, so 0 is never a valid covered
166+
/// position.
164167
pub replay_after_wal_entry_position: u64,
165-
/// The most recent WAL entry position (0-based) when manifest was updated.
168+
/// The most recent WAL entry position observed at manifest write time.
169+
/// Default 0 means "no entry has been written yet"; WAL positions are
170+
/// 1-based.
166171
pub wal_entry_position_last_seen: u64,
167172
pub current_generation: u64,
168173
pub flushed_generations: Vec<FlushedGeneration>,

rust/lance/benches/mem_wal_index_micro.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,9 +396,12 @@ async fn measure_flush(
396396
let (epoch, _) = manifest_store.claim_epoch(0).await?;
397397
let flusher = MemTableFlusher::new(store, base_path, uri, shard_id, manifest_store);
398398

399+
// total_batches WAL entries were stamped at positions 1..=total_batches
400+
// by the mark_wal_flushed loop above (1-based positions).
401+
let covered_wal_entry_position = total_batches as u64;
399402
let t = Instant::now();
400403
let _result = flusher
401-
.flush_with_indexes(&memtable, epoch, index_configs)
404+
.flush_with_indexes(&memtable, epoch, index_configs, covered_wal_entry_position)
402405
.await?;
403406
let elapsed = t.elapsed();
404407

rust/lance/src/dataset/mem_wal/memtable/flush.rs

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,19 @@ impl MemTableFlusher {
7979
}
8080

8181
/// Flush the MemTable to storage (data files, indexes, bloom filter).
82+
///
83+
/// `covered_wal_entry_position` is stamped into the manifest's
84+
/// `replay_after_wal_entry_position` so post-restart replay skips the
85+
/// WAL entries this generation captures. Pass 0 only for shards that
86+
/// have not yet appended any WAL entry — non-zero positions are
87+
/// 1-based (see `FIRST_WAL_ENTRY_POSITION`).
8288
#[instrument(name = "mt_flush_storage", level = "info", skip_all, fields(shard_id = %self.shard_id, epoch, generation = memtable.generation(), row_count = memtable.row_count()))]
83-
pub async fn flush(&self, memtable: &MemTable, epoch: u64) -> Result<FlushResult> {
89+
pub async fn flush(
90+
&self,
91+
memtable: &MemTable,
92+
epoch: u64,
93+
covered_wal_entry_position: u64,
94+
) -> Result<FlushResult> {
8495
self.manifest_store.check_fenced(epoch).await?;
8596

8697
if memtable.row_count() == 0 {
@@ -113,9 +124,13 @@ impl MemTableFlusher {
113124
self.write_bloom_filter(&bloom_path, memtable.bloom_filter())
114125
.await?;
115126

116-
let last_wal_entry_position = memtable.last_flushed_wal_entry_position();
117127
let new_manifest = self
118-
.update_manifest(epoch, generation, &gen_folder_name, last_wal_entry_position)
128+
.update_manifest(
129+
epoch,
130+
generation,
131+
&gen_folder_name,
132+
covered_wal_entry_position,
133+
)
119134
.await?;
120135

121136
info!(
@@ -129,7 +144,7 @@ impl MemTableFlusher {
129144
path: gen_folder_name,
130145
},
131146
rows_flushed,
132-
covered_wal_entry_position: last_wal_entry_position,
147+
covered_wal_entry_position,
133148
})
134149
}
135150

@@ -184,12 +199,16 @@ impl MemTableFlusher {
184199
}
185200

186201
/// Flush the MemTable to storage with indexes.
202+
///
203+
/// See [`MemTableFlusher::flush`] for `covered_wal_entry_position`
204+
/// semantics.
187205
#[instrument(name = "mt_flush_with_indexes", level = "info", skip_all, fields(shard_id = %self.shard_id, epoch, generation = memtable.generation(), row_count = memtable.row_count(), index_count = index_configs.len()))]
188206
pub async fn flush_with_indexes(
189207
&self,
190208
memtable: &MemTable,
191209
epoch: u64,
192210
index_configs: &[MemIndexConfig],
211+
covered_wal_entry_position: u64,
193212
) -> Result<FlushResult> {
194213
self.manifest_store.check_fenced(epoch).await?;
195214

@@ -288,9 +307,13 @@ impl MemTableFlusher {
288307
self.write_bloom_filter(&bloom_path, memtable.bloom_filter())
289308
.await?;
290309

291-
let last_wal_entry_position = memtable.last_flushed_wal_entry_position();
292310
let new_manifest = self
293-
.update_manifest(epoch, generation, &gen_folder_name, last_wal_entry_position)
311+
.update_manifest(
312+
epoch,
313+
generation,
314+
&gen_folder_name,
315+
covered_wal_entry_position,
316+
)
294317
.await?;
295318

296319
info!(
@@ -304,7 +327,7 @@ impl MemTableFlusher {
304327
path: gen_folder_name,
305328
},
306329
rows_flushed: memtable.row_count(),
307-
covered_wal_entry_position: last_wal_entry_position,
330+
covered_wal_entry_position,
308331
})
309332
}
310333

@@ -883,7 +906,7 @@ mod tests {
883906
assert!(!memtable.all_flushed_to_wal());
884907

885908
let flusher = MemTableFlusher::new(store, base_path, base_uri, shard_id, manifest_store);
886-
let result = flusher.flush(&memtable, epoch).await;
909+
let result = flusher.flush(&memtable, epoch, 0).await;
887910

888911
assert!(result.is_err());
889912
assert!(
@@ -912,7 +935,7 @@ mod tests {
912935
let memtable = MemTable::new(schema, 1, vec![]).unwrap();
913936

914937
let flusher = MemTableFlusher::new(store, base_path, base_uri, shard_id, manifest_store);
915-
let result = flusher.flush(&memtable, epoch).await;
938+
let result = flusher.flush(&memtable, epoch, 0).await;
916939

917940
assert!(result.is_err());
918941
assert!(result.unwrap_err().to_string().contains("empty MemTable"));
@@ -950,7 +973,7 @@ mod tests {
950973
shard_id,
951974
manifest_store.clone(),
952975
);
953-
let result = flusher.flush(&memtable, epoch).await.unwrap();
976+
let result = flusher.flush(&memtable, epoch, 1).await.unwrap();
954977

955978
assert_eq!(result.generation.generation, 1);
956979
assert_eq!(result.rows_flushed, 10);
@@ -1011,7 +1034,7 @@ mod tests {
10111034
manifest_store.clone(),
10121035
);
10131036
let result = flusher
1014-
.flush_with_indexes(&memtable, epoch, &index_configs)
1037+
.flush_with_indexes(&memtable, epoch, &index_configs, 1)
10151038
.await
10161039
.unwrap();
10171040

@@ -1144,7 +1167,7 @@ mod tests {
11441167
manifest_store.clone(),
11451168
);
11461169
let result = flusher
1147-
.flush_with_indexes(&memtable, epoch, &index_configs)
1170+
.flush_with_indexes(&memtable, epoch, &index_configs, 1)
11481171
.await
11491172
.unwrap();
11501173

@@ -1271,7 +1294,7 @@ mod tests {
12711294
manifest_store.clone(),
12721295
);
12731296
let result = flusher
1274-
.flush_with_indexes(&memtable, epoch, &index_configs)
1297+
.flush_with_indexes(&memtable, epoch, &index_configs, 1)
12751298
.await
12761299
.unwrap();
12771300

rust/lance/src/dataset/mem_wal/wal.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ impl std::fmt::Debug for BatchDurableWatcher {
9494
/// A single WAL entry representing a batch of batches.
9595
#[derive(Debug, Clone)]
9696
pub struct WalEntry {
97-
/// WAL entry position (0-based, sequential).
97+
/// WAL entry position (1-based, sequential — see `FIRST_WAL_ENTRY_POSITION`).
9898
pub position: u64,
9999
/// Writer epoch at the time of write.
100100
pub writer_epoch: u64,
@@ -664,7 +664,12 @@ impl WalEntryData {
664664
// Generic WAL Appender and Tailer
665665
// ============================================================================
666666

667-
const FIRST_WAL_ENTRY_POSITION: u64 = 0;
667+
/// First valid WAL entry position. Positions are 1-based so that a
668+
/// `ShardManifest::replay_after_wal_entry_position` of 0 unambiguously means
669+
/// "no flush has ever stamped the cursor" — replay then starts at position 1
670+
/// without needing to consult `flushed_generations`, which an external
671+
/// compactor may legitimately drain back to empty.
672+
const FIRST_WAL_ENTRY_POSITION: u64 = 1;
668673
const MAX_APPEND_CREATE_CONFLICTS: usize = 1024;
669674
const APPEND_CONFLICT_REFRESH_INTERVAL: usize = 16;
670675
const MAX_CURSOR_PROBE: u64 = 4096;
@@ -1389,8 +1394,9 @@ mod tests {
13891394
let source = batch_store_source(&batch_store);
13901395
let result = buffer.flush(&source, batch_store.len()).await.unwrap();
13911396
let entry = result.entry.unwrap();
1392-
// First entry from a freshly-discovered position is 0 (atomic-create
1393-
// path discovers the tip via list and starts at FIRST_WAL_ENTRY_POSITION).
1397+
// First entry from a freshly-discovered position lands at
1398+
// FIRST_WAL_ENTRY_POSITION (atomic-create path discovers the tip
1399+
// via list).
13941400
assert_eq!(entry.position, FIRST_WAL_ENTRY_POSITION);
13951401
assert_eq!(entry.writer_epoch, 1);
13961402
assert_eq!(entry.num_batches, 2);
@@ -1629,6 +1635,7 @@ mod tests {
16291635
assert!(hint >= 1, "cursor hint never updated, last={hint}");
16301636

16311637
// next_position must still resolve to one past the last appended entry.
1632-
assert_eq!(tailer.next_position().await.unwrap(), 3);
1638+
// Three entries from a fresh shard land at 1, 2, 3, so next is 4.
1639+
assert_eq!(tailer.next_position().await.unwrap(), 4);
16331640
}
16341641
}

0 commit comments

Comments
 (0)