Skip to content

ai-persistence: capBodySize strips the body's known length, so artifacts can't be persisted to Cloudflare R2 #1030

Closed

Description

@tombeckenham

Summary

withGenerationPersistence cannot persist artifacts to a Cloudflare R2 BlobStore. Every generation that produces media fails with:

TypeError: Provided readable stream must have a known length
(request/response body or readable half of FixedLengthStream)
    at Object.put (src/lib/persistence.server.ts)
    at persistGenerationArtifacts (@tanstack/ai-persistence/dist/esm/middleware.js)
    at applyGenerationResultTransforms (@tanstack/ai/dist/esm/activities/middleware/run.js)
    at runGenerateImage (@tanstack/ai/dist/esm/activities/generateImage/index.js)

This affects any Worker following the shipped ai-persistence/build-cloudflare-artifact-store skill, whose recipe is incorrect on this point (details in "3." below).

Environment: @tanstack/ai-persistence (pkg.pr.new build for #1011) + @tanstack/ai, TanStack Start on Cloudflare Workers via @cloudflare/vite-plugin, workerd 1.20260730.1, wrangler 4.116.0. Artifacts sourced from fal / Gemini / xAI result URLs.

Root cause

descriptorBody() resolves a URL-backed descriptor by wrapping the response body:

body: capBodySize(response.body, maxBytes, descriptor.url),
size: 0,

and capBodySize enforces the cap with a TransformStream:

function capBodySize(body, maxBytes, url) {
  let seen = 0
  return body.pipeThrough(new TransformStream({ transform(chunk, controller) {
    seen += chunk.byteLength
    if (seen > maxBytes) { controller.error(...); return }
    controller.enqueue(chunk)
  }}))
}

The counting itself is fine — it streams, nothing is buffered. The problem is that a TransformStream's readable side carries no declared length, because a transform may emit any number of bytes. Response.body had a length; after pipeThrough it does not.

R2Bucket.put requires a known length for a single-shot upload (workerd will not implicitly buffer an arbitrary stream to discover it). So the BlobStore receives a body it cannot hand to R2.

There is no way to opt out from the caller side: the wrap is unconditional, and maxArtifactBytes: Infinity disables the threshold but not the wrapping.

Why the obvious fixes are wrong

Worth stating, since both look reasonable:

  • Buffer the stream in the store. DEFAULT_MAX_ARTIFACT_BYTES is 100 MB against a 128 MB Worker memory limit. This works for images and becomes an OOM risk for video — it fails late and under load rather than immediately.
  • Skip capBodySize whenever content-length is present. This is what I first assumed, but the code comment is right to reject it: fetch transparently decompresses, so on a content-encoding: gzip response content-length is the compressed size and the decoded body can be arbitrarily larger. Trusting it would open a decompression-bomb hole.

I worked around it by buffering one 8 MiB part and switching to an R2 multipart upload beyond that, which bounds memory regardless of artifact size. That works, but it is a fair amount of machinery for every Workers user to reinvent, and it costs the download/upload overlap that streaming straight to put would give.

Suggestions

Roughly in order of leverage:

1. Expose the known length to the store (additive, runtime-agnostic).

The middleware already knows declaredLength, and descriptorBody computes exact sizes for the bytes / json branches. Adding an optional hint to BlobPutOptions — say expectedLength?: number — would let a store choose single-shot vs multipart knowingly, or on workerd re-wrap with new FixedLengthStream(n). It changes no cap semantics and no existing behaviour.

2. Add a length-less stream to the conformance testkit.

runPersistenceConformance appears to exercise blobs.put with byte bodies. A case that puts a TransformStream-wrapped stream would have caught both this and the broken recipe as a test failure, instead of surfacing as a runtime error in an app. This is probably the highest-value change — a Uint8Array takes a different branch in most stores and proves nothing about the streaming path.

3. Fix ai-persistence/build-cloudflare-artifact-store.

The skill states:

BlobBody is ReadableStream<Uint8Array> | ArrayBuffer | ArrayBufferView | string | Blob — which is exactly what R2Bucket.put accepts, so the body flows straight through with no conversion.

That holds only for bodies that already carry a length, which is never true for the URL path — i.e. essentially every generated artifact. Anyone following this recipe gets a store that passes the conformance suite and fails on first real use.

4. Nit, same function.

const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { ... }

headers.get returns null when absent, and Number(null) === 0, which is finite — so a missing content-length reads as a declared length of 0 and always passes. Harmless today because the drain-time check is the real enforcement, but the early-reject silently never fires for chunked responses.

Reproduction

Any generateImage / generateVideo through withGenerationPersistence with an R2-backed blobs store, where the provider returns a result URL. Minimal shape of the failing call:

// R2 BlobStore.put — body is the capBodySize-wrapped stream
await bucket.put(key, body, { httpMetadata: { contentType } })
// TypeError: Provided readable stream must have a known length

Standalone, without any provider or this library — run inside a Worker with an R2 binding. The first put is a positive control isolating the pipeThrough as the sole cause:

// 1. Response.body straight through — succeeds, length preserved
await bucket.put('probe-direct', new Response('x'.repeat(1024)).body!)

// 2. Identical bytes via a pass-through TransformStream — throws
const lengthless = new Response('x'.repeat(1024)).body!.pipeThrough(new TransformStream())
await bucket.put('probe-wrapped', lengthless)

Observed on workerd 1.20260730.1:

Response.body direct           -> OK (length preserved)
pipeThrough(TransformStream)   -> THREW: Provided readable stream must have a known length
                                  (request/response body or readable half of FixedLengthStream)

Happy to send a PR for (1) and (2) if that direction seems right.

Activity

  1. tombeckenham commented on Jul 31, 2026

    @tombeckenham
    ContributorAuthor

    Some follow-up data that sharpens the severity, plus a related gap in the same recipe.

    DEFAULT_MAX_ARTIFACT_BYTES (100 MB) is below realistic video output

    generateVideo is a first-class activity, and generated clips routinely run to a few hundred MB — a 4K or longer clip well past 100 MB is ordinary, not pathological. At the default, those runs fail outright with exceeds maxArtifactBytes.

    The part relevant to this issue is the interaction between that default and the length-stripping, because I think it's hiding how much the stripping costs:

    • At a 100 MB ceiling, "just buffer the stream in the store" looks like a fine workaround. It fits in a 128 MB isolate, the conformance suite passes, and most people would ship it and never see a problem.
    • Raise the ceiling to something realistic for video and that same workaround becomes a guaranteed OOM, since the artifact alone now exceeds the isolate.

    So the low default makes a store that cannot stream look correct. Anyone who follows the Cloudflare recipe, hits the TypeError, and reaches for the obvious fix ends up with something that works on images, works in CI, and dies on the first large video. That's the case for suggestion (1) in the issue — surfacing the known length (or a size hint on BlobPutOptions) so a store can stream rather than buffer.

    Streaming is sufficient — measurement

    To check that a streaming store actually handles realistic sizes, I ran a 1 GB artifact through an R2 BlobStore that buffers one 8 MiB part and uses multipart upload beyond that:

    requested        1073741824 bytes (1024 MB)
    parts            128                      (R2 limit is 10000)
    put.size         1073741824               OK
    streamed back    1073741824 bytes         OK
    sentinel bytes   OK                       (position-dependent bytes verified every 4 KiB)
    put wall time    6087 ms                  (local Miniflare, not a network figure)
    

    Peak memory stays flat at one part regardless of artifact size. So there's no need to cap artifacts low on Workers provided the store can stream — which is exactly what the missing length prevents it from doing by default.

    For reference I've since raised maxArtifactBytes to 1 GB in my app, which is only safe because the store streams.

    Same recipe: the artifact-serving example has no Range support

    Minor, but the same shape of problem as the put advice — fine for images, wrong for video. The skill's "4. Serve the bytes back" example returns:

    return new Response(blob.body, {
      headers: {
        'content-type': record.mimeType,
        'content-length': String(record.size),
      },
    })

    For a library that generates video this is incomplete: <video> seeking requires 206 / Content-Range, and Safari will not play a video at all from a source that doesn't honour Range. At image sizes nobody notices; at a few hundred MB it means the artifact can't be played back or seeked, only fully downloaded. R2's get takes a range option, so the example could show it.

  2. added a commit that references this issue on Jul 31, 2026
    985fe4d
  3. added a commit that references this issue on Jul 31, 2026
    bef85d2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions