Skip to content

Commit 613ba65

Browse files
YasenTcursoragent
andauthored
fix(content-guard): bound Features Service calls and fix per-entry cache TTL (#1303)
FeatureContentGuard rebuilt a new SSLContext, aiohttp session, and event loop on every cache-miss request with no timeout, and FeatureContentGuardCache applied Redis's hash-level TTL instead of a per-entry one, letting one account's cache write evict another account's still-valid entry. This made content-guarded downloads consistently take 7-10s to first byte, even on repeat requests that should have hit the cache. - Reuse a pooled requests.Session instead of rebuilding TLS/session/event loop per request, bounded by a new FEATURE_SERVICE_API_TIMEOUT setting - Store per-entry expiry inside the cached value and validate on read, instead of relying on the shared hash's EXPIRE - Add unit tests covering cache hit/miss/expiry/legacy-format/timeout behavior Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 99bd324 commit 613ba65

5 files changed

Lines changed: 382 additions & 45 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed high latency (7-10s time-to-first-byte) on content-guarded repositories using ``FeatureContentGuard``. The guard's Features Service call had no timeout and rebuilt a TLS context, HTTP client, and event loop on every single request; it now reuses a pooled ``requests.Session`` and is bounded by ``FEATURE_SERVICE_API_CONNECT_TIMEOUT``/``FEATURE_SERVICE_API_READ_TIMEOUT``. Also fixed a caching bug where ``FeatureContentGuardCache``'s TTL was applied to the shared Redis hash instead of the individual cached entry, which could cause one account's write to prematurely evict another account's still-valid cached result, and hardened the guard so an unexpected error (e.g. a misconfigured setting) fails closed with ``PermissionError`` instead of escaping and crashing pulpcore's guard-caching logic.

pulp_service/pulp_service/app/models.py

Lines changed: 112 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import asyncio
21
import json
32
import logging
4-
import ssl
3+
import threading
4+
import time
55
from base64 import b64decode
66
from binascii import Error as Base64DecodeError
77
from datetime import UTC, datetime
88
from gettext import gettext as _
99
from hashlib import sha256
1010

11-
import aiohttp
1211
import jq
12+
import requests
1313
from django.conf import settings
1414
from django.contrib.postgres.fields import ArrayField, HStoreField
1515
from django.db import models
@@ -54,38 +54,115 @@ class FeatureContentGuardCache(Cache):
5454
class FeatureContentGuard(HeaderContentGuard, AutoAddObjPermsMixin):
5555
features = ArrayField(models.TextField())
5656

57-
def _check_for_feature(self, account_id):
58-
cert_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
59-
cert_context.load_cert_chain(certfile=settings.FEATURE_SERVICE_API_CERT_PATH)
57+
_session_lock = threading.Lock()
58+
_session = None
6059

61-
account_id_query_param = f"accountId={account_id}"
62-
features_query_param = "&".join(f"features={feature}" for feature in self.features)
63-
feature_service_api_url = f"{settings.FEATURE_SERVICE_API_URL}?{account_id_query_param}&{features_query_param}"
60+
@classmethod
61+
def _get_session(cls):
62+
"""
63+
Returns a process-wide ``requests.Session`` configured with the client certificate.
6464
65-
async def fetch_feature():
66-
async with aiohttp.ClientSession() as session:
67-
async with session.get(feature_service_api_url, ssl=cert_context, raise_for_status=True) as response:
68-
return await response.json()
65+
The previous implementation built a brand new ``ssl.SSLContext`` (re-reading and
66+
re-parsing the certificate off disk), a new ``aiohttp.ClientSession``, and a brand
67+
new asyncio event loop (via ``asyncio.run``) on *every single content request* that
68+
missed the cache. Reusing a session lets urllib3 keep a warm, pooled TLS connection
69+
to the Features Service and avoids that repeated setup cost.
70+
"""
71+
if cls._session is None:
72+
with cls._session_lock:
73+
if cls._session is None:
74+
session = requests.Session()
75+
session.cert = settings.FEATURE_SERVICE_API_CERT_PATH
76+
cls._session = session
77+
return cls._session
78+
79+
def _check_for_feature(self, account_id):
80+
session = self._get_session()
81+
params = [("accountId", account_id), *(("features", feature) for feature in self.features)]
82+
# permit() runs synchronously on the content app's shared sync-to-async worker thread
83+
# (pulpcore.content.handler.Handler._permit is invoked via sync_to_async). A slow or
84+
# hanging call here doesn't just delay this request -- it can stall every other
85+
# request being served through that same thread, so this must be bounded.
86+
#
87+
# This must be a real Python tuple built here, not a settings value passed through
88+
# as-is: `requests` only splits a *tuple* into (connect, read) timeouts, and Pulp's
89+
# settings pipeline can silently turn a tuple default into a list, which `requests`
90+
# then treats as a single (invalid) timeout value and raises a ValueError.
91+
timeout = (settings.FEATURE_SERVICE_API_CONNECT_TIMEOUT, settings.FEATURE_SERVICE_API_READ_TIMEOUT)
6992

7093
try:
7194
_logger.info("[%s] Making a request to feature service API ...", datetime.now(tz=UTC))
72-
response = asyncio.run(fetch_feature())
95+
response = session.get(
96+
settings.FEATURE_SERVICE_API_URL,
97+
params=params,
98+
timeout=timeout,
99+
)
100+
response.raise_for_status()
73101
_logger.info("[%s] Got a response from feature service API!", datetime.now(tz=UTC))
74-
except aiohttp.ClientResponseError as err:
75-
if err.status == 400:
76-
_logger.exception("Failed to request information for a user. BadRequest. URL: %s", err.request_info.url)
77102

78-
if err.status == 403:
103+
features_available = {feature["name"] for feature in response.json()["features"]}
104+
return features_available == set(self.features)
105+
except requests.Timeout as err:
106+
_logger.warning(
107+
"Failed to fetch the Subscription feature information for a user. "
108+
"The Features Service API did not respond within %s seconds.",
109+
timeout,
110+
)
111+
raise PermissionError(_("Access denied.")) from err
112+
except requests.HTTPError as err:
113+
status_code = err.response.status_code if err.response is not None else None
114+
if status_code == 400:
115+
_logger.exception("Failed to request information for a user. BadRequest. URL: %s", err.request.url)
116+
117+
if status_code == 403:
79118
_logger.exception(
80119
"Failed to request information for a user. "
81120
"Permission Denied. Verify if the certificate is still valid."
82121
)
83122

84123
_logger.warning(_("Failed to fetch the Subscription feature information for a user."))
85124
raise PermissionError(_("Access denied.")) from err
125+
except requests.RequestException as err:
126+
_logger.warning("Failed to reach the Features Service API: %s", err)
127+
raise PermissionError(_("Access denied.")) from err
128+
except Exception as err:
129+
# `permit()` is only ever allowed to return normally or raise `PermissionError`
130+
# (pulpcore.content.handler.Handler.auth_cached assumes exactly that contract).
131+
# Anything else -- a bug here, a misconfigured setting, an unexpected response
132+
# shape -- would otherwise escape as-is and crash with a confusing, unrelated
133+
# `UnboundLocalError` in pulpcore's guard-caching `finally` block, masking the
134+
# real error entirely. Fail closed and log loudly instead.
135+
_logger.exception("Unexpected error while checking the Features Service.")
136+
raise PermissionError(_("Access denied.")) from err
86137

87-
features_available = {feature["name"] for feature in response["features"]}
88-
return features_available == set(self.features)
138+
@staticmethod
139+
def _get_cached_result(feature_cache, key):
140+
"""
141+
Returns the cached True/False result for `key`, or None if there is no live entry.
142+
143+
`Cache.set`'s `expires` argument calls Redis' `EXPIRE` on the *entire* hash the
144+
entry lives under, not on the individual field (Redis hash fields don't carry their
145+
own TTL). Every account sharing `FeatureContentGuardCache`'s single base key would
146+
therefore have its cached result's lifetime reset by any *other* account's write,
147+
which can prematurely evict still-valid entries. Instead, the expiration is embedded
148+
in the cached value itself and validated on read here, matching the pattern
149+
`pulpcore.cache.AsyncContentCache` already uses for the same reason.
150+
"""
151+
raw = feature_cache.get(key)
152+
if not raw:
153+
return None
154+
try:
155+
entry = json.loads(raw)
156+
if entry["expires_at"] < time.time():
157+
return None
158+
return entry["allowed"]
159+
except (TypeError, ValueError, KeyError):
160+
return None
161+
162+
@staticmethod
163+
def _set_cached_result(feature_cache, key, allowed):
164+
entry = {"allowed": allowed, "expires_at": time.time() + feature_cache.default_expires_ttl}
165+
feature_cache.set(key, json.dumps(entry), expires=feature_cache.default_expires_ttl)
89166

90167
def permit(self, request):
91168
try:
@@ -113,31 +190,21 @@ def permit(self, request):
113190
_logger.exception("Access not allowed - Invalid JSON or Path not found.")
114191
raise PermissionError(_("Access denied.")) from exc
115192

116-
try:
117-
cache_key = f"{header_value}-{','.join(self.features)}"
118-
cache_key_digest = sha256(bytes(cache_key, "utf8")).hexdigest()
119-
feature_cache = FeatureContentGuardCache()
120-
account_allowed = feature_cache.get(cache_key_digest)
121-
122-
if not account_allowed:
123-
account_allowed = self._check_for_feature(header_value)
124-
serialized_account_allowed = json.dumps(account_allowed)
125-
feature_cache.set(
126-
cache_key_digest,
127-
serialized_account_allowed,
128-
expires=feature_cache.default_expires_ttl,
129-
)
130-
131-
if isinstance(account_allowed, bytes):
132-
account_allowed = json.loads(account_allowed)
133-
134-
if not account_allowed:
135-
_logger.warning("Access not allowed - Features not available for the user.")
136-
raise PermissionError(_("Access denied."))
137-
138-
except aiohttp.ClientResponseError as exc:
139-
_logger.warning("Access not allowed - Failed to check for features.")
140-
raise PermissionError(_("Access denied.")) from exc
193+
cache_key = f"{header_value}-{','.join(self.features)}"
194+
cache_key_digest = sha256(bytes(cache_key, "utf8")).hexdigest()
195+
feature_cache = FeatureContentGuardCache()
196+
account_allowed = self._get_cached_result(feature_cache, cache_key_digest)
197+
198+
if account_allowed is None:
199+
_logger.debug("Feature cache MISS for key %s", cache_key_digest)
200+
account_allowed = self._check_for_feature(header_value)
201+
self._set_cached_result(feature_cache, cache_key_digest, account_allowed)
202+
else:
203+
_logger.debug("Feature cache HIT for key %s", cache_key_digest)
204+
205+
if not account_allowed:
206+
_logger.warning("Access not allowed - Features not available for the user.")
207+
raise PermissionError(_("Access denied."))
141208

142209
return
143210

pulp_service/pulp_service/app/settings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@
77

88
FEATURE_SERVICE_API_URL = "https://feature.stage.api.redhat.com/features/v2/featureStatus"
99
FEATURE_SERVICE_API_CERT_PATH = ""
10+
# Connect/read timeouts (seconds) for calls made to the Features Service API. This call
11+
# happens synchronously on the content app's shared sync-to-async worker thread, so it must
12+
# be bounded -- otherwise a slow/unavailable Features Service stalls every content request
13+
# being served by that worker, not just the guarded one.
14+
# NOTE: kept as two scalars rather than a single (connect, read) tuple -- Pulp's settings
15+
# pipeline round-trips through JSON/YAML in places, which silently turns tuples into lists,
16+
# and `requests` only splits a *tuple* into (connect, read); a list is treated as a single
17+
# malformed timeout value and raises a ValueError.
18+
FEATURE_SERVICE_API_CONNECT_TIMEOUT = 2
19+
FEATURE_SERVICE_API_READ_TIMEOUT = 5
1020
AUTHENTICATION_HEADER_DEBUG = False
1121
INSTALLED_APPS = "@merge django.contrib.admin.apps.SimpleAdminConfig,hijack,hijack.contrib.admin"
1222
TEST_TASK_INGESTION = False

0 commit comments

Comments
 (0)