Skip to content

fix(index): key rows on (app_id, project_id, id) - #481

Merged
gloryfromca merged 23 commits into
mainfrom
refactor/index-record-declarations
Oct 12, 2026
Merged

gloryfromca merged 23 commits into
mainfrom
refactor/index-record-declarations

Conversation

@gloryfromca

@gloryfromca gloryfromca commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Derived-index rows (LanceDB / Milvus) were merged on id alone, and id is only unique within one (app_id, project_id): a user's episodes, atomic facts, agent cases, foresights, skills and profile written under a second project replaced the first project's rows (markdown was never affected). Every business table now declares PRIMARY_KEY = ("app_id", "project_id", "id") on a backend-neutral mixin; LanceDB merges on it and Milvus keys its collections on the composed __pk column; every single-row read and write goes through primary_key(app_id, project_id, id). A multi-project LanceDB root indexed before this release gets its lost rows back by itself: the first full pass after upgrading (server start, or cascade sync) re-reconciles every indexed markdown file once and records it in .index/.index_reconcile_version; cascade sync --force-all is the manual entry to the same pass.

The first four commits are the behaviour-neutral refactor (declarations onto IndexRecordMeta, record models to infra/persistence/records/, startup check); the rest is the fix. Full write-up, acceptance cases and evidence: .work_context/profile_skill_scope_collision/ (local, not committed).

Area

  • Architecture method
  • Benchmark
  • Use case
  • Documentation
  • Developer experience
  • CI, build, or release

Verification

make lint                      # ruff + import-linter (4 contracts kept) + repo gates + openapi drift
uv run pytest tests/unit       # 2655 passed, 4 skipped
uv run pytest tests/integration# 186 passed, 7 skipped (LanceDB backend)
uv run pytest tests/e2e/test_get_endpoint_e2e.py -k lancedb   # 23 passed
Real-host acceptance (acceptance-cases.md, L/E/U/M tiers, 35/35 cases): two projects, one user, one agent;
  v1.4.1 roots upgraded in place; starting the new server on an old root recovers the 10 overwritten rows
  by itself (log: cascade_index_reconcile_enqueued count=8); interrupted --force-all converges; rebuild;
  failed-row requeue; backfill leaves the other project's vector byte-identical; Milvus tier on a local
  Docker Milvus 2.6: old collection refused at startup, rebuild, then two rows per table.

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

🤖 Generated with Claude Code

zhanghui and others added 15 commits October 9, 2026 17:20
TABLE_NAME / BM25_FIELDS were declared on BaseLanceTable yet consumed by
every derived-index backend through schema_for; a backend-neutral
declaration lived on a backend-specific class. IndexRecordMeta is their
new home, with PRIMARY_KEY declared alongside (no default on purpose —
a table that forgets it must fail loudly, not key on id). BaseLanceTable
mixes it in and keeps only the Lance mechanics.

No behaviour change: nothing reads PRIMARY_KEY yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
schema_for now reads PRIMARY_KEY off the model into IndexSchema.primary_key
and rejects a missing, empty, or unknown-column declaration; IndexField
loses its ad-hoc `primary` flag. All seven business tables declare
("app_id", "project_id", "id"); chassis test tables declare ("id",).

The Milvus adapter keeps `id` as its primary key for now (field name
check instead of the removed flag), so collection layout is unchanged.
Nothing writes by the declared key yet — that is the follow-up fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The seven derived-index record models lived under lancedb/tables although
Milvus consumes them too. They now live in infra/persistence/records, a
package whose __init__ only re-exports — placing them under index/ would
close an import cycle, because the index facade loads the backends at
import time and the LanceDB backend imports the lancedb package, which
in turn needs the models.

Public names are unchanged: everos.infra.persistence.index and
everos.infra.persistence.lancedb re-export the same classes. import-linter
treats records.** as private internals like its siblings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The index facade now runs schema_for over every registered table in
startup() and ensure_business_indexes(), so a table without a
PRIMARY_KEY fails the server boot and the cascade CLI with the same
message the write path will raise — on either backend. It lives on the
facade rather than in the LanceDB package because the latter cannot
import index.schema without closing the facade's import cycle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
LanceRepoBase.upsert now merges on the table's PRIMARY_KEY instead of
the single `id` column, so the same owner's records in two
(app_id, project_id) spaces no longer replace each other. The `by`
parameter is gone: the key is the table's declaration, not the caller's
choice, and a model without one is rejected with the same message
schema_for raises.

primary_key(app_id, project_id, row_id) joins the predicate helpers so a
single row can be addressed by its full key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IndexRepository, the router and both backends drop `add` (plain insert,
no business caller) and the `by` parameter of `upsert`. A contract test
pins both: every write to a business table goes through a merge on the
table's declared PRIMARY_KEY, and no caller can pick another key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Milvus allows one scalar primary key per collection, so the declared
PRIMARY_KEY columns are joined into a private `__pk` column
("app_id/project_id/id") that becomes the key; `id` is an ordinary column
again. The composition rejects a component containing the separator and
an over-long result. The same owner's rows in two projects therefore
coexist on Milvus as they now do on LanceDB.

find_topk_relevant_in_cluster hydrates dense-search hits by the full key
instead of by `id` alone, which could land on another space's row.

Existing collections lack `__pk`; verify_collection reports the drift and
`everos cascade rebuild` recreates them from markdown.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The five business reads that fetched a row by `id` alone — the digest
short-circuit in the profile, skill and knowledge-topic handlers, the
profile recaller and the profile branch of get — now look the row up by
(app_id, project_id, id), and the vector backfill patches a row by the
same key. By `id` alone each of them could land on another project's
row: the handlers would misreport an identical cross-project profile as
"skipped", search and get would return the other project's profile, and
backfill would rewrite both rows.

get_by_id leaves the repository contract and both backends; the core
LanceDB repository keeps its scalar lookup for chassis use only.
ProfileRecaller.fetch takes the scope explicitly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Rows that were overwritten before the index was keyed on
(app_id, project_id, id) do not come back on their own: a past day's
daily-log file never changes again, so the scanner never re-queues it.
`cascade sync --force-all` force-enqueues every row the state table
knows — done, failed and pending alike — and drains; each handler's
entry-level diff then re-embeds only the entries whose rows are missing
or changed, so a healthy single-project root costs nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/api.md says `id` is unique within (app_id, project_id); the storage
layout describes the declared PRIMARY_KEY instead of `<owner_id>_<entry_id>`;
the changelog records the fix, the Milvus rebuild and `cascade sync
--force-all`. A TODO points the scalar-index follow-up at #477.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Real EpisodeWriter, real EpisodeHandler, real temporary LanceDB: two
projects append the same owner's first entry of the day, the ids collide
by design, and the index keeps both rows. Mutating Episode.PRIMARY_KEY
back to ("id",) makes it fail with 1 == 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The vector backfill addressed its write by (app_id, project_id, id) but
still carried the freshly embedded vectors in per-batch maps keyed by id,
so two same-id rows from different projects in one batch swapped
payloads. Reflection deprecated atomic facts by parent_id + owner only,
and parent ids restart per project. Both now carry the scope. Milvus
update() refuses a primary-key column in the patch, since the re-upsert
would land under a new __pk and leave the old row behind.

Tests that reached the routed repositories through the removed add /
get_by_id now use upsert and find_one_where(primary_key(...)); the
contract test pins get_by_id's absence as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The cascade runbook and the CLI overview name `cascade sync --force-all`
and the rebuild a Milvus root needs once after upgrading; the
architecture table and the import-linter comment state the one
cross-subpackage carve-out, `lancedb -> records`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The partial-embed warning still asked whether row.id was in the map now
keyed by (app_id, project_id, id), so it always reported the primary
side missing. The Milvus integration lookups rewritten last round
looked rows up under the default scope while the fixture creates them
under test_app/test_project.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gloryfromca gloryfromca changed the title refactor(index): backend-neutral record declarations fix(index): key rows on (app_id, project_id, id) Oct 10, 2026
The how-to still pointed at infra/persistence/lancedb/tables/ and its
example table declared no key; a table written from it is refused at
startup. It now shows the neutral declarations and the facade write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gloryfromca
gloryfromca force-pushed the refactor/index-record-declarations branch from 2c5a626 to 025acad Compare October 10, 2026 02:30
zhanghui and others added 6 commits October 10, 2026 10:37
CI runners export FORCE_COLOR, so Typer splits the option name with
escape sequences and the substring check missed it. The hermetic env
now disables colour and the assertion strips escapes first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The neutral index contract used to reach LanceDB through a 469-line
wrapper in backends/lancedb.py that re-implemented every read on top of
the chassis repository. LanceIndexContract is now a mixin each per-table
repository inherits next to its chassis base: it renders the neutral
Predicate into LanceDB's where string on the way down and adds the reads
the chassis lacks (count_where, scan, the two search routes, the
find_by_* helpers for single-file tables). The package singletons are
the contract objects; the facade wraps them directly, and
backends/lancedb.py keeps only the lifecycle, symmetric with Milvus.

No behaviour change: unit 2653, integration 186, the LanceDB acceptance
groups and the import-linter contracts all pass unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
backends/ had shrunk to two fifty-line delegators. LanceIndexBackend and
MilvusIndexBackend now live in the lancedb and milvus packages as
`index_backend`; the facade picks one by `index.backend`, importing the
Milvus package only on that branch so pymilvus stays an optional extra.
import-linter whitelists that engine-selection edge in place of the
removed backends entries.

No behaviour change: unit 2653, integration 186, the acceptance groups
and the four import-linter contracts pass unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first full pass over a root (server start, or an unscoped
`cascade sync`) force-enqueues every ledger row once and records it in
`.index/.index_reconcile_version`, so a multi-project LanceDB root
indexed before 1.4.2 gets its overwritten rows back without an operator
step; `cascade sync --force-all` stays as the manual entry to the same
pass. A kinds-scoped sync does not trigger it, so backfill's skill-only
pass never leaves other kinds queued. `force_enqueue_all` keeps each
row's recorded mtime so the scanner does not queue the files a second
time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On a root without the marker, `cascade sync --force-all` queued the
whole ledger twice: once explicitly and once more inside the unscoped
`sync_once` that follows. The forced pass now goes through
`reconcile_index_once(force=True)`, which writes the marker, so the
second pass is a no-op. The docstring no longer presents the missing
lock as a safety property; cross-process exclusion is the OME lock the
server and the exclusive CLI commands hold.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Keeping each row's mtime on the forced re-enqueue also kept its
`retry_count`, so a row that had exhausted the cross-cycle budget was
re-failed by the worker without the handler running and its lost index
rows never came back. `force_enqueue` now resets the budget explicitly.

`cascade sync` and `cascade rebuild` keep draining until a pass processes
nothing: the worker bounds one drain at 5000 rows, which the ledger the
one-time reconcile queues after an upgrade can exceed, and the command
must not report success with rows still pending.

The reconcile marker is written as max(current, version), so an older
binary's forced pass cannot roll a newer root back; the docstrings state
what the pass repairs (missing rows and changed content digests) and
which lock window it runs in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gloryfromca
gloryfromca force-pushed the refactor/index-record-declarations branch from 457d8ac to fe26e7c Compare October 10, 2026 10:32
`cascade sync` now keeps calling `drain_once` until a pass processes
nothing, so the fake that checks the OME lock is held while the CLI
drains needs that method as well — and asserts the lock across both the
scan and the confirming drain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gloryfromca
gloryfromca merged commit 1805535 into main Oct 12, 2026
11 checks passed
@gloryfromca
gloryfromca deleted the refactor/index-record-declarations branch October 12, 2026 03:33
gloryfromca pushed a commit that referenced this pull request Oct 12, 2026
A real-host run of POST /cascade/quiesce on a 12000-row ledger left 50
rows behind with the previous commit: inside a server the background
worker claims batches too, so a pass of ours can find nothing pending
while the worker still holds the last batch; quiesce then cancels the
worker and that batch stays orphaned in `processing` until the next
start. The race predates #481; the ledger an upgrade re-queues made it
routine.

drain_fully now stops on the ledger, not on its own pass: it loops while
the queue summary reports pending rows (which count `processing`), waits
one poll interval when a pass finds nothing, and gives up after 120 s
without progress so a hung handler cannot hold the caller forever.

Same root, pre-fix binary: 11350 rows left; this commit: 0, 12000 done.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gloryfromca added a commit that referenced this pull request Oct 12, 2026
…487)

* fix(cascade): quiesce drains fully, the reconcile pass commits once

Follow-ups from the post-merge review of #481.

POST /cascade/quiesce called sync_once once, whose drain stops at the
worker's bound (max_passes x batch size = 5000 rows). The ledger the
one-time reconcile queues after an upgrade can exceed that, so quiesce
froze a partial index while reporting quiesced=true. drain_fully now
lives on the orchestrator and the CLI's sync / rebuild and the API route
share it.

force_enqueue_all committed once per ledger row (~0.9 ms each), holding
the server's startup for the whole pass on a large root; it now issues
every row in one session with one commit (0.32 ms/row measured on 2000
rows), handing out LSNs in ledger order.

CHANGELOG and the cascade runbook name the two roots the automatic pass
does not cover: a server started with EVEROS_DISABLE_CASCADE=1 (run
cascade sync --force-all once) and a multi-project root where reflection
ran before 1.4.2 (cascade rebuild once; the pass does not clear the
deprecated_by stamps one project's reflection left on another's rows).

import-linter's forbidden list drops the deleted backends package.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(cascade): drain_fully waits for the worker's in-flight batch

A real-host run of POST /cascade/quiesce on a 12000-row ledger left 50
rows behind with the previous commit: inside a server the background
worker claims batches too, so a pass of ours can find nothing pending
while the worker still holds the last batch; quiesce then cancels the
worker and that batch stays orphaned in `processing` until the next
start. The race predates #481; the ledger an upgrade re-queues made it
routine.

drain_fully now stops on the ledger, not on its own pass: it loops while
the queue summary reports pending rows (which count `processing`), waits
one poll interval when a pass finds nothing, and gives up after 120 s
without progress so a hung handler cannot hold the caller forever.

Same root, pre-fix binary: 11350 rows left; this commit: 0, 12000 done.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: zhanghui <zhanghui@shanda.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
gloryfromca added a commit that referenced this pull request Oct 14, 2026
Pre-release so this cycle's index rework — per-project primary keys
(#481), the quiesce drain (#487), the capped LanceDB caches and the
query embedding budget (#496) — is exercised against a real PyPI
resolution before 1.4.2 goes out. The release pipeline's own smoke
installs the built wheel with `--no-deps`, so it cannot catch a
resolution break on its own.

`docs/openapi.json` moves with the version: `app.openapi()["info"]
["version"]` reads `everos.__version__` (installed package metadata),
and `make lint` fails on a stale dump.

Testers must pass `--pre`; pip skips pre-releases by default and would
silently install 1.4.1 instead.

Co-authored-by: zhanghui <zhanghui@shanda.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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