Skip to content

feat(engine)!: bound optimize runs and report each table's outcome - #1204

Closed
ragnorc wants to merge 13 commits into
ModernRelay:mainfrom
ragnorc:feat/bounded-optimize-request
Closed

ragnorc wants to merge 13 commits into
ModernRelay:mainfrom
ragnorc:feat/bounded-optimize-request

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Rollout step 1 (Engine) of remote optimize through the serving owner. It is stacked on #1175 and includes its commits; review the six commits after it, one per slice. The server record and routes (step 2, #1205, closed until acceptance) and the CLI served path (step 3, #1206, closed until acceptance) stack on it.

The RFC is back under review as #1214 after #1203 reverted its early merge. This PR stays a draft until the RFC is accepted, and follows any change the review makes.

optimize_as(actor, OptimizeRequest) returns an OptimizeReport. OptimizeRequest::default() is today's full run.

  1. Gate-hold bound and per-table outcomes (feat(engine)!).
    • Order. A full-catalog run visits tables in type-key order from start_at and wraps. A tables request works in its own order and neither reads nor reports a resume position. Bad requests are refused before any effect.
    • Pipeline. Plans run ahead, and rewrites start in visit order within the planning and rewrite concurrency.
    • Gate hold. Once the gate-hold deadline passes, every table whose turn has not come is deferred(gate_hold_limit). The finished tables publish once, and resume_at names the first deferred table.
    • Tail. The configuration clear and the adjacency artifact report their own outcomes and never fail a published run. Every writer gate is released before the artifact scan.
    • CLI. --max-gate-hold and --tables are added. JSON reports the outcomes and drops the __manifest row and the always-null version fields (breaking note).
  2. Before-publish hook. The shared publication helper calls an optional hook once: after the updates are prepared, and before the coordinator lock and the publisher's first attempt. The hook receives the intent nonce and the captured head. Its failure publishes nothing and is Unpublished. Other callers pass no hook.
  3. Per-table failure isolation. A table's own planning failure (BeforeEffect) or staging failure (Unpublished) reports it failed with its error, and the other tables still publish. An uncertain failure still fails the run. The DST harnesses treat a failed table as the op's failure, so their obligations keep their meaning.
  4. Cancellation.
    • Checks. OptimizeRequest::with_cancellation is checked before every plan, before every rewrite, and before the publication. It is also awaited in between, so a table in progress is dropped where it stands.
    • A cancelled run defers every table as cancelled and publishes nothing. It skips the configuration clear and the artifact, and leaves the resume position where it was.
    • A late cancellation arrives after the last check. The run finishes and reports arrived_late.
  5. Resource ceilings and the run byte budget.
    • Ceilings. TableCeilings covers source fragments, rows and bytes, managed Blob bytes, and index rows. Each is measured from the table's compaction plan before its first write, fragments first, and only when set.
    • Sizes. Bytes are recorded data-file sizes, or a HEAD request through the collector's base rule, now shared as data_file_location and deletion_file_location. Lance 13 records no deletion-file size, so deletion files always take a HEAD.
    • Blob bytes come from the descriptor scan that compaction sizing already runs, refactored into one visitor. External references count nothing.
    • Over a ceiling. The table is deferred(resource_limit) with nothing written for it.
    • Budget. run_source_bytes is also the default table byte ceiling, and a larger explicit ceiling is refused. The first table that would pass the budget, and every later one, is deferred(byte_budget), and resume_at names it.
  6. Adjacency artifact bound.
    • Pre-check. artifact_bytes is checked first against a lower bound: the fixed bytes at the shortest header, plus 8 bytes per live edge row.
    • Counting build. The graph-index build now streams one batch at a time and counts each batch's growth from the widest header. It stops before keeping another batch or allocating the adjacency arrays.
    • Outcome. Either stop is skipped(size_limit) and writes nothing.
    • Write. The artifact write runs in a spawned task the run awaits, so a dropped run cannot drop an issued write.

Tests

  • maintenance.rs tests for:
    • the gate hold and resume position;
    • table lists and their refusals, including a table ceiling above the budget;
    • the before-publish hook: called once, its nonce answered by the commit, and a failing hook publishing nothing;
    • cancellation before the run and late cancellation;
    • each table ceiling, with a deletion file in the plan;
    • the byte budget, measured through a refusing hook, and its resume;
    • Blob bytes ignoring external references;
    • the artifact limit, both before the scan and during the counting build, writing nothing, then rewriting at its exact size.
  • failpoints.rs tests for:
    • a failed configuration clear after publication;
    • one table failing while the other publishes;
    • cancellation while a table is parked mid-staging.
  • Matrix: the optimize post-detached cell now asserts the run succeeds.
  • Unit: a persist.rs test brackets the serialized size between the two bounds.
  • CLI: the renderer test covers every outcome, including a failed table.
  • Ran locally:
    • engine maintenance::, traversal_adaptive::, traversal_indexed:: and merge_cleanup::;
    • the optimize, cleanup and collector failpoints;
    • detached_commit_matrix, policy_engine_chassis, forbidden_apis and the seam guard;
    • the CLI renderer and optimize integration tests;
    • the DST cost golden (unchanged) and the maintenance obligation tests;
    • the GQT traversal cases;
    • Clippy in both feature graphs, cargo fmt --check, check-docs.py and typos.

