Skip to content

Commit f0cd149

Browse files
committed
feat(etl-uvicorn): settings-scoped cache for per-invoke derived state
A plugin consuming current_invocation_settings() builds its handler per distinct settings payload instead of once at boot, and construction typically does network work (model resolution, prechecks). This gives that pattern one home next to the accessor that creates the need: settings_cache_key digests the canonical settings JSON so secret-bearing payloads are never raw keys, and SettingsScopedCache memoizes derived state bounded by both size and age — age matters because state built from since-rotated credentials must not outlive them on a quiet pod. Stdlib-only, so the package's dependency set is unchanged.
1 parent 657faef commit f0cd149

2 files changed

Lines changed: 163 additions & 2 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from unittest.mock import MagicMock
2+
3+
import pytest
4+
5+
from unstructured_platform_plugins.invocation_settings import (
6+
SettingsScopedCache,
7+
settings_cache_key,
8+
)
9+
10+
11+
class TestSettingsCacheKey:
12+
def test_key_is_insensitive_to_key_order(self):
13+
assert settings_cache_key({"a": 1, "b": 2}) == settings_cache_key({"b": 2, "a": 1})
14+
15+
def test_key_is_sensitive_to_values(self):
16+
assert settings_cache_key({"a": 1}) != settings_cache_key({"a": 2})
17+
18+
def test_secret_values_do_not_appear_in_the_key(self):
19+
secret = "sk-super-secret-credential"
20+
key = settings_cache_key({"api_key": secret})
21+
assert secret not in key
22+
23+
24+
class TestSettingsScopedCache:
25+
def test_second_lookup_with_same_settings_does_not_rebuild(self):
26+
cache = SettingsScopedCache()
27+
build = MagicMock(return_value="handler")
28+
29+
first = cache.get_or_build({"model": "a"}, build)
30+
second = cache.get_or_build({"model": "a"}, build)
31+
32+
assert first == second == "handler"
33+
build.assert_called_once()
34+
35+
def test_distinct_settings_build_distinct_values(self):
36+
cache = SettingsScopedCache()
37+
38+
first = cache.get_or_build({"model": "a"}, lambda: object())
39+
second = cache.get_or_build({"model": "b"}, lambda: object())
40+
41+
assert first is not second
42+
43+
def test_entry_expires_after_ttl(self):
44+
now = [0.0]
45+
cache = SettingsScopedCache(ttl_seconds=10, clock=lambda: now[0])
46+
build = MagicMock(return_value="handler")
47+
48+
cache.get_or_build({"model": "a"}, build)
49+
now[0] = 11.0
50+
cache.get_or_build({"model": "a"}, build)
51+
52+
assert build.call_count == 2
53+
54+
def test_entry_survives_within_ttl(self):
55+
now = [0.0]
56+
cache = SettingsScopedCache(ttl_seconds=10, clock=lambda: now[0])
57+
build = MagicMock(return_value="handler")
58+
59+
cache.get_or_build({"model": "a"}, build)
60+
now[0] = 9.0
61+
cache.get_or_build({"model": "a"}, build)
62+
63+
build.assert_called_once()
64+
65+
def test_size_bound_evicts_least_recently_used(self):
66+
cache = SettingsScopedCache(maxsize=2)
67+
builds = {name: MagicMock(return_value=name) for name in ("a", "b", "c")}
68+
69+
cache.get_or_build({"model": "a"}, builds["a"])
70+
cache.get_or_build({"model": "b"}, builds["b"])
71+
# Refresh "a" so "b" is the eviction candidate when "c" lands.
72+
cache.get_or_build({"model": "a"}, builds["a"])
73+
cache.get_or_build({"model": "c"}, builds["c"])
74+
75+
cache.get_or_build({"model": "a"}, builds["a"])
76+
cache.get_or_build({"model": "b"}, builds["b"])
77+
78+
builds["a"].assert_called_once()
79+
assert builds["b"].call_count == 2
80+
81+
def test_clear_forces_rebuild(self):
82+
cache = SettingsScopedCache()
83+
build = MagicMock(return_value="handler")
84+
85+
cache.get_or_build({"model": "a"}, build)
86+
cache.clear()
87+
cache.get_or_build({"model": "a"}, build)
88+
89+
assert build.call_count == 2
90+
91+
@pytest.mark.parametrize("kwargs", [{"ttl_seconds": 0}, {"ttl_seconds": -1}, {"maxsize": 0}])
92+
def test_degenerate_bounds_are_rejected(self, kwargs):
93+
with pytest.raises(ValueError):
94+
SettingsScopedCache(**kwargs)

unstructured_platform_plugins/invocation_settings.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@
1414
from __future__ import annotations
1515

1616
import asyncio
17+
import hashlib
1718
import json
1819
import logging
19-
from collections.abc import Iterator
20+
import threading
21+
import time
22+
from collections import OrderedDict
23+
from collections.abc import Callable, Iterator, Mapping
2024
from contextlib import contextmanager
2125
from contextvars import ContextVar
22-
from typing import Any, Optional
26+
from typing import Any, Optional, TypeVar
2327

2428
from fastapi import FastAPI
2529
from starlette.types import ASGIApp, Receive, Scope, Send
@@ -36,6 +40,8 @@
3640

3741
logger = logging.getLogger(__name__)
3842

43+
T = TypeVar("T")
44+
3945
_METADATA_PATH = "/metadata"
4046
_INVOKE_PATH = "/invoke"
4147

@@ -241,3 +247,64 @@ def install_invocation_envelope(app: FastAPI) -> None:
241247
return
242248
app.state.invocation_envelope_installed = True
243249
app.add_middleware(InvocationEnvelopeMiddleware)
250+
251+
252+
def settings_cache_key(invocation_settings: Mapping[str, Any]) -> str:
253+
"""Digest of the canonical settings JSON, safe as a cache key for secret-bearing payloads."""
254+
return hashlib.sha256(json.dumps(invocation_settings, sort_keys=True).encode()).hexdigest()
255+
256+
257+
class SettingsScopedCache:
258+
"""Bind expensive derived state (clients, models, handlers) to the settings that built it.
259+
260+
A plugin consuming ``current_invocation_settings()`` builds its handler per distinct settings
261+
payload instead of once at boot, and construction typically does network work (model
262+
resolution, prechecks), so results are memoized keyed by ``settings_cache_key``. Both bounds
263+
matter under shared tenancy: size caps how many distinct payloads stay live, and age evicts
264+
state built from credentials that may since have been rotated — eviction driven only by the
265+
count of distinct payloads can take arbitrarily long on a quiet pod.
266+
267+
Thread-safe for lookups and inserts. Concurrent misses for the same settings may build twice;
268+
the extra build is wasted work, never wrong state.
269+
"""
270+
271+
def __init__(
272+
self,
273+
*,
274+
ttl_seconds: float = 15 * 60,
275+
maxsize: int = 32,
276+
clock: Callable[[], float] = time.monotonic,
277+
) -> None:
278+
if ttl_seconds <= 0:
279+
raise ValueError("ttl_seconds must be positive")
280+
if maxsize < 1:
281+
raise ValueError("maxsize must be at least 1")
282+
self._ttl_seconds = float(ttl_seconds)
283+
self._maxsize = maxsize
284+
self._clock = clock
285+
self._lock = threading.Lock()
286+
self._entries: OrderedDict[str, tuple[float, Any]] = OrderedDict()
287+
288+
def get_or_build(self, invocation_settings: Mapping[str, Any], build: Callable[[], T]) -> T:
289+
"""Return the cached value for these settings, building it on a miss."""
290+
key = settings_cache_key(invocation_settings)
291+
now = self._clock()
292+
with self._lock:
293+
entry = self._entries.get(key)
294+
if entry is not None:
295+
expires_at, value = entry
296+
if now < expires_at:
297+
self._entries.move_to_end(key)
298+
return value
299+
del self._entries[key]
300+
value = build()
301+
with self._lock:
302+
self._entries[key] = (now + self._ttl_seconds, value)
303+
self._entries.move_to_end(key)
304+
while len(self._entries) > self._maxsize:
305+
self._entries.popitem(last=False)
306+
return value
307+
308+
def clear(self) -> None:
309+
with self._lock:
310+
self._entries.clear()

0 commit comments

Comments
 (0)