Skip to content

Commit 8d7515e

Browse files
committed
test: Pin gap semantics and delay-source read ordering
1 parent 0b18295 commit 8d7515e

3 files changed

Lines changed: 90 additions & 12 deletions

File tree

‎ldclient/impl/aio/concurrency.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ def start(self):
215215
log.info("Task %s has already been started; ignoring" % self.__label)
216216
return
217217
self.__task = asyncio.ensure_future(self._run())
218+
self.__task.add_done_callback(_log_task_exception)
218219
try:
219220
self.__task.set_name(f"{self.__label}.repeating")
220221
except AttributeError:

‎ldclient/testing/impl/test_repeating_task.py‎

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,35 +79,70 @@ def test_task_executes_until_stopped():
7979
assert no_more_items is True
8080

8181

82-
class _MutableDelay(DelaySource):
83-
"""A delay source a test can move between invocations."""
82+
class _RecordingDelay(DelaySource):
83+
"""A delay source that records each read, so a test can see when the task
84+
asks for a wait rather than only what the action saw."""
8485

85-
def __init__(self, seconds: float):
86+
def __init__(self, seconds: float, events: list):
8687
self.seconds = seconds
88+
self._events = events
8789

8890
@property
8991
def next_delay(self) -> float:
92+
self._events.append(('read', self.seconds))
9093
return self.seconds
9194

9295

9396
def test_the_task_reads_the_delay_source_after_every_invocation():
94-
"""A value the action decides takes effect on the next wait."""
95-
reads = Queue()
96-
delays = _MutableDelay(0.01)
97+
"""One read per invocation, after it. A task that read the source once up
98+
front would show a read before the first invocation, and would never see
99+
the value the action set."""
100+
events: list = []
101+
delays = _RecordingDelay(0.01, events)
97102

98103
def do_task():
99-
reads.put(delays.seconds)
100-
delays.seconds = 0.02 # what the next wait must use
104+
events.append('invoke')
105+
delays.seconds = 0.02
101106

102-
task = RepeatingTask("ldclient.testing.mutable-delay", delays, 0, do_task)
107+
task = RepeatingTask("ldclient.testing.recording-delay", delays, 0, do_task)
103108
try:
104109
task.start()
105-
assert reads.get(True, 1) == 0.01
106-
assert reads.get(True, 1) == 0.02
107-
assert reads.get(True, 1) == 0.02
110+
deadline = time.time() + 2
111+
while events.count('invoke') < 3 and time.time() < deadline:
112+
time.sleep(0.005)
108113
finally:
109114
task.stop()
110115

116+
# Reads and invocations alternate, starting with an invocation, and every
117+
# read sees 0.02 -- the initial 0.01 is never read.
118+
assert events[:5] == ['invoke', ('read', 0.02), 'invoke', ('read', 0.02), 'invoke']
119+
120+
121+
def test_the_interval_starts_when_the_callback_returns():
122+
"""A slow callback must not shorten its own wait: one invocation to the
123+
next is the interval plus however long the callback took."""
124+
work = 0.15
125+
interval = 0.15
126+
starts = Queue()
127+
128+
def do_task():
129+
starts.put(time.time())
130+
time.sleep(work)
131+
132+
task = RepeatingTask.at_interval("ldclient.testing.slow-callback", interval, 0, do_task)
133+
try:
134+
first = None
135+
task.start()
136+
first = starts.get(True, 2)
137+
second = starts.get(True, 2)
138+
finally:
139+
task.stop()
140+
141+
# Measuring the interval from the start of the callback would give about
142+
# `interval`; measuring from its return gives interval + work. The 10%
143+
# slack is for scheduling noise, and leaves the two regimes far apart.
144+
assert (second - first) >= (interval + work) * 0.9
145+
111146

112147
def test_whatever_the_action_returns_is_ignored():
113148
"""Guards big-segment polling, whose action returns a status object."""

‎ldclient/testing/test_aio.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,48 @@ async def action():
177177
await asyncio.sleep(0.1)
178178
assert counts['n'] == 1
179179

180+
@pytest.mark.asyncio
181+
async def test_async_a_task_that_dies_is_logged(self, caplog):
182+
"""Without a done callback the held reference suppresses asyncio's own
183+
warning, so a dead loop would be entirely silent."""
184+
caplog.set_level(logging.ERROR)
185+
186+
class Exploding:
187+
@property
188+
def next_delay(self):
189+
raise RuntimeError("delay source is broken")
190+
191+
async def action():
192+
pass
193+
194+
task = aio.AsyncRepeatingTask("test.repeating", Exploding(), 0, action)
195+
task.start()
196+
await _async_wait_until(lambda: caplog.records, timeout=2)
197+
task.stop()
198+
199+
assert "Unhandled exception in background task" in caplog.records[0].getMessage()
200+
201+
@pytest.mark.asyncio
202+
async def test_async_interval_starts_when_the_callback_returns(self):
203+
"""A slow callback must not shorten its own wait: one invocation to the
204+
next is the interval plus however long the callback took."""
205+
work = 0.15
206+
interval = 0.15
207+
starts: list = []
208+
209+
async def action():
210+
starts.append(time.time())
211+
await asyncio.sleep(work)
212+
213+
task = aio.AsyncRepeatingTask.at_interval("test.repeating", interval, 0, action)
214+
task.start()
215+
await _async_wait_until(lambda: len(starts) >= 2, timeout=3)
216+
task.stop()
217+
218+
# Measuring the interval from the start of the callback would give
219+
# about `interval`; measuring from its return gives interval + work.
220+
assert (starts[1] - starts[0]) >= (interval + work) * 0.9
221+
180222
@pytest.mark.asyncio
181223
async def test_async_second_start_logs_and_does_not_raise(self, caplog):
182224
"""Mirrors the sync primitive. A raise here can surface out of a caller

0 commit comments

Comments
 (0)