Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/infrahub/api/admission/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from enum import StrEnum


class RejectionReason(StrEnum):
"""Which mechanism shed a request. Doubles as the `reason` metric label."""

STRESS = "stress"
CODEL = "codel"
BACKSTOP = "backstop"
190 changes: 74 additions & 116 deletions backend/infrahub/api/admission/controller.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
from __future__ import annotations

import random
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Literal
from typing import TYPE_CHECKING, Callable, Generator, Protocol

from infrahub.database.load_signal import UNSTRESSED_RATIO, get_reference_query_load_tracker
from infrahub.database.load_signal import UNSTRESSED_RATIO
from infrahub.log import get_logger

from . import metrics
from .capacity import derive_max_concurrency
from .codel import CoDelController
from .priority import Priority
from .retry_policy import RetryAfterPolicy
from .slot_pool import PrioritySlotPool
from .constants import RejectionReason

if TYPE_CHECKING:
from infrahub import config
from infrahub.database.load_signal import LoadSignal

from .slot_pool import Acquisition
from .codel import CoDelController
from .priority import Priority
from .retry_policy import RetryAfterPolicy
from .slot_pool import Acquisition, PrioritySlotPool

log = get_logger()

# Backstop shedding (waiter queue saturated) is unambiguous overload, so its retry-after hint
# always uses the top intensity tier regardless of the current stress ratio.
Expand Down Expand Up @@ -48,12 +49,9 @@ def stress_tier(*, ratio: float, threshold: float) -> int:
return 3


def stress_shed_fraction(*, ratio: float, threshold: float) -> float:
"""Fraction of requests to shed for a class given the stress ratio and the class's trigger.

Returns ``0.0`` below the trigger, then steps up as the ratio climbs to 2x, 5x, and beyond.
"""
return _SHED_FRACTION_BY_TIER[stress_tier(ratio=ratio, threshold=threshold)]
def stress_shed_fraction(*, tier: int) -> float:
"""Fraction of a class's requests to shed at a given severity tier."""
return _SHED_FRACTION_BY_TIER[tier]


@dataclass(frozen=True)
Expand All @@ -67,20 +65,40 @@ class Admitted:
class Rejected:
"""Decision to shed the request, with the reason and a Retry-After hint (seconds)."""

reason: Literal["stress", "codel", "backstop"]
reason: RejectionReason
retry_after: int


AdmissionDecision = Admitted | Rejected


@contextmanager
def _contained_observer_failure() -> Generator[None]:
"""Log whatever one observer raises instead of letting it reach the admission path."""
try:
yield
except Exception:
log.warning("admission observer raised; continuing", exc_info=True)


class AdmissionObserver(Protocol):
"""Sink notified as a request moves through the admission decision."""

def on_offered(self, *, priority: Priority) -> None: ...

def on_admitted(self, *, priority: Priority) -> None: ...

def on_rejected(self, *, priority: Priority, reason: RejectionReason) -> None: ...

def on_sojourn(self, *, priority: Priority, seconds: float) -> None: ...


class AdmissionController:
"""Turns a priority class into an admit/shed decision.

Composes the shared slot pool, one CoDel controller per priority class, a per-class
waiter backstop, and a database-stress signal. All tuning is passed in, so the class
carries no dependency on global settings and is directly testable; the module-level
factory wires the defaults.
waiter backstop, and a database-stress signal. Every collaborator and tuning value is
injected, so the class carries no dependency on global settings and is directly testable.

Two independent signals shed a request. Database stress (how much slower the reference query
is than its all-time best) sheds a growing fraction of a class as the ratio climbs past that
Expand All @@ -103,17 +121,12 @@ def __init__(
stress_thresholds: dict[Priority, float],
stress_min_samples: int,
retry_policy: RetryAfterPolicy,
observers: list[AdmissionObserver],
rng: Callable[[], float] = random.random,
) -> None:
self._slot_pool = slot_pool
# Publish the live gauges straight from the pool's own state transitions, so a class's
# waiter count is reflected the moment a request enqueues. The sink is a plain function
# fed the counts by the pool, so nothing reads back into the pool.
self._slot_pool.set_observer(_publish_slot_metrics)
# Publish the sustained-load gauge the same way: the policy pushes the duration to a plain
# sink, so it carries no metrics dependency of its own.
self._retry_policy = retry_policy
self._retry_policy.set_observer(_publish_sustained_load_metric)
self._observers = observers
self._backstop_max_waiters = backstop_max_waiters
self._stress_signal = stress_signal
self._stress_thresholds = stress_thresholds
Expand All @@ -132,7 +145,7 @@ async def admit(self, *, priority: Priority) -> AdmissionDecision:
``Admitted`` carrying the held slot, or ``Rejected`` with the shed reason.

"""
metrics.OFFERED_TOTAL.labels(priority=priority.label).inc()
self._observe_offered(priority=priority)

