Blob storage: Blob state machine with a pluggable gRPC data plane#109
Draft
rjhuijsman wants to merge 6 commits into
Draft
Blob storage: Blob state machine with a pluggable gRPC data plane#109rjhuijsman wants to merge 6 commits into
Blob state machine with a pluggable gRPC data plane#109rjhuijsman wants to merge 6 commits into
Conversation
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>
Current Aviator status
This pull request is currently open (not queued). How to mergeTo merge this PR, comment
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.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
The commit series here is human-authored, but the split out of a monorepo PR and the rebase onto current
mainwere 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
Blobstate 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 viaREBOOT_BLOB_DATA_PLANE_URL:rbt dev runandrbt serve runspawn the open-source filesystem server as a subprocess; thereboot.aio.tests.Rebootharness runs it in-process, soreboot.std.blobsworks in unit tests out of the box.Configurationnames 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. TheBloblibrary 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.REBOOT_BLOB_DATA_PLANE_URLat 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 thedownloader_idsallow-list (either deliberately open when unset; the uploader is not implicitly a downloader), andInfois 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.
reboot: support the PUT method for custom HTTP routes— the byte protocol needsPUT.reboot: run library pre_run hooks in the test harness— so a library can start machinery before the application serves.reboot/std: add a Blob state machine with a gRPC blob data plane— the control plane, theBlobDataPlaneinterface, the filesystem data plane, and the reverse proxy.rbt: run the filesystem blob data plane under dev/serve run.reboot/std/react: add browser helpers for blob upload and download—useBlobUpload,BlobUploader,useBlobDownloadUrl.reboot/examples/chat-room: support message attachments— the whole thing end to end in an example.Validation
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): attachmentPUTobserved 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.jsoncommitted: 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 missingREBOOT_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.PUTgoing 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:etagexposure.mainhad extracted the route's CORS policy into_cors_policy(), so the original inlineexpose_headersedit no longer applied. Theetagexposure now lives in the sharedCORS_EXPOSE_HEADERStuple inreboot/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 coverexpose_headers.reboot/aio/tests.py.mainhad addedENVVAR_REBOOT_IN_TEST, the_applicationfield, andmake_valid_oauth_access_tokenin exactly the region where this series adds the data plane'sstart()/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.mainaddedfrontend_type_check_testafter this series forked, and the example moved tofrontend/web/. Since the example now imports@reboot-dev/reboot-std-apiand@reboot-dev/reboot-std-react/blobs, the test would have resolved them from the published 1.3.0 (which has no./blobsexport). The locally built std packages are now overlaid, following the existingkcdc-2025/bank-pydanticpattern.Documentation snippet ranges in
from_react.mdxwere 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
codexacross 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 liveRebootinstance'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 theConfigurationredesign 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,BeginUploadidempotency, gRPC channel event-loop affinity, and legacy-servicer registration.🤖 Generated with Claude Code