Skip to content

Stream file uploads, taking the data as a reader - #71

Merged
samlown merged 9 commits into
mainfrom
large-file-uploads
Oct 1, 2026
Merged

samlown merged 9 commits into
mainfrom
large-file-uploads

Conversation

@alvarolivie

@alvarolivie alvarolivie commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

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.

f, err := c.Silo().Files().Create(ctx, &invopop.CreateSiloFile{
    EntryID: entryID,
    Name:    "invoice.xml",
    Data:    file, // *os.File, bytes.NewReader(data), ...
})

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.

🤖 Generated with Claude Code

alvarolivie and others added 4 commits September 10, 2026 11:21
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>
# Conflicts:
#	invopop/silo_attachments_test.go
@alvarolivie
alvarolivie marked this pull request as ready for review September 29, 2026 10:35
alvarolivie and others added 2 commits September 29, 2026 14:15
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Preserve request data when uploads fail so retries can complete successfully.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
What changed in this PR

Updates Silo file uploads to register metadata first, then stream raw data, with support for reader-based uploads.

Changes:

  • Adds two-step streaming uploads and CreateStream.
  • Adds hashing, MIME detection, size calculation, and raw request support.
  • Expands streaming tests and shared response helpers.
File Description
invopop/​silo_entries_test.go Uses the shared response helper.
invopop/​silo_attachments.go Implements streaming registration and uploads.
invopop/​silo_attachments_test.go Tests streaming behavior and validation.
invopop/​invopop.go Adds raw request-body support.
invopop/​helpers_test.go Refactors JSON response fixtures.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread invopop/silo_attachments.go Outdated
samlown and others added 2 commits October 1, 2026 11:07
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 samlown changed the title Split large file uploads onto the streaming route Stream file uploads, taking the data as a reader Oct 1, 2026
@samlown
samlown requested a lite review from Copilot October 1, 2026 11:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Moderate issues remain for oversized non-seekable data and negative file sizes.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Reject negative file sizes during registration

invopop/​silo_attachments.go:285

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).

Comment thread invopop/silo_attachments.go Outdated
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>

@samlown samlown left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

@samlown
samlown merged commit d88b99d into main Oct 1, 2026
2 checks passed
@samlown
samlown deleted the large-file-uploads branch October 1, 2026 11:27
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.

3 participants