@@ -60,7 +60,8 @@ enum PICT_OP {
6060 PARTIAL_UPDATE_ROW = 6 ,
6161 PARTIAL_UPDATE_COLUMN = 7 ,
6262 CONDITION_UPDATE = 8 ,
63- MAX = 9 ,
63+ MIXED_UPSERT_DELETE = 9 ,
64+ MAX = 10 ,
6465};
6566
6667static const std::string kTestGroupPath = " ./test_lake_primary_key_consistency" ;
@@ -255,6 +256,7 @@ class LakePrimaryKeyConsistencyTest : public TestBase, testing::WithParamInterfa
255256 items.emplace_back (PICT_OP ::PARTIAL_UPDATE_ROW , 5 );
256257 items.emplace_back (PICT_OP ::PARTIAL_UPDATE_COLUMN , 5 );
257258 items.emplace_back (PICT_OP ::CONDITION_UPDATE , 5 );
259+ items.emplace_back (PICT_OP ::MIXED_UPSERT_DELETE , 17 );
258260 _random_op_selector = std::make_unique<WeightedRandomOpSelector<int , PICT_OP >>(_random_generator.get (), items);
259261 _io_failure_generator =
260262 std::make_unique<IOFailureGenerator<int , PICT_OP >>(_random_generator.get (), io_failure_percent);
@@ -283,9 +285,15 @@ class LakePrimaryKeyConsistencyTest : public TestBase, testing::WithParamInterfa
283285 _old_pk_index_parallel_compaction_task_split_threshold_bytes =
284286 config::pk_index_parallel_compaction_task_split_threshold_bytes;
285287 config::pk_index_parallel_compaction_task_split_threshold_bytes = 4 * 1024 * 1024 ;
288+ // Preserve in-transaction upsert/delete order so the Replayer (which replays ops in write order)
289+ // matches the tablet -- required for the interleaved mixed_upsert_delete_op (delete-then-upsert).
290+ // Both the serial (non-spill) and op-aware spill merge paths honor it.
291+ _old_lake_enable_pk_preserve_txn_delete_order = config::lake_enable_pk_preserve_txn_delete_order;
292+ config::lake_enable_pk_preserve_txn_delete_order = true ;
286293 }
287294
288295 void TearDown () override {
296+ config::lake_enable_pk_preserve_txn_delete_order = _old_lake_enable_pk_preserve_txn_delete_order;
289297 (void )fs::remove_all (kTestGroupPath );
290298 config::l0_max_mem_usage = _old_l0_size;
291299 config::write_buffer_size = _old_memtable_size;
@@ -477,6 +485,100 @@ class LakePrimaryKeyConsistencyTest : public TestBase, testing::WithParamInterfa
477485 return Status::OK ();
478486 }
479487
488+ // Build a delete chunk that reuses the first `count` keys of `src` (an upsert chunk), so a delete
489+ // targets keys that were just upserted in the same transaction.
490+ std::pair<ChunkPtr, std::vector<uint32_t >> gen_delete_from (const ChunkPtr& src, size_t count) {
491+ count = std::min<size_t >(count, static_cast <size_t >(src->num_rows ()));
492+ std::vector<std::string> key_strs;
493+ std::vector<Slice> key_col;
494+ std::vector<int > c1v (count);
495+ std::vector<int > c2v (count);
496+ std::vector<uint8_t > ops (count, TOpType::DELETE );
497+ key_strs.reserve (count);
498+ for (size_t i = 0 ; i < count; i++) {
499+ key_strs.emplace_back (src->columns ()[0 ]->get (i).get_slice ().to_string ());
500+ c1v[i] = src->columns ()[1 ]->get (i).get_int32 ();
501+ c2v[i] = src->columns ()[2 ]->get (i).get_int32 ();
502+ }
503+ for (const auto & s : key_strs) {
504+ key_col.emplace_back (s);
505+ }
506+ auto c0 = BinaryColumn::create ();
507+ auto c1 = Int32Column::create ();
508+ auto c2 = Int32Column::create ();
509+ auto c3 = Int8Column::create ();
510+ c0->append_strings (key_col.data (), key_col.size ());
511+ c1->append_numbers (c1v.data (), c1v.size () * sizeof (int ));
512+ c2->append_numbers (c2v.data (), c2v.size () * sizeof (int ));
513+ c3->append_numbers (ops.data (), ops.size () * sizeof (uint8_t ));
514+ auto indexes = std::vector<uint32_t >(count);
515+ for (uint32_t i = 0 ; i < count; i++) {
516+ indexes[i] = i;
517+ }
518+ return {std::make_shared<Chunk>(Columns{std::move (c0), std::move (c1), std::move (c2), std::move (c3)},
519+ _slot_cid_map),
520+ std::move (indexes)};
521+ }
522+
523+ // Apply a random INTERLEAVED sequence of upserts and deletes over a small pool of key sets within one
524+ // transaction, so a given key may be upserted, deleted, and re-upserted in any order. This exercises
525+ // in-transaction order preservation in both directions: a delete followed by a re-upsert of the same
526+ // key must keep the row, and an upsert followed by a delete must drop it. With
527+ // lake_enable_pk_preserve_txn_delete_order on (set in SetUp) and load spill on by default, this runs
528+ // through the op-aware spill merge. The Replayer replays the ops in write order, so any path that
529+ // resolves them out of order is caught.
530+ Status mixed_upsert_delete_op () {
531+ std::unique_ptr<ConfigResetGuard<int64_t >> force_index_mem_flush_guard = random_force_index_mem_flush ();
532+ auto txn_id = next_id ();
533+ ASSIGN_OR_ABORT (auto delta_writer, DeltaWriterBuilder ()
534+ .set_tablet_manager (_tablet_mgr.get ())
535+ .set_tablet_id (_tablet_metadata->id ())
536+ .set_txn_id (txn_id)
537+ .set_partition_id (_partition_id)
538+ .set_mem_tracker (_mem_tracker.get ())
539+ .set_schema_id (_tablet_schema->id ())
540+ .set_slot_descriptors (&_slot_pointers)
541+ .set_profile (&_dummy_runtime_profile)
542+ .build ());
543+ RETURN_IF_ERROR (delta_writer->open ());
544+ // A pool of fresh key sets to interleave over; reusing them forces upserts and deletes to hit the
545+ // same keys, so their in-transaction order actually matters.
546+ size_t pool_size = std::max<size_t >(static_cast <size_t >(_random_generator->random () % MaxUpsert), 1 );
547+ std::vector<ChunkPtr> key_pool;
548+ for (size_t i = 0 ; i < pool_size; i++) {
549+ key_pool.emplace_back (gen_upsert_data (true ).first );
550+ }
551+ // At least 2 steps so an interleaving actually occurs.
552+ size_t steps = std::max<size_t >(static_cast <size_t >(_random_generator->random () % (MaxUpsert * 2 )), 2 );
553+ for (size_t s = 0 ; s < steps; s++) {
554+ const auto & base = key_pool[_random_generator->random () % key_pool.size ()];
555+ if (base->num_rows () == 0 ) {
556+ continue ;
557+ }
558+ if ((_random_generator->random () % 2 ) == 0 ) {
559+ // Upsert the pool chunk's keys.
560+ std::vector<uint32_t > indexes (base->num_rows ());
561+ for (uint32_t i = 0 ; i < base->num_rows (); i++) {
562+ indexes[i] = i;
563+ }
564+ RETURN_IF_ERROR (delta_writer->write (*base, indexes.data (), indexes.size ()));
565+ _replayer->upsert (base);
566+ } else {
567+ // Delete the pool chunk's keys.
568+ auto chunk_index = gen_delete_from (base, base->num_rows ());
569+ RETURN_IF_ERROR (delta_writer->write (*(chunk_index.first ), chunk_index.second .data (),
570+ chunk_index.second .size ()));
571+ _replayer->erase (chunk_index.first );
572+ }
573+ }
574+ RETURN_IF_ERROR (delta_writer->finish_with_txnlog ());
575+ delta_writer->close ();
576+ // Publish version
577+ RETURN_IF_ERROR (publish_single_version (_tablet_metadata->id (), _version + 1 , txn_id));
578+ _version++;
579+ return Status::OK ();
580+ }
581+
480582 Status partial_update_op (PartialUpdateMode mode) {
481583 std::unique_ptr<ConfigResetGuard<int64_t >> force_index_mem_flush_guard = random_force_index_mem_flush ();
482584 std::unique_ptr<ConfigResetGuard<bool >> pk_index_eager_build_guard = random_pk_index_eager_build ();
@@ -679,6 +781,9 @@ class LakePrimaryKeyConsistencyTest : public TestBase, testing::WithParamInterfa
679781 case CONDITION_UPDATE :
680782 st = condition_update ();
681783 break ;
784+ case MIXED_UPSERT_DELETE :
785+ st = mixed_upsert_delete_op ();
786+ break ;
682787 default :
683788 break ;
684789 }
@@ -731,6 +836,7 @@ class LakePrimaryKeyConsistencyTest : public TestBase, testing::WithParamInterfa
731836 int64_t _old_pk_index_eager_build_threshold_bytes = 0 ;
732837 int64_t _old_pk_index_parallel_execution_min_rows = 0 ;
733838 int64_t _old_pk_index_parallel_compaction_task_split_threshold_bytes = 0 ;
839+ bool _old_lake_enable_pk_preserve_txn_delete_order = false ;
734840};
735841
736842TEST_P (LakePrimaryKeyConsistencyTest, test_local_pk_consistency) {
0 commit comments