Add blob upload: PUT /graphs/{id}/blob and omnigraph blob put#372
Add blob upload: PUT /graphs/{id}/blob and omnigraph blob put#372ragnorc wants to merge 4 commits into
Conversation
New public Omnigraph::write_blob_at{,_as}(branch, type, id, prop, bytes,
precondition) replaces one existing node row's Blob cell. Update-only: a
missing row is a typed not-found. The write composes the existing
Mutation-v9 protocol exactly — mutate_one_attempt's publish tail
(validate, stage_all, lineage, commit_all, publish, RecoveryRequired
wrapping, sidecar cleanup) is extracted into one shared
publish_staged_mutation helper so the orchestration cannot drift and
every durable call keeps its single registered site.
The row is read via a new target-column-excluding variant of the
materializing scan: other blob cells are carried through (external refs
inline, the documented update behavior) while the replaced cell is
null-filled — its old payload is neither read nor charged against the
32 MiB batch budget. The payload enters as raw bytes through Lance's
BlobArrayBuilder; no base64 round-trip.
Preconditions are transport-neutral BlobPrecondition tokens evaluated
against the same pinned base each attempt prepares from; the write's
retry classification is deliberately retryable (unlike .gq updates)
because the whole read set is re-derived per attempt, so both
last-writer-wins and If-Match re-evaluation hold by construction. A
mismatch returns the must-use BlobWriteOutcome::PreconditionFailed
variant rather than widening the error taxonomy. blob_etag derivation
moves into the engine (api-types delegates, unchanged signature) and
api-types gains the shared RFC 9110 parse_if_match boundary parser.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149LrcotcFUeveBZBA4ww5T
The request body IS the payload — no base64 inflation, no JSON envelope — so the full 32 MiB ingest budget is usable (the inline-base64 route effectively capped payloads near 24 MiB and died at the body limit). Update-only (404 on a missing row); branch defaults to main; snapshot addressing is rejected as read-only. The handler mirrors the load route's shape: Cedar change action on the branch, then per-actor workload admission sized by the body — a binary PUT does not bypass the 429 contract other write endpoints carry. If-Match is parsed at the transport boundary by the shared api-types parser (entity-tag lists, *, weak-tag rejection per RFC 9110) into the engine's typed precondition; a mismatch answers 412 with the current validator in ETag. Success answers 200 with the committed validator in ETag plus a BlobPutOutput JSON body shared with the CLI. Documented Hyrum-facing contracts: the validator is table-version-granular, and a row's OTHER external-reference blob columns are inlined by the write. Also corrects a stale server.md line claiming external blob sizes are recorded at load time (removed by the canonical-shape fix; sizes are unknown by design). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149LrcotcFUeveBZBA4ww5T
omnigraph blob put TYPE ID PROP uploads raw payload bytes from --file or stdin into one existing row's Blob cell — no base64 step, so the full 32 MiB budget is usable from the command line. The remote arm PUTs the raw body with an optional If-Match header; the embedded arm parses the same --if-match value with the shared api-types parser and calls write_blob_at_as under the --as actor. Both arms print the shared BlobPutOutput object and surface a stale precondition through one identical typed error carrying the current validator, so exit codes and messages hold parity by construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149LrcotcFUeveBZBA4ww5T
parity_blob_put pins the strongest cross-arm contract: scrubbed-JSON equality where etag and size are not in the volatile allowlist, so the identity-derived validator must match exactly (both arms start from the byte-mirrored graph and apply the identical operation), plus a round-trip get. Stale If-Match, missing-row, and oversize rows pin matching failure exit codes and the shared typed precondition error. KNOWN_DIVERGENCES stays empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149LrcotcFUeveBZBA4ww5T
| let committed = self | ||
| .read_blob_at( | ||
| crate::db::ReadTarget::branch(requested.as_deref().unwrap_or("main")), | ||
| type_name, | ||
| id, | ||
| property, | ||
| ) | ||
| .await?; |
There was a problem hiding this comment.
Post-Commit Read Returns Wrong ETag
After this upload is published, read_blob_at reads the mutable branch head rather than the version just committed. If another writer updates or deletes the row in that window, this request returns the later writer's ETag or reports an error even though its own upload is already durable, violating the Written outcome and HTTP response contract.
Context Used: AGENTS.md (source)
| let bytes = match &file { | ||
| Some(path) => std::fs::read(path)?, | ||
| None => { | ||
| let mut buffer = Vec::new(); | ||
| std::io::Read::read_to_end(&mut std::io::stdin().lock(), &mut buffer)?; | ||
| buffer | ||
| } | ||
| }; |
There was a problem hiding this comment.
Oversized Input Is Buffered Unbounded
Both file and stdin input are fully loaded into a Vec before the 32 MiB limit is checked. A large file can exhaust the CLI process's memory before the embedded engine rejects it or the remote server returns 413; the reader should stop after observing one byte beyond the supported limit.
Context Used: AGENTS.md (source)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b92681d8c1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let precondition = headers | ||
| .get(IF_MATCH) | ||
| .and_then(|value| value.to_str().ok()) | ||
| .map(parse_if_match); |
There was a problem hiding this comment.
Reject malformed If-Match instead of dropping it
When a direct HTTP client sends an If-Match header that cannot be converted with to_str() (for example an obs-text/non-UTF8 value), this path turns the failed parse into None and the engine performs an unconditional last-writer-wins blob overwrite. Because If-Match is the safety precondition for this write endpoint, malformed values should be rejected or treated as a non-match (412), not silently ignored.
Useful? React with 👍 / 👎.
|
Closing this PR because its contract and implementation are a direct follow-up to #367, which was reverted by #373 to restore the RFC governance boundary. This PR also introduces a new public engine, HTTP, and CLI write feature ( The implementation can be revisited after a blob-delivery RFC establishes the read/write contract, concurrency semantics, resource limits, and API/CLI surface. |
What & why
Adds the blob upload half of the blob surface that merged in #367:
PUT /graphs/{id}/blobaccepts raw payload bytes as the request body (no base64, no JSON envelope) and materializes them into one existing row's Blob cell as a single atomic graph commit, withomnigraph blob putexposing the same contract over both the embedded and remote arms. This closes the workflow that previously required chunking: a 33 MB PDF whose base64 form (~44 MB) died at the 32 MiB ingest body limit now uploads withcurl -T big.pdfin one call — verified live end-to-end (200 in ~0.3 s, SHA-256-identical GET round-trip, staleIf-Match→ 412).Backing issue / RFC
Checklist
write_blob_atcomposes the Mutation-v9 protocol via a shared publish-tail helper extracted frommutate_one_attempt, so recovery classifies its sidecar identically to a one-row mutation; If-Match parsing stays at the transport boundary (invariant 11); the write is admission-gated like every write endpointNotes for reviewers
If-Match(strong comparison, entity-tag lists,*= row exists) answers 412 with the current validator; unconditional PUT is last-writer-wins. Snapshot addressing is rejected on the write..gqupdates (retryable = false), the blob PUT reprepares on pre-effectReadSetChanged— it is a declarative full-cell replacement whose whole read set (row scan + precondition) is re-derived per attempt, which is exactly what makes last-writer-wins absorb unrelated conflicts AND keeps If-Match evaluated against the fresh base. Commented at the site.BlobWriteOutcome::PreconditionFailedis a#[must_use]Ok variant, not an error — 412 is a well-formed answer, and the closed error taxonomy stays untouched. Both CLI arms map it to one identical typed error (parity-pinned).🤖 Generated with Claude Code
https://claude.ai/code/session_0149LrcotcFUeveBZBA4ww5T
Generated by Claude Code
Note
Medium Risk
Touches durable write orchestration (shared mutation publish path), admission-gated HTTP writes, and conditional concurrency via ETags; behavior is heavily tested but incorrect precondition or budget handling could affect data integrity.
Overview
Adds the blob upload surface alongside existing blob read:
PUT /graphs/{id}/blobandomnigraph blob putaccept raw octet-stream bodies (no base64/JSON) to replace one existing row’s Blob cell on a branch.The engine exposes
write_blob_at/write_blob_at_as, routing through a sharedpublish_staged_mutationtail so blob writes use the same Mutation-v9 commit/recovery path as one-row mutations. Uploads support optionalBlobPrecondition/ RFC 9110If-Match(strong tags,*); mismatches returnBlobWriteOutcome::PreconditionFailed(HTTP 412 / shared CLI error).blob_etagandparse_if_matchmove into the engine withomnigraph-api-typesre-exports for server/CLI parity.Scans can skip materializing the target blob column so the old payload isn’t read or charged against the 32 MiB budget. The server handler enforces policy, workload admission, and a 32 MiB body limit; CLI reads stdin/
--file, supports--if-match, and mirrors remote vs embedded behavior in tests and the parity matrix. Docs and OpenAPI add the upload contract.Reviewed by Cursor Bugbot for commit b92681d. Bugbot is set up for automated code reviews on this repo. Configure here.
Greptile Summary
This PR adds raw blob uploads across the engine, HTTP API, and CLI. The main changes are:
PUT /graphs/{id}/blobendpoint with authorization, admission, and body limits.omnigraph blob putcommand.Confidence Score: 4/5
The post-commit ETag race and unbounded CLI input buffering need fixes before merging.
crates/omnigraph/src/exec/mutation.rs; crates/omnigraph-cli/src/main.rs
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant C as Client participant H as Blob PUT handler participant E as Engine participant M as Mutation publisher C->>H: Raw bytes + optional If-Match H->>E: write_blob_at_as E->>E: Pin base and check precondition E->>M: Stage and publish row replacement M-->>E: Commit durable E->>E: Re-read mutable branch head for ETag Note over E: Another write can intervene here E-->>H: Written outcome or read error H-->>C: Upload response%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant C as Client participant H as Blob PUT handler participant E as Engine participant M as Mutation publisher C->>H: Raw bytes + optional If-Match H->>E: write_blob_at_as E->>E: Pin base and check precondition E->>M: Stage and publish row replacement M-->>E: Commit durable E->>E: Re-read mutable branch head for ETag Note over E: Another write can intervene here E-->>H: Written outcome or read error H-->>C: Upload responseReviews (1): Last reviewed commit: "Add blob put rows to the embedded/remote..." | Re-trigger Greptile
Context used: