Skip to content

Commit f2e3d4b

Browse files
authored
Merge pull request #165 from minrk/tweak-perf
Improve performance, scaling
2 parents 79c6ef0 + 242e40b commit f2e3d4b

File tree

3 files changed

+90
-26
lines changed

3 files changed

+90
-26
lines changed

jupyterhub_traefik_proxy/fileprovider.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@ async def add_route(self, routespec, target, data):
200200
)
201201
raise
202202
try:
203-
await self._wait_for_route(traefik_routespec)
203+
async with self.semaphore:
204+
await self._wait_for_route(traefik_routespec)
204205
except TimeoutError:
205206
self.log.error(
206207
f"Is Traefik configured to watch {self.dynamic_config_file}?"

jupyterhub_traefik_proxy/kv_proxy.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
# Copyright (c) Jupyter Development Team.
1919
# Distributed under the terms of the Modified BSD License.
2020

21+
import asyncio
2122
import json
2223
from collections.abc import MutableMapping
24+
from functools import wraps
2325

2426
import escapism
2527
from traitlets import Unicode
@@ -28,6 +30,32 @@
2830
from .proxy import TraefikProxy
2931

3032

33+
def _one_at_a_time(method):
34+
"""decorator to limit an async method to be called only once
35+
36+
If multiple concurrent calls to this method are made,
37+
piggy-back on the outstanding call instead of queuing
38+
or letting requests pile up.
39+
"""
40+
41+
@wraps(method)
42+
async def locked_method(*args, **kwargs):
43+
if getattr(method, "_shared_future", None) is not None:
44+
f = method._shared_future
45+
if f.done():
46+
method._shared_future = None
47+
else:
48+
return await f
49+
50+
method._shared_future = f = asyncio.ensure_future(method(*args, **kwargs))
51+
try:
52+
return await f
53+
finally:
54+
method._shared_future = None
55+
56+
return locked_method
57+
58+
3159
class TKvProxy(TraefikProxy):
3260
"""
3361
JupyterHub Proxy implementation using traefik and a key-value store.
@@ -218,19 +246,11 @@ async def add_route(self, routespec, target, data):
218246
[self.kv_jupyterhub_prefix, "routes", escapism.escape(routespec)]
219247
)
220248

221-
status, response = await self._kv_atomic_add_route_parts(
222-
jupyterhub_routespec, target, data, route_keys, rule
223-
)
249+
async with self.semaphore:
250+
status, response = await self._kv_atomic_add_route_parts(
251+
jupyterhub_routespec, target, data, route_keys, rule
252+
)
224253

225-
if self.should_start:
226-
try:
227-
# Check if traefik was launched
228-
self.traefik_process.pid
229-
except AttributeError:
230-
self.log.error(
231-
"You cannot add routes if the proxy isn't running! Please start the proxy: proxy.start()"
232-
)
233-
raise
234254
if status:
235255
self.log.debug(
236256
"Added service %s with the alias %s.", target, route_keys.service_alias
@@ -245,8 +265,9 @@ async def add_route(self, routespec, target, data):
245265
self.log.error(
246266
"Couldn't add route for %s. Response: %s", routespec, response
247267
)
248-
249-
await self._wait_for_route(routespec)
268+
raise RuntimeError(f"Couldn't add route for {routespec}")
269+
async with self.semaphore:
270+
await self._wait_for_route(routespec)
250271

251272
async def delete_route(self, routespec):
252273
"""Delete a route and all the traefik related info associated given a routespec,
@@ -260,16 +281,18 @@ async def delete_route(self, routespec):
260281
self, routespec, separator=self.kv_separator
261282
)
262283

263-
status, response = await self._kv_atomic_delete_route_parts(
264-
jupyterhub_routespec, route_keys
265-
)
284+
async with self.semaphore:
285+
status, response = await self._kv_atomic_delete_route_parts(
286+
jupyterhub_routespec, route_keys
287+
)
266288
if status:
267289
self.log.debug("Routespec %s was deleted.", routespec)
268290
else:
269291
self.log.error(
270292
"Couldn't delete route %s. Response: %s", routespec, response
271293
)
272294

295+
@_one_at_a_time
273296
async def get_all_routes(self):
274297
"""Fetch and return all the routes associated by JupyterHub from the
275298
proxy.

jupyterhub_traefik_proxy/proxy.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
# Copyright (c) Jupyter Development Team.
1919
# Distributed under the terms of the Modified BSD License.
2020

21+
import asyncio
2122
import json
2223
import os
2324
from os.path import abspath
@@ -27,7 +28,7 @@
2728
from jupyterhub.proxy import Proxy
2829
from jupyterhub.utils import exponential_backoff, new_token, url_path_join
2930
from tornado.httpclient import AsyncHTTPClient, HTTPClientError
30-
from traitlets import Any, Bool, Dict, Integer, Unicode, default, validate
31+
from traitlets import Any, Bool, Dict, Integer, Unicode, default, observe, validate
3132

3233
from . import traefik_utils
3334

@@ -37,6 +38,26 @@ class TraefikProxy(Proxy):
3738

3839
traefik_process = Any()
3940

41+
concurrency = Integer(
42+
10,
43+
config=True,
44+
help="""
45+
The number of requests allowed to be concurrently outstanding to the proxy
46+
47+
Limiting this number avoids potential timeout errors
48+
by sending too many requests to update the proxy at once
49+
""",
50+
)
51+
semaphore = Any()
52+
53+
@default('semaphore')
54+
def _default_semaphore(self):
55+
return asyncio.BoundedSemaphore(self.concurrency)
56+
57+
@observe('concurrency')
58+
def _concurrency_changed(self, change):
59+
self.semaphore = asyncio.BoundedSemaphore(change.new)
60+
4061
static_config_file = Unicode(
4162
"traefik.toml", config=True, help="""traefik's static configuration file"""
4263
)
@@ -86,6 +107,20 @@ def __init__(self, **kwargs):
86107
static_config = Dict()
87108
dynamic_config = Dict()
88109

110+
traefik_providers_throttle_duration = Unicode(
111+
"0s",
112+
config=True,
113+
help="""
114+
throttle traefik reloads of configuration.
115+
116+
When traefik sees a change in configuration,
117+
it will wait this long before applying the next one.
118+
This affects how long adding a user to the proxy will take.
119+
120+
See https://doc.traefik.io/traefik/providers/overview/#providersprovidersthrottleduration
121+
""",
122+
)
123+
89124
traefik_api_url = Unicode(
90125
"http://localhost:8099",
91126
config=True,
@@ -235,17 +270,18 @@ async def _check_for_traefik_service(self, routespec, kind):
235270
expected = (
236271
traefik_utils.generate_alias(routespec, kind) + "@" + self.provider_name
237272
)
238-
path = f"/api/http/{kind}s"
273+
path = f"/api/http/{kind}s/{expected}"
239274
try:
240275
resp = await self._traefik_api_request(path)
241-
json_data = json.loads(resp.body)
242-
except Exception:
276+
json.loads(resp.body)
277+
except HTTPClientError as e:
278+
if e.code == 404:
279+
self.log.debug(f"traefik {expected} not yet in {kind}")
280+
return False
243281
self.log.exception(f"Error checking traefik api for {kind} {routespec}")
244282
return False
245-
246-
service_names = [service['name'] for service in json_data]
247-
if expected not in service_names:
248-
self.log.debug(f"traefik {expected} not yet in {kind}")
283+
except Exception:
284+
self.log.exception(f"Error checking traefik api for {kind} {routespec}")
249285
return False
250286

251287
# found the expected endpoint
@@ -266,6 +302,7 @@ async def _check_traefik_dynamic_conf_ready():
266302
await exponential_backoff(
267303
_check_traefik_dynamic_conf_ready,
268304
f"Traefik route for {routespec} configuration not available",
305+
scale_factor=1.2,
269306
timeout=self.check_route_timeout,
270307
)
271308

@@ -358,6 +395,9 @@ async def _setup_traefik_static_config(self):
358395
Subclasses should specify any traefik providers themselves, in
359396
:attrib:`self.static_config["providers"]`
360397
"""
398+
self.static_config["providers"][
399+
"providersThrottleDuration"
400+
] = self.traefik_providers_throttle_duration
361401

362402
if self.traefik_log_level:
363403
self.static_config["log"] = {"level": self.traefik_log_level}

0 commit comments

Comments
 (0)