Skip to content

EXPORT PARTITION: KILLED status can be overwritten to COMPLETED due to race condition #113

Description

@Selfeer

Summary

A race condition in replicated partition export allows the KILLED terminal status to be overwritten to COMPLETED when KILL EXPORT PARTITION is issued concurrently with the last part's completion/commit. This violates the kill contract — cancellation is acknowledged to the operator, but the export is still finalized and committed.

Severity

High — cancellation is a control-plane safety mechanism. Fail-open behavior after acknowledged kill can cause unexpected data movement side effects.

Root Cause

The commit path in ExportPartitionUtils::commit() writes status=COMPLETED using trySet(..., -1) (unconditional version), which overwrites any concurrent status change. Meanwhile, killExportPartition() correctly uses compare-and-set (trySet(..., stat.version)) to transition PENDING → KILLED, but nothing prevents the commit path from subsequently overwriting KILLED → COMPLETED.

Commit path (src/Storages/MergeTree/ExportPartitionUtils.cpp):

destination_storage->commitExportPartitionTransaction(manifest.transaction_id, manifest.partition_id, exported_paths, context);

LOG_INFO(log, "ExportPartition: Committed export, mark as completed");
if (Coordination::Error::ZOK == zk->trySet(fs::path(entry_path) / "status",
    String(magic_enum::enum_name(ExportReplicatedMergeTreePartitionTaskEntry::Status::COMPLETED)).data(), -1))
{
    LOG_INFO(log, "ExportPartition: Marked export as completed");
}

Kill path (src/Storages/StorageReplicatedMergeTree.cpp):

if (status_from_zk.value() != ExportReplicatedMergeTreePartitionTaskEntry::Status::PENDING)
{
    LOG_INFO(log, "Export partition task is {}, can not cancel it",
        String(magic_enum::enum_name(status_from_zk.value())));
    return CancellationCode::CancelCannotBeSent;
}

if (zk->trySet(status_path,
    String(magic_enum::enum_name(ExportReplicatedMergeTreePartitionTaskEntry::Status::KILLED)),
    stat.version) != Coordination::Error::ZOK)

The problem: the commit path uses version -1 (unconditional overwrite), so even if killExportPartition has already successfully set the status to KILLED, the commit path blindly overwrites it to COMPLETED.

Reproduction Steps

  1. Start a replicated partition export with multiple parts.
  2. Delay destination writes so the completion callback timing is controllable.
  3. Issue KILL EXPORT PARTITION <txid> after the last part upload begins but before the commit path writes the status.
  4. Observe that KILL returns success, but the commit path subsequently sets status=COMPLETED.
  5. Final ZooKeeper status is COMPLETED instead of KILLED.

Expected Behavior

Once KILLED is set, it must remain terminal. The commit path should fail-closed if the status has been changed from PENDING to KILLED during execution.

Suggested Fix Direction

Use compare-and-set semantics when writing COMPLETED in the commit path — only transition if the current status is still PENDING. If the status has changed (e.g., to KILLED), skip the commit finalization or roll back:

// Read current status + version before committing
auto [current_status, stat] = zk->get(fs::path(entry_path) / "status");

if (current_status != magic_enum::enum_name(ExportReplicatedMergeTreePartitionTaskEntry::Status::PENDING))
{
    LOG_WARNING(log, "ExportPartition: Status changed to {}, skipping commit finalization", current_status);
    return;
}

destination_storage->commitExportPartitionTransaction(...);

if (zk->trySet(fs::path(entry_path) / "status",
    String(magic_enum::enum_name(ExportReplicatedMergeTreePartitionTaskEntry::Status::COMPLETED)).data(),
    stat.version) != Coordination::Error::ZOK)
{
    LOG_WARNING(log, "ExportPartition: Failed to mark as COMPLETED (status was concurrently modified)");
    // Handle rollback or mark as indeterminate
}

Additionally, consider checking status before calling commitExportPartitionTransaction() to avoid committing data that should have been cancelled.

Suggested Test Direction

  • Integration test with deterministic delay injection + concurrent KILL EXPORT PARTITION.
  • Assert that the final ZooKeeper status remains KILLED after the race.
  • Assert that no terminal successful finalize is recorded.
  • Stress test with concurrent exports/kills across replicas with ZooKeeper fault injection.

Affected Components

  • src/Storages/MergeTree/ExportPartitionUtils.cppcommit() function
  • src/Storages/StorageReplicatedMergeTree.cppkillExportPartition() function
  • Replicated export partition orchestration subsystem

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions