Files
ray-project--ray/python/ray/serve/tests/unit/test_rolling_window.py
T
2026-07-13 13:17:40 +08:00

1090 lines
35 KiB
Python

import threading
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
import pytest
from ray.serve._private.rolling_window import (
RollingWindowAccumulator,
RollingWindowMax,
_RollingWindowBase,
)
class TestRollingWindowBaseInit:
def test_basic_initialization(self):
"""Test basic initialization with default parameters."""
base = _RollingWindowBase(window_duration_s=10.0)
assert base.window_duration_s == 10.0
assert base.num_buckets == 60 # default
assert base.bucket_duration_s == 10.0 / 60
def test_custom_num_buckets(self):
"""Test initialization with custom number of buckets."""
base = _RollingWindowBase(
window_duration_s=100.0,
num_buckets=10,
)
assert base.window_duration_s == 100.0
assert base.num_buckets == 10
assert base.bucket_duration_s == 10.0
def test_invalid_window_duration(self):
"""Test that invalid window duration raises ValueError."""
with pytest.raises(ValueError, match="window_duration_s must be positive"):
_RollingWindowBase(window_duration_s=0)
with pytest.raises(ValueError, match="window_duration_s must be positive"):
_RollingWindowBase(window_duration_s=-1.0)
def test_invalid_num_buckets(self):
"""Test that invalid num_buckets raises ValueError."""
with pytest.raises(ValueError, match="num_buckets must be positive"):
_RollingWindowBase(window_duration_s=10.0, num_buckets=0)
with pytest.raises(ValueError, match="num_buckets must be positive"):
_RollingWindowBase(window_duration_s=10.0, num_buckets=-1)
class TestRollingWindowAccumulatorSingleThread:
def test_basic_add_and_get_total(self):
"""Test basic add and get_total functionality."""
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
# Add some values
accumulator.add(100.0)
accumulator.add(50.0)
accumulator.add(25.0)
# Total should be sum of all added values
assert accumulator.get_total() == 175.0
def test_add_zero(self):
"""Test that zero values are handled correctly."""
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
accumulator.add(0.0)
accumulator.add(0.0)
assert accumulator.get_total() == 0.0
def test_add_negative(self):
"""Test that negative values are handled correctly."""
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
accumulator.add(100.0)
accumulator.add(-50.0)
assert accumulator.get_total() == 50.0
def test_add_float_precision(self):
"""Test float precision with small values."""
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
for _ in range(1000):
accumulator.add(0.001)
# Allow for small floating point errors
assert abs(accumulator.get_total() - 1.0) < 0.0001
def test_empty_accumulator(self):
"""Test get_total on empty accumulator."""
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
assert accumulator.get_total() == 0.0
def test_bucket_rotation(self):
"""Test that buckets rotate correctly as time passes."""
with patch("time.time") as mock_time:
# Start at time 0
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=10.0, # 10 second window
num_buckets=10, # Each bucket is 1 second
)
# Add 100 to first bucket
accumulator.add(100.0)
assert accumulator.get_total() == 100.0
# Advance time by 1 second (move to next bucket)
mock_time.return_value = 1.0
accumulator.add(200.0)
assert accumulator.get_total() == 300.0
# Advance time by 9 more seconds (10 total - first bucket should expire)
mock_time.return_value = 10.0
accumulator.add(50.0)
# First bucket (100) should be cleared, only 200 + 50 remain
assert accumulator.get_total() == 250.0
def test_full_window_idle(self):
"""Test that all buckets are cleared after being idle for full window."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
# Add value at time 0
accumulator.add(100.0)
assert accumulator.get_total() == 100.0
# Advance past the full window (all data should be stale)
mock_time.return_value = 15.0
assert accumulator.get_total() == 0.0
def test_concurrent_adds_same_bucket(self):
"""Test that multiple adds within same bucket period are accumulated."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
# Add multiple values without time advancing
for _ in range(100):
accumulator.add(1.0)
assert accumulator.get_total() == 100.0
def test_gradual_data_expiry(self):
"""Test that data gradually expires as the window slides."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10, # 1 second per bucket
)
# Add 10 values, one per second
for i in range(10):
mock_time.return_value = float(i)
accumulator.add(10.0)
# Total should be 100
mock_time.return_value = 9.0
assert accumulator.get_total() == 100.0
# After 1 more second, the first bucket expires
mock_time.return_value = 10.0
# Need to trigger rotation by calling get_total or add
total = accumulator.get_total()
assert total == 90.0
# After 5 more seconds, 6 buckets have expired
mock_time.return_value = 15.0
total = accumulator.get_total()
assert total == 40.0
def test_large_time_jump(self):
"""Test handling of large time jumps (e.g., system clock changes)."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
accumulator.add(100.0)
assert accumulator.get_total() == 100.0
# Jump forward by a very large amount
mock_time.return_value = 1000000.0
assert accumulator.get_total() == 0.0
# Should still work after the jump
accumulator.add(50.0)
assert accumulator.get_total() == 50.0
class TestRollingWindowAccumulatorMultiThread:
def test_thread_registration(self):
"""Test that threads are correctly registered."""
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
# Initially no threads registered
assert accumulator.get_num_registered_threads() == 0
# After first add, one thread registered
accumulator.add(100.0)
assert accumulator.get_num_registered_threads() == 1
# Multiple adds from same thread don't increase count
accumulator.add(100.0)
assert accumulator.get_num_registered_threads() == 1
def test_multiple_threads_registration(self):
"""Test that multiple threads are correctly registered."""
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
num_threads = 8
barrier = threading.Barrier(num_threads)
def worker():
barrier.wait() # Synchronize all threads
accumulator.add(1.0)
threads = [threading.Thread(target=worker) for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
assert accumulator.get_num_registered_threads() == num_threads
assert accumulator.get_total() == num_threads
def test_concurrent_adds_correctness(self):
"""Test that concurrent adds produce correct total."""
accumulator = RollingWindowAccumulator(
window_duration_s=600.0, # Large window to avoid expiry
num_buckets=60,
)
num_threads = 8
adds_per_thread = 1000
value_per_add = 1.0
def worker():
for _ in range(adds_per_thread):
accumulator.add(value_per_add)
threads = [threading.Thread(target=worker) for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
expected = num_threads * adds_per_thread * value_per_add
assert accumulator.get_total() == expected
def test_concurrent_adds_with_threadpool(self):
"""Test concurrent adds using ThreadPoolExecutor."""
accumulator = RollingWindowAccumulator(
window_duration_s=600.0,
num_buckets=60,
)
num_workers = 16
adds_per_worker = 500
def worker():
for _ in range(adds_per_worker):
accumulator.add(1.0)
return adds_per_worker
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = [executor.submit(worker) for _ in range(num_workers)]
total_adds = sum(f.result() for f in futures)
assert accumulator.get_total() == total_adds
def test_add_and_get_total_concurrent(self):
"""Test concurrent add() and get_total() calls."""
accumulator = RollingWindowAccumulator(
window_duration_s=600.0,
num_buckets=60,
)
num_threads = 4
iterations = 1000
results = []
lock = threading.Lock()
def adder():
for _ in range(iterations):
accumulator.add(1.0)
def reader():
totals = []
for _ in range(iterations):
totals.append(accumulator.get_total())
with lock:
results.extend(totals)
threads = []
for _ in range(num_threads):
threads.append(threading.Thread(target=adder))
threads.append(threading.Thread(target=reader))
for t in threads:
t.start()
for t in threads:
t.join()
# Final total should be exactly num_threads * iterations
expected = num_threads * iterations
assert accumulator.get_total() == expected
# All read totals should be non-negative and <= expected
assert all(0 <= r <= expected for r in results)
def test_thread_isolation_independent_values(self):
"""Test that each thread has its own independent bucket storage.
This verifies that values added by one thread don't overwrite
values added by another thread.
"""
accumulator = RollingWindowAccumulator(
window_duration_s=600.0,
num_buckets=60,
)
num_threads = 4
value_per_thread = [100.0, 200.0, 300.0, 400.0]
barrier = threading.Barrier(num_threads)
def worker(thread_idx):
barrier.wait() # Synchronize start
# Each thread adds its unique value multiple times
for _ in range(10):
accumulator.add(value_per_thread[thread_idx])
threads = [
threading.Thread(target=worker, args=(i,)) for i in range(num_threads)
]
for t in threads:
t.start()
for t in threads:
t.join()
# Total should be sum of all threads' contributions
expected = sum(v * 10 for v in value_per_thread)
assert accumulator.get_total() == expected
assert accumulator.get_num_registered_threads() == num_threads
def test_thread_isolation_bucket_rotation(self):
"""Test that bucket rotation in one thread doesn't affect other threads.
Each thread should maintain its own bucket index and rotation time.
"""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10, # 1 second per bucket
)
results = {}
lock = threading.Lock()
def thread_a():
# Thread A adds at time 0
accumulator.add(100.0)
with lock:
results["a_added"] = True
def thread_b():
# Wait for thread A to finish
while "a_added" not in results:
pass
# Thread B adds at time 5 (different bucket)
mock_time.return_value = 5.0
accumulator.add(200.0)
with lock:
results["b_added"] = True
t_a = threading.Thread(target=thread_a)
t_b = threading.Thread(target=thread_b)
t_a.start()
t_a.join()
t_b.start()
t_b.join()
# Both threads should be registered
assert accumulator.get_num_registered_threads() == 2
# At time 5, thread A's value (added at time 0) should still be valid
# because the window is 10 seconds
mock_time.return_value = 5.0
assert accumulator.get_total() == 300.0
# At time 12, thread A's value should have expired (added at time 0,
# window is 10s), but thread B's value (added at time 5) should still
# be valid
mock_time.return_value = 12.0
total = accumulator.get_total()
assert total == 200.0
def test_thread_local_data_not_shared(self):
"""Test that thread-local data objects are truly separate.
Verifies that accessing _local.data from different threads returns
different objects.
"""
accumulator = RollingWindowAccumulator(
window_duration_s=600.0,
num_buckets=60,
)
data_ids = []
lock = threading.Lock()
def worker():
accumulator.add(1.0)
# Get the id of this thread's data object
data = accumulator._local.data
with lock:
data_ids.append(id(data))
num_threads = 4
threads = [threading.Thread(target=worker) for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
# All threads should have different data object IDs
assert len(set(data_ids)) == num_threads, (
f"Expected {num_threads} unique data objects, "
f"got {len(set(data_ids))}: {data_ids}"
)
class TestUtilizationCalculation:
def test_utilization_formula(self):
"""Test that utilization is calculated correctly.
Utilization = user_code_time / (window_duration * max_ongoing_requests)
"""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=600.0, # 10 minutes
num_buckets=60,
)
# Simulate 1 request taking 60 seconds (60000 ms) over a 10 minute window
# with max_ongoing_requests=1
# Utilization = 60000 / (600 * 1000 * 1) = 10%
accumulator.add(60000.0) # 60 seconds in ms
total_user_code_time_ms = accumulator.get_total()
window_duration_ms = 600 * 1000
max_ongoing_requests = 1
max_capacity_ms = window_duration_ms * max_ongoing_requests
utilization_percent = (total_user_code_time_ms / max_capacity_ms) * 100
assert utilization_percent == 10.0
def test_utilization_with_multiple_concurrent_requests(self):
"""Test utilization calculation with multiple concurrent requests."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=600.0, # 10 minutes
num_buckets=60,
)
# If max_ongoing_requests=4 and we use 240 seconds of user code time
# Utilization = 240000 / (600000 * 4) = 10%
accumulator.add(240000.0) # 240 seconds in ms
total_user_code_time_ms = accumulator.get_total()
window_duration_ms = 600 * 1000
max_ongoing_requests = 4
max_capacity_ms = window_duration_ms * max_ongoing_requests
utilization_percent = (total_user_code_time_ms / max_capacity_ms) * 100
assert utilization_percent == 10.0
def test_full_utilization(self):
"""Test 100% utilization scenario."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=600.0,
num_buckets=60,
)
# Full utilization: 600 seconds * 2 concurrent requests = 1200 seconds
# = 1200000 ms of user code time
max_ongoing_requests = 2
accumulator.add(1200000.0)
total_user_code_time_ms = accumulator.get_total()
window_duration_ms = 600 * 1000
max_capacity_ms = window_duration_ms * max_ongoing_requests
utilization_percent = (total_user_code_time_ms / max_capacity_ms) * 100
assert utilization_percent == 100.0
def test_utilization_capped_at_100(self):
"""Test that utilization is capped at 100% even if calculation exceeds it."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=600.0,
num_buckets=60,
)
# Over-utilization scenario (shouldn't happen in practice but test the cap)
max_ongoing_requests = 1
accumulator.add(700000.0) # More than 600 seconds
total_user_code_time_ms = accumulator.get_total()
window_duration_ms = 600 * 1000
max_capacity_ms = window_duration_ms * max_ongoing_requests
utilization_percent = (total_user_code_time_ms / max_capacity_ms) * 100
utilization_percent = min(utilization_percent, 100.0) # Cap at 100%
assert utilization_percent == 100.0
class TestEdgeCases:
def test_very_small_window(self):
"""Test with a very small window duration."""
accumulator = RollingWindowAccumulator(
window_duration_s=0.001, # 1 millisecond
num_buckets=10,
)
accumulator.add(1.0)
# Value should be present immediately
assert accumulator.get_total() >= 0.0
def test_very_large_window(self):
"""Test with a very large window duration."""
accumulator = RollingWindowAccumulator(
window_duration_s=86400.0, # 24 hours
num_buckets=1440, # 1 minute per bucket
)
accumulator.add(1000.0)
assert accumulator.get_total() == 1000.0
def test_single_bucket(self):
"""Test with a single bucket."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=1,
)
accumulator.add(100.0)
assert accumulator.get_total() == 100.0
# After window expires, everything is cleared
mock_time.return_value = 15.0
assert accumulator.get_total() == 0.0
def test_many_buckets(self):
"""Test with many buckets."""
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=1000,
)
for _ in range(100):
accumulator.add(1.0)
assert accumulator.get_total() == 100.0
def test_very_large_values(self):
"""Test with very large values."""
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
large_value = 1e15
accumulator.add(large_value)
accumulator.add(large_value)
assert accumulator.get_total() == 2 * large_value
def test_very_small_values(self):
"""Test with very small values."""
accumulator = RollingWindowAccumulator(
window_duration_s=10.0,
num_buckets=10,
)
small_value = 1e-15
for _ in range(1000):
accumulator.add(small_value)
# Should be approximately 1000 * 1e-15 = 1e-12
assert abs(accumulator.get_total() - 1e-12) < 1e-14
class TestRollingWindowMaxSingleThread:
def test_basic_add_and_get_max(self):
"""Test that get_max returns the largest value added."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
tracker.add(100.0)
tracker.add(500.0)
tracker.add(50.0)
assert tracker.get_max() == 500.0
def test_single_value(self):
"""Test get_max with a single value."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
tracker.add(42.0)
assert tracker.get_max() == 42.0
def test_empty_tracker(self):
"""Test get_max on empty tracker returns 0."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
assert tracker.get_max() == 0.0
def test_all_same_values(self):
"""Test get_max when all values are identical."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
for _ in range(100):
tracker.add(42.0)
assert tracker.get_max() == 42.0
def test_increasing_values(self):
"""Test that max tracks the latest highest value."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
for i in range(1, 11):
tracker.add(float(i * 100))
assert tracker.get_max() == 1000.0
def test_decreasing_values(self):
"""Test that max retains the first (highest) value."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
for i in range(10, 0, -1):
tracker.add(float(i * 100))
assert tracker.get_max() == 1000.0
def test_max_across_buckets(self):
"""Test that get_max returns max across different time buckets."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
tracker = RollingWindowMax(
window_duration_s=10.0,
num_buckets=10,
)
# Bucket 0: max 100
tracker.add(100.0)
tracker.add(50.0)
# Bucket 1: max 500
mock_time.return_value = 1.0
tracker.add(500.0)
tracker.add(200.0)
# Bucket 2: max 30
mock_time.return_value = 2.0
tracker.add(30.0)
assert tracker.get_max() == 500.0
def test_max_expires_with_window(self):
"""Test that the max value expires when its bucket rotates out."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
tracker = RollingWindowMax(
window_duration_s=10.0,
num_buckets=10,
)
# Add a high value in bucket 0
tracker.add(999.0)
assert tracker.get_max() == 999.0
# Add a lower value in bucket 1
mock_time.return_value = 1.0
tracker.add(50.0)
assert tracker.get_max() == 999.0
# Advance past the window so bucket 0 expires
mock_time.return_value = 10.0
tracker.add(50.0)
# The 999 from bucket 0 should be gone
assert tracker.get_max() == 50.0
def test_full_window_idle(self):
"""Test that get_max returns 0 after being idle for full window."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
tracker = RollingWindowMax(
window_duration_s=10.0,
num_buckets=10,
)
tracker.add(999.0)
assert tracker.get_max() == 999.0
# Advance past the full window
mock_time.return_value = 15.0
assert tracker.get_max() == 0.0
def test_gradual_max_expiry(self):
"""Test that the max shifts as old buckets expire."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
tracker = RollingWindowMax(
window_duration_s=10.0,
num_buckets=10,
)
# Bucket 0 (t=0): max=1000
tracker.add(1000.0)
# Bucket 3 (t=3): max=500
mock_time.return_value = 3.0
tracker.add(500.0)
# Bucket 7 (t=7): max=200
mock_time.return_value = 7.0
tracker.add(200.0)
# At t=7, all buckets are valid
assert tracker.get_max() == 1000.0
# At t=10, bucket 0 (with 1000) expires
mock_time.return_value = 10.0
tracker.add(10.0)
assert tracker.get_max() == 500.0
# At t=13, bucket 3 (with 500) expires
mock_time.return_value = 13.0
tracker.add(10.0)
assert tracker.get_max() == 200.0
# At t=17, bucket 7 (with 200) expires
mock_time.return_value = 17.0
tracker.add(10.0)
assert tracker.get_max() == 10.0
def test_large_time_jump(self):
"""Test handling of large time jumps."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
tracker = RollingWindowMax(
window_duration_s=10.0,
num_buckets=10,
)
tracker.add(999.0)
assert tracker.get_max() == 999.0
# Jump forward by a very large amount
mock_time.return_value = 1000000.0
assert tracker.get_max() == 0.0
# Should still work after the jump
tracker.add(42.0)
assert tracker.get_max() == 42.0
def test_max_does_not_accumulate(self):
"""Test that add() takes max, not sum, within a bucket."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
tracker.add(100.0)
tracker.add(100.0)
tracker.add(100.0)
# Should be 100 (max), not 300 (sum)
assert tracker.get_max() == 100.0
class TestRollingWindowMaxMultiThread:
def test_thread_registration(self):
"""Test that threads are correctly registered."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
assert tracker.get_num_registered_threads() == 0
tracker.add(100.0)
assert tracker.get_num_registered_threads() == 1
tracker.add(200.0)
assert tracker.get_num_registered_threads() == 1
def test_multiple_threads_registration(self):
"""Test that multiple threads are correctly registered."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
num_threads = 8
barrier = threading.Barrier(num_threads)
def worker():
barrier.wait()
tracker.add(1.0)
threads = [threading.Thread(target=worker) for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
assert tracker.get_num_registered_threads() == num_threads
def test_max_across_threads(self):
"""Test that get_max returns the global max across all threads."""
tracker = RollingWindowMax(
window_duration_s=600.0,
num_buckets=60,
)
num_threads = 8
barrier = threading.Barrier(num_threads)
def worker(thread_idx):
barrier.wait()
tracker.add(float((thread_idx + 1) * 100))
threads = [
threading.Thread(target=worker, args=(i,)) for i in range(num_threads)
]
for t in threads:
t.start()
for t in threads:
t.join()
assert tracker.get_max() == float(num_threads * 100)
def test_concurrent_adds_with_threadpool(self):
"""Test concurrent adds using ThreadPoolExecutor."""
tracker = RollingWindowMax(
window_duration_s=600.0,
num_buckets=60,
)
num_workers = 16
adds_per_worker = 500
def worker(worker_idx):
for i in range(adds_per_worker):
tracker.add(float(worker_idx * adds_per_worker + i))
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = [executor.submit(worker, i) for i in range(num_workers)]
for f in futures:
f.result()
expected_max = float(num_workers * adds_per_worker - 1)
assert tracker.get_max() == expected_max
def test_add_and_get_max_concurrent(self):
"""Test concurrent add() and get_max() calls."""
tracker = RollingWindowMax(
window_duration_s=600.0,
num_buckets=60,
)
num_threads = 4
iterations = 1000
max_value = float(num_threads * iterations)
results = []
lock = threading.Lock()
def adder(thread_idx):
for i in range(iterations):
tracker.add(float(thread_idx * iterations + i))
def reader():
maxes = []
for _ in range(iterations):
maxes.append(tracker.get_max())
with lock:
results.extend(maxes)
threads = []
for i in range(num_threads):
threads.append(threading.Thread(target=adder, args=(i,)))
threads.append(threading.Thread(target=reader))
for t in threads:
t.start()
for t in threads:
t.join()
assert tracker.get_max() == max_value - 1
# All read values should be non-negative and <= max_value
assert all(0 <= r <= max_value for r in results)
def test_thread_isolation_with_expiry(self):
"""Test that bucket expiry works correctly across threads."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
tracker = RollingWindowMax(
window_duration_s=10.0,
num_buckets=10,
)
results = {}
lock = threading.Lock()
def thread_a():
# Thread A adds high value at time 0
tracker.add(1000.0)
with lock:
results["a_added"] = True
def thread_b():
while "a_added" not in results:
pass
# Thread B adds lower value at time 5
mock_time.return_value = 5.0
tracker.add(200.0)
with lock:
results["b_added"] = True
t_a = threading.Thread(target=thread_a)
t_b = threading.Thread(target=thread_b)
t_a.start()
t_a.join()
t_b.start()
t_b.join()
assert tracker.get_num_registered_threads() == 2
# At time 5, thread A's 1000 is still valid
mock_time.return_value = 5.0
assert tracker.get_max() == 1000.0
# At time 12, thread A's data (added at time 0) has expired
mock_time.return_value = 12.0
assert tracker.get_max() == 200.0
class TestRollingWindowMaxEdgeCases:
def test_single_bucket(self):
"""Test with a single bucket."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
tracker = RollingWindowMax(
window_duration_s=10.0,
num_buckets=1,
)
tracker.add(100.0)
tracker.add(500.0)
assert tracker.get_max() == 500.0
# After window expires, everything is cleared
mock_time.return_value = 15.0
assert tracker.get_max() == 0.0
def test_many_buckets(self):
"""Test with many buckets."""
tracker = RollingWindowMax(
window_duration_s=10.0,
num_buckets=1000,
)
for i in range(100):
tracker.add(float(i))
assert tracker.get_max() == 99.0
def test_very_large_values(self):
"""Test with very large values."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
large_value = 1e15
tracker.add(large_value)
tracker.add(large_value - 1)
assert tracker.get_max() == large_value
def test_very_small_values(self):
"""Test with very small positive values."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
tracker.add(1e-15)
tracker.add(2e-15)
tracker.add(1e-15)
assert tracker.get_max() == 2e-15
def test_zero_values(self):
"""Test that zero values are handled correctly."""
tracker = RollingWindowMax(window_duration_s=10.0, num_buckets=10)
tracker.add(0.0)
tracker.add(0.0)
assert tracker.get_max() == 0.0
def test_new_max_replaces_old_after_expiry(self):
"""Test the typical latency tracking scenario: old spike expires,
new lower steady-state values become the max."""
with patch("time.time") as mock_time:
mock_time.return_value = 0.0
tracker = RollingWindowMax(
window_duration_s=60.0,
num_buckets=6,
)
# Latency spike at t=0
tracker.add(5000.0)
# Normal latencies at t=10, t=20, t=30
for t in [10.0, 20.0, 30.0]:
mock_time.return_value = t
tracker.add(50.0)
# Spike is still in window
assert tracker.get_max() == 5000.0
# At t=60, the spike bucket (t=0) has expired
mock_time.return_value = 60.0
tracker.add(50.0)
assert tracker.get_max() == 50.0
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))