fleet-memory/hindsight-api-slim/tests/test_schema_isolation.py
Nicolò Boschi 15ea23d5d6
feat: introduce hindsight-api-slim and hindsight-all-slim packages (#560)
* feat: introduce hindsight-api-slim and hindsight-all-slim packages

Closes #552

- Move all source code from hindsight-api/ to new hindsight-api-slim/
- hindsight-api-slim has heavy ML deps (torch, sentence-transformers,
  transformers, einops, flashrank, mlx, mlx-lm, safetensors) and
  pg0-embedded as optional extras: [local-ml], [embedded-db], [all]
- hindsight-api becomes a zero-code meta-package depending on
  hindsight-api-slim[all] for full backward compatibility
- Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed
- hindsight-all updated to depend on hindsight-api-slim[all]
- pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db]
- Dockerfile: replace sed hack with proper uv sync --extra flags
- Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and
  all path references throughout the repo

* refactor: rename hindsight/ directory to hindsight-all/

* docs: document hindsight-api-slim and hindsight-all-slim package variants

Add package variants table and extras explanation to installation.md

* docs: remove emojis from installation.md, use professional tone

* docs: link Docker slim variant to pip package variants section

* docs: consolidate Docker image variants into single table

* ci: fix working-directory paths after package restructure

- Replace all hindsight-api → hindsight-api-slim in test.yml
- Replace hindsight → hindsight-all in test.yml
- Add --extra embedded-db to test-embed API install step

* ci: add local-ml and embedded-db extras to API sync steps

These extras were previously implicit in the old hindsight-api package
(which bundled everything). Now that hindsight-api-slim uses optional
extras, we must explicitly request local-ml and embedded-db in CI.

* ci: add API install step with embedded-db to test-embed smoke test

The smoke test starts hindsight-api as a daemon, which requires pg0-embedded.
Add a dedicated install step for hindsight-api-slim with embedded-db extra
so the daemon can start successfully.

* ci: remove --no-install-project when using optional extras

When --no-install-project is combined with --extra, the optional deps
are not installed because extras require the project to be active.
Remove --no-install-project from steps that need local-ml or embedded-db.

* ci: fix ordering of uv sync steps to preserve optional extras

When uv sync runs for a different workspace member, it removes optional
extras installed for other members. Fix by always running extra-requiring
API sync last, after other workspace member syncs.

Also remove --no-install-project from embedded-db sync in test-embed,
as --no-install-project prevents optional extras from being active.

* ci: add local-ml extra to test-embed API install for smoke test

The smoke test starts the full API server which needs sentence-transformers
for local embeddings (default provider). Add local-ml extra to the install.

* ci: simplify extras with --all-extras and add slim pip smoke test

- Replace explicit --extra local-ml --extra embedded-db with --all-extras
  for cleaner, more maintainable sync steps
- Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without
  local ML models, using Cohere for embeddings/reranking (mirrors Docker
  slim smoke test approach)

* ci: simplify slim smoke test to health check only (mirrors Docker test)
2026-03-13 13:50:03 +01:00

411 lines
15 KiB
Python

"""
Tests for multi-tenant schema isolation.
Verifies that concurrent retain operations from different tenants
are properly isolated in their respective PostgreSQL schemas.
"""
import asyncio
import uuid
import pytest
import pytest_asyncio
from hindsight_api.engine.memory_engine import _current_schema, fq_table
from hindsight_api.extensions import RequestContext, TenantContext, TenantExtension
from hindsight_api.migrations import run_migrations
class MultiSchemaTestTenantExtension(TenantExtension):
"""
Test tenant extension that maps API keys to schema names.
API keys are in format: "key-{schema_name}"
Provisions schemas on first access using run_migrations(schema=name).
"""
def __init__(self, config: dict):
super().__init__(config)
self.db_url = config.get("db_url")
# Pre-configured valid schemas for test
self.valid_schemas = config.get("valid_schemas", set())
# Track provisioned schemas
self._provisioned: set[str] = set()
async def authenticate(self, context: RequestContext) -> TenantContext:
if not context.api_key:
from hindsight_api.extensions import AuthenticationError
raise AuthenticationError("API key required")
# Parse schema from API key (format: "key-{schema}")
if context.api_key.startswith("key-"):
schema = context.api_key[4:] # Remove "key-" prefix
if schema in self.valid_schemas:
# Provision schema on first access
if schema not in self._provisioned and self.db_url:
run_migrations(self.db_url, schema=schema)
self._provisioned.add(schema)
return TenantContext(schema_name=schema)
from hindsight_api.extensions import AuthenticationError
raise AuthenticationError(f"Unknown API key: {context.api_key}")
async def list_tenants(self) -> list:
from hindsight_api.extensions.tenant import Tenant
return [Tenant(schema=schema) for schema in self.valid_schemas]
async def drop_schema(conn, schema_name: str) -> None:
"""Drop a schema and all its contents."""
await conn.execute(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')
async def count_memories_in_schema(conn, schema_name: str, bank_id: str) -> int:
"""Count memory units in a specific schema for a bank."""
result = await conn.fetchval(
f'SELECT COUNT(*) FROM "{schema_name}".memory_units WHERE bank_id = $1',
bank_id,
)
return result or 0
async def get_memory_texts_in_schema(conn, schema_name: str, bank_id: str) -> list[str]:
"""Get all memory texts in a specific schema for a bank."""
rows = await conn.fetch(
f'SELECT text FROM "{schema_name}".memory_units WHERE bank_id = $1 ORDER BY text',
bank_id,
)
return [row["text"] for row in rows]
class TestSchemaIsolation:
"""Tests for multi-tenant schema isolation."""
@pytest.mark.asyncio
async def test_concurrent_inserts_isolated_by_schema(self, memory, pg0_db_url):
"""
Multiple concurrent database operations from different tenants
should store data in their respective schemas without cross-contamination.
Uses run_migrations(schema=x) to provision schemas like a real extension.
"""
import asyncpg
# Test schemas
schemas = ["tenant_alpha", "tenant_beta", "tenant_gamma"]
bank_id = f"test-isolation-{uuid.uuid4().hex[:8]}"
# Clean up any existing schemas
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
await drop_schema(conn, schema)
finally:
await conn.close()
# Configure tenant extension that provisions schemas via run_migrations
tenant_ext = MultiSchemaTestTenantExtension({
"db_url": pg0_db_url,
"valid_schemas": set(schemas),
})
memory._tenant_extension = tenant_ext
# Define concurrent insert tasks for each tenant
async def insert_for_tenant(schema_name: str, content_prefix: str):
"""Insert memories for a specific tenant using schema context."""
# Authenticate to set the schema context
tenant_request = RequestContext(api_key=f"key-{schema_name}")
await memory._authenticate_tenant(tenant_request)
# Now fq_table will use the correct schema
pool = await memory._get_pool()
from hindsight_api.engine.db_utils import acquire_with_retry
async with acquire_with_retry(pool) as conn:
# Insert 3 memories for this tenant
for i in range(3):
await conn.execute(
f"""
INSERT INTO {fq_table('memory_units')} (bank_id, text, event_date, fact_type)
VALUES ($1, $2, now(), 'world')
""",
bank_id,
f"MARKER_{content_prefix}_DOC{i}: Memory for {schema_name}",
)
# Run concurrent inserts for all tenants
await asyncio.gather(
insert_for_tenant("tenant_alpha", "ALPHA"),
insert_for_tenant("tenant_beta", "BETA"),
insert_for_tenant("tenant_gamma", "GAMMA"),
)
# Verify isolation - each schema should only have its own data
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
texts = await get_memory_texts_in_schema(conn, schema, bank_id)
prefix = schema.replace("tenant_", "").upper()
# Should have exactly 3 memories
assert len(texts) == 3, f"Schema {schema} should have 3 memories, got {len(texts)}"
# All texts should contain the schema's marker
for text in texts:
assert f"MARKER_{prefix}" in text, (
f"Memory in {schema} missing its marker: {text}"
)
# Should NOT contain other tenants' markers
other_prefixes = ["ALPHA", "BETA", "GAMMA"]
other_prefixes.remove(prefix)
for other in other_prefixes:
for text in texts:
assert f"MARKER_{other}" not in text, (
f"Cross-contamination! Schema {schema} has {other}'s marker: {text}"
)
finally:
# Cleanup
for schema in schemas:
await drop_schema(conn, schema)
await conn.close()
# Reset tenant extension
memory._tenant_extension = None
_current_schema.set("public")
@pytest.mark.asyncio
async def test_schema_context_isolation_in_concurrent_tasks(self, pg0_db_url):
"""
Verify that _current_schema contextvar is properly isolated
between concurrent async tasks.
"""
results = {}
errors = []
async def check_schema_context(schema_name: str, delay: float):
"""Set schema context, wait, then verify it's still correct."""
try:
# Set the schema
_current_schema.set(schema_name)
# Small delay to allow interleaving
await asyncio.sleep(delay)
# Verify schema is still correct
current = _current_schema.get()
if current != schema_name:
errors.append(f"Expected {schema_name}, got {current}")
# Verify fq_table uses correct schema
table = fq_table("memory_units")
expected = f"{schema_name}.memory_units"
if table != expected:
errors.append(f"Expected {expected}, got {table}")
results[schema_name] = current
except Exception as e:
errors.append(f"Error in {schema_name}: {e}")
# Run many concurrent tasks with different schemas
tasks = []
for i in range(10):
for schema in ["schema_a", "schema_b", "schema_c"]:
# Vary delays to create interleaving
delay = 0.01 * (i % 3)
tasks.append(check_schema_context(f"{schema}_{i}", delay))
await asyncio.gather(*tasks)
# No errors should have occurred
assert not errors, f"Schema context isolation errors: {errors}"
@pytest.mark.asyncio
async def test_list_memories_respects_schema(self, memory, pg0_db_url):
"""
list_memory_units should only return memories from the current schema.
Uses run_migrations(schema=x) to provision schemas.
"""
import asyncpg
schemas = ["tenant_list_a", "tenant_list_b"]
bank_id = f"test-list-{uuid.uuid4().hex[:8]}"
# Clean up any existing schemas and provision via migrations
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
await drop_schema(conn, schema)
finally:
await conn.close()
# Provision schemas using run_migrations
for schema in schemas:
run_migrations(pg0_db_url, schema=schema)
# Insert test data directly into each schema
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
await conn.execute(
f"""
INSERT INTO "{schema}".memory_units (bank_id, text, event_date, fact_type)
VALUES ($1, $2, now(), 'world')
""",
bank_id,
f"Direct insert for {schema}",
)
finally:
await conn.close()
# Configure tenant extension
tenant_ext = MultiSchemaTestTenantExtension({
"db_url": pg0_db_url,
"valid_schemas": set(schemas),
})
memory._tenant_extension = tenant_ext
try:
# Query as tenant_list_a - should only see tenant_list_a's data
tenant_a_request = RequestContext(api_key="key-tenant_list_a")
await memory._authenticate_tenant(tenant_a_request)
result_a = await memory.list_memory_units(bank_id=bank_id, request_context=tenant_a_request)
texts_a = [item["text"] for item in result_a.get("items", [])]
assert len(texts_a) == 1, f"Expected 1 memory for tenant_list_a, got {len(texts_a)}"
assert "tenant_list_a" in texts_a[0], f"Wrong content: {texts_a[0]}"
# Query as tenant_list_b - should only see tenant_list_b's data
tenant_b_request = RequestContext(api_key="key-tenant_list_b")
await memory._authenticate_tenant(tenant_b_request)
result_b = await memory.list_memory_units(bank_id=bank_id, request_context=tenant_b_request)
texts_b = [item["text"] for item in result_b.get("items", [])]
assert len(texts_b) == 1, f"Expected 1 memory for tenant_list_b, got {len(texts_b)}"
assert "tenant_list_b" in texts_b[0], f"Wrong content: {texts_b[0]}"
finally:
# Cleanup
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
await drop_schema(conn, schema)
finally:
await conn.close()
memory._tenant_extension = None
_current_schema.set("public")
@pytest.mark.asyncio
async def test_high_concurrency_schema_isolation(self, memory, pg0_db_url):
"""
Stress test: Many concurrent operations across multiple schemas
should maintain perfect isolation.
Uses run_migrations(schema=x) to provision schemas like a real extension.
"""
import asyncpg
# Create more schemas for stress test
num_schemas = 5
ops_per_schema = 10
schemas = [f"stress_tenant_{i}" for i in range(num_schemas)]
bank_id = f"test-stress-{uuid.uuid4().hex[:8]}"
# Clean up any existing schemas first
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
await drop_schema(conn, schema)
finally:
await conn.close()
# Provision schemas using run_migrations
for schema in schemas:
run_migrations(pg0_db_url, schema=schema)
# Configure tenant extension (schemas already provisioned)
tenant_ext = MultiSchemaTestTenantExtension({
"db_url": pg0_db_url,
"valid_schemas": set(schemas),
})
# Mark schemas as already provisioned so extension doesn't re-run migrations
tenant_ext._provisioned = set(schemas)
memory._tenant_extension = tenant_ext
errors = []
async def insert_one(schema: str, item_id: int):
"""Single insert operation for tracking."""
try:
# Authenticate to set the schema context
tenant_request = RequestContext(api_key=f"key-{schema}")
await memory._authenticate_tenant(tenant_request)
# Insert using fq_table
pool = await memory._get_pool()
from hindsight_api.engine.db_utils import acquire_with_retry
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
INSERT INTO {fq_table('memory_units')} (bank_id, text, event_date, fact_type)
VALUES ($1, $2, now(), 'world')
""",
bank_id,
f"STRESS_MARKER_{schema}_ITEM{item_id}: Memory for {schema}",
)
except Exception as e:
errors.append(f"Insert error for {schema}: {e}")
# Run many concurrent operations
tasks = []
for i in range(ops_per_schema):
for schema in schemas:
tasks.append(insert_one(schema, i))
await asyncio.gather(*tasks)
# Check for errors during insert
assert not errors, f"Errors during insert: {errors}"
# Verify no cross-contamination
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
texts = await get_memory_texts_in_schema(conn, schema, bank_id)
# Should have exactly ops_per_schema memories
assert len(texts) == ops_per_schema, (
f"Schema {schema} should have {ops_per_schema} memories, got {len(texts)}"
)
# All memories should reference this schema only
for text in texts:
# Check it contains our schema marker
assert f"STRESS_MARKER_{schema}" in text, (
f"Memory in {schema} doesn't contain schema marker: {text}"
)
# Check it doesn't contain other schema markers
for other_schema in schemas:
if other_schema != schema:
assert f"STRESS_MARKER_{other_schema}" not in text, (
f"Cross-contamination! {schema} has {other_schema}'s data: {text}"
)
finally:
# Cleanup
for schema in schemas:
await drop_schema(conn, schema)
await conn.close()
memory._tenant_extension = None
_current_schema.set("public")