Repository navigation
schema: remove a constraint from a property that stays #1064
Description
Problem
schema plan refuses every removal of a constraint from a property that stays. Examples are dropping @index from score: I64 @index, or removing @unique, @range or @check from a property that is kept. The only way out is to drop the property, or to rebuild the graph. Some reasons to remove one:
- An index costs write and
optimizetime and storage for a column nobody filters on any more. - A uniqueness or range rule turns out to be wrong for the data, and writers must work around it.
#1014 (PR #1062) made a property drop take its own constraints with it. It left this case refused on purpose, because the right answer depends on what the constraint backs physically. This issue asks maintainers to decide that, kind by kind.
The current refusal is also emitted twice for a bare @index: once by plan_property_constraints ("removing property constraints from 'Doc.score'") and once by plan_constraints ("removing constraints from 'Doc'"), both in crates/omnigraph-compiler/src/catalog/schema_plan.rs.
What each removal touches today
Evidence is from main at 764fb42.
| Constraint removed from a kept property | Physical state behind it | What changes if it goes |
|---|---|---|
@unique, @range, @check (node) |
none: catalog rules only, read by validate.rs, the loader and merge |
Writes are validated less strictly. No stored row becomes invalid. Merge's pure-insert fast path (exec/merge.rs, around line 3475) only widens. |
@index on an edge property |
none: "edges only receive the id/src/dst BTREEs" (table_ops.rs, index work for edges) |
nothing |
composite @index(a, b) |
none: node index work builds single-column indexes only (table_ops.rs, index_cols.len() != 1 is skipped) |
nothing |
@index on a node scalar, enum, date or bool |
a Lance BTREE | cost only |
@index on a node Vector |
a Lance vector index | nearest moves from the ANN probe ladder to Lance's exact flat scan: recall can only rise, and cost grows |
@index on a node free-text String |
a Lance inverted index, whose segment carries the analyzer | the results of search, match_text, fuzzy and bm25 change (see below) |
@key |
id derivation, plus the implied BTREE on node keys | identity. It must stay refused: the is_key committed-lookup skip in validate.rs depends on a key existing from type creation on. |
Why leaving the physical index in place is not an option
The tempting cheap path is to remove the declaration and keep the Lance index. It fails on three counts:
- Plans would still use it. The v2 planner gathers index facts from the physical Lance indexes, not from declarations (
engine/plan_source/indexes.rs,gather→catalog). A BTREE the schema no longer declares would keep steering anchor selection, and full-text coverage (FullTextCoverage::of_facts) would keep counting an inverted index the schema no longer declares. The schema would no longer describe what the engine does. - It would not save the cost the user wanted to shed.
optimizefolds lagging indexes regardless of declarations:TableStore::can_fold_indexexcludes system, kind-unknown and full-text indexes, never undeclared ones. So an undeclared BTREE or vector index would be rebuilt whole on every laggingoptimize, forever. - For full-text, it would be a shadow source of truth. A remaining segment keeps applying its analyzer to rows it does not cover (
docs/dev/lance.md, Index segments row), so search semantics would live only in physical state that no declaration describes and nothing maintains (ensure_indicesand the full-text rebuild read only declared indexes).
Draft RFC 0046 already anticipates declared: false indexes "dropped by a schema change". It would report them, which keeps the drift visible but does not end it.
Proposed change
Three steps, by what the constraint backs. @key stays refused throughout.
1. Metadata-only removals, now. These back no physical state: @unique, @range and @check on a kept property, @index on an edge property, and composite @index.
- Plan them as one new step, for example
DropConstraint { type_kind, type_name, constraint }. It is the mirror ofAddConstraint. Apply records it in the accepted schema only, with no table effect, the way it appliesAddConstrainttoday. - Emit one step, not the two refusals above.
- One-way door: re-adding
@unique,@rangeor@checkto an existing type is refused (re-adding a uniqueness rule is part of schema: support adding required, unique properties to existing types #721's scope). The plan line and the docs should say so. This mirrors a property drop, which also cannot be undone by apply.
2. Single-property BTREE and vector @index on a node, next. Apply removes the physical index in the same graph publication:
- Stage
Operation::CreateIndex { new_indices: [], removed_indices: <every segment of that index name> }on the table and commit it detached. This is the shapestage_index_foldandstage_create_indicesalready stage, with no new index. Lance's ownDataset::drop_indexbuilds the same transaction but commits it linearly (lance-11.0.0/src/index.rs), which graph tables must not do. The Lance transaction spec states thatCreateIndex"adds, replaces, or removes secondary indices". - Pin the result in the one
__manifestpublication. If the same apply also evolves that table's columns, chain the index removal on the evolution commits, like the existing Project → Merge chain. cleanup's collector reclaims the index directory once no retained version names it. Older commits keep their index.- This is a new table effect in schema apply. It needs a seam, rows in
crates/omnigraph/tests/detached_commit_matrix.rs(writer × window × fault), a failpoint test that nothing moves before publication, and a DST storage-cost golden row. - For a vector index, the docs and the plan line should state that
nearestbecomes an exact scan.
3. Free-text String @index: keep refused until the analyzer is part of the schema. Today, removing the inverted index switches Lance to its flat path with a bare SimpleTokenizer: case-sensitive, unstemmed (#747, #749). The same search() would then silently return different rows. #792 (analyzed lexical search) makes the analyzer a property of the field, after which removal changes only cost and joins step 2.
If RFC 0047 step 3 lands first (#747: a full-text function on a property with no @index is a compile-time T27 refusal), then removing a String's @index also invalidates every stored query that searches it. In that case the plan must recompile stored queries against the desired schema and refuse, or list them, before apply. Otherwise the next server start quarantines the graph, as RFC 0047's Operators section notes.
Affected areas
- Compiler planner:
plan_constraints,plan_property_constraintsand a newSchemaMigrationStepvariant. Steps travel as untyped JSON (SchemaApplyOutput.stepsisVec<Value>inopenapi.json), so the OpenAPI file does not change. The newkindstill reaches clients: the CLI renderer (omnigraph-cli/src/output.rs),cluster plan, and API consumers such as omnigraph-ts and Studio. - Engine, step 2 only:
db/omnigraph/schema_apply.rs, aTableStoreindex-removal stager, recovery and failpoint tests, and the DST cost golden. - Docs: the schema guide's supported changes, the skill's schema reference (
skills/omnigraph/references/schema.md), and RFC 0046 if it should state that schema apply never leavesdeclared: falseindexes behind.
Alternatives considered
- Keep refusing everything and document it. PR fix(schema): drop a property together with the constraints that name only it #1062 already documents the refusal. Users keep unused indexes and wrong rules forever.
- Remove the declaration and keep the physical index. Rejected above: plans keep using it,
optimizekeeps maintaining it, and for full-text it becomes the only record of the query semantics. - One generic rule: removal always supported, apply drops whatever index exists, full-text included. This silently changes search results (Unindexed full-text search is case-sensitive #747, Index state changes which rows a text query matches #749) and breaks invariant 7 ("missing coverage may change cost but not correctness").
- Steps 1 and 2 together. Possible, but step 2 adds a crash-window surface to schema apply that deserves its own review. Step 1 is planner-only.
Related: #1014 and PR #1062 (a drop takes its constraints), #721 (adding required or unique properties), #747, #749 and #792 (analyzer and index state), RFC 0046 (index status), RFC 0047 (search plan validation).
Activity
ragnorc commentedon Oct 20, 2026
Summary
schema planrefuses every removal of a constraint from a property that stays. The only supported way to lose the constraint is to drop the property, or to rebuild the graph. That refusal is still present on currentmain. No pull request implements the removal.The accepted schema is the source of truth for constraints and for indexes. A removal has to make the published snapshot match that schema. Keeping the Lance index after the declaration is gone leaves plans,
optimize, and full-text search on physical state the schema no longer describes.Reproduction
Checked again immediately before this comment. Issue #1064 is open. It has no comments. The cross-references are PR #1062 (merged, and it leaves this case refused) and issue #1048 (change feeds across schema changes). Neither one implements constraint removal.
b692610c4aa31066cb6a9fd34830f56977288943(b692610c, 2026-10-20).origin/mainmatches this SHA.764fb422db1d8f7f64b99379dd5d01797449fff1. At that commit the Lance pin was=11.0.0. Currentmainpins=13.0.0(PR build(storage): upgrade lance to 13.0.0 #1072).rust-toolchain.toml.target/debug/omnigraphfrom this checkout,omnigraph 0.14.0, internal-schema 16.schema plandoes not need rows. Index build does, so this run did not create a Lance index.Commands, for each case:
schema planexits 0 whensupportedis false.schema applyexits 1, writes nothing, andschema showstill prints the base schema. Apply reports only the first unsupported step. The second step is visible in the plan, not in the apply error.email: String? @uniquekept,@uniqueremovedDoc.email, then constraints onDoc@index.@range(age, 0..200)removed,agekeptDoc@check(code, "[A-Z]+")removed,codekeptDocw: I64? @indexremoved,wkeptE.w, then constraints onE@index(a, b)removed,aandbkeptDocscore: I64 @indexremoved,scorekeptDoc.score, thenDocv: Vector(3)? @indexremoved,vkeptDoc.v, thenDocbody: String? @indexremoved,bodykeptDoc.body, thenDocslug: String @keykept,@keyremovedDoc.slug, thenDoc@keystays refused.status: enum(...) @index,when: Date? @index,flag: Bool? @index@index(score)with no property annotationDoc@indexonce the table has rows.Exact plan text for
score: I64 @index:JSON
kindisunsupported_change.codeis absent. Apply returnscode: bad_requestand onlyremoving property constraints from 'Doc.score' is not supported in schema migration v1.The double refusal is the property annotation plus the type constraint that the parser copies from it (
desugar_property_constraints).@rangeand@checkare type constraints only, so they produce one step. Composite@index(a, b)is also type-level only.A
.gqtcase cannot expressschema plan. The cheapest existing Rust test isplan_drops_a_property_with_the_constraints_that_name_only_it_issue_1014incrates/omnigraph-compiler/src/catalog/schema_plan.rs. On this SHA it passes. Its row@index removed from a property that staysexpectssupported == falseand a reason that starts withremoving constraints from. It does not assert the property-level step.A temporary compiler test expected one supported step for removing
@uniquefrom a kept property. It failed, then the edit was removed. Nothing was pushed.Command:
cargo test -p omnigraph-compiler --lib issue_10.What changed since 764fb42
The refusal text and the two functions are unchanged. Line numbers moved.
plan_property_constraintsandplan_constraintsare still the two refusal sites (schema_plan.rslines 787-795 and 857-865).is_keycomment invalidate.rsnow says the planner refuses every key change. The skip itself is unchanged (validate.rslines 329-334).T27and closed Unindexed full-text search is case-sensitive #747. At 764fb42,T27did not exist. A full-text call on a free-text String with no@indexis now a compile error. RFC 0047 step 3 is onmainfor that refusal.engine/plan_source/indexes.rsgatheris unchanged from 764fb42.can_fold_indexhas the same predicate. It moved from line 2694 to line 2709.table_ops.rsline 799 to line 751. The edge comment moved from line 855 to line 805.exec/merge.rsline 3451. The range, check, and unique checks are lines 3475-3477. The function body matches 764fb42.FullTextCoverage::of_factshas the same rule.of_tableis new: a table with zero fragments isFull. The trait default changed fromAbsenttoPartial. Neither change reads the schema declaration.Root cause
plan_property_constraintstreats any removed property constraint shape (@key,@unique, or@index) as unsupported.plan_constraintstreats any removedConstraintIRthat does not end with a dropped property as unsupported. A property annotation is stored in both places, so one edit produces two steps.There is no
DropConstraintvariant.SchemaMigrationStep::AddConstraintexists. Apply handlesAddConstraintas catalog-only (schema_apply.rslines 412-425). Apply has no path that removes an index from a column that stays. A property drop removes that column's index because Lanceretain_relevant_indicesdrops an index on a dropped field. That path does not run when the field stays.Physical-state table
@unique,@range,@checkare catalog rules. A removal makes later writes less strict. No stored row becomes invalid. The pure-insert fast path only widens.constraints_foremits non-key@uniquewithis_key: false, so the committed lookup runs (validate.rslines 313-322 and 1186-1199). Range and check are catalog constraints.plan_index_work_nodedoes not build an index for them.proven_fast_forward_needs_no_validationreturns true only when range, check, and unique lists are empty (merge.rslines 3475-3477).@indexhas no user index.plan_index_work_edge_on_datasetbuilds only the id, src, and dst BTREEs (table_ops.rslines 805-836).@index(a, b)has no physical index.index_cols.len() != 1skips the declaration (table_ops.rslines 750-753).@indexis a Lance BTREE.PropType::index_kindreturnsBtreefor those scalars, and forDateTime(types.rslines 242-258). The builder runs only when the table has rows (table_ops.rsline 732). These graphs were empty, so no BTREE was created.Vector@indexis a Lance vector index.nearestthen uses the exact scan.index_kindreturnsVector. Empty graphs built no index. The planner always attachesNearestPolicy::DEFAULT. Execution calls Lancenearestand setsuse_index(false)on the exact pass (scan.rs). This run did not executenearest.@indexis an inverted index whose segment carries the analyzer.index_kindreturnsFullTextfor a non-enum String.ensure_indicesand the untrained-segment declaration read declared indexes. This run did not build a segment.docs/dev/lance.mdstill says a segment applies its analyzer to rows it does not cover, and a column with no segment uses Lance's bare tokenizer.@keymust stay refused. Theis_keyskip depends on a key that exists from type creation.validate.rslines 329-334 document the skip.Why a kept index is the wrong result
gatherloadsdataset.load_indices()and does not read the catalog (indexes.rslines 13-29). Scan access and validate-access use those facts.FullTextCoverage::of_factscounts anInvertedfact on the column (source.rslines 318-336). A BTREE the schema no longer declares still narrows scans. An inverted index the schema no longer declares still counts as coverage.optimizekeeps the cost.can_fold_indexexcludes system indexes, indexes with no details, and full-text indexes (table_store.rslines 2709-2710). It does not exclude an undeclared BTREE or vector index. A lagging one is rebuilt onoptimize.ensure_indicesand the full-text rebuild walk declared indexes (table_ops.rslines 750-767 and the declared-column filter near line 693).can_fold_indexdoes not fold full-text. A leftover segment keeps the analyzer, and nothing in the schema maintains it.Draft RFC 0046 still reports
declared: falsefor an index dropped by a schema change, and it says the fold pass maintains undeclared foldable indexes. That report shows the drift. It does not remove the drift.Lance
Lance does not fix this issue. The refusal is in the OmniGraph planner. The substrate can already express the index removal.
Pinned Lance is crates.io
13.0.0, the newest stable release (published 2026-10-07, tag v13.0.0). There is no stable 13.0.1, 13.1, or 14. No open OmniGraph pull request upgrades Lance. v14.0.0-beta.21 exists. It is not a stable release.Dataset::drop_indexin Lance 13.0.0 buildsOperation::CreateIndex { new_indices: [], removed_indices }and commits it withapply_commit, which is a linear commit (index.rs on v13.0.0). The same function body is in v14.0.0-beta.21. The transaction spec says CreateIndex adds, replaces, or removes secondary indexes. Bothnew_indicesandremoved_indicesare repeated fields.The manifest builder removes indexes by UUID and accepts an empty
new_indiceslist (lance-tablemanifest_build.rs, CreateIndex arm). A Lance test commits that shape on the linear history (dataset_transactions.rs, the covering-fence case). Detached commit uses that same manifest build.do_commit_detached_transactiondoes not reject CreateIndex. OmniGraphcommit_detached(table_store.rsline 826) already commits a transaction withwith_detached(true).This is support in the commit path. It is not a test that a detached index-only removal opens and keeps the rows. Graph tables must not call
drop_index, because that moves linear HEAD.Open Lance PR lance-format/lance#6806 adds an uncommitted transaction for existing index segments. It does not drop an index, and it does not change
drop_index. Lance PR #9481 (in the v14 betas, not in 13.0.0) changes the BTREE row domain. It does not change the removal transaction. Later index commits onindex.rsafter v13.0.0 are full-text finalize, distributedoptimize, coverage tracing, and remap. None of them remove the need for OmniGraph to plan the constraint change.Design
Keep the three-step split. Change one classification. Step 1 must follow physical backing, not the annotation syntax.
A type-level
@index(score)on a node scalar is the same BTREE asscore: I64 @index. If step 1 drops only the declaration, the BTREE stays, and the failure mode in the issue returns. Edge@index, composite@index,@unique,@range, and@checkhave no physical index. Those are step 1. A one-column node BTREE or vector index is step 2, in either syntax. A one-column free-text String@indexstays refused.@keystays refused in both directions.Emit one
DropConstraintstep. Plan it fromConstraintIR, which is the stored constraint. The property-constraint shape is a copy of@key,@unique, and@index. If both passes still run, the double refusal remains.AddConstraintis the mirror, and apply already records it in the catalog with no table write.Re-adding
@unique,@range, or@checkstays refused. That work is #721, because a new rule has to be proved against existing rows. The plan line and the schema guide should say the removal cannot be undone by apply. Re-adding@indexstays allowed.AddConstraintalready plans it. An index is not a one-way door. A rule is, until #721.This one-way door is the same class as a property drop. Older commits keep the old contract. The new contract accepts rows the old rule rejected. That is the user need. An empty-table-only removal is safer and does not meet that need.
Step 2 stages one detached
CreateIndexwith an emptynew_indiceslist and every segment of that index name inremoved_indices. Pin that version in the one__manifestpublication. If the same apply also changes columns, chain the index removal after the Project and Merge commits, as schema apply already chains those commits (schema_apply.rslines 821-841). Do not callDataset::drop_index.cleanupreclaims the index directory when no retained version names it. Older commits keep their index.The crash window is the existing schema-apply window. A detached commit before publication is unreachable. A crash after publication leaves the pin without the index. This is a new effect inside the schema-apply writer, so the detached-commit matrix, the failpoint, and the DST cost golden need a row. The current matrix has a schema-apply writer. It does not have an index-removal transaction.
Vector removal changes cost.
nearestno longer has that index, so the scan is exact. Recall can rise. The plan line should say that.Step 3 stays refused until the analyzer is part of the schema (#792).
T27is already onmain. A query that callssearch,match_text,fuzzy, orbm25on a property with no@indexfails at compile time. That removes the silent tokenizer change for a new query. Two gaps remain.validate_query_source). A removed full-text@indexthat a stored query still searches failsquery_typecheck_errorbefore publish. Standaloneschema applyhas no stored-query registry. A later server start recompiles stored queries and quarantines the graph when one fails (RFC 0047, Operators). When a registry is present, the plan should list those queries before apply. Cluster plan already has the typecheck. Standalone apply should say that it cannot see stored queries.#963 is an open RFC for schema apply with live branches. It records today's fact that removing an
@indexis refused, and that a non-full-text index has no table effect. Its stored signature includes full-text@indexand excludes other indexes. A branch keeps its own contract and pin. That stays consistent.Step 2 breaks the "no table effect" sentence. The index removal does not change rows, so #963's live-branch check would not wait for a branch. A later merge treats the table as uniform, because the signature ignores the BTREE or vector index. The merge can then publish a version that still has the index under a contract that does not declare it.
optimizewould fold that index again.Amend #963 before step 2, or in the same change. The stored signature should include declared node BTREE and vector indexes, as it already includes full-text indexes. A removal then makes the table migrated, and the merge takes the newer side's version, which is the version apply published without the index. Step 1 does not need that signature change. A removed rule changes the contract sequence, and validation uses the newer contract. The rows were valid under the stricter rule.
#1015 plans after files are composed into one schema.
DropConstraintshould use the canonical type name. It does not need a second constraint store.#884 keeps one table per concrete node. Interface properties are not planned through
plan_constraintstoday. Do not add an interface-level physical index. If a later change copies an interface@indexonto each implementer, the drop has to name each concrete table.RFC 0046 should say that schema apply does not publish a contract that drops an index while the pinned version still has that index.
declared: falseremains the report for an index that was created outside the engine, and for a crash window that has not been published.render_schema_plan_stepis an exhaustive match (output.rs). A new variant does not compile until that arm exists. The human line should name the constraint, and for a rule it should say that apply cannot add it back. For a vector index it should say thatnearestbecomes an exact scan.cluster planprints each step as JSON (write_cluster_plan_human), so the newkindappears there without a new renderer. Operators will read raw JSON until that path uses the same line.SchemaApplyOutput.stepsis annotated asVec<Value>(omnigraph-api-types).openapi.jsondoes not gain a variant. A client that matches onkindneeds the new variant. A client that ignores an unknownkindkeeps working. Rust deserializes the enum, so an old Rust client fails on the newkind.Alternatives
optimizestill rebuilds a BTREE or vector index. Full-text search keeps an analyzer the schema does not name.T27now makes the query fail instead of changing rows, but the analyzer is still lost.The split is the lower-liability sequence. Step 1 removes the stuck rule. Step 2 removes the stuck index, with the recovery tests the write path already requires. Doing the physical drop in the same change as the catalog rule adds storage liability before the planner behavior is settled.
What this deprioritizes: the speed of dropping the index. Cleanup deletes the index files later, when no retained version names them. That is the same retention rule as a dropped column. Correctness of one publication comes first. Efficiency of immediate file deletion does not.
Test plan
DropConstraintfor@unique,@range,@check, an edge@index, and a composite@index. Assert one step. Assert@keyremoval stays unsupported. Assert re-adding@unique,@range, and@checkstays unsupported. Assert a node scalar@index, a type-level@index(score), a vector@index, and a free-text@indexstay unsupported until their own step.DropConstraint, including the one-way sentence for a rule.cluster planJSON contains the newkind. A storedsearch()on a property whose@indexis removed fails typecheck before apply.ensure_indicesdoes not rebuild it.optimizedoes not fold it. A failpoint before publication leaves the graph pin and the index unchanged. Add the schema-apply index-removal cell todetached_commit_matrix.rsand the DST storage-cost golden row.nearestis exact after the index is gone.Related work
@uniqueproperty. Re-adding a rule belongs there.T27refuses unindexed full-text calls.fuzzymatches. rfc: analyzed lexical search on the shared expression model #792 is the fix. It is another reason to keep free-text removal refused.declared: false.T27) is onmain. The rollout table in the RFC still lists step 3 as the step that closes Unindexed full-text search is case-sensitive #747.No merged or open pull request removes a constraint from a property that stays.
Supersession
No open issue has the same need with a wider or more accurate scope. #1014 was the property-drop case and is closed. This comment does not close #1064.
Limitations
nearestorsearchquery was run.drop_indexfunction body, not a full diff of Lance.