c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
575 lines
24 KiB
Python
575 lines
24 KiB
Python
"""
|
|
Release E2E test: 10 parallel users exercising a live Cognee HTTP server.
|
|
|
|
Boots ``uvicorn cognee.api.client:app`` with ``ENABLE_BACKEND_ACCESS_CONTROL=True``
|
|
and fresh storage roots, registers 10 users over HTTP, then runs their scenarios
|
|
concurrently:
|
|
|
|
- users 0-2 (writer): one dataset, sync cognify, CHUNKS search
|
|
- users 3-4 (multi): two datasets, background cognify + status polling
|
|
- user 5 (item_deleter): three documents, delete one data item, its chunk disappears
|
|
- users 6-7 (dataset_deleter): delete the dataset, build a new one under a different name
|
|
- user 8 (forgetter): forget the dataset, recreate the SAME name from scratch
|
|
- user 9 (memory_forgetter): forget memoryOnly=True, re-cognify without re-adding files
|
|
|
|
Every document embeds a unique sentinel token. Search verification is LLM-free:
|
|
SearchType.CHUNKS only queries the vector store and returns raw chunk text, so a
|
|
user's results must contain their own sentinels and never a foreign one. Dataset
|
|
listings, cross-user search (403) and status probes are asserted both before and
|
|
after the delete/forget churn, while users 0-4 keep searching during the churn to
|
|
catch neighbour interference.
|
|
|
|
Cognify itself needs real LLM/embedding credentials in the environment.
|
|
|
|
Usage:
|
|
python cognee/tests/test_release_multi_user_e2e.py
|
|
pytest cognee/tests/test_release_multi_user_e2e.py
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
|
|
HOST = "127.0.0.1"
|
|
PORT = int(os.environ.get("HTTP_API_PORT", "8000"))
|
|
BASE_URL = f"http://{HOST}:{PORT}"
|
|
RUN_ID = uuid.uuid4().hex[:8]
|
|
NUM_USERS = 10
|
|
COGNIFY_TIMEOUT_SECONDS = 1800.0
|
|
SEARCH_TOP_K = 25
|
|
|
|
TOPICS = [
|
|
"the history of alpine glaciers",
|
|
"deep sea bioluminescent creatures",
|
|
"the architecture of gothic cathedrals",
|
|
"fermentation in traditional cooking",
|
|
"the physics of violin acoustics",
|
|
"desert irrigation techniques",
|
|
"the evolution of postal systems",
|
|
"volcanic soil and viticulture",
|
|
"polar navigation before satellites",
|
|
"the printing press and literacy",
|
|
]
|
|
|
|
# Every sentinel ever created, per user index — used to detect cross-user content leaks.
|
|
ALL_SENTINELS: dict[int, set[str]] = {index: set() for index in range(NUM_USERS)}
|
|
|
|
|
|
def log(message: str) -> None:
|
|
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
print(f"[{timestamp}] {message}", flush=True)
|
|
|
|
|
|
def make_document(user_index: int, doc_index: int) -> tuple[str, str]:
|
|
"""Return (sentinel, document text); the sentinel is registered for leak checks."""
|
|
topic = TOPICS[user_index]
|
|
sentinel = f"SENTINEL-{RUN_ID}-U{user_index}-D{doc_index}"
|
|
ALL_SENTINELS[user_index].add(sentinel)
|
|
text = (
|
|
f"This report covers {topic}. It was written for release validation and describes "
|
|
f"{topic} across several sentences so that cognify can build a small knowledge graph "
|
|
f"with entities, places and events related to {topic}. Practitioners agree that "
|
|
f"{topic} rewards careful study, and this document summarizes the essentials. "
|
|
f"The internal reference code of this report is {sentinel}."
|
|
)
|
|
return sentinel, text
|
|
|
|
|
|
class UserSession:
|
|
"""One simulated user: an authenticated HTTP client plus what the user owns."""
|
|
|
|
def __init__(self, index: int):
|
|
self.index = index
|
|
self.email = f"release_user_{index}_{RUN_ID}@example.com"
|
|
self.password = f"Release-Test-{RUN_ID}-{index}!"
|
|
self.client = httpx.AsyncClient(
|
|
base_url=BASE_URL,
|
|
timeout=httpx.Timeout(COGNIFY_TIMEOUT_SECONDS, connect=15.0),
|
|
)
|
|
self.expected_datasets: set[str] = set()
|
|
self.sentinels: set[str] = set() # sentinels currently retrievable via search
|
|
self.main_dataset: str = ""
|
|
self.victim_sentinel: str = "" # item_deleter only: sentinel of the deleted document
|
|
|
|
async def close(self) -> None:
|
|
await self.client.aclose()
|
|
|
|
async def register_and_login(self) -> None:
|
|
response = await self.client.post(
|
|
"/api/v1/auth/register", json={"email": self.email, "password": self.password}
|
|
)
|
|
assert response.status_code == 201, (
|
|
f"user {self.index}: register failed: {response.status_code} {response.text}"
|
|
)
|
|
response = await self.client.post(
|
|
"/api/v1/auth/login", data={"username": self.email, "password": self.password}
|
|
)
|
|
assert response.status_code == 200, (
|
|
f"user {self.index}: login failed: {response.status_code} {response.text}"
|
|
)
|
|
self.client.headers["Authorization"] = f"Bearer {response.json()['access_token']}"
|
|
|
|
async def add_text(self, dataset_name: str, filename: str, text: str) -> None:
|
|
response = await self.client.post(
|
|
"/api/v1/add",
|
|
data={"datasetName": dataset_name},
|
|
files=[("data", (filename, text.encode("utf-8"), "text/plain"))],
|
|
)
|
|
assert response.status_code == 200, (
|
|
f"user {self.index}: add to '{dataset_name}' failed:"
|
|
f" {response.status_code} {response.text}"
|
|
)
|
|
|
|
async def list_datasets(self) -> dict[str, str]:
|
|
"""Return {dataset name: dataset id} for every dataset visible to this user."""
|
|
response = await self.client.get("/api/v1/datasets")
|
|
assert response.status_code == 200, (
|
|
f"user {self.index}: dataset listing failed: {response.status_code} {response.text}"
|
|
)
|
|
return {dataset["name"]: dataset["id"] for dataset in response.json()}
|
|
|
|
async def dataset_id(self, dataset_name: str) -> str:
|
|
datasets = await self.list_datasets()
|
|
assert dataset_name in datasets, (
|
|
f"user {self.index}: dataset '{dataset_name}' missing from listing {sorted(datasets)}"
|
|
)
|
|
return datasets[dataset_name]
|
|
|
|
async def list_data(self, dataset_id: str) -> list[dict]:
|
|
response = await self.client.get(f"/api/v1/datasets/{dataset_id}/data")
|
|
assert response.status_code == 200, (
|
|
f"user {self.index}: data listing failed: {response.status_code} {response.text}"
|
|
)
|
|
return response.json()
|
|
|
|
async def cognify(self, dataset_names: list[str], background: bool = False) -> None:
|
|
response = await self.client.post(
|
|
"/api/v1/cognify",
|
|
json={"datasets": dataset_names, "runInBackground": background},
|
|
)
|
|
assert response.status_code == 200, (
|
|
f"user {self.index}: cognify of {dataset_names} failed:"
|
|
f" {response.status_code} {response.text}"
|
|
)
|
|
|
|
async def wait_for_cognify(self, dataset_id: str) -> None:
|
|
deadline = time.time() + COGNIFY_TIMEOUT_SECONDS
|
|
while time.time() < deadline:
|
|
response = await self.client.get(
|
|
"/api/v1/datasets/status", params={"dataset": dataset_id}
|
|
)
|
|
assert response.status_code == 200, (
|
|
f"user {self.index}: status check failed: {response.status_code} {response.text}"
|
|
)
|
|
status = response.json().get(dataset_id)
|
|
if status is not None:
|
|
assert "ERRORED" not in status, (
|
|
f"user {self.index}: cognify errored for dataset {dataset_id}"
|
|
)
|
|
if "COMPLETED" in status:
|
|
return
|
|
await asyncio.sleep(3)
|
|
raise AssertionError(
|
|
f"user {self.index}: cognify of dataset {dataset_id} did not complete"
|
|
f" within {COGNIFY_TIMEOUT_SECONDS}s"
|
|
)
|
|
|
|
async def search_chunks(
|
|
self,
|
|
dataset_names: list[str] | None = None,
|
|
dataset_ids: list[str] | None = None,
|
|
query: str = "release validation report",
|
|
) -> httpx.Response:
|
|
payload: dict = {"searchType": "CHUNKS", "query": query, "topK": SEARCH_TOP_K}
|
|
if dataset_names is not None:
|
|
payload["datasets"] = dataset_names
|
|
if dataset_ids is not None:
|
|
payload["datasetIds"] = dataset_ids
|
|
return await self.client.post("/api/v1/search", json=payload)
|
|
|
|
async def delete_dataset(self, dataset_id: str) -> None:
|
|
response = await self.client.delete(f"/api/v1/datasets/{dataset_id}")
|
|
assert response.status_code == 200, (
|
|
f"user {self.index}: dataset delete failed: {response.status_code} {response.text}"
|
|
)
|
|
|
|
async def delete_data_item(self, dataset_id: str, data_id: str) -> None:
|
|
response = await self.client.delete(f"/api/v1/datasets/{dataset_id}/data/{data_id}")
|
|
assert response.status_code == 200, (
|
|
f"user {self.index}: data item delete failed: {response.status_code} {response.text}"
|
|
)
|
|
|
|
async def forget(self, dataset_name: str, memory_only: bool = False) -> None:
|
|
response = await self.client.post(
|
|
"/api/v1/forget", json={"dataset": dataset_name, "memoryOnly": memory_only}
|
|
)
|
|
assert response.status_code == 200, (
|
|
f"user {self.index}: forget of '{dataset_name}' failed:"
|
|
f" {response.status_code} {response.text}"
|
|
)
|
|
|
|
|
|
def search_blob(user: UserSession, response: httpx.Response) -> str:
|
|
"""Flatten a search response to one string, robust to result wrapper shape."""
|
|
assert response.status_code == 200, (
|
|
f"user {user.index}: search failed: {response.status_code} {response.text}"
|
|
)
|
|
return json.dumps(response.json())
|
|
|
|
|
|
def assert_search_content(
|
|
user: UserSession,
|
|
blob: str,
|
|
expected_present: set[str],
|
|
expected_absent: set[str] = frozenset(),
|
|
) -> None:
|
|
"""Assert own sentinels are found, retired ones are gone, and nothing foreign leaked."""
|
|
for sentinel in expected_present:
|
|
assert sentinel in blob, (
|
|
f"user {user.index}: own sentinel {sentinel} not found in search results"
|
|
)
|
|
for sentinel in expected_absent:
|
|
assert sentinel not in blob, (
|
|
f"user {user.index}: retired sentinel {sentinel} still in search results"
|
|
)
|
|
for other_index, foreign_sentinels in ALL_SENTINELS.items():
|
|
if other_index == user.index:
|
|
continue
|
|
for sentinel in foreign_sentinels:
|
|
assert sentinel not in blob, (
|
|
f"user {user.index}: search results leaked sentinel {sentinel}"
|
|
f" belonging to user {other_index}"
|
|
)
|
|
|
|
|
|
async def build_and_verify_dataset(
|
|
user: UserSession, dataset_name: str, doc_index: int, background: bool = False
|
|
) -> str:
|
|
"""Add one document, cognify, and verify its sentinel is retrievable via CHUNKS."""
|
|
sentinel, text = make_document(user.index, doc_index)
|
|
await user.add_text(dataset_name, f"user{user.index}_doc{doc_index}.txt", text)
|
|
dataset_id = await user.dataset_id(dataset_name)
|
|
assert len(await user.list_data(dataset_id)) >= 1
|
|
await user.cognify([dataset_name], background=background)
|
|
await user.wait_for_cognify(dataset_id)
|
|
user.expected_datasets.add(dataset_name)
|
|
user.sentinels.add(sentinel)
|
|
blob = search_blob(user, await user.search_chunks([dataset_name], query=TOPICS[user.index]))
|
|
assert_search_content(user, blob, {sentinel})
|
|
return sentinel
|
|
|
|
|
|
async def build_writer(user: UserSession) -> None:
|
|
user.main_dataset = f"writer_{user.index}_{RUN_ID}"
|
|
await build_and_verify_dataset(user, user.main_dataset, doc_index=0)
|
|
log(f"user {user.index} (writer): built '{user.main_dataset}'")
|
|
|
|
|
|
async def build_multi(user: UserSession) -> None:
|
|
"""Two datasets, cognified in the background and awaited via the status endpoint."""
|
|
names = [f"multi_{suffix}_{user.index}_{RUN_ID}" for suffix in ("a", "b")]
|
|
sentinels = {}
|
|
for doc_index, name in enumerate(names):
|
|
sentinel, text = make_document(user.index, doc_index)
|
|
sentinels[name] = sentinel
|
|
await user.add_text(name, f"user{user.index}_doc{doc_index}.txt", text)
|
|
await user.cognify(names, background=True)
|
|
datasets = await user.list_datasets()
|
|
await asyncio.gather(*(user.wait_for_cognify(datasets[name]) for name in names))
|
|
user.expected_datasets.update(names)
|
|
user.sentinels.update(sentinels.values())
|
|
user.main_dataset = names[0]
|
|
for name in names:
|
|
blob = search_blob(user, await user.search_chunks([name], query=TOPICS[user.index]))
|
|
assert_search_content(user, blob, {sentinels[name]})
|
|
log(f"user {user.index} (multi): built {names} via background cognify")
|
|
|
|
|
|
async def build_item_deleter(user: UserSession) -> None:
|
|
"""Three documents in one dataset; one of them ('victim') gets deleted during churn."""
|
|
user.main_dataset = f"trimmed_{user.index}_{RUN_ID}"
|
|
filenames = ["keeper_one.txt", "victim.txt", "keeper_two.txt"]
|
|
for doc_index, filename in enumerate(filenames):
|
|
sentinel, text = make_document(user.index, doc_index)
|
|
if filename == "victim.txt":
|
|
user.victim_sentinel = sentinel
|
|
await user.add_text(user.main_dataset, filename, text)
|
|
user.sentinels.add(sentinel)
|
|
dataset_id = await user.dataset_id(user.main_dataset)
|
|
assert len(await user.list_data(dataset_id)) == 3
|
|
await user.cognify([user.main_dataset])
|
|
await user.wait_for_cognify(dataset_id)
|
|
user.expected_datasets.add(user.main_dataset)
|
|
blob = search_blob(
|
|
user, await user.search_chunks([user.main_dataset], query=TOPICS[user.index])
|
|
)
|
|
assert_search_content(user, blob, user.sentinels)
|
|
log(f"user {user.index} (item_deleter): built '{user.main_dataset}' with 3 documents")
|
|
|
|
|
|
BUILDERS = {
|
|
0: build_writer,
|
|
1: build_writer,
|
|
2: build_writer,
|
|
3: build_multi,
|
|
4: build_multi,
|
|
5: build_item_deleter,
|
|
6: build_writer,
|
|
7: build_writer,
|
|
8: build_writer,
|
|
9: build_writer,
|
|
}
|
|
|
|
|
|
async def churn_item_deleter(user: UserSession) -> None:
|
|
"""Delete a single data item and verify only its chunk disappears."""
|
|
dataset_id = await user.dataset_id(user.main_dataset)
|
|
victim = next(item for item in await user.list_data(dataset_id) if "victim" in item["name"])
|
|
await user.delete_data_item(dataset_id, victim["id"])
|
|
user.sentinels.discard(user.victim_sentinel)
|
|
assert len(await user.list_data(dataset_id)) == 2
|
|
blob = search_blob(
|
|
user, await user.search_chunks([user.main_dataset], query=TOPICS[user.index])
|
|
)
|
|
assert_search_content(user, blob, user.sentinels, expected_absent={user.victim_sentinel})
|
|
log(f"user {user.index} (item_deleter): deleted one document, siblings intact")
|
|
|
|
|
|
async def churn_dataset_deleter(user: UserSession) -> None:
|
|
"""Delete the whole dataset, then build a fresh one under a different name."""
|
|
old_name = user.main_dataset
|
|
old_sentinels = set(user.sentinels)
|
|
await user.delete_dataset(await user.dataset_id(old_name))
|
|
user.expected_datasets.discard(old_name)
|
|
user.sentinels.clear()
|
|
assert old_name not in await user.list_datasets(), (
|
|
f"user {user.index}: dataset '{old_name}' still listed after delete"
|
|
)
|
|
user.main_dataset = f"reborn_{user.index}_{RUN_ID}"
|
|
new_sentinel = await build_and_verify_dataset(user, user.main_dataset, doc_index=1)
|
|
blob = search_blob(
|
|
user, await user.search_chunks([user.main_dataset], query=TOPICS[user.index])
|
|
)
|
|
assert_search_content(user, blob, {new_sentinel}, expected_absent=old_sentinels)
|
|
log(f"user {user.index} (dataset_deleter): deleted '{old_name}', built '{user.main_dataset}'")
|
|
|
|
|
|
async def churn_forgetter(user: UserSession) -> None:
|
|
"""Forget the dataset, then recreate it under the SAME name from scratch."""
|
|
name = user.main_dataset
|
|
old_sentinels = set(user.sentinels)
|
|
await user.forget(name)
|
|
user.expected_datasets.discard(name)
|
|
user.sentinels.clear()
|
|
assert name not in await user.list_datasets(), (
|
|
f"user {user.index}: dataset '{name}' still listed after forget"
|
|
)
|
|
new_sentinel = await build_and_verify_dataset(user, name, doc_index=1)
|
|
blob = search_blob(user, await user.search_chunks([name], query=TOPICS[user.index]))
|
|
assert_search_content(user, blob, {new_sentinel}, expected_absent=old_sentinels)
|
|
log(f"user {user.index} (forgetter): forgot and recreated '{name}'")
|
|
|
|
|
|
async def churn_memory_forgetter(user: UserSession) -> None:
|
|
"""Forget memory only, then re-cognify the preserved raw files."""
|
|
name = user.main_dataset
|
|
await user.forget(name, memory_only=True)
|
|
dataset_id = await user.dataset_id(name) # dataset record must survive a memory-only forget
|
|
assert len(await user.list_data(dataset_id)) == 1, (
|
|
f"user {user.index}: raw data lost by memory-only forget"
|
|
)
|
|
# Memory is wiped: search must not return the sentinel; the vector collections may
|
|
# be gone entirely, which surfaces as 422 (search prerequisites not met).
|
|
response = await user.search_chunks([name], query=TOPICS[user.index])
|
|
if response.status_code == 200:
|
|
blob = json.dumps(response.json())
|
|
for sentinel in user.sentinels:
|
|
assert sentinel not in blob, (
|
|
f"user {user.index}: sentinel {sentinel} still retrievable after memory-only forget"
|
|
)
|
|
else:
|
|
assert response.status_code == 422, (
|
|
f"user {user.index}: unexpected search failure after memory-only forget:"
|
|
f" {response.status_code} {response.text}"
|
|
)
|
|
await user.cognify([name])
|
|
await user.wait_for_cognify(dataset_id)
|
|
blob = search_blob(user, await user.search_chunks([name], query=TOPICS[user.index]))
|
|
assert_search_content(user, blob, user.sentinels)
|
|
log(f"user {user.index} (memory_forgetter): wiped memory and re-cognified '{name}'")
|
|
|
|
|
|
async def steady_reader(user: UserSession, stop: asyncio.Event) -> None:
|
|
"""Keep searching while other users churn; results must stay correct throughout."""
|
|
iterations = 0
|
|
while not stop.is_set():
|
|
blob = search_blob(
|
|
user,
|
|
await user.search_chunks(sorted(user.expected_datasets), query=TOPICS[user.index]),
|
|
)
|
|
assert_search_content(user, blob, user.sentinels)
|
|
iterations += 1
|
|
with contextlib.suppress(asyncio.TimeoutError):
|
|
await asyncio.wait_for(stop.wait(), timeout=2.0)
|
|
log(f"user {user.index}: {iterations} steady reads during churn, all consistent")
|
|
|
|
|
|
async def verify_isolation(users: list[UserSession]) -> None:
|
|
"""Each user sees exactly their own datasets and cannot touch anyone else's."""
|
|
listings = {user.index: await user.list_datasets() for user in users}
|
|
for user in users:
|
|
assert set(listings[user.index]) == user.expected_datasets, (
|
|
f"user {user.index}: expected datasets {sorted(user.expected_datasets)},"
|
|
f" listing shows {sorted(listings[user.index])}"
|
|
)
|
|
|
|
async def probe_foreign(user: UserSession) -> None:
|
|
for other in users:
|
|
if other is user:
|
|
continue
|
|
for name, dataset_id in listings[other.index].items():
|
|
response = await user.search_chunks(dataset_ids=[dataset_id])
|
|
assert response.status_code == 403, (
|
|
f"user {user.index} searching '{name}' of user {other.index}:"
|
|
f" expected 403, got {response.status_code} {response.text[:200]}"
|
|
)
|
|
response = await user.client.get(
|
|
"/api/v1/datasets/status", params={"dataset": dataset_id}
|
|
)
|
|
# Denial surfaces either as 409 or as a response omitting the dataset.
|
|
if response.status_code == 200:
|
|
assert dataset_id not in response.json(), (
|
|
f"user {user.index} got status of foreign dataset '{name}'"
|
|
)
|
|
else:
|
|
assert response.status_code == 409, (
|
|
f"user {user.index} status probe on '{name}':"
|
|
f" unexpected {response.status_code} {response.text[:200]}"
|
|
)
|
|
|
|
await asyncio.gather(*(probe_foreign(user) for user in users))
|
|
log("isolation verified: listings exact, cross-user search 403, status denied")
|
|
|
|
|
|
async def run_scenarios() -> None:
|
|
users = [UserSession(index) for index in range(NUM_USERS)]
|
|
try:
|
|
log(f"registering {NUM_USERS} users")
|
|
await asyncio.gather(*(user.register_and_login() for user in users))
|
|
|
|
log("phase 1: parallel build (add + cognify + search)")
|
|
await asyncio.gather(*(BUILDERS[user.index](user) for user in users))
|
|
|
|
log("phase 2: isolation matrix")
|
|
await verify_isolation(users)
|
|
|
|
log("phase 3: churn (delete / forget / recreate) with concurrent readers")
|
|
stop = asyncio.Event()
|
|
|
|
async def churn() -> None:
|
|
try:
|
|
await asyncio.gather(
|
|
churn_item_deleter(users[5]),
|
|
churn_dataset_deleter(users[6]),
|
|
churn_dataset_deleter(users[7]),
|
|
churn_forgetter(users[8]),
|
|
churn_memory_forgetter(users[9]),
|
|
)
|
|
finally:
|
|
stop.set()
|
|
|
|
await asyncio.gather(churn(), *(steady_reader(user, stop) for user in users[:5]))
|
|
|
|
log("phase 4: final isolation matrix and content check")
|
|
await verify_isolation(users)
|
|
for user in users:
|
|
blob = search_blob(
|
|
user,
|
|
await user.search_chunks(sorted(user.expected_datasets), query=TOPICS[user.index]),
|
|
)
|
|
assert_search_content(user, blob, user.sentinels)
|
|
finally:
|
|
await asyncio.gather(*(user.close() for user in users), return_exceptions=True)
|
|
|
|
|
|
def wait_for_server(url: str, timeout: float = 300.0) -> None:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=2) as response:
|
|
if response.status == 200:
|
|
return
|
|
except (urllib.error.URLError, ConnectionError, TimeoutError):
|
|
pass
|
|
time.sleep(1)
|
|
raise RuntimeError(f"Cognee server at {url} did not become ready within {timeout}s")
|
|
|
|
|
|
def start_server() -> subprocess.Popen:
|
|
"""Start uvicorn with access control on and fresh storage roots.
|
|
|
|
A repo .env (loaded by cognee with override=True) wins over these env vars;
|
|
CI has no .env, so the job env fully controls the configuration there.
|
|
"""
|
|
storage_root = tempfile.mkdtemp(prefix=f"cognee_release_e2e_{RUN_ID}_")
|
|
env = {
|
|
**os.environ,
|
|
"ENABLE_BACKEND_ACCESS_CONTROL": "True",
|
|
"DATA_ROOT_DIRECTORY": os.path.join(storage_root, "data"),
|
|
"SYSTEM_ROOT_DIRECTORY": os.path.join(storage_root, "system"),
|
|
}
|
|
return subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"uvicorn",
|
|
"cognee.api.client:app",
|
|
"--host",
|
|
HOST,
|
|
"--port",
|
|
str(PORT),
|
|
],
|
|
env=env,
|
|
start_new_session=True,
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
log(f"starting Cognee server on {BASE_URL} (run id {RUN_ID})")
|
|
server = start_server()
|
|
try:
|
|
wait_for_server(f"{BASE_URL}/health")
|
|
log("server ready")
|
|
asyncio.run(run_scenarios())
|
|
log("release multi-user E2E passed")
|
|
finally:
|
|
log("shutting down server")
|
|
try:
|
|
os.killpg(server.pid, signal.SIGTERM)
|
|
server.wait(timeout=15)
|
|
except ProcessLookupError:
|
|
pass
|
|
except subprocess.TimeoutExpired:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.killpg(server.pid, signal.SIGKILL)
|
|
|
|
|
|
def test_release_multi_user() -> None:
|
|
main()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|