Peers: pending local updates suppress heartbeats during full resync · Issue #3518 · haproxy/haproxy · GitHub
Skip to content

Peers: pending local updates suppress heartbeats during full resync #3518

Open

Description

@brenc

Detailed Description of the Problem

A full peer resynchronization can repeatedly disconnect and restart if the receiving node has a pending local table update. The learner sends no heartbeats even though normal updates are deferred until learning finishes. In a two-node loopback reproduction, the connection closes about every five seconds and the receiving table never finishes populating.

This reproduces without any reload and with only one peers section, so it is separate from #3517. I found it while investigating resynchronization of million-entry tables, then reduced it to 100,000 entries and a paced loopback TCP relay. The relay forwards both directions continuously at roughly 200 KiB/s; it does not disconnect or drop bytes.

Observed controls:

  • Unmodified 3.4.6: fails identically; the table plateaus at 28,994 entries with tx_hbt=0.
  • 3.4.3: local writes during learning reproduce the loop with tx_hbt=0.
  • 3.4.6 carrying only the Multiple peers sections lose stick tables on reload (shared dont_stop state) #3517 patch, which does not touch this path: local writes reproduce the loop and the test exits nonzero.
  • The same 3.4.6 binary with an idle learner: all 100,000 entries synchronize in roughly 22 seconds, heartbeats are sent, and the connection stays established.
  • With the candidate fix below: 100,001 entries synchronize with local writes enabled, and the deferred learner-originated write reaches the teacher afterward. Tested with plain TCP and one thread (about 22 seconds) and with mutual TLS and eight threads (about 31 seconds, including a startup connection collision).

Expected Behavior

Full resynchronization should finish while local requests update tables. Updates that cannot yet be sent should not suppress the heartbeat needed to keep the synchronization connection alive.

Steps to Reproduce the Behavior

Save the standalone Python script below as test-haproxy-peer-resync.py and run:

python3 test-haproxy-peer-resync.py /absolute/path/to/haproxy
# Control: same paced synchronization without concurrent local writes
python3 test-haproxy-peer-resync.py /absolute/path/to/haproxy --idle
# Mutual TLS and multiple threads (requires openssl)
python3 test-haproxy-peer-resync.py /absolute/path/to/haproxy --tls --threads 8

The script uses temporary configs, dynamic loopback ports, and two HAProxy processes that it owns and terminates. It seeds 100,000 IPv6 entries on A before starting B, paces transport so synchronization takes longer than five seconds, and updates a distinct key on B once per second. It checks full table size, sampled values, and deferred replication back to A. Failed runs print peer states and traces and exit nonzero. Python 3.13 was used.

Standalone reproducer
#!/usr/bin/env python3
"""Test peer heartbeats during slow full resynchronization with local writes.

Uses two isolated HAProxy processes and paced loopback relays. No reload is
involved. A successful run must learn every seeded entry,
retain the receiving node's marker, and replicate its deferred write back.
Requires Python 3.13, loopback socket access, and openssl for optional mTLS.
"""

import argparse
import os
from pathlib import Path
import re
import signal
import socket
import subprocess
import tempfile
import threading
import time


MARKER = "2001:db8:ffff::1"


def command(path: Path, request: str) -> str:
    """Send request to a runtime socket; return its reply or raise on I/O errors."""
    with socket.socket(socket.AF_UNIX) as client:
        client.settimeout(10)
        client.connect(str(path))
        client.sendall((request + "\n").encode())
        chunks: list[bytes] = []
        while chunk := client.recv(65536):
            chunks.append(chunk)
        return b"".join(chunks).decode()


def listener() -> socket.socket:
    """Reserve and return an IPv4 loopback listening socket on a free port."""
    server = socket.socket()
    server.bind(("127.0.0.1", 0))
    server.listen()
    return server


