Skip to content

Commit 54fe5a9

Browse files
idle detection
1 parent d2f54f3 commit 54fe5a9

9 files changed

Lines changed: 334 additions & 19 deletions

File tree

fastloop/config.default.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ prettyPrintLogs: true
44
loopDelayS: 0.1
55
ssePollIntervalS: 0.1
66
sseKeepAliveS: 10.0
7-
shutdownIdle: true
8-
maxIdleCycles: 10
97
shutdownTimeoutS: 10.0
108
cors:
119
enabled: true

fastloop/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
LEASE_TTL_S = 30
99
LEASE_HEARTBEAT_INTERVAL_S = 10
1010
MAX_EVENT_HISTORY = 1000
11+
MEANINGFUL_WORK_THRESHOLD_S = 0.01

fastloop/context.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,18 @@ def __init__(
5353
self.initial_event: LoopEvent | None = initial_event
5454
self.state_manager: StateManager = state_manager
5555
self.event_this_cycle: bool = False
56+
self._wait_time_this_cycle: float = 0.0
5657

5758
integrations = integrations or []
5859
self.integrations: dict[str, Integration] = {i.type(): i for i in integrations}
5960
self.integration_events: dict[str, list[Any]] = {
6061
i.type(): i.events() for i in integrations
6162
}
6263

64+
def _reset_cycle_tracking(self) -> None:
65+
self.event_this_cycle = False
66+
self._wait_time_this_cycle = 0.0
67+
6368
def stop(self):
6469
self._stop_requested = True
6570
raise LoopStoppedError()
@@ -132,7 +137,6 @@ async def wait_for(
132137
if self.should_stop:
133138
raise LoopStoppedError()
134139

135-
# Try to get event immediately
136140
event_result = await self.state_manager.pop_event(
137141
self.loop_id,
138142
event, # type: ignore
@@ -142,18 +146,18 @@ async def wait_for(
142146
self.event_this_cycle = True
143147
return cast(E, event_result) # noqa
144148

145-
# Wait for notification or timeout
146149
remaining_timeout = timeout - (asyncio.get_event_loop().time() - start)
147150
if remaining_timeout <= 0:
148151
break
149152

150-
# Wait for event notification or poll interval
151153
poll_timeout = min(
152154
EVENT_POLL_INTERVAL_S, remaining_timeout or EVENT_POLL_INTERVAL_S
153155
)
156+
wait_start = time.monotonic()
154157
await self.state_manager.wait_for_event_notification(
155158
pubsub, timeout=poll_timeout
156159
)
160+
self._wait_time_this_cycle += time.monotonic() - wait_start
157161

158162
finally:
159163
if pubsub is not None:

fastloop/fastloop.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,14 @@ def loop(
210210
on_stop: Callable[..., Any] | None = None,
211211
integrations: list[Integration] | None = None,
212212
stop_on_disconnect: bool = False,
213+
stop_after_idle_seconds: float | None = None,
214+
pause_after_idle_seconds: float | None = None,
213215
) -> Callable[[Callable[..., Any] | type[Loop]], Callable[..., Any] | type[Loop]]:
214216
"""Decorator to register a loop function or class."""
217+
if stop_after_idle_seconds is not None and pause_after_idle_seconds is not None:
218+
raise ValueError(
219+
"Cannot set both stop_after_idle_seconds and pause_after_idle_seconds"
220+
)
215221

216222
def _decorator(
217223
func_or_class: Callable[..., Any] | type[Loop],
@@ -250,6 +256,8 @@ def _decorator(
250256
"loop_delay": self.config.loop_delay_s,
251257
"integrations": integrations,
252258
"stop_on_disconnect": stop_on_disconnect,
259+
"stop_after_idle_seconds": stop_after_idle_seconds,
260+
"pause_after_idle_seconds": pause_after_idle_seconds,
253261
"loop_instance": loop_instance,
254262
}
255263
else:
@@ -361,6 +369,8 @@ async def _event_handler(request: dict[str, Any], func: Any = func):
361369
context=context,
362370
loop=loop,
363371
loop_delay=self.config.loop_delay_s,
372+
stop_after_idle_seconds=stop_after_idle_seconds,
373+
pause_after_idle_seconds=pause_after_idle_seconds,
364374
)
365375
if started:
366376
logger.info(
@@ -700,6 +710,8 @@ async def restart_loop(self, loop_id: str) -> bool:
700710
context=context,
701711
loop=loop,
702712
loop_delay=metadata["loop_delay"],
713+
stop_after_idle_seconds=metadata.get("stop_after_idle_seconds"),
714+
pause_after_idle_seconds=metadata.get("pause_after_idle_seconds"),
703715
)
704716
if started:
705717
logger.info("Restarted loop", extra={"loop_id": loop.loop_id})

fastloop/loop.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import asyncio
1010
import json
11+
import time
1112
import traceback
1213
import uuid
1314
from collections.abc import Callable
@@ -16,7 +17,7 @@
1617
from fastapi import HTTPException
1718
from fastapi.responses import StreamingResponse
1819

19-
from .constants import CANCEL_GRACE_PERIOD_S
20+
from .constants import CANCEL_GRACE_PERIOD_S, MEANINGFUL_WORK_THRESHOLD_S
2021
from .exceptions import (
2122
EventTimeoutError,
2223
LoopClaimError,
@@ -76,6 +77,8 @@ async def _run(
7677
delay: float,
7778
loop_start_func: Callable[..., Any] | None,
7879
loop_stop_func: Callable[..., Any] | None,
80+
stop_after_idle_seconds: float | None = None,
81+
pause_after_idle_seconds: float | None = None,
7982
) -> None:
8083
try:
8184
async with self.state_manager.with_claim(loop_id): # type: ignore
@@ -85,10 +88,12 @@ async def _run(
8588
await loop_start_func(context)
8689
else:
8790
loop_start_func(context) # type: ignore
88-
idle_cycles = 0
91+
92+
last_active_time = time.time()
8993

9094
while not context.should_stop and not context.should_pause:
91-
context.event_this_cycle = False
95+
context._reset_cycle_tracking()
96+
cycle_start = time.monotonic()
9297

9398
try:
9499
if asyncio.iscoroutinefunction(func):
@@ -122,15 +127,41 @@ async def _run(
122127
},
123128
)
124129

125-
if not context.event_this_cycle:
126-
idle_cycles += 1
130+
cycle_duration = time.monotonic() - cycle_start
131+
work_time = cycle_duration - context._wait_time_this_cycle
132+
is_active = (
133+
work_time > MEANINGFUL_WORK_THRESHOLD_S
134+
or context.event_this_cycle
135+
)
136+
137+
if is_active:
138+
last_active_time = time.time()
139+
else:
140+
idle_seconds = time.time() - last_active_time
127141
if (
128-
idle_cycles >= self.config.max_idle_cycles
129-
and self.config.shutdown_idle
142+
stop_after_idle_seconds is not None
143+
and idle_seconds >= stop_after_idle_seconds
130144
):
145+
logger.info(
146+
"Loop idle timeout reached, stopping",
147+
extra={
148+
"loop_id": loop_id,
149+
"idle_seconds": idle_seconds,
150+
},
151+
)
152+
raise LoopStoppedError()
153+
if (
154+
pause_after_idle_seconds is not None
155+
and idle_seconds >= pause_after_idle_seconds
156+
):
157+
logger.info(
158+
"Loop idle timeout reached, pausing",
159+
extra={
160+
"loop_id": loop_id,
161+
"idle_seconds": idle_seconds,
162+
},
163+
)
131164
raise LoopPausedError()
132-
else:
133-
idle_cycles = 0
134165

135166
try:
136167
await asyncio.sleep(delay)
@@ -180,13 +211,22 @@ async def start(
180211
context: Any,
181212
loop: LoopState,
182213
loop_delay: float = 0.1,
214+
stop_after_idle_seconds: float | None = None,
215+
pause_after_idle_seconds: float | None = None,
183216
) -> bool:
184217
if loop.loop_id in self.loop_tasks:
185218
return False
186219

187220
self.loop_tasks[loop.loop_id] = asyncio.create_task(
188221
self._run(
189-
func, context, loop.loop_id, loop_delay, loop_start_func, loop_stop_func
222+
func,
223+
context,
224+
loop.loop_id,
225+
loop_delay,
226+
loop_start_func,
227+
loop_stop_func,
228+
stop_after_idle_seconds,
229+
pause_after_idle_seconds,
190230
)
191231
)
192232

fastloop/types.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,6 @@ class BaseConfig(BaseModel):
148148
loop_delay_s: float = 0.1
149149
sse_poll_interval_s: float = 0.1
150150
sse_keep_alive_s: float = 10.0
151-
shutdown_idle: bool = True
152-
max_idle_cycles: int = 10
153151
shutdown_timeout_s: float = 10.0
154152
port: int = 8000
155153
host: str = "localhost"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastloop"
3-
version = "0.1.101"
3+
version = "0.1.102"
44
description = "A Python package for deploying stateful loops"
55
readme = "README.md"
66
requires-python = ">=3.12"

0 commit comments

Comments
 (0)