Skip to content

Project indexing can delete accepted-but-unmaterialized notes (production data loss) #1256

Description

@phernandez

Summary

A project-wide index can delete a valid DB-first note while its Markdown file is still waiting for asynchronous materialization.

This is confirmed production data loss. The caller receives 202 / Created, but the initial project index can later classify the accepted database row as an externally deleted file, remove Entity (cascading NoteContent), and leave no durable note or file.

Production Logfire issue #2489 is one observable interleaving. Other interleavings return status=missing normally and do not create an exception issue.

Production evidence

Observed on 2026-08-13 with Cloud release bc2e7e889e16552052094e21a99a7b03cb71005c and Core 0.22.2.dev219+38a2ed67 (38a2ed675f7f33f23be129f3948ff52ec5f023b3).

Representative fresh-project sequence, with tenant and content identity redacted:

  1. An MCP create_memory_project call created an empty project and immediately queued project-index job 9497726.
  2. A following MCP write_note accepted entity 10, NoteContent version 1, returned Created, and queued materialization job 9497727.
  3. PGQueuer picked both jobs at approximately 19:24:46Z.
  4. The index listed the project prefix from S3 and found zero objects.
  5. ChangeDetector.load_all_indexed_paths() nevertheless returned the new DB-only note, so delete planning reported one deleted file.
  6. Project-index maintenance locked and deleted the entity; ON DELETE CASCADE removed note_content.
  7. Materialization had already loaded both ORM rows. Its session.flush() then raised:
    sqlalchemy.orm.exc.StaleDataError: UPDATE statement on table 'note_content' expected to update 1 row(s); 0 were matched.
  8. Current tenant state contains neither entity 10 nor its note_content, and no later materialization job exists. This job failed before writing S3.

Traces:

The exception is not the only production signature. Over the latest 14-day query window, materialization emitted nine normal status=missing results across five tenants. At least two on 2026-08-13 align within one second with a same-tenant, same-project index deleting exactly one row:

This gives one exception-confirmed and two independently correlated production occurrences today. The other six missing outcomes may be legitimate delete-after-enqueue races and are not claimed as data loss without further correlation.

Root cause

The consistency model is DB-first for accepted Markdown, but project delete detection currently treats every indexed entity path as if its storage projection must already exist:

  • ChangeDetector.load_all_indexed_paths() delegates to EntityRepository.get_all_file_paths().
  • plan_file_changes() computes deletes as all_db_paths - storage_paths - moved_old_paths.
  • No NoteContent.file_write_status, db_version, or file_version state is carried into delete planning.
  • RepositoryProjectIndexMaintenanceStore defaults to TrustPlannedProjectIndexDeleteVerifier. The S3 runtime therefore trusts the scan verdict at apply time.
  • Project-index deletion correctly takes the shared NoteContent lock before deleting Entity, but locking cannot make the deletion semantically valid; it only decides which transaction wins.

A newly accepted note is a valid state even when its file is absent. Treating storage as authoritative during that window destroys the durable source of truth.

The StaleDataError is a secondary ORM check-then-flush race: preflight reads live models, project-index deletion removes them, and the generated UPDATE asserts one matched row. Converting only that exception to a missing/no-op result would hide the error but would not prevent data loss.

Required design

Keep asynchronous materialization. Do not make the request wait for S3 and do not use timing delays or queue ordering as the correctness boundary.

The fix should establish both invariants:

  1. Project-index delete planning/application cannot delete accepted content whose current file projection is not proven synchronized.
  2. Materialization preflight handles a legitimate concurrent explicit delete without leaking StaleDataError.

A likely shape is:

  • load typed delete candidates rather than bare paths, carrying the minimum accepted/materialized lineage needed to distinguish storage-owned indexed files from DB-owned pending content;
  • exclude pending, writing, failed, and otherwise unsynchronized current DB versions from external-delete planning;
  • revalidate the same lineage with a guarded SQL predicate at delete application so an accepted write or materialization transition after the scan cannot be deleted from a stale plan;
  • use a guarded lower-level UPDATE ... WHERE entity/project/version/checksum still match RETURNING ... (or equivalent row-locking CAS) for materialization preflight and return a detached value object; zero rows must be classified from current state rather than becoming an ORM row-count exception.

The exact predicate needs to preserve non-Markdown/storage-origin entities and true external deletion of previously synchronized Markdown.

Acceptance criteria

  • A Postgres concurrency regression runs the real fresh-project sequence: initial project index is delayed, write_note is accepted, and index plus materialization are released concurrently.
  • After all work settles, the entity, note_content, search state, and S3 object all exist and agree on current path/checksum/version.
  • The regression fails against the pre-fix implementation by deleting the accepted note.
  • Both race orders are covered: index attempts deletion before materialization claims the row, and after preflight has begun.
  • Project delete planning excludes accepted-but-unmaterialized current content without relying on age, sleep, FIFO job order, or process-local locks.
  • Delete application revalidates its snapshot/CAS state so a concurrent accepted write cannot be destroyed.
  • A genuinely externally deleted, previously synchronized file still removes its entity and derived search/vector rows.
  • An explicit user delete racing a queued materialization remains idempotent: the materializer reports a typed missing/stale outcome and guarded cleanup cannot remove a newer file.
  • Materialization preflight no longer leaks StaleDataError when its target disappears.
  • Cloud integration coverage exercises the S3/PGQueuer adapter composition, not only an in-memory planner.
  • Production acceptance queries show no same-tenant/project index deletion followed by materialization missing/error for the deleted generation.
  • Identify affected production writes and define recovery before closing; do not assume exception issue count equals impact.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcloudBasic Memory CloudproductionObserved in production

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions