Repository navigation
fix(index): key rows on (app_id, project_id, id) - #481
Merged
Merged
Conversation
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>
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
force-pushed
the
refactor/index-record-declarations
branch
from
October 10, 2026 02:30
2c5a626 to
025acad
Compare
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
force-pushed
the
refactor/index-record-declarations
branch
from
October 10, 2026 10:32
457d8ac to
fe26e7c
Compare
`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>
Kendrick-Song
approved these changes
Oct 12, 2026
8 of 12 tasks
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>
This was referenced Oct 12, 2026
Closed
Closed
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Derived-index rows (LanceDB / Milvus) were merged on
idalone, andidis 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 declaresPRIMARY_KEY = ("app_id", "project_id", "id")on a backend-neutral mixin; LanceDB merges on it and Milvus keys its collections on the composed__pkcolumn; every single-row read and write goes throughprimary_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, orcascade sync) re-reconciles every indexed markdown file once and records it in.index/.index_reconcile_version;cascade sync --force-allis the manual entry to the same pass.The first four commits are the behaviour-neutral refactor (declarations onto
IndexRecordMeta, record models toinfra/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
Verification
Checklist
main..envfiles, dependency folders, or generated output.Notes for Reviewers
reconcile_index_once, marker-guarded, no lock; a healthy root pays one read pass and no embedding: 1568 entries in under a second locally); Milvus roots must runeveros cascade --root <root> rebuild --yesonce (the server refuses to start on an old collection and says so). CHANGELOG has both.handlers/user_profile.py,search/recall/profile.pyandrecords/user_profile.pyare touched here; @juwei.yue'sfeat/llm-multiround-retrievaledits the same files (profile multi-subject) and will need a rebase on top of this.add/get_by_idany more: the only write is a merge on the declared key. The chassisLanceRepoBase.addstays for non-business tables and is unreachable from the business layers (import-linter).cascade rebuildhas no--rootalias (sibling commands do).🤖 Generated with Claude Code