Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 242e61340d fix: retain async fails 2025-12-17 13:12:44 +01:00
Nicolò Boschi 6633f77d43 fix: retain async fails 2025-12-17 12:55:05 +01:00
3 changed files with 199 additions and 13 deletions
+14 -10
View File
@@ -1452,18 +1452,22 @@ def _register_routes(app: FastAPI):
bank_id,
)
def parse_metadata(metadata):
"""Parse result_metadata which may be a string or dict."""
if metadata is None:
return {}
if isinstance(metadata, str):
return json.loads(metadata)
return metadata
return {
"bank_id": bank_id,
"operations": [
{
"id": str(row["operation_id"]),
"task_type": row["operation_type"],
"items_count": row["result_metadata"].get("items_count", 0)
if row["result_metadata"]
else 0,
"document_id": row["result_metadata"].get("document_id")
if row["result_metadata"]
else None,
"items_count": parse_metadata(row["result_metadata"]).get("items_count", 0),
"document_id": parse_metadata(row["result_metadata"]).get("document_id"),
"created_at": row["created_at"].isoformat(),
"status": row["status"],
"error_message": row["error_message"],
@@ -1499,7 +1503,7 @@ def _register_routes(app: FastAPI):
async with acquire_with_retry(pool) as conn:
# Check if operation exists and belongs to this memory bank
result = await conn.fetchrow(
"SELECT bank_id FROM async_operations WHERE id = $1 AND bank_id = $2", op_uuid, bank_id
"SELECT bank_id FROM async_operations WHERE operation_id = $1 AND bank_id = $2", op_uuid, bank_id
)
if not result:
@@ -1508,7 +1512,7 @@ def _register_routes(app: FastAPI):
)
# Delete the operation
await conn.execute("DELETE FROM async_operations WHERE id = $1", op_uuid)
await conn.execute("DELETE FROM async_operations WHERE operation_id = $1", op_uuid)
return {
"success": True,
@@ -1769,13 +1773,13 @@ def _register_routes(app: FastAPI):
async with acquire_with_retry(pool) as conn:
await conn.execute(
"""
INSERT INTO async_operations (id, bank_id, task_type, items_count)
INSERT INTO async_operations (operation_id, bank_id, operation_type, result_metadata)
VALUES ($1, $2, $3, $4)
""",
operation_id,
bank_id,
"retain",
len(contents),
json.dumps({"items_count": len(contents)}),
)
# Submit task to background queue
@@ -311,7 +311,7 @@ class MemoryEngine:
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
result = await conn.fetchrow(
"SELECT id FROM async_operations WHERE id = $1", uuid.UUID(operation_id)
"SELECT operation_id FROM async_operations WHERE operation_id = $1", uuid.UUID(operation_id)
)
if not result:
# Operation was cancelled, skip processing
@@ -369,7 +369,7 @@ class MemoryEngine:
try:
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
await conn.execute("DELETE FROM async_operations WHERE id = $1", uuid.UUID(operation_id))
await conn.execute("DELETE FROM async_operations WHERE operation_id = $1", uuid.UUID(operation_id))
except Exception as e:
logger.error(f"Failed to delete async operation record {operation_id}: {e}")
@@ -386,7 +386,7 @@ class MemoryEngine:
"""
UPDATE async_operations
SET status = 'failed', error_message = $2
WHERE id = $1
WHERE operation_id = $1
""",
uuid.UUID(operation_id),
truncated_error,
@@ -426,3 +426,185 @@ async def test_document_deletion(api_client):
f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024"
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_async_retain(api_client):
"""Test asynchronous retain functionality.
When async=true is passed, the retain endpoint should:
1. Return immediately with success and async_=true
2. Process the content in the background
3. Eventually store the memories
"""
import asyncio
test_bank_id = f"async_retain_test_{datetime.now().timestamp()}"
# Store memory with async=true
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={
"async": True,
"items": [
{
"content": "Alice is a senior engineer at TechCorp. She has been working on the authentication system for 5 years.",
"context": "team introduction"
}
]
}
)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert result["async"] is True, "Response should indicate async processing"
assert result["items_count"] == 1
# Check operations endpoint to see the pending operation
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations")
assert response.status_code == 200
ops_result = response.json()
assert "operations" in ops_result
# Wait for async processing to complete (poll with timeout)
max_wait_seconds = 30
poll_interval = 0.5
elapsed = 0
memories_found = False
while elapsed < max_wait_seconds:
# Check if memories are stored
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/memories/list",
params={"limit": 10}
)
assert response.status_code == 200
items = response.json()["items"]
if len(items) > 0:
memories_found = True
break
await asyncio.sleep(poll_interval)
elapsed += poll_interval
assert memories_found, f"Async retain did not complete within {max_wait_seconds} seconds"
# Verify we can recall the stored memory
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": "Who works at TechCorp?",
"thinking_budget": 30
}
)
assert response.status_code == 200
search_results = response.json()
assert len(search_results["results"]) > 0, "Should find the asynchronously stored memory"
# Verify Alice is mentioned
found_alice = any("Alice" in r["text"] for r in search_results["results"])
assert found_alice, "Should find Alice in search results"
@pytest.mark.asyncio
async def test_async_retain_parallel(api_client):
"""Test multiple async retain operations running in parallel.
Verifies that:
1. Multiple async operations can be submitted concurrently
2. All operations complete successfully
3. The exact number of documents are processed
"""
import asyncio
test_bank_id = f"async_parallel_test_{datetime.now().timestamp()}"
num_documents = 5
# Prepare multiple documents to retain
documents = [
{
"content": f"Document {i}: This is test content about Person{i} who works at Company{i}.",
"context": f"test document {i}",
"document_id": f"doc_{i}"
}
for i in range(num_documents)
]
# Submit all async retain operations in parallel
async def submit_async_retain(doc):
return await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={
"async": True,
"items": [doc]
}
)
# Run all submissions concurrently
responses = await asyncio.gather(*[submit_async_retain(doc) for doc in documents])
# Verify all submissions succeeded
for i, response in enumerate(responses):
assert response.status_code == 200, f"Document {i} submission failed"
result = response.json()
assert result["success"] is True
assert result["async"] is True
# Check operations endpoint - should show pending operations
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations")
assert response.status_code == 200
# Wait for all async operations to complete (poll with timeout)
max_wait_seconds = 60
poll_interval = 1.0
elapsed = 0
all_docs_processed = False
while elapsed < max_wait_seconds:
# Check document count
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents")
assert response.status_code == 200
docs = response.json()["items"]
if len(docs) >= num_documents:
all_docs_processed = True
break
await asyncio.sleep(poll_interval)
elapsed += poll_interval
assert all_docs_processed, f"Expected {num_documents} documents, but only {len(docs)} were processed within {max_wait_seconds} seconds"
# Verify exact document count
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents")
assert response.status_code == 200
final_docs = response.json()["items"]
assert len(final_docs) == num_documents, f"Expected exactly {num_documents} documents, got {len(final_docs)}"
# Verify each document exists
doc_ids = {doc["id"] for doc in final_docs}
for i in range(num_documents):
assert f"doc_{i}" in doc_ids, f"Document doc_{i} not found"
# Verify memories were created for all documents
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/memories/list",
params={"limit": 100}
)
assert response.status_code == 200
memories = response.json()["items"]
assert len(memories) >= num_documents, f"Expected at least {num_documents} memories, got {len(memories)}"
# Verify we can recall content from different documents
for i in [0, num_documents - 1]: # Check first and last
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": f"Who works at Company{i}?",
"thinking_budget": 30
}
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) > 0, f"Should find memories for document {i}"