Skip to content

Commit adacb44

Browse files
authored
Merge pull request #102 from kellyrowland/configurable-cull
Configurable cull arbitration - continuation of #90
2 parents 715d60e + 474a199 commit adacb44

3 files changed

Lines changed: 116 additions & 16 deletions

File tree

jupyterhub_idle_culler/__init__.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020
from tornado.httputil import url_concat
2121
from tornado.ioloop import IOLoop, PeriodicCallback
2222
from tornado.log import LogFormatter
23-
from traitlets import Bool, Int, Unicode, default
23+
from traitlets import Bool, Callable, Int, Unicode, default
2424
from traitlets.config import Application
2525

26+
from .utils import maybe_future
27+
2628
__version__ = "1.4.1.dev"
2729

2830
STATE_FILTER_MIN_VERSION = V("1.3.0")
@@ -78,6 +80,11 @@ def utcnow():
7880
return datetime.now(timezone.utc)
7981

8082

83+
def default_cull_arbiter(*, inactive, inactive_limit, server, **kwargs):
84+
"""Return True if time inactive exceeds limit, the classic cull check."""
85+
return inactive.total_seconds() >= inactive_limit
86+
87+
8188
async def cull_idle(
8289
url,
8390
api_token,
@@ -93,6 +100,7 @@ async def cull_idle(
93100
api_page_size=0,
94101
cull_default_servers=True,
95102
cull_named_servers=True,
103+
cull_arbiter=default_cull_arbiter,
96104
):
97105
"""Shutdown idle single-user servers
98106
@@ -226,26 +234,18 @@ async def handle_server(user, server_name, server, max_age, inactive_limit):
226234
# for running servers
227235
inactive = age
228236

229-
# CUSTOM CULLING TEST CODE HERE
230-
# Add in additional server tests here. Return False to mean "don't
231-
# cull", True means "cull immediately", or, for example, update some
232-
# other variables like inactive_limit.
233-
#
234-
# Here, server['state'] is the result of the get_state method
235-
# on the spawner. This does *not* contain the below by
236-
# default, you may have to modify your spawner to make this
237-
# work. The `user` variable is the user model from the API.
238-
#
239-
# if server['state']['profile_name'] == 'unlimited'
240-
# return False
241-
# inactive_limit = server['state']['culltime']
242-
243237
is_default_server = server_name == ""
244238
is_named_server = server_name != ""
245239

240+
cull_result = await maybe_future(
241+
cull_arbiter(
242+
inactive=inactive, inactive_limit=inactive_limit, server=server
243+
)
244+
)
245+
246246
should_cull = (
247247
inactive is not None
248-
and inactive.total_seconds() >= inactive_limit
248+
and cull_result
249249
and (
250250
(cull_default_servers and is_default_server)
251251
or (cull_named_servers and is_named_server)
@@ -508,6 +508,36 @@ class IdleCuller(Application):
508508
config=True,
509509
)
510510

511+
cull_arbiter_hook = Callable(
512+
default_cull_arbiter,
513+
help=dedent("""
514+
Enable custom culling logic.
515+
516+
By default, the idle culler compares a server's time since last
517+
activity with a specified idle time limit. This hook allows for
518+
additional or more arbitrary logic when deciding whether to cull a
519+
server or not. To customize culling logic, define a callable taking
520+
3 arguments:
521+
522+
def my_cull_arbiter(inactive, inactive_limit, server):
523+
if server['state']['profile_name'] == 'unlimited':
524+
return False
525+
return inactive.total_seconds() >= inactive_limit
526+
c.IdleCuller.cull_arbiter_hook = my_cull_arbiter
527+
528+
- 'inactive' is the server's time since last activity, a timedelta object
529+
- 'inactive_limit' is the idle timeout limit, in seconds
530+
- 'server' is the server being considered for culling
531+
532+
This callable should return True if the server should be culled, and
533+
False if it should not. In this example, servers with a profile
534+
name of "unlimited" are never culled, but all others are subject to
535+
the default time limit logic.
536+
""").strip(),
537+
).tag(
538+
config=True,
539+
)
540+
511541
cull_every = Int(
512542
0,
513543
help=dedent("""
@@ -658,6 +688,8 @@ def start(self):
658688

659689
api_token = os.environ["JUPYTERHUB_API_TOKEN"]
660690

691+
cull_arbiter = self.cull_arbiter_hook
692+
661693
try:
662694
AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")
663695
except ImportError as e:
@@ -683,6 +715,7 @@ def start(self):
683715
api_page_size=self.api_page_size,
684716
cull_default_servers=self.cull_default_servers,
685717
cull_named_servers=self.cull_named_servers,
718+
cull_arbiter=cull_arbiter,
686719
)
687720
# schedule first cull immediately
688721
# because PeriodicCallback doesn't start until the end of the first interval

