fix(upload): refuse a stored upload that lost bytes on the way in - #19
Conversation
The api counts the bytes it forwards from each multipart file; the file server counts the bytes it reads, stats the stored object and reports its size. Any mismatch deletes the object and fails that file, so a caller never receives a reference to a truncated input. Seen on KS-7 and AX41: the agent CLI package stored tail-truncated while the upload reported success.
Engine-level proof on KS-7 (head 61a8f27)Candidate
The refusals show where the bytes go missing. In all 22 the api forwarded 262,600 and the file server read and stored only 226,319 (x21) or 225,423 (x1); the file server's own received-vs-stored check never fired. So the loss is on the api -> file_server hop (Bun axios.put of the busboy stream, or the file server's express request read), not in MinIO. Every refused object was deleted, and no truncated object reached a sandbox. |
Root cause (tcpdump on KS-7, Agent/MCP): streaming the busboy file into axios.put under Bun ended a well-formed chunked request early, dropping chunks written under backpressure; the file server stored exactly what it was sent. The api now stages the file (already capped at the plan size by busboy) and PUTs it as a single fixed-length body. The stored-size checks stay as the guard.
Root cause and sender fix (head 49c542d)Agent/MCP took a tcpdump of the api -> file_server hop on the KS-7 side pair. One truncated PUT on the wire: chunked, The fix: Same engine-level run as before (Console client, 4 parallel x 100, side pair on the KS-7 engine network):
The file server logged 400 of 400 objects at 262,600 bytes. The unit test now also asserts the PUT carries |
Review P1: staging every file at once removed busboy's backpressure, so a batch against a slow file server held every file in the api. Forwards now run one at a time per request (createForwardQueue); a file is read only on its turn and busboy waits on the rest, so the api holds at most one staged file per request. The staged chunks are released once joined.
Review P1 (unbounded staging), head 5c8c3feForwards now run one at a time per upload request (
|
Problem
A multipart upload can be stored with its tail missing while the api answers success. Seen on KS-7 and on AX41 with the Console's agent CLI package (262,600 bytes):
Download-side checks can't see this: the file server's Content-Length matches the short object. The sandbox then fails
tarwithgzip: stdin: unexpected end of file, and the Console reportsOPTALE_AGENT_WORKSPACE_CLI_INSTALL_FAILED / sandbox_install_result_invalid.Reproduction against the KS-7 engine with the Console's exact client (form-data + axios, Buffer body): 12 of 400 uploads truncated at concurrency 4; serial runs were clean. The upload code is unchanged since June. Root cause (tcpdump): the api's axios.put of the busboy stream under Bun ends a well-formed chunked body early, dropping chunks written under backpressure.
Change
Content-Length, never as an unknown-length stream.service/src/service/upload-forward.ts:/uploadand/upload/batchforward each file throughforwardUploadToFileServer, which knows the exact byte count it sends. When the file server's reported stored size differs, or is missing, the helper deletes the object and fails that file (Upload incomplete). In a batch the file is marked as an error; a single upload returns 500.service/src/file-server.tsuploadFile: counts the bytes it reads, stats the stored object, removes it and fails when the two differ, and returnssize(newStoredUploadResult). The file server's own logs then show which side lost the bytes: received vs stored on the file server, and forwarded vs stored on the api.Deploy order
file_server before api, or both together. The api refuses any answer without
size, so a new api in front of an old file_server fails every upload.Proof
bun test src/service src/types: 50 pass, including the newupload-forward.test.ts(full, short and size-less answers against a real local HTTP server; the short case asserts the DELETE).