Skip to content

fix(reflection): batch source deprecation, revert it on failure, cap sources per merge - #445

Open
L4XB wants to merge 3 commits into
EverMind-AI:mainfrom
L4XB:fix/reflection-deprecation-batch-and-cap
Open

L4XB wants to merge 3 commits into
EverMind-AI:mainfrom
L4XB:fix/reflection-deprecation-batch-and-cap

Conversation

@L4XB

@L4XB L4XB commented Sep 9, 2026

Copy link
Copy Markdown

Summary

Addresses the first two defects in #443 (Reflection V1 on large clusters):

Why the run failed. _deprecate_lance_episodes / _deprecate_lance_facts issued one LanceDB update per source episode and per fact parent, all started concurrently with asyncio.gather. Every update takes the table's write lock, so on a 228-member cluster the calls queued behind one another until the 15 s write-lock deadline (lancedb_write_lock_deadline_exceeded storm), the run was reported failed, and the updates that had already gone through stayed applied: sources half-deprecated toward a merge that was never committed to the cluster.

Changes

  1. Batched, sequential deprecation. Sources are deprecated in batches of 100 ids with one entry_id IN (...) (episodes) / parent_id IN (...) (facts) update each, issued sequentially. A merge now takes a handful of lock acquisitions instead of hundreds.
  2. Compensation on failure. The LanceDB rows are written first and the markdown frontmatter last, so the durable record only lists deprecations that were actually applied. If any write fails, the deprecated_by values written by this run (only rows pointing at this merge's entry id) are cleared before the error propagates. A failed run now leaves the sources, the markdown record and the cluster as they were; the merged episode itself is still detected by the existing orphan check on the next run, as before.
  3. Per-merge source cap. _MAX_SOURCES_PER_MERGE = 50 (module constant next to _MAX_CLUSTERS_PER_RUN). A larger cluster merges its existing merged episode(s) plus the oldest sources up to the cap; deferred members stay in the cluster, are neither merged nor deprecated in this run, and are folded in by later runs in update mode. The cluster's count after a merge now reflects the members that remain (previously hard-coded to 1). Wiring the cap into [reflection] config can follow if you prefer it configurable.

The third point in the issue (prompt / output-language configuration) is a product decision and is left out.

Area

  • Architecture method

Verification

uv run pytest tests/unit/test_memory          711 passed
make lint                                     ruff, import-linter, datetime discipline, openapi drift: OK

New tests in tests/unit/test_memory/test_reflection/test_orchestrator.py:

  • test_deprecate_lance_episodes_batches_updates: 250 ids → 3 updates with IN predicates covering every id.
  • test_deprecate_failure_reverts_applied_writes: the second batch raises VectorStoreBusyError → the markdown record is never patched, both applied batches are reverted (deprecated_by = NULL only where it equals this merge's entry id), and the error propagates so the run is still reported failed.
  • test_run_caps_sources_per_merge_and_keeps_the_rest: with the cap at 2 and a 3-member cluster, only the two oldest sources are reflected, deprecated and removed; the deferred member plus the merged episode remain (count=2).

Checklist

  • I kept the change scoped to the relevant area.
  • I am opening this from a separate branch, not pushing directly to main.
  • I updated docs, examples, or setup notes when behavior changed.
  • I added or updated tests when the change affects behavior.
  • I did not commit secrets, .env files, dependency folders, or generated output.
  • Active relative links in Markdown files resolve.

Notes for Reviewers

The reorder (LanceDB first, markdown last) is what makes the compensation complete without having to un-patch frontmatter. If you would rather keep markdown first, the revert still works for the LanceDB side, but the frontmatter would then re-apply the deprecation on the next cascade reconcile.

Refs #443

…sources per merge

Reflection deprecated the sources of a merge with one LanceDB update per
episode and per fact parent, all started concurrently. Every update
takes the table write lock, so on a large cluster (228 sources in the
report) the calls queued behind each other until they hit the 15 s
write-lock deadline; the run was then reported failed while the updates
that had already gone through stayed applied, leaving sources
half-deprecated toward a merge that was never committed to the cluster.

- Deprecate in batches of 100 ids with one `entry_id IN (...)` /
  `parent_id IN (...)` update each, issued sequentially, so a merge takes
  a handful of lock acquisitions instead of hundreds.
- Write the LanceDB rows first and the markdown frontmatter last, and on
  any failure clear the `deprecated_by` values this run wrote (only rows
  pointing at this merge's entry) before propagating the error. A failed
  run now leaves the sources, the markdown record and the cluster as
  they were.
- Cap one merge at 50 source episodes. A larger cluster merges its
  existing merged episode plus the oldest sources; the deferred members
  stay in the cluster (its count now reflects them) and are folded in by
  later runs in update mode.

Refs EverMind-AI#443 (the prompt/language configuration is left for a separate change)
@BrierAinz

Copy link
Copy Markdown

The LanceDB-first / markdown-last ordering is a good fix for the half-deprecated failure mode. I like that the compensation predicate clears only rows pointing at this merge entry id; that avoids undoing unrelated deprecations from another successful run.

One edge I would want covered, either by test or review note: retry after a compensated failure should be idempotent when the failed run created the merged episode row but did not patch markdown frontmatter. In other words, the orphan detection path should either remove/ignore that merge candidate before the retry or guarantee it cannot be selected as a source and produce a duplicate reflection.

The source cap also changes operator expectations a bit: a large cluster is now intentionally convergent across multiple runs, not atomic in one run. If there is run telemetry, exposing sources_deferred / cluster_remaining_count would make this much easier to distinguish from a silently incomplete reflection.

A run that fails after writing its merged episode (the deprecation
writes fail and are reverted) leaves that episode live and outside the
cluster. It is never loaded as a source, since sources come from cluster
membership, but the retry wrote a second merged episode beside it, so
the cluster ended with two live merges of the same sources.

Orphan detection moves from the start of the run, where it only logged,
to the deprecation step: live merged episodes of the cluster that are
neither current members nor this run's merge are deprecated toward the
new merge in the same writes, and reverted with them if those fail, so a
retry converges on one live merged episode.
With the per-merge source cap a large cluster converges over several
runs rather than in one. reflection_deprecated now carries
cluster_remaining_count (members this merge left for a later run) and
retired_orphan_count (merged episodes of failed runs retired here).
@L4XB

L4XB commented Sep 23, 2026

Copy link
Copy Markdown
Author

@BrierAinz Good catch. The edge was real, so I fixed it rather than just adding a note.

Retry after a compensated failure. The failed run's merged episode stays live and outside the cluster. It can never be selected as a source, because sources come from cluster membership and the membership update is the step that never ran. But the retry wrote a second merged episode beside it. A test that makes the first deprecation write miss its lock deadline, lets compensation revert it, and then retries ended with two live merged episodes at the previous head:

assert [r.entry_id for r in live] == [merged_id]
AssertionError: assert ['ep_20260923_00000004', 'ep_20260923_00000005'] == ['ep_20260923_00000005']

9f6d620 moves orphan detection from the start of the run, where it only logged, into the deprecation step. Live merged episodes of the cluster that are neither current members nor this run's merge are deprecated toward the new merge in the same writes, and reverted with them if those fail, so a retry converges on one live merged episode. The orphan's markdown is patched like any source's: a merged episode is written to markdown first, so it always has an md_path. test_retry_after_failed_deprecation_leaves_one_merged_episode passes for both the lance and markdown failure points and fails without the change. The integration and orchestrator unit files pass (18 tests), and make lint's gates are clean.

Telemetry for the cap. A capped run already logs reflection_cluster_capped with deferred_count. f03d7f5 adds cluster_remaining_count (the members this merge left for a later run) and retired_orphan_count to reflection_deprecated, so a cluster that converges over several runs is visible as such.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants