Skip to content

Blob storage: Blob state machine with a pluggable gRPC data plane#109

Draft
rjhuijsman wants to merge 6 commits into
mainfrom
rjh.blob-storage-v2
Draft

Blob storage: Blob state machine with a pluggable gRPC data plane#109
rjhuijsman wants to merge 6 commits into
mainfrom
rjh.blob-storage-v2

Conversation

@rjhuijsman

Copy link
Copy Markdown
Contributor

Important

The commit series here is human-authored, but the split out of a monorepo PR and the rebase onto current main were performed by an agent — including the conflict resolutions called out under "Rebase notes" below. Those need author review before peer review.

Before this change, Reboot had no first-class way to store large binary objects: state machines hold protobuf state, which is unsuited to multi-megabyte payloads, so applications had nowhere to put user uploads like images or videos.

Design

A Blob state machine is the control plane for one immutable-once-committed binary object: its state holds only metadata (content type, size, upload progress, lifecycle, authorization) while the bytes travel directly between the client and a data plane via URLs minted per part. Uploads are resumable, sizes are enforced against the real bytes at commit time, and never-committed blobs expire automatically.

The data plane is a plain gRPC service, BlobDataPlane (data_plane.proto — no Reboot options, implementable by anything), discovered via REBOOT_BLOB_DATA_PLANE_URL:

  • Every local run mode provides one automatically. rbt dev run and rbt serve run spawn the open-source filesystem server as a subprocess; the reboot.aio.tests.Reboot harness runs it in-process, so reboot.std.blobs works in unit tests out of the box.
  • Reachability is declared, not hardcoded. A data plane's Configuration names the path namespaces (method + prefix, required to live under the reserved /__/reboot/blob/) it needs forwarded to it, plus the port of its HTTP byte endpoint — the host and transport security are deduced from the gRPC endpoint the application already has, so a data plane never needs to know how it is addressed externally. The Blob library reverse-proxies exactly those namespaces; the loopback-only filesystem server uses this, so one origin/tunnel serves control plane and bytes. A data plane with directly-reachable URLs (presigned S3) asks for nothing and the application is never in the byte path — and per-URL mixing (e.g. proxied uploads, CDN downloads) falls out for free.
  • Swappable under running applications. Download URLs are minted per request via the data plane, so pointing REBOOT_BLOB_DATA_PLANE_URL at a richer implementation (e.g. S3 behind a CDN) takes effect immediately, with no stale URLs and no application changes.

Authorization: blob creation is application-mediated; the blob's random id then acts as a capability; uploads require a matching uploader_id, downloads a match in the downloader_ids allow-list (either deliberately open when unset; the uploader is not implicitly a downloader), and Info is visible to anyone who may upload or download.

The Reboot Cloud implementation of BlobDataPlane (S3-backed, served by each application's facilitator) lives outside this repository and lands separately; nothing here depends on it.

Contents

Reviewable in sequence; each commit builds and its tests pass.

  1. reboot: support the PUT method for custom HTTP routes — the byte protocol needs PUT.
  2. reboot: run library pre_run hooks in the test harness — so a library can start machinery before the application serves.
  3. reboot/std: add a Blob state machine with a gRPC blob data plane — the control plane, the BlobDataPlane interface, the filesystem data plane, and the reverse proxy.
  4. rbt: run the filesystem blob data plane under dev/serve run.
  5. reboot/std/react: add browser helpers for blob upload and downloaduseBlobUpload, BlobUploader, useBlobDownloadUrl.
  6. reboot/examples/chat-room: support message attachments — the whole thing end to end in an example.

Validation

  • Unit tests (tests/reboot/std/blobs/v1/blobs_tests.py): upload/download/commit/delete, ETag and size enforcement, and the uploader/downloader/info authorization checks — against the harness-run filesystem data plane, through the application's reverse-proxied byte routes. Existing harness-based test suites still pass with the data plane auto-running.
  • rbt dev run (chat-room, dev wheel, real browser): attachment PUT observed going to the app origin's /__/reboot/blob/... proxy route (HMAC-signed, expiring URL) and on to the spawned filesystem data plane; image rendered for the sender and for a second participant in an isolated browser context; on-disk part bytes byte-identical to the upload; meta.json committed: true. This end-to-end pass caught a real bug the unit tests structurally could not (the harness runs the data plane in-process): the spawned data-plane subprocess was missing REBOOT_CRYPTO_ROOT_KEYS, so URL signing failed. Fixed by establishing the root keys in the application env before the spawn and passing that env to the subprocess.
  • A private S3-backed data plane (deployed on a local cluster against MinIO) exercised the same example in the browser with the part PUT going directly to a presigned URL — not a single blob byte through the app origin — confirming the "data plane asks for no forwarded paths" path works end to end.

Rebase notes

This series was split out of a monorepo PR and rebased across 63 commits of main. Three resolutions are worth a look:

  • CORS etag exposure. main had extracted the route's CORS policy into _cors_policy(), so the original inline expose_headers edit no longer applied. The etag exposure now lives in the shared CORS_EXPOSE_HEADERS tuple in reboot/routing/cors_settings.py, which means it applies to both the permissive and the credentialed allow-list branch (the original touched only the permissive one). The envoy goldens are unaffected — they don't cover expose_headers.
  • reboot/aio/tests.py. main had added ENVVAR_REBOOT_IN_TEST, the _application field, and make_valid_oauth_access_token in exactly the region where this series adds the data plane's start()/stop(). Both sides are kept. Verified with //tests/reboot/aio:applications_test_py, //tests/reboot/aio/auth:oauth_token_verifier_test_py, and //tests/reboot/pydantic/auto_construct_user:test_py.
  • Chat-room frontend type-check overlay. main added frontend_type_check_test after this series forked, and the example moved to frontend/web/. Since the example now imports @reboot-dev/reboot-std-api and @reboot-dev/reboot-std-react/blobs, the test would have resolved them from the published 1.3.0 (which has no ./blobs export). The locally built std packages are now overlaid, following the existing kcdc-2025/bank-pydantic pattern.

Documentation snippet ranges in from_react.mdx were regenerated for the example's new location; this also fixed a pre-existing truncation where the proto snippet cut off its closing brace.

Adversarial review

Reviewed adversarially with codex across the design's evolution (six rounds in total). On this final series it found and we fixed: the local data-plane spawn ignoring a data plane configured via --env/--env-file; the test harness clobbering another live Reboot instance's data-plane env; and a leaked HTTP server when the data plane's gRPC surface fails to start. A verification round then confirmed all fixes sound, and separately confirmed the presigned-URL path (no forwarded paths → no proxy routes → URLs passed through verbatim). A further round on the Configuration redesign confirmed the namespace enforcement and route construction sound, and found two issues we fixed (a data plane needing forwarded paths now fails fast on non-Python applications instead of silently 404ing, and IPv6 literals keep their brackets in the derived proxy target) plus one pre-existing framework-wide observation (FastAPI mounts registered at / take precedence over later-added routes, including these proxy routes — not blob-specific). Earlier rounds had already hardened startup/port races, proxy session lifecycle, BeginUpload idempotency, gRPC channel event-loop affinity, and legacy-servicer registration.

🤖 Generated with Claude Code

rjhuijsman and others added 6 commits July 23, 2026 14:46
Before this change, an application's custom HTTP routes
(`application.http`) could only be registered for `GET`, `POST`, and
`OPTIONS`; the docs listed the `PUT`/`DELETE`/... gap as a known
limitation. This blocked serving a plain-HTTP upload endpoint, where
`PUT` is the natural verb.

Add `application.http.put(...)`, a sibling of the existing `post(...)`
that forwards `methods=["PUT"]` to the underlying FastAPI route (the
route-capture machinery already supports arbitrary methods; only the
public sugar was missing). Update the custom-HTTP-routes
documentation to list `PUT` among the supported methods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Before this change, `Application.run()` invoked each library's
`pre_run(application)` hook, but the `Reboot` in-process test harness
(`reboot.aio.tests`) did not. A library that performs application
setup in `pre_run` — for example, registering custom HTTP routes —
therefore behaved differently under test than in a real run, and its
routes were simply absent when brought up via the harness.

Call `library.pre_run(...)` for every library in `Reboot.up()`, before
deciding whether a local Envoy is needed, mirroring what
`Application.run()` does. Libraries must already tolerate being
`pre_run` more than once (a test may bring the same application up
again after a `down`).

This harness behavior is covered by unit tests introduced in a later
commit (`reboot/std: add a `Blob` state machine with a gRPC blob data
plane`): `blobs_tests.py` brings an application up with the
`BlobsLibrary`, whose `pre_run` hook connects to the blob data plane
and registers the byte-proxying HTTP routes the tests then exercise —
which succeeds only when the harness has invoked `pre_run`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reboot had no first-class way to store large binary objects: state
machines hold protobuf state, which is unsuited to multi-megabyte
payloads, so applications had nowhere to put user uploads like images
or videos.

Add `rbt.std.blobs.v1.Blob`, a state machine that is the *control
plane* for one immutable-once-committed binary object. Its state holds
only metadata — content type, expected/maximum size, upload progress,
lifecycle status — while the bytes live in a *data plane* and travel
directly between the client and that data plane via URLs minted per
part. Uploads are resumable (parts are idempotent by number), sizes
are enforced against the real bytes at commit time, and blobs that are
never committed expire automatically.

The data plane is a gRPC service, `BlobDataPlane` (`data_plane.proto`),
deliberately free of Reboot options so that anything can implement it;
the control plane discovers it via `REBOOT_BLOB_DATA_PLANE_URL` and
calls it to provision uploads, mint URLs, finalize objects, and delete
bytes. Keeping the implementation behind a bare gRPC URL means richer
data planes (e.g. S3 behind a CDN) can live outside this repository,
and can be swapped under running applications: download URLs are
minted per request, so existing blobs simply start resolving through
the new implementation.

A data plane whose URLs are not directly reachable by clients asks,
via its `Configuration`, for path namespaces (under the reserved
`/__/reboot/blob/`) to be forwarded to it; the `Blob` library then
registers reverse-proxying routes on the application, so a single
application origin serves both control plane and bytes and no second
port needs exposing. A data plane whose URLs are directly reachable
(e.g. presigned S3) asks for nothing and is never in the
application's path.

This commit ships the open-source implementation: a filesystem server
(`_filesystem_server.py`) combining the gRPC service with an HTTP byte
endpoint whose URLs carry expiring HMAC signatures (keyed from the
Reboot-managed cryptographic root keys). It binds loopback only and
relies on the forwarded-path proxying above.

The `reboot.aio.tests.Reboot` harness runs this data plane in-process
for every test, so applications using `reboot.std.blobs` work in unit
tests out of the box — which is also how this commit is tested; a
later commit has `rbt dev run`/`rbt serve run` provide the same data
plane for local runs.

Authorization model: blob creation is application-mediated (the
application enforces quota and size policy), after which the blob's
framework-generated random id acts as a capability; upload-side calls
are restricted to the recorded `uploader_id`, downloads to the
`downloader_ids` allow-list, with either left open deliberately when
unset. See the authorizer notes in `blobs.py`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With the blob data plane behind a gRPC interface, an application that
uses `reboot.std.blobs` needs a data-plane service to talk to — but
local development must keep working out of the box, with no external
service to configure.

Have `rbt dev run` and `rbt serve run` start the open-source
filesystem data-plane server as a background subprocess whenever
`REBOOT_BLOB_DATA_PLANE_URL` is not already set (in Reboot Cloud, or
via `--env`, it is — and then nothing is spawned), and point the
application at it on localhost. The server picks its own ports and
reports them through a ready file only once both its gRPC and HTTP
endpoints are listening, so there is no port-allocation race and the
application can never observe a half-started data plane. Blob bytes
live under the application's state directory, so `rbt dev expunge`
removes them along with the rest of the state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Uploading a file to a `Blob` from the browser means driving the whole
multipart protocol — fetching upload instructions, `PUT`ting each
part to its URL, reporting ETags, committing, and polling for the
result — plus resuming after a dropped connection. Before this change
an application author had to write all of that by hand against the
generated client.

Add `@reboot-dev/reboot-std-react/blobs`:

- `useBlobUpload()` — the dead-simple case: given a blob id (from an
  application RPC, since creation is app-mediated) and a `File`, it
  uploads every part directly to the data plane, resumes already-
  confirmed parts, reports progress, and commits.
- `BlobUploader` — the same machinery for bytes that don't come from
  a `File` (media recorders, transforms), with explicit
  `putPart`/`commit` and a `writable()` stream.
- `useBlobDownloadUrl()` — resolves to a URL for a committed blob
  (e.g. for an `<img src>`), plus a re-exported reactive `useBlob` so
  any participant — not just the uploader — can render live progress.

The part-`PUT` response carries the part's ETag, which the browser
must read to report it back; expose the `etag` response header
through Envoy's CORS configuration so cross-origin uploads (including
direct-to-S3 uploads on the Cloud) can see it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chat-room example only sent text, so it demonstrated nothing
about storing binary data. Give it file attachments, wired the way a
real app would: message-first, with attachment bytes uploaded after
the message is already visible.

`Send` now takes a list of attachment descriptors; the servicer
checks each against a per-attachment size limit, creates a `Blob` per
attachment, embeds the blob ids in the immediately-published message,
and returns them. The web frontend then uploads each file into its
blob via `useBlobUpload`, and every participant renders the
attachment off its reactive blob status — a progress bar while it
uploads (visible to everyone, since progress lives on the blob's
state), the image once committed. No end-user auth here, so blobs are
created with an empty `owner_id` (anyone in the room may upload).

The documentation snippets that embed the chat-room proto are
regenerated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aviator-app

aviator-app Bot commented Jul 23, 2026

Copy link
Copy Markdown

Current Aviator status

Aviator will automatically update this comment as the status of the PR changes.
Comment /aviator refresh to force Aviator to re-examine your PR (or learn about other /aviator commands).

This pull request is currently open (not queued).

How to merge

To merge this PR, comment /aviator merge or add the mergequeue-ready label.


See the real-time status of this PR on the Aviator webapp.
Use the Aviator Chrome Extension to see the status of your PR within GitHub.

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