Skip to content

Commit 642f2d5

Browse files
committed
fix(sync): recover a crash between file write and publish instead of flagging a conflict
The startup recovery sweep (:448) re-drives note_content rows stuck in 'writing'/'pending'. A crash after the file write but before the DB publish leaves the row 'writing' with the correct accepted content already on disk and file_checksum still None (new note) or the pre-write checksum (update). On re-drive the write guard saw a present file whose checksum did not match the expected previous checksum and raised RuntimeFileConflictError, so recovery marked the row 'external_change_detected' and never advanced file_version — recovery only actually worked when the file had never been written, arguably the rarer crash location. Add a same-content short-circuit to write_prepared_note_to_content_store: read the on-disk checksum once, and if it equals the checksum of the accepted content we would write, skip the redundant write and return the existing file's state so the publisher advances file_version and marks the row 'synced'. Only raise a conflict when the present file matches neither the accepted content nor the expected previous checksum (a genuine external edit). Extract the shared runtime_file_conflict helper so assert_runtime_file_matches_expected and the write path apply identical conflict logic from a single checksum read. Adds a regression test seeding a stuck 'writing' row whose accepted content is already on disk and asserting recovery reaches 'synced' with file_version advanced. Found by adversarial review of the recovery sweep. Signed-off-by: phernandez <paul@basicmachines.co>
1 parent ed2636d commit 642f2d5

4 files changed

Lines changed: 115 additions & 23 deletions

File tree

src/basic_memory/cloud/note_content_materialization.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,15 @@ async def drain_pending_materializations() -> None:
129129
# --- Startup Recovery ---
130130
# accept_write marks note_content "pending", then the materialization preflight
131131
# 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.
132+
# "synced". If the process dies anywhere between those points the row is stuck
133+
# forever: the crash may land before the file write (nothing on disk) or after it
134+
# but before publish (the correct accepted file is already on disk, row still
135+
# "writing"). On the next startup we re-drive every stuck row. The write path
136+
# short-circuits when the accepted content is already on disk, so the
137+
# crash-after-write case publishes to "synced" instead of tripping the
138+
# external-change guard. The db_version compare-and-set guard in the preflight and
139+
# publisher makes recovery unconditionally safe: an older recovery attempt can
140+
# never overwrite a newer accepted write or its file.
138141

139142
# Synthetic provenance stamped on recovered writes so operators can tell a
140143
# crash-recovery materialization apart from a normal accept-path write in logs

src/basic_memory/runtime/note_content.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,19 +1173,37 @@ async def read_runtime_file_checksum(
11731173
return await reader.compute_checksum(file_path)
11741174

11751175

1176+
def runtime_file_conflict(
1177+
actual_checksum: RuntimeFileChecksum | None,
1178+
expected_checksum: RuntimeFileChecksum | None,
1179+
file_path: RuntimeFilePath,
1180+
) -> RuntimeFileConflict | None:
1181+
"""Return a conflict when a present file does not match the expected checksum.
1182+
1183+
An absent file (``actual_checksum is None``) never conflicts. A present file
1184+
conflicts unless the caller expected exactly that checksum — a ``None``
1185+
expectation (a fresh note that assumes no file) always conflicts with a
1186+
present file.
1187+
"""
1188+
if actual_checksum is None:
1189+
return None
1190+
if expected_checksum is None or actual_checksum != expected_checksum:
1191+
return RuntimeFileConflict(
1192+
file_path=file_path,
1193+
expected_checksum=expected_checksum,
1194+
actual_checksum=actual_checksum,
1195+
)
1196+
return None
1197+
1198+
11761199
async def assert_runtime_file_matches_expected(
11771200
reader: RuntimeFileChecksumReader,
11781201
expected: RuntimeExpectedFileState,
11791202
) -> None:
11801203
"""Raise when a guarded write would overwrite an unexpected runtime file."""
11811204
actual_checksum = await read_runtime_file_checksum(reader, expected.file_path)
1182-
if actual_checksum is None:
1183-
return
1184-
if expected.expected_checksum is None or actual_checksum != expected.expected_checksum:
1185-
raise RuntimeFileConflictError(
1186-
RuntimeFileConflict(
1187-
file_path=expected.file_path,
1188-
expected_checksum=expected.expected_checksum,
1189-
actual_checksum=actual_checksum,
1190-
)
1191-
)
1205+
conflict = runtime_file_conflict(
1206+
actual_checksum, expected.expected_checksum, expected.file_path
1207+
)
1208+
if conflict is not None:
1209+
raise RuntimeFileConflictError(conflict)

src/basic_memory/runtime/note_materialization.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
from datetime import datetime
77
from typing import Protocol
88

9+
from basic_memory.file_utils import compute_checksum
910
from basic_memory.runtime.note_content import (
10-
RuntimeExpectedFileState,
1111
RuntimeFileChecksumReader,
12+
RuntimeFileConflictError,
1213
RuntimeNoteMaterializationJobRequest,
13-
assert_runtime_file_matches_expected,
14+
read_runtime_file_checksum,
15+
runtime_file_conflict,
1416
)
1517
from basic_memory.runtime.storage import RuntimeFileChecksum, RuntimeFilePath
1618
from basic_memory.runtime.note_object_metadata import RuntimeNoteObjectMetadata
@@ -92,13 +94,34 @@ async def write_prepared_note_to_content_store(
9294
prepared_write: RuntimePreparedNoteWrite,
9395
) -> RuntimeWrittenFileState:
9496
"""Write one prepared accepted note after checking the expected file state."""
95-
await assert_runtime_file_matches_expected(
96-
content_store,
97-
RuntimeExpectedFileState(
97+
accepted_checksum = await compute_checksum(prepared_write.markdown_content)
98+
actual_checksum = await read_runtime_file_checksum(content_store, prepared_write.file_path)
99+
100+
# Trigger: the correct accepted content is already on disk — e.g. a crash
101+
# after the file write but before the DB publish left the note_content row
102+
# 'writing', and startup recovery re-drives the same write.
103+
# Why: the previous_file_checksum guard would otherwise misread that
104+
# already-correct file as an external conflict (previous_file_checksum is
105+
# None for a new note, or the pre-write checksum for an update), stranding
106+
# the row in 'external_change_detected' and never advancing file_version.
107+
# Outcome: skip the redundant write and return the existing file's state so
108+
# the publisher advances file_version and marks the row 'synced'.
109+
if actual_checksum is not None and actual_checksum == accepted_checksum:
110+
file_metadata = await content_store.get_file_metadata(prepared_write.file_path)
111+
return RuntimeWrittenFileState(
98112
file_path=prepared_write.file_path,
99-
expected_checksum=prepared_write.previous_file_checksum,
100-
),
113+
file_checksum=actual_checksum,
114+
file_updated_at=file_metadata.modified_at,
115+
)
116+
117+
# A present file that matches neither the accepted content nor the expected
118+
# previous checksum is a genuine external edit: refuse to overwrite it.
119+
conflict = runtime_file_conflict(
120+
actual_checksum, prepared_write.previous_file_checksum, prepared_write.file_path
101121
)
122+
if conflict is not None:
123+
raise RuntimeFileConflictError(conflict)
124+
102125
file_checksum = await content_store.write_file(
103126
prepared_write.file_path,
104127
prepared_write.markdown_content,

tests/cloud/test_note_content_materialization.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,54 @@ async def test_recover_stuck_materializations_does_not_overwrite_unexpected_file
547547
assert row.file_write_status == "external_change_detected"
548548

549549

550+
@pytest.mark.asyncio
551+
async def test_recover_stuck_materializations_publishes_already_written_file(
552+
session_maker,
553+
test_project: Project,
554+
sample_entity,
555+
file_service: FileService,
556+
) -> None:
557+
"""A crash after the file write but before publish is recovered, not a conflict.
558+
559+
The row is left 'writing' with file_checksum None while the correct accepted
560+
content is already on disk. Recovery must recognise the same content, skip the
561+
redundant write, and publish to 'synced' instead of raising an external-change
562+
conflict — the arguably-more-common crash location the guard alone mishandles.
563+
"""
564+
markdown_content = "# Recovered\n\nThe file was written before the crash.\n"
565+
await _seed_stuck_note_content(
566+
session_maker,
567+
project_id=test_project.id,
568+
entity_id=sample_entity.id,
569+
markdown_content=markdown_content,
570+
db_version=1,
571+
db_checksum="db-checksum-1",
572+
file_write_status="writing",
573+
)
574+
# Simulate the crash-after-write-before-publish window: the accepted content
575+
# is already on disk while the row still reads 'writing' (file_checksum None).
576+
target = file_service.base_path / sample_entity.file_path
577+
target.parent.mkdir(parents=True, exist_ok=True)
578+
target.write_text(markdown_content, encoding="utf-8")
579+
580+
recovered = await recover_stuck_materializations(
581+
session_maker=session_maker,
582+
file_service=file_service,
583+
project_id=test_project.id,
584+
)
585+
586+
assert recovered == 1
587+
assert target.read_text(encoding="utf-8") == markdown_content
588+
589+
repository = NoteContentRepository(project_id=test_project.id)
590+
async with db.scoped_session(session_maker) as session:
591+
row = await repository.get_by_entity_id(session, sample_entity.id)
592+
assert row is not None
593+
assert row.file_write_status == "synced"
594+
assert row.file_checksum is not None
595+
assert row.file_version == 1
596+
597+
550598
@pytest.mark.asyncio
551599
async def test_run_recovery_materialization_does_not_revert_newer_accepted_version(
552600
session_maker,

0 commit comments

Comments
 (0)