Skip to content

fix(writes): charge Blob payload apart from the row around it - #903

Open
ragnorc wants to merge 5 commits into
ModernRelay:mainfrom
ragnorc:claude/blob-payload-budget
Open

ragnorc wants to merge 5 commits into
ModernRelay:mainfrom
ragnorc:claude/blob-payload-budget

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

What & why

Step 1 ("3-pre") of the RFC 0033 Phase 3 plan in #900.

Every keyed write checked one 32 MiB ceiling on a batch's whole Arrow memory. That count includes a managed Blob value's bytes, so a value of exactly 32 MiB could never fit: the row around it is never empty. A one-row batch holding a 33,554,432-byte value and an id measures 33,555,112 bytes. The documented 32 MiB per-value limit was therefore unreachable through keyed writes, and the planned blob put could not keep its "32 MiB inclusive" promise.

Change

  • One shared accounting type. KeyedBytes in storage_layer.rs splits a batch's Arrow memory into two parts, each under its own 32 MiB ceiling:

    • payload: the logical length of each managed Blob value in the logical {data, uri} shape;
    • framing: everything else, including URIs and Blob offsets and validity.

    The two parts always sum to the old count, so no byte leaves the accounting.

  • Every keyed check uses it:

    • mutation and load staging, per table and per operation;
    • stage_keyed_write and keyed-write preparation, including the predicted external copy;
    • the update scan that carries untouched Blob cells (the payload left over becomes the budget for reading those cells);
    • branch merge row buffering and its chunk bound;
    • the proven-insert chunking;
    • the compatibility loader's pre-decode forecast (account_keyed_json_row), where a base64: value counts its decoded length as payload.
  • Resource names. Framing refusals keep their existing names. A payload refusal uses the same name with Blob payload bytes in place of bytes, for example keyed write Blob payload bytes for node:Document.

  • Unchanged limits:

    • encoded limits: the strict NDJSON line cap (used by omnigraph load and /load/ndjson) and HTTP body caps;
    • the per-value decoded check, now spelled with the payload constant;
    • overwrite loads.
  • Lance guards:

    • filtered_scan_tolerates_merge_update_row_id_overlap now also asserts that a whole-row merge-update keeps the row's stable row id. The Blob write's ETag evidence will rely on that.
    • New merge_insert_refuses_an_external_blob_outside_the_dataset_bases pins why carried external cells are materialized rather than re-sent as references.

Behavior changes

  • A managed value of exactly 32 MiB now fits through an embedded .gq insert or update parameter, a carried update cell, and the embedded load API.
  • One operation may hold up to 32 MiB of Blob payload plus 32 MiB of other row data, where it held 32 MiB in all. This is an accounting envelope, not a measured heap figure.
  • An update carrying an oversized Blob cell now reports retained keyed batch Blob payload bytes per operation.

Tests

  • staged_tests::keyed_bytes_split_blob_payload_from_framing: an exact-limit batch's split, a one-byte-over payload refusal, and an external reference counted as framing only.
  • staged_tests::pending_scan_budget_caps_are_inclusive_and_one_over_is_typed: extended with the payload ceiling.
  • loader::tests::parsed_forecast_charges_base64_payload_apart_from_framing.
  • writes.rs:
    • mutation_update_rejects_oversized_blob_before_payload_read_pre_effect now asserts the payload refusal name, plus a new inclusive case: an external cell of exactly 32 MiB is carried and stored.
    • New exact_limit_blob_payload_fits_beside_its_row_and_one_more_byte_is_refused covers:
      • an insert and a compatibility load admit an exact-limit value, and both read back byte-exact;
      • the strict loader refuses the 44.7 MB line;
      • one more byte is refused by name, with no graph commit.
  • The two Lance guards above.

