Skip to content

perf(schema): apply property changes as metadata-only commits; optimize erases dropped columns - #871

Merged
ragnorc merged 23 commits into
ModernRelay:mainfrom
ragnorc:claude/schema-apply-metadata-only
Oct 10, 2026
Merged

ragnorc merged 23 commits into
ModernRelay:mainfrom
ragnorc:claude/schema-apply-metadata-only

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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's rebuild_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-loss is gone (#810), every drop behaves the same. So the rewrite is deleted rather than made streaming.

Schema apply is metadata-only.

  • Adding a nullable property, renaming one, and dropping one are now detached Lance commits that keep every data file:
    • Operation::Project handles renames and drops;
    • an all-null Operation::Merge handles adds, with missing columns read as null.
  • They are published once with the accepted schema contract, through the same door as before.
  • Lance refuses a field-id rename inside a Merge (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_evolution reads only the manifest and refuses conflicts before any effect. stage_schema_evolution is the new sealed primitive, registered in forbidden_apis.
  • Field ids, stable property markers, the primary key and Blob-v2 metadata are preserved. New ids sit above 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 to benchmarks/deferred/ and no longer run):

Blob bytes in table Before After
64 MiB 72.1 MiB 5.1 MiB
128 MiB 136.1 MiB 5.1 MiB

Explicit erasure of dropped properties. A drop is logical at apply: the column's bytes stay in current data files.

  • optimize now 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 public CompactionPlan/TaskData, with the existing per-row Blob batch bound.
  • cleanup then reclaims the old files.
  • Index coverage carries over: with stable row ids, Lance's Rewrite moves every row-id-domain index's fragment bitmap onto the new fragments, so erasure costs no index rebuild.
  • plan_table_compaction refuses options that set a Lance per-run source budget (max_source_*), which Lance would apply to its own tasks only. The engine sets none.
  • So the contract is: drop, then optimize, then cleanup erases the values. A test asserts that no file under the graph root holds the dropped bytes afterwards.

Compatibility and behaviour changes

  • Existing indexes keep covering their rows across these steps; a renamed column's index is looked up by field id.
  • Ranged external Blob references now survive schema changes, which previously refused them.
  • A dropped property's values are no longer freed by cleanup alone; they need optimize first. The merged, unreleased allow-data-loss-removed fragment and the schema guide are corrected to say so.
  • Fragments: 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. optimize rewrites it in one commit and a second optimize is a no-op. After cleanup --keep 1 no 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 with OMNIGRAPH_MATRIX=full.
  • Suites: engine lib 388, schema_apply 39, lance_surface_guards 63, maintenance 53, changes 50, writes 56, failpoints 118; GQT 255; compiler 434; CLI cli_cluster_e2e 11 and cli_schema_config 27. Clippy (both graphs), fmt, docs and release-note checks all pass.
  • Not run: the DST suite; each commit built individually.

Decisions

The six design questions for @azimafroozeh are decided, with the reasoning in the decisions comment:

  1. A drop is logical at apply; optimize then cleanup erase the bytes.
  2. Erasure is always on in optimize, with no flag.
  3. A table that renames or drops and also adds gets two chained detached commits, Lance's own Project and Merge shapes.
  4. Lance per-run source budgets are refused in code rather than silently bypassed.
  5. Planner refusals are manifest_internal.
  6. Erasure runs on main only; 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.

@ragnorc ragnorc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 use Merge with fresh field IDs. A combined change needs two detached commits because Lance rejects field-path changes inside Merge. 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.

Comment thread docs/user/schema/index.md Outdated
ragnorc added 13 commits October 5, 2026 12:53
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.
@ragnorc
ragnorc force-pushed the claude/schema-apply-metadata-only branch from 41ade51 to b571842 Compare October 5, 2026 11:25
@ragnorc

ragnorc commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. All findings are addressed in five commits on top, rebased onto main (v0.12.0):

  • P2, branch retention: fixed as in the inline reply. The erasure test now includes the branch case.
  • Cost wording: the schema guide, docs/dev/writes.md and the performance note now say apply cost no longer grows with stored rows or payload bytes, and still grows with fragment, column and index metadata.
  • A second review (gpt-6-astra) found a real bug. After a nullable Blob property is added, older fragments have no data file for it, and Lance reads the column as null. row_compare looked up the field's data file before classifying the descriptor. Diffs, the change feed and three-way merges over rows in old fragments therefore failed with fragment … has no data file for blob field ….
    • Fix (b130f793): classify first, and resolve the data file only for managed values. A managed value with no backing file is still BlobIntegrity.
    • Tests (21dd2790): the schema-boundary change-feed test now adds attachment: Blob? and updates rows in the old fragment. A new three-way merge test, branch_merge_compares_rows_written_before_a_blob_property_was_added, covers merges. Both fail without the fix.
    • The other Blob paths were swept: single-cell reads, export, update/merge carry, merge and compaction sizing, the collector, and upgrade. They already classify first or don't depend on per-field files.
  • Release notes: v0.12.0 shipped, so the earlier edit to allow-data-loss-removed.breaking.md is reverted; it's byte-identical to the published note, and release_notes.py verify --version v0.12.0 passes. The drop change is stated only in this PR's dropped-property-storage.changed.md, relative to v0.12. release.json is moved to v0.12.1, identical to feat(cluster): simplify live deployment and delete managed graphs #878, so check-docs passes; whichever PR merges first, the other merges cleanly.

Engine suites: 1,012 passed. Also CLI cluster/schema tests, GQT 255, both clippy graphs, and the docs and release-note checks.

@ragnorc

ragnorc commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

@azimafroozeh I need your call on six decisions in schema_apply and table_store. Each has my recommendation, so a plain "agree" works. CI is green, apart from the still-running Storage Upgrade Compatibility check, and the review findings so far are addressed.

  1. Dropped-property storage. A drop is now logical at apply. Its values stay in the current data files until optimize rewrites those fragments; then cleanup reclaims the old files. In v0.12 the drop's rewrite did this at apply, at the cost of holding the table in memory. Recommend: accept. The alternative is to keep a physical rewrite for drops only, which brings back the unbounded rewrite this PR removes.
  2. Erasure in optimize: always on, or behind a flag. plan_table_compaction always adds a single-fragment task for every fragment that still lists a dropped field id. That costs one rewrite per affected fragment, once per drop. On a vector-indexed table, a rewritten large fragment loses vector-index coverage, so the same optimize rebuilds that index. Recommend: always on. Erasure shouldn't depend on an operator remembering a flag, and the cost is paid once. If you'd rather keep optimize's cost predictable, the alternative is optimize --erase-dropped, with docs saying erasure needs it.
  3. Two detached commits when a table renames or drops and also adds. Lance's merge_schema_valid refuses a field-id rename inside a Merge, including on detached commits. So the table gets Project (renames and drops), then Merge (adds) chained on it. Both are published once, and the matrix covers the window between them. A drop plus an add alone could go in one Merge, but I kept one shape. Recommend: keep.
  4. Erasure tasks and Lance's budgets. The added tasks are single-fragment, so adjacent small fragments holding dropped data are rewritten but not coalesced. They also aren't counted against max_source_fragments / max_source_rows / max_source_bytes. The engine sets none today, and the function's comment says any future budget must count them. Recommend: accept for now.
  5. Error type for storage-planner refusals. plan_schema_evolution refuses type, nullability or stable-marker conflicts as OmniError::Manifest, mirroring the system-column upgrade preflight. The compiler never emits such steps, so reaching that refusal means internal inconsistency. Recommend: manifest_internal, unless you want them typed for callers.
  6. Branches and tags. optimize erases on main only. A branch or tag created after the drop keeps the old files until it's deleted; merging releases nothing, as the earlier review showed. This is documented. Recommend: accept. Running erasure per branch can be a follow-up if needed.

I'll mark the PR ready once you've answered these and the running re-review is clean.

@azimafroozeh azimafroozeh self-assigned this Oct 5, 2026
@ragnorc

ragnorc commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

The red Storage Upgrade Compatibility check here is not caused by this PR. main has failed the same three crossversion journeys since #876: the same errors appear on its merge-queue run, on main, on the v0.12.0 tag and on #878, and this PR touches neither the CLI tests nor the upgrade code. Diagnosis and a question for @azimafroozeh are on #876. Everything else is green, and the gpt-6-astra re-review of the fixes approves with no new findings.

@azimafroozeh
azimafroozeh marked this pull request as ready for review October 5, 2026 12:22
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 6, 2026
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
@ragnorc

ragnorc commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

The red GQ Logic Tests check here is not caused by this PR. GQT (ordinary) was cancelled at its 60-minute budget. Every step that finished passed; the job ran out of time in the benchmark-parity step.

Since #894, that job builds the engine three times, because omnigraph-gqt, omnigraph-gqt-core and omnigraph-bench each resolve their own feature graph. On main this costs about 20 minutes per extra graph even with a warm cache.

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. bench/concurrent-merges timed out the same way this morning, so the rerun I started will probably time out as well.

The fix is #896: one package selection, so one engine build, which matches the cache rule in docs/dev/ci.md. Once it merges I will merge main into this branch.

ragnorc and others added 2 commits October 8, 2026 15:58
…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.
@ragnorc

ragnorc commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Decisions on the six open questions

I 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; optimize then cleanup erase the bytes. Decided: accept.

  • Physical erasure has always needed cleanup. Under the old apply-time rewrite, earlier __manifest versions still named the old files until cleanup released them, and so did any branch, tag or kept version. So the rewrite never gave a stronger guarantee; it only folded one step into apply.
  • The price of that rewrite was memory proportional to the table's Blob bytes (136 MiB at 128 MiB) inside the live server, which breaks invariant 11.
  • The new procedure, drop then optimize then cleanup, is deterministic and bounded.

2. Erasure is always on in optimize, with no flag. Decided: keep.

  • Lance itself rewrites a fragment once its deletions pass 10% of its rows (materialize_deletions_threshold). A dropped column deletes 100% of that column's cells in every fragment, so the same rule makes every such fragment a candidate.
  • A flag would leave dropped data in place indefinitely for any operator who doesn't know about it.
  • The cost is paid once per drop: one rewrite of each fragment that still holds the column, with the existing per-task Blob batch bound.
  • Correction to my earlier note: this costs no index rebuild. With stable row ids, Lance's Rewrite recalculates every row-id-domain index's fragment bitmap onto the new fragments (recalculate_fragment_bitmap). Only ZoneMap and Bloom indexes, the address-domain ones, lose coverage, and the engine builds neither. A single-fragment task always meets that function's all-or-nothing group rule.

3. Two detached commits for a table that renames or drops and also adds. Decided: keep.

  • They are exactly Lance's own commit shapes. alter_columns (rename) and drop_columns commit Operation::Project; the all-null add_columns commits Operation::Merge.
  • merge_schema_valid refuses a field-id rename inside a Merge, and putting additions into a Project would rely on behaviour Lance never uses itself.
  • Atomicity is unaffected. The intermediate version is unreachable until the single publication, the collector follows the chain through read_version, and the matrix covers the window between the two commits.

4. Erasure tasks and Lance's compaction budgets. Decided: refuse budgets in code, not just in a comment.

  • The coalescing concern was wrong. Lance's planner already groups adjacent small fragments into one task (CompactWithNeighbors), whether or not they hold dropped data. The engine adds a task only for a fragment no Lance task includes: one at target size, or a lone small one.
  • The budget concern was real but latent. limit_tasks_to_source_budget runs inside Lance's planner, so appended tasks would bypass max_source_*.
  • The engine sets no budget: it always passes CompactionOptions::default(), and dataset-config overrides apply only through from_dataset_config, which the engine doesn't call.
  • So plan_table_compaction now refuses options that set any budget, as manifest_internal. A future budget then fails loudly until it counts these tasks, instead of being silently exceeded. Test: staged_tests::table_compaction_planning_refuses_source_budgets, which fails with the check removed. Commit 3c88c18; the docs/dev/lance.md row records it.

5. Error type for planner refusals. Decided: manifest_internal, already implemented.

  • The compiler never emits a type change, a nullability change or a marker conflict as a schema-evolution step, so reaching the refusal means internal inconsistency. That is a defect class, not a user error.
  • It also matches the compaction refusal above and the address-style rewrite check in stage_compaction.

6. Erasure on main only. Decided: accept.

  • optimize compacts only main for everything (optimize_all_datasets), not just erasure. Schema apply already requires that main is the only live branch, so the only branches that can hold dropped bytes are ones created after the drop.
  • A branch or tag is a retained reference by design. Erasing past it would mean rewriting the history it exists to keep.
  • So the contract stays as documented in the fragment and the schema guide: delete those branches and tags, then run optimize and cleanup (e.g. --keep 1). Per-branch compaction would be a separate feature for all of optimize.

Also corrected in the PR description: the vector-index cost (see 2) and the coalescing claim (see 4).

@ragnorc ragnorc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@ragnorc
ragnorc enabled auto-merge October 8, 2026 23:25
ragnorc and others added 2 commits October 9, 2026 14:17
…etadata-only

# Conflicts:
#	crates/omnigraph-core/src/instrumentation.rs
#	docs/dev/blob.md
#	docs/dev/lance.md
#	docs/rfcs/0033-blob-management.md
@ragnorc
ragnorc disabled auto-merge October 10, 2026 00:52
@ragnorc
ragnorc enabled auto-merge October 10, 2026 00:52
@ragnorc
ragnorc added this pull request to the merge queue Oct 10, 2026
Merged via the queue into ModernRelay:main with commit db59f08 Oct 10, 2026
33 checks passed
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 13, 2026
…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.
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Oct 13, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants