|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import asyncio |
5 | 6 | from collections.abc import Mapping |
6 | 7 | from dataclasses import dataclass, replace |
7 | 8 | from uuid import UUID |
8 | 9 |
|
| 10 | +from loguru import logger |
9 | 11 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker |
10 | 12 |
|
11 | 13 | from basic_memory import db, file_utils |
|
40 | 42 |
|
41 | 43 | LOCAL_NOTE_CONTENT_TENANT_ID = UUID(int=0) |
42 | 44 |
|
| 45 | +# Strong references to in-flight deferred materialization tasks. asyncio only |
| 46 | +# keeps weak references to tasks, so without this a fire-and-forget background |
| 47 | +# materialization could be garbage-collected mid-write. |
| 48 | +_pending_materializations: set[asyncio.Task[None]] = set() |
| 49 | + |
43 | 50 |
|
44 | 51 | def note_content_payload_file_path( |
45 | 52 | payload: RuntimeNoteContentResponsePayload, |
@@ -197,15 +204,58 @@ class LocalNoteContentMaterializationProvider: |
197 | 204 | session_maker: async_sessionmaker[AsyncSession] |
198 | 205 | file_service: FileService |
199 | 206 | file_indexer: IndexFileExecutor | None = None |
| 207 | + test_mode: bool = False |
200 | 208 |
|
201 | 209 | async def materialize_write_change( |
202 | 210 | self, |
203 | 211 | accepted: RuntimeAcceptedNoteChange[RuntimeNoteContentResponsePayload], |
204 | 212 | ) -> RuntimeAcceptedNoteChange[RuntimeNoteContentResponsePayload]: |
205 | | - """Write accepted note content to the local filesystem when needed.""" |
| 213 | + """Materialize an accepted note write OFF the accept path. |
| 214 | +
|
| 215 | + Cloud/local parity (DO NOT UNDO): cloud's materialize_write_change |
| 216 | + enqueues a PGQ job and returns immediately, letting Tigris object storage |
| 217 | + + indexing catch up asynchronously because S3 writes are slow. Locally we |
| 218 | + mirror that with an in-process background task. The accept has already |
| 219 | + persisted note_content (the write/read-through cache that serves reads); |
| 220 | + here we only schedule writing the markdown file (the source of truth) and |
| 221 | + indexing it. Writing + indexing the file is the heavy part of a write, so |
| 222 | + doing it inline reintroduces a ~3x write-load regression |
| 223 | + (benchmarks/docs/write-load-benchmark.md). |
| 224 | +
|
| 225 | + PARITY INVARIANT: production must defer. Test mode runs inline ONLY so |
| 226 | + tests can assert file/search state synchronously — never make the |
| 227 | + production path synchronous to "simplify" this. |
| 228 | + """ |
206 | 229 | if accepted.materialization is None: |
207 | 230 | return accepted |
| 231 | + if self.test_mode: |
| 232 | + return await self._materialize_write_now(accepted) |
| 233 | + self._schedule_materialization(accepted) |
| 234 | + return accepted |
| 235 | + |
| 236 | + def _schedule_materialization( |
| 237 | + self, |
| 238 | + accepted: RuntimeAcceptedNoteChange[RuntimeNoteContentResponsePayload], |
| 239 | + ) -> None: |
| 240 | + task = asyncio.create_task(self._materialize_write_logged(accepted)) |
| 241 | + _pending_materializations.add(task) |
| 242 | + task.add_done_callback(_pending_materializations.discard) |
| 243 | + |
| 244 | + async def _materialize_write_logged( |
| 245 | + self, |
| 246 | + accepted: RuntimeAcceptedNoteChange[RuntimeNoteContentResponsePayload], |
| 247 | + ) -> None: |
| 248 | + try: |
| 249 | + await self._materialize_write_now(accepted) |
| 250 | + except Exception: # pragma: no cover - defensive background guard |
| 251 | + logger.exception("Local note materialization failed") |
208 | 252 |
|
| 253 | + async def _materialize_write_now( |
| 254 | + self, |
| 255 | + accepted: RuntimeAcceptedNoteChange[RuntimeNoteContentResponsePayload], |
| 256 | + ) -> RuntimeAcceptedNoteChange[RuntimeNoteContentResponsePayload]: |
| 257 | + if accepted.materialization is None: # pragma: no cover - guarded by caller |
| 258 | + return accepted |
209 | 259 | storage = LocalNoteContentStorage(self.file_service) |
210 | 260 | cleanup_enqueuer = InlineNoteFileDeleteEnqueuer(storage) |
211 | 261 | result = await run_note_materialization( |
|
0 commit comments