When soft-stop is requested, the ring forwarder applet used to close
immediately. This dropped not only the messages still buffered in the
ring, but also every message that in-flight streams keep producing
while soft-stop progresses: with a ring-based log pipeline, several
hundred milliseconds worth of access logs were lost on every reload
(~3400 lines per reload at 3000 req/s in our tests).

Instead, keep the forwarder flushing during soft-stop and close it once
the data plane is drained and the ring has quiesced:

  - drained: no stoppable frontend connection remains, i.e. actconn
    minus the sessions of log-forward (syslog) frontends is zero.
    Syslog sessions must be excluded because they are log consumers
    which never terminate by themselves: when a ring's server points
    to a log-forward section of the same process (a common way to
    fan out logs), the forwarder's own connection would otherwise
    prevent the drain condition from ever being met and the old
    worker would hang until hard-stop-after. Non-stoppable sessions
    (master CLI) are never accounted in actconn, so plain zero is
    the right mark. Note that neither "jobs" nor "unstoppable_jobs"
    can be used here: jobs also counts listeners and appctx
    sessions, and unstoppable_jobs counts the master CLI *listener*,
    not connections, while those CLI connections never appear in
    actconn.

  - quiesced: the drain state is sampled before dispatching, so that
    the messages emitted by the last closing streams are guaranteed
    to be seen by the dispatch (streams emit their final log before
    releasing their connection, and the ring lock orders both sides).
    On top of that the forwarder only closes after the ring stayed
    empty for one extra poll period (20ms), because with chained
    sinks (ring -> log-forward -> log backend in the same process)
    the last messages may still be traversing the local relay when
    the drain is observed.

Since a closing stream does not wake the forwarder, the drain state is
re-checked every SINK_FWD_DRAIN_POLL_MS (20ms). If the ring is not
empty but the output buffer is stuck (wedged or unreachable
downstream), give up after SINK_FWD_WEDGE_MS (1s) without any forward
progress so that a dead consumer cannot hold the process up to
hard-stop-after. If the connection was never established, close
immediately as before since there is nothing to flush to.

Measurements on a production-like pipeline (frontend -> ring ->
log-forward -> log backend, 3000 req/s, one reload every 10s):

  - log loss per reload:  ~3400 lines  -> 0 (15/15 reloads)
  - old worker exit:      ~180ms       -> ~230ms (one extra
    drain-confirmation cycle)
  - ring with two servers (two forwarder sessions): unpatched
    behaviour keeps the old worker alive until hard-stop-after on
    every reload; with this patch it exits in ~230ms
  - dead downstream: immediate close, no change (~170ms)
  - a request outliving the reload (e.g. long poll) now gets its
    final log line delivered; previously the forwarder could close
    while the last connection was still active and its log was lost

This addresses GitHub issue #3436, which contains a minimal
reproducer on a vanilla 3.2.20 build (268-358 lines lost per 3
reloads at 2000 req/s with a 50ms backend, 0 without reloads).

This patch is against master. For backports: 3.2 and older use
sc_opposite(sc)->state < SC_ST_EST instead of SE_FL_APPLET_NEED_CONN
to detect a not-yet-established connection, and co_skip() instead of
applet_reset_input(); the rest applies as-is (a 3.2-adapted version
was build- and load-tested the same way).
---
--- a/include/haproxy/sink-t.h  2026-07-02 18:01:56.994222051 +0900
+++ b/include/haproxy/sink-t.h  2026-07-02 18:02:30.909879796 +0900
@@ -41,6 +41,8 @@ struct sink_forward_target {
        struct server *srv;    // used server
        struct appctx *appctx; // appctx of current session
        uint last_conn;        // copy of now_ms for last session establishment 
attempt
+       int wedge_exp;         // soft-stop: give-up deadline while the output 
is stuck; TICK_ETERNITY/0 = unset
+       int quiet_exp;         // soft-stop: close deadline while drained and 
empty; TICK_ETERNITY/0 = unset
        size_t ofs;            // ring buffer reader offset
        size_t e_processed;    // processed events
        struct sink *sink;     // the associated sink
--- a/src/sink.c        2026-07-02 18:01:57.018222516 +0900
+++ b/src/sink.c        2026-07-02 18:02:30.909879796 +0900
@@ -422,6 +422,34 @@ void sink_setup_proxy(struct proxy *px)
        sink_proxies_list = px;
 }
 
