@cryptotaxi247 / netdata-1 / commits / c6438952c

Improve stale replication (#21357)

* Implement stuck replication detection and response handling * Fix checks and commit response for not found chart * Initialize replication stuck detection counter and refine replication completion conditions * Refine streaming response handling to prevent data loss during buffer overflow

Stelios Fragkakis committed Nov 30, 2025 at 20:54 UTC c6438952c2b4b393bf1f5c0a7e31c09478e8807a
4 files changed +171 -1
src/database/rrdset-index-id.c
+3
@@ -103,6 +103,9 @@ static void rrdset_insert_callback(const DICTIONARY_ITEM *item __maybe_unused, v
103
104 rw_spinlock_init(&st->alerts.spinlock);
105
106 + // Initialize replication stuck detection counter
107 + st->replication_empty_response_count = 0;
108 +
109 // initialize the db tiers
110 {
111 for(size_t tier = 0; tier < nd_profile.storage_tiers; tier++) {
src/database/rrdset.h
+3
@@ -228,6 +228,9 @@ struct rrdset {
228 } replay;
229 #endif // NETDATA_LOG_REPLICATION_REQUESTS
230
231 + // Replication stuck detection - outside debug flag for production safety
232 + uint8_t replication_empty_response_count; // track consecutive empty responses
233 +
234 SPINLOCK destroy_lock;
235 };
236
src/plugins.d/pluginsd_replication.c
+115
@@ -359,6 +359,9 @@ ALWAYS_INLINE PARSER_RC pluginsd_replay_rrdset_collection_state(char **words, si
359 ALWAYS_INLINE PARSER_RC pluginsd_replay_end(char **words, size_t num_words, PARSER *parser) {
360 if (num_words < 7) { // accepts 7, but the 7th is optional
361 nd_log(NDLS_DAEMON, NDLP_ERR, "REPLAY: malformed " PLUGINSD_KEYWORD_REPLAY_END " command");
362 + RRDSET *st = pluginsd_get_scope_chart(parser);
363 + if(st)
364 + st->replication_empty_response_count = 0;
365 return PARSER_RC_ERROR;
366 }
367
@@ -402,6 +405,10 @@ ALWAYS_INLINE PARSER_RC pluginsd_replay_end(char **words, size_t num_words, PARS
405
406 parser->user.data_collections_count++;
407
408 + // Reset empty response counter when we receive actual data
409 + if(parser->user.replay.rset_enabled && st)
410 + st->replication_empty_response_count = 0;
411 +
412 if(parser->user.replay.rset_enabled && st->rrdhost->receiver) {
413 time_t now = now_realtime_sec();
414 time_t started = st->rrdhost->receiver->replication.first_time_s;
@@ -433,6 +440,9 @@ ALWAYS_INLINE PARSER_RC pluginsd_replay_end(char **words, size_t num_words, PARS
440 st->replay.log_next_data_collection = true;
441 #endif
442
443 + if(start_streaming)
444 + st->replication_empty_response_count = 0;
445 +
446 if (start_streaming) {
447 #ifdef REPLICATION_TRACKING
448 st->stream.rcv.who = REPLAY_WHO_FINISHED;
@@ -465,6 +475,111 @@ ALWAYS_INLINE PARSER_RC pluginsd_replay_end(char **words, size_t num_words, PARS
475 return PARSER_RC_OK;
476 }
477
478 + // ========================================================================
479 + // SAFETY NET: Detect stuck replication loops
480 + // ========================================================================
481 + //
482 + // We received start_streaming=false, which means we need to send another
483 + // replication request. However, we need to detect if we're stuck in an
484 + // infinite retry loop where no progress is being made.
485 + //
486 + // This can happen when:
487 + // 1. Parent already has newer data than child
488 + // 2. Child keeps splitting responses due to buffer constraints
489 + // 3. Network issues causing repeated empty/failed responses
490 +
491 + // Check parent's current retention to detect if we're already caught up
492 + time_t local_first_entry = 0, local_last_entry = 0;
493 + rrdset_get_retention_of_tier_for_collected_chart(
494 + st, &local_first_entry, &local_last_entry, now_realtime_sec(), 0);
495 +
496 + // Detect suspicious pattern: parent requested data but is already caught up
497 + // This indicates we're in a loop where child keeps splitting responses
498 + // even though parent doesn't need more data.
499 + bool parent_already_caught_up = (local_last_entry >= last_entry_child);
500 + bool requested_non_empty_range = (first_entry_requested != 0 || last_entry_requested != 0);
501 + bool is_suspicious_response = (requested_non_empty_range && parent_already_caught_up);
502 +
503 + bool should_check_for_stuck_replication = false;
504 +
505 + // Track consecutive suspicious responses - applies to all builds
506 + if(is_suspicious_response) {
507 + st->replication_empty_response_count++;
508 + // After 3 consecutive suspicious responses, we need to investigate
509 + if(st->replication_empty_response_count >= 3) {
510 + should_check_for_stuck_replication = true;
511 + }
512 + } else {
513 + // Reset counter if this was a legitimate response (parent still catching up)
514 + st->replication_empty_response_count = 0;
515 + }
516 +
517 + if (should_check_for_stuck_replication) {
518 + // We already have local_first_entry and local_last_entry from above
519 +
520 + // Check multiple conditions to ensure we're truly stuck:
521 + //
522 + // Condition 1: Parent has data that covers or exceeds child's retention
523 + // (We already checked this in parent_already_caught_up, but verify again)
524 + bool parent_has_equal_or_newer_data = (local_last_entry >= last_entry_child);
525 +
526 + // Calculate the gap for logging purposes
527 + time_t gap_to_child = (last_entry_child > local_last_entry) ?
528 + (last_entry_child - local_last_entry) : 0;
529 +
530 + // Condition 2: Parent's data is reasonably recent
531 + time_t wall_clock = now_realtime_sec();
532 + bool parent_data_is_recent = (local_last_entry > 0 &&
533 + (wall_clock - local_last_entry) < 300);
534 +
535 + // Only finish replication if parent has equal or newer data than child
536 + // Do NOT terminate if there's any gap, as that would cause data loss
537 + if (parent_has_equal_or_newer_data) {
538 +
539 + // Log with appropriate level based on confidence
540 + ND_LOG_FIELD_PRIORITY level = (parent_has_equal_or_newer_data && parent_data_is_recent) ?
541 + NDLP_INFO : NDLP_WARNING;
542 +
543 + nd_log(NDLS_DAEMON, level,
544 + "PLUGINSD REPLAY: 'host:%s/chart:%s' detected stuck replication loop. "
545 + "Parent last entry: %llu, Child last entry: %llu, Gap: %llu seconds, "
546 + "Empty responses: %u. Forcing replication to finish.",
547 + rrdhost_hostname(host), rrdset_id(st),
548 + (unsigned long long)local_last_entry,
549 + (unsigned long long)last_entry_child,
550 + (unsigned long long)gap_to_child,
551 + (unsigned int)st->replication_empty_response_count
552 + );
553 +
554 + st->replication_empty_response_count = 0;
555 +
556 + // IMPORTANT: Mark as finished and decrement counter NOW, before sending final request.
557 + // This prevents infinite loops even if child continues to respond with start_streaming=false.
558 + // The next REPLAY_END will see FINISHED flag and handle accordingly.
559 + RRDSET_FLAGS old = rrdset_flag_set_and_clear(
560 + st, RRDSET_FLAG_RECEIVER_REPLICATION_FINISHED,
561 + RRDSET_FLAG_RECEIVER_REPLICATION_IN_PROGRESS | RRDSET_FLAG_SYNC_CLOCK);
562 +
563 + if(!(old & RRDSET_FLAG_RECEIVER_REPLICATION_FINISHED)) {
564 + if(rrdhost_receiver_replicating_charts_minus_one(st->rrdhost) == 0)
565 + pulse_host_status(host, PULSE_HOST_STATUS_RCV_RUNNING, 0);
566 + }
567 +
568 + pluginsd_clear_scope_chart(parser, PLUGINSD_KEYWORD_REPLAY_END);
569 + host->stream.rcv.status.replication.percent = 100.0;
570 + worker_set_metric(WORKER_RECEIVER_JOB_REPLICATION_COMPLETION, host->stream.rcv.status.replication.percent);
571 +
572 + // Send one final request to notify child. If child responds with start_streaming=true,
573 + // it will start streaming. If it responds with start_streaming=false, the next
574 + // REPLAY_END will see the FINISHED flag and log a warning but not loop forever.
575 + bool ok = replicate_chart_request(send_to_plugin, parser, host, st,
576 + first_entry_child, last_entry_child, child_world_time,
577 + 0, 0); // prev_wanted = 0,0 to trigger empty request path
578 +
579 + return ok ? PARSER_RC_OK : PARSER_RC_ERROR;
580 + }
581 + }
582 +
583 #ifdef REPLICATION_TRACKING
584 st->stream.rcv.who = REPLAY_WHO_ME;
585 #endif
src/streaming/stream-replication-sender.c
+50 -1
@@ -442,6 +442,16 @@ static bool replication_query_execute(BUFFER *wb, struct replication_query *q, s
442
443 if(buffer_strlen(wb) > max_msg_size && last_end_time_in_buffer) {
444 q->query.before = last_end_time_in_buffer;
445 +
446 + // CRITICAL: When splitting a response due to buffer overflow, we MUST set
447 + // enable_streaming=false to prevent data loss. If we send start_streaming=true
448 + // with partial data, the parent will think replication is complete when we've
449 + // actually truncated the requested interval, causing permanent data loss.
450 + //
451 + // The parent-side stuck detection will handle infinite loops differently by:
452 + // 1. Detecting when no progress is made after multiple rounds
453 + // 2. Explicitly marking replication as FINISHED before sending final request
454 + // 3. Handling the child's response appropriately whether it says true or false
455 q->query.enable_streaming = false;
456
457 internal_error(
@@ -1198,8 +1208,47 @@ static bool replication_execute_request(struct replication_request *rq, bool wor
1208 if(!rq->st) {
1209 __atomic_add_fetch(&replication_globals.atomic.error_not_found, 1, __ATOMIC_RELAXED);
1210 nd_log(NDLS_DAEMON, NDLP_ERR,
1201 - "STREAM SND REPLAY ERROR: 'host:%s/chart:%s' not found",
1211 + "STREAM SND REPLAY ERROR: 'host:%s/chart:%s' not found, sending empty response to unblock parent",
1212 rrdhost_hostname(rq->sender->host), string2str(rq->chart_id));
1213 +
1214 + // CRITICAL: Parent is waiting for a response! We MUST send REPLAY_END even if chart not found
1215 + // Otherwise parent will wait forever with chart stuck in replicating state.
1216 + // Send empty response with start_streaming=true to finish replication for this non-existent chart.
1217 + BUFFER *wb = sender_thread_buffer(rq->sender, REPLICATION_THREAD_BUFFER_INITIAL_SIZE);
1218 +
1219 + bool with_slots = (rq->sender->capabilities & STREAM_CAP_SLOTS) ? true : false;
1220 + NUMBER_ENCODING integer_encoding = (rq->sender->capabilities & STREAM_CAP_IEEE754) ?
1221 + NUMBER_ENCODING_BASE64 : NUMBER_ENCODING_DECIMAL;
1222 +
1223 + buffer_fast_strcat(wb, PLUGINSD_KEYWORD_REPLAY_BEGIN, sizeof(PLUGINSD_KEYWORD_REPLAY_BEGIN) - 1);
1224 + if(with_slots) {
1225 + buffer_fast_strcat(wb, " "PLUGINSD_KEYWORD_SLOT":", sizeof(PLUGINSD_KEYWORD_SLOT) - 1 + 2);
1226 + buffer_print_uint64_encoded(wb, integer_encoding, 0); // slot 0 for unknown chart
1227 + }
1228 + buffer_fast_strcat(wb, " '", 2);
1229 + buffer_fast_strcat(wb, string2str(rq->chart_id), string_strlen(rq->chart_id));
1230 + buffer_fast_strcat(wb, "'\n", 2);
1231 +
1232 + // Send REPLAY_END with empty data and start_streaming=true to unblock parent
1233 + buffer_fast_strcat(wb, PLUGINSD_KEYWORD_REPLAY_END " ", sizeof(PLUGINSD_KEYWORD_REPLAY_END) - 1 + 1);
1234 + buffer_print_int64_encoded(wb, integer_encoding, 0); // update_every
1235 + buffer_fast_strcat(wb, " ", 1);
1236 + buffer_print_uint64_encoded(wb, integer_encoding, 0); // db_first_entry
1237 + buffer_fast_strcat(wb, " ", 1);
1238 + buffer_print_uint64_encoded(wb, integer_encoding, 0); // db_last_entry
1239 + buffer_fast_strcat(wb, " true ", 7); // start_streaming=true (force finish)
1240 + buffer_print_uint64_encoded(wb, integer_encoding, 0); // after
1241 + buffer_fast_strcat(wb, " ", 1);
1242 + buffer_print_uint64_encoded(wb, integer_encoding, 0); // before
1243 + buffer_fast_strcat(wb, " ", 1);
1244 + buffer_print_uint64_encoded(wb, integer_encoding, now_realtime_sec()); // wall_clock_time
1245 + buffer_fast_strcat(wb, "\n", 1);
1246 +
1247 + sender_commit(rq->sender, wb, STREAM_TRAFFIC_TYPE_REPLICATION);
1248 + __atomic_add_fetch(&rq->sender->host->stream.snd.status.replication.counter_out, 1, __ATOMIC_RELAXED);
1249 + replication_replied_add();
1250 +
1251 + ret = true; // Consider this a successful response
1252 goto cleanup;
1253 }
1254 }