Two existing writes.rs tests pinned the old combined allowance and were adapted:

  • external_blob_bytes_join_the_operation_payload_allowance_before_any_payload_read now shows that a copied external payload shares the operation's payload allowance with managed payload: 12 MiB copied plus 21 MiB managed is refused before any payload read. The old case, 24 MiB copied plus 10 MiB of rows, is now accepted under the separate allowances.
  • materialized_blob_batches_are_rechecked_against_the_operation_allowance_before_staging keeps its purpose. The builder's unused capacity is framing, so 13 MiB of rows per type makes the post-read re-check refuse what the prediction admitted.

Local runs, all with --features failpoints:

Target Result
engine lib table_store 57 passed
engine lib loader 35 passed
writes 57 passed
lance_surface_guards 62 passed, 2 ignored
branching 48 passed
merge_fast_forward 20 passed
merge_truth_table 2 passed
end_to_end 69 passed
changes 50 passed

Also: engine Clippy (default and failpoints), cargo fmt, check-docs.py and typos. The full workspace graph and the cloud suites are left to CI.

Docs: docs/user/blobs.md (limits table and loader note), docs/user/mutations/index.md, docs/user/cli/managed-data.md, docs/dev/writes.md, docs/dev/execution.md, and changelog fragment blob-payload-budget.changed.md.

