Skip to content

Commit 7880fc6

Browse files
committed
rptest/util: add check_consistently() safety helper
The inverse of wait_until: assert a condition holds for the whole duration window instead of waiting for it to become true, failing fast with AssertionError the moment it breaks.
1 parent ea0bda2 commit 7880fc6

2 files changed

Lines changed: 132 additions & 3 deletions

File tree

tests/rptest/tests/util_test.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Copyright 2026 Redpanda Data, Inc.
2+
#
3+
# Use of this software is governed by the Business Source License
4+
# included in the file licenses/BSL.md
5+
#
6+
# As of the Change Date specified in that file, in accordance with
7+
# the Business Source License, use of this software will be governed
8+
# by the Apache License, Version 2.0
9+
10+
import time
11+
12+
from ducktape.mark.resource import cluster
13+
from ducktape.tests.test import Test
14+
15+
from rptest.util import check_consistently, expect_exception
16+
17+
18+
class CheckConsistentlyTest(Test):
19+
@cluster(num_nodes=0)
20+
def test_holds_across_window(self) -> None:
21+
# A condition that stays true returns without raising, polled repeatedly.
22+
calls = 0
23+
24+
def cond() -> bool:
25+
nonlocal calls
26+
calls += 1
27+
return True
28+
29+
check_consistently(cond, duration_sec=0.1, interval_sec=0.005)
30+
assert calls >= 2
31+
32+
@cluster(num_nodes=0)
33+
def test_slow_poll_forces_a_check_past_the_deadline(self) -> None:
34+
calls = 0
35+
36+
def cond() -> bool:
37+
nonlocal calls
38+
calls += 1
39+
if calls == 2:
40+
# Begins inside the window but returns well after it.
41+
time.sleep(0.5)
42+
return True
43+
44+
check_consistently(cond, duration_sec=0.1, interval_sec=0)
45+
# The slow second poll started before the deadline, so success requires
46+
# a third poll -- one that begins after the deadline.
47+
assert calls == 3
48+
49+
@cluster(num_nodes=0)
50+
def test_raises_when_condition_becomes_false(self) -> None:
51+
# False from the start: fails on the first poll, before sleeping.
52+
calls = 0
53+
54+
def false_from_start() -> bool:
55+
nonlocal calls
56+
calls += 1
57+
return False
58+
59+
with expect_exception(AssertionError, lambda _: True):
60+
check_consistently(false_from_start, duration_sec=5, interval_sec=0.01)
61+
assert calls == 1
62+
63+
# Flips to false mid-window: raises as soon as it does.
64+
calls = 0
65+
66+
def flips() -> bool:
67+
nonlocal calls
68+
calls += 1
69+
return calls < 3
70+
71+
with expect_exception(AssertionError, lambda _: True):
72+
check_consistently(flips, duration_sec=5, interval_sec=0.001)
73+
assert calls == 3

tests/rptest/util.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,58 @@ def wait_until_with_progress_check(
218218
) from last_exception
219219

220220

221+
def check_consistently(
222+
condition: Callable[[], bool],
223+
duration_sec: float,
224+
interval_sec: float = 0.1,
225+
err_msg: str | Callable[[], str] = "",
226+
) -> None:
227+
"""
228+
Safety check: passes only if `condition` stays true for the whole
229+
`duration_sec` window, and fails fast the moment it becomes false.
230+
231+
The safety counterpart to `wait_until`'s liveness check: `wait_until`
232+
asserts that something good eventually happens (it blocks until a
233+
condition becomes true), whereas `check_consistently` asserts that nothing
234+
bad happens (it blocks while the condition *remains* true, raising
235+
AssertionError the moment it returns false).
236+
237+
`condition` is polled every `interval_sec`; the `duration_sec` window is
238+
measured from after the first poll, so at least `duration_sec` elapses
239+
between the first check and the last. Exceptions propagate to the caller
240+
unchanged.
241+
242+
Note: a passing safety assertion establishes only that the guarded event
243+
did not occur within `duration_sec`; it does not establish that the window
244+
was long enough for that event to occur at all. Too short a `duration_sec`
245+
therefore yields a meaningless pass.
246+
247+
To make the assertion solid, pair it with a positive control -- a companion
248+
test in which, under conditions that should trigger the behavior, the event
249+
is observed within the same `duration_sec`. Ideally the two are
250+
parameterization of a single test rather than independently written cases,
251+
so that they share setup and window and cannot silently drift out of sync.
252+
253+
For example, a test asserting that data is not evicted while disk headroom
254+
is sufficient should be accompanied by one demonstrating that the same
255+
window suffices to evict data under disk pressure.
256+
"""
257+
258+
def poll() -> None:
259+
if not condition():
260+
msg = err_msg() if callable(err_msg) else err_msg
261+
raise AssertionError(msg or f"condition did not hold for {duration_sec}s")
262+
263+
poll()
264+
stop = time.monotonic() + duration_sec
265+
while True:
266+
time.sleep(interval_sec)
267+
started = time.monotonic()
268+
poll()
269+
if started >= stop:
270+
return
271+
272+
221273
def segments_count(redpanda, topic, partition_idx):
222274
storage = redpanda.storage(scan_cache=False)
223275
topic_partitions = storage.partitions("kafka", topic)
@@ -414,7 +466,9 @@ def is_truncated():
414466
is_truncated,
415467
timeout_sec=timeout_sec,
416468
backoff_sec=1,
417-
err_msg=lambda: f"truncation couldn't be verified for {topic=} and {target_bytes=}. last run partition_sizes={sizes}",
469+
err_msg=lambda: (
470+
f"truncation couldn't be verified for {topic=} and {target_bytes=}. last run partition_sizes={sizes}"
471+
),
418472
)
419473

420474

@@ -560,8 +614,10 @@ def check_throttle_rate(node):
560614
return True
561615
metrics = list(redpanda.metrics(node))
562616
family = filter(
563-
lambda fam: fam.name
564-
== "vectorized_raft_recovery_partition_movement_assigned_bandwidth",
617+
lambda fam: (
618+
fam.name
619+
== "vectorized_raft_recovery_partition_movement_assigned_bandwidth"
620+
),
565621
metrics,
566622
)
567623
shard_rates = next(family).samples

0 commit comments

Comments
 (0)