Repository navigation
perf(schema): apply property changes as metadata-only commits; optimize erases dropped columns - #871
Conversation
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: request changes for the erasure instructions below. I found no blocking defect in the new storage path.
This PR makes schema changes practical on large graphs, especially graphs with large Blob values. The old path read and rebuilt each affected table. The new path changes Lance metadata and preserves existing files, row IDs, and surviving indexes. Added properties read as null. A drop hides the property immediately, but optimize must rewrite its files before cleanup can erase the stored values.
Finding — P2, correct the branch retention instructions.
Merging a branch does not release its old table pin. A branch created after a property drop can still retain files containing that property's bytes. Merging it, optimizing main, and running cleanup does not erase those bytes while that branch remains. The inline comment gives the reproduction and required correction.
Contract, workload, and tradeoffs
- The target workload has many versioned tables, frequent small writes, and potentially large Blob values. Removing payload work from schema apply addresses the cause of its memory growth. The supported deployment still has one writer process. Schema apply still requires main to be the only live branch. This PR adds no distributed writer guarantee.
- Renames and drops use
Project. Additions useMergewith fresh field IDs. A combined change needs two detached commits because Lance rejects field-path changes insideMerge. This adds one metadata commit and a failure window. Graph readers see neither commit until the existing graph publication installs all pins and the schema together. See the publication loop. - Erasure now costs a rewrite during
optimize, even for a large fragment with no deletions. A single-fragment task fits Lance's existing rewrite model. It avoids another maintenance API or durable queue. It can copy large surviving Blob values and remove vector-index coverage, which can trigger a whole index rebuild in the same optimize. - The extra erasure tasks bypass Lance's source budgets. Current engine callers use default options without those budgets. This is acceptable within that boundary, but future budget controls must include these tasks. See the shared compaction planner. Erasure also needs enough temporary storage for old and new files until retention releases the old files.
Liability
The direction reduces liability despite the positive total line count. It removes the whole-table batch, Blob reconstruction, and schema-specific external-range refusal. It reuses Lance's existing operations, the existing detached-commit path, and the existing collector. The added obligations are field-ID preservation, commit ordering, and explicit dropped-column detection. The schema planner centralizes the first two. The new substrate guards cover those obligations. Five similar changes should extend these shared paths, rather than add five independent rewrite systems.
Validation and limits
Reviewed head: 41ade515db21dead5cbe45985ded2d0e957eb29c, in a clean isolated checkout.
- Local baseline, Rust 1.97.1: 63 Lance guards passed, with two ignored. All 51 maintenance tests and 31 schema-apply tests passed.
- With failpoints enabled: all 39 schema-apply tests passed. All 78 schema-apply recovery cells passed, including failures between the two detached commits.
- All 50 change-feed tests passed. Formatting, documentation checks, and AGENTS link checks passed.
- The temporary retention probe failed after a real merge, optimize, and cleanup. The control passed after branch deletion and another cleanup. All temporary source edits are restored.
- CI passed on merge commit
97d37b591454a8f3326943838d6b4b6ef350deed. Its Git tree exactly matches the reviewed head. Local checks used file storage. I did not repeat cloud-backend or full-workspace tests locally.
I checked the pinned Lance 11.0.0 implementation at ab6b5bbe. The relevant code covers all-null additions, Project/Merge validation, missing-field reads, index retention, and compaction. Current upstream guides differ from this pin in places, so the source and tests control the conclusion. I did not independently measure the PR's heap figures or production latency.
Optional documentation improvement: qualify the claim that apply time and memory do not grow with table size. Payload size no longer drives them, but fragment manifests and index metadata still do. The new Merge clones the fragment inventory (source). That distinction matters for graphs built through many small commits.
Pin the Lance 11 facts a metadata-only schema apply relies on: on a stable-row-id V2_2 table holding inline, packed and dedicated Blob-v2 payloads, null and a valid empty value, a detached Project that renames and drops columns and a detached Merge chained on it that adds nullable String and Blob columns write no data file, keep surviving field ids, metadata, the primary key and index coverage, drop the index on the dropped column, keep stable row ids and every payload readable, read the added columns as null and never move HEAD. Lance refuses a Merge that renames a field, which is why the two commits chain.
Add the sealed TableStore::plan_schema_evolution and stage_schema_evolution primitives. The plan reads only the manifest: each target column keeps the field id, type, nullability and metadata of the column it maps to (by rename or name) and adopts its stable property marker; new columns must be nullable and take ids above Manifest::max_field_id; a type, nullability or marker conflict refuses. Staging yields at most two detached-ready transactions, a Project for renames and drops and then a Merge over the unchanged fragments for additions, each checked against the columns the previous step left. Register both in the forbidden-API gateway lists.
Schema apply no longer rewrites a table to add, rename or drop a property. It plans every evolved table's commits before the first effect and stages them detached (a Project, then a Merge when columns are added), publishing the tip in the same single manifest publication with the accepted contract. No row or Blob payload is read, so memory no longer grows with the table's Blob bytes, surviving indexes keep their coverage, and a ranged external Blob descriptor is kept as stored instead of refused. Remove the whole-table rewrite (batch_for_schema_apply_rewrite, the schema-apply rebuild_blob_column), the ranged-external preflight that existed only for it, and the dead concat helper. The drop test now asserts zero payload reads and unchanged data files; the ranged descriptor test asserts preservation.
…memory - schema_apply: add, rename and drop on a Blob table (Project then Merge) and a plain table (Project): unchanged data files, zero payload reads, field ids, markers, primary key and index coverage preserved, one publication, historical shape, then keyed writes, queries, optimize and export on the evolved tables. - changes: commits after a column add name only the rows they wrote. - detached_commit_matrix: the schema-apply writer now parks inside a two-commit chain as well as after it. - compaction_memory: an ignored instrument showing schema apply's peak allocation flat in a table's Blob bytes. - failpoints: comments follow the new effect shape.
Update the write-path, Blob and Lance guides (new schema-evolution fence row), the testing map and the user schema guide, amend RFC 0033 sections 4, 8.4, 10 and the rollout with a decision-log entry, and add changelog notes for the performance change and for dropped-property storage, which cleanup alone no longer frees.
A metadata-only drop keeps each data file that also holds a surviving column, and Lance's compaction planner selects fragments by size, deletions and overlays, never by dropped columns, so a dropped property's values left the files only by accident. Optimize now plans each table through TableStore::plan_table_compaction: Lance's plan plus a single-fragment task for every other fragment whose data or overlay files still list a field id the schema lacks. The rewrite scans the current schema only, so drop, optimize, cleanup erases the values. The planning gate and staging share the plan, the tasks keep the per-task Blob batch sizing (from the kept Blob columns alone), and the table still stages one Rewrite and publishes once. - lance_surface_guards: field-id bookkeeping of dropped columns (Blob-v2 listed by its own id), the default planner leaving such a fragment alone, and a chosen-fragment rewrite writing only current ids. - maintenance: drop a String and a Blob property (inline, packed and dedicated) on one undeleted fragment; optimize rewrites it in one commit sized by the kept Blob, leaves a table without drops unsized and uncommitted, converges, and after cleanup no file of the graph holds the dropped bytes. - compaction_memory: an ignored erasure instrument (34.1 MiB peak against the 42 MiB compaction bound).
A drop is logical at apply; optimize rewrites every fragment still holding a dropped property's values and cleanup then deletes the old files. Replace the dropped-property storage note with that contract, correct the unreleased allow-data-loss note, and update the schema and maintenance guides, the write-path, Blob and Lance guides (a dropped-column erasure fence row), the testing map and the RFC 0033 decision-log entry.
Row comparison resolved the data file holding a Blob column in the row's fragment before it classified the descriptor. A nullable Blob property added by a metadata-only schema apply has no data file in the fragments written before the add, where Lance reads it as null, so a change page, a feed poll, a net diff or a three-way merge over such a row failed with a Blob integrity error. The data file now qualifies a managed identity only: null and external identities name no file, and a managed descriptor whose fragment has no data file for its field stays a Blob integrity error.
The change feed test adds a nullable Blob beside a String to a populated table, then updates rows of the old fragment and inserts a new row: the commit page, the feed and the net diff carry exact before and after images. A general three-way branch merge updates rows of that fragment on both sides and carries each side's values.
A branch created after a drop and before the optimize keeps the dropped bytes through a merge into main, another optimize and cleanup; deleting it lets the next cleanup erase them.
Merging a branch releases none of its table versions: cleanup keeps every live branch's head and creation point and every tagged version. Erasing a dropped property needs optimize, then deleting every branch created from a commit before it and every tag naming such a commit, then cleanup with a retention that excludes the commits before the optimize. Schema apply no longer scales with stored rows or payload bytes, but it still scales with fragment, schema and index metadata: planning walks the fragment inventory, the Merge carries it and each detached commit serializes it.
…ge against v0.12 allow-data-loss-removed.breaking.md is part of the published v0.12.0 release, where it was true, and returns to its released bytes. The new dropped-property-storage note states the change relative to v0.12.
41ade51 to
b571842
Compare
|
Thanks. All findings are addressed in five commits on top, rebased onto
Engine suites: 1,012 passed. Also CLI cluster/schema tests, GQT 255, both clippy graphs, and the docs and release-note checks. |
|
@azimafroozeh I need your call on six decisions in
I'll mark the PR ready once you've answered these and the running re-review is clean. |
|
The red |
v0.12.0 is tagged and its snapshot generated, but release.json still named it, so the documentation check compared every note added since with that published snapshot and failed. The release procedure's post-publication step moves the configuration to the next version on the published base; this is the same change ModernRelay#871 and ModernRelay#878 carry.
…etadata-only # Conflicts: # changelog.d/release.json # docs/rfcs/0033-blob-management.md
…etadata-only # Conflicts: # docs/dev/lance.md # docs/dev/testing.md
|
The red Since #894, that job builds the engine three times, because This run also restored no cache. Its runner image ships Rust 1.98.1, main's cache was saved under 1.99.0, and rust-cache includes the runner's preinstalled toolchains in its key. With no cache the job needs about 70 minutes. The fix is #896: one package selection, so one engine build, which matches the cache rule in |
…ld bypass Lance applies max_source_fragments, max_source_rows and max_source_bytes inside its planner, to the tasks it returns. plan_table_compaction appends a task for every other fragment still holding a dropped column, so those tasks would run outside any budget a caller set. The engine sets none, so options that set one are now refused as an internal error instead of being documented as an exception.
Decisions on the six open questionsI re-examined each one against the invariants and the Lance 11 source and decided them, so the PR no longer waits on them. @azimafroozeh, flag any you disagree with; each is cheap to change. Throughout: the logical schema is the contract. Bytes in data files are physical state, which maintenance reclaims and retention protects. 1. A drop is logical at apply;
2. Erasure is always on in
3. Two detached commits for a table that renames or drops and also adds. Decided: keep.
4. Erasure tasks and Lance's compaction budgets. Decided: refuse budgets in code, not just in a comment.
5. Error type for planner refusals. Decided:
6. Erasure on
Also corrected in the PR description: the vector-index cost (see 2) and the coalescing claim (see 4). |
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: approve, subject to the remaining required CI checks. This follow-up reviews cf3109fb5077f8785da36de3896500d1ef48464b. The earlier blocking documentation finding is fixed. I found no new blocking defect.
This PR makes schema changes cheaper for graphs with large tables or Blob values. It changes Lance metadata instead of reading and rebuilding all stored values. A dropped property disappears from the schema immediately. Physical erasure requires optimize, release of retained references, and cleanup.
The new commit closes a maintenance contract gap. Lance limits the tasks its planner returns. OmniGraph then adds tasks to remove dropped columns. Those added tasks could exceed a caller's limit. The shared planner now rejects all three source limits before calling Lance.
Contract and tradeoffs
- Both planning and execution use default options. They set no source limits. Thus current CLI, server, and embedded optimize calls keep their behavior. The check applies before storage-backend work and adds no cloud-specific path.
- The planner rejects limits even when a table has no dropped columns. This gives every graph table one supported contract. The cost is loss of optional incremental-compaction controls at this internal boundary. A future bounded-maintenance feature must budget the complete plan, including erasure tasks.
- These limits count source fragments, rows, or file bytes. They are not process-memory limits. Lance's byte limit also excludes Blob sidecars. This change does not bound total optimize memory, duration, or I/O. Large surviving Blob values still make erasure expensive.
- The schema guide now explains that merging a branch releases no files. It names branch deletion, tags, and retained history as erasure conditions. It also qualifies metadata cost for highly fragmented tables. These changes address the earlier review.
Liability
The incremental fix adds 56 lines and removes five, mainly documentation and a regression test. It reduces liability by turning an unsupported combination into an explicit error. It adds no queue, durable state, public API, or second budget implementation. Five similar maintenance changes should share this planner and its admission rules. They should not add separate task schedulers. The broader PR still trades cheaper schema changes for explicit erasure work during maintenance.
Validation and limits
- Inspected the exact head in a clean isolated checkout. The new merges contain no manual resolutions. The only engine changes since the previously assessed head are this check and its test.
- Checked pinned Lance 11.0.0 at
ab6b5bbe46009ed78746b444df8db59a8bc5d842. Its budget function limits only its own task list. Its public planner does not load another budget from dataset configuration. - Local checks: formatting of both changed Rust files, documentation checks (219 Markdown files), and AGENTS links passed. The checkout remains clean. I did not complete a fresh local Rust test build. A cached binary failed the refusal test, but its source provenance was unverified, so I excluded that result. The Rust test conclusion below relies on exact-head CI.
- Exact-head CI passes the workspace tests. Its log includes the new budget-refusal test, both relevant Lance guards, and the optimize/cleanup erasure test. RustFS S3 tests also pass. GQT ordinary, GQT DST, and the pinned DST suite also pass. Azurite and storage-upgrade checks remain in progress.
- I did not repeat cloud tests or heap benchmarks locally. The performance figures in the PR remain author measurements. The historical memory instrument is now deferred.
This is a COMMENT review because the authenticated account is the PR author. No new inline finding is required.
…etadata-only # Conflicts: # crates/omnigraph-core/src/instrumentation.rs # docs/dev/blob.md # docs/dev/lance.md # docs/rfcs/0033-blob-management.md
…lay#815) * planner: refusals by design carry planner diagnostics The query shapes engine v2's planner refuses by design now carry the same diagnostic contract as parse and type refusals. The ranking of a binding a traversal reaches gets code P001, stage `plan`, the refused call as the expression, and a fix that answers the query: declare that binding first, so the ranking starts the traversal. The edge-selection refusals added with edge alternation and wildcard traversal get P002 (no finite traversal work limit), P003 (the limit out of range or recorded twice) and P004 (CSR traversal mode), with fixes where one exists. `PlanError::Unsupported` holds the diagnostic and `PlanError::refused` builds it; the gate's `Unrouted::UnsupportedQuery` carries it, and `plan_source` returns it as `OmniError::Compiler` on the ordinary and the inspected door, so the server answers 400 with the `diagnostic` object and the CLI prints the code, stage and fix. The diagnostic kind `plan` renders its one-line text as `plan error: P001: …`. Fixes ModernRelay#786. * engine: a refusal raised in the statistics pass keeps its diagnostic Before the planning that runs, `load_column_statistics` resolves and rewrites the query once to find the tables whose column statistics the cost model needs. It mapped every planning error through `no_plan`, so a refusal by design raised while resolving, such as an edge wildcard under CSR traversal mode (P004), came back as an internal error carrying the diagnostic only as text. It now maps a refusal through the same conversion the gate's result uses, and leaves `no_plan` for defects. * planner: refuse an rrf over two connected bindings without a reorder fix P001's fix, declare the ranked binding first, cannot answer an rrf() whose arms rank two bindings one traversal connects: the binding declared first starts the traversal and the other is reached, so following the fix only produced the opposite hint. Such a fusion is now refused before either arm is ranked, as P005, which names both bindings and the fused call and offers no fix. The reorder hint stays for a single ranked binding. The issue-786 owner test now runs the fused shape in both declaration orders and requires the same fixless refusal. * release notes: open v0.12.1 now that v0.12.0 is published v0.12.0 is tagged and its snapshot generated, but release.json still named it, so the documentation check compared every note added since with that published snapshot and failed. The release procedure's post-publication step moves the configuration to the next version on the published base; this is the same change ModernRelay#871 and ModernRelay#878 carry. * test: import ManifestErrorKind beside main's refusal assertions after the merge * test(gqt): keep the connected-rrf P005 refusal in the corpus An rrf() whose arms rank two bindings one traversal connects is refused as P005 in both declaration orders; the case now asserts the public error for each order, beside the existing P001 step.
…0047) (ModernRelay#839) * planner: refusals by design carry planner diagnostics The query shapes engine v2's planner refuses by design now carry the same diagnostic contract as parse and type refusals. The ranking of a binding a traversal reaches gets code P001, stage `plan`, the refused call as the expression, and a fix that answers the query: declare that binding first, so the ranking starts the traversal. The edge-selection refusals added with edge alternation and wildcard traversal get P002 (no finite traversal work limit), P003 (the limit out of range or recorded twice) and P004 (CSR traversal mode), with fixes where one exists. `PlanError::Unsupported` holds the diagnostic and `PlanError::refused` builds it; the gate's `Unrouted::UnsupportedQuery` carries it, and `plan_source` returns it as `OmniError::Compiler` on the ordinary and the inspected door, so the server answers 400 with the `diagnostic` object and the CLI prints the code, stage and fix. The diagnostic kind `plan` renders its one-line text as `plan error: P001: …`. Fixes ModernRelay#786. * engine: a refusal raised in the statistics pass keeps its diagnostic Before the planning that runs, `load_column_statistics` resolves and rewrites the query once to find the tables whose column statistics the cost model needs. It mapped every planning error through `no_plan`, so a refusal by design raised while resolving, such as an edge wildcard under CSR traversal mode (P004), came back as an internal error carrying the diagnostic only as text. It now maps a refusal through the same conversion the gate's result uses, and leaves `no_plan` for defects. * planner: accept every read plan against its checked query One validator in omnigraph-planner (validate/) constructs the only executable plan type, AcceptedPlan, whose fields are private. Ordinary execution, the explain document, the explain statement and the inspected run call the same accept_query path; explain renders the accepted plan and reports validation.scope. The checked declaration and its type context travel beside the IR (CompiledQuery), and the requirements are derived from them: binding identity, search identity and approximation, retained eligibility predicates, correlated blocks, the projection and score origin, order and cut, and the declared candidate and probe caps. A fresh plan failing a check is a planner defect; an exhausted validation limit is a resource outcome. Replay decodes an envelope (query source and name, scope, schema digest, bound plan) within a byte limit, checks its versions first, refuses a changed schema as incompatible facts, and accepts the plan again against requirements derived afresh. The implicit tie-break now orders declared bindings before the bindings the lowering makes up (anonymous endpoints, cycle temps), as RFC 0047's comparator requires; the validator rejected the old order. * planner: typed order keys; the validator recomputes the order it checks A node's declared ordering is typed order keys (column, expression with direction and null placement, identity, fused rank), derived by one rule from the operator and its inputs: a sort establishes exactly its comparator, identity keys included, and a filter keeps its input's order. The bound plan format carries them (version 2). Acceptance recomputes the root's order from the operators rather than reading declared properties, and each check has a test that breaks the plan it guards. * engine: a BM25 scan filters before scoring only under full full-text coverage Lance scores fragments no full-text segment covers flat, from statistics over the rows its prefilter admits, so a filter applied before scoring changes those rows' BM25 scores. The plan source reads the ranked property's full-text coverage at the pinned dataset version (full, partial or absent), the plan records it in its assumptions, and each ranked scan declares where it applies its eligibility: a bm25 scan before scoring only under full coverage, else after, as Lance's postfilter. Acceptance refuses a bm25 scan that filters before scoring without recorded full coverage. GQT gains the claim `ranked bm25 eligibility <placement>`. * planner: check the exact fragment's derivation from its canonical form A query of one binding with Boolean, integer and String eligibility, an optional leading bm25() over a String property, direct property sort keys, a limit and a projection of properties and the selected score is a member of the exact fragment. The optimizer records the rules it applies to such a chain (absorb a conjunct into the scan, prune the scan's columns, lower each node, rank the scan by bm25 with its eligibility placement); the validator checks that the IR is the declaration's lowering, rebuilds the canonical chain, re-applies every rule against its precondition and requires the result to equal the plan. A member is accepted as exact_subset, everything else as invariants_only. Replay envelopes carry the derivation, and replay checks it again. * engine: the plan declares the search policies the run adapts within A nearest scan declares its probe policy (the escalation factor, the flat rescan of rows the partition search did not reach, the uncapped fail-closed rescan when Lance's counters are missing, and the flat start when the eligible set fits within the fetch); every pre-pass declares what an empty eligible set decides and whether the recorded full-text coverage of the bm25 scans it feeds admits it. The scan's ladder and both gates read these from the plan instead of their own constants, and the rrf gate's coverage guard is the recorded fact, no longer a dataset read at run time. Acceptance checks that every ladder terminates and keeps its fallbacks, and that every pre-pass draws its eligible set from required first hops and feeds only what its kind may prefilter. The execution report records each gate's verdict with the counts it read and each probe attempt per rung, and a replay must record the same decisions. * planner: test replay decoding limits and version refusals Bytes over the evidence limit exhaust before anything is decoded, every envelope version is read before the body, and the replay door answers an envelope of another version as a conflict that asks for the query again. * planner: membership, entry parity and evidence-cost tests; linear retention A table of admitted and excluded forms pins exact-fragment membership; the plain and explained acceptance entries agree on plan, scope and an exhausted limit; an instrument prints derivation bytes, steps, nodes, visits and time as a member query grows. Predicate retention resumes after its last match, so in-order placement checks in linear work, and a derivation charges the conjuncts each absorption copies. * lance guards: pin the full-text prefilter and postfilter scoring facts A prefilter changes the BM25 score of a row no full-text segment covers, and a postfilter keeps the whole corpus's score; the eligibility placement of a bm25 scan rests on both. * docs: plan acceptance, declared search policy and RFC 0047 step 2a as built The execution guide describes plan acceptance, the exact fragment and the replay envelope; the explain guide documents validation, a bm25 scan's eligibility placement, a nearest scan's policy, the recorded full-text coverage and the profile's search decision rows, which the profile now carries; the testing map names the validator's owners; the Lance guide adds the eligibility placement fence; the GQT README adds the scope and eligibility claims. RFC 0047 records where the built design differs from its text, and three release notes describe the user-visible changes. * planner: test derivation references to unreached and superseded nodes * forbidden apis: register the three-way full-text coverage read * planner, engine: lowering-aware key binding; replay re-establishes coverage An order key the planner binds to a return item by equal lowering is accepted when the written key lowers to that item, though the two are written differently (`"a" in $d.tags` and `$d.tags contains "a"`), and a tie-break omission whose returned items all lower to order keys is accepted as an equivalence. Before this a legal query failed as a planner defect. The replay door re-establishes every full-text coverage fact a plan records from the pinned snapshot before acceptance: an envelope claiming full coverage of a partially indexed property, with its bm25 scan and derivation switched to filter before scoring, was accepted and would have changed BM25 scores. Both found in a Codex review. * planner, engine: constant aliased order keys; fusion arms report their probes Two defects a Codex review found in plan acceptance and the run report. An order key that is constant for every row (a parameter, `now()`, a single edge type's `@type`) orders nothing, and the order check discounts such keys on both sides. When `return` projects the constant under an alias, the planner binds the key to that alias, and the check compared an unresolved alias on one side with a discounted constant on the other, so `return { $x as x } order { $x }` failed acceptance as a planner defect. Both sides now resolve an alias to the `return` item it names before discounting constants. A fusion recorded its gate decision and returned without its nearest arms' probe attempts, so inspection and replay could not see them, and every nearest scan wrote one shared report, so two nearest arms would overwrite each other. Each nearest scan now reports under its own plan node; the standalone search reads its scan's entry and a fusion records each arm's attempts after its gate. Regressions: cases/v2/constant_order_key_bound_to_a_return_alias.gqt, validate/tests.rs::a_constant_key_bound_to_a_return_alias_is_accepted, and engine_v2_plan_replay.rs::a_fusion_replays, extended with a two-arm rrf(nearest, nearest). * planner: refuse an rrf over two connected bindings without a reorder fix P001's fix, declare the ranked binding first, cannot answer an rrf() whose arms rank two bindings one traversal connects: the binding declared first starts the traversal and the other is reached, so following the fix only produced the opposite hint. Such a fusion is now refused before either arm is ranked, as P005, which names both bindings and the fused call and offers no fix. The reorder hint stays for a single ranked binding. The issue-786 owner test now runs the fused shape in both declaration orders and requires the same fixless refusal. * planner: refuse an unwritten row cut; release replaced derivation nodes Two review findings on plan acceptance. The row-cut check compared a written `limit` with the root's cut but let any cut through when the query wrote none: a saved count plan with an added Limit(0) replayed as no rows. A read plan now carries exactly the query's cut, one at the root when it writes `limit` and none anywhere when it does not, a correlated block's tree included. The derivation arena stored each rule's successors and kept the nodes they replaced, so absorbing N conjuncts one at a time retained O(N^2) predicate data while every counter passed; a 526 KB query held 68 MB of literals. A replaced node is unreachable already (a rule naming it is refused), so it is now released before its successor is stored, and the arena keeps only each role's current node. RFC 0047's claim that the byte and node limits bound evidence memory is replaced by that linear bound. Regressions: validate/tests.rs::an_unwritten_row_cut_fails_the_row_cut, validate/tests.rs::the_derivation_arena_keeps_only_current_nodes, and engine_v2_plan_replay.rs::an_unwritten_row_cut_refuses_the_replay_of_a_count. * release notes: open v0.12.1 now that v0.12.0 is published v0.12.0 is tagged and its snapshot generated, but release.json still named it, so the documentation check compared every note added since with that published snapshot and failed. The release procedure's post-publication step moves the configuration to the next version on the published base; this is the same change ModernRelay#871 and ModernRelay#878 carry. * planner: every node declares its row cut, and acceptance justifies each The row-cut check counted `Limit` and `Page` nodes, so a capped `Sort` below the final sort cut rows that acceptance never saw: a saved ordered plan with such a sort replayed as no rows. `PhysicalNode::row_cut` now names each node's count cut in one match over every variant (a `Limit`'s or `Page`'s rows, a `Sort`'s `fetch`, a `RankFuse`'s limit, a ranked scan's candidate cap), so a node kind that gains a cut cannot compile without declaring it. `check_row_cuts` walks every live node and accepts a declared cut only where the query requires it: the root `Limit` and the final sort's `fetch` at the query's `limit`, the fusion at that `limit`, and a ranked scan's cap, which the search check already matches to its declared policy. Every other cut is refused, the final sort keeping its top-k. Regressions: validate/tests.rs::an_unwritten_row_cut_fails_the_row_cut (capped sorts below the final sort of an ordered and of a limited query) and engine_v2_plan_replay.rs::an_unwritten_row_cut_refuses_the_replay (the count's root cut and `liked`'s capped sort). * planner: say what the exhaustive row-cut match does and does not force The match forces a classification for a new node variant; a field that gives an existing variant a cut compiles unchanged, so its arm still needs a hand audit. The doc comment and RFC 0047's decision log claimed more. * test: import ManifestErrorKind beside main's refusal assertions after the merge * planner: accept plans against the typed lowering of the query Main typed the IR (explicit casts, typed literals and parameters), which broke acceptance's AST-to-IR matching and its constant evaluator. Plan and requirement now compare as typed IR: the compiler's lowering of the declaration (filter constants folded) states every conjunct, traversal, block predicate, return item and order key in the form a plan carries it, and the declaration anchors how many of each the query writes and which rank call a projected score names. - Phase 1 of acceptance runs the plan's own well-formedness checks (aggregate specs, output schemas, fusion row keys). - Eligibility is two-sided: every required conjunct is placed, and every placed conjunct is required, a constant true, or the canonical identity of a written single String key equality, re-derived through a KeyIds hook (scan_access's key narrowing). - Traversal identity: endpoints, edge selection, endpoint types, hop bounds and edge binding of every traversal. - Order: the final comparator is recomputed as the planner binds it; a fused search leads with _rrf descending, a search-led aggregate appends its group keys, and a fusion no longer declares an order or a row cut. - Late hydration (HydrateColumns) is checked against the written return items and gets a checked exact-subset rule, HydrateReturnColumns. - Exact-subset membership is decided on the typed IR, admitting a constant cast of a literal or parameter. - RULES_VERSION and SEMANTICS_VERSION move to 2. * planner: record key narrowing as a checked rule; test both-sided eligibility Key narrowing runs after the derivation is recorded, so an exact-fragment member like `Doc { slug: $s }` on a table with an id index would carry a predicate its derivation did not explain and fail acceptance as a planner defect. finalize_scan_access now returns the identity conjuncts it adds, the planned run records a NarrowKey step, and the subset checker re-derives the id through the same function the invariants use. Tests: an unwritten conjunct fails predicate origin; a planned narrowing is accepted as exact subset while a forged id or an identity without its written key equality is refused; edited hop bounds fail traversal identity; fused and grouped searches sort as the query requires; a hydrated member keeps its exact subset and a misplaced hydration is refused. * planner: judge order like the planner and derive full-text coverage from index facts A constant return item cannot tell rows apart, so it counts as ordered for the tie-break; an `order` clause whose keys all fold to constants still requires the identity tie-break sort; an alias key covers every return item lowering to the expression it names. Acceptance names a conjunct or sort key whose text matches the query's but whose stored types do not. `FullTextCoverage::of_facts` derives the coverage fact from the planner's index facts: full only when every inverted segment on the column reports known, complete coverage. * engine: accept every plan through one gate and re-derive narrowed keys Execution no longer re-checks aggregate specs, output schemas or fusion row keys: acceptance checks them first, so a saved plan that fails one is a bad request at the replay door. The engine supplies acceptance's key-id derivation (`CatalogKeys`, the loader's canonical id) and reads full-text coverage from the index facts, at planning and again on replay; the separate `TableStore::fts_coverage` reader is gone. The replay tests move to the envelope door and assert acceptance's refusals for forged plans, leaf types and sort aliases. * docs: record step 2a on the typed lowering RFC 0047 states the requirements from the typed lowering, two-sided predicate checks, traversal identity, the single acceptance gate, the narrow_key and hydrate_return_columns rules, full-text coverage from index facts and the replay versions, with a decision-log entry. The execution, testing and explain guides and the bm25 release note follow. * test(gqt): keep the connected-rrf P005 refusal in the corpus An rrf() whose arms rank two bindings one traversal connects is refused as P005 in both declaration orders; the case now asserts the public error for each order, beside the existing P001 step. * planner: check predicate origin above a fusion, not only inside its arms A fused plan's arms each copy the match, so retention stays per arm; but the nodes between the fusion and the root were checked by nothing, and a saved plan could filter fused rows there and pass acceptance. Every arm's pipeline now carries the nodes above the fusion for predicate origin, traversal identity and bindings, and a correlated block above the fusion is refused. `a_fusion_replays` forges a `Filter(false)` above the fusion and requires the replay door's refusal. * test(replay): forge the post-fusion filter under the fusion's actual parent The fused plan feeds its RankFuse into a Projection, not a Sort; the forged plan now rewires whichever single-input node reads the fusion.
What & why
Schema apply rewrote every table whose properties changed. It scanned the whole table into one batch and read every managed Blob value into memory (
batch_for_schema_apply_rewrite, schema apply'srebuild_blob_column). Peak memory grew with the table's Blob bytes. Schema deployments now run inside the live server (#861), so one large Blob table could exhaust the server. That breaks invariant 11. The rewrite also discarded index coverage and refused ranged external Blob references.The planner never allows a property change that needs a data rewrite: required adds (OG-MF-103) and type changes (OG-MF-106) are refused. And since
--allow-data-lossis gone (#810), every drop behaves the same. So the rewrite is deleted rather than made streaming.Schema apply is metadata-only.
Operation::Projecthandles renames and drops;Operation::Mergehandles adds, with missing columns read as null.merge_schema_valid, also on detached commits). A table that both renames or drops and adds therefore gets two detached commits, Project then Merge, and the pin names the last.TableStore::plan_schema_evolutionreads only the manifest and refuses conflicts before any effect.stage_schema_evolutionis the new sealed primitive, registered inforbidden_apis.max_field_id, and the evolved schema keeps catalog column order, which Lance merge-insert's full-schema path requires.Measured peak (instrument in
compaction_memory.rs, since moved by #894 tobenchmarks/deferred/and no longer run):Explicit erasure of dropped properties. A drop is logical at apply: the column's bytes stay in current data files.
optimizenow always rewrites every fragment that still stores a dropped field id, alongside Lance's own size- and deletion-based selection. Detection reads manifest metadata only; each such fragment becomes a single-fragment task added to Lance's own plan, using the publicCompactionPlan/TaskData, with the existing per-row Blob batch bound.cleanupthen reclaims the old files.Rewritemoves every row-id-domain index's fragment bitmap onto the new fragments, so erasure costs no index rebuild.plan_table_compactionrefuses options that set a Lance per-run source budget (max_source_*), which Lance would apply to its own tasks only. The engine sets none.optimize, thencleanuperases the values. A test asserts that no file under the graph root holds the dropped bytes afterwards.Compatibility and behaviour changes
cleanupalone; they needoptimizefirst. The merged, unreleasedallow-data-loss-removedfragment and the schema guide are corrected to say so.schema-apply-metadata-only.performance,dropped-property-storage.changed.Testing
lance_surface_guards: detached Project/Merge on a Blob-v2 table keeps data files, payloads, null/empty, ids and metadata; one Merge can't carry a rename; compacting a chosen fragment drops the dropped column's bytes, including Blob sidecars.schema_apply: add, rename and drop on Blob and plain tables — data files unchanged, zero payload reads, index coverage, markers, a ranged external reference kept, historical shape readable, one publication, then keyed writes, queries, optimize and export.maintenance: drop a String and a Blob property (inline, packed, dedicated) on one large fragment with no deletions.optimizerewrites it in one commit and a secondoptimizeis a no-op. Aftercleanup --keep 1no file contains the dropped bytes. A table with nothing to do isn't scanned. The test fails with the new selection disabled.changes: no spurious row changes across a metadata-only schema change.detached_commit_matrix: schema-apply cells, including a window between the two chained commits; all 78 cells pass withOMNIGRAPH_MATRIX=full.schema_apply39,lance_surface_guards63,maintenance53,changes50,writes56,failpoints118; GQT 255; compiler 434; CLIcli_cluster_e2e11 andcli_schema_config27. Clippy (both graphs), fmt, docs and release-note checks all pass.Decisions
The six design questions for @azimafroozeh are decided, with the reasoning in the decisions comment:
optimizethencleanuperase the bytes.optimize, with no flag.ProjectandMergeshapes.manifest_internal.mainonly; branches and tags are retained references.@aaltshuler (runtime / deployments): schema apply's peak no longer scales with table size, so a deployment needn't reserve table-sized memory for it. I didn't amend the server-runtime or settlement RFCs; tell me if they should mention it.