Every keyed write checked one 32 MiB ceiling on a batch's whole Arrow
memory, which includes a managed Blob value's bytes, so a value of
exactly 32 MiB never fit beside its row. KeyedBytes splits that count into
payload (each managed value's logical length) and framing (the rest),
each under its own 32 MiB ceiling, and every keyed check uses it:
staging, keyed-write preparation, the update carry scan, branch merge
buffering and proven-insert chunking, and the compatibility loader's
pre-decode forecast. The halves sum to the old count. A payload refusal
names its resource with 'Blob payload bytes' in place of 'bytes'.

Two Lance guards pin what the Blob write plan relies on: a whole-row
merge-update keeps the row's stable row id, and merge-insert refuses an
external reference outside the dataset's bases.
@ragnorc
ragnorc marked this pull request as ready for review October 8, 2026 23:54

@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 loader forecast in the inline comment. Reviewed 2c305ca41de66be39be4d3a2e648f74c109b2ee6. I use COMMENT because the authenticated account is the PR author.

A 32 MiB Blob could never fit under the old combined limit: its surrounding row also used bytes. This PR separates managed payload from framing, with a 32 MiB limit for each. The shared accounting type serves writes, carried update cells, external copies, and merge chunks. It makes the inclusive payload limit reachable through embedded mutations and compatibility loads. HTTP body and strict NDJSON line limits still apply first.

The remaining defect is a false refusal. A 24 MiB Blob beside a String containing base64: plus 12 MiB of A succeeds through mutation. Merge-load rejects the same values as 33 MiB of Blob payload. The loader must use the declared property type, as the Arrow check does. This leaves part of the new admission contract unsatisfied. The old combined limit also refused this row.

The core design addresses the cause and reduces liability. KeyedBytes replaces repeated scalar checks with shared operations over two explicit units. It adds no persisted state, public API, or storage format. Five similar write changes could reuse this type without adding five accounting rules. The remaining liability is the separate text-based rule in the loader. Its callers already have the catalog properties needed to remove that rule.

The tradeoff is a larger accounted retention envelope: up to 64 MiB per operation instead of 32 MiB. This is not an RSS bound. Computing payload lengths also visits the Blob cells. I did not benchmark CPU time or peak memory. Unused Arrow capacity still counts as framing, so no retained buffer bytes leave the accounting. See logical_blob_payload_bytes and the external-copy admission and recheck.

Local validation used Rust 1.97.1, the locked dependencies, failpoints, and local filesystem storage:

Check Result
Clean head, writes owner 57 passed, none skipped
Unchanged Blob GQT case, head and parent One ordinary execution passed at each commit
Same temporary GQT boundary regression at b8b631ee8f813e557805baf33efe869011f48275 Failed at the added insert: keyed entity bytes for node:Doc, actual 33,556,272, limit 33,554,432
Same regression at 2c305ca41de66be39be4d3a2e648f74c109b2ee6 Passed, one ordinary execution
Temporary mixed String/Blob assertion in the existing exact-limit Rust test Mutation passed. Merge-load failed: keyed parsed entity Blob payload bytes for node:Document, actual 34,603,008, limit 33,554,432

Please include a minimal GQT boundary regression. I extended blob_update_carries_unassigned_blobs.gqt with one insert, one 32 MiB Blob parameter, and expect affected: nodes=1 edges=0. Thus, the claim that this is outside the case format is too broad. Generated loads have a 16 MiB cap, but mutation parameters do not. The temporary parameter fixture is 44.7 MB of base64 text; that fixture cost should remain explicit. Keep byte-read and pre-effect assertions in the existing Rust owner. The compatibility merge-load probe also belongs there: GQT exposes generated loads, whose cap prevents this input.

Commands (the GQT file was identical at both commits):

cargo +1.97.1 test -p omnigraph-engine --locked --features failpoints --test writes -- --test-threads=2
cargo +1.97.1 test -p omnigraph-engine --locked --features failpoints --test writes exact_limit_blob_payload_fits_beside_its_row_and_one_more_byte_is_refused -- --exact --nocapture
cargo +1.97.1 run -p omnigraph-gqt --locked --features omnigraph/failpoints --bin omnigraph-gqt -- /tmp/review-903/blob_limit.gqt --target omnigraph-engine --storage local-filesystem

I reused CARGO_TARGET_DIR=/Users/ragnor/work/omnigraph/.claude/worktrees/omnigraph-heal-race-2f5af1/target with RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable'. At the parent, after rebuilding with the unchanged GQT case, I ran "$CARGO_TARGET_DIR/debug/omnigraph-gqt" /tmp/review-903/blob_limit.gqt --target omnigraph-engine --storage local-filesystem --artifacts /tmp/review-903/gqt-parent-artifacts. The final head confirmation passed again in 15.09 seconds. GQT results above cover the ordinary target only. All temporary source edits are restored. No PR changes were pushed.

I checked the complete relevant Lance guidance against crates.io Lance 11.0.0, source commit ab6b5bbe46009ed78746b444df8db59a8bc5d842. Its merge-insert writer uses default write parameters and preserves captured stable row IDs. I also checked the pinned Blob builder and external-Blob writer options against the upstream Blob guide. No Lance dependency or storage-format change is part of this PR.

Exact-head CI also passed: workspace tests and cloud/storage checks, ordinary and DST GQT, and the pinned DST suite. These are CI results, separate from the local tests above. I found no additional blocking defect or optional design change to request.

Comment thread crates/omnigraph/src/loader/mod.rs Outdated
The compatibility loader's pre-decode forecast charged every `base64:`
string as Blob payload, so a declared String whose text starts with
`base64:` used the payload ceiling: a 24 MiB Blob beside such a 12 MiB
String was refused at 33 MiB although the batch check admits it. The
forecast now takes the type's Blob properties, as the Arrow check does:
only a Blob property's `base64:` value is payload; every other value is
framing.
@ragnorc

ragnorc commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

On the GQT boundary regression from the review: the case format can express it, but only as 44.7 MB of literal base64: text in a checked-in case, which would be by far the largest file in the repository and over the 10 s case budget. So I corrected the Rust test's comment (it said the boundary was outside the case format, which was too broad), and the format gains generated parameters in #927: --- params generate: v1 seed: <u64> with a {kind: blob, byte, length} column. Once #927 merges, this PR adds the boundary as a small cases_slow/ case: one insert with a generated 32 MiB Blob parameter and expect affected: nodes=1 edges=0. The byte reads, pre-effect assertions and the compatibility-loader probe stay in the Rust owner, as you suggested.

@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 until the promised minimal GQT regression is included. The loader defect from my previous review is fixed. I found no further code defect. Reviewed 90b6688ad773b4195723caa3416a64f5e332b740. This is a COMMENT review because the authenticated account is the author.

This PR lets a 32 MiB Blob fit beside its row. It separates payload bytes from the surrounding row bytes, with a 32 MiB limit for each. The follow-up fixes a false refusal when an ordinary String starts with base64:. For example, a 24 MiB Blob and 12 MiB of such String text must not count as 33 MiB of Blob payload.

The fix addresses the general cause: the accepted schema owns the property type. All four node/edge loader paths now pass the existing catalog's Blob property set to the shared forecast. Only those properties receive payload accounting. Nested values stay framing. The Arrow builders use the same catalog authority.

This removes a competing type rule and reduces long-term liability. It adds private arguments, with no public API, persistent state, or storage format. Five similar loader changes can reuse this helper. It adds one set lookup per property. I did not benchmark the cost.

The full PR permits up to 64 MiB of accounted retention across its two limits. That is not an RSS bound. HTTP body and strict NDJSON line limits still apply before this embedded-write boundary.

The earlier GQT request remains open at this head. Your update proposes a compact slow case after #927. Please include that case before merge, with evidence that it fails before the fix and passes after. Keep the compatibility-load, byte-read, and unchanged-head assertions in the existing Rust owner. GQT's generated-load cap prevents the large compatibility-load probe.

The planned generated parameter would avoid the 44.7 MB literal fixture used for my proof. That generator is outside this review's scope. I found no new inline defect or optional design change to request.

Local validation used Rust 1.97.1, locked dependencies, failpoints, and local filesystem storage.

Regression Before Exact reviewed head
Mixed String/Blob load, same writes test At 2c305ca41de66be39be4d3a2e648f74c109b2ee6, failed with payload actual 34,603,008 and limit 33,554,432 One test passed, none skipped
Temporary GQT boundary case, identical file At b8b631ee8f813e557805baf33efe869011f48275, failed at the added insert: keyed entity bytes actual 33,556,272, limit 33,554,432 One ordinary execution passed in 12.72 seconds

For the loader check, I copied the head's test into the old checkout without changing its production code. The failure came from the predicted false refusal. A clean head baseline and the restored-head run both passed.

The GQT probe extends blob_update_carries_unassigned_blobs.gqt with one insert. Its Blob parameter contains 32 MiB of byte 0x07, and it expects one affected node. This proof uses a 60-second file timeout. The GQT proof covers one ordinary filesystem execution at each commit. Other environments were unselected. Production placement must respect the ordinary corpus's 10-second cap, or use cases_slow.

Commands, run at the commits above:

export RUSTUP_TOOLCHAIN=1.97.1
export CARGO_TARGET_DIR=/tmp/review-903-followup/target
export RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable'
cargo test -p omnigraph-engine --locked --features failpoints --test writes exact_limit_blob_payload_fits_beside_its_row_and_one_more_byte_is_refused -- --exact --nocapture
cargo run -p omnigraph-gqt --locked --features omnigraph/failpoints --bin omnigraph-gqt -- /tmp/review-903-followup/blob_limit.gqt --target omnigraph-engine --storage local-filesystem --artifacts /tmp/review-903-followup/gqt-before-artifacts

The head GQT run used gqt-after-artifacts. The documentation and agent-link checks passed. All temporary source edits are restored. No PR changes were pushed.

The exact-head CI run passed. Its writes owner passed all 57 tests, including the new mixed String/Blob assertion. The loader forecast unit test passed too. Ordinary and DST GQT and the pinned DST suite also passed. These CI results are separate from the local checks above.

I checked the relevant upstream guidance against crates.io Lance 11.0.0, source ab6b5bbe46009ed78746b444df8db59a8bc5d842. The Blob builder and merge-insert writer support the unchanged storage assumptions. Current upstream documentation includes newer features, so it was not a substitute for the pinned source. I did not run cloud storage, DST, or performance checks locally.

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