mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
ConfigResolver converted the immutable global config with dataclasses.asdict() on every resolution, and config resolution is per-request, so a 400+ field deep walk landed on the hot path of every recall and every retain sub-batch. The conversion was only ever used as a shallow copy: the dict was spliced with the tenant/bank overrides and fed straight back into HindsightConfig(**dict). Resolve with copy.copy + setattr instead. That also drops the nine-field replace() that existed purely to undo asdict()'s recursion -- it flattened the nested member dataclasses (llm_members and friends) into plain dicts, which the old code then had to restore by hand. The two read paths get the same treatment: get_bank_config now projects the ~50 API-visible fields straight off the resolved object instead of materializing all 400+ and discarding most, and get_bank_configs builds its base from that same projection. Container values are copied on the way out, so a caller editing a returned list or dict still cannot reach the process-global config -- the property asdict()'s deep copy used to provide for free. Measured on the default config, per call: resolve_full_config 374.2us -> 3.5us (107x) get_bank_config 629.3us -> 10.9us (58x) get_bank_configs(x1) 254.4us -> 8.2us (31x) get_bank_configs(x50) 859.6us -> 38.6us (22x) Over 200 recall-shaped resolutions the Python-level call count drops from 3,856,401 to 42,801 (90x), with 256,800 deepcopy calls and 1.4M isinstance calls gone entirely.
This commit is contained in:
@@ -9,9 +9,10 @@ multiple API servers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass, fields, replace
|
||||
from dataclasses import dataclass, fields, replace
|
||||
from functools import lru_cache
|
||||
from types import UnionType
|
||||
from typing import TYPE_CHECKING, Any, Union, get_args, get_origin
|
||||
@@ -78,7 +79,21 @@ class ValidatedBankConfigUpdate:
|
||||
"""
|
||||
|
||||
updates: dict[str, Any]
|
||||
parent_config: dict[str, Any] | None = None
|
||||
parent_config: HindsightConfig | None = None
|
||||
|
||||
|
||||
def _detached(value: Any) -> Any:
|
||||
"""Copy container values on their way out of a config object.
|
||||
|
||||
Config reads used to go through ``asdict()``, which deep-copied everything, so a
|
||||
caller could edit the dict it got back. Reading fields straight off the object is
|
||||
~100x cheaper but hands out the process-global config's own lists and dicts, so
|
||||
copy those few (``retain_strategies``, ``memory_defense``, ``entity_labels``, ...).
|
||||
Scalars and strings are immutable and pass through untouched.
|
||||
"""
|
||||
if isinstance(value, dict | list):
|
||||
return copy.deepcopy(value)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_retain_strategy_chunking(base_config: HindsightConfig, strategies: Any) -> None:
|
||||
@@ -114,7 +129,7 @@ def _validate_retain_strategy_chunking(base_config: HindsightConfig, strategies:
|
||||
|
||||
|
||||
def _validate_projected_bank_config(
|
||||
parent_config: dict[str, Any],
|
||||
parent_config: HindsightConfig,
|
||||
current_overrides: dict[str, Any],
|
||||
normalized_updates: dict[str, Any],
|
||||
configurable_fields: set[str],
|
||||
@@ -134,7 +149,7 @@ def _validate_projected_bank_config(
|
||||
else:
|
||||
projected[key] = value
|
||||
|
||||
base_config = HindsightConfig(**{**parent_config, **projected})
|
||||
base_config = replace(parent_config, **projected) if projected else parent_config
|
||||
validate_retain_chunking_config(
|
||||
base_config.retain_chunk_size,
|
||||
base_config.retain_structured_chunk_size,
|
||||
@@ -162,26 +177,51 @@ class ConfigResolver:
|
||||
self._global_config = _get_raw_config()
|
||||
self._configurable_fields = HindsightConfig.get_configurable_fields()
|
||||
self._credential_fields = HindsightConfig.get_credential_fields()
|
||||
# The API-visible subset, resolved once: every config read filters to it.
|
||||
self._public_fields = frozenset(self._configurable_fields - self._credential_fields)
|
||||
|
||||
async def _resolve_parent_config_dict(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
|
||||
"""Resolve global + tenant config before bank-level overrides."""
|
||||
config_dict = asdict(self._global_config)
|
||||
async def _resolve_tenant_overrides(self, scope: str, context: RequestContext | None = None) -> dict[str, Any]:
|
||||
"""Tenant-level overrides to apply on top of the global config.
|
||||
|
||||
if self.tenant_extension and context:
|
||||
try:
|
||||
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
|
||||
if tenant_overrides:
|
||||
# Normalize keys and filter to configurable fields only
|
||||
normalized_tenant = normalize_config_dict(tenant_overrides)
|
||||
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
|
||||
config_dict.update(configurable_tenant)
|
||||
logger.debug(
|
||||
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
|
||||
Returns only the fields the tenant actually overrides — the global config is
|
||||
the base *object*, not a dict we rebuild per request. ``scope`` names the
|
||||
caller (a bank id, or a description of a bulk resolve) for log messages only;
|
||||
tenant config is per-request, not per-bank.
|
||||
"""
|
||||
if not (self.tenant_extension and context):
|
||||
return {}
|
||||
try:
|
||||
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load tenant config for {scope}: {e}")
|
||||
return {}
|
||||
if not tenant_overrides:
|
||||
return {}
|
||||
# Normalize keys and filter to configurable fields only
|
||||
normalized_tenant = normalize_config_dict(tenant_overrides)
|
||||
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
|
||||
if configurable_tenant:
|
||||
logger.debug(f"Applied tenant config overrides for {scope}: {list(configurable_tenant.keys())}")
|
||||
return configurable_tenant
|
||||
|
||||
return config_dict
|
||||
def _with_overrides(self, overrides: dict[str, Any]) -> HindsightConfig:
|
||||
"""Global config with ``overrides`` applied, as a fresh object.
|
||||
|
||||
``copy.copy`` rather than ``asdict()`` + ``HindsightConfig(**dict)``: the config
|
||||
has 400+ fields and resolution is per-request, so deep-walking the whole tree to
|
||||
produce what is only ever used as a shallow copy dominated the recall hot path
|
||||
(see #4209). A shallow copy is also *more* correct — ``asdict()`` flattened the
|
||||
nested member dataclasses (``llm_members`` and friends) into plain dicts, which
|
||||
the old code then had to restore field by field.
|
||||
|
||||
Safe because ``HindsightConfig`` is a plain non-frozen dataclass: no
|
||||
``__post_init__``, no ``init=False`` fields, no ``__slots__``, and no non-field
|
||||
instance attributes. A test pins those properties.
|
||||
"""
|
||||
resolved = copy.copy(self._global_config)
|
||||
for key, value in overrides.items():
|
||||
setattr(resolved, key, value)
|
||||
return resolved
|
||||
|
||||
async def resolve_full_config(
|
||||
self, bank_id: str, context: RequestContext | None = None, *, cached: bool = True
|
||||
@@ -204,33 +244,15 @@ class ConfigResolver:
|
||||
Returns:
|
||||
Complete HindsightConfig with hierarchical overrides applied
|
||||
"""
|
||||
config_dict = await self._resolve_parent_config_dict(bank_id, context)
|
||||
overrides = await self._resolve_tenant_overrides(f"bank {bank_id}", context)
|
||||
|
||||
# Load bank config overrides
|
||||
bank_overrides = await self._load_bank_config(bank_id, cached=cached)
|
||||
if bank_overrides:
|
||||
config_dict.update(bank_overrides)
|
||||
overrides.update(bank_overrides)
|
||||
logger.debug(f"Applied bank config overrides for bank {bank_id}: {list(bank_overrides.keys())}")
|
||||
|
||||
# Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object)
|
||||
# Create a new config instance by copying the global config and updating fields
|
||||
resolved_config = HindsightConfig(**config_dict)
|
||||
# Multi-LLM chains and the reranker failover chain are static credential fields
|
||||
# (never tenant/bank-overridable), but asdict() above flattened their member
|
||||
# dataclasses into plain dicts. Restore the original typed objects from the global
|
||||
# config so the resolved object stays well-typed for any consumer that reads them.
|
||||
resolved_config = replace(
|
||||
resolved_config,
|
||||
reranker_members=self._global_config.reranker_members,
|
||||
llm_members=self._global_config.llm_members,
|
||||
llm_strategy=self._global_config.llm_strategy,
|
||||
retain_llm_members=self._global_config.retain_llm_members,
|
||||
retain_llm_strategy=self._global_config.retain_llm_strategy,
|
||||
reflect_llm_members=self._global_config.reflect_llm_members,
|
||||
reflect_llm_strategy=self._global_config.reflect_llm_strategy,
|
||||
consolidation_llm_members=self._global_config.consolidation_llm_members,
|
||||
consolidation_llm_strategy=self._global_config.consolidation_llm_strategy,
|
||||
)
|
||||
resolved_config = self._with_overrides(overrides)
|
||||
validate_retain_chunking_config(
|
||||
resolved_config.retain_chunk_size,
|
||||
resolved_config.retain_structured_chunk_size,
|
||||
@@ -275,21 +297,29 @@ class ConfigResolver:
|
||||
"""
|
||||
# Resolve full config with all hierarchical overrides
|
||||
resolved_config = await self.resolve_full_config(bank_id, context, cached=cached)
|
||||
config_dict = asdict(resolved_config)
|
||||
|
||||
# SECURITY: drop static/infrastructure + credential fields, then permission-filter.
|
||||
filtered = self._strip_static_and_credential_fields(config_dict)
|
||||
filtered = self._public_fields_of(resolved_config)
|
||||
return await self._apply_permission_filter(filtered, bank_id, context)
|
||||
|
||||
def _strip_static_and_credential_fields(self, config_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Keep only configurable, non-credential fields.
|
||||
def _public_fields_of(self, config: HindsightConfig) -> dict[str, Any]:
|
||||
"""Project a resolved config down to its configurable, non-credential fields.
|
||||
|
||||
Reads the ~50 public fields straight off the object instead of materializing
|
||||
all 400+ with ``asdict()`` and throwing most away (#4209).
|
||||
|
||||
SECURITY: excludes static/infrastructure fields and ALL credential fields
|
||||
(API keys, base URLs, etc.) so a resolved config is safe to return over the API.
|
||||
"""
|
||||
return {
|
||||
k: v for k, v in config_dict.items() if k in self._configurable_fields and k not in self._credential_fields
|
||||
}
|
||||
return {name: _detached(getattr(config, name)) for name in self._public_fields}
|
||||
|
||||
def _strip_static_and_credential_fields(self, config_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Keep only configurable, non-credential fields of an already-dict config.
|
||||
|
||||
SECURITY: same allow-list as :meth:`_public_fields_of`, for the paths that
|
||||
merge plain override dicts rather than resolved config objects.
|
||||
"""
|
||||
return {k: v for k, v in config_dict.items() if k in self._public_fields}
|
||||
|
||||
async def _apply_permission_filter(
|
||||
self, filtered: dict[str, Any], bank_id: str, context: RequestContext | None
|
||||
@@ -330,20 +360,17 @@ class ConfigResolver:
|
||||
return {}
|
||||
|
||||
# Global + tenant base, resolved once (tenant override is per-request, not per-bank).
|
||||
base_dict = asdict(self._global_config)
|
||||
if self.tenant_extension and context:
|
||||
try:
|
||||
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
|
||||
if tenant_overrides:
|
||||
normalized_tenant = normalize_config_dict(tenant_overrides)
|
||||
base_dict.update({k: v for k, v in normalized_tenant.items() if k in self._configurable_fields})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load tenant config for bulk resolve: {e}")
|
||||
base_dict = self._public_fields_of(self._global_config)
|
||||
tenant_base = await self._resolve_tenant_overrides("bulk resolve", context)
|
||||
base_dict.update(self._strip_static_and_credential_fields(tenant_base))
|
||||
|
||||
# All bank overrides in one query, then merge + strip per bank.
|
||||
bank_overrides = await self._load_bank_configs(bank_ids)
|
||||
stripped = {
|
||||
bank_id: self._strip_static_and_credential_fields({**base_dict, **bank_overrides.get(bank_id, {})})
|
||||
bank_id: {
|
||||
**base_dict,
|
||||
**self._strip_static_and_credential_fields(bank_overrides.get(bank_id, {})),
|
||||
}
|
||||
for bank_id in bank_ids
|
||||
}
|
||||
|
||||
@@ -580,9 +607,9 @@ class ConfigResolver:
|
||||
# bad update is rejected before the bank is created; _persist_bank_config
|
||||
# repeats it against the committed state under the bank row lock, which
|
||||
# is what makes the result independent of request interleaving.
|
||||
parent_config: dict[str, Any] | None = None
|
||||
parent_config: HindsightConfig | None = None
|
||||
if not _CROSS_FIELD_CONSTRAINED_FIELDS.isdisjoint(normalized_updates):
|
||||
parent_config = await self._resolve_parent_config_dict(bank_id, context)
|
||||
parent_config = self._with_overrides(await self._resolve_tenant_overrides(f"bank {bank_id}", context))
|
||||
current_overrides = (
|
||||
await self._load_bank_config(bank_id)
|
||||
if projected_bank_overrides is None
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Shape and cost guarantees for ConfigResolver's per-request resolution.
|
||||
|
||||
Resolution runs on every recall and every retain sub-batch, so how it builds the
|
||||
resolved config matters as much as what it resolves. These tests pin the
|
||||
properties the cheap path depends on (#4209): the config is shallow-copyable,
|
||||
the copy is indistinguishable from the dict round-trip it replaced, typed member
|
||||
dataclasses survive it, and a caller still cannot reach into the process-global
|
||||
config through a value it was handed.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
from dataclasses import asdict, replace
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.config import HindsightConfig, LLMMemberConfig, LLMStrategyConfig, _get_raw_config
|
||||
from hindsight_api.config_resolver import ConfigResolver
|
||||
|
||||
from .test_hierarchical_config import FakeBankConfigBackend, FakeBankConfigConnection, MockTenantExtension
|
||||
|
||||
BANK = "test-config-shape-bank"
|
||||
|
||||
|
||||
class _BulkCapableBackend(FakeBankConfigBackend):
|
||||
"""FakeBankConfigBackend plus the ``fetch`` the bulk path needs."""
|
||||
|
||||
def acquire(self):
|
||||
return _BulkCapableConnection(self)
|
||||
|
||||
def transaction(self):
|
||||
return _BulkCapableConnection(self)
|
||||
|
||||
|
||||
class _BulkCapableConnection(FakeBankConfigConnection):
|
||||
async def fetch(self, query, bank_ids):
|
||||
return [{"bank_id": bank_id, "config": self.backend.config} for bank_id in bank_ids]
|
||||
|
||||
|
||||
def _resolver(**kwargs) -> ConfigResolver:
|
||||
return ConfigResolver(backend=FakeBankConfigBackend(), **kwargs)
|
||||
|
||||
|
||||
def test_hindsight_config_is_safely_shallow_copyable():
|
||||
"""Pin the dataclass properties ``_with_overrides`` relies on.
|
||||
|
||||
``copy.copy`` is only interchangeable with a full reconstruction while the
|
||||
config has no ``__post_init__``, no ``init=False`` fields, no ``__slots__``,
|
||||
and no instance state outside its fields. Adding any of those to
|
||||
HindsightConfig would silently change what resolution returns, so fail here
|
||||
rather than in production.
|
||||
"""
|
||||
config = _get_raw_config()
|
||||
|
||||
assert not hasattr(config, "__post_init__"), "a __post_init__ would be skipped by copy.copy"
|
||||
assert not hasattr(HindsightConfig, "__slots__"), "__slots__ would break vars()-based copying"
|
||||
assert [f.name for f in dataclasses.fields(config) if not f.init] == [], (
|
||||
"an init=False field cannot be round-tripped through the constructor"
|
||||
)
|
||||
assert set(vars(config)) == {f.name for f in dataclasses.fields(config)}, (
|
||||
"non-field instance attributes would not survive a reconstruction"
|
||||
)
|
||||
assert copy.copy(config) == replace(config)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolved_config_matches_dict_roundtrip():
|
||||
"""The copy-based resolution returns exactly what the asdict() one did.
|
||||
|
||||
The reference below is the pre-#4209 construction: flatten the global config
|
||||
with asdict(), splice the overrides in, rebuild, then restore the member
|
||||
dataclasses asdict() had turned into plain dicts.
|
||||
"""
|
||||
tenant = MockTenantExtension({"retain_extraction_mode": "tenant-mode", "retain_chunk_size": 5000})
|
||||
resolver = ConfigResolver(backend=FakeBankConfigBackend(), tenant_extension=tenant)
|
||||
global_config = resolver._global_config
|
||||
context = _context()
|
||||
|
||||
for overrides in ({}, {"retain_chunk_size": 2000, "enable_observations": False}):
|
||||
resolver._backend.config = dict(overrides)
|
||||
|
||||
resolved = await resolver.resolve_full_config(BANK, context, cached=False)
|
||||
|
||||
merged = asdict(global_config)
|
||||
merged.update({"retain_extraction_mode": "tenant-mode", "retain_chunk_size": 5000})
|
||||
merged.update(overrides)
|
||||
expected = replace(
|
||||
HindsightConfig(**merged),
|
||||
reranker_members=global_config.reranker_members,
|
||||
llm_members=global_config.llm_members,
|
||||
llm_strategy=global_config.llm_strategy,
|
||||
retain_llm_members=global_config.retain_llm_members,
|
||||
retain_llm_strategy=global_config.retain_llm_strategy,
|
||||
reflect_llm_members=global_config.reflect_llm_members,
|
||||
reflect_llm_strategy=global_config.reflect_llm_strategy,
|
||||
consolidation_llm_members=global_config.consolidation_llm_members,
|
||||
consolidation_llm_strategy=global_config.consolidation_llm_strategy,
|
||||
)
|
||||
assert resolved == expected
|
||||
assert resolved is not global_config, "resolution must not hand out the process-global config"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolution_preserves_typed_member_dataclasses():
|
||||
"""Multi-LLM members stay dataclasses, not the dicts asdict() flattened them into."""
|
||||
resolver = _resolver()
|
||||
member = LLMMemberConfig(
|
||||
provider="openai",
|
||||
api_key="k",
|
||||
model="gpt-4",
|
||||
base_url=None,
|
||||
reasoning_effort=None,
|
||||
extra_body=None,
|
||||
default_headers=None,
|
||||
bedrock_service_tier=None,
|
||||
gemini_service_tier=None,
|
||||
)
|
||||
strategy = LLMStrategyConfig(mode="failover")
|
||||
resolver._global_config = replace(resolver._global_config, llm_members=[member], llm_strategy=strategy)
|
||||
|
||||
resolved = await resolver.resolve_full_config(BANK, cached=False)
|
||||
|
||||
assert resolved.llm_members == [member]
|
||||
assert isinstance(resolved.llm_members[0], LLMMemberConfig)
|
||||
assert isinstance(resolved.llm_strategy, LLMStrategyConfig)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolution_does_not_mutate_the_global_config():
|
||||
"""Overrides land on the copy, never on the shared global object."""
|
||||
resolver = _resolver()
|
||||
before = replace(resolver._global_config)
|
||||
resolver._backend.config = {"retain_chunk_size": before.retain_chunk_size + 111}
|
||||
|
||||
resolved = await resolver.resolve_full_config(BANK, cached=False)
|
||||
|
||||
assert resolved.retain_chunk_size == before.retain_chunk_size + 111
|
||||
assert resolver._global_config == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bank_config_response_containers_are_detached():
|
||||
"""A caller editing a returned container must not reach the global config.
|
||||
|
||||
Config reads used to go through asdict(), which deep-copied every value.
|
||||
Reading fields off the object is far cheaper but would otherwise hand out the
|
||||
process-global config's own dicts and lists.
|
||||
"""
|
||||
resolver = _resolver()
|
||||
resolver._global_config = replace(resolver._global_config, retain_strategies={"fast": {"retain_chunk_size": 900}})
|
||||
|
||||
config = await resolver.get_bank_config(BANK, cached=False)
|
||||
config["retain_strategies"]["fast"]["retain_chunk_size"] = 1
|
||||
config["retain_strategies"]["injected"] = {}
|
||||
|
||||
assert resolver._global_config.retain_strategies == {"fast": {"retain_chunk_size": 900}}
|
||||
second = await resolver.get_bank_config(BANK, cached=False)
|
||||
assert second["retain_strategies"] == {"fast": {"retain_chunk_size": 900}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bank_config_excludes_static_and_credential_fields():
|
||||
"""The public projection is exactly configurable-minus-credential fields."""
|
||||
resolver = _resolver()
|
||||
|
||||
config = await resolver.get_bank_config(BANK, cached=False)
|
||||
|
||||
configurable = HindsightConfig.get_configurable_fields()
|
||||
credentials = HindsightConfig.get_credential_fields()
|
||||
assert set(config) == configurable - credentials
|
||||
assert not set(config) & credentials
|
||||
assert "database_url" not in config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_and_single_bank_config_agree():
|
||||
"""get_bank_configs is the batched form of get_bank_config, including tenant overrides."""
|
||||
tenant = MockTenantExtension({"retain_chunk_size": 5000})
|
||||
resolver = ConfigResolver(backend=_BulkCapableBackend(), tenant_extension=tenant)
|
||||
resolver._backend.config = {"enable_observations": False}
|
||||
context = _context()
|
||||
|
||||
single = await resolver.get_bank_config(BANK, context, cached=False)
|
||||
bulk = await resolver.get_bank_configs([BANK], context)
|
||||
|
||||
assert bulk[BANK] == single
|
||||
assert single["retain_chunk_size"] == 5000
|
||||
assert single["enable_observations"] is False
|
||||
|
||||
|
||||
def _context():
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
return RequestContext(api_key=None, api_key_id=None, tenant_id=None, internal=False)
|
||||
Reference in New Issue
Block a user