jupyterhub_idle_culler/utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Miscellaneous utilities"""
2+
3+
import asyncio
4+
import concurrent.futures
5+
import inspect
6+
7+
8+
def maybe_future(obj):
9+
"""Return an asyncio Future
10+
11+
Use instead of gen.maybe_future
12+
13+
For our compatibility, this must accept:
14+
15+
- asyncio coroutine (gen.maybe_future doesn't work in tornado < 5)
16+
- tornado coroutine (asyncio.ensure_future doesn't work)
17+
- scalar (asyncio.ensure_future doesn't work)
18+
- concurrent.futures.Future (asyncio.ensure_future doesn't work)
19+
- tornado Future (works both ways)
20+
- asyncio Future (works both ways)
21+
22+
Borrowed from https://github.com/jupyterhub/jupyterhub/blob/main/jupyterhub/utils.py
23+
since duplicating the one function here is lighter than adding jupyterhub as a
24+
dependency.
25+
"""
26+
if inspect.isawaitable(obj):
27+
# already awaitable, use ensure_future
28+
return asyncio.ensure_future(obj)
29+
elif isinstance(obj, concurrent.futures.Future):
30+
return asyncio.wrap_future(obj)
31+
else:
32+
# could also check for tornado.concurrent.Future
33+
# but with tornado >= 5.1 tornado.Future is asyncio.Future
34+
f = asyncio.Future()
35+
f.set_result(obj)
36+
return f

tests/test_idle_culler.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ async def count_active_users(admin_request):
3030
return active_users
3131

3232

33+
def cull_arbiter_function(inactive, inactive_limit, server):
34+
return True
35+
36+
37+
async def async_cull_arbiter_function(inactive, inactive_limit, server):
38+
return False
39+
40+
3341
async def test_cull_idle(cull_idle, start_users, admin_request):
3442
assert await count_active_users(admin_request) == 0
3543
await start_users(3)
@@ -46,5 +54,28 @@ async def test_cull_idle(cull_idle, start_users, admin_request):
4654
assert await count_active_users(admin_request) == 0
4755

4856

57+
async def test_custom_cull_arbiter(cull_idle, start_users, admin_request):
58+
assert await count_active_users(admin_request) == 0
59+
await start_users(3)
60+
assert await count_active_users(admin_request) == 3
61+
await cull_idle(
62+
inactive_limit=300, logger=app_log, cull_arbiter=cull_arbiter_function
63+
)
64+
# time has not passed but the cull arbiter function returns true
65+
# so, everyone culled
66+
assert await count_active_users(admin_request) == 0
67+
68+
69+
async def test_async_custom_cull_arbiter(cull_idle, start_users, admin_request):
70+
assert await count_active_users(admin_request) == 0
71+
await start_users(3)
72+
assert await count_active_users(admin_request) == 3
73+
# test an async arbiter that just returns false to verify that no changes are made
74+
await cull_idle(
75+
inactive_limit=300, logger=app_log, cull_arbiter=async_cull_arbiter_function
76+
)
77+
assert await count_active_users(admin_request) == 3
78+
79+
4980
def test_help():
5081
check_output([sys.executable, "-m", "jupyterhub_idle_culler", "--help"])

0 commit comments

Comments
 (0)