Add the `maintain` and `reclaim` Cedar actions. `maintain` covers
optimize, index builds, the full-text rebuild and the repair report and
is judged on the branch maintained; `reclaim` covers cleanup and takes
no branch scope.

Maintenance has no actor-less public engine entry point any more:
`optimize_as`, `cleanup_as`, `ensure_indices_as`, `ensure_indices_on_as`,
`rebuild_full_text_indices_on_as` and the read-only `repair_report_as`
each enforce their action before any effect. The full-text rebuild
admits `maintain` or `change` through `PolicyChecker::check_any`.
Repair loses its publishing mode, its options and its hard-coded actor
strings. The CLI, DST, GQT and bench callers move to the `_as` forms;
the direct CLI passes the actor it resolves for direct writes.
…cy-actions

The index build from main checked `change`; it now checks `maintain` like
the other index writers, and `ensure_indices_on_as` returns main's
`IndexBuildResult`. The actor-less `ensure_indices` and `ensure_indices_on`
stay removed. Main's unreleased index-build note is updated to match, and
the maintenance page and notes are brought within their caps.
…utcomes

optimize_as takes an OptimizeRequest (tables, start_at, max_gate_hold,
planning and rewrite concurrency) and returns an OptimizeReport.

A full-catalog run visits tables in type-key order from start_at and
wraps; a tables request works in its own order and reports no resume
position. Bad requests are refused before any effect. Plans run ahead
in order and rewrites start in visit order. Once the gate-hold deadline
passes, every table whose turn has not come is deferred, the finished
tables publish once, and resume_at names the first deferred table.

The configuration clear runs under the gates and reports its own
outcome; the adjacency artifact is built after every writer gate is
released. Neither fails a run whose commit is durable.

The CLI gains --max-gate-hold and --tables. optimize --json reports one
outcome per table, the input and published commits, the gate hold and
both tail outcomes; the __manifest row becomes configuration_clear and
the always-null published_dataset_version and lance_head_version go.
The shared publication helper takes an optional hook and calls it once,
after the updates are prepared and before the coordinator lock and the
publisher's first attempt, with the lineage intent's nonce and the
captured head. Every attempt reads that head and carries that nonce, so
a call per attempt would record nothing new. A hook failure publishes
nothing and is labelled Unpublished whatever its own evidence says. The
helper's other callers pass no hook.

OptimizeRequest::with_before_publish passes the hook with the run's
per-table outcomes. A run that stages no table never calls it.
A table's planning failure is labelled BeforeEffect and its staging
failure Unpublished. Either reports the table failed, keeps the error on
its stats, and leaves the other tables to publish in the run's one
commit. A failure whose evidence stays uncertain still fails the run, as
do failures outside one table: authority, catalog, gates and the
publisher.

The CLI reports a failed table with its error. The DST harnesses treat a
failed table as the op's failure, so the convergence obligation still
requires a rerun to finish every table.
OptimizeRequest::with_cancellation takes a shared handle. The run checks
it before every table's plan and rewrite and before the publication, and
awaits it meanwhile, so a table in progress is dropped where it stands;
its detached effects stay unreachable. A cancelled run defers every
table as cancelled, publishes nothing, leaves the configuration and the
resume position as they were, and skips the artifact scan. A
cancellation that arrives after the last check lets the run finish and
is reported as having arrived late; only the artifact scan is skipped.
OptimizeLimits gains TableCeilings (source fragments, rows and bytes,
managed Blob bytes, and the rows an index rebuild or build would scan)
and run_source_bytes. Each set ceiling is measured from the table's
compaction plan before its first write, fragments first so they bound
the size requests, and counting stops once a value passes its ceiling.
A table over one, or one whose size cannot be read, is deferred as
resource_limit with the measure, and nothing is written for it.

Source bytes are a data file's recorded size or a HEAD of the file, and
a HEAD of each deletion file, which Lance 13 records no size for. Files
resolve through the collector's base rule, now shared as
data_file_location and deletion_file_location. Blob bytes come from the
descriptor scan compaction sizing already runs, refactored into one
visitor; external references count nothing.

The run budget is also the default table byte ceiling, and a larger
table ceiling is refused before any effect, so a first table within its
ceilings always fits. The first table that would pass the remaining
budget is deferred as byte_budget with every later one, and resume_at
names it. Each table's measured bytes are reported on its stats.
OptimizeLimits::artifact_bytes bounds the whole object. Before the edge
scan, a lower bound (the 20-byte prefix, every section prelude, one
extra offset entry per array, the header at its shortest, and 8 bytes
per live edge row) skips the artifact when it already passes the limit.
During the scan, GraphIndex::build_counted reads each edge table one
batch at a time and adds what the batch grows the object by, counted
from the widest header: 4 bytes plus the UTF-8 length per new id, 4
bytes in every offset array its type sizes, and 8 bytes per edge. Once
the count passes the limit it stops before keeping another batch or
allocating the adjacency arrays. Either stop reports skipped(size_limit)
and writes nothing. The unbounded build streams the same way instead of
collecting every batch first.

The artifact write runs in a spawned task the run awaits, so a dropped
run cannot drop an issued write on the mutable key.

@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.

Why, what, how, impact

Why. Remote optimize (RFC #1214) needs optimize to be bounded, cancellable and to report what it did per table.
What. optimize_as(actor, OptimizeRequest) returns an OptimizeReport. It adds a gate-hold bound, a before-publish hook, per-table failure isolation, cancellation, per-table ceilings plus a run byte budget, and an adjacency-artifact size bound.
How. One pipeline (run_table_pipeline) plans ahead, starts rewrites in visit order, and publishes once. Every stop becomes a typed outcome (Deferred(..), Skipped(..), Failed).
Impact. Direct optimize keeps its behavior under OptimizeRequest::default(). The CLI JSON output changes (breaking note present).

Reviewed head: 6db66e1e (merge base with main 14815c6e; main is now dd7a9f75). I read the six slice commits after #1175 in optimize.rs, table_ops.rs, graph_index/{mod,persist}.rs, collector.rs, table_store.rs and the CLI change.

Blocking

  1. The branch conflicts with main. A merge-tree against dd7a9f75 conflicts in db/mod.rs, db/omnigraph/optimize.rs, db/omnigraph/repair.rs, tests/forbidden_apis.rs and tests/integration/maintenance.rs. #1270 changed the write path. Rebase, then re-run the optimize failpoint and DST maintenance tests, because the conflicts sit in the pipeline file.
  2. Order. This PR contains #1175's commits and waits on RFC #1214. It cannot merge before either. This is a process block, not a code defect.

I found no correctness defect in the code at this head.

What I checked in the code

  • Gate-hold stop: Bound(turns, ..) names the first unvisited table. turns counts tables popped in visit order, and ready is ordered, so resume_at is correct. The first table always starts.
  • Byte budget: when a budget is set, every table is measured (table_source_bytes() falls back to the budget). A table ceiling above the budget is refused. The first table with work therefore always fits.
  • Cancellation: a cancelled run publishes nothing and skips the configuration clear. The staged tables are deferred(cancelled). Dropped staging futures leave only unreachable detached commits. A late cancel is ArrivedLate.
  • Failure isolation: only BeforeEffect and Unpublished evidence stays with the table. Any other evidence fails the run.
  • Artifact bound: ArtifactCount is an upper bound on the written bytes (my reading, and the PR's unit test shows it within the header digits). The pre-check is a true lower bound. The spawned write task keeps an issued write from being dropped.
  • Blob refactor: visit_row_blob_bytes keeps the old early stop. A table with no Blob column now returns early and gives Some(0), the same answer as before.

Optional

  1. The before-publish hook runs under all writer gates and has no bound. max_gate_hold does not cover it. The doc says the writer wait is at most the hold, plus the last table, the publication and the configuration clear. A slow hook (a server record write) breaks that sentence. Either bound the hook with a timeout that fails as Unpublished, or add the hook to the documented wait.
  2. index_rows may not see a rebuild that compaction causes. measure_plan gets index_work from the plan before any write. apply_optimize_table_effects calls stage_index_fold after the rewrite, on the new tip. If a rewrite alone makes an index lag, that rebuild can run over more rows than the ceiling allows. I did not test this; it is a question about the contract. A test with a vector or scalar index and a compaction-only table would settle it.
  3. Liability of five parallel ceilings. Each ceiling touches an enum variant, as_str, a builder, a measure_plan arm, and the CLI and JSON output. A sixth measure repeats all of that. A small table of (measure, limit, measure_fn) would give one owner. Keep the typed output tokens.
  4. Repeat cost of the artifact skip. When the artifact is over its limit and none exists, every later run repeats the count_rows pre-check and the counting scan, up to the limit. The cost is bounded by the limit. Say so in the maintenance doc, or cache the verdict by edge-table stamps.

Validation

Local, at the exact head 6db66e1e (shared target dir, 2 cores):

  • cargo test -p omnigraph-engine --locked --test integration maintenance::: 61 passed, 0 failed.
  • cargo test -p omnigraph-engine --locked --features failpoints --test failpoints optimize: 11 passed, 0 failed (includes cancel mid-run, per-table failure, failed configuration clear).

CI on this head: every check in statusCheckRollup is SUCCESS or SKIPPED, including CI Gate, Test Workspace, DST pinned suite and the GQT jobs.

Not run by me: clippy, DST cost golden, CLI tests, the persist.rs unit test. I rely on CI for those.

Limitation on the regression proof. This is a new API, not a bug fix. The base has no OptimizeRequest, so the new tests cannot compile there, and a compile failure is not a before/after proof. GQT cannot express optimize outcomes, gate holds or cancellation, so the Rust integration and failpoint owners are the right place. I did not run the performance and storage-cost claims; the PR says the DST cost golden is unchanged, and CI is green on it.

@ragnorc ragnorc closed this Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant