Skip to content

Commit 0578853

Browse files
authored
[data_storage] avoid false NODE_NOT_REGISTERED during heartbeat (#302)
1 parent d24fb6d commit 0578853

4 files changed

Lines changed: 135 additions & 15 deletions

File tree

docs/design/report_event_snapshot_uri_version.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,14 @@ snapshot replace + commit/abort
384384
lifecycle 写入;BatchMerge 的 block-create 与 targeted-location 两个 RMW 阶段之间同样释放并
385385
重新获取 lease;
386386
- mutation 在已经持有 metadata 锁时只做上述非阻塞的 per-reporter lifecycle lease 获取,
387-
避免与 cleanup 的 `lifecycle -> metadata` 顺序形成锁序反转;不同 reporter 使用独立 fence,
388-
不会因其他 host 的 HEARTBEAT/REGISTER 产生假失败;
387+
避免与 cleanup 的 `lifecycle -> metadata` 顺序形成锁序反转。lifecycle fence 不存在、reporter
388+
已注销、generation 不匹配,或 `try_lock` 与 REGISTER、HOST_DOWN、unavailable recovery 等
389+
unique lifecycle writer 冲突时,返回 `NODE_NOT_REGISTERED`;不同 reporter 使用独立 fence;
390+
- 已注册且可用的 steady HEARTBEAT 不改变 registration/generation,因此持有 shared lifecycle
391+
lease 完成心跳时间、状态与指标发布;同 generation 的 ADD/DELETE 可以同时取得 shared lease,
392+
REGISTER、HOST_DOWN 和 unavailable recovery 等 lifecycle transition 则持有 unique lease;
393+
- 同一 reporter 的 HEARTBEAT 应保持 single-flight。曾评估在等待 status 锁前释放 node-table 锁,
394+
但这会允许重叠 HEARTBEAT 乱序发布完整 status snapshot,因此未采用;
389395
- liveness unregister 的 generation 比较与节点删除在同一把锁内完成;
390396
- 显式 HOST_DOWN 的 generation 捕获与节点删除同样在同一把锁内完成,Heartbeat/REGISTER
391397
只能在线性化的 HOST_DOWN 之前或之后生效,不能在中间恢复后又被旧请求删除;

kv_cache_manager/data_storage/event_report_backend.cc

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -511,11 +511,29 @@ ErrorCode EventReportBackend::OnHeartbeat(const std::string &instance_id,
511511
}
512512
const ReporterSnapshotKey reporter_key{instance_id, host_ip_port};
513513
const auto lifecycle_fence = GetOrCreateLifecycleFence(reporter_key);
514+
515+
// Fast path: a steady HEARTBEAT does not change lifecycle state. Use a
516+
// shared lease so same-generation ADD/DELETE can proceed; it still pins
517+
// NodeInfo and excludes REGISTER/HOST_DOWN writers.
518+
{
519+
std::shared_lock<std::shared_mutex> lifecycle_lock(lifecycle_fence->mutex);
520+
if (!AcceptingReports()) {
521+
return EC_INSTANCE_NOT_EXIST;
522+
}
523+
std::unique_lock<std::shared_mutex> nodes_lock(nodes_mutex_);
524+
if (TryPublishSteadyHeartbeatLocked(reporter_key, *lifecycle_fence, system_status, nodes_lock)) {
525+
return EC_OK;
526+
}
527+
}
528+
529+
// Slow path: HEARTBEAT may create or recover a node and change lifecycle
530+
// state. Drop the shared lease, acquire it exclusively, and revalidate
531+
// because shared_mutex has no atomic lock upgrade.
514532
std::unique_lock<std::shared_mutex> lifecycle_lock(lifecycle_fence->mutex);
515533
if (!AcceptingReports()) {
516534
return EC_INSTANCE_NOT_EXIST;
517535
}
518-
std::unique_lock<std::shared_mutex> lock(nodes_mutex_);
536+
std::unique_lock<std::shared_mutex> nodes_lock(nodes_mutex_);
519537
auto &host_map = instance_nodes_[instance_id];
520538
auto it = host_map.find(host_ip_port);
521539
if (it == host_map.end()) {
@@ -563,16 +581,47 @@ ErrorCode EventReportBackend::OnHeartbeat(const std::string &instance_id,
563581
}
564582
lifecycle_fence->generation = node_generation_[instance_id][host_ip_port];
565583
lifecycle_fence->registered = true;
584+
PublishHeartbeatStatus(info, system_status, nodes_lock);
585+
return EC_OK;
586+
}
587+
588+
bool EventReportBackend::TryPublishSteadyHeartbeatLocked(const ReporterSnapshotKey &reporter_key,
589+
const LifecycleFence &lifecycle_fence,
590+
const std::map<std::string, std::string> &system_status,
591+
std::unique_lock<std::shared_mutex> &nodes_lock) {
592+
if (!lifecycle_fence.registered) {
593+
return false;
594+
}
595+
const auto instance_it = instance_nodes_.find(reporter_key.instance_id);
596+
const auto generation_it = node_generation_.find(reporter_key.instance_id);
597+
if (instance_it == instance_nodes_.end() || generation_it == node_generation_.end()) {
598+
return false;
599+
}
600+
const auto node_it = instance_it->second.find(reporter_key.host_ip_port);
601+
const auto host_generation_it = generation_it->second.find(reporter_key.host_ip_port);
602+
if (node_it == instance_it->second.end() || !node_it->second || host_generation_it == generation_it->second.end() ||
603+
lifecycle_fence.generation != host_generation_it->second ||
604+
!node_it->second->available.load(std::memory_order_relaxed)) {
605+
return false;
606+
}
607+
auto &info = *node_it->second;
608+
info.last_heartbeat_ms.store(NowMillis(), std::memory_order_release);
609+
PublishHeartbeatStatus(info, system_status, nodes_lock);
610+
return true;
611+
}
612+
613+
void EventReportBackend::PublishHeartbeatStatus(NodeInfo &info,
614+
const std::map<std::string, std::string> &system_status,
615+
std::unique_lock<std::shared_mutex> &nodes_lock) {
566616
std::unique_lock<std::mutex> status_lock(info.status_mutex);
567617
const std::map<std::string, std::string> previous_system_status = info.last_system_status;
568618
info.last_system_status = system_status;
569619
const auto metrics_tags = info.metrics_tags;
570620

571-
// The per-reporter lifecycle writer keeps NodeInfo alive and prevents
572-
// HOST_DOWN/REGISTER from crossing gauge publication. The status lock
573-
// serializes SetNodeUnavailable's gauge reset. Release the global node
574-
// table lock so metric work for one reporter does not block all others.
575-
lock.unlock();
621+
// Lock handoff: status_mutex now serializes this publication with
622+
// SetNodeUnavailable's gauge reset, while the lifecycle lease pins
623+
// NodeInfo. Release the global node-table lock before slower metric work.
624+
nodes_lock.unlock();
576625
if (metrics_registry_) {
577626
const auto parse_gauge = [](const std::string &value, double &out) {
578627
if (value.empty()) {
@@ -607,7 +656,6 @@ ErrorCode EventReportBackend::OnHeartbeat(const std::string &instance_id,
607656
}
608657
}
609658
}
610-
return EC_OK;
611659
}
612660

613661
void EventReportBackend::SetNodeUnavailable(const std::string &instance_id, const std::string &host_ip_port) {
@@ -1126,6 +1174,9 @@ ErrorCode EventReportBackend::AcquireLifecycleMutationLease(const ReporterSnapsh
11261174
if (!lifecycle_fence) {
11271175
return EC_NODE_NOT_REGISTERED;
11281176
}
1177+
// ADD/DELETE mutate metadata, not reporter lifecycle. This shared lease
1178+
// pins and validates the current registration/generation while the
1179+
// metadata RMW is in progress.
11291180
auto lease = std::make_shared<std::shared_lock<std::shared_mutex>>(lifecycle_fence->mutex, std::try_to_lock);
11301181
if (!lease->owns_lock()) {
11311182
// Do not wait behind a lifecycle writer while the caller may already
@@ -1151,10 +1202,11 @@ ErrorCode EventReportBackend::CommitSnapshotVersionIfGeneration(const ReporterSn
11511202
return EC_NODE_NOT_REGISTERED;
11521203
}
11531204
// Commit runs after the metadata RMW has released its shard locks, so it
1154-
// can safely wait for a transient HEARTBEAT writer. Using the mutation
1205+
// can safely wait for a transient lifecycle writer. Using the mutation
11551206
// path's try-lock here would turn harmless lock contention into a failed
1156-
// snapshot. REGISTER/HOST_DOWN still serialize first and are rejected by
1157-
// the generation/registered check below.
1207+
// snapshot. REGISTER/HOST_DOWN and a lifecycle-changing HEARTBEAT still
1208+
// serialize first and are rejected by the generation/registered check
1209+
// below.
11581210
std::shared_lock<std::shared_mutex> lifecycle_lease(lifecycle_fence->mutex);
11591211
if (!AcceptingReports()) {
11601212
return EC_INSTANCE_NOT_EXIST;
@@ -1197,9 +1249,9 @@ ErrorCode EventReportBackend::AcquireSnapshotCleanupLease(const ReporterSnapshot
11971249
return EC_MISMATCH;
11981250
}
11991251
// Cleanup acquires this lease before taking metadata locks. It can block
1200-
// behind a short-lived HEARTBEAT/REGISTER writer without creating the
1201-
// lifecycle->metadata / metadata->lifecycle inversion that forces delta
1202-
// mutations to use try_lock.
1252+
// behind a short-lived REGISTER or lifecycle-changing HEARTBEAT writer
1253+
// without creating the lifecycle->metadata / metadata->lifecycle
1254+
// inversion that forces delta mutations to use try_lock.
12031255
auto lease = std::make_shared<std::shared_lock<std::shared_mutex>>(lifecycle_fence->mutex);
12041256
if (!AcceptingReports() || !lifecycle_fence->registered || lifecycle_fence->generation != expected_generation) {
12051257
return EC_MISMATCH;

kv_cache_manager/data_storage/event_report_backend.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,21 @@ class EventReportBackend : public DataStorageBackend {
188188
MetricsTags metrics_tags;
189189
};
190190

191+
// Caller holds the reporter lifecycle lease and nodes_mutex_. Publishes
192+
// and returns true only when HEARTBEAT does not change lifecycle. Success
193+
// releases nodes_lock; failure leaves it held.
194+
bool TryPublishSteadyHeartbeatLocked(const ReporterSnapshotKey &reporter_key,
195+
const LifecycleFence &lifecycle_fence,
196+
const std::map<std::string, std::string> &system_status,
197+
std::unique_lock<std::shared_mutex> &nodes_lock);
198+
199+
// Caller holds the reporter lifecycle lease and nodes_mutex_. This method
200+
// hands protection to status_mutex, releases the global node-table lock,
201+
// and publishes status and metrics while the lifecycle lease pins NodeInfo.
202+
void PublishHeartbeatStatus(NodeInfo &info,
203+
const std::map<std::string, std::string> &system_status,
204+
std::unique_lock<std::shared_mutex> &nodes_lock);
205+
191206
void LivenessCheckerLoop();
192207
void ClearNodeGauges(const NodeInfo &info);
193208
static int64_t NowMillis() {

kv_cache_manager/data_storage/test/event_report_backend_test.cc

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ TEST_F(EventReportBackendTest, OnHeartbeatRefreshesAndRevivesNode) {
246246
EventReportBackend backend(metrics_registry_);
247247
ASSERT_EQ(EC_OK, backend.Open(MakeConfig(/*hb*/ 200, /*grace*/ 5000, /*tick*/ 50), "trace"));
248248
ASSERT_EQ(EC_OK, backend.RegisterNode("test_inst", "10.0.0.3:8080", {"mem"}));
249+
const uint64_t registered_generation = backend.GetNodeGeneration("test_inst", "10.0.0.3:8080");
249250

250251
int64_t initial_hb = 0;
251252
{
@@ -258,6 +259,7 @@ TEST_F(EventReportBackendTest, OnHeartbeatRefreshesAndRevivesNode) {
258259

259260
std::this_thread::sleep_for(20ms);
260261
ASSERT_EQ(EC_OK, backend.OnHeartbeat("test_inst", "10.0.0.3:8080", {{"version", "er-0.18"}}));
262+
ASSERT_EQ(registered_generation, backend.GetNodeGeneration("test_inst", "10.0.0.3:8080"));
261263
{
262264
auto &host_map = backend.instance_nodes_["test_inst"];
263265
auto it = host_map.find("10.0.0.3:8080");
@@ -268,6 +270,7 @@ TEST_F(EventReportBackendTest, OnHeartbeatRefreshesAndRevivesNode) {
268270
backend.SetNodeUnavailable("test_inst", "10.0.0.3:8080");
269271
ASSERT_FALSE(backend.IsNodeAvailable("test_inst", "10.0.0.3:8080"));
270272
ASSERT_EQ(EC_OK, backend.OnHeartbeat("test_inst", "10.0.0.3:8080", {}));
273+
ASSERT_GT(backend.GetNodeGeneration("test_inst", "10.0.0.3:8080"), registered_generation);
271274
{
272275
auto &host_map = backend.instance_nodes_["test_inst"];
273276
auto it = host_map.find("10.0.0.3:8080");
@@ -283,6 +286,50 @@ TEST_F(EventReportBackendTest, OnHeartbeatRefreshesAndRevivesNode) {
283286
ASSERT_EQ(EC_OK, backend.Close());
284287
}
285288

289+
TEST_F(EventReportBackendTest, SteadyHeartbeatDoesNotRejectConcurrentLifecycleMutationLease) {
290+
EventReportBackend backend(metrics_registry_);
291+
const ReporterSnapshotKey reporter_key{"heartbeat-mutation-lease", "10.0.0.32:8080"};
292+
ASSERT_EQ(EC_OK, backend.RegisterNode(reporter_key.instance_id, reporter_key.host_ip_port, {"mem"}));
293+
const uint64_t generation = backend.GetNodeGeneration(reporter_key.instance_id, reporter_key.host_ip_port);
294+
295+
auto &node = *backend.instance_nodes_[reporter_key.instance_id][reporter_key.host_ip_port];
296+
node.last_heartbeat_ms.store(0, std::memory_order_relaxed);
297+
298+
// Pin HEARTBEAT after it refreshes the timestamp but before it publishes
299+
// system status. At that point it still holds its lifecycle lease, so the
300+
// concurrent mutation below exercises the exact production overlap
301+
// without relying on scheduler timing or sleeps.
302+
std::unique_lock<std::mutex> status_gate(node.status_mutex);
303+
auto heartbeat = std::async(std::launch::async, [&] {
304+
return backend.OnHeartbeat(reporter_key.instance_id, reporter_key.host_ip_port, {{"load", "1"}});
305+
});
306+
307+
const auto deadline = std::chrono::steady_clock::now() + 1s;
308+
while (node.last_heartbeat_ms.load(std::memory_order_acquire) == 0 && std::chrono::steady_clock::now() < deadline) {
309+
std::this_thread::yield();
310+
}
311+
const bool heartbeat_reached_status_gate = node.last_heartbeat_ms.load(std::memory_order_acquire) != 0;
312+
if (!heartbeat_reached_status_gate) {
313+
status_gate.unlock();
314+
EXPECT_EQ(EC_OK, heartbeat.get());
315+
FAIL() << "heartbeat did not reach the status publication gate";
316+
}
317+
318+
// A steady heartbeat is a lifecycle reader, not an unfenced operation:
319+
// lifecycle writers must still wait until its status publication ends.
320+
const auto lifecycle_fence = backend.GetOrCreateLifecycleFence(reporter_key);
321+
std::unique_lock<std::shared_mutex> lifecycle_writer(lifecycle_fence->mutex, std::try_to_lock);
322+
EXPECT_FALSE(lifecycle_writer.owns_lock());
323+
324+
EventReportBackend::LifecycleMutationLease mutation_lease;
325+
const ErrorCode mutation_ec = backend.AcquireLifecycleMutationLease(reporter_key, generation, mutation_lease);
326+
327+
mutation_lease.reset();
328+
status_gate.unlock();
329+
EXPECT_EQ(EC_OK, heartbeat.get());
330+
EXPECT_EQ(EC_OK, mutation_ec);
331+
}
332+
286333
TEST_F(EventReportBackendTest, DataMutationsDoNotRefreshHeartbeat) {
287334
EventReportBackend backend(metrics_registry_);
288335
ASSERT_EQ(EC_OK, backend.Open(MakeConfig(/*hb*/ 5000, /*grace*/ 10000, /*tick*/ 50), "trace"));

0 commit comments

Comments
 (0)