Skip to content

Commit 1656210

Browse files
committed
perf(resilience): isolate backend executors and add inflight backpressure
1 parent e6adf80 commit 1656210

1 file changed

Lines changed: 73 additions & 8 deletions

File tree

analytics/views_ajax.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import json
44
import logging
55
import os
6+
import datetime
7+
import threading
68
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
79

810
from pyramid.view import view_config
@@ -23,10 +25,22 @@
2325
_USAGE_YEARLY_TIMEOUT_SECONDS = float(
2426
os.environ.get("USAGE_YEARLY_TIMEOUT_SECONDS", str(_USAGE_REPORT_TIMEOUT_SECONDS))
2527
)
26-
_AFFILIATIONS_EXECUTOR = ThreadPoolExecutor(
28+
_USAGE_TIMEOUT_POOL_SIZE = int(os.environ.get("USAGE_TIMEOUT_POOL_SIZE", "8"))
29+
_USAGE_MAX_INFLIGHT = int(os.environ.get("USAGE_TIMEOUT_MAX_INFLIGHT", str(_USAGE_TIMEOUT_POOL_SIZE)))
30+
_PUBLICATION_MAX_INFLIGHT = int(
31+
os.environ.get("PUBLICATION_AFFILIATIONS_MAX_INFLIGHT", str(_AFFILIATIONS_TIMEOUT_POOL_SIZE))
32+
)
33+
34+
_USAGE_EXECUTOR = ThreadPoolExecutor(
35+
max_workers=_USAGE_TIMEOUT_POOL_SIZE,
36+
thread_name_prefix="usage-timeout",
37+
)
38+
_PUBLICATION_EXECUTOR = ThreadPoolExecutor(
2739
max_workers=_AFFILIATIONS_TIMEOUT_POOL_SIZE,
28-
thread_name_prefix="affiliations-timeout",
40+
thread_name_prefix="publication-timeout",
2941
)
42+
_USAGE_INFLIGHT = threading.BoundedSemaphore(value=_USAGE_MAX_INFLIGHT)
43+
_PUBLICATION_INFLIGHT = threading.BoundedSemaphore(value=_PUBLICATION_MAX_INFLIGHT)
3044

3145

3246
def _usage_cache_key(prefix, payload):
@@ -36,11 +50,39 @@ def _usage_cache_key(prefix, payload):
3650
)
3751

3852

