Repository navigation
fix(storage): buffer unrewindable bodies so S3 uploads work over plain HTTP - #952
Merged
Merged
Conversation
…n HTTP WriteReader sent every body under 100 MiB with a raw PutObject. aws-sdk-go-v2 must re-read the body to sign the payload and compute its default request checksum, and its rewind-free alternative (trailing checksums) exists only over TLS, so an io.Pipe or HTTP request body failed client-side against any http:// endpoint: "compute input header checksum failed, unseekable stream is not supported without TLS and trailing checksum". Tiering migration of every daily file under 100 MiB, edge-sync hub receive (stage and promote) and a peer-replication pull into an S3-backed node were all broken against SeaweedFS, MinIO or any plain-HTTP proxy; over TLS they worked. isSeekable mirrors the SDK's own probe. An unrewindable body up to one part (16 MiB) is read exactly into a right-sized buffer and sent through the unchanged PutObject branch; larger ones go through the SDK Uploader wrapped in an exact-length, ctx-aware reader. Rewindable bodies take exactly the path they took before. A body whose length differs from the declared size now fails with ErrBodyLength and commits nothing; every request body is now rewindable, so transient upload errors are retried. The tiering migrator sizes its copy from the source file rather than the metadata row and prefers ErrBodyLength over the closed-pipe error it induces. Not routing everything through the Uploader is deliberate: its part pool releases slices when no upload overlaps, so every 1 KiB migration would allocate and drop a fresh 16 MiB. Tests: nine httptest-stub cases (the SDK fails client-side, so a stub reproduces it) and an objectstore-tagged contract test against SeaweedFS over plain HTTP, the first to observe the multipart path against a real store. Live: #951's shared-storage rig migrated a daily file end-to-end (migrated=1 errors=0) where it previously failed every cycle.
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.
Summary
S3Backend.WriteReadersent bodies under 100 MiB with a rawPutObject; aws-sdk-go-v2 must re-read the body to sign the payload and compute the default request checksum, and its rewind-free alternative (trailing checksums) exists only over TLS — so anio.Pipeor HTTP request body failed client-side, before a byte was sent, withcompute input header checksum failed, unseekable stream is not supported without TLS and trailing checksum. Broken callers: the tiering migrator's streaming copy (every daily file under 100 MiB — fix(tiering): gate migration on the primary writer in shared-storage mode #951's live run sawerrors=1every cycle), edge-sync hub receive (stage and promote), and a peer-replication pull into an S3-backed node. Over TLS these worked, which is why AWS deployments never noticed. The repo was on a post-checksum-default SDK from the start; born broken, not regressed.isSeekablemirrors the SDK's own probe (anio.SeekerwhoseSeekworks). An unrewindable body ≤ 16 MiB is read exactly into a right-sized buffer (spoolExact:io.ReadFull+ a one-byte EOF probe, ctx-checked) and sent through the unchangedPutObjectbranch; larger ones go through the existing SDKUploaderpath wrapped inexactLengthReader(declared-length enforcement, per-read ctx check, the source's own errors pass through verbatim so edge sync'serrors.Is(err, errShortBody)still holds). Rewindable bodies (ingest flushes, compaction output, backups, the delete API's rewritten file) take exactly the path they took before.storage.ErrBodyLengthand commits nothing (single part: no request; multipart: aborted) — before it was truncated or rejected depending on TLS; every request body is now rewindable, so transient errors on these uploads are retried instead of failing withfailed to rewind transport stream. The migrator sizes its copy fromStatFileon the source rather than the metadata row, and prefersErrBodyLengthover the closed-pipe error it induces on the reading side.tiered_storage.cold.s3_storage_class(defaultGLACIER) is never applied —S3Confighas no such field — so cold objects land in STANDARD. Honoring it would make cold data unreadable by DuckDB without a restore path; design decision, raised separately.Design + matrix + adversarial-review history:
docs/progress/2026-09-28-s3-unseekable-put-over-http.md(untracked).Test plan
go build,gofmt -lempty,go vetoninternal/storage,internal/tiering,internal/apigo test -race ./internal/storage/ ./internal/tiering/greenhttptestS3 stub (the SDK's failure is client-side, so the stub reproduces it): single PutObject, exactly-one-part spooled, 20 MiB multipart, short and long bodies on both paths (nothing committed, multipart aborted), inner-error passthrough on both paths, seek-probe routing, cancelled context, seekable path unchanged. Pre-fix: the two unseekable-success tests fail on the unmodified tree with the SDK error (recorded before implementing).//go:build objectstorecontract test against real SeaweedFS 4.47 over plain HTTP (the configuration CI runs,.github/workflows/ci.yml:120-124): 1 KiB single PutObject and 20 MiB multipart with CRC32 part headers, read back byte-equal — the multipart path over plain HTTP had never been observed against a real store before this. Whole tagged suite green locally.http://,cold.s3_prefix=archive/,*/1schedule) built from this branch. A daily file seeded in the shared hot bucket: on the next tick the leader loggedFile migrated successfully … size_bytes=1072 … to=coldandMigration cycle completed errors=0 migrated=1— the migrator's own streamingPutObject, which fix(tiering): gate migration on the primary writer in shared-storage mode #951's run could only stage by hand. The object landed atarchive/tier_smoke/cpu/1970/01/20/cpu_19700120_daily.parquetin the cold bucket, the hot copy is gone, and the reader (which never migrates) learned the cold row on its next sync withmigrated_at= the object's timestamp (18:44:00Z) and answersSELECT COUNT(*)with hot + cold rows (10 = 5 + 5).io.ReadFullso a spurious(0, nil)cannot pass as end-of-stream; the spool path no longer logs (every caller does); a length mismatch is neither counted nor logged as a storage error on the multipart path either; the migration record and log carry the stat'd size. A 32 MiB (exact part multiple) case was added to the multipart test.