# The stress ratio is the shared server-load proxy: it drives the graduated shed decision
# and, via the retry policy, both the adaptive Retry-After and the sustained-load clock.
Expand All @@ -142,35 +155,57 @@ async def admit(self, *, priority: Priority) -> AdmissionDecision:
tier = stress_tier(ratio=ratio, threshold=self._stress_thresholds[priority])

if self._slot_pool.waiters(priority=priority) >= self._backstop_max_waiters[priority]:
metrics.REJECTED_TOTAL.labels(priority=priority.label, reason="backstop").inc()
self._observe_rejected(priority=priority, reason=RejectionReason.BACKSTOP)
# A saturated waiter queue is unambiguous overload, so the hint uses the top tier.
return Rejected(reason="backstop", retry_after=self._retry_policy.retry_after(tier=_BACKSTOP_TIER))
return Rejected(
reason=RejectionReason.BACKSTOP, retry_after=self._retry_policy.retry_after(tier=_BACKSTOP_TIER)
)

# Database stress is evaluated before the request queues for a slot: a stressed class sheds
# a random fraction of its requests, and a shed one gets its fast 429 without waiting behind
# a saturated pool or consuming waiter capacity. CoDel keys off the measured sojourn, so it
# can only run once a slot is held — a request shed here never reaches it.
if tier >= 1 and self._rng() < _SHED_FRACTION_BY_TIER[tier]:
metrics.REJECTED_TOTAL.labels(priority=priority.label, reason="stress").inc()
return Rejected(reason="stress", retry_after=self._retry_policy.retry_after(tier=tier))
if tier >= 1 and self._rng() < stress_shed_fraction(tier=tier):
self._observe_rejected(priority=priority, reason=RejectionReason.STRESS)
return Rejected(reason=RejectionReason.STRESS, retry_after=self._retry_policy.retry_after(tier=tier))

acquisition = await self._slot_pool.acquire(priority=priority)
# Once a slot is held, any exception before returning Admitted would strand it (the
# caller only releases what it receives), so guard the whole window and release on error.
try:
metrics.SOJOURN_SECONDS.labels(priority=priority.label).observe(acquisition.sojourn)
self._observe_sojourn(priority=priority, seconds=acquisition.sojourn)

if self._codel_priority_map[priority].should_drop(sojourn=acquisition.sojourn):
self._slot_pool.release(acquisition=acquisition)
metrics.REJECTED_TOTAL.labels(priority=priority.label, reason="codel").inc()
return Rejected(reason="codel", retry_after=self._retry_policy.retry_after(tier=tier))
self._observe_rejected(priority=priority, reason=RejectionReason.CODEL)
return Rejected(reason=RejectionReason.CODEL, retry_after=self._retry_policy.retry_after(tier=tier))

metrics.ADMITTED_TOTAL.labels(priority=priority.label).inc()
self._observe_admitted(priority=priority)
return Admitted(acquisition=acquisition)
except Exception:
self._slot_pool.release(acquisition=acquisition)
raise

def _observe_offered(self, *, priority: Priority) -> None:
for observer in self._observers:
with _contained_observer_failure():
observer.on_offered(priority=priority)

def _observe_admitted(self, *, priority: Priority) -> None:
for observer in self._observers:
with _contained_observer_failure():
observer.on_admitted(priority=priority)

def _observe_rejected(self, *, priority: Priority, reason: RejectionReason) -> None:
for observer in self._observers:
with _contained_observer_failure():
observer.on_rejected(priority=priority, reason=reason)

def _observe_sojourn(self, *, priority: Priority, seconds: float) -> None:
for observer in self._observers:
with _contained_observer_failure():
observer.on_sojourn(priority=priority, seconds=seconds)

def _current_stress_ratio(self) -> float:
# Below the sample floor the ratio is unreliable (a cold or outlier floor would distort
# it), so the signal reads as unstressed — matching the shed gate, which also stays quiet
Expand All @@ -180,87 +215,10 @@ def _current_stress_ratio(self) -> float:
return self._stress_signal.stress_ratio_median()

def release(self, *, acquisition: Acquisition) -> None:
"""Return a served request's slot; the pool observer refreshes the live gauges.
"""Return a served request's slot; the pool's observers refresh the live gauges.

The release flows through the pool, whose observer drives ``in_flight``/``waiters``
The release flows through the pool, whose observers drive ``in_flight``/``waiters``
back down, so a finished request is reflected immediately rather than lingering
until the next admit.
"""
self._slot_pool.release(acquisition=acquisition)


def _publish_slot_metrics(priority: Priority, *, in_flight: int, waiters: int) -> None:
"""Pool observer sink: mirror a class's live in-flight and waiter counts onto the gauges."""
metrics.IN_FLIGHT.labels(priority=priority.label).set(in_flight)
metrics.WAITERS.labels(priority=priority.label).set(waiters)


def _publish_sustained_load_metric(sustained_seconds: float) -> None:
"""Retry-policy observer sink: mirror the current sustained-load duration onto the gauge."""
metrics.SUSTAINED_LOAD_SECONDS.set(sustained_seconds)


def build_admission_controller(settings: config.Settings) -> AdmissionController:
"""Build the default admission controller from the given settings.

Keeps settings resolution out of ``AdmissionController`` itself: the class stays
settings-free and testable while this factory owns the wiring of the defaults.
"""
max_concurrency = derive_max_concurrency(
pool_size=settings.database.max_connection_pool_size,
factor=settings.api.backpressure_max_concurrency_factor,
)
# Set the gauge wherever the controller is actually built, so it reflects the derived cap
# in use rather than being frozen at some earlier import.
metrics.MAX_CONCURRENCY.set(max_concurrency)
slot_pool = PrioritySlotPool(max_concurrency=max_concurrency)

# The stress window lives on the shared tracker; apply the configured length here, where
# settings are available, rather than at the tracker's construction.
tracker = get_reference_query_load_tracker()
tracker.window_seconds = settings.api.backpressure_stress_window_seconds

base_backstop = settings.api.backpressure_backstop_max_waiters
backstop_max_waiters = {
Priority.HIGH: max(1, int(base_backstop * settings.api.backpressure_backstop_high_multiplier)),
Priority.MEDIUM: base_backstop,
Priority.LOW: max(1, int(base_backstop * settings.api.backpressure_backstop_low_multiplier)),
}
stress_thresholds = {
Priority.HIGH: settings.api.backpressure_shed_high_stress_ratio,
Priority.MEDIUM: settings.api.backpressure_shed_medium_stress_ratio,
Priority.LOW: settings.api.backpressure_shed_low_stress_ratio,
}
# HIGH gets a larger effective target so it sheds last; MEDIUM and LOW share the base target.
codel = {
Priority.HIGH: CoDelController(
target=settings.api.backpressure_codel_target_seconds * settings.api.backpressure_high_target_multiplier,
interval=settings.api.backpressure_codel_interval_seconds,
),
Priority.MEDIUM: CoDelController(
target=settings.api.backpressure_codel_target_seconds,
interval=settings.api.backpressure_codel_interval_seconds,
),
Priority.LOW: CoDelController(
target=settings.api.backpressure_codel_target_seconds,
interval=settings.api.backpressure_codel_interval_seconds,
),
}
retry_policy = RetryAfterPolicy(
level1_seconds=settings.api.backpressure_retry_after_level1_seconds,
level2_seconds=settings.api.backpressure_retry_after_level2_seconds,
level3_seconds=settings.api.backpressure_retry_after_level3_seconds,
max_seconds=settings.api.backpressure_retry_after_max_seconds,
significant_load_ratio=settings.api.backpressure_significant_load_stress_ratio,
sustained_warn_seconds=settings.api.backpressure_sustained_load_warn_seconds,
sustained_high_seconds=settings.api.backpressure_sustained_load_high_seconds,
)
return AdmissionController(
slot_pool=slot_pool,
codel_priority_map=codel,
backstop_max_waiters=backstop_max_waiters,
stress_signal=tracker,
stress_thresholds=stress_thresholds,
stress_min_samples=settings.api.backpressure_stress_min_samples,
retry_policy=retry_policy,
)
99 changes: 99 additions & 0 deletions backend/infrahub/api/admission/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from infrahub.database.load_signal_registry import get_reference_query_load_tracker

from . import metrics
from .capacity import derive_max_concurrency
from .codel import CoDelController
from .controller import AdmissionController
from .priority import Priority
from .retry_policy import RetryAfterPolicy
from .slot_pool import PrioritySlotPool

if TYPE_CHECKING:
from infrahub import config

from .controller import AdmissionObserver
from .retry_policy import RetryPolicyObserver
from .slot_pool import SlotPoolObserver


def build_admission_controller(
*,
settings: config.Settings,
admission_observers: list[AdmissionObserver],
slot_pool_observers: list[SlotPoolObserver],
retry_policy_observers: list[RetryPolicyObserver],
) -> AdmissionController:
"""Build the default admission controller from the given settings and observers.

Keeps settings resolution out of ``AdmissionController`` itself: the decision logic stays
settings-free and directly testable while this module owns the wiring of the defaults.

Args:
settings: Source of every tuning value the object graph needs.
admission_observers: Sinks notified as each request is offered, admitted, or shed.
slot_pool_observers: Sinks notified as a class's in-flight and waiter counts change.
retry_policy_observers: Sinks notified with the current sustained-load duration.

"""
max_concurrency = derive_max_concurrency(
pool_size=settings.database.max_connection_pool_size,
factor=settings.api.backpressure_max_concurrency_factor,
)
# Set the gauge wherever the controller is actually built, so it reflects the derived cap
# in use rather than being frozen at some earlier import.
metrics.MAX_CONCURRENCY.set(max_concurrency)
slot_pool = PrioritySlotPool(max_concurrency=max_concurrency, observers=slot_pool_observers)

# The database layer feeds this same instance, so the gate reads the signal the queries write.
tracker = get_reference_query_load_tracker()

base_backstop = settings.api.backpressure_backstop_max_waiters
backstop_max_waiters = {
Priority.HIGH: max(1, int(base_backstop * settings.api.backpressure_backstop_high_multiplier)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: An infinite high or low backstop multiplier can make server startup fail while the admission controller is built. The corresponding ApiSettings fields could reject non-finite values before this conversion, matching the existing validation on backpressure_max_concurrency_factor.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/api/admission/factory.py, line 56:

<comment>An infinite high or low backstop multiplier can make server startup fail while the admission controller is built. The corresponding `ApiSettings` fields could reject non-finite values before this conversion, matching the existing validation on `backpressure_max_concurrency_factor`.</comment>

<file context>
@@ -0,0 +1,99 @@
+
+    base_backstop = settings.api.backpressure_backstop_max_waiters
+    backstop_max_waiters = {
+        Priority.HIGH: max(1, int(base_backstop * settings.api.backpressure_backstop_high_multiplier)),
+        Priority.MEDIUM: base_backstop,
+        Priority.LOW: max(1, int(base_backstop * settings.api.backpressure_backstop_low_multiplier)),
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed with new settings validation and test

Priority.MEDIUM: base_backstop,
Priority.LOW: max(1, int(base_backstop * settings.api.backpressure_backstop_low_multiplier)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Positive but improperly ordered backstop multipliers can cause HIGH traffic to be rejected before LOW traffic under queue pressure. Cross-field validation could preserve the intended priority relationship (high_multiplier >= 1 >= low_multiplier, or an equivalent high-capacity ordering).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/api/admission/factory.py, line 58:

<comment>Positive but improperly ordered backstop multipliers can cause HIGH traffic to be rejected before LOW traffic under queue pressure. Cross-field validation could preserve the intended priority relationship (`high_multiplier >= 1 >= low_multiplier`, or an equivalent high-capacity ordering).</comment>

<file context>
@@ -0,0 +1,99 @@
+    backstop_max_waiters = {
+        Priority.HIGH: max(1, int(base_backstop * settings.api.backpressure_backstop_high_multiplier)),
+        Priority.MEDIUM: base_backstop,
+        Priority.LOW: max(1, int(base_backstop * settings.api.backpressure_backstop_low_multiplier)),
+    }
+    stress_thresholds = {
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed with new setting validation

}
stress_thresholds = {
Priority.HIGH: settings.api.backpressure_shed_high_stress_ratio,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A valid-looking settings override can reverse priority shedding: HIGH requests begin shedding at a lower stress ratio than LOW requests when the thresholds are not ordered. Cross-field validation could enforce LOW ≤ MEDIUM ≤ HIGH before this map is constructed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/api/admission/factory.py, line 61:

<comment>A valid-looking settings override can reverse priority shedding: HIGH requests begin shedding at a lower stress ratio than LOW requests when the thresholds are not ordered. Cross-field validation could enforce LOW ≤ MEDIUM ≤ HIGH before this map is constructed.</comment>

<file context>
@@ -0,0 +1,99 @@
+        Priority.LOW: max(1, int(base_backstop * settings.api.backpressure_backstop_low_multiplier)),
+    }
+    stress_thresholds = {
+        Priority.HIGH: settings.api.backpressure_shed_high_stress_ratio,
+        Priority.MEDIUM: settings.api.backpressure_shed_medium_stress_ratio,
+        Priority.LOW: settings.api.backpressure_shed_low_stress_ratio,
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed with new setting validation and tests

Priority.MEDIUM: settings.api.backpressure_shed_medium_stress_ratio,
Priority.LOW: settings.api.backpressure_shed_low_stress_ratio,
}
# HIGH gets a larger effective target so it sheds last; MEDIUM and LOW share the base target.
codel = {
Priority.HIGH: CoDelController(
target=settings.api.backpressure_codel_target_seconds * settings.api.backpressure_high_target_multiplier,
interval=settings.api.backpressure_codel_interval_seconds,
),
Priority.MEDIUM: CoDelController(
target=settings.api.backpressure_codel_target_seconds,
interval=settings.api.backpressure_codel_interval_seconds,
),
Priority.LOW: CoDelController(
target=settings.api.backpressure_codel_target_seconds,
interval=settings.api.backpressure_codel_interval_seconds,
),
}
retry_policy = RetryAfterPolicy(
observers=retry_policy_observers,
level1_seconds=settings.api.backpressure_retry_after_level1_seconds,
level2_seconds=settings.api.backpressure_retry_after_level2_seconds,
level3_seconds=settings.api.backpressure_retry_after_level3_seconds,
max_seconds=settings.api.backpressure_retry_after_max_seconds,
significant_load_ratio=settings.api.backpressure_significant_load_stress_ratio,
sustained_warn_seconds=settings.api.backpressure_sustained_load_warn_seconds,
sustained_high_seconds=settings.api.backpressure_sustained_load_high_seconds,
)
return AdmissionController(
slot_pool=slot_pool,
codel_priority_map=codel,
backstop_max_waiters=backstop_max_waiters,
stress_signal=tracker,
stress_thresholds=stress_thresholds,
stress_min_samples=settings.api.backpressure_stress_min_samples,
retry_policy=retry_policy,
observers=admission_observers,
)
Loading
Loading