Mooncake/mooncake-transfer-engine/tent/tests/progress_worker_test.cpp

488 lines
18 KiB
C++

// Copyright 2026 KVCache.AI
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Tests the ProgressWorker skeleton (issue #2116, follow-up to PR #2160).
// Goals:
// * default-off behavior is byte-identical to the pre-worker world;
// * with the worker enabled and enable_auto_failover_on_poll=false, a
// caller that only submits + observes status (never calls
// progressBatch / waitTransferCompletion) still sees its batch
// progress through failover;
// * one notify advances the engine by exactly one progress step;
// * freeBatch racing the worker is safe (no UAF, no crash);
// * worker shuts down cleanly on engine destruction.
#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include "tent/common/config.h"
#include "tent/common/types.h"
#include "tent/runtime/segment.h"
#include "tent/runtime/transfer_engine_impl.h"
#include "tent/runtime/transport.h"
#include "tent/transport/fault_proxy/fault_proxy_transport.h"
namespace mooncake {
namespace tent {
namespace {
// ---------------------------------------------------------------------------
// FakeTransport — same minimal shape used by engine_failover_e2e_test.cpp.
// Kept local to avoid cross-test linkage; sources of truth diverging is OK
// because we only exercise the "completes / status-can-be-overridden" surface.
// ---------------------------------------------------------------------------
class FakeSubBatch : public Transport::SubBatch {
public:
size_t size() const override { return task_count; }
size_t task_count = 0;
std::vector<Request> requests;
std::vector<TransferStatus> statuses;
std::vector<int> poll_counts;
};
class FakeTransport : public Transport {
public:
using StatusFactory = std::function<TransferStatus(const Request&)>;
using PollStatusFactory =
std::function<TransferStatus(const Request&, int)>;
explicit FakeTransport(TransportType self_type,
StatusFactory status_factory = {},
PollStatusFactory poll_status_factory = {})
: self_type_(self_type),
status_factory_(std::move(status_factory)),
poll_status_factory_(std::move(poll_status_factory)) {
caps.dram_to_dram = true;
}
std::atomic<int> install_calls{0};
std::atomic<int> submit_calls{0};
std::atomic<int> status_calls{0};
std::atomic<int> add_mem_calls{0};
Status install(std::string& /*local_segment_name*/,
std::shared_ptr<ControlService> /*metadata*/,
std::shared_ptr<Topology> /*local_topology*/,
std::shared_ptr<Config> /*conf*/ = nullptr) override {
++install_calls;
return Status::OK();
}
Status allocateSubBatch(SubBatchRef& batch, size_t /*max_size*/) override {
batch = new FakeSubBatch();
return Status::OK();
}
Status freeSubBatch(SubBatchRef& batch) override {
delete batch;
batch = nullptr;
return Status::OK();
}
Status submitTransferTasks(
SubBatchRef batch, const std::vector<Request>& request_list) override {
++submit_calls;
auto* fb = static_cast<FakeSubBatch*>(batch);
for (const auto& req : request_list) {
if (status_factory_) {
fb->statuses.push_back(status_factory_(req));
} else {
fb->statuses.push_back(
{TransferStatusEnum::COMPLETED, req.length});
}
fb->requests.push_back(req);
fb->poll_counts.push_back(0);
fb->task_count++;
}
return Status::OK();
}
Status getTransferStatus(SubBatchRef batch, int task_id,
TransferStatus& status) override {
++status_calls;
auto* fb = static_cast<FakeSubBatch*>(batch);
if (task_id < 0 || task_id >= (int)fb->statuses.size()) {
return Status::InvalidArgument("bad task_id" LOC_MARK);
}
++fb->poll_counts[task_id];
if (poll_status_factory_) {
status = poll_status_factory_(fb->requests[task_id],
fb->poll_counts[task_id]);
} else {
status = fb->statuses[task_id];
}
return Status::OK();
}
Status addMemoryBuffer(BufferDesc& desc,
const MemoryOptions& /*options*/) override {
++add_mem_calls;
desc.transports.push_back(self_type_);
return Status::OK();
}
Status addMemoryBuffer(std::vector<BufferDesc>& desc_list,
const MemoryOptions& options) override {
for (auto& d : desc_list) {
auto s = addMemoryBuffer(d, options);
if (!s.ok()) return s;
}
return Status::OK();
}
Status removeMemoryBuffer(BufferDesc& /*desc*/) override {
return Status::OK();
}
Status allocateLocalMemory(void** addr, size_t size,
MemoryOptions& /*options*/) override {
*addr = std::malloc(size);
if (!*addr) return Status::InternalError("malloc failed" LOC_MARK);
return Status::OK();
}
Status freeLocalMemory(void* addr, size_t /*size*/) override {
std::free(addr);
return Status::OK();
}
bool warmupMemory(void* /*addr*/, size_t /*length*/) override {
return false;
}
const char* getName() const override {
return self_type_ == RDMA ? "<fake-rdma>" : "<fake-tcp>";
}
private:
TransportType self_type_;
StatusFactory status_factory_;
PollStatusFactory poll_status_factory_;
};
std::shared_ptr<Config> makeMinimalP2PConfig() {
auto cfg = std::make_shared<Config>();
cfg->set("metadata_type", "p2p");
cfg->set("metadata_servers", "");
cfg->set("rpc_server_hostname", "127.0.0.1");
cfg->set("rpc_server_port", "0");
cfg->set("log_level", "warning");
cfg->set("merge_requests", false);
cfg->set("transports/tcp/enable", false);
cfg->set("transports/shm/enable", false);
cfg->set("transports/rdma/enable", false);
cfg->set("transports/io_uring/enable", false);
cfg->set("transports/nvlink/enable", false);
cfg->set("transports/mnnvl/enable", false);
cfg->set("transports/gds/enable", false);
cfg->set("transports/ascend_direct/enable", false);
cfg->set("max_failover_attempts", 3);
return cfg;
}
// ---------------------------------------------------------------------------
// 1. Default config: worker is not constructed, notifyBatchMaybeReady is a
// no-op, and behavior matches PR #2160 exactly.
// ---------------------------------------------------------------------------
TEST(ProgressWorker, DisabledByDefaultLeavesBehaviorUnchanged) {
auto cfg = makeMinimalP2PConfig();
TransferEngineImpl engine(cfg);
ASSERT_TRUE(engine.available());
auto fake_rdma = std::make_shared<FakeTransport>(RDMA);
auto fake_tcp = std::make_shared<FakeTransport>(TCP);
std::string seg = engine.getSegmentName();
ASSERT_TRUE(fake_rdma->install(seg, nullptr, nullptr).ok());
ASSERT_TRUE(fake_tcp->install(seg, nullptr, nullptr).ok());
engine.swapTransportForTest(RDMA, fake_rdma);
engine.swapTransportForTest(TCP, fake_tcp);
constexpr size_t kBufLen = 4096;
std::vector<uint8_t> buf(kBufLen, 0x10);
ASSERT_TRUE(engine.registerLocalMemory(buf.data(), kBufLen).ok());
BatchID batch_id = engine.allocateBatch(1);
ASSERT_NE(batch_id, (BatchID)0);
Request req;
req.opcode = Request::WRITE;
req.source = buf.data();
req.target_id = LOCAL_SEGMENT_ID;
req.target_offset = reinterpret_cast<uint64_t>(buf.data());
req.length = kBufLen;
ASSERT_TRUE(engine.submitTransfer(batch_id, {req}).ok());
// No-op when the worker isn't constructed.
engine.notifyBatchMaybeReady(batch_id);
engine.notifyBatchMaybeReady((BatchID)0);
TransferStatus status{};
ASSERT_TRUE(engine.getTransferStatus(batch_id, status).ok());
EXPECT_EQ(status.s, TransferStatusEnum::COMPLETED);
EXPECT_EQ(fake_tcp->submit_calls.load(), 0);
EXPECT_TRUE(engine.freeBatch(batch_id).ok());
EXPECT_TRUE(engine.unregisterLocalMemory(buf.data(), kBufLen).ok());
}
// ---------------------------------------------------------------------------
// 2. Worker drives failover when the caller does not poll with
// allow_failover. This is the integration shape mooncake-pg needs.
// ---------------------------------------------------------------------------
TEST(ProgressWorker, ProgressesWithoutPollAutoFailover) {
auto cfg = makeMinimalP2PConfig();
cfg->set("enable_auto_failover_on_poll", false);
cfg->set("enable_progress_worker", true);
TransferEngineImpl engine(cfg);
ASSERT_TRUE(engine.available());
auto fake_rdma = std::make_shared<FakeTransport>(RDMA);
auto fake_tcp = std::make_shared<FakeTransport>(TCP);
FaultPolicy rdma_policy;
rdma_policy.status_corrupt_rate = 1.0;
auto proxied_rdma =
std::make_shared<FaultProxyTransport>(fake_rdma, rdma_policy);
std::string seg = engine.getSegmentName();
ASSERT_TRUE(proxied_rdma->install(seg, nullptr, nullptr).ok());
ASSERT_TRUE(fake_tcp->install(seg, nullptr, nullptr).ok());
engine.swapTransportForTest(RDMA, proxied_rdma);
engine.swapTransportForTest(TCP, fake_tcp);
constexpr size_t kBufLen = 4096;
std::vector<uint8_t> buf(kBufLen, 0xC1);
ASSERT_TRUE(engine.registerLocalMemory(buf.data(), kBufLen).ok());
BatchID batch_id = engine.allocateBatch(1);
ASSERT_NE(batch_id, (BatchID)0);
Request req;
req.opcode = Request::WRITE;
req.source = buf.data();
req.target_id = LOCAL_SEGMENT_ID;
req.target_offset = reinterpret_cast<uint64_t>(buf.data());
req.length = kBufLen;
ASSERT_TRUE(engine.submitTransfer(batch_id, {req}).ok());
// Drive the worker until terminal. We deliberately never call
// progressBatch / waitTransferCompletion here — only
// notifyBatchMaybeReady + observation-only getTransferStatus
// (which, with auto-failover-on-poll disabled, will not advance failover
// by itself).
TransferStatus status{};
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::milliseconds(2000);
while (std::chrono::steady_clock::now() < deadline) {
engine.notifyBatchMaybeReady(batch_id);
std::this_thread::sleep_for(std::chrono::milliseconds(5));
if (fake_tcp->submit_calls.load() == 0) continue;
status = {};
ASSERT_TRUE(engine.getTransferStatus(batch_id, status).ok());
if (status.s == TransferStatusEnum::COMPLETED) break;
}
EXPECT_EQ(status.s, TransferStatusEnum::COMPLETED)
<< "progress worker must drive failover when caller never calls "
"progressBatch";
EXPECT_EQ(fake_rdma->submit_calls.load(), 1);
EXPECT_GE(fake_tcp->submit_calls.load(), 1);
EXPECT_TRUE(engine.freeBatch(batch_id).ok());
EXPECT_TRUE(engine.unregisterLocalMemory(buf.data(), kBufLen).ok());
}
// ---------------------------------------------------------------------------
// 3. One notify == one progress step. The worker must not loop internally
// until completion; the next step requires another notify.
// ---------------------------------------------------------------------------
TEST(ProgressWorker, SingleNotifyAdvancesOneStep) {
auto cfg = makeMinimalP2PConfig();
cfg->set("enable_auto_failover_on_poll", false);
cfg->set("enable_progress_worker", true);
TransferEngineImpl engine(cfg);
ASSERT_TRUE(engine.available());
auto fake_rdma = std::make_shared<FakeTransport>(
RDMA, FakeTransport::StatusFactory{},
[](const Request& req, int poll_count) {
if (poll_count < 3) {
return TransferStatus{TransferStatusEnum::PENDING, 0};
}
return TransferStatus{TransferStatusEnum::COMPLETED, req.length};
});
auto fake_tcp = std::make_shared<FakeTransport>(TCP);
std::string seg = engine.getSegmentName();
ASSERT_TRUE(fake_rdma->install(seg, nullptr, nullptr).ok());
ASSERT_TRUE(fake_tcp->install(seg, nullptr, nullptr).ok());
engine.swapTransportForTest(RDMA, fake_rdma);
engine.swapTransportForTest(TCP, fake_tcp);
constexpr size_t kBufLen = 4096;
std::vector<uint8_t> buf(kBufLen, 0xC2);
ASSERT_TRUE(engine.registerLocalMemory(buf.data(), kBufLen).ok());
BatchID batch_id = engine.allocateBatch(1);
ASSERT_NE(batch_id, (BatchID)0);
Request req;
req.opcode = Request::WRITE;
req.source = buf.data();
req.target_id = LOCAL_SEGMENT_ID;
req.target_offset = reinterpret_cast<uint64_t>(buf.data());
req.length = kBufLen;
ASSERT_TRUE(engine.submitTransfer(batch_id, {req}).ok());
// Initial state: nothing polled yet.
EXPECT_EQ(fake_rdma->status_calls.load(), 0);
// Drive exactly one progress step via the worker. We can't observe the
// step instantly, but we can wait until status_calls increments by 1
// and then assert it does NOT keep climbing to 3 on its own.
engine.notifyBatchMaybeReady(batch_id);
const auto step_deadline =
std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
while (std::chrono::steady_clock::now() < step_deadline &&
fake_rdma->status_calls.load() == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
ASSERT_EQ(fake_rdma->status_calls.load(), 1)
<< "worker should issue exactly one poll for a single notify";
// Give the worker a generous window to misbehave. status_calls must
// stay at 1 because we did not notify again and the engine did not
// reach a terminal state.
std::this_thread::sleep_for(std::chrono::milliseconds(50));
EXPECT_EQ(fake_rdma->status_calls.load(), 1)
<< "worker must not loop internally until completion";
EXPECT_TRUE(engine.freeBatch(batch_id).ok());
EXPECT_TRUE(engine.unregisterLocalMemory(buf.data(), kBufLen).ok());
}
// ---------------------------------------------------------------------------
// 4. freeBatch races with worker notifications. With ASAN/UBSAN this
// catches missing alive_batches_ / progress_mutex_ coverage.
// ---------------------------------------------------------------------------
TEST(ProgressWorker, FreeBatchRacesWithWorker) {
auto cfg = makeMinimalP2PConfig();
cfg->set("enable_auto_failover_on_poll", false);
cfg->set("enable_progress_worker", true);
TransferEngineImpl engine(cfg);
ASSERT_TRUE(engine.available());
auto fake_rdma = std::make_shared<FakeTransport>(RDMA);
auto fake_tcp = std::make_shared<FakeTransport>(TCP);
std::string seg = engine.getSegmentName();
ASSERT_TRUE(fake_rdma->install(seg, nullptr, nullptr).ok());
ASSERT_TRUE(fake_tcp->install(seg, nullptr, nullptr).ok());
engine.swapTransportForTest(RDMA, fake_rdma);
engine.swapTransportForTest(TCP, fake_tcp);
constexpr size_t kBufLen = 4096;
std::vector<uint8_t> buf(kBufLen, 0xC3);
ASSERT_TRUE(engine.registerLocalMemory(buf.data(), kBufLen).ok());
// Concurrently spam stale notifications from a second thread while the
// main thread submits, frees, and re-allocates batches.
std::atomic<bool> stop{false};
std::atomic<BatchID> latest{0};
std::thread spammer([&] {
while (!stop.load(std::memory_order_acquire)) {
BatchID bid = latest.load(std::memory_order_acquire);
engine.notifyBatchMaybeReady(bid);
std::this_thread::sleep_for(std::chrono::microseconds(50));
}
});
constexpr int kRounds = 100;
for (int i = 0; i < kRounds; ++i) {
BatchID batch_id = engine.allocateBatch(1);
ASSERT_NE(batch_id, (BatchID)0);
Request req;
req.opcode = Request::WRITE;
req.source = buf.data();
req.target_id = LOCAL_SEGMENT_ID;
req.target_offset = reinterpret_cast<uint64_t>(buf.data());
req.length = kBufLen;
ASSERT_TRUE(engine.submitTransfer(batch_id, {req}).ok());
latest.store(batch_id, std::memory_order_release);
engine.notifyBatchMaybeReady(batch_id);
// Free immediately; the worker may pick the notification up after
// free. The progress_mutex_ + alive_batches_ guard must keep this
// safe.
EXPECT_TRUE(engine.freeBatch(batch_id).ok());
}
stop.store(true, std::memory_order_release);
spammer.join();
EXPECT_TRUE(engine.unregisterLocalMemory(buf.data(), kBufLen).ok());
}
// ---------------------------------------------------------------------------
// 5. Engine teardown joins the worker cleanly even with pending notifies.
// ---------------------------------------------------------------------------
TEST(ProgressWorker, EngineDestructorJoinsWorker) {
auto cfg = makeMinimalP2PConfig();
cfg->set("enable_auto_failover_on_poll", false);
cfg->set("enable_progress_worker", true);
{
TransferEngineImpl engine(cfg);
ASSERT_TRUE(engine.available());
auto fake_rdma = std::make_shared<FakeTransport>(RDMA);
auto fake_tcp = std::make_shared<FakeTransport>(TCP);
std::string seg = engine.getSegmentName();
ASSERT_TRUE(fake_rdma->install(seg, nullptr, nullptr).ok());
ASSERT_TRUE(fake_tcp->install(seg, nullptr, nullptr).ok());
engine.swapTransportForTest(RDMA, fake_rdma);
engine.swapTransportForTest(TCP, fake_tcp);
// Push some notifies for non-existent batches; worker must reject
// them via alive_batches_ check and stay alive.
for (int i = 0; i < 8; ++i) {
engine.notifyBatchMaybeReady((BatchID)(uintptr_t)0xdeadbeef);
}
}
// If teardown hangs or crashes here, gtest fails this test on timeout
// / signal — no further assert needed.
SUCCEED();
}
} // namespace
} // namespace tent
} // namespace mooncake