mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
feat(api,clients): admission control with a bounded wait, and client retry (#4253)
* feat(api,clients): admission control with a bounded wait, and client retry The engine already caps concurrent work (`recall_max_concurrent` and friends), but `async with semaphore` is backpressure, not admission control: it bounds how much runs and lets an unbounded queue form behind it. Measured on a 2-vCPU container, 1024 concurrent recalls against a 32-permit semaphore produced a 12.8s p50 -- the latency did not go away, it moved into the semaphore queue, and the server spent CPU on responses whose callers had long since gone. Server: per-operation lanes with a deadline ------------------------------------------- `api/admission.py` adds lanes with two numbers: how much runs concurrently, and how long a request may queue before it is refused with 503 + `Retry-After`. Enforced by an `admit_for` dependency on the same routes that carry `precheck_for`, so a refusal happens before the body is deserialised -- the cheapest point to say no, and where an extension already rejects on quota. Lanes are per operation because per-request cost spans three orders of magnitude (measured: ~0.1ms /health/live, ~0.5ms bank stats, ~23ms recall); one global cap calibrated for recall would throttle health checks 100x too hard. Only recall, reflect and retain get lanes -- the other PrecheckOperations are low-volume administrative routes, and gating them would add knobs nobody tunes. Limits are PER WORKER and derived from the CPU budget this process actually has, via the existing cgroup-aware detector (`os.cpu_count()` reports the host's cores under `--cpus`, which would size limits for a machine the process cannot use). `in_flight` is a latency target, not a capacity limit: a c=1024 sweep measured throughput flat at 40-45 rps whether the limit was 8, 16 or 24 per worker, while p50 moved 1.4s -> 2.3s. It is bounded on both sides -- too low throttles I/O-bound work (8 permits against a 500ms provider caps a worker at 16 rps), too high rebuilds the queue this exists to prevent. A queued request whose client disconnects releases its place immediately, using the token `ClientDisconnectCancellationMiddleware` already puts on the scope. That is what makes a patient 30s deadline affordable: the queue self-cleans, so waiting costs nothing when nobody is listening. Verified end to end -- a client that gave up at 1s freed its slot at 1.003s, not at the deadline. Clients: retry the idempotent calls ----------------------------------- Both maintained wrappers retry recall and reflect on 429/503. Writes are not retried: the Python wrapper documents that `operation_id` is ignored for synchronous retain, so a retry there could duplicate. Two properties matter more than the retry. `Retry-After` is honoured, because the server sends it knowing its own queue depth. And the wait is jittered -- a burst that all receive `Retry-After: 1` and obey it exactly returns in lockstep and rebuilds the spike. The generated Python client ships `ExponentialRetry`, which has neither, and was off by default; this is why it stays off. * fix(admission): decrement queued once on abandon, drop no-op lanes, regen docs skill - An abandoned waiter decremented stats.queued in its except branch and again in finally, driving the gauge negative. - admit_for on dry-run/mental-model/files routes was a no-op (no lane exists). - Stale config comments on the kill switch and reflect sizing. - HTTP-level test for 503 + Retry-After; regenerated docs skill. * chore(embed): re-sync bundled env.example with repo root
This commit is contained in:
@@ -464,6 +464,25 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
# Explicit refreshes always run immediately.
|
||||
# HINDSIGHT_API_MENTAL_MODEL_MIN_REFRESH_INTERVAL_SECONDS=0
|
||||
|
||||
# Admission control. The *_MAX_CONCURRENT caps above bound how much runs at once and
|
||||
# let an unbounded queue form behind them; these bound how long a request may WAIT
|
||||
# before it is refused with 503 + Retry-After. A queued request whose client
|
||||
# disconnects releases its place immediately, so a patient deadline costs nothing
|
||||
# when nobody is still listening.
|
||||
# Limits are PER WORKER PROCESS and, when left at 0, derived from the CPU budget this
|
||||
# process actually has (cgroup quota, not the host's core count) divided by
|
||||
# HINDSIGHT_API_WORKERS. A positive value overrides the derivation; a NEGATIVE value
|
||||
# disables that lane entirely (0 cannot mean "off", because it means "derive").
|
||||
# in_flight is a latency target, not a capacity limit: throughput is cores divided by
|
||||
# CPU-per-request either way. Too low throttles I/O-bound work, too high rebuilds the
|
||||
# queue this exists to prevent.
|
||||
# HINDSIGHT_API_ADMISSION_RECALL_MAX_IN_FLIGHT=0
|
||||
# HINDSIGHT_API_ADMISSION_RECALL_MAX_WAIT_MS=30000
|
||||
# HINDSIGHT_API_ADMISSION_REFLECT_MAX_IN_FLIGHT=0
|
||||
# HINDSIGHT_API_ADMISSION_REFLECT_MAX_WAIT_MS=5000
|
||||
# HINDSIGHT_API_ADMISSION_RETAIN_MAX_IN_FLIGHT=0
|
||||
# HINDSIGHT_API_ADMISSION_RETAIN_MAX_WAIT_MS=2000
|
||||
|
||||
# Recall pipeline stages (all on by default). Each is hierarchical, so a single
|
||||
# bank can switch a stage off via the config API without changing the server
|
||||
# default. Turning all four off reduces recall to a single vector query, the
|
||||
|
||||
@@ -95,6 +95,17 @@ def _available_cpu_count() -> int:
|
||||
return max(1, min(candidates))
|
||||
|
||||
|
||||
def available_cpu_count() -> int:
|
||||
"""Public alias for :func:`_available_cpu_count`.
|
||||
|
||||
Admission-control defaults size themselves from the real CPU budget, and that
|
||||
budget must come from here rather than ``os.cpu_count()``: under ``--cpus`` the
|
||||
latter reports the host's cores, which would size limits for a machine the
|
||||
process cannot use.
|
||||
"""
|
||||
return _available_cpu_count()
|
||||
|
||||
|
||||
def default_native_thread_count() -> int:
|
||||
"""Per-pool cap: ``_MAX_NATIVE_THREADS``, or available CPUs if fewer."""
|
||||
return min(_MAX_NATIVE_THREADS, _available_cpu_count())
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Admission control: bound the wait, not just the concurrency.
|
||||
|
||||
The engine already limits concurrent work (``recall_max_concurrent`` and friends),
|
||||
but a bare ``async with semaphore`` is *backpressure*, not admission control: it
|
||||
caps how much runs at once and lets an unbounded queue form behind it. Measured on
|
||||
a 2-vCPU container, 1024 concurrent recalls against a 32-permit semaphore produced
|
||||
a 12.8s p50 — the latency did not go away, it moved from the event loop into the
|
||||
semaphore queue, and the server spent CPU on responses whose clients had long
|
||||
since given up.
|
||||
|
||||
What is missing is a bound on *waiting*. A lane here has two numbers:
|
||||
|
||||
* ``max_in_flight`` — how much of this operation runs concurrently;
|
||||
* ``max_wait_seconds`` — how long a request may queue before it is refused.
|
||||
|
||||
A request that cannot be admitted within its deadline gets 503 with ``Retry-After``
|
||||
immediately, which is a far better answer than a response that arrives after the
|
||||
caller timed out. Capacity is unchanged; what changes is that the capacity stops
|
||||
being spent on work nobody is waiting for.
|
||||
|
||||
**Where this runs.** As a FastAPI dependency on the same routes that carry
|
||||
``precheck_for``, so a rejection happens *before the request body is deserialised*
|
||||
— the cheapest possible point to say no, and the same place an extension already
|
||||
rejects on quota.
|
||||
|
||||
**Why a lane per operation rather than one global limit.** Per-request cost spans
|
||||
three orders of magnitude on this API (measured: ~0.1ms for ``/health/live``,
|
||||
~0.5ms for bank stats, ~23ms for a recall). A single global request cap calibrated
|
||||
for recall would throttle health checks that the server can serve 100x faster; one
|
||||
calibrated for health would never engage for recall. The operations worth limiting
|
||||
are exactly the ones :class:`PrecheckOperation` already enumerates.
|
||||
|
||||
**The limits are PER WORKER PROCESS.** Each ``--workers N`` process imports the app
|
||||
and builds its own controller, so the process-wide budget is ``N x max_in_flight``.
|
||||
Size a lane for one worker, not for the cluster.
|
||||
|
||||
A plain ``asyncio.Semaphore`` is correct here because one process runs one event
|
||||
loop. (It would not be under ``--event-loops > 1`` on a free-threaded build, where
|
||||
each loop would get its own semaphore and admit N times the limit — if multi-loop
|
||||
serving ever comes back, this needs ``CrossLoopSemaphore`` instead.) The semaphore
|
||||
is constructed before any loop is running, which is fine on 3.10+: it binds lazily
|
||||
on first await, not at construction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..cancellation import CancellationToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaneConfig:
|
||||
"""Limits for one operation class."""
|
||||
|
||||
#: Concurrent requests of this operation. 0 disables the lane entirely (the
|
||||
#: resolved value -- see `admission_in_flight_for`, where a *negative* env var
|
||||
#: is what resolves to 0, because 0 there means "derive from the CPU budget").
|
||||
max_in_flight: int
|
||||
#: How long a request may queue for a permit before being refused. 0 means
|
||||
#: "never queue": either a permit is free now or the request is rejected.
|
||||
max_wait_seconds: float
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.max_in_flight > 0
|
||||
|
||||
|
||||
class AdmissionRejected(Exception):
|
||||
"""Raised when a request could not be admitted within its deadline."""
|
||||
|
||||
def __init__(self, lane: str, waited_seconds: float, limit: int) -> None:
|
||||
self.lane = lane
|
||||
self.waited_seconds = waited_seconds
|
||||
self.limit = limit
|
||||
super().__init__(
|
||||
f"admission refused for {lane!r} after waiting {waited_seconds * 1000:.0f}ms (limit {limit} concurrent)"
|
||||
)
|
||||
|
||||
@property
|
||||
def retry_after_seconds(self) -> int:
|
||||
"""Conservative hint for the client, in whole seconds (``Retry-After``)."""
|
||||
return max(1, round(self.waited_seconds)) if self.waited_seconds else 1
|
||||
|
||||
|
||||
class AdmissionAbandoned(Exception):
|
||||
"""The client disconnected while its request was queued for a permit.
|
||||
|
||||
Distinct from :class:`AdmissionRejected`: there is nobody left to send a 503 to,
|
||||
so the only useful thing to do is stop working on the request.
|
||||
"""
|
||||
|
||||
def __init__(self, lane: str) -> None:
|
||||
self.lane = lane
|
||||
super().__init__(f"client disconnected while queued for {lane!r}")
|
||||
|
||||
|
||||
class _ClientGone(Exception):
|
||||
"""Internal signal from the acquire race."""
|
||||
|
||||
|
||||
async def _acquire_unless_abandoned(
|
||||
semaphore: asyncio.Semaphore, timeout: float, abandoned: CancellationToken | None
|
||||
) -> None:
|
||||
"""Acquire within ``timeout``, giving up early if the client disconnects.
|
||||
|
||||
Raises :class:`_ClientGone` when the client vanished first, ``TimeoutError`` when
|
||||
the deadline passed.
|
||||
"""
|
||||
if abandoned is None:
|
||||
await asyncio.wait_for(semaphore.acquire(), timeout=timeout)
|
||||
return
|
||||
|
||||
# Already gone before it even queued: there is no work worth starting, and
|
||||
# checking here also settles the race below, where a free permit and a cancelled
|
||||
# token would otherwise both be "done" and the permit would win.
|
||||
if abandoned.cancelled:
|
||||
raise _ClientGone()
|
||||
|
||||
acquire = asyncio.ensure_future(semaphore.acquire())
|
||||
gone = asyncio.ensure_future(abandoned.wait())
|
||||
try:
|
||||
done, _ = await asyncio.wait({acquire, gone}, timeout=timeout, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
gone.cancel()
|
||||
|
||||
if acquire in done:
|
||||
acquire.result()
|
||||
return
|
||||
|
||||
# Not getting a permit. Cancelling a pending acquire is safe -- asyncio's
|
||||
# Semaphore wakes the next waiter when a granted-then-cancelled acquire unwinds
|
||||
# -- but a permit can land in the instant we give up, so if the task completed
|
||||
# anyway hand it straight back rather than leak it for the life of the process.
|
||||
acquire.cancel()
|
||||
try:
|
||||
await acquire
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
else:
|
||||
semaphore.release()
|
||||
|
||||
if gone in done:
|
||||
raise _ClientGone()
|
||||
raise TimeoutError()
|
||||
|
||||
|
||||
@dataclass
|
||||
class LaneStats:
|
||||
"""Observable counters for one lane. Cheap to read; useful in an incident."""
|
||||
|
||||
admitted: int = 0
|
||||
rejected: int = 0
|
||||
#: Clients that disconnected while queued -- neither served nor refused.
|
||||
abandoned: int = 0
|
||||
in_flight: int = 0
|
||||
queued: int = 0
|
||||
total_wait_seconds: float = 0.0
|
||||
|
||||
@property
|
||||
def mean_wait_ms(self) -> float:
|
||||
total = self.admitted + self.rejected
|
||||
return (self.total_wait_seconds / total * 1000) if total else 0.0
|
||||
|
||||
|
||||
class AdmissionController:
|
||||
"""Per-operation concurrency limits with a bounded queue wait."""
|
||||
|
||||
def __init__(self, lanes: dict[str, LaneConfig]) -> None:
|
||||
self._configs = lanes
|
||||
self._semaphores: dict[str, asyncio.Semaphore] = {
|
||||
name: asyncio.Semaphore(cfg.max_in_flight) for name, cfg in lanes.items() if cfg.enabled
|
||||
}
|
||||
self._stats: dict[str, LaneStats] = {name: LaneStats() for name in lanes}
|
||||
|
||||
def lane_config(self, operation: str) -> LaneConfig | None:
|
||||
return self._configs.get(operation)
|
||||
|
||||
def stats(self) -> dict[str, LaneStats]:
|
||||
return self._stats
|
||||
|
||||
@asynccontextmanager
|
||||
async def admit(self, operation: str, abandoned: CancellationToken | None = None) -> AsyncIterator[None]:
|
||||
"""Hold a permit for ``operation`` for the duration of the block.
|
||||
|
||||
Falls through with no gating when the operation has no lane or the lane is
|
||||
disabled, so an unconfigured operation behaves exactly as it does today.
|
||||
|
||||
``abandoned`` is the request's client-disconnect token when one is available
|
||||
(recall and reflect carry one — see :mod:`hindsight_api.api.disconnect`). A
|
||||
queued request whose client has gone away gives up its place immediately
|
||||
instead of holding it for the full deadline. That is what makes a *longer*
|
||||
wait safe: the queue self-cleans, so patience costs nothing when nobody is
|
||||
still listening.
|
||||
"""
|
||||
semaphore = self._semaphores.get(operation)
|
||||
if semaphore is None:
|
||||
yield
|
||||
return
|
||||
|
||||
config = self._configs[operation]
|
||||
stats = self._stats[operation]
|
||||
started = time.monotonic()
|
||||
stats.queued += 1
|
||||
# "Never queue" mode still goes through acquire() to keep the permit
|
||||
# accounting honest; the deadline is just short enough to succeed only when a
|
||||
# permit is already free.
|
||||
timeout = 0.001 if config.max_wait_seconds <= 0 else config.max_wait_seconds
|
||||
try:
|
||||
await _acquire_unless_abandoned(semaphore, timeout, abandoned)
|
||||
except _ClientGone:
|
||||
waited = time.monotonic() - started
|
||||
# `queued` is decremented once, in the `finally` below.
|
||||
stats.abandoned += 1
|
||||
# Worth logging on its own: callers giving up while queued is the signal
|
||||
# that the deadline is longer than they are willing to wait.
|
||||
logger.info(
|
||||
"admission abandoned: lane=%s waited=%.3fs limit=%d queued=%d",
|
||||
operation,
|
||||
waited,
|
||||
config.max_in_flight,
|
||||
stats.queued,
|
||||
)
|
||||
raise AdmissionAbandoned(operation) from None
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
waited = time.monotonic() - started
|
||||
stats.rejected += 1
|
||||
stats.total_wait_seconds += waited
|
||||
logger.warning(
|
||||
"admission refused: lane=%s waited=%.3fs limit=%d in_flight=%d queued=%d",
|
||||
operation,
|
||||
waited,
|
||||
config.max_in_flight,
|
||||
stats.in_flight,
|
||||
stats.queued,
|
||||
)
|
||||
raise AdmissionRejected(operation, waited, config.max_in_flight) from None
|
||||
finally:
|
||||
stats.queued -= 1
|
||||
|
||||
waited = time.monotonic() - started
|
||||
stats.admitted += 1
|
||||
stats.total_wait_seconds += waited
|
||||
stats.in_flight += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stats.in_flight -= 1
|
||||
semaphore.release()
|
||||
|
||||
|
||||
def build_controller_from_config(config) -> AdmissionController:
|
||||
"""Build the controller from ``HindsightConfig``.
|
||||
|
||||
Only the three high-volume operations get a lane. `files_retain`,
|
||||
`dry_run_extract` and the mental-model routes are administrative and low-volume:
|
||||
gating them would add knobs nobody tunes without protecting anything that
|
||||
actually saturates a worker. They fall through ungated, exactly as before.
|
||||
|
||||
A lane resolving to 0 in-flight is off (set its env var negative).
|
||||
"""
|
||||
return AdmissionController(
|
||||
{
|
||||
"recall": LaneConfig(config.admission_recall_max_in_flight, config.admission_recall_max_wait_seconds),
|
||||
"reflect": LaneConfig(config.admission_reflect_max_in_flight, config.admission_reflect_max_wait_seconds),
|
||||
"retain": LaneConfig(config.admission_retain_max_in_flight, config.admission_retain_max_wait_seconds),
|
||||
}
|
||||
)
|
||||
@@ -26,6 +26,7 @@ from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from hindsight_api.api import page_markdown
|
||||
from hindsight_api.api.admission import AdmissionAbandoned, AdmissionRejected, build_controller_from_config
|
||||
from hindsight_api.api.disconnect import ClientDisconnectCancellationMiddleware, get_scope_cancellation_token
|
||||
from hindsight_api.api.observability import HttpObservabilityMiddleware
|
||||
from hindsight_api.api.passthrough_headers import collect_passthrough_headers
|
||||
@@ -4925,6 +4926,10 @@ def create_app(
|
||||
# re-discover) and the metrics in a pure-ASGI middleware installed below.
|
||||
app.router.route_class = UnknownParamsRoute
|
||||
|
||||
# Per-operation admission control, consulted by the `admit_for` dependency on
|
||||
# the heavy routes. One controller per app so the limits are a process budget.
|
||||
app.state.admission = build_controller_from_config(config)
|
||||
|
||||
# Register all routes
|
||||
_register_routes(app)
|
||||
|
||||
@@ -5082,6 +5087,44 @@ def _register_routes(app: FastAPI):
|
||||
extra_headers = collect_passthrough_headers(request.headers.raw, get_config().extension_passthrough_headers)
|
||||
return RequestContext(api_key=api_key, extra_headers=extra_headers)
|
||||
|
||||
def admit_for(operation: PrecheckOperation):
|
||||
"""Build a FastAPI dependency that holds an admission permit for the request.
|
||||
|
||||
Yield-style so the permit is held for the whole request and released once the
|
||||
response has been produced. Declared alongside ``precheck_for`` on the heavy
|
||||
routes: FastAPI resolves dependencies before deserialising the body, so an
|
||||
overloaded server refuses without ever reading the payload.
|
||||
|
||||
Returns 503 with ``Retry-After`` rather than queueing indefinitely — see
|
||||
:mod:`hindsight_api.api.admission` for why the wait, not the concurrency, is
|
||||
the thing worth bounding.
|
||||
"""
|
||||
|
||||
async def _admit_dep(request: Request):
|
||||
controller = getattr(app.state, "admission", None)
|
||||
if controller is None:
|
||||
yield
|
||||
return
|
||||
# Recall and reflect carry a disconnect token (see api/disconnect.py). A
|
||||
# queued request whose client has gone gives up its place immediately,
|
||||
# which is what makes a patient deadline affordable.
|
||||
abandoned = get_scope_cancellation_token(request.scope)
|
||||
try:
|
||||
async with controller.admit(str(operation), abandoned=abandoned):
|
||||
yield
|
||||
except AdmissionAbandoned:
|
||||
# Nobody left to answer. Close the request without spending a slot
|
||||
# or building a response.
|
||||
raise HTTPException(status_code=499, detail="client disconnected while queued") from None
|
||||
except AdmissionRejected as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(f"Server is at capacity for '{e.lane}' ({e.limit} concurrent); try again shortly."),
|
||||
headers={"Retry-After": str(e.retry_after_seconds)},
|
||||
) from None
|
||||
|
||||
return _admit_dep
|
||||
|
||||
def precheck_for(operation: PrecheckOperation):
|
||||
"""
|
||||
Build a FastAPI dependency that runs ``OperationValidator.precheck``.
|
||||
@@ -5672,6 +5715,7 @@ def _register_routes(app: FastAPI):
|
||||
http_request: Request,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
_precheck: None = Depends(precheck_for(PrecheckOperation.RECALL)),
|
||||
_admit: None = Depends(admit_for(PrecheckOperation.RECALL)),
|
||||
):
|
||||
"""Run a recall and return results with trace."""
|
||||
import time
|
||||
@@ -5924,6 +5968,7 @@ def _register_routes(app: FastAPI):
|
||||
http_request: Request,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
_precheck: None = Depends(precheck_for(PrecheckOperation.REFLECT)),
|
||||
_admit: None = Depends(admit_for(PrecheckOperation.REFLECT)),
|
||||
):
|
||||
metrics = get_metrics_collector()
|
||||
|
||||
@@ -9020,6 +9065,7 @@ def _register_routes(app: FastAPI):
|
||||
request: RetainRequest,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
_precheck: None = Depends(precheck_for(PrecheckOperation.RETAIN)),
|
||||
_admit: None = Depends(admit_for(PrecheckOperation.RETAIN)),
|
||||
):
|
||||
"""Retain memories with optional async processing."""
|
||||
metrics = get_metrics_collector()
|
||||
|
||||
@@ -657,6 +657,17 @@ ENV_ENABLE_DRY_RUN_EXTRACT = "HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT"
|
||||
ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"
|
||||
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
|
||||
|
||||
# Admission control. The engine's *_MAX_CONCURRENT caps limit how much runs at once
|
||||
# and let an unbounded queue form behind them; these bound how long a request may
|
||||
# WAIT before being refused with 503. A lane's MAX_IN_FLIGHT of 0 derives the limit
|
||||
# from the CPU budget; a negative value disables the lane.
|
||||
ENV_ADMISSION_RECALL_MAX_IN_FLIGHT = "HINDSIGHT_API_ADMISSION_RECALL_MAX_IN_FLIGHT"
|
||||
ENV_ADMISSION_RECALL_MAX_WAIT_MS = "HINDSIGHT_API_ADMISSION_RECALL_MAX_WAIT_MS"
|
||||
ENV_ADMISSION_REFLECT_MAX_IN_FLIGHT = "HINDSIGHT_API_ADMISSION_REFLECT_MAX_IN_FLIGHT"
|
||||
ENV_ADMISSION_REFLECT_MAX_WAIT_MS = "HINDSIGHT_API_ADMISSION_REFLECT_MAX_WAIT_MS"
|
||||
ENV_ADMISSION_RETAIN_MAX_IN_FLIGHT = "HINDSIGHT_API_ADMISSION_RETAIN_MAX_IN_FLIGHT"
|
||||
ENV_ADMISSION_RETAIN_MAX_WAIT_MS = "HINDSIGHT_API_ADMISSION_RETAIN_MAX_WAIT_MS"
|
||||
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
|
||||
ENV_RECALL_MAX_QUERY_TOKENS = "HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS"
|
||||
ENV_RECALL_DIAGNOSTIC_PHASES = "HINDSIGHT_API_RECALL_DIAGNOSTIC_PHASES"
|
||||
@@ -1468,6 +1479,59 @@ DEFAULT_ENABLE_BANK_LLM_HEALTH = False
|
||||
DEFAULT_DEFAULT_BANK_TEMPLATE: dict | None = None # BankTemplateManifest dict applied to newly-created banks
|
||||
DEFAULT_GRAPH_RETRIEVER = "link_expansion"
|
||||
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
|
||||
|
||||
# Admission-control defaults. These are PER WORKER PROCESS: with `--workers N` the
|
||||
# process budget is N x the value here.
|
||||
#
|
||||
# Sized for the reference shape of 2 vCPU / 2 workers.
|
||||
#
|
||||
# `in_flight` does NOT set capacity -- capacity is cores / cpu-per-request, and a
|
||||
# c=1024 sweep measured throughput flat at 40-45 rps whether the limit was 8, 16 or
|
||||
# 24 per worker. What it sets is queue depth, and hence the latency of an ADMITTED
|
||||
# request: client latency ~= MAX_WAIT_MS + in_flight_total / throughput. Measured at
|
||||
# c=1024 (2 workers, untuned): 8/worker -> 1.4s p50, 16 -> 1.8s, 24 -> 2.3s,
|
||||
# 32 -> 3.3s.
|
||||
#
|
||||
# It is bounded on BOTH sides, which is why the smallest value is not the best one:
|
||||
#
|
||||
# floor in_flight >= target_rps * service_time / workers
|
||||
# Too low throttles I/O-bound work. A recall that waits 500ms on a slow
|
||||
# embedding provider can only run in_flight/0.5s per second, so 8 permits
|
||||
# would cap a worker at 16 rps -- well under what its CPU could serve.
|
||||
# ceiling in_flight <= target_latency * throughput / workers
|
||||
# Too high just rebuilds the unbounded queue this exists to prevent.
|
||||
#
|
||||
# 16 sits between them for the reference shape: ~1.8s p50 under extreme overload
|
||||
# (vs 12.8s with no admission control) while leaving headroom for providers slower
|
||||
# than the benchmark's. Raise it if provider latency is high, lower it if latency
|
||||
# matters more than peak throughput.
|
||||
#
|
||||
# It is expressed PER CORE so a bigger machine is not throttled to a small box's
|
||||
# queue depth: the reference shape is 2 vCPU / 2 workers, i.e. one core per worker,
|
||||
# where this yields the measured 16. `admission_in_flight_for` applies it to the
|
||||
# CPU budget this process actually has (cgroup quota, not os.cpu_count()).
|
||||
DEFAULT_ADMISSION_RECALL_IN_FLIGHT_PER_CORE = 16
|
||||
DEFAULT_ADMISSION_RECALL_MAX_IN_FLIGHT = 0 # 0 = derive from cores; set to override
|
||||
# 30s, not 1s. The SDKs are patient (the Python client defaults to a 300s request
|
||||
# timeout) so a long queue is observable rather than wasted, and a queued request
|
||||
# whose client disconnects releases its place immediately -- so patience costs
|
||||
# nothing when nobody is still listening. This absorbs a burst instead of refusing
|
||||
# it. Under *sustained* overload it is still bufferbloat: the queue fills to the
|
||||
# deadline and the back of it is refused anyway, just later. Lower it if the traffic
|
||||
# is persistently over capacity rather than spiky.
|
||||
DEFAULT_ADMISSION_RECALL_MAX_WAIT_MS = 30000
|
||||
# Reflect is LLM-bound: seconds of wall time, little CPU, and already capped
|
||||
# downstream by the per-operation LLM semaphore, so the admission lane only needs to
|
||||
# stop an unbounded queue forming in front of it. A shorter wait than recall: a
|
||||
# reflect already takes seconds, so queueing another 30s on top rarely helps anyone.
|
||||
DEFAULT_ADMISSION_REFLECT_IN_FLIGHT_PER_CORE = 16
|
||||
DEFAULT_ADMISSION_REFLECT_MAX_IN_FLIGHT = 0
|
||||
DEFAULT_ADMISSION_REFLECT_MAX_WAIT_MS = 5000
|
||||
# Synchronous retain runs extraction inline; async retain returns as soon as the
|
||||
# operation is queued, so this only bites on the synchronous path.
|
||||
DEFAULT_ADMISSION_RETAIN_IN_FLIGHT_PER_CORE = 32
|
||||
DEFAULT_ADMISSION_RETAIN_MAX_IN_FLIGHT = 0
|
||||
DEFAULT_ADMISSION_RETAIN_MAX_WAIT_MS = 2000
|
||||
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
|
||||
DEFAULT_RECALL_MAX_QUERY_TOKENS = 500 # Maximum tokens allowed in recall query
|
||||
DEFAULT_RECALL_DIAGNOSTIC_PHASES = True # Record the subset (diagnostic=true) recall phase metrics
|
||||
@@ -2680,6 +2744,38 @@ def _parse_default_bank_template(raw: str | None) -> dict | None:
|
||||
return parsed
|
||||
|
||||
|
||||
def admission_in_flight_for(explicit: int, per_core: int, workers: int) -> int:
|
||||
"""Resolve an admission lane's in-flight limit.
|
||||
|
||||
Three cases, because "derive" and "off" both need to be expressible:
|
||||
|
||||
* ``explicit > 0`` -- use it verbatim;
|
||||
* ``explicit == 0`` -- derive from the CPU budget (the default);
|
||||
* ``explicit < 0`` -- disable the lane, so the operation is never gated.
|
||||
|
||||
Otherwise the limit is
|
||||
derived from the CPU budget this process actually has, divided by the number of
|
||||
worker processes sharing it -- the limit is PER WORKER, so a 4-core box running
|
||||
4 workers gets the same per-worker depth as a 1-core box running 1.
|
||||
|
||||
Deriving beats a fixed number because the right depth scales with the machine:
|
||||
queue depth trades latency against the risk of throttling I/O-bound work, and
|
||||
both sides of that trade move with core count. It is deliberately floored at
|
||||
``per_core`` so a fractional-core deployment still admits enough concurrent work
|
||||
to keep its CPU busy while requests wait on embeddings or an LLM.
|
||||
"""
|
||||
if explicit > 0:
|
||||
return explicit
|
||||
if explicit < 0:
|
||||
# Negative is the kill switch. It cannot be 0, because 0 is what "derive"
|
||||
# has to mean for an unset env var.
|
||||
return 0
|
||||
from ._thread_limits import available_cpu_count
|
||||
|
||||
cores_per_worker = available_cpu_count() / max(1, workers)
|
||||
return max(per_core, round(per_core * cores_per_worker))
|
||||
|
||||
|
||||
@dataclass
|
||||
class HindsightConfig:
|
||||
"""Configuration container for Hindsight API."""
|
||||
@@ -3044,6 +3140,12 @@ class HindsightConfig:
|
||||
# Recall
|
||||
graph_retriever: str
|
||||
recall_max_concurrent: int
|
||||
admission_recall_max_in_flight: int
|
||||
admission_recall_max_wait_seconds: float
|
||||
admission_reflect_max_in_flight: int
|
||||
admission_reflect_max_wait_seconds: float
|
||||
admission_retain_max_in_flight: int
|
||||
admission_retain_max_wait_seconds: float
|
||||
recall_connection_budget: int
|
||||
recall_max_query_tokens: int
|
||||
recall_diagnostic_phases: bool
|
||||
@@ -4462,6 +4564,33 @@ class HindsightConfig:
|
||||
# Recall
|
||||
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
|
||||
recall_max_concurrent=int(os.getenv(ENV_RECALL_MAX_CONCURRENT, str(DEFAULT_RECALL_MAX_CONCURRENT))),
|
||||
admission_recall_max_in_flight=admission_in_flight_for(
|
||||
int(os.getenv(ENV_ADMISSION_RECALL_MAX_IN_FLIGHT, str(DEFAULT_ADMISSION_RECALL_MAX_IN_FLIGHT))),
|
||||
DEFAULT_ADMISSION_RECALL_IN_FLIGHT_PER_CORE,
|
||||
int(os.getenv(ENV_WORKERS, "1")),
|
||||
),
|
||||
admission_recall_max_wait_seconds=float(
|
||||
os.getenv(ENV_ADMISSION_RECALL_MAX_WAIT_MS, str(DEFAULT_ADMISSION_RECALL_MAX_WAIT_MS))
|
||||
)
|
||||
/ 1000.0,
|
||||
admission_reflect_max_in_flight=admission_in_flight_for(
|
||||
int(os.getenv(ENV_ADMISSION_REFLECT_MAX_IN_FLIGHT, str(DEFAULT_ADMISSION_REFLECT_MAX_IN_FLIGHT))),
|
||||
DEFAULT_ADMISSION_REFLECT_IN_FLIGHT_PER_CORE,
|
||||
int(os.getenv(ENV_WORKERS, "1")),
|
||||
),
|
||||
admission_reflect_max_wait_seconds=float(
|
||||
os.getenv(ENV_ADMISSION_REFLECT_MAX_WAIT_MS, str(DEFAULT_ADMISSION_REFLECT_MAX_WAIT_MS))
|
||||
)
|
||||
/ 1000.0,
|
||||
admission_retain_max_in_flight=admission_in_flight_for(
|
||||
int(os.getenv(ENV_ADMISSION_RETAIN_MAX_IN_FLIGHT, str(DEFAULT_ADMISSION_RETAIN_MAX_IN_FLIGHT))),
|
||||
DEFAULT_ADMISSION_RETAIN_IN_FLIGHT_PER_CORE,
|
||||
int(os.getenv(ENV_WORKERS, "1")),
|
||||
),
|
||||
admission_retain_max_wait_seconds=float(
|
||||
os.getenv(ENV_ADMISSION_RETAIN_MAX_WAIT_MS, str(DEFAULT_ADMISSION_RETAIN_MAX_WAIT_MS))
|
||||
)
|
||||
/ 1000.0,
|
||||
recall_connection_budget=int(
|
||||
os.getenv(ENV_RECALL_CONNECTION_BUDGET, str(DEFAULT_RECALL_CONNECTION_BUDGET))
|
||||
),
|
||||
|
||||
@@ -407,6 +407,10 @@ def main():
|
||||
uvicorn_config["reload"] = True
|
||||
if args.workers > 1:
|
||||
uvicorn_config["workers"] = args.workers
|
||||
# Export the worker count so each child process can size its share of the CPU
|
||||
# budget (admission limits are per worker). uvicorn spawns children that
|
||||
# re-import the app, so the environment is the only channel that reaches them.
|
||||
os.environ[ENV_WORKERS] = str(args.workers)
|
||||
if args.forwarded_allow_ips:
|
||||
uvicorn_config["forwarded_allow_ips"] = args.forwarded_allow_ips
|
||||
if args.ssl_keyfile:
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Tests for per-operation admission control.
|
||||
|
||||
The property under test is the one the engine's existing semaphores do NOT have:
|
||||
a request that cannot get a permit within its deadline is *refused*, rather than
|
||||
queueing until the caller has given up.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.api.admission import (
|
||||
AdmissionController,
|
||||
AdmissionRejected,
|
||||
LaneConfig,
|
||||
)
|
||||
|
||||
|
||||
def _controller(**lanes: LaneConfig) -> AdmissionController:
|
||||
return AdmissionController(dict(lanes))
|
||||
|
||||
|
||||
async def test_admits_up_to_the_limit():
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=2, max_wait_seconds=1.0))
|
||||
async with controller.admit("recall"):
|
||||
async with controller.admit("recall"):
|
||||
assert controller.stats()["recall"].in_flight == 2
|
||||
assert controller.stats()["recall"].in_flight == 0
|
||||
assert controller.stats()["recall"].admitted == 2
|
||||
|
||||
|
||||
async def test_rejects_when_full_rather_than_queueing_forever():
|
||||
"""The whole point: a bounded wait, then 503 -- not an unbounded queue."""
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=1, max_wait_seconds=0.05))
|
||||
|
||||
async with controller.admit("recall"):
|
||||
started = asyncio.get_running_loop().time()
|
||||
with pytest.raises(AdmissionRejected) as excinfo:
|
||||
async with controller.admit("recall"):
|
||||
pytest.fail("should not have been admitted")
|
||||
waited = asyncio.get_running_loop().time() - started
|
||||
|
||||
# Refused at roughly the deadline, not after the holder finished.
|
||||
assert 0.04 <= waited < 1.0
|
||||
assert excinfo.value.lane == "recall"
|
||||
assert excinfo.value.limit == 1
|
||||
assert excinfo.value.retry_after_seconds >= 1
|
||||
assert controller.stats()["recall"].rejected == 1
|
||||
|
||||
|
||||
async def test_permit_is_released_and_a_waiter_then_proceeds():
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=1, max_wait_seconds=2.0))
|
||||
order: list[str] = []
|
||||
|
||||
async def holder():
|
||||
async with controller.admit("recall"):
|
||||
order.append("holder-in")
|
||||
await asyncio.sleep(0.05)
|
||||
order.append("holder-out")
|
||||
|
||||
async def waiter():
|
||||
await asyncio.sleep(0.01)
|
||||
async with controller.admit("recall"):
|
||||
order.append("waiter-in")
|
||||
|
||||
await asyncio.gather(holder(), waiter())
|
||||
assert order == ["holder-in", "holder-out", "waiter-in"]
|
||||
|
||||
|
||||
async def test_permit_released_when_the_body_raises():
|
||||
"""A failing request must not leak its permit -- otherwise the lane shrinks."""
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=1, max_wait_seconds=0.05))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
async with controller.admit("recall"):
|
||||
raise ValueError("boom")
|
||||
|
||||
assert controller.stats()["recall"].in_flight == 0
|
||||
# The permit came back, so the next request is admitted.
|
||||
async with controller.admit("recall"):
|
||||
pass
|
||||
assert controller.stats()["recall"].admitted == 2
|
||||
|
||||
|
||||
async def test_lanes_are_independent():
|
||||
"""A saturated lane must not refuse a different operation."""
|
||||
controller = _controller(
|
||||
recall=LaneConfig(max_in_flight=1, max_wait_seconds=0.05),
|
||||
reflect=LaneConfig(max_in_flight=1, max_wait_seconds=0.05),
|
||||
)
|
||||
async with controller.admit("recall"):
|
||||
# reflect has its own permit and is unaffected by recall being full.
|
||||
async with controller.admit("reflect"):
|
||||
pass
|
||||
with pytest.raises(AdmissionRejected):
|
||||
async with controller.admit("recall"):
|
||||
pass
|
||||
|
||||
|
||||
async def test_unknown_operation_falls_through_ungated():
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=1, max_wait_seconds=0.05))
|
||||
async with controller.admit("not_a_lane"):
|
||||
pass # no exception, no accounting
|
||||
|
||||
|
||||
async def test_lane_disabled_by_zero_in_flight():
|
||||
"""0 is the documented kill switch and must not gate anything."""
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=0, max_wait_seconds=1.0))
|
||||
async with controller.admit("recall"):
|
||||
async with controller.admit("recall"):
|
||||
pass
|
||||
assert controller.stats()["recall"].admitted == 0
|
||||
|
||||
|
||||
async def test_zero_wait_refuses_immediately_when_full():
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=1, max_wait_seconds=0.0))
|
||||
async with controller.admit("recall"):
|
||||
started = asyncio.get_running_loop().time()
|
||||
with pytest.raises(AdmissionRejected):
|
||||
async with controller.admit("recall"):
|
||||
pass
|
||||
assert asyncio.get_running_loop().time() - started < 0.05
|
||||
|
||||
|
||||
class TestDerivedDefaults:
|
||||
"""The in-flight default is derived from the CPU budget, not hardcoded."""
|
||||
|
||||
def test_explicit_value_always_wins(self):
|
||||
from hindsight_api.config import admission_in_flight_for
|
||||
|
||||
assert admission_in_flight_for(7, per_core=16, workers=4) == 7
|
||||
|
||||
def test_derives_from_cores_divided_by_workers(self, monkeypatch):
|
||||
from hindsight_api import config as config_mod
|
||||
|
||||
monkeypatch.setattr("hindsight_api._thread_limits.available_cpu_count", lambda: 8)
|
||||
# 8 cores / 2 workers = 4 cores each -> 4 x 16
|
||||
assert config_mod.admission_in_flight_for(0, per_core=16, workers=2) == 64
|
||||
|
||||
def test_floored_at_per_core_on_a_fractional_core_box(self, monkeypatch):
|
||||
"""A worker with less than a core still needs enough depth to stay busy
|
||||
while requests wait on embeddings or an LLM."""
|
||||
from hindsight_api import config as config_mod
|
||||
|
||||
monkeypatch.setattr("hindsight_api._thread_limits.available_cpu_count", lambda: 1)
|
||||
assert config_mod.admission_in_flight_for(0, per_core=16, workers=4) == 16
|
||||
|
||||
def test_reference_shape_matches_the_measured_default(self, monkeypatch):
|
||||
"""2 vCPU / 2 workers is the shape the 16 was calibrated on."""
|
||||
from hindsight_api import config as config_mod
|
||||
|
||||
monkeypatch.setattr("hindsight_api._thread_limits.available_cpu_count", lambda: 2)
|
||||
assert config_mod.admission_in_flight_for(0, per_core=16, workers=2) == 16
|
||||
|
||||
|
||||
class TestAbandonedWhileQueued:
|
||||
"""A queued request whose client disconnected must free its place at once.
|
||||
|
||||
This is what makes a patient deadline affordable: without it a 30s wait means
|
||||
30s of held connections for callers that may already be gone.
|
||||
"""
|
||||
|
||||
async def test_disconnect_releases_the_waiter_immediately(self):
|
||||
from hindsight_api.api.admission import AdmissionAbandoned
|
||||
from hindsight_api.cancellation import CancellationToken
|
||||
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=1, max_wait_seconds=30.0))
|
||||
token = CancellationToken()
|
||||
|
||||
async with controller.admit("recall"):
|
||||
started = asyncio.get_running_loop().time()
|
||||
|
||||
async def disconnect_soon():
|
||||
await asyncio.sleep(0.05)
|
||||
token.cancel("client disconnected")
|
||||
|
||||
async def queued():
|
||||
async with controller.admit("recall", abandoned=token):
|
||||
pytest.fail("should not have been admitted")
|
||||
|
||||
disconnector = asyncio.create_task(disconnect_soon())
|
||||
with pytest.raises(AdmissionAbandoned):
|
||||
await queued()
|
||||
waited = asyncio.get_running_loop().time() - started
|
||||
await disconnector
|
||||
|
||||
# Gave up on disconnect, not after the 30s deadline.
|
||||
assert waited < 1.0
|
||||
|
||||
async def test_abandoned_waiter_does_not_consume_a_permit(self):
|
||||
"""The permit must still be there for the next real caller."""
|
||||
from hindsight_api.api.admission import AdmissionAbandoned
|
||||
from hindsight_api.cancellation import CancellationToken
|
||||
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=1, max_wait_seconds=5.0))
|
||||
token = CancellationToken()
|
||||
token.cancel("gone before it even queued")
|
||||
|
||||
with pytest.raises(AdmissionAbandoned):
|
||||
async with controller.admit("recall", abandoned=token):
|
||||
pass
|
||||
|
||||
# Lane is intact: a live caller is admitted straight away.
|
||||
async with controller.admit("recall"):
|
||||
assert controller.stats()["recall"].in_flight == 1
|
||||
assert controller.stats()["recall"].in_flight == 0
|
||||
|
||||
async def test_live_client_still_admitted_normally(self):
|
||||
"""An un-cancelled token must not change the happy path."""
|
||||
from hindsight_api.cancellation import CancellationToken
|
||||
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=1, max_wait_seconds=1.0))
|
||||
async with controller.admit("recall", abandoned=CancellationToken()):
|
||||
assert controller.stats()["recall"].in_flight == 1
|
||||
assert controller.stats()["recall"].admitted == 1
|
||||
|
||||
async def test_abandoned_waiter_leaves_queue_count_at_zero(self):
|
||||
"""The abandon path must decrement `queued` exactly once."""
|
||||
from hindsight_api.api.admission import AdmissionAbandoned
|
||||
from hindsight_api.cancellation import CancellationToken
|
||||
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=1, max_wait_seconds=5.0))
|
||||
token = CancellationToken()
|
||||
token.cancel("gone")
|
||||
|
||||
with pytest.raises(AdmissionAbandoned):
|
||||
async with controller.admit("recall", abandoned=token):
|
||||
pass
|
||||
|
||||
stats = controller.stats()["recall"]
|
||||
assert stats.queued == 0
|
||||
assert stats.abandoned == 1
|
||||
|
||||
def test_negative_disables_the_lane(self, monkeypatch):
|
||||
"""0 means "derive", so the kill switch has to be a negative value."""
|
||||
from hindsight_api import config as config_mod
|
||||
|
||||
monkeypatch.setattr("hindsight_api._thread_limits.available_cpu_count", lambda: 8)
|
||||
assert config_mod.admission_in_flight_for(-1, per_core=16, workers=1) == 0
|
||||
|
||||
|
||||
async def test_http_recall_refused_with_503_and_retry_after_when_lane_full(memory):
|
||||
"""End to end through the route dependency: a full lane answers 503 + Retry-After."""
|
||||
import httpx
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
|
||||
app = create_app(memory, initialize_memory=False)
|
||||
controller = _controller(recall=LaneConfig(max_in_flight=1, max_wait_seconds=0.0))
|
||||
app.state.admission = controller
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
async with controller.admit("recall"):
|
||||
response = await client.post(
|
||||
"/v1/default/banks/admission-test/memories/recall",
|
||||
json={"query": "anything"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert int(response.headers["retry-after"]) >= 1
|
||||
assert controller.stats()["recall"].rejected == 1
|
||||
@@ -7,13 +7,16 @@ easy-to-use interface on top of the auto-generated OpenAPI client.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
from importlib import metadata
|
||||
from pathlib import Path
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Literal
|
||||
|
||||
import hindsight_client_api
|
||||
from hindsight_client_api.exceptions import ApiException
|
||||
|
||||
try:
|
||||
_CLIENT_VERSION = metadata.version("hindsight-client")
|
||||
@@ -125,6 +128,63 @@ def _trigger_input(trigger: dict[str, Any]) -> Any:
|
||||
return model(**unnamed_defaults, **trigger)
|
||||
|
||||
|
||||
#: Attempts a retryable call makes in total, including the first.
|
||||
DEFAULT_MAX_ATTEMPTS = 3
|
||||
|
||||
#: Fallback backoff when the server sends 503 without a usable ``Retry-After``.
|
||||
_FALLBACK_BACKOFF_SECONDS = 0.5
|
||||
|
||||
|
||||
async def _retry_on_capacity(
|
||||
call: "Callable[[], Awaitable[Any]]",
|
||||
max_attempts: int,
|
||||
rng: "random.Random",
|
||||
) -> Any:
|
||||
"""Run ``call``, retrying while the server reports it is at capacity.
|
||||
|
||||
Only for **idempotent** operations. Recall and reflect are reads, so a repeat is
|
||||
free; synchronous retain is not, and is deliberately excluded — its
|
||||
``operation_id`` is ignored, so a retry there could duplicate a write.
|
||||
|
||||
Two things matter more than the retry itself:
|
||||
|
||||
``Retry-After`` is honoured. The server sends it precisely because it knows how
|
||||
long its queue is; retrying sooner just earns another 503.
|
||||
|
||||
The wait is **jittered**. A burst of clients that all receive ``Retry-After: 1``
|
||||
and obey it exactly will come back in lockstep and rebuild the spike that caused
|
||||
the rejection. Spreading them over the interval is what makes retrying safe, and
|
||||
it is the part the generated client's ``ExponentialRetry`` does not do.
|
||||
"""
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
return await call()
|
||||
except ApiException as e:
|
||||
at_capacity = e.status in (429, 503)
|
||||
if not at_capacity or attempt == max_attempts:
|
||||
raise
|
||||
wait = _retry_after_seconds(e) or _FALLBACK_BACKOFF_SECONDS * (2 ** (attempt - 1))
|
||||
# Full jitter: sleep somewhere in [0, wait], so a synchronised burst
|
||||
# spreads out instead of returning together.
|
||||
await asyncio.sleep(rng.uniform(0, wait))
|
||||
raise AssertionError("unreachable") # pragma: no cover
|
||||
|
||||
|
||||
def _retry_after_seconds(e: "ApiException") -> float | None:
|
||||
"""Parse ``Retry-After`` (delta-seconds form) from a response, if present."""
|
||||
headers = getattr(e, "headers", None)
|
||||
if not headers:
|
||||
return None
|
||||
raw = headers.get("Retry-After") or headers.get("retry-after")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return max(0.0, float(raw))
|
||||
except (TypeError, ValueError):
|
||||
# HTTP-date form; the fallback backoff is a better answer than parsing dates.
|
||||
return None
|
||||
|
||||
|
||||
class Hindsight:
|
||||
"""
|
||||
High-level, easy-to-use Hindsight API client.
|
||||
@@ -199,6 +259,7 @@ class Hindsight:
|
||||
api_key: str | None = None,
|
||||
timeout: float = 300.0,
|
||||
user_agent: str | None = None,
|
||||
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
||||
):
|
||||
"""
|
||||
Initialize the Hindsight client.
|
||||
@@ -211,11 +272,19 @@ class Hindsight:
|
||||
should set this to identify themselves (e.g.
|
||||
``"hindsight-crewai/1.2.0"``). Defaults to
|
||||
``hindsight-client-python/<version>``.
|
||||
max_attempts: Total attempts for *idempotent* calls (recall, reflect)
|
||||
when the server reports it is at capacity (429/503). 1 disables
|
||||
retrying. Waits honour ``Retry-After`` and are jittered; writes are
|
||||
never retried here.
|
||||
"""
|
||||
config = hindsight_client_api.Configuration(host=base_url, access_token=api_key)
|
||||
self._api_client = hindsight_client_api.ApiClient(config)
|
||||
self._api_client.user_agent = user_agent or DEFAULT_USER_AGENT
|
||||
self._timeout = timeout
|
||||
self._max_attempts = max(1, max_attempts)
|
||||
# Per-client RNG so jitter is injectable in tests and independent of any
|
||||
# seeding the calling application does to the global `random` module.
|
||||
self._retry_rng = random.Random()
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
if api_key:
|
||||
@@ -1244,7 +1313,11 @@ class Hindsight:
|
||||
temporal_window=temporal_window_obj,
|
||||
)
|
||||
|
||||
return await self._memory_api.recall_memories(bank_id, request_obj, _request_timeout=self._timeout)
|
||||
return await _retry_on_capacity(
|
||||
lambda: self._memory_api.recall_memories(bank_id, request_obj, _request_timeout=self._timeout),
|
||||
self._max_attempts,
|
||||
self._retry_rng,
|
||||
)
|
||||
|
||||
async def areflect(
|
||||
self,
|
||||
@@ -1336,7 +1409,11 @@ class Hindsight:
|
||||
exclude_mental_model_ids=exclude_mental_model_ids,
|
||||
)
|
||||
|
||||
return await self._memory_api.reflect(bank_id, request_obj, _request_timeout=self._timeout)
|
||||
return await _retry_on_capacity(
|
||||
lambda: self._memory_api.reflect(bank_id, request_obj, _request_timeout=self._timeout),
|
||||
self._max_attempts,
|
||||
self._retry_rng,
|
||||
)
|
||||
|
||||
# Mental Models methods
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Retry policy for idempotent calls when the server is at capacity.
|
||||
|
||||
The server's admission control answers 503 with ``Retry-After`` when a lane is
|
||||
full. Retrying is only safe if it honours that hint and spreads the retries out:
|
||||
a burst that all obey ``Retry-After: 1`` exactly comes back in lockstep and
|
||||
rebuilds the spike that caused the rejection.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_client.hindsight_client import (
|
||||
_retry_after_seconds,
|
||||
_retry_on_capacity,
|
||||
)
|
||||
from hindsight_client_api.exceptions import ApiException
|
||||
|
||||
|
||||
def _at_capacity(status: int = 503, retry_after: str | None = "2") -> ApiException:
|
||||
e = ApiException(status=status)
|
||||
e.headers = {"Retry-After": retry_after} if retry_after is not None else {}
|
||||
return e
|
||||
|
||||
|
||||
class TestRetryAfterParsing:
|
||||
def test_reads_delta_seconds(self):
|
||||
assert _retry_after_seconds(_at_capacity(retry_after="7")) == 7.0
|
||||
|
||||
def test_missing_header_returns_none(self):
|
||||
assert _retry_after_seconds(_at_capacity(retry_after=None)) is None
|
||||
|
||||
def test_http_date_form_falls_back(self):
|
||||
"""Date form is legal but rarer; the caller's backoff is a better answer."""
|
||||
assert _retry_after_seconds(_at_capacity(retry_after="Wed, 21 Oct 2026 07:28:00 GMT")) is None
|
||||
|
||||
def test_negative_is_clamped(self):
|
||||
assert _retry_after_seconds(_at_capacity(retry_after="-5")) == 0.0
|
||||
|
||||
|
||||
class TestRetryBehaviour:
|
||||
async def test_succeeds_without_retrying(self):
|
||||
calls = []
|
||||
|
||||
async def call():
|
||||
calls.append(1)
|
||||
return "ok"
|
||||
|
||||
assert await _retry_on_capacity(call, 3, random.Random(0)) == "ok"
|
||||
assert len(calls) == 1
|
||||
|
||||
async def test_retries_503_then_succeeds(self):
|
||||
calls = []
|
||||
|
||||
async def call():
|
||||
calls.append(1)
|
||||
if len(calls) < 3:
|
||||
raise _at_capacity(503, "0")
|
||||
return "ok"
|
||||
|
||||
assert await _retry_on_capacity(call, 3, random.Random(0)) == "ok"
|
||||
assert len(calls) == 3
|
||||
|
||||
async def test_retries_429_too(self):
|
||||
calls = []
|
||||
|
||||
async def call():
|
||||
calls.append(1)
|
||||
if len(calls) < 2:
|
||||
raise _at_capacity(429, "0")
|
||||
return "ok"
|
||||
|
||||
assert await _retry_on_capacity(call, 3, random.Random(0)) == "ok"
|
||||
|
||||
async def test_gives_up_after_max_attempts_and_reraises(self):
|
||||
calls = []
|
||||
|
||||
async def call():
|
||||
calls.append(1)
|
||||
raise _at_capacity(503, "0")
|
||||
|
||||
with pytest.raises(ApiException) as excinfo:
|
||||
await _retry_on_capacity(call, 3, random.Random(0))
|
||||
assert excinfo.value.status == 503
|
||||
assert len(calls) == 3
|
||||
|
||||
async def test_does_not_retry_other_errors(self):
|
||||
"""A 400 is the caller's problem; repeating it just wastes a round trip."""
|
||||
calls = []
|
||||
|
||||
async def call():
|
||||
calls.append(1)
|
||||
raise ApiException(status=400)
|
||||
|
||||
with pytest.raises(ApiException):
|
||||
await _retry_on_capacity(call, 3, random.Random(0))
|
||||
assert len(calls) == 1
|
||||
|
||||
async def test_max_attempts_of_one_disables_retrying(self):
|
||||
calls = []
|
||||
|
||||
async def call():
|
||||
calls.append(1)
|
||||
raise _at_capacity(503, "0")
|
||||
|
||||
with pytest.raises(ApiException):
|
||||
await _retry_on_capacity(call, 1, random.Random(0))
|
||||
assert len(calls) == 1
|
||||
|
||||
async def test_wait_is_jittered_within_retry_after(self, monkeypatch):
|
||||
"""Two clients given the same Retry-After must not wake together."""
|
||||
slept: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
slept.append(seconds)
|
||||
|
||||
monkeypatch.setattr("hindsight_client.hindsight_client.asyncio.sleep", fake_sleep)
|
||||
|
||||
async def call():
|
||||
raise _at_capacity(503, "4")
|
||||
|
||||
for seed in (1, 2, 3):
|
||||
with pytest.raises(ApiException):
|
||||
await _retry_on_capacity(call, 2, random.Random(seed))
|
||||
|
||||
assert all(0 <= s <= 4 for s in slept), slept
|
||||
assert len(set(slept)) > 1, "identical waits: jitter is not being applied"
|
||||
@@ -85,6 +85,51 @@ export const CLIENT_VERSION: string =
|
||||
typeof __CLIENT_VERSION__ !== "undefined" ? __CLIENT_VERSION__ : "0.0.0-dev";
|
||||
export const DEFAULT_USER_AGENT = `hindsight-client-typescript/${CLIENT_VERSION}`;
|
||||
|
||||
/** Attempts a retryable call makes in total, including the first. */
|
||||
export const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
|
||||
/** Fallback backoff when the server sends 503 without a usable `Retry-After`. */
|
||||
const FALLBACK_BACKOFF_MS = 500;
|
||||
|
||||
/** Parse `Retry-After` (delta-seconds form) into milliseconds, if present. */
|
||||
export function retryAfterMs(response: Response | undefined): number | null {
|
||||
const raw = response?.headers?.get("retry-after");
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const seconds = Number(raw);
|
||||
// The HTTP-date form is legal but rare; the caller's backoff beats parsing dates.
|
||||
if (!Number.isFinite(seconds)) return null;
|
||||
return Math.max(0, seconds * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `send`, retrying while the server reports it is at capacity (429/503).
|
||||
*
|
||||
* Only for **idempotent** operations. Recall and reflect are reads, so a repeat is
|
||||
* free; synchronous retain is not, and is deliberately excluded — its
|
||||
* `operation_id` is ignored there, so a retry could duplicate a write.
|
||||
*
|
||||
* Two things matter more than the retry itself. `Retry-After` is honoured, because
|
||||
* the server sends it knowing how long its own queue is. And the wait is
|
||||
* **jittered**: a burst of clients that all receive `Retry-After: 1` and obey it
|
||||
* exactly return in lockstep and rebuild the spike that caused the rejection.
|
||||
*/
|
||||
export async function retryOnCapacity<T extends { response?: Response }>(
|
||||
send: () => Promise<T>,
|
||||
maxAttempts: number,
|
||||
random: () => number = Math.random
|
||||
): Promise<T> {
|
||||
let result = await send();
|
||||
for (let attempt = 1; attempt < maxAttempts; attempt++) {
|
||||
const status = result.response?.status;
|
||||
if (status !== 429 && status !== 503) return result;
|
||||
const wait = retryAfterMs(result.response) ?? FALLBACK_BACKOFF_MS * 2 ** (attempt - 1);
|
||||
// Full jitter: sleep somewhere in [0, wait] so a synchronised burst spreads out.
|
||||
await new Promise((resolve) => setTimeout(resolve, random() * wait));
|
||||
result = await send();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface HindsightClientOptions {
|
||||
baseUrl: string;
|
||||
/**
|
||||
@@ -100,6 +145,12 @@ export interface HindsightClientOptions {
|
||||
userAgent?: string;
|
||||
/** Optional headers sent with every request. */
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Total attempts for *idempotent* calls (recall, reflect) when the server reports
|
||||
* it is at capacity (429/503). 1 disables retrying. Waits honour `Retry-After`
|
||||
* and are jittered; writes are never retried.
|
||||
*/
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +287,7 @@ function warnIfOperationIdDropped(
|
||||
|
||||
export class HindsightClient {
|
||||
private client: Client;
|
||||
private maxAttempts: number;
|
||||
|
||||
constructor(options: HindsightClientOptions) {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -251,6 +303,7 @@ export class HindsightClient {
|
||||
headers,
|
||||
})
|
||||
);
|
||||
this.maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -478,39 +531,43 @@ export class HindsightClient {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<RecallResponse> {
|
||||
const response = await sdk.recallMemories({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: {
|
||||
query,
|
||||
types: options?.types,
|
||||
prefer_observations: options?.preferObservations,
|
||||
max_tokens: options?.maxTokens,
|
||||
budget: options?.budget || "mid",
|
||||
trace: options?.trace,
|
||||
query_timestamp: options?.queryTimestamp,
|
||||
include: {
|
||||
entities:
|
||||
options?.includeEntities === false
|
||||
? null
|
||||
: options?.includeEntities
|
||||
? { max_tokens: options?.maxEntityTokens ?? 500 }
|
||||
const response = await retryOnCapacity(
|
||||
() =>
|
||||
sdk.recallMemories({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: {
|
||||
query,
|
||||
types: options?.types,
|
||||
prefer_observations: options?.preferObservations,
|
||||
max_tokens: options?.maxTokens,
|
||||
budget: options?.budget || "mid",
|
||||
trace: options?.trace,
|
||||
query_timestamp: options?.queryTimestamp,
|
||||
include: {
|
||||
entities:
|
||||
options?.includeEntities === false
|
||||
? null
|
||||
: options?.includeEntities
|
||||
? { max_tokens: options?.maxEntityTokens ?? 500 }
|
||||
: undefined,
|
||||
chunks: options?.includeChunks
|
||||
? { max_tokens: options?.maxChunkTokens ?? 8192 }
|
||||
: undefined,
|
||||
chunks: options?.includeChunks
|
||||
? { max_tokens: options?.maxChunkTokens ?? 8192 }
|
||||
: undefined,
|
||||
source_facts: options?.includeSourceFacts
|
||||
? { max_tokens: options?.maxSourceFactsTokens ?? 4096 }
|
||||
: undefined,
|
||||
},
|
||||
tags: options?.tags,
|
||||
tags_match: options?.tagsMatch,
|
||||
tag_groups: options?.tagGroups,
|
||||
min_scores: options?.minScores,
|
||||
temporal_window: options?.temporalWindow,
|
||||
},
|
||||
signal: options?.signal,
|
||||
});
|
||||
source_facts: options?.includeSourceFacts
|
||||
? { max_tokens: options?.maxSourceFactsTokens ?? 4096 }
|
||||
: undefined,
|
||||
},
|
||||
tags: options?.tags,
|
||||
tags_match: options?.tagsMatch,
|
||||
tag_groups: options?.tagGroups,
|
||||
min_scores: options?.minScores,
|
||||
temporal_window: options?.temporalWindow,
|
||||
},
|
||||
signal: options?.signal,
|
||||
}),
|
||||
this.maxAttempts
|
||||
);
|
||||
|
||||
return this.validateResponse(response, "recall");
|
||||
}
|
||||
@@ -558,25 +615,29 @@ export class HindsightClient {
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
const response = await sdk.reflect({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: {
|
||||
query,
|
||||
context: options?.context,
|
||||
budget: options?.budget || "low",
|
||||
tags: options?.tags,
|
||||
tags_match: options?.tagsMatch,
|
||||
tag_groups: options?.tagGroups,
|
||||
apply_all_directives: options?.applyAllDirectives,
|
||||
response_schema: options?.responseSchema,
|
||||
fact_types: options?.factTypes,
|
||||
exclude_mental_models: options?.excludeMentalModels,
|
||||
exclude_mental_model_ids: options?.excludeMentalModelIds,
|
||||
include,
|
||||
},
|
||||
signal: options?.signal,
|
||||
});
|
||||
const response = await retryOnCapacity(
|
||||
() =>
|
||||
sdk.reflect({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: {
|
||||
query,
|
||||
context: options?.context,
|
||||
budget: options?.budget || "low",
|
||||
tags: options?.tags,
|
||||
tags_match: options?.tagsMatch,
|
||||
tag_groups: options?.tagGroups,
|
||||
apply_all_directives: options?.applyAllDirectives,
|
||||
response_schema: options?.responseSchema,
|
||||
fact_types: options?.factTypes,
|
||||
exclude_mental_models: options?.excludeMentalModels,
|
||||
exclude_mental_model_ids: options?.excludeMentalModelIds,
|
||||
include,
|
||||
},
|
||||
signal: options?.signal,
|
||||
}),
|
||||
this.maxAttempts
|
||||
);
|
||||
|
||||
return this.validateResponse(response, "reflect");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Retry policy for idempotent calls when the server is at capacity.
|
||||
*
|
||||
* Mirrors tests/test_retry_on_capacity.py in the Python wrapper: the two
|
||||
* maintained wrappers are expected to expose the same behaviour.
|
||||
*/
|
||||
import { retryAfterMs, retryOnCapacity } from "../src/index";
|
||||
|
||||
function res(status: number, retryAfter?: string): { response: Response } {
|
||||
const headers = new Headers();
|
||||
if (retryAfter !== undefined) headers.set("retry-after", retryAfter);
|
||||
return { response: { status, headers } as unknown as Response };
|
||||
}
|
||||
|
||||
describe("retryAfterMs", () => {
|
||||
it("reads delta-seconds", () => {
|
||||
expect(retryAfterMs(res(503, "7").response)).toBe(7000);
|
||||
});
|
||||
|
||||
it("returns null when absent", () => {
|
||||
expect(retryAfterMs(res(503).response)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back on the HTTP-date form", () => {
|
||||
expect(retryAfterMs(res(503, "Wed, 21 Oct 2026 07:28:00 GMT").response)).toBeNull();
|
||||
});
|
||||
|
||||
it("clamps a negative value", () => {
|
||||
expect(retryAfterMs(res(503, "-5").response)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retryOnCapacity", () => {
|
||||
it("does not retry a success", async () => {
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return res(200);
|
||||
};
|
||||
await retryOnCapacity(send, 3, () => 0);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("retries a 503 and returns the eventual success", async () => {
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return calls < 3 ? res(503, "0") : res(200);
|
||||
};
|
||||
const out = await retryOnCapacity(send, 3, () => 0);
|
||||
expect(calls).toBe(3);
|
||||
expect(out.response?.status).toBe(200);
|
||||
});
|
||||
|
||||
it("retries a 429 as well", async () => {
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return calls < 2 ? res(429, "0") : res(200);
|
||||
};
|
||||
await retryOnCapacity(send, 3, () => 0);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("gives up after maxAttempts and returns the last response", async () => {
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return res(503, "0");
|
||||
};
|
||||
const out = await retryOnCapacity(send, 3, () => 0);
|
||||
expect(calls).toBe(3);
|
||||
expect(out.response?.status).toBe(503);
|
||||
});
|
||||
|
||||
it("does not retry other failures", async () => {
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return res(400);
|
||||
};
|
||||
await retryOnCapacity(send, 3, () => 0);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("maxAttempts of 1 disables retrying", async () => {
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return res(503, "0");
|
||||
};
|
||||
await retryOnCapacity(send, 1, () => 0);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("jitters the wait within Retry-After", async () => {
|
||||
// Two clients handed the same Retry-After must not wake together.
|
||||
const waits: number[] = [];
|
||||
const realSetTimeout = global.setTimeout;
|
||||
// @ts-expect-error test double
|
||||
global.setTimeout = (fn: () => void, ms: number) => {
|
||||
waits.push(ms);
|
||||
return realSetTimeout(fn, 0);
|
||||
};
|
||||
try {
|
||||
for (const r of [0.1, 0.5, 0.9]) {
|
||||
await retryOnCapacity(
|
||||
async () => res(503, "4"),
|
||||
2,
|
||||
() => r
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
global.setTimeout = realSetTimeout;
|
||||
}
|
||||
expect(waits.every((w) => w >= 0 && w <= 4000)).toBe(true);
|
||||
expect(new Set(waits).size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -1420,6 +1420,12 @@ For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a cust
|
||||
| `HINDSIGHT_API_LINK_EXPANSION_TIMEOUT` | Timeout (seconds) for the per-entity graph expansion query in `link_expansion` retrieval. | `10` |
|
||||
| `HINDSIGHT_API_RECALL_MAX_CONCURRENT` | Max concurrent recall operations per worker (backpressure) | `32` |
|
||||
| `HINDSIGHT_API_RECALL_CONNECTION_BUDGET` | Max concurrent DB connections per recall operation | `4` |
|
||||
| `HINDSIGHT_API_ADMISSION_RECALL_MAX_IN_FLIGHT` | Concurrent recalls admitted per worker before requests queue. `0` derives it from the CPU budget this process has (cgroup quota) divided by `HINDSIGHT_API_WORKERS`; a negative value disables the lane. A latency target, not a capacity limit: throughput is unchanged either way, but too low throttles I/O-bound work and too high rebuilds the queue. | `0` (derived) |
|
||||
| `HINDSIGHT_API_ADMISSION_RECALL_MAX_WAIT_MS` | How long a recall may queue for a slot before being refused with 503 + `Retry-After`. A queued request whose client disconnects releases its place immediately. Absorbs bursts; lower it if load is persistently over capacity, where a long queue just delays the same refusals. | `30000` |
|
||||
| `HINDSIGHT_API_ADMISSION_REFLECT_MAX_IN_FLIGHT` | As above, for reflect. | `0` (derived) |
|
||||
| `HINDSIGHT_API_ADMISSION_REFLECT_MAX_WAIT_MS` | As above, for reflect. | `5000` |
|
||||
| `HINDSIGHT_API_ADMISSION_RETAIN_MAX_IN_FLIGHT` | As above, for retain. Only bites on the synchronous path; an async retain returns as soon as the operation is queued. | `0` (derived) |
|
||||
| `HINDSIGHT_API_ADMISSION_RETAIN_MAX_WAIT_MS` | As above, for retain. | `2000` |
|
||||
| `HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS` | Maximum token length of a recall query. API requests exceeding this limit are rejected with HTTP 400; recalls that Hindsight runs internally (consolidation, reflect, MCP) truncate the query to the limit instead of failing. `0` disables the limit. | `500` |
|
||||
| `HINDSIGHT_API_QUERY_ANALYZER_LANGUAGES` | Restrict the locales `dateparser` considers when extracting temporal constraints from a recall query, as a comma-separated list of language codes (e.g. `en` or `en,zh`). Empty keeps full auto-detection across all supported locales. Restricting is significantly faster (auto-detection dominates recall's CPU cost) and avoids locale misdetection on a known-language corpus, but explicit dates written in an unlisted locale will then misparse rather than yield no constraint — only set this when you know which languages your queries use. Does not affect Chinese, which is handled before `dateparser` runs. | _(empty)_ |
|
||||
| `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` |
|
||||
|
||||
@@ -196,6 +196,10 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
# HINDSIGHT_API_GZIP_MIN_SIZE=1024
|
||||
# Diagnostic: log event-loop lag percentiles every N seconds. 0 disables.
|
||||
# HINDSIGHT_API_LOOP_LAG_REPORT_SECONDS=0
|
||||
# Record every event-loop lag sample as a histogram (hindsight_event_loop_lag_seconds).
|
||||
# HINDSIGHT_API_LOOP_LAG_METRIC=false
|
||||
# With --workers N, make /metrics cover every worker, each series labelled api_worker=<slot>.
|
||||
# HINDSIGHT_API_METRICS_WORKER_LABEL=false
|
||||
# Record the diagnostic (subset) recall phase metrics.
|
||||
# HINDSIGHT_API_RECALL_DIAGNOSTIC_PHASES=true
|
||||
# Record 1 in N recall-phase metric observations (1 = all). Cuts metrics CPU at high recall rates.
|
||||
@@ -460,6 +464,25 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
# Explicit refreshes always run immediately.
|
||||
# HINDSIGHT_API_MENTAL_MODEL_MIN_REFRESH_INTERVAL_SECONDS=0
|
||||
|
||||
# Admission control. The *_MAX_CONCURRENT caps above bound how much runs at once and
|
||||
# let an unbounded queue form behind them; these bound how long a request may WAIT
|
||||
# before it is refused with 503 + Retry-After. A queued request whose client
|
||||
# disconnects releases its place immediately, so a patient deadline costs nothing
|
||||
# when nobody is still listening.
|
||||
# Limits are PER WORKER PROCESS and, when left at 0, derived from the CPU budget this
|
||||
# process actually has (cgroup quota, not the host's core count) divided by
|
||||
# HINDSIGHT_API_WORKERS. A positive value overrides the derivation; a NEGATIVE value
|
||||
# disables that lane entirely (0 cannot mean "off", because it means "derive").
|
||||
# in_flight is a latency target, not a capacity limit: throughput is cores divided by
|
||||
# CPU-per-request either way. Too low throttles I/O-bound work, too high rebuilds the
|
||||
# queue this exists to prevent.
|
||||
# HINDSIGHT_API_ADMISSION_RECALL_MAX_IN_FLIGHT=0
|
||||
# HINDSIGHT_API_ADMISSION_RECALL_MAX_WAIT_MS=30000
|
||||
# HINDSIGHT_API_ADMISSION_REFLECT_MAX_IN_FLIGHT=0
|
||||
# HINDSIGHT_API_ADMISSION_REFLECT_MAX_WAIT_MS=5000
|
||||
# HINDSIGHT_API_ADMISSION_RETAIN_MAX_IN_FLIGHT=0
|
||||
# HINDSIGHT_API_ADMISSION_RETAIN_MAX_WAIT_MS=2000
|
||||
|
||||
# Recall pipeline stages (all on by default). Each is hierarchical, so a single
|
||||
# bank can switch a stage off via the config API without changing the server
|
||||
# default. Turning all four off reduces recall to a single vector query, the
|
||||
|
||||
@@ -1420,6 +1420,12 @@ For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a cust
|
||||
| `HINDSIGHT_API_LINK_EXPANSION_TIMEOUT` | Timeout (seconds) for the per-entity graph expansion query in `link_expansion` retrieval. | `10` |
|
||||
| `HINDSIGHT_API_RECALL_MAX_CONCURRENT` | Max concurrent recall operations per worker (backpressure) | `32` |
|
||||
| `HINDSIGHT_API_RECALL_CONNECTION_BUDGET` | Max concurrent DB connections per recall operation | `4` |
|
||||
| `HINDSIGHT_API_ADMISSION_RECALL_MAX_IN_FLIGHT` | Concurrent recalls admitted per worker before requests queue. `0` derives it from the CPU budget this process has (cgroup quota) divided by `HINDSIGHT_API_WORKERS`; a negative value disables the lane. A latency target, not a capacity limit: throughput is unchanged either way, but too low throttles I/O-bound work and too high rebuilds the queue. | `0` (derived) |
|
||||
| `HINDSIGHT_API_ADMISSION_RECALL_MAX_WAIT_MS` | How long a recall may queue for a slot before being refused with 503 + `Retry-After`. A queued request whose client disconnects releases its place immediately. Absorbs bursts; lower it if load is persistently over capacity, where a long queue just delays the same refusals. | `30000` |
|
||||
| `HINDSIGHT_API_ADMISSION_REFLECT_MAX_IN_FLIGHT` | As above, for reflect. | `0` (derived) |
|
||||
| `HINDSIGHT_API_ADMISSION_REFLECT_MAX_WAIT_MS` | As above, for reflect. | `5000` |
|
||||
| `HINDSIGHT_API_ADMISSION_RETAIN_MAX_IN_FLIGHT` | As above, for retain. Only bites on the synchronous path; an async retain returns as soon as the operation is queued. | `0` (derived) |
|
||||
| `HINDSIGHT_API_ADMISSION_RETAIN_MAX_WAIT_MS` | As above, for retain. | `2000` |
|
||||
| `HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS` | Maximum token length of a recall query. API requests exceeding this limit are rejected with HTTP 400; recalls that Hindsight runs internally (consolidation, reflect, MCP) truncate the query to the limit instead of failing. `0` disables the limit. | `500` |
|
||||
| `HINDSIGHT_API_QUERY_ANALYZER_LANGUAGES` | Restrict the locales `dateparser` considers when extracting temporal constraints from a recall query, as a comma-separated list of language codes (e.g. `en` or `en,zh`). Empty keeps full auto-detection across all supported locales. Restricting is significantly faster (auto-detection dominates recall's CPU cost) and avoids locale misdetection on a known-language corpus, but explicit dates written in an unlisted locale will then misparse rather than yield no constraint — only set this when you know which languages your queries use. Does not affect Chinese, which is handled before `dateparser` runs. | _(empty)_ |
|
||||
| `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` |
|
||||
|
||||
Reference in New Issue
Block a user