class Relay:
    """Pace both directions of loopback TCP sessions without inspecting payloads.

    The caller owns the listener. close() stops accepting, closes active sockets,
    and joins worker threads. Failed connections are closed, not retried.
    """

    def __init__(self, server: socket.socket, destination: int, delay: float):
        """Start forwarding server to destination, delaying each 1 KiB by seconds."""
        self.server = server
        self.destination = destination
        self.delay = delay
        self.stopped = threading.Event()
        self.lock = threading.Lock()
        self.sockets: set[socket.socket] = set()
        self.workers: list[threading.Thread] = []
        self.thread = threading.Thread(target=self._accept, daemon=True)
        self.thread.start()

    def _accept(self) -> None:
        """Accept loopback connections and start one worker for each direction."""
        self.server.settimeout(0.2)
        while not self.stopped.is_set():
            try:
                client, _ = self.server.accept()
            except socket.timeout:
                continue
            except OSError:
                break
            try:
                remote = socket.create_connection(("127.0.0.1", self.destination), 1)
                remote.settimeout(None)
            except OSError:
                client.close()
                continue
            with self.lock:
                self.sockets.update((client, remote))
            for source, target in ((client, remote), (remote, client)):
                worker = threading.Thread(
                    target=self._forward, args=(source, target), daemon=True,
                )
                self.workers.append(worker)
                worker.start()

    def _forward(self, source: socket.socket, target: socket.socket) -> None:
        """Copy paced chunks, closing both directions on EOF or a socket error."""
        try:
            while not self.stopped.is_set():
                chunk = source.recv(1024)
                if not chunk:
                    break
                target.sendall(chunk)
                self.stopped.wait(self.delay)
        except OSError:
            pass
        finally:
            for connection in (source, target):
                try:
                    connection.shutdown(socket.SHUT_RDWR)
                except OSError:
                    pass
                connection.close()
                with self.lock:
                    self.sockets.discard(connection)

    def close(self) -> None:
        """Stop the relay and release its listener, sockets, and worker threads."""
        self.stopped.set()
        self.server.close()
        self.thread.join(timeout=2)
        with self.lock:
            connections = list(self.sockets)
        for connection in connections:
            try:
                connection.shutdown(socket.SHUT_RDWR)
            except OSError:
                pass
            connection.close()
        for worker in self.workers:
            worker.join(timeout=2)


def key(index: int) -> str:
    """Return a unique documentation-only IPv6 key for a nonnegative index."""
    return f"2001:db8::{index >> 16:x}:{index & 65535:x}"


def run(binary: Path, entries: int, threads: int, delay: float,
        timeout: int, tls: bool, idle: bool) -> bool:
    """Exercise binary with paced peer traffic; return whether resync succeeds.

    entries controls the initial table size; threads sets each node's workers;
    delay is seconds per relay chunk; timeout bounds learning after node B
    starts. tls enables mutual TLS; idle disables writes during learning for
    a control experiment. Temporary files and owned processes are always cleaned.
    Raises on startup, configuration, runtime socket, or subprocess errors.
    """
    with tempfile.TemporaryDirectory(prefix="ha-resync-") as directory:
        root = Path(directory)
        reserved = [listener() for _ in range(4)]
        ports = [server.getsockname()[1] for server in reserved]
        processes: list[subprocess.Popen[str]] = []
        relays: list[Relay] = []
        sockets = [root / "a.sock", root / "b.sock"]
        try:
            incoming = outgoing = ""
            if tls:
                subprocess.run([
                    "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
                    "-keyout", str(root / "key.pem"), "-out", str(root / "cert.pem"),
                    "-days", "1", "-subj", "/CN=lab",
                ], check=True, capture_output=True)
                (root / "node.pem").write_bytes(
                    (root / "cert.pem").read_bytes() + (root / "key.pem").read_bytes(),
                )
                incoming = f" ssl crt {root}/node.pem verify required ca-file {root}/cert.pem"
                outgoing = f"  default-server ssl verify required ca-file {root}/cert.pem crt {root}/node.pem\n"
            for index in range(2):
                relays.append(Relay(reserved[index + 2], ports[index], delay))
            for index, node in enumerate(("a", "b")):
                config = root / f"{node}.cfg"
                config.write_text(f"""global
  localpeer {node}
  nbthread {threads}
  stats socket {sockets[index]} level admin
peers synced
  bind 127.0.0.1:{ports[index]}{incoming}
{outgoing}  server {node}
  server {'b' if node == 'a' else 'a'} 127.0.0.1:{ports[3 - index]}
  table guard type ipv6 size {entries + 100} expire 1h store gpt(1),http_req_rate(1m),http_err_rate(1m)
""")
                reserved[index].close()
                with (root / f"{node}.log").open("w") as log:
                    process = subprocess.Popen(
                        [str(binary), "-db", "-f", str(config)],
                        stdout=log, stderr=log, text=True, start_new_session=True,
                    )
                processes.append(process)
                deadline = time.monotonic() + 10
                while not sockets[index].exists():
                    if process.poll() is not None or time.monotonic() > deadline:
                        raise RuntimeError((root / f"{node}.log").read_text())
                    time.sleep(0.05)
                command(sockets[index], "trace peers event none event sess_end event sess_shut event sess_err event proto_error event sess_resync level state sink buf0 start now")
                if index == 0:
                    for offset in range(0, entries, 100):
                        request = ";".join(
                            f"set table synced/guard key {key(i)} data.gpt[0] 93"
                            for i in range(offset, min(offset + 100, entries))
                        )
                        reply = command(sockets[0], request)
                        if reply.strip():
                            raise RuntimeError(f"Seeding failed: {reply[:200]}")
                    # Wait for the source to finish its initial sync timeout.
                    deadline = time.monotonic() + 15
                    while True:
                        flags = re.search(r"disabled=0 flags=(0x[0-9a-f]+)", command(sockets[0], "show peers"))
                        if flags and int(flags[1], 16) & 3 == 3:
                            break
                        if time.monotonic() > deadline:
                            raise RuntimeError("Source did not finish initial sync")
                        time.sleep(0.1)
            started = time.monotonic()
            last_write = 0
            observed_learning = False
            while time.monotonic() - started < timeout:
                time.sleep(1)
                if not idle:
                    last_write += 1
                    command(sockets[1], f"set table synced/guard key {MARKER} data.gpt[0] {last_write}")
                peers = command(sockets[1], "show peers")
                observed_learning |= "learn_state=PROCESSING" in peers
                flags = re.search(r"disabled=0 flags=(0x[0-9a-f]+)", peers)
                used = re.search(r"used:(\d+)", command(sockets[1], "show table"))
                summary = [line.strip() for line in peers.splitlines() if "(remote," in line or "tx_hbt=" in line]
                elapsed = time.monotonic() - started
                print(f"elapsed={elapsed:.1f}s used={used[1] if used else '?'} " + " ".join(summary), flush=True)
                if flags and int(flags[1], 16) & 3 == 3 and used and int(used[1]) == entries + (not idle):
                    if not observed_learning or elapsed < 8:
                        raise RuntimeError("Resync was too fast; increase entries or relay delay")
                    for sample in (0, entries // 2, entries - 1):
                        if "gpt0=93" not in command(sockets[1], f"show table synced/guard key {key(sample)}"):
                            raise RuntimeError("Learned sample value is incorrect")
                    deadline = time.monotonic() + 10
                    while not idle:
                        response = command(sockets[0], f"show table synced/guard key {MARKER}")
                        value = re.search(r"gpt0=(\d+)", response)
                        if value and int(value[1]) == last_write:
                            break
                        if time.monotonic() > deadline:
                            raise RuntimeError("Deferred local write did not reach source")
                        time.sleep(0.1)
                    print("PASS: full resync completed; sampled values verified"
                          + ("" if idle else "; deferred write verified"), flush=True)
                    return True
            print("FAIL: full resync did not finish", flush=True)
            for index, node in enumerate(("a", "b")):
                print(f"--- {node} ---\n" + command(sockets[index], "show peers"))
                print(command(sockets[index], "show events buf0"))
            return False
        finally:
            for process in processes:
                if process.poll() is None:
                    os.killpg(process.pid, signal.SIGTERM)
                    process.wait(timeout=5)
            for relay in relays:
                relay.close()
            for server in reserved:
                server.close()


def main() -> None:
    """Parse test options and exit nonzero on failed synchronization or errors."""
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("binary", type=Path)
    parser.add_argument("--entries", type=int, default=100000)
    parser.add_argument("--threads", type=int, default=1)
    parser.add_argument("--delay", type=float, default=0.005)
    parser.add_argument("--timeout", type=int, default=45)
    parser.add_argument("--tls", action="store_true")
    parser.add_argument("--idle", action="store_true")
    args = parser.parse_args()
    if args.entries < 1000 or args.threads < 1 or args.delay <= 0 or args.timeout < 10:
        parser.error("entries >= 1000, threads >= 1, delay > 0, timeout >= 10 required")
    raise SystemExit(0 if run(args.binary.resolve(), args.entries, args.threads,
                            args.delay, args.timeout, args.tls, args.idle) else 1)


if __name__ == "__main__":
    main()

Do you have any idea what may have caused this?

__process_running_peer_sync() treats st->last_pushed != st->table->localupdate as evidence that updates will be sent. It clears PEER_F_HEARTBEAT and postpones the heartbeat timer in that case.

But peer_send_msgs() only sends normal incremental updates when the peer is not learning (learnstate == PEER_LR_ST_NOTASSIGNED) and not in the teaching branch (PEER_F_TEACH_PROCESS clear). While learning, a local write creates a pending update that cannot be sent yet, but it still suppresses heartbeats.

Why that closes the connection after about five seconds, with line numbers from unmodified 3.4.6 src/peers.c:

  1. While learnstate == PEER_LR_ST_PROCESSING the learner does not advance st->last_get (line 1829), so it sends no acks either.
  2. Heartbeats are therefore the learner's only outgoing traffic, and the pending local update suppresses them.
  3. The peers frontend runs with independent streams (PR_O2_INDEPSTR, line 3442). sc_ep_report_send_activity() in include/haproxy/stconn.h only reports read activity when SC_FL_INDEP_STR is clear, so the teacher's continuous sending does not refresh its own read timeout.
  4. The frontend's client and server timeouts are hardcoded to 5 s (lines 3438 and 3439). The teacher hears nothing for 5 s and closes the session.

This is also why tx_hbt and no_hbt both stay at zero in the traces: the explicit dead-peer path never runs, the stream simply times out on the teacher.

There is a related case on the teacher: after PEER_F_TEACH_FINISHED is set, it waits for the resync confirmation before resuming normal updates, and PEER_F_TEACH_PROCESS stays set during that wait. The pending local-update comparison can continue suppressing heartbeats there. A learner-only condition fixed the plain-TCP test but was not enough for the paced TLS test; checking both learning and teaching state passed both.

Source inspection shows the same unmatched conditions in upstream master at 88576119c7efbda508c5d65c1f418f5fdeb8fd97; I have not run this reproducer against a master build.

Do you have an idea how to solve the issue?

Only suppress heartbeats for normal incremental updates when those updates are eligible to be sent. During learning or teaching, keep scheduling heartbeats. This adds the two state conditions that the normal-update branch of peer_send_msgs() already uses:

--- a/src/peers.c
+++ b/src/peers.c
@@ -3707,7 +3707,9 @@
 
 					/* Awake session if there is data to push */
 					for (st = peer->tables; st ; st = st->next) {
-						if (st->last_pushed != st->table->localupdate) {
+						if (peer->learnstate == PEER_LR_ST_NOTASSIGNED &&
+						    !(peer->flags & PEER_F_TEACH_PROCESS) &&
+						    st->last_pushed != st->table->localupdate) {
 							/* wake up the peer handler to push local updates */
 							update_to_push = 1;
 							/* There is no need to send a heartbeat message

This is a candidate fix for maintainer review, not an assertion that every full-resync edge case is covered. With this patch, 101 applicable upstream peer/TLS/Lua/HTTP-rule/QUIC regression tests passed, 13 were skipped, and none failed. The #3517 reproducer also still retains both tables through five reloads with eight threads.

What is your configuration?

The reproducer generates this configuration for each node (ports and socket paths differ). Both peer directions go through the paced relay. There is no reload, no second peers section, and no application backend:

global
  localpeer a
  nbthread 1
  stats socket /tmp/example/a.sock level admin
peers synced
  bind 127.0.0.1:19001
  server a
  server b 127.0.0.1:19004
  table guard type ipv6 size 100100 expire 1h store gpt(1),http_req_rate(1m),http_err_rate(1m)

The --tls variant adds a temporary self-signed CA/certificate and verify required on both bind and default-server. Its keys are created only inside the temporary test directory and deleted afterward.

Output of haproxy -vv

Unmodified 3.4.6 built with make TARGET=linux-glibc USE_OPENSSL= USE_LUA= USE_PCRE2=:

HAProxy version 3.4.6-56332c5 2026/09/28 - https://haproxy.org/
Status: long-term supported branch - will stop receiving fixes around Q2 2031.
Known bugs: http://www.haproxy.org/bugs/bugs-3.4.6.html
Running on: Linux 6.12.107+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.107-1 (2026-08-29) x86_64
Build options : 
  TARGET  = linux-glibc
  CC      = cc
  CFLAGS  = -O2 -g -fwrapv -fvect-cost-model=very-cheap
  OPTIONS = USE_OPENSSL= USE_LUA= USE_PCRE2=
  DEBUG   = 

Feature list : -51DEGREES +ACCEPT4 +BACKTRACE -CLOSEFROM +CPU_AFFINITY +CRYPT_H -DEVICEATLAS +DL -ECH -ENGINE +EPOLL -EVPORTS +GETADDRINFO +HAVE_TCP_MD5SIG -KQUEUE +KTLS -LIBATOMIC +LIBCRYPT +LINUX_CAP +LINUX_SPLICE +LINUX_TPROXY -LUA -MATH -MEMORY_PROFILING +NETFILTER +NS -OBSOLETE_LINKER -OPENSSL -OPENSSL_AWSLC -OPENSSL_WOLFSSL -OT -PCRE -PCRE2 -PCRE2_JIT -PCRE_JIT +POLL +PRCTL -PROCCTL -PROMEX -PTHREAD_EMULATION -QUIC -QUIC_OPENSSL_COMPAT +RT +SHM_OPEN +SLZ -SSL -STATIC_PCRE -STATIC_PCRE2 +TFO +THREAD +THREAD_DUMP +TPROXY +TRACE -WURFL -ZLIB
Detected feature list : +HAVE_WORKING_TCP_MD5SIG

Default settings :
  bufsize = 16384, maxrewrite = 1024, maxpollevents = 200

Built with multi-threading support (MAX_TGROUPS=32, MAX_THREADS=1024, default=8).
Built with network namespace support.
Built with libslz for stateless compression.
Compression algorithms supported : identity("identity"), deflate("deflate"), raw-deflate("deflate"), gzip("gzip")
Built with transparent proxy support using: IP_TRANSPARENT IPV6_TRANSPARENT IP_FREEBIND
Built without PCRE or PCRE2 support (using libc's regex instead)
Encrypted password support via crypt(3): yes
Built with gcc compiler version 14.2.0

Available polling systems :
      epoll : pref=300,  test result OK
       poll : pref=200,  test result OK
     select : pref=150,  test result OK
Total: 3 (3 usable), will use epoll.

Available multiplexer protocols :
(protocols marked as <default> cannot be specified using 'proto' keyword)
         h2 : mode=HTTP  side=FE|BE  mux=H2    flags=HTX|HOL_RISK|NO_UPG
  <default> : mode=HTTP  side=FE|BE  mux=H1    flags=HTX
         h1 : mode=HTTP  side=FE|BE  mux=H1    flags=HTX|NO_UPG
       fcgi : mode=HTTP  side=BE     mux=FCGI  flags=HTX|HOL_RISK|NO_UPG
  <default> : mode=SPOP  side=BE     mux=SPOP  flags=HOL_RISK|NO_UPG
       spop : mode=SPOP  side=BE     mux=SPOP  flags=HOL_RISK|NO_UPG
  <default> : mode=TCP   side=FE|BE  mux=PASS  flags=
       none : mode=TCP   side=FE|BE  mux=PASS  flags=NO_UPG

Available services : none

Available filters :
	[BWLIM] bwlim-in
	[BWLIM] bwlim-out
	[CACHE] cache
	[COMP] comp-req
	[COMP] comp-res
	[COMP] compression
	[FCGI] fcgi-app
	[SPOE] spoe
	[TRACE] trace

The 3.4.3 build on which the problem was first observed:

HAProxy version 3.4.3-80ea565fd 2026/07/29 - https://haproxy.org/
Status: long-term supported branch - will stop receiving fixes around Q2 2031.
Known bugs: http://www.haproxy.org/bugs/bugs-3.4.3.html
Running on: Linux 6.12.107+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.107-1 (2026-08-29) x86_64
Build options : 
  TARGET  = linux-glibc
  CC      = cc
  CFLAGS  = -O2 -g -fwrapv -fvect-cost-model=very-cheap
  OPTIONS = USE_GETADDRINFO=1 USE_OPENSSL_AWSLC=1 USE_LUA=1 USE_QUIC=1 USE_PROMEX=1 USE_PCRE2=1 USE_PCRE2_JIT=1
  DEBUG   = -DDEBUG_STRICT -DDEBUG_STRICT_ACTION -DDEBUG_MEMORY_POOLS -DDEBUG_DONT_SHARE_POOLS -DDEBUG_POOL_INTEGRITY

Feature list : -51DEGREES +ACCEPT4 +ACME +BACKTRACE -CLOSEFROM +CPU_AFFINITY +CRYPT_H -DEVICEATLAS +DL -ECH -ENGINE +EPOLL -EVPORTS +GETADDRINFO +HAVE_TCP_MD5SIG -KQUEUE +KTLS -LIBATOMIC +LIBCRYPT +LINUX_CAP +LINUX_SPLICE +LINUX_TPROXY +LUA +MATH -MEMORY_PROFILING +NETFILTER +NS -OBSOLETE_LINKER +OPENSSL +OPENSSL_AWSLC -OPENSSL_WOLFSSL -OT -PCRE +PCRE2 +PCRE2_JIT -PCRE_JIT +POLL +PRCTL -PROCCTL +PROMEX -PTHREAD_EMULATION +QUIC -QUIC_OPENSSL_COMPAT +RT +SHM_OPEN +SLZ +SSL -STATIC_PCRE -STATIC_PCRE2 +TFO +THREAD +THREAD_DUMP +TPROXY +TRACE -WURFL -ZLIB
Detected feature list :

Default settings :
  bufsize = 16384, maxrewrite = 1024, maxpollevents = 200

Built with multi-threading support (MAX_TGROUPS=32, MAX_THREADS=1024, default=8).
Built with SSL library version : OpenSSL 1.1.1 (compatible; AWS-LC 4.0.0)
Running on SSL library version : AWS-LC 4.0.0
SSL library supports TLS extensions : yes
SSL library supports SNI : yes
SSL library FIPS mode : no
SSL library default verify directory : /etc/ssl/certs
SSL library supports : TLSv1.0 TLSv1.1 TLSv1.2 TLSv1.3
QUIC: connection sock-per-conn mode support : unknown
QUIC: GSO emission support : unknown
Built with Lua version : Lua 5.4.7
Built with the Prometheus exporter as a service
Built with network namespace support.
Built with libslz for stateless compression.
Compression algorithms supported : identity("identity"), deflate("deflate"), raw-deflate("deflate"), gzip("gzip")
Built with transparent proxy support using: IP_TRANSPARENT IPV6_TRANSPARENT IP_FREEBIND
Built with PCRE2 version : 10.46 2025-08-27
PCRE2 library supports JIT : yes
Encrypted password support via crypt(3): yes
Built with gcc compiler version 14.2.0

Available polling systems :
      epoll : pref=300,  test result OK
       poll : pref=200,  test result OK
     select : pref=150,  test result OK
Total: 3 (3 usable), will use epoll.

Available multiplexer protocols :
(protocols marked as <default> cannot be specified using 'proto' keyword)
       qmux : mode=HTTP  side=FE|BE  mux=QMUX  flags=HTX|NO_UPG
       quic : mode=HTTP  side=FE|BE  mux=QUIC  flags=HTX|NO_UPG|FRAMED
         h2 : mode=HTTP  side=FE|BE  mux=H2    flags=HTX|HOL_RISK|NO_UPG
  <default> : mode=HTTP  side=FE|BE  mux=H1    flags=HTX
         h1 : mode=HTTP  side=FE|BE  mux=H1    flags=HTX|NO_UPG
       fcgi : mode=HTTP  side=BE     mux=FCGI  flags=HTX|HOL_RISK|NO_UPG
  <default> : mode=SPOP  side=BE     mux=SPOP  flags=HOL_RISK|NO_UPG
       spop : mode=SPOP  side=BE     mux=SPOP  flags=HOL_RISK|NO_UPG
  <default> : mode=TCP   side=FE|BE  mux=PASS  flags=
       none : mode=TCP   side=FE|BE  mux=PASS  flags=NO_UPG

Available services : prometheus-exporter
Available filters :
	[BWLIM] bwlim-in
	[BWLIM] bwlim-out
	[CACHE] cache
	[COMP] comp-req
	[COMP] comp-res
	[COMP] compression
	[FCGI] fcgi-app
	[SPOE] spoe
	[TRACE] trace

Last Outputs and Backtraces

Representative failing learner (3.4.6 with only the #3517 patch):

learn_state=PROCESSING last_status=ESTA
flags=0x20900005
used:18401 out of 100001 expected entries
tx_hbt=0 rx_hbt=0 no_hbt=0 new_conn=5 proto_err=0

The table plateaus while connections repeatedly restart. The local writes use a distinct key, so they do not overwrite entries being learned. No crash or backtrace is involved.

Additional Information

#3521 describes the same hardcoded five-second timeout and independent-stream behaviour on the local reload handover path, where the new worker has no heartbeat scheduling at all. The two fixes are independent and touch different code paths.

I searched existing peer/heartbeat/full-resync reports. The nearby #3234 was a different shutdown-handling defect in the peer applet, where a peer listener stopped responding, and #1216 concerns repeated reloads. Neither covers this no-reload reproduction.

Field results: a 3.4.6 build carrying this patch together with the fixes proposed in #3517, #3519, #3520 and #3521 was deployed to a four-node production cluster on 2026-10-02. Each node has two peers sections, the nodes talk over mTLS, and the replicated tables hold 1.3 to 2.4 million entries each. After deployment, reloading the nodes one at a time and then all four at once preserved every checked entry and test marker. The local handovers took 15 to 33 seconds and the new worker sent three to six progress heartbeats (#3521) during each.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions