@@ -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+
221273def 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