Lesson 3 — Streaming the reserved URL + OOM improvements

Grounded in a production incident · media-svc · real GCS policy decoded live
← Lesson 1 · ← Lesson 2 · Reference →

0. Correcting lesson 2 — GCS does NOT reject chunked encoding

Lesson 2 was wrong about Constraint 2. It claimed GCS pre-signed POST rejects Transfer-Encoding: chunked. But the httpkit PostForm that UploadToReservedURL already uses sends chunked encoding — and it works. The content-length-range condition is validated at the end of the upload, not the beginning. GCS counts bytes as they arrive and rejects only if out of range.

Here is the actual httpkit PostForm source — note the io.Pipe:

// httpkit/request/request.go — PostForm
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
ct := writer.FormDataContentType()
go func() {
    for _, form := range forms {
        _ = writer.WriteField(form.Key, form.Value)
    }
    fw, _ := writer.CreatePart(createFileMimeHeader(...))
    _, _ = io.Copy(fw, bytes.NewReader(file.Content))  ← file must be []byte already
    pw.CloseWithError(writer.Close())
}()
req, _ := http.NewRequestWithContext(ctx, "POST", url, pr)  ← body is PipeReader = chunked

The HTTP request body is a *io.PipeReader — Go sends it chunked. GCS accepts it. The only real barrier is file.Content []byte — the library forces us to buffer the download before we can hand it to the library. Remove the library, and streaming works.

1. What the real policy tells us

Decoding the live GCS policy from the example reveals something important:

{
  "conditions": [
    ["content-length-range", 1, 10485760],   ← max 10 MB
    {"content-type": "image/png"},               ← reserved path = IMAGES, not videos
    {"bucket": "example-assets-bucket"},
    {"key": "upload/2026/.../01KV9...png"}
  ],
  "expiration": "2026-06-17T04:42:02Z"          ← 1-hour window
}

The reserved upload path handles images (PNG, max 10 MB), not videos. The video OOM risk — 80 MB per pod — was already fixed in the normal path. The reserved path's unbuffered cost is at most 10 MB per worker iteration.

Still worth fixing: if 10 workers run concurrently, that's 100 MB of image buffers held simultaneously — on a 512 MB pod that eats ~20% of RAM just for in-flight image data. Streaming eliminates this entirely.

2. How to stream the reserved path — concrete implementation

Add a new method DownloadAndUploadToReservedURL to the upload repository. It follows the same io.Pipe goroutine pattern as DownloadAndUploadFile, bypassing httpkit's File.Content []byte requirement entirely:

// app/repository/uploadsvc/upload/download_and_upload_reserved.go

func (r *uploadRepository) DownloadAndUploadToReservedURL(
    ctx context.Context, downloadURL string, req ReservedUploadRequest,
) error {
    // Phase A: open the download stream
    dlResp, err := request.Get(ctx, downloadURL)
    if err != nil {
        return apierror.Error("0500", fmt.Errorf("download failed: %w", err))
    }
    defer dlResp.Close()
    if !dlResp.Ok() {
        return apierror.Error("0500", fmt.Errorf("download status %d", dlResp.StatusCode))
    }

    // Fast-fail: if provider sends Content-Length, check it before starting the pipe.
    limit := maxDownloadBytesLimit()
    if dlResp.ContentLength > limit {
        return apierror.Error("0500", fmt.Errorf("file too large: %d bytes", dlResp.ContentLength))
    }

    // Phase B: construct multipart body via pipe
    pr, pw := io.Pipe()
    mw := multipart.NewWriter(pw)
    contentType := mw.FormDataContentType()

    go func() {
        // All form fields FIRST — file must be the final part (GCS policy)
        for key, val := range req.FormFields {
            if err := mw.WriteField(key, val); err != nil {
                pw.CloseWithError(err); return
            }
        }

        // Override MIME type with policy's content-type if present
        mimeType := req.File.MimeType
        if ct := req.FormFields["content-type"]; ct != "" {
            mimeType = ct
        }

        h := make(textproto.MIMEHeader)
        h.Set("Content-Disposition",
            fmt.Sprintf(`form-data; name="file"; filename="%s"`, req.File.Filename))
        h.Set("Content-Type", mimeType)
        fw, err := mw.CreatePart(h)
        if err != nil {
            pw.CloseWithError(err); return
        }

        // Pipe download → multipart file part with size limit
        n, err := io.Copy(fw, io.LimitReader(dlResp.Body, limit+1))
        if n > limit {
            pw.CloseWithError(fmt.Errorf("file exceeds maximum allowed size")); return
        }
        if err != nil {
            pw.CloseWithError(err); return
        }
        pw.CloseWithError(mw.Close())
    }()

    // Phase C: POST to GCS pre-signed URL — body is PipeReader (chunked) = works
    resp, err := http.Post(req.UploadURL, contentType, pr)
    // or: use httpkit/request/v2.Post(ctx, req.UploadURL, pr, ...)
    if err != nil {
        return apierror.Error("0500", fmt.Errorf("upload to GCS failed: %w", err))
    }
    defer resp.Body.Close()
    if resp.StatusCode >= 400 {
        body, _ := io.ReadAll(resp.Body)
        return apierror.Error("0500", fmt.Errorf("GCS upload status %d: %s", resp.StatusCode, body))
    }
    io.Copy(io.Discard, resp.Body)
    return nil
}

Then in status_checker.go, replace the two-step reserved path with this single call:

// Before (buffers up to 10 MB)
fileBytes, err := s.mediauploadRepo.DownloadFile(ctx, res.OutputURL)
err = s.mediauploadRepo.UploadToReservedURL(ctx, upload.ReservedUploadRequest{
    File: upload.File{Data: fileBytes, ...}, ...
})

// After (zero-copy streaming)
err = s.mediauploadRepo.DownloadAndUploadToReservedURL(ctx, res.OutputURL,
    upload.ReservedUploadRequest{
        File:       upload.File{Filename: "image.png", MimeType: "image/png"},
        UploadURL:  reserved.UploadURL,
        FormFields: reserved.FormFields,
    })

3. Do BytePlus / Hedra tell us the file size?

Neither GetStatusResponse struct from Hedra or BytePlus contains file size. The status payload only carries a URL and token counts. But there is a second place to look: the actual download HTTP response headers.

// httpkit/request/v2 Response embeds *http.Response
dlResp, _ := request.Get(ctx, res.OutputURL)
dlResp.ContentLength   // -1 if provider omits Content-Length, actual bytes if set
ProviderStatus response has size?Download Content-Length likely?
Hedra No Likely yes — Hedra uses AWS CloudFront CDN which usually sets Content-Length on MP4s
BytePlus Seedance No (only token counts in Usage) Maybe — depends on their CDN config; BytePlus uses their own CDN
Replicate Via webhook payload Usually yes — Replicate serves outputs from their own storage with Content-Length

Use ContentLength for fast-fail only — not as a dependency for streaming. If it's -1, stream anyway with io.LimitReader as the safety valve. If it's set and over the limit, reject before starting the pipe.

4. Full OOM & performance improvement map

P1
now Stream reserved URL path
Implement DownloadAndUploadToReservedURL as shown above. Eliminates up to 10 MB heap per concurrent worker iteration. Pure media-svc change, no infra dependency.
P2
now Content-Length fast-fail on download
Both DownloadAndUploadFile and the new reserved variant should check dlResp.ContentLength against maxDownloadBytesLimit() before starting the pipe. Avoids wasting pipe goroutine setup + GCS connection for files that will be rejected anyway.
P3
now Add download timeout to worker context
The network ops in StatusChecker use the loop ctx which has no deadline. A stalled CDN will block one worker slot indefinitely. Wrap with context.WithTimeout(ctx, 5*time.Minute) for the download+upload phase, independent of the overall polling ticker.
P4
later Parallelize the status_checker loop
The current loop processes checkpoints serially — if there are 20 video_processing items, each download+upload (30–60s) runs one after the other. A goroutine fan-out with a semaphore (e.g. semaphore.NewWeighted(5)) would process 5 concurrently. Combined with streaming, each goroutine uses ~32 KB.
P5
infra Switch reserved path to GCS Signed PUT URL
GCS signed PUT URLs accept a raw body — no multipart, no policy form fields, no field-ordering constraint. Just PUT with Content-Length (or chunked). Requires upload-svc to generate PUT signed URLs instead of POST. Simplest long-term path: the multipart construction complexity disappears entirely.
P6
infra Increase pod memory + horizontal scaling
Even with streaming, the pod runs other allocations (DB connections, JSON payloads, Go runtime GC). With streaming video ops at ~32 KB each, you can safely raise worker concurrency. Target: set pod to 256–384 MB and use the freed headroom for horizontal scale-out, not larger buffers.

5. One dangerous edge case in the streaming reserved path

The req.FormFields is a map[string]string. Map iteration order in Go is randomized deliberately at runtime. The form fields written to the multipart body will be in an unpredictable order on every call.

Is this a problem for GCS? No — the GCS policy validates field presence and values, not their order among each other. The only firm requirement is that the file must be the last part in the multipart body, which our code guarantees by writing form fields in the goroutine first and the file part last.

The content-type form field has one extra subtlety: it appears both as a form field (text part in multipart, value image/png) AND as the Content-Type MIME header of the file part. Both must match the policy. Our implementation handles this with the req.FormFields["content-type"] override on the file part header.

6. Quick check

Q1. httpkit's PostForm already uses io.Pipe to send the body. So why does UploadToReservedURL still buffer the entire file to []byte?

Q2. You call dlResp.ContentLength and get -1. The download is from a BytePlus CDN URL. Should you abort, buffer, or stream?

Q3. Why must the file be the last part in the GCS pre-signed POST multipart body?