]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Test] upstream: deterministic SRV rate-window test via libev fake clock 6030/head
authorVsevolod Stakhov <vsevolod@rspamd.com>
Sat, 9 May 2026 13:38:18 +0000 (14:38 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Sat, 9 May 2026 13:38:18 +0000 (14:38 +0100)
Switch rspamd_upstream_fail's rate-window timestamp from
rspamd_get_ticks(FALSE) to a new rspamd_upstream_now_fresh helper that
calls ev_now_update_if_cheap then ev_now. Multiple fail() calls in a
single loop iteration now see fresh times, and tests can drive virtual
time through the libev hook without sleeping.

  * rspamd_upstream_now / rspamd_upstream_now_fresh helpers hoisted to
    the top of upstream.c with a short comment about why ev_now matters
    (loop-cached time = tests can drive it; production correctness wart
    of mixed time sources goes away).
  * rspamd_upstream_ctx_set_event_loop_for_test: install a loop on
    upstream_ctx without going through rspamd_upstreams_library_config
    (which needs a full rspamd_config).
  * rspamd_test::fake_clock RAII helper installs the libev hook,
    advances virtual time, and resyncs the loop on construct/destroy.

The "error budget is per member" SRV test drops g_usleep(1000) and the
error_time = 0.002 s macOS-jitter workaround; uses error_time = 1.0 s,
max_errors = 4, and clk.advance(0.1) between fails. Test runs in 80 ms
and is fully deterministic.

src/libutil/upstream.c
src/libutil/upstream_internal.h
test/rspamd_cxx_unit_upstream_srv.hxx
test/rspamd_test_fake_time.hxx [new file with mode: 0644]

index 5e4a6c38d48d6029bfaefc3f96cafb0b349a07c7..8edd1506e1d7e329c3037f035e5eefaf85f6e256 100644 (file)
@@ -315,6 +315,39 @@ static void rspamd_upstream_resolve_addrs(const struct upstream_list *ls,
 static void rspamd_upstream_set_inactive(struct upstream_list *ls,
                                                                                 struct upstream *upstream);
 
+/*
+ * Time helpers. We use ev_now() — the loop's cached time — wherever an event
+ * loop is available, so all decisions in a single loop iteration agree on
+ * "now" and tests can drive time deterministically via the libev fake-clock
+ * hook. The rspamd_get_ticks() fallback covers paths that may run before the
+ * event loop is wired up (early init, unit tests of pure helpers).
+ */
+static inline double
+rspamd_upstream_now(const struct upstream *up)
+{
+       if (up->ctx && up->ctx->event_loop) {
+               return ev_now(up->ctx->event_loop);
+       }
+       return rspamd_get_ticks(FALSE);
+}
+
+/*
+ * Same as rspamd_upstream_now() but first refreshes the loop's cached
+ * monotonic time. Use on rare paths (e.g. fail handling) where multiple
+ * timestamps may be sampled in a single loop iteration and we want each
+ * sample to reflect actual elapsed time. The "_if_cheap" variant only
+ * touches the monotonic clock; no realtime read.
+ */
+static inline double
+rspamd_upstream_now_fresh(const struct upstream *up)
+{
+       if (up->ctx && up->ctx->event_loop) {
+               ev_now_update_if_cheap(up->ctx->event_loop);
+               return ev_now(up->ctx->event_loop);
+       }
+       return rspamd_get_ticks(FALSE);
+}
+
 void rspamd_upstreams_library_config(struct rspamd_config *cfg,
                                                                         struct upstream_ctx *ctx,
                                                                         struct ev_loop *event_loop,
@@ -1190,6 +1223,13 @@ rspamd_upstream_srv_test_get_parent(struct upstream_list *ups)
        return NULL;
 }
 
+void rspamd_upstream_ctx_set_event_loop_for_test(struct upstream_ctx *ctx,
+                                                                                                struct ev_loop *event_loop)
+{
+       g_assert(ctx != NULL);
+       ctx->event_loop = event_loop;
+}
+
 void rspamd_upstream_member_force_alive_for_test(struct upstream *member,
                                                                                                 const char *ip_str)
 {
@@ -1627,7 +1667,7 @@ void rspamd_upstream_fail(struct upstream *upstream,
        }
 
        if (upstream->ctx && upstream->active_idx != -1 && upstream->ls) {
-               sec_cur = rspamd_get_ticks(FALSE);
+               sec_cur = rspamd_upstream_now_fresh(upstream);
 
                RSPAMD_UPSTREAM_LOCK(upstream);
                if (upstream->errors == 0) {
@@ -3458,15 +3498,6 @@ rspamd_upstream_refill_tokens(struct upstream *up,
        up->last_refill_at = now;
 }
 
-static inline double
-rspamd_upstream_now(const struct upstream *up)
-{
-       if (up->ctx && up->ctx->event_loop) {
-               return ev_now(up->ctx->event_loop);
-       }
-       return rspamd_get_ticks(FALSE);
-}
-
 struct upstream *
 rspamd_upstream_get_token_bucket(struct upstream_list *ups,
                                                                 struct upstream *except,
index d9ff21826b1eb358bb659d6144ef270324bf6893..e77cb00dd73aa97a6fc082ec57daf454bf5d00dd 100644 (file)
@@ -74,6 +74,15 @@ void rspamd_upstream_member_force_alive_for_test(struct upstream *member,
  */
 struct upstream *rspamd_upstream_srv_test_get_parent(struct upstream_list *ups);
 
+/*
+ * Install an event loop on the context without going through
+ * rspamd_upstreams_library_config (which requires a full rspamd_config).
+ * Test-only: lets unit tests drive ev_now() and timer firing through the
+ * libev fake-clock hook (see ev.h: ev_set_fake_time_cb).
+ */
+void rspamd_upstream_ctx_set_event_loop_for_test(struct upstream_ctx *ctx,
+                                                                                                struct ev_loop *event_loop);
+
 #ifdef __cplusplus
 }
 #endif
index 8431a9379d10ca6c92f0016eb61b8836ff3a7d88..350a2dca144ece30b986c2ec12324da472d9280f 100644 (file)
@@ -28,6 +28,8 @@
 
 #include "libutil/upstream.h"
 #include "libutil/upstream_internal.h"
+#include "rspamd_test_fake_time.hxx"
+#include "contrib/libev/ev.h"
 
 #include <map>
 #include <set>
@@ -40,10 +42,20 @@ struct ctx_holder {
        struct upstream_ctx *ctx;
        struct upstream_list *ups;
        struct upstream *parent;
+       struct ev_loop *loop;
 
        ctx_holder()
        {
+               /*
+                * Use a non-default loop so installing a fake clock in one test
+                * can't bleed into anything that touches the default loop. The
+                * loop never runs ev_run() in these tests; we only need it for
+                * ev_now() to read through to our fake-clock hook.
+                */
+               loop = ev_loop_new(EVFLAG_AUTO);
+               REQUIRE(loop != nullptr);
                ctx = rspamd_upstreams_library_init();
+               rspamd_upstream_ctx_set_event_loop_for_test(ctx, loop);
                ups = rspamd_upstreams_create(ctx);
                rspamd_upstreams_set_rotation(ups, RSPAMD_UPSTREAM_ROUND_ROBIN);
 
@@ -61,6 +73,7 @@ struct ctx_holder {
        {
                rspamd_upstreams_destroy(ups);
                rspamd_upstreams_library_unref(ctx);
+               ev_loop_destroy(loop);
        }
 
        ctx_holder(const ctx_holder &) = delete;
@@ -283,26 +296,21 @@ TEST_SUITE("upstream_srv")
        {
                ctx_holder t;
                /*
-                * Squeeze the error window so a few fails over a few tens of
-                * ms cross the rate threshold. Defaults (4 errors / 10s) would
-                * require multi-second sleeps to trigger in unit tests.
-                */
-               /*
-                * Rate-based inactive transition fires when:
-                *   (sec_cur - last_fail) >= error_time  AND
-                *   errors / elapsed > max_errors / error_time
-                *
-                * Pick aggressive limits so we comfortably exceed the threshold
-                * even with macOS scheduler jitter on g_usleep.
+                * Realistic defaults — the rate-window math is the same whether we
+                * run in real or fake time, so there's no need to squeeze error_time
+                * down to milliseconds. Five errors over a one-second budget with
+                * max_errors=4 puts us comfortably past the rate threshold.
                 */
                rspamd_upstreams_set_limits(t.ups,
                                                                        /* revive_time */ 60.0,
                                                                        /* revive_jitter */ 0.4,
-                                                                       /* error_time */ 0.002,
+                                                                       /* error_time */ 1.0,
                                                                        /* dns_timeout */ 1.0,
-                                                                       /* max_errors */ 1,
+                                                                       /* max_errors */ 4,
                                                                        /* dns_retransmits */ 2);
 
+               rspamd_test::fake_clock clk(1000.0, t.loop);
+
                t.apply({
                        {"a.example.com", 11335, 1, 10},
                        {"b.example.com", 11335, 1, 10},
@@ -320,11 +328,12 @@ TEST_SUITE("upstream_srv")
                 * Pre-refactor, the three SRV targets shared one error budget;
                 * a burst here would have killed every target. With per-member
                 * budgets, only `bad` crosses the rate threshold and exits the
-                * alive list.
+                * alive list. Each fail() advances virtual time by 100 ms so the
+                * rate-window arithmetic sees real elapsed time without sleeping.
                 */
                for (int i = 0; i < 12; i++) {
                        rspamd_upstream_fail(bad, TRUE, "test");
-                       g_usleep(1000); /* 1 ms — gives ample margin over error_time=2ms */
+                       clk.advance(0.1);
                }
 
                /*
diff --git a/test/rspamd_test_fake_time.hxx b/test/rspamd_test_fake_time.hxx
new file mode 100644 (file)
index 0000000..2aa7704
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2026 Vsevolod Stakhov
+ *
+ * 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.
+ */
+
+/*
+ * Test-only fake-clock helper. Drives ev_now() and timer firing in our
+ * bundled libev (see ev.h: ev_set_fake_time_cb / ev_now_resync) so unit
+ * tests can advance virtual time deterministically instead of sleeping.
+ *
+ * Process-global by design — the libev hook is process-wide. Use one
+ * fake_clock per scope, and don't run two in parallel within the same
+ * process.
+ */
+
+#ifndef RSPAMD_TEST_FAKE_TIME_HXX
+#define RSPAMD_TEST_FAKE_TIME_HXX
+
+#include "contrib/libev/ev.h"
+
+namespace rspamd_test {
+
+class fake_clock {
+public:
+       /* Install the fake clock at `start` seconds. The optional `loop` is
+        * resynced so its cached realtime/monotonic state matches the new
+        * source — required when the loop was created before the hook was
+        * installed (otherwise ev_now() returns garbage from interpolation).
+        */
+       explicit fake_clock(double start = 1000.0, struct ev_loop *loop = nullptr)
+               : now_(start), loop_(loop)
+       {
+               instance_ = this;
+               ev_set_fake_time_cb(&fake_clock::read);
+               if (loop_) {
+                       ev_now_resync(loop_);
+               }
+       }
+
+       ~fake_clock()
+       {
+               ev_set_fake_time_cb(nullptr);
+               instance_ = nullptr;
+               if (loop_) {
+                       ev_now_resync(loop_);
+               }
+       }
+
+       fake_clock(const fake_clock &) = delete;
+       fake_clock &operator=(const fake_clock &) = delete;
+
+       /* Move time forward by `seconds`. Negative values intentionally
+        * unsupported: backward jumps would defeat libev's monotonic
+        * assumption and produce garbage timer behaviour.
+        */
+       void advance(double seconds)
+       {
+               if (seconds < 0.0) {
+                       seconds = 0.0;
+               }
+               now_ += seconds;
+       }
+
+       void set(double t)
+       {
+               now_ = t;
+       }
+
+       double now() const
+       {
+               return now_;
+       }
+
+private:
+       static double read()
+       {
+               return instance_ ? instance_->now_ : 0.0;
+       }
+
+       double now_;
+       struct ev_loop *loop_;
+
+       static inline fake_clock *instance_ = nullptr;
+};
+
+}// namespace rspamd_test
+
+#endif /* RSPAMD_TEST_FAKE_TIME_HXX */