Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ebc4e5e
fix(core): add generation-guarded relation persistence
phernandez Aug 9, 2026
dd53e1b
fix(core): claim note generations before relation publication
phernandez Aug 9, 2026
afb158a
fix(core): publish accepted relations after content commit
phernandez Aug 9, 2026
13752a4
refactor(core): retire legacy relation replacement writers
phernandez Aug 9, 2026
c2fd384
fix(core): guard relation resolution by source generation
phernandez Aug 9, 2026
eba3ad4
fix(core): avoid mutual-link resolver lock inversion
phernandez Aug 9, 2026
59d4f75
fix(core): align relation runtimes with generation guards
phernandez Aug 9, 2026
931758a
fix(core): stop retrying deferred note generations
phernandez Aug 9, 2026
438e2bb
refactor(core): remove remaining legacy relation writers
phernandez Aug 9, 2026
54bbc33
test(core): align brace retry coverage with generations
phernandez Aug 9, 2026
ba56b1b
fix(core): preserve relation publication invariants
phernandez Aug 9, 2026
8316afa
fix(core): harden post-commit relation publication
phernandez Aug 9, 2026
533a94b
fix(core): resolve self links across index paths
phernandez Aug 9, 2026
37347fa
fix(core): align relation publication boundaries
phernandez Aug 9, 2026
d5f362a
docs(core): centralize relation lock ordering
phernandez Aug 9, 2026
4d346f8
fix(core): preserve unbootstrapped legacy relations
phernandez Aug 9, 2026
d7bd4fb
fix(core): persist relation projection retry work
phernandez Aug 9, 2026
1003e38
fix(core): align remaining relation generation paths
phernandez Aug 9, 2026
6d11ed3
fix: close remaining relation generation race paths
phernandez Aug 9, 2026
6987500
fix(core): guard directory delete membership
phernandez Aug 10, 2026
659842f
fix(core): guard relation maintenance races
phernandez Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Add generation ownership to relation projections.

Revision ID: r1m2n3o4p5q6
Revises: q0l1m2n3o4p5
Create Date: 2026-08-09 17:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op


revision: str = "r1m2n3o4p5q6"
down_revision: Union[str, None] = "q0l1m2n3o4p5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Backfill relation rows with their source note's accepted generation."""
op.add_column(
"relation_search_refresh",
sa.Column("publication_generation", sa.BigInteger(), nullable=True),
)
op.create_index(
"ix_relation_search_refresh_project_publication_generation",
"relation_search_refresh",
["project_id", "publication_generation"],
unique=False,
)
op.add_column(
"relation",
sa.Column(
"generation",
sa.BigInteger(),
nullable=False,
server_default=sa.text("0"),
),
)

connection = op.get_bind()
if connection.dialect.name == "postgresql":
op.execute(
"""
UPDATE relation
SET generation = note_content.db_version
FROM note_content
WHERE relation.from_id = note_content.entity_id
AND relation.project_id = note_content.project_id
"""
)
else:
op.execute(
"""
UPDATE relation
SET generation = COALESCE(
(
SELECT note_content.db_version
FROM note_content
WHERE note_content.entity_id = relation.from_id
AND note_content.project_id = relation.project_id
),
0
)
"""
)

op.create_index(
"ix_relation_project_from_generation",
"relation",
["project_id", "from_id", "generation"],
unique=False,
)


def downgrade() -> None:
"""Remove relation generation ownership."""
op.drop_index("ix_relation_project_from_generation", table_name="relation")
if op.get_bind().dialect.name == "postgresql":
op.drop_column("relation", "generation")
else:
with op.batch_alter_table("relation") as batch_op:
batch_op.drop_column("generation")

