You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Stream file uploads, taking the data as a reader - #71
Blocked on invopop/api#126. Releasing this before that route exists 404s every file upload, not just large ones.
Sending a file's bytes inline means the API and the silo each hold the whole payload in memory, and the request is capped by the 4MiB gRPC message limit between them. A 12.8MB invoice hit that ceiling.
Part of APP-873, alongside invopop/silo#251 and invopop/api#126.
What changed
Files().Create now streams the data instead of sending it inline — it derives the sha256, mime and size, registers the file with no bytes, then PUTs the raw contents to the file's /data path.
Breaking: CreateSiloFile.Data is now an io.Reader. Callers holding bytes change Data: data to Data: bytes.NewReader(data); the compiler flags every call site. See Data as a reader below.
No size threshold. An earlier version kept small files inline to save a round trip; it's gone. It meant two code paths, a behaviour cliff at an arbitrary byte count, and small files still held whole in memory by both services. One path is simpler, and the round trip is in-cluster against workflows already making several calls.
Create accepts a registration with no data, given sha256, mime and size — which the API has always documented but this client rejected outright, without even exposing the fields. UploadData is exported for callers that already hold an io.Reader.
Worth your attention
It's now two requests rather than one. If the second fails you're left with a registered-but-unstored file — inherent to the two-step flow the API documents, and a retry heals it: re-registering the same hash returns the existing file, and the upload is skipped when the silo reports the content already stored.
CreateSiloFile.Data is tagged json:"-", so the contents can never be marshalled into the registration body.
Testing
go test -race ./... and golangci-lint green. Tests cover the two-call protocol and its ordering, that the payload never appears in the registration call, that the data call sends raw bytes rather than base64, the content-type default, the already-stored skip, and that a retry after a failed upload sends the contents again with both seekable and non-seekable readers.
Data as a reader
Data takes the contents as a reader, so a file on disk no longer has to be read into a []byte first, which is the one thing streaming was meant to avoid.
An earlier revision added a separate CreateStream action and kept Data []byte. One Create taking a reader is simpler than two near-identical actions, and we're pre-1.0, so this breaks the field instead.
Registration has to describe the file before the contents go anywhere, so a bare reader cannot just be passed through. Three cases:
The reader is an io.ReadSeeker. It's always read from its start, measured in one pass when SHA256/Size aren't given, and wound back. Covers *os.File and *bytes.Reader, which is nearly every real use. No extra memory, no temp file.
Not seekable, SHA256 and Size already set. Sent straight through, so a once-through stream works. This is the one case that can't be retried.
Not seekable, undescribed. Held in memory, exactly as the []byte path did. The held copy replaces Data on the request.
Retries.Create updates the request with the generated ID, hash, size and MIME. Because a seekable reader is rewound, and a non-seekable one is replaced by its held copy, calling Create again with the same request after a failed upload re-registers the same file and sends the contents again.
MIME is detected from the first 3072 bytes of the measuring pass, the most mimetype looks at.
I deliberately did not spool the non-seekable case to a temp file, the way silo does. Silo owns its disk; a client library writing to $TMPDIR unasked would be a surprise in a read-only container or a function with a small /tmp. The seekable path already avoids buffering for the case that matters.
Sending a file's bytes inline means the API and the silo each hold the
whole payload in memory, and the request is capped by the 4MB gRPC
message limit between them. A 12.8MB UBL invoice hit that ceiling.
Files().Create now switches to register-then-stream on its own once the
data passes 1MB, so callers keep calling Create and get the streaming
path for free. The threshold sits well below 4MB, leaving room for the
rest of the request on the inline path.
Create also accepts a registration with no data, given sha256, mime and
size, which the API has always documented but this client rejected
outright. CreateSiloFile gains the sha256 and size fields needed for
it, and CreateAndUpload and UploadData are exported for callers that
already hold a reader.
A create that comes back already stored skips the upload: the silo
returns the existing file when it holds content under the same hash, so
sending the bytes again would be wasted.
Requires an API with PUT /entries/:entry_id/files/:id/data. Do not
release ahead of it, or payloads over 1MB will 404.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The size threshold meant two code paths, a behaviour cliff at an
arbitrary byte count, and small files still being held whole in memory
by both the API and the silo for the sake of saving a round trip. None
of that is worth keeping.
Create now hands any data it is given to CreateAndUpload. The cost is
an extra request per file, in-cluster, against workflows already making
several; and a registered-but-unstored record should the second call
fail, which is inherent to the two-step flow the API has always
documented and which a retry heals, as re-registering the same hash
returns the existing file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Create called CreateAndUpload which called Create back, terminating
only because the middle step nil'd the caller's Data, and mutating the
caller's struct on the way through. Create now derives the details into
a local, registers, and uploads, with register holding the metadata
call. One path, no recursion.
CreateAndUpload goes with it: unreleased, and Create does the same job.
UploadData stays for callers that already hold a reader.
Digest via dsig, as the rest of the org spells it, dropping the
crypto/sha256 and encoding/hex imports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Callers with a file on disk had to read it into a []byte first, which is
the one thing streaming was meant to avoid.
Registration needs the hash and size before the contents go anywhere, so
a bare reader cannot simply be passed through. Set SHA256 and Size and
the reader is sent untouched. Otherwise it is measured first: a seekable
one is read and wound back, which covers *os.File and *bytes.Reader, and
anything else is held in memory, as it is today.
MIME is detected from the first 3072 bytes of that pass, the most
mimetype looks at, so a caller that omits it gets the same answer the
[]byte path gives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Content io.Reader beside Data []byte meant two ways to say the same
thing on a struct that gets marshalled, and a runtime error for setting
both. CreateStream takes the reader as an argument instead, so the two
cannot be confused, and Create goes back to what it was.
Both share create, which registers and then sends whatever contents are
left to send.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Create cleared req.Data before registering, so retrying Create after a
failed UploadData registered the file again and returned without sending
the contents. Registration now sends a copy without the data instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Breaking change: callers holding bytes now pass bytes.NewReader(data).
One Create covers every case instead of two near-identical actions.
A seekable reader is always read from its start, so a retry after a
failed upload sends the contents again. A reader that cannot seek is
held in memory and the held copy kept on the request for the same
reason.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
samlown
changed the title
Split large file uploads onto the streaming route
Stream file uploads, taking the data as a reader
Oct 1, 2026
The registration guard rejects only Size == 0, so a data-less request with a negative Size and non-empty SHA256/MIME is serialized and sent as invalid file metadata. Since Size represents a byte count, reject all non-positive values here (consistent with the existing zero-size rejection).
Only the seekable path refused content over the int32 limit; a held
payload over 2 GiB wrapped to a wrong registered size. Both paths now
share fileSize.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
Nice. I've made a few changes that break the SDK, but I think its better to start using io.Reader instead of byte arrays by default. This should be easy to fix in most circumstances.
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
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.
Sending a file's bytes inline means the API and the silo each hold the whole payload in memory, and the request is capped by the 4MiB gRPC message limit between them. A 12.8MB invoice hit that ceiling.
Part of APP-873, alongside invopop/silo#251 and invopop/api#126.
What changed
Files().Createnow streams the data instead of sending it inline — it derives the sha256, mime and size, registers the file with no bytes, then PUTs the raw contents to the file's/datapath.Breaking:
CreateSiloFile.Datais now anio.Reader. Callers holding bytes changeData: datatoData: bytes.NewReader(data); the compiler flags every call site. See Data as a reader below.No size threshold. An earlier version kept small files inline to save a round trip; it's gone. It meant two code paths, a behaviour cliff at an arbitrary byte count, and small files still held whole in memory by both services. One path is simpler, and the round trip is in-cluster against workflows already making several calls.
Createaccepts a registration with no data, givensha256,mimeandsize— which the API has always documented but this client rejected outright, without even exposing the fields.UploadDatais exported for callers that already hold anio.Reader.Worth your attention
CreateSiloFile.Datais taggedjson:"-", so the contents can never be marshalled into the registration body.Testing
go test -race ./...andgolangci-lintgreen. Tests cover the two-call protocol and its ordering, that the payload never appears in the registration call, that the data call sends raw bytes rather than base64, the content-type default, the already-stored skip, and that a retry after a failed upload sends the contents again with both seekable and non-seekable readers.Data as a reader
Datatakes the contents as a reader, so a file on disk no longer has to be read into a[]bytefirst, which is the one thing streaming was meant to avoid.An earlier revision added a separate
CreateStreamaction and keptData []byte. OneCreatetaking a reader is simpler than two near-identical actions, and we're pre-1.0, so this breaks the field instead.Registration has to describe the file before the contents go anywhere, so a bare reader cannot just be passed through. Three cases:
io.ReadSeeker. It's always read from its start, measured in one pass whenSHA256/Sizearen't given, and wound back. Covers*os.Fileand*bytes.Reader, which is nearly every real use. No extra memory, no temp file.SHA256andSizealready set. Sent straight through, so a once-through stream works. This is the one case that can't be retried.[]bytepath did. The held copy replacesDataon the request.Retries.
Createupdates the request with the generated ID, hash, size and MIME. Because a seekable reader is rewound, and a non-seekable one is replaced by its held copy, callingCreateagain with the same request after a failed upload re-registers the same file and sends the contents again.MIME is detected from the first 3072 bytes of the measuring pass, the most
mimetypelooks at.I deliberately did not spool the non-seekable case to a temp file, the way silo does. Silo owns its disk; a client library writing to
$TMPDIRunasked would be a surprise in a read-only container or a function with a small/tmp. The seekable path already avoids buffering for the case that matters.🤖 Generated with Claude Code