+/* While stopping but the data plane is not drained yet, re-check that often
+ * (a stream closing does not wake the forwarder by itself). */
+#define SINK_FWD_DRAIN_POLL_MS 20
+/* If the downstream is wedged (we still hold messages but make no progress),
+ * give up after this long so a stuck downstream cannot hold the process up to
+ * "hard-stop-after". */
+#define SINK_FWD_WEDGE_MS      1000
+
+/* Returns non-zero once the data plane is drained during soft-stop: no
+ * stoppable frontend connection remains, so no stream may produce new
+ * messages into the rings anymore. Sessions attached to log-forward
+ * (syslog) frontends are excluded from the count: they are log consumers,
+ * do not produce messages into local rings, and never terminate by
+ * themselves, so waiting for them would prevent the drain condition from
+ * ever being met when a ring's server points to a log-forward section of
+ * the same process. Non-stoppable sessions (e.g. the master CLI) are
+ * never accounted in actconn, so plain zero is the drained mark.
+ */
+static int sink_dataplane_drained(void)
+{
+       struct proxy *px;
+       int conns = HA_ATOMIC_LOAD(&actconn);
+
+       for (px = cfg_log_forward; px; px = px->next)
+               conns -= HA_ATOMIC_LOAD(&px->feconn);
+       return conns <= 0;
+}
+
 static void _sink_forward_io_handler(struct appctx *appctx,
                                      ssize_t (*msg_handler)(void *ctx, struct 
ist v1, struct ist v2, size_t ofs, size_t len, char delim))
 {
@@ -430,14 +458,33 @@ static void _sink_forward_io_handler(str
        struct ring *ring = sink->ctx.ring;
        size_t ofs, last_ofs;
        size_t processed;
+       int drained = 0;
        int ret = 0;
 
        if (unlikely(applet_fl_test(appctx, APPCTX_FL_EOS|APPCTX_FL_ERROR)))
                goto out;
 
-       /* if stopping was requested, close immediately */
-       if (unlikely(stopping))
-               goto soft_close;
+       /* If stopping was requested, we used to close immediately. That dropped
+        * not only the messages still buffered in the ring, but also every
+        * message that in-flight streams keep producing during the soft-stop
+        * window (e.g. access logs lost on every reload). Instead we keep the
+        * forwarder flushing and decide whether to close after the dispatch
+        * below, based on whether the data plane is drained. If the downstream
+        * connection was never established there is nothing to flush to, so we
+        * close right away.
+        *
+        * The drain state is sampled BEFORE dispatching: streams emit their
+        * final log before releasing their connection, and the ring lock taken
+        * by the dispatch synchronizes with the writer's, so once <drained> is
+        * observed here, every message emitted so far is guaranteed to be seen
+        * by this dispatch. Sampling it after dispatching would leave a window
+        * where the last messages slip in unseen and are lost on close.
+        */
+       if (unlikely(stopping)) {
+               if (se_fl_test(appctx->sedesc, SE_FL_APPLET_NEED_CONN))
+                       goto soft_close;
+               drained = sink_dataplane_drained();
+       }
 
        /* if the connection is not established, inform the stream that we want
         * to be notified whenever the connection completes.
@@ -481,6 +528,36 @@ static void _sink_forward_io_handler(str
        }
 
        if (ret) {
+               /* the ring is empty: everything available has been forwarded */
+               if (unlikely(stopping) && drained) {
+                       /* Soft-stop and the data plane was already drained 
before
+                        * this dispatch: no stoppable connection remains that 
could
+                        * produce a new message. Messages may still be in 
flight
+                        * towards this ring through a local relay though (e.g.
+                        * ring -> log-forward -> log backend chained in the 
same
+                        * process), so only close once the ring has stayed 
empty
+                        * for one full poll period after the drain was 
observed:
+                        * anything the relays still held lands in the ring in 
the
+                        * meantime and resets the timer below via <processed>.
+                        * This follows HAProxy's normal soft-stop drain, so the
+                        * forwarder never extends the worker's lifetime beyond 
it.
+                        */
+                       if (!processed && tick_isset(sft->quiet_exp) &&
+                           tick_is_expired(sft->quiet_exp, now_ms)) {
+                               HA_SPIN_UNLOCK(SFT_LOCK, &sft->lock);
+                               goto soft_close;
+                       }
+                       if (processed || !tick_isset(sft->quiet_exp))
+                               sft->quiet_exp = tick_add(now_ms, 
SINK_FWD_DRAIN_POLL_MS);
+                       sft->wedge_exp = TICK_ETERNITY;
+               }
+               else if (unlikely(stopping)) {
+                       /* not drained yet: restart the quiesce confirmation 
from
+                        * scratch once the drain is observed
+                        */
+                       sft->quiet_exp = TICK_ETERNITY;
+                       sft->wedge_exp = TICK_ETERNITY;
+               }
                /* let's be woken up once new data arrive */
                MT_LIST_APPEND(&ring->waiters, &appctx->wait_entry);
                ofs = ring_tail(ring);
@@ -492,6 +569,30 @@ static void _sink_forward_io_handler(str
                        applet_have_more_data(appctx);
                } else
                        applet_have_no_more_data(appctx);
+               /* Draining or confirming quiescence: the data plane may keep
+                * producing messages and a closing stream won't wake us, so
+                * re-check shortly. No wedge timeout here since the ring is
+                * empty (we are only waiting, not stuck on a busy downstream).
+                */
+               if (unlikely(stopping))
+                       task_schedule(appctx->t, tick_add(now_ms, 
SINK_FWD_DRAIN_POLL_MS));
+       }
+       else if (unlikely(stopping)) {
+               /* The ring is not empty but the output is full: we still hold
+                * messages we cannot push right now. We are woken again once 
the
+                * buffer drains, but bound the wait so a wedged or unreachable
+                * downstream cannot hold the process up to "hard-stop-after":
+                * give up if no message gets forwarded for SINK_FWD_WEDGE_MS
+                * (the deadline is pushed back on every forwarded message).
+                */
+               if (processed || !tick_isset(sft->wedge_exp))
+                       sft->wedge_exp = tick_add(now_ms, SINK_FWD_WEDGE_MS);
+               if (tick_is_expired(sft->wedge_exp, now_ms)) {
+                       HA_SPIN_UNLOCK(SFT_LOCK, &sft->lock);
+                       goto soft_close;
+               }
+               sft->quiet_exp = TICK_ETERNITY;
+               task_schedule(appctx->t, sft->wedge_exp);
        }
        HA_SPIN_UNLOCK(SFT_LOCK, &sft->lock);
 


Reply via email to