op.drop_index(
"ix_relation_search_refresh_project_publication_generation",
table_name="relation_search_refresh",
)
if op.get_bind().dialect.name == "postgresql":
op.drop_column("relation_search_refresh", "publication_generation")
else:
with op.batch_alter_table("relation_search_refresh") as batch_op:
batch_op.drop_column("publication_generation")
1 change: 1 addition & 0 deletions src/basic_memory/deps/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ async def get_index_file_executor_v2_external(
raise RuntimeError("Index file executor requires a project-scoped entity repository")

batch_indexer = BatchIndexer(
project_id=project_id,
app_config=app_config,
entity_service=entity_service,
entity_repository=entity_repository,
Expand Down
59 changes: 56 additions & 3 deletions src/basic_memory/index/local_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
FileIndexOperation,
FileIndexResult,
IndexEntitySearchWriter,
IndexedEntity,
IndexInputFile,
IndexingBatchResult,
StorageIndexFileWriter,
Expand Down Expand Up @@ -321,21 +322,33 @@ async def index_markdown_file(
resolve_relations=True,
refresh_unchanged_derived_state=existing is not None,
)
outcome = await self.note_content_reconciler.reconcile(
reconciliation = await self.note_content_reconciler.reconcile(
entity=synced.entity,
markdown_content=synced.markdown_content,
observed_at=synced.updated_at,
source=source,
anchor=anchor,
)
if outcome == "current":
# Trigger: the indexed file is older than the accepted DB generation.
# Why: retrying identical bytes cannot make that file lineage current.
# Outcome: preserve accepted relations and finish without publishing this pass.
if reconciliation.status == "deferred":
break

if reconciliation.generation is not None:
generation_is_current = await self.publish_relation_generation(
synced,
generation=reconciliation.generation,
)
if generation_is_current:
break

logger.info(
"Retrying markdown index after concurrent accepted note write: {}",
"Retrying markdown index without a current relation generation: {}",
file_path,
entity_id=synced.entity.id,
attempt=attempt + 1,
reconciliation_status=reconciliation.status,
)
else:
raise NoteContentChangedDuringIndexError(
Expand Down Expand Up @@ -428,6 +441,39 @@ async def index_regular_file(
operation=operation,
)

async def publish_relation_generation(
self,
synced: SyncedMarkdownFile,
*,
generation: int,
) -> bool:
"""Publish one claimed relation set, then preserve legacy inline resolution."""
indexed = IndexedEntity(
path=synced.file_path,
entity_id=synced.entity.id,
permalink=synced.entity.permalink,
checksum=synced.checksum,
content_type=synced.content_type,
markdown_content=synced.markdown_content,
relations=synced.relations,
resolve_relations=synced.resolve_relations,
)
generation_is_current = await self.batch_indexer.publish_relation_generation(
indexed,
generation=generation,
)
if generation_is_current and synced.resolve_relations:
await self.batch_indexer.resolve_relation_targets(
[synced.entity.id],
max_concurrent=1,
)
if generation_is_current:
await self.batch_indexer.refresh_indexed_entity_search(
indexed,
generation=generation,
)
return generation_is_current

async def index_current_markdown_file(
self,
path: RuntimeFilePath,
Expand Down Expand Up @@ -488,6 +534,8 @@ async def index_current_markdown_file(
content_type=self.file_service.content_type(path),
updated_at=file_metadata.modified_at,
size=file_metadata.size,
relations=(),
resolve_relations=resolve_relations,
)

return await self.index_changed_markdown_file(
Expand Down Expand Up @@ -536,6 +584,8 @@ async def refresh_unchanged_markdown_file(
content_type=self.file_service.content_type(input_file.path),
updated_at=file_metadata.modified_at,
size=file_metadata.size,
relations=indexed.relations,
resolve_relations=resolve_relations,
)

async def index_changed_markdown_file(
Expand Down Expand Up @@ -602,6 +652,8 @@ async def index_changed_markdown_file(
content_type=self.file_service.content_type(input_file.path),
updated_at=file_metadata.modified_at,
size=file_metadata.size,
relations=indexed.relations,
resolve_relations=resolve_relations,
)


Expand Down Expand Up @@ -671,6 +723,7 @@ async def build_local_index_project_dependencies(
app_config=app_config,
)
batch_indexer = BatchIndexer(
project_id=project.id,
app_config=app_config,
entity_service=entity_service,
entity_repository=entity_repository,
Expand Down
Loading
Loading