fix: resolve entity relations in background to prevent cold start blocking (#319)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2025-09-28 09:24:29 -05:00
committed by GitHub
parent f818702ab7
commit 324844a670
9 changed files with 150 additions and 46 deletions
@@ -27,6 +27,26 @@ from basic_memory.schemas.base import Permalink, Entity
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
"""Background task to resolve relations for a specific entity.
This runs asynchronously after the API response is sent, preventing
long delays when creating entities with many relations.
"""
try:
# Only resolve relations for the newly created entity
await sync_service.resolve_relations(entity_id=entity_id)
logger.debug(
f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
)
except Exception as e:
# Log but don't fail - this is a background task
logger.warning(
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
)
## Create endpoints
@@ -88,15 +108,12 @@ async def create_or_update_entity(
# reindex
await search_service.index_entity(entity, background_tasks=background_tasks)
# Attempt immediate relation resolution when creating new entities
# This helps resolve forward references when related entities are created in the same session
# Schedule relation resolution as a background task for new entities
# This prevents blocking the API response while resolving potentially many relations
if created:
try:
await sync_service.resolve_relations()
logger.debug(f"Resolved relations after creating entity: {entity.permalink}")
except Exception as e: # pragma: no cover
# Don't fail the entire request if relation resolution fails
logger.warning(f"Failed to resolve relations after entity creation: {e}")
background_tasks.add_task(
resolve_relations_background, sync_service, entity.id, entity.permalink or ""
)
result = EntityResponse.model_validate(entity)
@@ -73,5 +73,18 @@ class RelationRepository(Repository[Relation]):
result = await self.execute_query(query)
return result.scalars().all()
async def find_unresolved_relations_for_entity(self, entity_id: int) -> Sequence[Relation]:
"""Find unresolved relations for a specific entity.
Args:
entity_id: The entity whose unresolved outgoing relations to find.
Returns:
List of unresolved relations where this entity is the source.
"""
query = select(Relation).filter(Relation.from_id == entity_id, Relation.to_id.is_(None))
result = await self.execute_query(query)
return result.scalars().all()
def get_load_options(self) -> List[LoaderOption]:
return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]
+1 -1
View File
@@ -14,7 +14,7 @@ Key Concepts:
import os
import mimetypes
import re
from datetime import datetime, time, timedelta
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Optional, Annotated, Dict
+38 -25
View File
@@ -422,34 +422,47 @@ class EntityService(BaseService[EntityModel]):
# Clear existing relations first
await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
# Process each relation
for rel in markdown.relations:
# Resolve the target permalink
target_entity = await self.link_resolver.resolve_link(
rel.target,
)
# Batch resolve all relation targets in parallel
if markdown.relations:
import asyncio
# if the target is found, store the id
target_id = target_entity.id if target_entity else None
# if the target is found, store the title, otherwise add the target for a "forward link"
target_name = target_entity.title if target_entity else rel.target
# Create tasks for all relation lookups
lookup_tasks = [
self.link_resolver.resolve_link(rel.target) for rel in markdown.relations
]
# Create the relation
relation = Relation(
from_id=db_entity.id,
to_id=target_id,
to_name=target_name,
relation_type=rel.type,
context=rel.context,
)
try:
await self.relation_repository.add(relation)
except IntegrityError:
# Unique constraint violation - relation already exists
logger.debug(
f"Skipping duplicate relation {rel.type} from {db_entity.permalink} target: {rel.target}"
# Execute all lookups in parallel
resolved_entities = await asyncio.gather(*lookup_tasks, return_exceptions=True)
# Process results and create relation records
for rel, resolved in zip(markdown.relations, resolved_entities):
# Handle exceptions from gather and None results
target_entity: Optional[Entity] = None
if not isinstance(resolved, Exception):
# Type narrowing: resolved is Optional[Entity] here, not Exception
target_entity = resolved # type: ignore
# if the target is found, store the id
target_id = target_entity.id if target_entity else None
# if the target is found, store the title, otherwise add the target for a "forward link"
target_name = target_entity.title if target_entity else rel.target
# Create the relation
relation = Relation(
from_id=db_entity.id,
to_id=target_id,
to_name=target_name,
relation_type=rel.type,
context=rel.context,
)
continue
try:
await self.relation_repository.add(relation)
except IntegrityError:
# Unique constraint violation - relation already exists
logger.debug(
f"Skipping duplicate relation {rel.type} from {db_entity.permalink} target: {rel.target}"
)
continue
return await self.repository.get_by_file_path(path)
+19 -4
View File
@@ -585,12 +585,27 @@ class SyncService:
# update search index
await self.search_service.index_entity(updated)
async def resolve_relations(self):
"""Try to resolve any unresolved relations"""
async def resolve_relations(self, entity_id: int | None = None):
"""Try to resolve unresolved relations.
unresolved_relations = await self.relation_repository.find_unresolved_relations()
Args:
entity_id: If provided, only resolve relations for this specific entity.
Otherwise, resolve all unresolved relations in the database.
"""
logger.info("Resolving forward references", count=len(unresolved_relations))
if entity_id:
# Only get unresolved relations for the specific entity
unresolved_relations = (
await self.relation_repository.find_unresolved_relations_for_entity(entity_id)
)
logger.info(
f"Resolving forward references for entity {entity_id}",
count=len(unresolved_relations),
)
else:
# Get all unresolved relations (original behavior)
unresolved_relations = await self.relation_repository.find_unresolved_relations()
logger.info("Resolving all forward references", count=len(unresolved_relations))
for relation in unresolved_relations:
logger.trace(