Altinity / Altinity/clickhouse-regression

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

Open
#113 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

26.1-antalya
Dominant language
Python
Stars
27
Forks
10
Avg merge
1m
Merged PRs (30d)
2

Description

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

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with commit() in src/Storages/MergeTree/ExportPartitionUtils.cpp and killExportPartition() in src/Storages/StorageReplicatedMergeTree.cpp; trace their ZooKeeper status reads and writes. Add a deterministic-delay integration test that races completion with KILL EXPORT PARTITION, then verify the final status remains KILLED and no successful finalization is recorded.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
databases, distributed-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
50/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.