Skip to content

fix(engine): close the 2 Lance 7.0.0 alignment failures (immutable PK + native namespace) - #236

Merged
aaltshuler merged 3 commits into
mainfrom
fix/lance7-manifest-migration
Jun 14, 2026
Merged

fix(engine): close the 2 Lance 7.0.0 alignment failures (immutable PK + native namespace)#236
aaltshuler merged 3 commits into
mainfrom
fix/lance7-manifest-migration

Conversation

@aaltshuler

@aaltshuler aaltshuler commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

The Lance 6→7 bump (#229) merged green-on-build but left 2 of ~140 engine tests
failing
— a clean build masked two runtime behavior changes. Both reproduced on
main (independent of any other branch). Diagnosed from Lance 7's own source.

1. Production bug — immutable unenforced primary key

omnigraph marks __manifest.object_id as Lance's unenforced PK for merge-insert
row-level CAS. Lance 7 (dataset/transaction.rs:2472-2480) makes that key
immutable once set — any re-write errors "cannot be changed once set", even
re-applying the same value. The v1→v2 internal-schema migration relied on Lance 6's
idempotent re-apply for crash-recovery (a crash after the field-set but before
the stamp bump re-enters the migration with the PK already present). Under v7 that
re-entry errors, so a real pre-v0.4.0 graph crashing in that window could never
finish migrating.

Fix: guard the set with schema().unenforced_primary_key().is_empty() — a
genuine first-set still runs, a re-set is skipped. Crash-idempotency restored by
construction. (db/manifest/migrations.rs.) The existing
test_publish_migrates_pre_stamp_manifest_to_current_version is the red→green
regression guard.

2. Test-only — native DirectoryNamespace decoupled

A surface-guard test pokes Lance's native DirectoryNamespace (omnigraph
production never uses it — its publisher writes __manifest directly). Lance 7's
check_table_status now reports omnigraph's manifest tables absent, so
list/describe/create all return TableNotFound (observed). No production
impact
— the guard was realigned to v7's behavior, which only strengthens its
thesis (the native namespace can't even enumerate omnigraph's tables now).

3. Docs — lance.md 7.0.0 stanza

The #229 stanza claimed "no Lance API surface omnigraph uses changed" (it verified
the build, not the suite). Corrected: documents both runtime changes and the lesson
a clean build is not a clean alignment; run cargo test --workspace before
declaring a Lance bump done.
Notes MR-A (delete_where → staged two-phase) is now
unblocked by v7 — separate follow-up.

Verification

  • db::manifest::tests: 28/28 green (was 26 + 2 failing).
  • cargo test --workspace --locked: 1386 passed, 0 failed — the regressions are
    closed and nothing else broke.

🤖 Generated with Claude Code

Greptile Summary

This PR fixes two Lance 7.0.0 alignment failures that were missed by the original build-only verification in #229. The production fix guards migrate_v1_to_v2 with schema().unenforced_primary_key().is_empty() to restore crash-idempotency: Lance 7 now errors on any re-write of the unenforced PK (even re-applying the same value), so a crash between the field-set and the stamp bump would have permanently broken migration on pre-v0.4.0 graphs.

  • migrations.rs: adds the is_empty() guard so a crashed-then-retried v1→v2 migration skips the PK set and proceeds to stamp — crash-idempotency is restored by construction.
  • tests.rs: realigns test_directory_namespace_direct_publish_cannot_replace_native_omnigraph_write_path to Lance 7's TableNotFound behavior (the native DirectoryNamespace no longer enumerates omnigraph's manifest-tracked tables at all, widening the decoupling the test was already asserting).
  • docs/dev/lance.md: corrects the build(deps): bump Lance 6.0.1 → 7.0.0 (correct-by-design substrate alignment) #229 stanza's "no API surface changed" claim to compile-time only, documents both runtime behavior changes, and records the lesson to run cargo test --workspace before declaring a Lance bump done.

Confidence Score: 4/5

The production fix is correct and targeted — the is_empty() guard restores crash-idempotency without altering normal migration behavior. The test-side changes faithfully track Lance 7's new runtime behavior with no impact on production paths.

The migration guard is logically sound, but it checks only that some PK is present rather than asserting it is the expected object_id field. The crash-recovery scenario that motivated the fix also lacks a dedicated regression test — the existing test simulates it via stamp-stripping a fresh graph, not via a genuine pre-v0.4.0 dataset with no PK.

Both comments on migrations.rs are worth a second look — the missing debug_assert for PK field identity, and the absence of a targeted crash-recovery regression test.

Important Files Changed

Filename Overview
crates/omnigraph/src/db/manifest/migrations.rs Adds unenforced_primary_key().is_empty() guard to migrate_v1_to_v2 to restore crash-idempotency under Lance 7's immutable-PK constraint; the guard doesn't verify the existing PK is the expected object_id field, though this can't go wrong in practice today
crates/omnigraph/src/db/manifest/tests.rs Realigns test_directory_namespace_direct_publish_cannot_replace_native_omnigraph_write_path to Lance 7's TableNotFound behavior; uses fragile {:?} debug-format string matching for all three new assertions
docs/dev/lance.md Adds two new Lance 7 behavior-change bullets (immutable PK, native-namespace decoupling), corrects the "no API surface changed" claim to scope it to compile-time, and bumps the audit date to 2026-06-15

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["migrate_v1_to_v2 called"] --> B{"dataset.schema()\n.unenforced_primary_key()\n.is_empty()?"}
    B -- "true (no PK set)" --> C["update_field_metadata:\nset object_id as unenforced PK"]
    C --> D["set_stamp(dataset, 2)"]
    B -- "false (PK already set)" --> D
    D --> E["✓ Migration complete"]

    subgraph "Crash-recovery scenario (fixed)"
        F["1st run: PK empty → set PK → CRASH\nbefore stamp bump"] --> G["2nd run: PK present\n→ old code: errors (Lance 7)\n→ new code: skips → stamps ✓"]
    end

    style C fill:#d4edda
    style D fill:#d4edda
    style G fill:#d4edda
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "docs(lance): document the 2 runtime beha..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

aaltshuler and others added 3 commits June 15, 2026 02:18
… 7's immutable unenforced primary key

Lance 7 (dataset/transaction.rs) makes the unenforced primary key immutable
once set: any write touching the reserved `lance-schema:unenforced-primary-key`
field metadata after the PK is set errors "cannot be changed once set" — even
re-applying the same value. `migrate_v1_to_v2` previously relied on the old Lance
6 idempotency (re-applying the annotation was a no-op-ish bump), which it needs
for crash-recovery: a v1 graph that crashes after the field-set but before the
stamp bump re-enters the migration with the PK already present.

Under Lance 7 that re-entry now errors, so a real pre-v0.4.0 graph crashing in
that window could never complete its migration. Guard the field-set with
`schema().unenforced_primary_key().is_empty()` so a genuine first-set still runs
but a re-set is skipped — restoring crash-idempotency by construction. (Fresh
graphs bake the PK into manifest_schema() at init and never run this migration.)

The existing test_publish_migrates_pre_stamp_manifest_to_current_version is the
regression guard: red under Lance 7 before this change, green after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…TableNotFound)

`test_directory_namespace_direct_publish_cannot_replace_native_omnigraph_write_path`
pokes Lance's NATIVE DirectoryNamespace (not omnigraph's production write path,
which is the manifest merge_insert publisher) to document that it cannot replace
omnigraph's authority.

Lance 7's DirectoryNamespace routes list/describe/create_table_version through
`check_table_status`, which now reports an omnigraph-manifest-tracked table as
absent — so all three return TableNotFound for `node:Person` (observed). The
native namespace is now fully decoupled from omnigraph's manifest: it cannot
enumerate, inspect, or publish over omnigraph's tables. This strengthens the
guard's thesis. Realigned the assertions to the v7 behavior and kept the
authority check (omnigraph's refresh ignores the direct append; row_count stays
0). Test-only; no production impact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gnment stanza

The #229 stanza verified a clean engine *build* but not the test suite, and
claimed "no Lance API surface omnigraph uses changed." Two runtime behaviors did,
caught only by the full test suite:

- the unenforced primary key is immutable once set in v7 (transaction.rs) — broke
  the v1→v2 manifest migration's crash-idempotency; fixed by an is-set guard;
- the native DirectoryNamespace returns TableNotFound for omnigraph
  manifest-tracked tables (dir.rs) — test-only; the surface guard was realigned.

Corrects the over-broad "no surface changed" claim, adds both findings, and notes
the lesson: a clean build is not a clean alignment — run cargo test --workspace
before declaring a Lance bump done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aaltshuler
aaltshuler requested a review from ragnorc as a code owner June 14, 2026 23:26
Comment on lines +346 to 394
let assert_table_not_found = |what: &str, dbg: String| {
assert!(
dbg.contains("TableNotFound") && dbg.contains("node:Person"),
"{what}: expected TableNotFound for node:Person, got: {dbg}"
);
};
assert_table_not_found(
"list_table_versions",
format!(
"{:?}",
namespace
.list_table_versions(ListTableVersionsRequest {
id: Some(vec!["node:Person".to_string()]),
descending: Some(true),
..Default::default()
})
.await
.unwrap_err()
),
);
assert_table_not_found(
"describe_table_version",
format!(
"{:?}",
namespace
.describe_table_version(DescribeTableVersionRequest {
id: Some(vec!["node:Person".to_string()]),
version: Some(person_version as i64),
..Default::default()
})
.await
.unwrap_err()
),
);
assert_table_not_found(
"create_table_version",
format!(
"{:?}",
namespace
.create_table_version(version_metadata.to_create_table_version_request(
"node:Person",
person_version,
1,
None,
))
.await
.unwrap_err()
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Debug-format string matching is fragile for error assertions

All three new assertions rely on format!("{:?}", err) and look for "TableNotFound" + "node:Person" as a substring. This works today, but the Debug representation of Lance's error types is not a stability guarantee — a refactor of the error enum (or even just a wrapping layer) can change the rendered string without breaking the public API. If that happens, the assertions silently fail to match and start printing misleading diagnostics. A typed match on the error variant, or at least to_string() (which exercises Display, a more-stable surface), would be more durable here.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment on lines 125 to 138
async fn migrate_v1_to_v2(dataset: &mut Dataset) -> Result<()> {
dataset
.update_field_metadata()
.update(
"object_id",
[(OBJECT_ID_PK_KEY.to_string(), "true".to_string())],
)
.map_err(|e| OmniError::Lance(e.to_string()))?
.await
.map_err(|e| OmniError::Lance(e.to_string()))?;
if dataset.schema().unenforced_primary_key().is_empty() {
dataset
.update_field_metadata()
.update(
"object_id",
[(OBJECT_ID_PK_KEY.to_string(), "true".to_string())],
)
.map_err(|e| OmniError::Lance(e.to_string()))?
.await
.map_err(|e| OmniError::Lance(e.to_string()))?;
}
set_stamp(dataset, 2).await
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Guard is is_empty() but doesn't verify the PK is the expected field

dataset.schema().unenforced_primary_key().is_empty() correctly short-circuits when a PK is already registered — but it doesn't assert that the registered PK is object_id. If a future migration or tooling error left a different field annotated as the PK, is_empty() would return false, the update_field_metadata call would be silently skipped, and the manifest dataset would enter v2 with the wrong CAS key — breaking merge-insert row-level conflict detection for that graph permanently. In practice the only writer is omnigraph's own manifest_schema() / this migration, so it can't happen today; but adding a debug_assert_eq!(dataset.schema().unenforced_primary_key(), "object_id") in the else branch would make the invariant machine-checked and surface any future drift at test time rather than silently at conflict-resolution time.

Fix in Claude Code

Comment on lines 125 to 138
async fn migrate_v1_to_v2(dataset: &mut Dataset) -> Result<()> {
dataset
.update_field_metadata()
.update(
"object_id",
[(OBJECT_ID_PK_KEY.to_string(), "true".to_string())],
)
.map_err(|e| OmniError::Lance(e.to_string()))?
.await
.map_err(|e| OmniError::Lance(e.to_string()))?;
if dataset.schema().unenforced_primary_key().is_empty() {
dataset
.update_field_metadata()
.update(
"object_id",
[(OBJECT_ID_PK_KEY.to_string(), "true".to_string())],
)
.map_err(|e| OmniError::Lance(e.to_string()))?
.await
.map_err(|e| OmniError::Lance(e.to_string()))?;
}
set_stamp(dataset, 2).await
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Crash-recovery path isn't directly covered by the regression test

test_publish_migrates_pre_stamp_manifest_to_current_version exercises the guard by stripping the stamp from a fresh graph (which already has the PK baked in from manifest_schema()). That's sufficient to turn the test red under Lance 7 and green after the fix, but it never exercises the crash-recovery half of the scenario described in the PR: a genuine pre-v0.4.0 dataset (no PK annotation at all) where the migration ran once, set the PK, crashed before stamping, and must now complete idempotently on re-entry. AGENTS.md rule 12 asks for a regression that reproduces "the predicted symptom" — the actual failing mode for the legacy-graph recovery path (v1 stamp + PK present from a prior crashed run) has no dedicated test. Extending the existing test, or adding a small test_migrate_v1_to_v2_is_idempotent_when_pk_already_set, would close that gap.

Fix in Claude Code

@aaltshuler
aaltshuler merged commit ceb37dd into main Jun 14, 2026
9 checks passed
@aaltshuler
aaltshuler deleted the fix/lance7-manifest-migration branch June 14, 2026 23:37
aaltshuler added a commit that referenced this pull request Jun 15, 2026
… not just non-empty (#239)

Greptile follow-up (#236): `migrate_v1_to_v2` guarded the field-set with
`unenforced_primary_key().is_empty()`, which skips the set whenever *any* field is
the PK — including the (corrupt/unexpected) case where a field other than
`object_id` carries it. That would silently leave merge-insert row-level CAS keyed
on the wrong column, and Lance 7 forbids changing the PK afterward.

Match on the specific PK field instead: `["object_id"]` is the idempotent
crash-recovery no-op, `[]` sets it (the genuine pre-v0.4.0 first migration), and any
other PK refuses loudly. Defensive — Lance won't let a fresh graph reach the error
branch — but correct by construction. The idempotent re-entry path stays covered by
test_publish_migrates_pre_stamp_manifest_to_current_version (28 manifest tests green).

Co-authored-by: Claude Opus 4.8 <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.

1 participant