39-
def _run_with_timeout(callable_obj, fallback_data, timeout_seconds, operation_name):
40-
future = _AFFILIATIONS_EXECUTOR.submit(callable_obj)
53+
def _submit_guarded(executor, inflight_guard, callable_obj, operation_name):
54+
if not inflight_guard.acquire(blocking=False):
55+
logger.warning(
56+
"Inflight guard reached for %s; returning fallback without backend call",
57+
operation_name
58+
)
59+
return None
60+
61+
try:
62+
future = executor.submit(callable_obj)
63+
except Exception:
64+
inflight_guard.release()
65+
raise
66+
67+
def _release_guard(_):
68+
try:
69+
inflight_guard.release()
70+
except ValueError:
71+
logger.warning("Inflight guard release imbalance for %s", operation_name)
72+
73+
future.add_done_callback(_release_guard)
74+
return future
75+
76+
77+
def _run_with_timeout(executor, inflight_guard, callable_obj, fallback_data, timeout_seconds, operation_name):
78+
future = _submit_guarded(executor, inflight_guard, callable_obj, operation_name)
79+
if future is None:
80+
return fallback_data
81+
4182
try:
4283
return future.result(timeout=timeout_seconds)
4384
except FuturesTimeoutError:
85+
future.cancel()
4486
logger.warning(
4587
"Timeout in %s after %.2fs; returning fallback",
4688
operation_name,
@@ -133,13 +175,27 @@ def _usage_selected_values(request):
133175
return selected_code, selected_collection_code, selected_document_code
134176

135177

178+
def _usage_date_range(request):
179+
today = datetime.datetime.now().isoformat()[0:10]
180+
range_start = (
181+
request.GET.get('range_start')
182+
or request.session.get('range_start')
183+
or '1998-01-01'
184+
)
185+
range_end = (
186+
request.GET.get('range_end')
187+
or request.session.get('range_end')
188+
or today
189+
)
190+
return range_start, range_end
191+
192+
136193
@view_config(route_name='usage_report_chart', request_method='GET', renderer='jsonp')
137194
@check_session
138195
def usage_report_chart(request):
139196

140197
api_version = request.GET.get('api_version', 'v2')
141-
range_start = request.GET.get('range_start', None)
142-
range_end = request.GET.get('range_end', None)
198+
range_start, range_end = _usage_date_range(request)
143199
report_code = request.GET.get('report_code', 'tr_j1')
144200
granularity = request.GET.get('granularity', 'monthly')
145201
selected_code, selected_collection_code, selected_document_code = _usage_selected_values(request)
@@ -161,6 +217,8 @@ def usage_report_chart(request):
161217
fallback_data = {'series': []}
162218

163219
data_chart = _run_with_timeout(
220+
_USAGE_EXECUTOR,
221+
_USAGE_INFLIGHT,
164222
lambda: cache_region.get_or_create(
165223
cache_key,
166224
lambda: request.stats.usage.get_usage_report(
@@ -191,8 +249,7 @@ def usage_report_chart(request):
191249
def usage_report_yearly_chart(request):
192250

193251
api_version = request.GET.get('api_version', 'v2')
194-
range_start = request.GET.get('range_start', None)
195-
range_end = request.GET.get('range_end', None)
252+
range_start, range_end = _usage_date_range(request)
196253
report_code = request.GET.get('report_code', 'cr_j1')
197254
metric_type = request.GET.get('metric_type', 'Total_Item_Requests')
198255
selected_code, selected_collection_code, selected_document_code = _usage_selected_values(request)
@@ -241,6 +298,8 @@ def _compute_yearly_chart():
241298
return request.stats.usage._title_report_to_yearly_chart_data(data_raw, metric_type=metric_type)
242299

243300
data_chart = _run_with_timeout(
301+
_USAGE_EXECUTOR,
302+
_USAGE_INFLIGHT,
244303
lambda: cache_region.get_or_create(cache_key, _compute_yearly_chart),
245304
fallback_data={'series': [], 'categories': []},
246305
timeout_seconds=_USAGE_YEARLY_TIMEOUT_SECONDS,
@@ -289,6 +348,8 @@ def publication_article_affiliations_map(request):
289348
cache_key = _usage_cache_key("publication_article_affiliations_map", cache_payload)
290349

291350
chart_data = _run_with_timeout(
351+
_PUBLICATION_EXECUTOR,
352+
_PUBLICATION_INFLIGHT,
292353
lambda: cache_region.get_or_create(
293354
cache_key,
294355
lambda: request.stats.publication.general(
@@ -326,6 +387,8 @@ def publication_article_affiliations(request):
326387
cache_key = _usage_cache_key("publication_article_affiliations", cache_payload)
327388

328389
chart_data = _run_with_timeout(
390+
_PUBLICATION_EXECUTOR,
391+
_PUBLICATION_INFLIGHT,
329392
lambda: cache_region.get_or_create(
330393
cache_key,
331394
lambda: request.stats.publication.general(
@@ -364,6 +427,8 @@ def publication_article_affiliations_publication_year(request):
364427
cache_key = _usage_cache_key("publication_article_affiliations_publication_year", cache_payload)
365428

366429
chart_data = _run_with_timeout(
430+
_PUBLICATION_EXECUTOR,
431+
_PUBLICATION_INFLIGHT,
367432
lambda: cache_region.get_or_create(
368433
cache_key,
369434
lambda: request.stats.publication.affiliations_by_publication_year(

0 commit comments

Comments
 (0)