Dynamic Backend Profiles - Architecture¶
Architecture overview of the dynamic profile registration system for backend configurations.
Overview¶
The dynamic profile system replaces static config.json files with a database-backed, multi-tenant configuration management system. This enables:
- Runtime profile creation without code deployment
- Multi-tenant isolation for SaaS deployments
- Concurrent access from every worker process and replica, with compare-and-set writes
- Version control for configuration changes
- Schema deployment automation via API
Architecture Diagram¶
flowchart TB
subgraph Client["<span style='color:#000'><b>Client Layer</b></span>"]
WebClient["<span style='color:#000'>Web client</span>"]
APIClients["<span style='color:#000'>REST API Clients</span>"]
end
subgraph Runtime["<span style='color:#000'><b>FastAPI Runtime Layer</b></span>"]
AdminRouter["<span style='color:#000'>Admin Router</span>"]
Validator["<span style='color:#000'>ProfileValidator</span>"]
end
subgraph Config["<span style='color:#000'><b>Configuration Layer</b></span>"]
ConfigMgr["<span style='color:#000'>ConfigManager</span>"]
BackendCfg["<span style='color:#000'>BackendConfig</span>"]
end
subgraph Persistence["<span style='color:#000'><b>Persistence Layer</b></span>"]
Store["<span style='color:#000'>ConfigStore Interface</span>"]
DB["<span style='color:#000'>Database</span>"]
end
WebClient -->|HTTP| AdminRouter
APIClients -->|HTTP| AdminRouter
AdminRouter --> Validator
AdminRouter --> ConfigMgr
ConfigMgr --> BackendCfg
ConfigMgr -->|Store Interface| Store
Store --> DB
style Client fill:#90caf9,stroke:#1565c0,color:#000
style Runtime fill:#ce93d8,stroke:#7b1fa2,color:#000
style Config fill:#ffcc80,stroke:#ef6c00,color:#000
style Persistence fill:#a5d6a7,stroke:#388e3c,color:#000
style WebClient fill:#64b5f6,stroke:#1565c0,color:#000
style APIClients fill:#64b5f6,stroke:#1565c0,color:#000
style AdminRouter fill:#ba68c8,stroke:#7b1fa2,color:#000
style Validator fill:#ba68c8,stroke:#7b1fa2,color:#000
style ConfigMgr fill:#ffb74d,stroke:#ef6c00,color:#000
style BackendCfg fill:#ffb74d,stroke:#ef6c00,color:#000
style Store fill:#81c784,stroke:#388e3c,color:#000
style DB fill:#81c784,stroke:#388e3c,color:#000
linkStyle 2,4,6 stroke:#000,stroke-width:2px Component Details¶
1. FastAPI Runtime Layer¶
Location: libs/runtime/cogniverse_runtime/routers/admin.py
Responsibilities:
- HTTP endpoint handling
- Request validation via Pydantic models
- Profile validation via ProfileValidator
- Schema deployment coordination
- Error handling and HTTP responses
Key Endpoints:
POST /admin/profiles- Create profileGET /admin/profiles- List profiles for tenantGET /admin/profiles/{profile_name}- Get profile detailsPUT /admin/profiles/{profile_name}- Update mutable fieldsDELETE /admin/profiles/{profile_name}- Delete profilePOST /admin/profiles/{profile_name}/deploy- Deploy schema
Dependencies:
- ConfigManager (injected via FastAPI Depends)
- ProfileValidator (injected via FastAPI Depends)
- SchemaLoader (for deployment)
2. Configuration Layer¶
Location: libs/foundation/cogniverse_foundation/config/manager.py
ConfigManager:
Centralized configuration manager with:
- Multi-tenant support
- Thread-safe operations
- Profile CRUD operations
- Deep merge for tenant overrides
Concurrent writes:
class ConfigManager:
def __init__(
self,
store: ConfigStore,
scoped_config_refresh_s: float = 5.0,
scoped_config_max_staleness_s: float = 60.0,
system_config_refresh_s: float = 5.0,
system_config_max_staleness_s: float = 60.0,
):
if store is None:
raise ValueError("store is required")
self.store = store
def add_backend_profile(self, profile, tenant_id=None, service="backend", *, replace=True):
tenant_id = require_tenant_id(tenant_id, source="ConfigManager.add_backend_profile")
def add(backend_config):
if not replace and profile.profile_name in backend_config.profiles:
raise BackendProfileExistsError(...)
backend_config.add_profile(profile)
# Compare-and-set read-modify-write of the stored backend config.
self._update_backend_config(tenant_id, service, add)
Why compare-and-set?
Every runtime worker process and replica writes the same per-tenant backend config, and without a conditional write concurrent changes race:
# Process 1: Reads config (profiles: A, B)
# Process 2: Reads config (profiles: A, B)
# Process 1: Adds profile C → Writes (profiles: A, B, C)
# Process 2: Adds profile D → Writes (profiles: A, B, D)
# Result: Profile C is LOST!
_update_backend_config runs the change through ConfigStore.update_config, which writes with compare_and_set_config against the version it read:
- Process 1 and process 2 both read version n (profiles: A, B)
- Process 1 writes version n + 1 (profiles: A, B, C)
- Process 2's write of version n + 1 is refused
- Process 2 re-reads version n + 1 and re-applies its change
- Process 2 writes version n + 2 (profiles: A, B, C, D)
- Both profiles persist correctly
The read goes to the store, not the scoped-config cache. A change that leaves the stored config as it was writes nothing. A writer that loses every attempt raises ConfigWriteConflictError and writes nothing.
How a write reaches searches: the store is the only copy. The shared search backend resolves the querying tenant's profiles per request through get_config (the shipped catalog, the system tenant's stored profiles and the tenant's own) and merges them into a local copy of the profiles it was built with, so one tenant's profile is never visible to another. The writing process's ConfigManager drops its held copy on the write, so its next search sees the change at once; every other process and replica sees it within scoped_config_max_staleness_s (60 s by default; about scoped_config_refresh_s, 5 s, for a tenant searched continuously).
BackendConfig:
Dataclass representing tenant's backend configuration:
@dataclass
class BackendConfig:
tenant_id: Optional[str] = None
backend_type: str = "vespa"
url: str = "http://localhost"
port: int = 8080
profiles: Dict[str, BackendProfileConfig] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
# tenant_id is required; raises via require_tenant_id if omitted
require_tenant_id(self.tenant_id, source="BackendConfig")
def add_profile(self, profile: BackendProfileConfig):
"""Add or replace profile"""
self.profiles[profile.profile_name] = profile
def merge_profile(self, profile_name: str, overrides: Dict[str, Any]) -> BackendProfileConfig:
"""Deep merge overrides into existing profile"""
base_profile = self.profiles.get(profile_name)
if not base_profile:
raise ValueError(f"Base profile '{profile_name}' not found")
merged = base_profile.to_dict()
self._deep_merge(merged, overrides)
return BackendProfileConfig.from_dict(profile_name, merged)
3. Persistence Layer¶
- Interface:
libs/sdk/cogniverse_sdk/interfaces/config_store.py - Example Implementation:
libs/vespa/cogniverse_vespa/config/config_store.py
ConfigStore Interface:
Backend-agnostic configuration storage with:
- Multi-tenant isolation
- Version tracking
- Concurrent access support
- Bounded version history (old versions pruned on write)
Example: VespaConfigStore:
class VespaConfigStore(ConfigStore):
"""Vespa-based configuration store with multi-tenant support."""
def __init__(
self,
vespa_app: Optional[Vespa] = None,
backend_url: str = "http://localhost",
backend_port: int = 8080,
schema_name: str = "config_metadata",
keep_versions: int = 10,
):
if vespa_app is not None:
self.vespa_app = vespa_app
else:
# Persistent session: config reads/writes are frequent and the
# store lives for the process — see _vespa_factory.py in the
# Backends Module doc for why this isn't plain make_vespa_app.
self.vespa_app = make_persistent_vespa_ops(url=backend_url, port=backend_port)
self.schema_name = schema_name
self.keep_versions = max(1, keep_versions)
def _get_latest_version(self, tenant_id, scope, service, config_key) -> int:
"""Get latest version number for a config"""
config_id = f"{tenant_id}:{scope.value}:{service}:{config_key}"
# yql_quote() escapes embedded quotes/backslashes before interpolation
yql = (
f"select version from {self.schema_name} "
f"where config_id contains {yql_quote(config_id)} "
f"order by version desc limit 1"
)
response = self.vespa_app.query(yql=yql)
if response.hits and len(response.hits) > 0:
return response.hits[0]["fields"]["version"]
return 0
def set_config(self, tenant_id, scope, service, config_key, config_value) -> ConfigEntry:
"""Set config as Vespa document with versioning"""
current_version = self._get_latest_version(tenant_id, scope, service, config_key)
new_version = current_version + 1
now = datetime.now()
entry = ConfigEntry(
tenant_id=tenant_id, scope=scope, service=service, config_key=config_key,
config_value=config_value, version=new_version, created_at=now, updated_at=now,
)
config_id = f"{tenant_id}:{scope.value}:{service}:{config_key}"
doc_id = f"{self.schema_name}::{config_id}::{new_version}"
self.vespa_app.feed_data_point(
schema=self.schema_name,
data_id=doc_id,
fields={
"config_id": config_id,
"tenant_id": tenant_id,
"scope": scope.value,
"service": service,
"config_key": config_key,
"config_value": json.dumps(config_value),
"version": new_version,
"created_at": entry.created_at.isoformat(),
"updated_at": entry.updated_at.isoformat(),
}
)
# Prune older versions beyond keep_versions so config_metadata
# doesn't grow unbounded across repeated updates
self._prune_old_versions(config_id, keep=self.keep_versions)
return entry
Backend Benefits:
- Unified storage with search backend
- No separate database to manage
- Same multi-tenant isolation as content
- Distributed and scalable
4. Validation Layer¶
Location: libs/core/cogniverse_core/validation/profile_validator.py
ProfileValidator:
Validates profile configurations against business rules:
class ProfileValidator:
def __init__(
self,
config_manager: "ConfigManager",
schema_templates_dir: Optional[Path] = None,
):
"""
Initialize ProfileValidator.
Args:
config_manager: ConfigManager instance for checking existing profiles
schema_templates_dir: Directory containing schema template JSON files
(defaults to configs/schemas/)
"""
self.config_manager = config_manager
self.schema_templates_dir = schema_templates_dir or Path("configs/schemas")
def validate_profile(
self, profile: BackendProfileConfig, tenant_id: str, is_update: bool = False
) -> List[str]:
"""Validate complete profile; the uniqueness check reads the tenant's
stored backend config, not the manager's held copy"""
errors = []
if not is_update:
errors.extend(self._validate_uniqueness(profile, tenant_id))
errors.extend(self._validate_profile_name(profile.profile_name))
errors.extend(self._validate_profile_type(profile.type))
errors.extend(self._validate_schema_template(profile.schema_name))
errors.extend(self._validate_embedding_model(profile.embedding_model))
errors.extend(self._validate_embedding_type(profile.embedding_type))
errors.extend(self._validate_strategies(profile.strategies))
errors.extend(self._validate_embedding_dimensions(profile))
return errors
def validate_update_fields(self, update_fields: dict) -> List[str]:
"""Validate that only mutable fields are being updated"""
immutable_fields = {
"schema_name", "embedding_model", "schema_config", "type", "model_loader",
}
errors = []
for field in immutable_fields:
if field in update_fields:
errors.append(
f"Field '{field}' cannot be updated. "
"Create a new profile instead for schema changes."
)
# Values of mutable fields are validated too, so an update can't write
# a malformed strategies block that create-time validation would reject
if "strategies" in update_fields:
errors.extend(self._validate_strategies(update_fields["strategies"]))
return errors
Validation Rules:
- Profile name: alphanumeric, underscore, and hyphen; max 100 chars; unique within tenant (create only)
- Profile type: enum (
video,image,audio,document,code) - Schema name: must exist in schema directory (
configs/schemas/{schema_name}_schema.json) withnameanddocument.fields - Embedding model: format
org/modelormodel-name(warns, does not reject, on unusual format) - Embedding type: enum (
multi_vector,single_vector) - Embedding dimensions:
schema_config.embedding_dim, if present, must be an integer in 1-100000 - Strategies: each entry must be a dict with a
classfield naming an importable class - Immutable on update:
schema_name,embedding_model,schema_config,type,model_loader
Data Flow¶
Profile Creation Flow¶
flowchart TB
User["<span style='color:#000'>1. User fills form - Web client</span>"]
API["<span style='color:#000'>2. POST /admin/profiles</span>"]
Router["<span style='color:#000'>3. Admin Router - Validate</span>"]
ConfigMgr["<span style='color:#000'>4. ConfigManager - Compare-and-set Read/Modify/Write</span>"]
Store["<span style='color:#000'>5. ConfigStore - Version/Persist</span>"]
Response["<span style='color:#000'>6. 201 Created</span>"]
Success["<span style='color:#000'>7. Success Message</span>"]
Search["<span style='color:#000'>Searches resolve the tenant's<br/>stored profiles per request</span>"]
User --> API
API --> Router
Router --> ConfigMgr
ConfigMgr --> Store
Store --> Response
Response --> Success
Store -.-> Search
style User fill:#90caf9,stroke:#1565c0,color:#000
style API fill:#90caf9,stroke:#1565c0,color:#000
style Router fill:#ce93d8,stroke:#7b1fa2,color:#000
style ConfigMgr fill:#ffcc80,stroke:#ef6c00,color:#000
style Store fill:#a5d6a7,stroke:#388e3c,color:#000
style Response fill:#ce93d8,stroke:#7b1fa2,color:#000
style Success fill:#90caf9,stroke:#1565c0,color:#000
style Search fill:#a5d6a7,stroke:#388e3c,color:#000
linkStyle 0,1,2,3,4,5,6 stroke:#000,stroke-width:2px Concurrent Updates Flow¶
sequenceDiagram
participant P1 as Process 1
participant Store as ConfigStore
participant P2 as Process 2
P1->>Store: Read config
Store-->>P1: Version 1
P2->>Store: Read config
Store-->>P2: Version 1
P1->>Store: compare_and_set(expected 1)
Store-->>P1: Version 2 written
P2->>Store: compare_and_set(expected 1)
Store-->>P2: Refused (version 2 stored)
P2->>Store: Re-read config, re-apply change
Store-->>P2: Version 2
P2->>Store: compare_and_set(expected 2)
Store-->>P2: Version 3 written Schema Deployment Flow¶
flowchart TB
Deploy["<span style='color:#000'>1. User clicks Deploy</span>"]
API["<span style='color:#000'>2. POST /admin/profiles/name/deploy</span>"]
Router["<span style='color:#000'>3. Admin Router - Get/Generate/Load</span>"]
Backend["<span style='color:#000'>4. Backend - Validate/Create/Index</span>"]
Result["<span style='color:#000'>5. Return Result</span>"]
UI["<span style='color:#000'>6. Update UI Status</span>"]
Deploy --> API
API --> Router
Router --> Backend
Backend --> Result
Result --> UI
style Deploy fill:#90caf9,stroke:#1565c0,color:#000
style API fill:#90caf9,stroke:#1565c0,color:#000
style Router fill:#ce93d8,stroke:#7b1fa2,color:#000
style Backend fill:#a5d6a7,stroke:#388e3c,color:#000
style Result fill:#ce93d8,stroke:#7b1fa2,color:#000
style UI fill:#90caf9,stroke:#1565c0,color:#000
linkStyle 0,1,2,3,4 stroke:#000,stroke-width:2px Multi-Tenant Isolation¶
Tenant Scoping¶
Every operation is scoped by tenant_id:
# Get profiles for tenant A
profiles_a = config_manager.list_backend_profiles(tenant_id="tenant_a")
# Get profiles for tenant B
profiles_b = config_manager.list_backend_profiles(tenant_id="tenant_b")
# No overlap - complete isolation
Backend-Level Isolation¶
All backend queries include tenant_id for isolation:
# Example: Vespa query with tenant filter
config = config_store.get_config(
tenant_id="tenant_a",
scope=ConfigScope.BACKEND,
service="backend",
config_key="backend_config"
)
# Only returns data for tenant_a
Schema Name Isolation¶
Deployed schemas include a tenant suffix. VespaSchemaManager.get_tenant_schema_name() first canonicalizes the tenant id to its org:tenant storage form (a simple id like acme becomes acme:acme) and then replaces : with _, so a simple-form tenant id is doubled in the suffix:
tenant_a → canonical tenant_a:tenant_a → profile: video_colpali → base schema: video_colpali_smol500_mv_frame → deployed: video_colpali_smol500_mv_frame_tenant_a_tenant_a
acme:prod → canonical acme:prod → profile: video_colpali → base schema: video_colpali_smol500_mv_frame → deployed: video_colpali_smol500_mv_frame_acme_prod
Tenant schema naming follows pattern: {base_schema_name}_{canonical_tenant_id.replace(':', '_')}
No naming conflicts in Vespa.
Version Control¶
Version Incrementing¶
Each set_config() creates a new version:
Version 1: Initial profile creation
Version 2: Update pipeline_config
Version 3: Update strategies
Version 4: Update description
Version History¶
Query historical versions via ConfigStore:
history = config_store.get_config_history(
tenant_id="acme",
scope=ConfigScope.BACKEND,
service="backend",
config_key="backend_config",
limit=10
)
Optimistic Concurrency¶
Admin router returns version in update response:
Clients can detect conflicts by comparing versions.
Deployment Architecture¶
Schema Templates¶
Schema templates stored on disk:
configs/schemas/
├── video_colpali_smol500_mv_frame_schema.json
├── video_colqwen_omni_mv_chunk_30s_schema.json
├── video_xclip_sv_chunk_6s_schema.json
├── ranking_strategies.json
└── ... (other schemas)
Template Loading¶
Location: libs/core/cogniverse_core/schemas/filesystem_loader.py
class FilesystemSchemaLoader(SchemaLoader):
"""
Load Vespa schemas from filesystem directory.
Inherits from SchemaLoader abstract interface defined in
libs/sdk/cogniverse_sdk/interfaces/schema_loader.py
"""
def __init__(self, base_path: Path):
"""Initialize with directory containing schema JSON files"""
self.base_path = Path(base_path)
def load_schema(self, schema_name: str) -> Dict[str, Any]:
"""Load schema template from disk"""
schema_file = self.base_path / f"{schema_name}_schema.json"
with open(schema_file, "r", encoding="utf-8") as f:
return json.load(f)
Tenant Schema Generation¶
Schema generation is handled by the backend's schema registry. The process:
- Load base schema template via
FilesystemSchemaLoader - Canonicalize
tenant_idand generate tenant-specific name:{base_schema_name}_{canonical_tenant_id.replace(':', '_')} - Apply any tenant-specific configurations
- Deploy to backend
Example flow (handled by schema registry and backend):
# Load base schema template
base_schema = schema_loader.load_schema(profile.schema_name)
# Generate tenant-specific name (in VespaSchemaManager.get_tenant_schema_name())
canonical = canonical_tenant_id(tenant_id) # "acme" -> "acme:acme"
tenant_schema_name = f"{base_schema_name}_{canonical.replace(':', '_')}"
# Deploy with tenant-specific naming (backend.deploy_schemas takes a list)
backend.deploy_schemas([{"name": tenant_schema_name, "definition": base_schema}])
Testing Strategy¶
Unit Tests¶
Locations: - tests/backends/unit/test_backend_config.py - ConfigManager backend methods, BackendConfig/BackendProfileConfig dataclass operations - tests/common/unit/test_profile_validator.py - ProfileValidator rules (name, type, schema template, embedding model/type/dimensions, strategies, uniqueness, update-field immutability)
Test individual components:
- ConfigManager methods
- ProfileValidator rules
- BackendConfig operations
Integration Tests¶
Multi-Tenant Tests (tests/admin/test_profile_multi_tenant.py):
- Tenant isolation when creating a profile with the same name in two tenants
- Cross-tenant access prevention on get/update/delete
- Empty
tenant_idrejected - Isolation persists across create/update/delete operations
Concurrent Tests (tests/admin/test_profile_concurrent.py):
- Concurrent profile creation
- Concurrent updates (version tracking)
- Concurrent same-profile-name creation across different tenants
- Concurrent reads while an update is in flight
- Concurrent list operations
- Concurrent delete operations
Test Fixtures¶
tests/admin/test_profile_api.py builds a minimal FastAPI app around the admin router with a real, isolated Vespa instance (no mocks at the storage boundary):
@pytest.fixture
def test_client(self, vespa_instance, temp_schema_dir: Path):
"""Create test client for profile API with isolated Vespa instance."""
from fastapi import FastAPI
from cogniverse_core.registries.backend_registry import BackendRegistry
from cogniverse_core.registries.schema_registry import SchemaRegistry
from cogniverse_core.schemas.filesystem_loader import FilesystemSchemaLoader
from cogniverse_foundation.config.manager import ConfigManager
from cogniverse_foundation.config.unified_config import SystemConfig
from cogniverse_runtime.routers import admin
from cogniverse_vespa.config.config_store import VespaConfigStore
BackendRegistry._instance = None
BackendRegistry._backend_instances.clear()
BackendRegistry._shared_schema_registry = None
SchemaRegistry._instance = None
# Config store pointing at the isolated Vespa instance (metadata schemas
# already deployed by the vespa_instance fixture)
store = VespaConfigStore(
backend_url="http://localhost", backend_port=vespa_instance["http_port"],
)
config_manager = ConfigManager(store=store)
config_manager.set_system_config(SystemConfig(backend_url="http://nonexistent", backend_port=9999))
schema_loader = FilesystemSchemaLoader(temp_schema_dir)
# Minimal app with just the admin router (no lifespan needed for CRUD tests)
test_app = FastAPI()
test_app.include_router(admin.router, prefix="/admin")
admin.set_config_manager(config_manager)
admin.set_schema_loader(schema_loader)
admin.set_profile_validator_schema_dir(temp_schema_dir)
try:
client = TestClient(test_app)
yield client
finally:
admin.reset_dependencies()
BackendRegistry._instance = None
BackendRegistry._backend_instances.clear()
BackendRegistry._shared_schema_registry = None
SchemaRegistry._instance = None
Performance Characteristics¶
Read Performance¶
- Cached reads: O(1) lookup in ConfigManager cache
- Uncached reads: Backend-dependent (e.g., Vespa index lookup)
- List operations: O(n) where n = number of profiles per tenant
Write Performance¶
- Single write: Backend-dependent; a Vespa profile write is a document read of the stored config, the compare-and-set's own read, a conditional
feed_data_point, a confirming read and a_prune_old_versionscleanup query, not benchmarked in this codebase - Concurrent writes: Compare-and-set across every process and replica; a writer that loses re-reads and retries with backoff, up to
CONFIG_UPDATE_MAX_ATTEMPTS - Version pruning overhead: Each
set_configcall also prunes versions beyondkeep_versions(default 10) for that config_id
Concurrency Limits¶
- Read throughput: Unlimited concurrent readers (backend-dependent)
- Write throughput: One committed write per backend-config version; concurrent writers to one tenant's config retry until theirs lands
Scaling Considerations¶
For high write concurrency:
- Use a distributed backend (e.g., Vespa cluster)
- Implement write batching
- Consider caching layer
Security Considerations¶
Tenant Isolation¶
- Strict
tenant_idvalidation - No cross-tenant access possible
- Database queries always filtered by tenant
Input Validation¶
- All request bodies validated via Pydantic models
- Profile names restricted to alphanumeric, underscore, and hyphen (max 100 chars)
- JSON fields validated for syntax
Query Safety¶
VespaConfigStorebuilds YQL query strings dynamically (there is no parameterized-query API in theConfigStoreinterface); every interpolated value is escaped withyql_quote()fromcogniverse_vespa._yqlbefore being embedded in thecontainsclause- Backend-specific security defaults
Future Enhancements¶
- API key authentication
- Role-based access control (RBAC)
- Audit logging for all operations
- Encryption at rest
Related Documentation¶
- Profile Management - User guide
- Profile API Reference - API docs
- Configuration System - Overall config architecture