Skip to content

Commit 324844a

Browse files
phernandezclaude
andauthored
fix: resolve entity relations in background to prevent cold start blocking (#319)
Signed-off-by: phernandez <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent f818702 commit 324844a

File tree

9 files changed

+152
-48
lines changed

9 files changed

+152
-48
lines changed

src/basic_memory/api/routers/knowledge_router.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,26 @@
2727

2828
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
2929

30+
31+
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
32+
"""Background task to resolve relations for a specific entity.
33+
34+
This runs asynchronously after the API response is sent, preventing
35+
long delays when creating entities with many relations.
36+
"""
37+
try:
38+
# Only resolve relations for the newly created entity
39+
await sync_service.resolve_relations(entity_id=entity_id)
40+
logger.debug(
41+
f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
42+
)
43+
except Exception as e:
44+
# Log but don't fail - this is a background task
45+
logger.warning(
46+
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
47+
)
48+
49+
3050
## Create endpoints
3151

3252

@@ -88,15 +108,12 @@ async def create_or_update_entity(
88108
# reindex
89109
await search_service.index_entity(entity, background_tasks=background_tasks)
90110

91-
# Attempt immediate relation resolution when creating new entities
92-
# This helps resolve forward references when related entities are created in the same session
111+
# Schedule relation resolution as a background task for new entities
112+
# This prevents blocking the API response while resolving potentially many relations
93113
if created:
94-
try:
95-
await sync_service.resolve_relations()
96-
logger.debug(f"Resolved relations after creating entity: {entity.permalink}")
97-
except Exception as e: # pragma: no cover
98-
# Don't fail the entire request if relation resolution fails
99-
logger.warning(f"Failed to resolve relations after entity creation: {e}")
114+
background_tasks.add_task(
115+
resolve_relations_background, sync_service, entity.id, entity.permalink or ""
116+
)
100117

101118
result = EntityResponse.model_validate(entity)
102119

src/basic_memory/repository/relation_repository.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,18 @@ async def find_unresolved_relations(self) -> Sequence[Relation]:
7373
result = await self.execute_query(query)
7474
return result.scalars().all()
7575

76+
async def find_unresolved_relations_for_entity(self, entity_id: int) -> Sequence[Relation]:
77+
"""Find unresolved relations for a specific entity.
78+
79+
Args:
80+
entity_id: The entity whose unresolved outgoing relations to find.
81+
82+
Returns:
83+
List of unresolved relations where this entity is the source.
84+
"""
85+
query = select(Relation).filter(Relation.from_id == entity_id, Relation.to_id.is_(None))
86+
result = await self.execute_query(query)
87+
return result.scalars().all()
88+
7689
def get_load_options(self) -> List[LoaderOption]:
7790
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]

src/basic_memory/schemas/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import os
1515
import mimetypes
1616
import re
17-
from datetime import datetime, time, timedelta
17+
from datetime import datetime, timedelta
1818
from pathlib import Path
1919
from typing import List, Optional, Annotated, Dict
2020

src/basic_memory/services/entity_service.py

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -422,34 +422,47 @@ async def update_entity_relations(
422422
# Clear existing relations first
423423
await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
424424

425-
# Process each relation
426-
for rel in markdown.relations:
427-
# Resolve the target permalink
428-
target_entity = await self.link_resolver.resolve_link(
429-
rel.target,
430-
)
431-
432-
# if the target is found, store the id
433-
target_id = target_entity.id if target_entity else None
434-
# if the target is found, store the title, otherwise add the target for a "forward link"
435-
target_name = target_entity.title if target_entity else rel.target
436-
437-
# Create the relation
438-
relation = Relation(
439-
from_id=db_entity.id,
440-
to_id=target_id,
441-
to_name=target_name,
442-
relation_type=rel.type,
443-
context=rel.context,
444-
)
445-
try:
446-
await self.relation_repository.add(relation)
447-
except IntegrityError:
448-
# Unique constraint violation - relation already exists
449-
logger.debug(
450-
f"Skipping duplicate relation {rel.type} from {db_entity.permalink} target: {rel.target}"
425+
# Batch resolve all relation targets in parallel
426+
if markdown.relations:
427+
import asyncio
428+
429+
# Create tasks for all relation lookups
430+
lookup_tasks = [
431+
self.link_resolver.resolve_link(rel.target) for rel in markdown.relations
432+
]
433+
434+
# Execute all lookups in parallel
435+
resolved_entities = await asyncio.gather(*lookup_tasks, return_exceptions=True)
436+
437+
# Process results and create relation records
438+
for rel, resolved in zip(markdown.relations, resolved_entities):
439+
# Handle exceptions from gather and None results
440+
target_entity: Optional[Entity] = None
441+
if not isinstance(resolved, Exception):
442+
# Type narrowing: resolved is Optional[Entity] here, not Exception
443+
target_entity = resolved # type: ignore
444+
445+
# if the target is found, store the id
446+
target_id = target_entity.id if target_entity else None
447+
# if the target is found, store the title, otherwise add the target for a "forward link"
448+
target_name = target_entity.title if target_entity else rel.target
449+
450+
# Create the relation
451+
relation = Relation(
452+
from_id=db_entity.id,
453+
to_id=target_id,
454+
to_name=target_name,
455+
relation_type=rel.type,
456+
context=rel.context,
451457
)
452-
continue
458+
try:
459+
await self.relation_repository.add(relation)
460+
except IntegrityError:
461+
# Unique constraint violation - relation already exists
462+
logger.debug(
463+
f"Skipping duplicate relation {rel.type} from {db_entity.permalink} target: {rel.target}"
464+
)
465+
continue
453466

454467
return await self.repository.get_by_file_path(path)
455468

src/basic_memory/sync/sync_service.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -585,12 +585,27 @@ async def handle_move(self, old_path, new_path):
585585
# update search index
586586
await self.search_service.index_entity(updated)
587587

588-
async def resolve_relations(self):
589-
"""Try to resolve any unresolved relations"""
588+
async def resolve_relations(self, entity_id: int | None = None):
589+
"""Try to resolve unresolved relations.
590590
591-
unresolved_relations = await self.relation_repository.find_unresolved_relations()
591+
Args:
592+
entity_id: If provided, only resolve relations for this specific entity.
593+
Otherwise, resolve all unresolved relations in the database.
594+
"""
592595

593-
logger.info("Resolving forward references", count=len(unresolved_relations))
596+
if entity_id:
597+
# Only get unresolved relations for the specific entity
598+
unresolved_relations = (
599+
await self.relation_repository.find_unresolved_relations_for_entity(entity_id)
600+
)
601+
logger.info(
602+
f"Resolving forward references for entity {entity_id}",
603+
count=len(unresolved_relations),
604+
)
605+
else:
606+
# Get all unresolved relations (original behavior)
607+
unresolved_relations = await self.relation_repository.find_unresolved_relations()
608+
logger.info("Resolving all forward references", count=len(unresolved_relations))
594609

595610
for relation in unresolved_relations:
596611
logger.trace(

tests/api/test_async_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ def test_create_client_configures_extended_timeouts():
3535
# Verify timeout configuration
3636
assert isinstance(client.timeout, Timeout)
3737
assert client.timeout.connect == 10.0 # 10 seconds for connection
38-
assert client.timeout.read == 30.0 # 30 seconds for reading
39-
assert client.timeout.write == 30.0 # 30 seconds for writing
40-
assert client.timeout.pool == 30.0 # 30 seconds for pool
38+
assert client.timeout.read == 30.0 # 30 seconds for reading
39+
assert client.timeout.write == 30.0 # 30 seconds for writing
40+
assert client.timeout.pool == 30.0 # 30 seconds for pool
4141

4242
# Also test with proxy URL
4343
with patch.dict("os.environ", {"BASIC_MEMORY_PROXY_URL": "http://localhost:8000"}):
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Test that relation resolution happens in the background."""
2+
3+
import pytest
4+
from unittest.mock import AsyncMock
5+
6+
from basic_memory.api.routers.knowledge_router import resolve_relations_background
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_resolve_relations_background_success():
11+
"""Test that background relation resolution calls sync service correctly."""
12+
# Create mocks
13+
sync_service = AsyncMock()
14+
sync_service.resolve_relations = AsyncMock(return_value=None)
15+
16+
entity_id = 123
17+
entity_permalink = "test/entity"
18+
19+
# Call the background function
20+
await resolve_relations_background(sync_service, entity_id, entity_permalink)
21+
22+
# Verify sync service was called with the entity_id
23+
sync_service.resolve_relations.assert_called_once_with(entity_id=entity_id)
24+
25+
26+
@pytest.mark.asyncio
27+
async def test_resolve_relations_background_handles_errors():
28+
"""Test that background relation resolution handles errors gracefully."""
29+
# Create mock that raises an exception
30+
sync_service = AsyncMock()
31+
sync_service.resolve_relations = AsyncMock(side_effect=Exception("Test error"))
32+
33+
entity_id = 123
34+
entity_permalink = "test/entity"
35+
36+
# Call should not raise - errors are logged
37+
await resolve_relations_background(sync_service, entity_id, entity_permalink)
38+
39+
# Verify sync service was called
40+
sync_service.resolve_relations.assert_called_once_with(entity_id=entity_id)

tests/schemas/test_base_timeframe_minimum.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ def test_one_day_returns_one_day(self):
5252

5353
# Should be approximately 1 day ago (within 24 hours)
5454
diff_hours = abs((result.replace(tzinfo=None) - one_day_ago).total_seconds()) / 3600
55-
assert diff_hours < 24, f"Expected ~1 day ago for '1d', got {result} (diff: {diff_hours} hours)"
55+
assert diff_hours < 24, (
56+
f"Expected ~1 day ago for '1d', got {result} (diff: {diff_hours} hours)"
57+
)
5658

5759
@freeze_time("2025-01-15 15:00:00")
5860
def test_two_days_returns_two_days(self):
@@ -63,7 +65,9 @@ def test_two_days_returns_two_days(self):
6365

6466
# Should be approximately 2 days ago (within 24 hours)
6567
diff_hours = abs((result.replace(tzinfo=None) - two_days_ago).total_seconds()) / 3600
66-
assert diff_hours < 24, f"Expected ~2 days ago for '2d', got {result} (diff: {diff_hours} hours)"
68+
assert diff_hours < 24, (
69+
f"Expected ~2 days ago for '2d', got {result} (diff: {diff_hours} hours)"
70+
)
6771

6872
@freeze_time("2025-01-15 15:00:00")
6973
def test_one_week_returns_one_week(self):
@@ -74,7 +78,9 @@ def test_one_week_returns_one_week(self):
7478

7579
# Should be approximately 1 week ago (within 24 hours)
7680
diff_hours = abs((result.replace(tzinfo=None) - one_week_ago).total_seconds()) / 3600
77-
assert diff_hours < 24, f"Expected ~1 week ago for '1 week', got {result} (diff: {diff_hours} hours)"
81+
assert diff_hours < 24, (
82+
f"Expected ~1 week ago for '1 week', got {result} (diff: {diff_hours} hours)"
83+
)
7884

7985
@freeze_time("2025-01-15 15:00:00")
8086
def test_zero_days_returns_one_day_minimum(self):
@@ -95,4 +101,4 @@ def test_timezone_awareness(self):
95101
def test_invalid_timeframe_raises_error(self):
96102
"""Test that invalid timeframe strings raise ValueError."""
97103
with pytest.raises(ValueError, match="Could not parse timeframe"):
98-
parse_timeframe("invalid_timeframe")
104+
parse_timeframe("invalid_timeframe")

tests/schemas/test_schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import os
44
import pytest
5-
from datetime import datetime, time, timedelta
5+
from datetime import datetime, timedelta
66
from pydantic import ValidationError, BaseModel
77

88
from basic_memory.schemas import (

0 commit comments

Comments
 (0)