@@ -401,3 +401,74 @@ TEST_F(PartitionValidatorTest, CompactionValidAfterSetStartOffset) {
401401 write_compaction (tp_a, std::move (c));
402402 expect_clean (validate ({.tidp = tp_a}));
403403}
404+
405+ // Simulate a prefix truncation between paginated validation calls. Page 1
406+ // validates [0,99] and returns resume_at_offset=0. Before page 2,
407+ // set_start_offset advances start to 100 and removes the [0,99] extent. The
408+ // validator should detect that resume_at is below start_offset and fall back
409+ // to first-page behavior.
410+ TEST_F (PartitionValidatorTest, PrefixTruncationDuringPagination) {
411+ auto o1 = create_object_id ();
412+ auto o2 = create_object_id ();
413+ add_extent (tp_a, o1, 0_o, 99_o);
414+ add_extent (tp_a, o2, 100_o, 199_o);
415+
416+ auto p1 = validate ({.tidp = tp_a, .max_extents = 1 });
417+ expect_clean (p1);
418+ ASSERT_TRUE (p1.resume_at_offset .has_value ());
419+
420+ // Simulate prefix truncation: advance start_offset, remove first extent.
421+ write_metadata (tp_a, 100_o, 200_o);
422+ {
423+ auto wb = db_->create_write_batch ();
424+ wb.remove (extent_row_key::encode (tp_a, 0_o), next_seqno ());
425+ db_->apply (std::move (wb)).get ();
426+ }
427+
428+ // Page 2 with stale resume_at_offset: clamped to start_offset=100,
429+ // re-reads [100,199] (exact match, not counted), no more extents.
430+ auto p2 = validate ({.tidp = tp_a, .resume_at_offset = p1.resume_at_offset });
431+ expect_clean (p2);
432+ EXPECT_FALSE (p2.resume_at_offset .has_value ());
433+ }
434+
435+ TEST_F (PartitionValidatorTest, MidExtentStartWithPagination) {
436+ auto o1 = create_object_id ();
437+ auto o2 = create_object_id ();
438+ write_metadata (tp_a, 50_o, 200_o);
439+
440+ // NOTE: first extent falls below start offset.
441+ write_extent (tp_a, 0_o, 99_o, o1);
442+ write_extent (tp_a, 100_o, 199_o, o2);
443+ write_object (o1);
444+ write_object (o2);
445+ write_term (tp_a, model::term_id{1 }, 0_o);
446+
447+ auto p1 = validate ({.tidp = tp_a, .max_extents = 1 });
448+ expect_clean (p1);
449+ EXPECT_EQ (p1.extents_validated , 1 );
450+ ASSERT_TRUE (p1.resume_at_offset .has_value ());
451+
452+ // The returned resume should be the first extent, which is below the start
453+ // offset.
454+ EXPECT_EQ (*p1.resume_at_offset , 0_o);
455+
456+ // We should be able to page through the rest of the partition without
457+ // issues (e.g. no looping on the same extent).
458+ auto p2 = validate (
459+ {.tidp = tp_a,
460+ .resume_at_offset = p1.resume_at_offset ,
461+ .max_extents = 1 });
462+ expect_clean (p2);
463+ EXPECT_TRUE (p2.resume_at_offset .has_value ());
464+ EXPECT_EQ (*p2.resume_at_offset , 100_o);
465+ EXPECT_GE (p2.extents_validated , 1 );
466+
467+ auto p3 = validate (
468+ {.tidp = tp_a,
469+ .resume_at_offset = p2.resume_at_offset ,
470+ .max_extents = 1 });
471+ expect_clean (p3);
472+ EXPECT_FALSE (p3.resume_at_offset .has_value ());
473+ EXPECT_GE (p3.extents_validated , 0 );
474+ }
0 commit comments