Adds a benchmark harness, which showed that intake buffered each upload whole, then makes intake streaming so the service's flat-memory property holds end to end rather than only for the queue and workers. The measurement problem first: /upload returns 202 once bytes are spooled and a reference is enqueued, so HTTP latency measures intake, not the pipeline. bin/bench.jl instead uploads a corpus and polls the terminal sinks until the count stops moving, reporting intake rate and end-to-end rate separately, sampling server RSS (kernel VmHWM, reset per run) and the intermediate stage depths so the bottleneck stage names itself. That exposed the buffering: HTTP.jl read the body into req.body, parse_multipart_form materialized each part, and read(p.data) copied again before spool_file wrote it — a 256 MiB upload grew RSS ~700 MiB, and 4 concurrent ones pushed a 950 MiB baseline past 2 GiB. - src/multipart.jl: incremental multipart/form-data reader. Pulls fixed chunks off the socket and hands each part's bytes straight to a sink, so memory is bounded by FS_UPLOAD_CHUNK_BYTES (64 KiB), not file size. Interface is two calls in a loop (next_part! then write_part_body! / skip_part_body!) so the handler keeps ordinary control flow. Retains the last length(delimiter)-1 bytes so a delimiter split across chunks still parses; part headers are bounded by policy, not by chunking. - src/server.jl: /upload is served by a stream handler. Oxygen's root handler wraps HTTP.streamhandler, which does request.body = read(stream) before dispatching — so no Oxygen route, not even a @stream route, can stream a body. root_stream_handler intercepts POST /upload at the stream level and delegates the rest to Oxygen unchanged; /upload is therefore absent from Oxygen's metrics and docs. A client hangup is classified as routine (info, not error) and answered best-effort; every exit path drains the body so keep-alive still works. - src/spool.jl: spool_file(bytes) -> spool_stream(write_body!, ...), which removes a partial file on a failed or abandoned write, so restart recovery can never pick up a truncated upload as if it were complete. - config.jl: FS_UPLOAD_CHUNK_BYTES, the intake memory dial. Streaming changes the 503 contract: a buffered handler knew up front how many files a request held, this one discovers them as they arrive. When the queue fills mid-request it no longer abandons the connection — it stops spooling (discarding remaining parts rather than writing files it cannot queue), drains, and answers 503 with the accepted list. Files already queued stay queued. Measured after (fresh server, 64 KiB chunk): 256 MiB +21.8 MiB, 1 GiB +20.8 MiB, 2 GiB +17.0 MiB at concurrency 1 — flat across a 32x size range; 4 concurrent 256 MiB uploads +86.9 MiB, linear in concurrency. A 2 GiB upload sustains 334 MiB/s. Small files did not regress (intake 107 -> 133 files/s, end-to-end 27.6 -> 32.9 files/s, p95 1930 -> 776 ms). A --size sweep that slopes upward is now the regression signal. - Tests (235 pass, 55 new): byte-exact round-trip of 10 files in one request, sizes straddling the chunk boundary (0/1/63/65535/65536/65537/ 131072/196615/1e6) plus a payload stuffed with near-boundary sequences; the same body parsed at chunk sizes 1..10000 to put the delimiter split at every offset; bounded allocation on a 16 MiB part; malformed and truncated bodies; spool_stream cleanup on a failed write. - Known cosmetic caveat, documented: when a body is cut short, HTTP.jl's own closeread logs an EOFError after the handler returns, because Content-Length promised more than arrived. Not reachable from a handler; the old code logged the same thing without replying.
297 lines
12 KiB
Julia
297 lines
12 KiB
Julia
# Streaming multipart/form-data reader.
|
|
#
|
|
# Why this exists: HTTP.jl's `parse_multipart_form` takes the *complete* request
|
|
# body as a byte vector, so using it means every file in the request sits in
|
|
# memory at once — and is then copied again per part. That contradicts the whole
|
|
# point of this service: file bytes belong on disk, and only a small reference
|
|
# travels through the queue. So intake needs a parser that never holds a file.
|
|
#
|
|
# This reader walks the body incrementally: it pulls fixed-size chunks off the
|
|
# socket and hands each part's bytes straight to a sink (the spool file). Peak
|
|
# memory per connection is `chunk_bytes` + the boundary length, regardless of how
|
|
# large — or how many — the uploaded files are.
|
|
#
|
|
# Interface: two calls in a loop, so the caller keeps ordinary control flow
|
|
# rather than inverting into callbacks.
|
|
#
|
|
# r = MultipartReader(io, boundary)
|
|
# while (part = next_part!(r)) !== nothing
|
|
# part.filename === nothing ? skip_part_body!(r) : write_part_body!(sink, r)
|
|
# end
|
|
#
|
|
# The grammar it implements (RFC 2046 §5.1, RFC 7578):
|
|
#
|
|
# [preamble] "--" boundary CRLF
|
|
# part-headers CRLF CRLF part-body
|
|
# CRLF "--" boundary CRLF ... another part ...
|
|
# CRLF "--" boundary "--" CRLF ... end of form, [epilogue]
|
|
#
|
|
# So the delimiter that *closes* a body is CRLF + "--" + boundary, and the two
|
|
# bytes after it say whether another part follows (CRLF) or the form is over
|
|
# ("--"). Every read is bounded, and the buffer retains only the last
|
|
# `length(delimiter)-1` bytes when no delimiter is found — that tail is what
|
|
# makes a delimiter split across two chunks parse correctly.
|
|
|
|
"Default socket read size, and therefore the memory bound per in-flight upload."
|
|
const UPLOAD_CHUNK_BYTES = 64 * 1024
|
|
|
|
"""
|
|
A part header block bigger than this is abuse, not a filename. Bounding it keeps
|
|
the one genuinely unbounded-looking read (headers, which must be buffered whole
|
|
to be parsed) from being a memory hole.
|
|
"""
|
|
const MAX_PART_HEADER_BYTES = 16 * 1024
|
|
|
|
const CRLF = UInt8[0x0d, 0x0a]
|
|
const CRLFCRLF = UInt8[0x0d, 0x0a, 0x0d, 0x0a]
|
|
const DASHDASH = UInt8[0x2d, 0x2d]
|
|
|
|
"A malformed (or truncated) multipart body. Callers turn this into a 400."
|
|
struct MultipartError <: Exception
|
|
msg::String
|
|
end
|
|
|
|
Base.showerror(io::IO, e::MultipartError) = print(io, "MultipartError: ", e.msg)
|
|
|
|
"What a part's headers said about it. `filename === nothing` means a plain form field, not a file."
|
|
struct MultipartPart
|
|
name::Union{String,Nothing}
|
|
filename::Union{String,Nothing}
|
|
content_type::Union{String,Nothing}
|
|
end
|
|
|
|
"""
|
|
MultipartReader(io, boundary; chunk_bytes = UPLOAD_CHUNK_BYTES)
|
|
|
|
An incremental reader over the multipart body arriving on `io`. `boundary` is the
|
|
value from the request's `Content-Type` header (see [`multipart_boundary`](@ref)).
|
|
"""
|
|
mutable struct MultipartReader{I<:IO}
|
|
io::I
|
|
dash_boundary::Vector{UInt8} # "--" boundary: opens the first part
|
|
delimiter::Vector{UInt8} # CRLF "--" boundary: closes every part
|
|
buf::Vector{UInt8} # rolling window; bounded by chunk_bytes + delimiter
|
|
pos::Int # next unconsumed index in buf
|
|
scratch::Vector{UInt8} # reused socket read target, so chunks don't churn the GC
|
|
chunk_bytes::Int
|
|
state::Symbol # :preamble | :at_delimiter | :body | :done
|
|
end
|
|
|
|
function MultipartReader(io::IO, boundary::AbstractString;
|
|
chunk_bytes::Int = UPLOAD_CHUNK_BYTES)
|
|
chunk_bytes > 0 || throw(ArgumentError("chunk_bytes must be positive"))
|
|
isempty(boundary) && throw(MultipartError("empty multipart boundary"))
|
|
dash_boundary = Vector{UInt8}(codeunits(string("--", boundary)))
|
|
return MultipartReader(io, dash_boundary, vcat(CRLF, dash_boundary),
|
|
UInt8[], 1, Vector{UInt8}(undef, chunk_bytes),
|
|
chunk_bytes, :preamble)
|
|
end
|
|
|
|
"""
|
|
multipart_boundary(content_type) -> String | nothing
|
|
|
|
Pull the boundary out of a `multipart/form-data` Content-Type header. Returns
|
|
`nothing` if the header is missing, is some other media type, or has no boundary
|
|
— all of which are the same 400 to a caller.
|
|
"""
|
|
function multipart_boundary(content_type::Union{AbstractString,Nothing})
|
|
content_type === nothing && return nothing
|
|
occursin(r"^\s*multipart/form-data"i, content_type) || return nothing
|
|
m = match(r"(?i:\bboundary)=(?:\"([^\"]+)\"|([^\s;]+))", content_type)
|
|
m === nothing && return nothing
|
|
return String(something(m[1], m[2]))
|
|
end
|
|
|
|
# ------------------------------------------------------------------ buffer plumbing
|
|
|
|
"Unconsumed bytes currently buffered."
|
|
navail(r::MultipartReader) = length(r.buf) - r.pos + 1
|
|
|
|
"Drop already-consumed bytes so the buffer stays bounded across a long body."
|
|
function compact!(r::MultipartReader)
|
|
r.pos == 1 && return nothing
|
|
n = navail(r)
|
|
n > 0 && copyto!(r.buf, 1, r.buf, r.pos, n)
|
|
resize!(r.buf, max(n, 0))
|
|
r.pos = 1
|
|
return nothing
|
|
end
|
|
|
|
"""
|
|
Pull one more chunk off the wire, returning `false` at end of body.
|
|
|
|
`readbytes!` on an `HTTP.Stream` returns at most what remains of the current
|
|
content-length or chunk, so this is bounded by `chunk_bytes`; `eof` is what
|
|
advances a chunked-encoded body to its next chunk, hence the guard.
|
|
"""
|
|
function fill_more!(r::MultipartReader)
|
|
eof(r.io) && return false
|
|
n = readbytes!(r.io, r.scratch, r.chunk_bytes)
|
|
n == 0 && return false
|
|
append!(r.buf, view(r.scratch, 1:n))
|
|
return true
|
|
end
|
|
|
|
"Buffer until `needle` is found, returning its range, or `nothing` at end of body."
|
|
function seek_needle!(r::MultipartReader, needle::Vector{UInt8}; limit::Int = 0)
|
|
while true
|
|
idx = findnext(needle, r.buf, r.pos)
|
|
idx === nothing || return idx
|
|
# Only the last length(needle)-1 bytes can still be part of a match, but
|
|
# the caller may need the skipped bytes (a part body), so trimming is the
|
|
# caller's job — we only enforce the optional limit.
|
|
limit > 0 && navail(r) > limit &&
|
|
throw(MultipartError("no delimiter within $limit bytes"))
|
|
compact!(r)
|
|
fill_more!(r) || return nothing
|
|
end
|
|
end
|
|
|
|
"Ensure at least `n` bytes are buffered; `false` if the body ended first."
|
|
function ensure!(r::MultipartReader, n::Int)
|
|
while navail(r) < n
|
|
compact!(r)
|
|
fill_more!(r) || return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
# Write `r.buf[range]` to `sink`. Goes through `unsafe_write` because
|
|
# `write(io, ::SubArray{UInt8})` falls back to a byte-at-a-time loop in Base,
|
|
# which would dominate the cost of a large upload.
|
|
function emit!(sink::IO, r::MultipartReader, from::Int, to::Int)
|
|
n = to - from + 1
|
|
n <= 0 && return 0
|
|
buf = r.buf # GC.@preserve needs a plain symbol, not a field access
|
|
GC.@preserve buf unsafe_write(sink, pointer(buf, from), UInt(n))
|
|
return n
|
|
end
|
|
|
|
# ------------------------------------------------------------------- parts
|
|
|
|
"""
|
|
next_part!(r) -> MultipartPart | nothing
|
|
|
|
Advance to the next part and return its headers, or `nothing` at the end of the
|
|
form. The previous part's body must have been consumed first (with
|
|
[`write_part_body!`](@ref) or [`skip_part_body!`](@ref)) — the reader cannot skip
|
|
a body it hasn't been told to, because the body is only bounded by finding the
|
|
next delimiter.
|
|
"""
|
|
function next_part!(r::MultipartReader)
|
|
r.state === :done && return nothing
|
|
r.state === :body &&
|
|
throw(MultipartError("the current part's body must be consumed before the next part"))
|
|
|
|
if r.state === :preamble
|
|
# Discard the preamble (RFC says ignore it) and consume the opening
|
|
# delimiter. Bounded: real clients send no preamble at all, and an
|
|
# unbounded scan here would be a way to make us buffer a whole body.
|
|
idx = seek_needle!(r, r.dash_boundary; limit = MAX_PART_HEADER_BYTES)
|
|
idx === nothing && throw(MultipartError("no multipart boundary found in body"))
|
|
r.pos = last(idx) + 1
|
|
r.state = :at_delimiter
|
|
end
|
|
|
|
# Just after a delimiter: "--" ends the form, CRLF introduces another part.
|
|
ensure!(r, 2) || throw(MultipartError("truncated body after a boundary delimiter"))
|
|
if view(r.buf, r.pos:r.pos+1) == DASHDASH
|
|
r.pos += 2
|
|
r.state = :done
|
|
return nothing
|
|
end
|
|
skip_linear_whitespace!(r)
|
|
ensure!(r, 2) || throw(MultipartError("truncated body after a boundary delimiter"))
|
|
view(r.buf, r.pos:r.pos+1) == CRLF ||
|
|
throw(MultipartError("boundary delimiter is not followed by a line ending"))
|
|
r.pos += 2
|
|
|
|
part = read_part_headers!(r)
|
|
r.state = :body
|
|
return part
|
|
end
|
|
|
|
"RFC 2046 allows spaces/tabs between the delimiter and its line ending."
|
|
function skip_linear_whitespace!(r::MultipartReader)
|
|
while ensure!(r, 1) && (r.buf[r.pos] == 0x20 || r.buf[r.pos] == 0x09)
|
|
r.pos += 1
|
|
end
|
|
return nothing
|
|
end
|
|
|
|
function read_part_headers!(r::MultipartReader)
|
|
# A part with no headers at all is `CRLF CRLF body`: the empty line comes
|
|
# immediately, so searching for CRLFCRLF would run past it into the body.
|
|
if ensure!(r, 2) && view(r.buf, r.pos:r.pos+1) == CRLF
|
|
r.pos += 2
|
|
return MultipartPart(nothing, nothing, nothing)
|
|
end
|
|
# The `limit` here bounds *buffering* — it only fires when the headers span
|
|
# chunks. The explicit length check below is the actual policy, so the rule
|
|
# doesn't depend on how the body happened to be chunked on the wire.
|
|
idx = seek_needle!(r, CRLFCRLF; limit = MAX_PART_HEADER_BYTES)
|
|
idx === nothing && throw(MultipartError("truncated body inside a part's headers"))
|
|
first(idx) - r.pos > MAX_PART_HEADER_BYTES &&
|
|
throw(MultipartError("part headers exceed $MAX_PART_HEADER_BYTES bytes"))
|
|
# Copying is fine: the check above bounds this block.
|
|
block = String(r.buf[r.pos:first(idx)-1])
|
|
r.pos = last(idx) + 1
|
|
return parse_part_headers(block)
|
|
end
|
|
|
|
"Unescape the backslash escapes RFC 2045 allows inside a quoted-string."
|
|
unquote(s::AbstractString) = replace(s, r"\\(.)" => s"\1")
|
|
|
|
function parse_part_headers(block::AbstractString)
|
|
name = filename = content_type = nothing
|
|
for line in eachsplit(block, "\r\n")
|
|
colon = findfirst(':', line)
|
|
colon === nothing && continue
|
|
key = lowercase(strip(line[1:colon-1]))
|
|
value = strip(line[colon+1:end])
|
|
if key == "content-disposition"
|
|
# `\b` matters: it keeps the `name=` pattern from matching inside `filename=`.
|
|
m = match(r"(?i:\bname)=(?:\"((?:[^\"\\]|\\.)*)\"|([^\s;]+))", value)
|
|
m === nothing || (name = unquote(String(something(m[1], m[2]))))
|
|
m = match(r"(?i:\bfilename)=(?:\"((?:[^\"\\]|\\.)*)\"|([^\s;]+))", value)
|
|
m === nothing || (filename = unquote(String(something(m[1], m[2]))))
|
|
elseif key == "content-type"
|
|
content_type = String(value)
|
|
end
|
|
end
|
|
return MultipartPart(name, filename, content_type)
|
|
end
|
|
|
|
"""
|
|
write_part_body!(sink, r) -> Int
|
|
|
|
Stream the current part's body into `sink`, returning the number of bytes
|
|
written. Nothing larger than a chunk is ever held in memory.
|
|
"""
|
|
function write_part_body!(sink::IO, r::MultipartReader)
|
|
r.state === :body || throw(MultipartError("no part body is open"))
|
|
total = 0
|
|
keep = length(r.delimiter) - 1 # a delimiter may straddle two chunks
|
|
while true
|
|
idx = findnext(r.delimiter, r.buf, r.pos)
|
|
if idx !== nothing
|
|
total += emit!(sink, r, r.pos, first(idx) - 1)
|
|
r.pos = last(idx) + 1
|
|
r.state = :at_delimiter
|
|
return total
|
|
end
|
|
# Emit only what cannot be the start of a straddling delimiter, then
|
|
# keep that tail and read more.
|
|
emit_to = length(r.buf) - keep
|
|
if emit_to >= r.pos
|
|
total += emit!(sink, r, r.pos, emit_to)
|
|
r.pos = emit_to + 1
|
|
end
|
|
compact!(r)
|
|
fill_more!(r) || throw(MultipartError("truncated body inside a part"))
|
|
end
|
|
end
|
|
|
|
"Consume and discard the current part's body (a form field, or a file we can't take)."
|
|
skip_part_body!(r::MultipartReader) = write_part_body!(devnull, r)
|