mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user