Skip to content

Commit ed2636d

Browse files
phernandezclaude
andcommitted
feat(sync): recover materializations stuck in writing/pending on startup
An accepted local note write returns before its markdown file is written: accept_write marks note_content "pending", the materialization preflight flips it to "writing", and the publisher records "synced" only after the file lands. A process crash between those points leaves the row stuck forever and the source-of-truth file never (re)materialized. Add a startup recovery sweep that re-drives these rows: - NoteContentRepository.find_stuck_materializations() returns project-scoped rows whose file_write_status is writing/pending. - recover_stuck_materializations() rebuilds a RuntimeNoteMaterializationJobRequest from each stuck row's own db_version/db_checksum and runs it through the standard preflight/writer/publisher/status-publisher via run_recovery_materialization(). The db_version compare-and-set guard makes this safe: an older recovery attempt can never revert a newer accepted write or clobber an unexpected file. Non-fatal per row so one poisoned note cannot block startup for the rest of the project. - initialize_file_indexing runs recover_project_materializations() once per locally-syncable project before the watch service serves, so files are re-written ahead of the initial project index scan. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 0f67ecd commit ed2636d

6 files changed

Lines changed: 530 additions & 1 deletion

File tree

src/basic_memory/cloud/note_content_materialization.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
RuntimeAcceptedNoteChange,
3131
RuntimeAcceptedNoteResponse,
3232
RuntimeNoteContentResponsePayload,
33+
RuntimeNoteMaterializationJobRequest,
3334
RuntimeNoteMaterializationResult,
3435
RuntimeNoteMaterializationStatus,
3536
plan_accepted_note_response,
@@ -125,6 +126,107 @@ async def drain_pending_materializations() -> None:
125126
await _materialization_pool.join()
126127

127128

129+
# --- Startup Recovery ---
130+
# accept_write marks note_content "pending", then the materialization preflight
131+
# flips it to "writing" before the file is written and the publisher records
132+
# "synced". If the process dies between those points the row is stuck forever and
133+
# the source-of-truth markdown file is never written. On the next startup we
134+
# re-drive every stuck row. The db_version compare-and-set guard in the preflight
135+
# and publisher makes this unconditionally safe: an older recovery attempt can
136+
# never overwrite a newer accepted write or its file, so recovery re-materializes
137+
# without first checking whether some other writer already caught up.
138+
139+
# Synthetic provenance stamped on recovered writes so operators can tell a
140+
# crash-recovery materialization apart from a normal accept-path write in logs
141+
# and object metadata.
142+
RECOVERY_NOTE_CHANGE_SOURCE = "note-content-materialization-recovery"
143+
RECOVERY_NOTE_ACTOR_NAME = "startup-recovery"
144+
145+
146+
async def run_recovery_materialization(
147+
request: RuntimeNoteMaterializationJobRequest,
148+
*,
149+
session_maker: async_sessionmaker[AsyncSession],
150+
file_service: FileService,
151+
) -> RuntimeNoteMaterializationResult:
152+
"""Re-drive one stuck materialization through the standard guarded write path.
153+
154+
Uses the same preflight/writer/publisher/status-publisher as an accept-path
155+
write, so the db_version and file-conflict guards apply unchanged. No cleanup
156+
paths: a recovery request carries no old-file move, so nothing is deleted.
157+
"""
158+
storage = LocalNoteContentStorage(file_service)
159+
return await run_note_materialization(
160+
request,
161+
preflight=RepositoryNoteMaterializationPreflight(session_maker=session_maker),
162+
writer=ContentStoreNoteMaterializationFileWriter(storage),
163+
publisher=RepositoryNoteMaterializationPublisher(session_maker=session_maker),
164+
status_publisher=RepositoryNoteMaterializationStatusPublisher(session_maker=session_maker),
165+
cleanup_enqueuer=InlineNoteFileDeleteEnqueuer(storage),
166+
)
167+
168+
169+
async def recover_stuck_materializations(
170+
*,
171+
session_maker: async_sessionmaker[AsyncSession],
172+
file_service: FileService,
173+
project_id: int,
174+
) -> int:
175+
"""Re-drive every note materialization stuck in writing/pending for a project.
176+
177+
Meant to run once per project at startup, before serving. Non-fatal per row:
178+
a single row that raises is logged and skipped so one poisoned note cannot
179+
block startup recovery for the rest of the project. Returns the number of rows
180+
that reached a written file state.
181+
"""
182+
async with db.scoped_session(session_maker) as session:
183+
stuck_rows = await NoteContentRepository(
184+
project_id=project_id
185+
).find_stuck_materializations(session)
186+
187+
if not stuck_rows:
188+
return 0
189+
190+
logger.info(
191+
"Recovering stuck note materializations",
192+
project_id=project_id,
193+
stuck_count=len(stuck_rows),
194+
)
195+
recovered = 0
196+
for row in stuck_rows:
197+
# Rebuild the queue request from the row's own accepted db_version/db_checksum
198+
# so the preflight guard matches the current accepted state; if a newer write
199+
# has since advanced the row, the guard trips and this attempt no-ops.
200+
request = RuntimeNoteMaterializationJobRequest(
201+
project_id=project_id,
202+
entity_id=row.entity_id,
203+
db_version=int(row.db_version),
204+
db_checksum=str(row.db_checksum),
205+
actor_name=RECOVERY_NOTE_ACTOR_NAME,
206+
source=RECOVERY_NOTE_CHANGE_SOURCE,
207+
)
208+
try:
209+
result = await run_recovery_materialization(
210+
request,
211+
session_maker=session_maker,
212+
file_service=file_service,
213+
)
214+
except Exception:
215+
# Trigger: one row's materialization raised (storage/DB error).
216+
# Why: recovery is best-effort startup cleanup; the version guard makes
217+
# a later retry safe, so one bad row must not abort the whole sweep.
218+
# Outcome: log and continue to the next stuck row.
219+
logger.exception(
220+
"Failed to recover stuck note materialization",
221+
project_id=project_id,
222+
entity_id=row.entity_id,
223+
)
224+
continue
225+
if result.status is RuntimeNoteMaterializationStatus.written:
226+
recovered += 1
227+
return recovered
228+
229+
128230
def note_content_payload_file_path(
129231
payload: RuntimeNoteContentResponsePayload,
130232
) -> RuntimeFilePath | None:

src/basic_memory/repository/note_content_repository.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from dataclasses import dataclass
44
from datetime import datetime
55
from pathlib import Path
6-
from typing import Any, Mapping, Optional, cast
6+
from typing import Any, Mapping, Optional, Sequence, cast
77

88
from sqlalchemy import select, update
99
from sqlalchemy.engine import CursorResult
@@ -326,6 +326,23 @@ async def update_state_fields(
326326
raise ValueError(f"Can't find NoteContent for entity {entity_id} after update")
327327
return updated
328328

329+
async def find_stuck_materializations(
330+
self, session: AsyncSession
331+
) -> Sequence[NoteContent]:
332+
"""Return accepted notes whose file write never completed.
333+
334+
``accept_write`` marks a row ``pending`` and the materialization preflight
335+
flips it to ``writing`` before the file is written and the publisher records
336+
``synced``/``written``. A process crash between those points leaves the row
337+
stuck in ``writing``/``pending`` forever and the source-of-truth markdown
338+
file never (re)materialized. Startup recovery re-drives exactly these rows;
339+
the db_version compare-and-set guard in the publisher keeps the retry safe
340+
(an older recovery attempt can never revert a newer accepted write).
341+
"""
342+
query = self.select().where(NoteContent.file_write_status.in_(("writing", "pending")))
343+
result = await session.execute(query)
344+
return list(result.scalars().all())
345+
329346
async def delete_by_entity_id(self, session: AsyncSession, entity_id: int) -> bool:
330347
"""Delete note_content by entity identifier."""
331348
note_content = await self.select_by_id(session, entity_id)

src/basic_memory/services/initialization.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import asyncio
88
import os
99
import sys
10+
from pathlib import Path
1011
from typing import TYPE_CHECKING, Protocol
1112

1213

@@ -20,6 +21,8 @@
2021
)
2122

2223
if TYPE_CHECKING:
24+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
25+
2326
from basic_memory.index.local_project import LocalProjectIndexRuntime
2427

2528

@@ -51,6 +54,43 @@ async def run_initial_project_index(
5154
)
5255

5356

57+
async def recover_project_materializations(
58+
project: Project,
59+
session_maker: "async_sessionmaker[AsyncSession]",
60+
) -> None:
61+
"""Re-drive note materializations a crash left stuck for one project.
62+
63+
A local note write returns before its markdown file is written; a crash
64+
between accepting the DB snapshot and writing the file leaves note_content
65+
stuck in writing/pending and the source-of-truth file missing. Runs once at
66+
startup, before the watch service serves, so the file is (re)written and then
67+
picked up by the initial project index. Non-fatal: a recovery failure must not
68+
block startup, so it is logged and startup continues.
69+
"""
70+
from basic_memory.cloud.note_content_materialization import recover_stuck_materializations
71+
from basic_memory.services.file_service import FileService
72+
73+
try:
74+
# FileService needs only base_path to write the accepted markdown bytes;
75+
# the markdown_processor/app_config are unused on the materialization path.
76+
file_service = FileService(Path(project.path))
77+
recovered = await recover_stuck_materializations(
78+
session_maker=session_maker,
79+
file_service=file_service,
80+
project_id=project.id,
81+
)
82+
if recovered:
83+
logger.info(
84+
"Recovered stuck note materializations on startup",
85+
project=project.name,
86+
recovered=recovered,
87+
)
88+
except Exception as e: # pragma: no cover - defensive startup guard
89+
logger.error(
90+
f"Error recovering stuck materializations for project {project.name}: {e}"
91+
)
92+
93+
5494
async def initialize_database(app_config: BasicMemoryConfig) -> None:
5595
"""Initialize database with migrations handled automatically by get_or_create_db.
5696
@@ -177,6 +217,12 @@ async def initialize_file_indexing(
177217
active_projects = [p for p in active_projects if p.name not in skip]
178218
logger.info(f"Skipping projects that are not locally indexable: {skip}")
179219

220+
# Recover materializations a crash left stuck (writing/pending) before serving.
221+
# Runs synchronously here so the source-of-truth files are re-written before the
222+
# initial project index scans them; bounded by the count of stuck rows.
223+
for project in active_projects:
224+
await recover_project_materializations(project, session_maker)
225+
180226
# Start indexing for all projects as background tasks (non-blocking)
181227
async def index_project_background(project: Project):
182228
"""Index a single project in the background."""

0 commit comments

Comments
 (0)