End-to-end throughput says how fast the pipeline is, not which stage is the reason. The four stages run concurrently behind their own queues, so the pipeline's rate *is* the slowest stage's rate and the others are invisible in it. Nothing outside the server can recover them either: known/, unknown/ and text/ are transient, and a file can cross one between two directory polls, so an external sampler misses exactly the stages worth measuring. So the pipeline counts its own work, and bin/bench.jl turns two scrapes into rates. - src/stats.jl: per-stage counters (completed/failed, bytes, busy_ns, blocked_ns, in_flight) plus intake counters, monotonic since startup in the Prometheus style — rates are the reader's job, so a scrape is stateless and two readers can't disturb each other. Recorded in worker_loop, the one place every stage's work passes through, so a new stage is instrumented the moment it is wired up and never on the read path. - src/server.jl: GET /stats. An ordinary Oxygen route (no body to stream), unlike /upload. Intake counts files at the point they become stage 1's problem, so intake totals and stage-1 arrivals refer to the same files. - src/queue.jl: capacity(q) joins length on the introspection seam — a depth of 900 means nothing without knowing whether the limit is 1000 or 1_000_000. utilization = (busy - blocked) / (window * workers) is the number that names the bottleneck: throughput alone can't tell a saturated stage from one starved by the stage ahead of it, since both report the same files/s. blocked_ns is what keeps that true. Stages 1 and 3 apply blocking backpressure — a full downstream queue means parking, not dropping — and that wait is inside the handler, so counting it as busy would pin stage 1 at 1.0 whenever stage 2 is the real jam, making every stage upstream of a jam look like the jam. enqueue_blocking! wraps the retry loop so the wait is measurable at all, and keeps the three routing paths from drifting into three different backoff behaviours. Measured (400 mixed files, 16 KiB, concurrency 16): stage 4 is the constraint at 0.87 utilization and 853 ms/file — github-linguist is a process spawn per file — while stages 1 and 3 idle under 0.10. Verified the blocked accounting against a deliberately starved server (FS_TEXT_WORKERS=1, FS_TEXT_QUEUE_CAPACITY=2): stage 3 reported 100% blocked at 0.0 utilization rather than looking saturated too. bin/bench_model.jl: the classifier alone, no server or queue in the way, because stage 1's 38.7 ms/file cannot plausibly be a 32-64-16-2 MLP. It isn't: Lux.apply is 2.3 us, read_features 4.2-6.0 us (flat across 1 KiB - 256 MiB, as the seek-to-tail design intends), classify() 8.4 us — so ~99.98% of stage 1 is rename, logging and contention, and the file read costs 3x the inference. Two findings: batching would buy ~13x (179 ns/file at batch 512 vs 2.34 us at batch 1), and inference does not scale past ~4 threads. A pure-compute control kernel runs the same sweep to place the blame — it reaches 14.3x at 16 tasks on this box, so the machine parallelizes and Lux.apply does not. BLAS threads and GC are both ruled out; the cause is inside Lux and is not diagnosed here. Three bugs in the memory measurement, all of which produced wrong answers that looked plausible: - detect_pid matched any process with the launch command in its argv, including the shell that started the server — one run reported 3.64 MiB as the server's memory. Candidates are now filtered by /proc/<pid>/comm, what the process is rather than what its arguments say; no pattern over argv can do that. - Baseline RSS was read *before* clear_refs reset the peak counter, so the two numbers had different origins. The 2 GiB run reported -1.01 MiB of growth; reading the baseline after the reset makes it 31.3 MiB. - Negative growth is now reported as "none measurable" rather than a negative figure, which reads as a memory saving. Re-measuring with those fixed keeps the claim that matters — growth is flat in file size (14-31 MiB from 256 MiB to 2 GiB), so nothing is buffering — but the concurrency coefficient does not survive: a freshly started server settles anywhere in an ~860-985 MiB band, so baseline variance is comparable to the growth being measured, and the old table quoted megabyte precision the measurement never supported. README now states the shape, requires a ~30s settle before a memory run, and says plainly that linear-in-concurrency is undemonstrated rather than leaving an authoritative-looking number. README also gains a single runnable sequence for all three harnesses: the server prerequisite was never shown inline, so following the benchmarking section top-to-bottom just produced "cannot reach /health". Tests: 276 pass (41 new) — the blocked-vs-busy split, worker_loop draining in_flight through a throwing handler, and the JSON round-trip of the field names bench.jl reads.
201 lines
10 KiB
Julia
201 lines
10 KiB
Julia
module FileServer
|
|
|
|
using Logging
|
|
using Random
|
|
using UUIDs
|
|
using HTTP
|
|
using JSON3
|
|
using Oxygen
|
|
using Lux
|
|
using JLD2
|
|
using Languages
|
|
|
|
include("multipart.jl") # streaming multipart reader (defines UPLOAD_CHUNK_BYTES, used by config.jl)
|
|
include("config.jl")
|
|
include("job.jl")
|
|
include("queue.jl")
|
|
include("stats.jl") # per-stage counters behind GET /stats (needs Config/Job/JobQueue)
|
|
include("spool.jl")
|
|
include("model.jl") # build_model() + read_features(); shared with bin/train.jl
|
|
include("classify.jl") # Classifier + load_classifier/classify (needs model.jl)
|
|
include("metadata.jl") # exiftool extraction + sidecar enrichment (stage 2)
|
|
include("content.jl") # binary-vs-text triage for unknown files (stage 3)
|
|
include("language.jl") # natural + programming language enrichment for text (stage 4)
|
|
include("cluster.jl") # unknown-format discovery by header clustering (stage 5, science)
|
|
include("catalog.jl") # durable single-owner format catalog (stage 5, phase B; needs cluster.jl + metadata.jl fsync)
|
|
include("worker.jl")
|
|
|
|
# Globals the HTTP handlers read at request time. Set once in `run`, before the
|
|
# server starts accepting connections. Declared after the includes above so the
|
|
# `Config`/`ChannelQueue` types exist.
|
|
const CONFIG = Ref{Config}()
|
|
const QUEUE = Ref{ChannelQueue}() # stage-1 (classification) queue; HTTP intake enqueues here
|
|
const KNOWN_QUEUE = Ref{ChannelQueue}() # stage-2 (enrichment) queue; stage-1 workers enqueue here
|
|
const UNKNOWN_QUEUE = Ref{ChannelQueue}() # stage-3 (content triage) queue; stage-1 workers enqueue here
|
|
const TEXT_QUEUE = Ref{ChannelQueue}() # stage-4 (language enrichment) queue; stage-3 workers enqueue here
|
|
const CLASSIFIER = Ref{Classifier}() # loaded once at startup, shared read-only across workers
|
|
const DETECTOR = Ref{LanguageDetector}() # natural-language detector; built once at startup, shared read-only
|
|
|
|
include("server.jl") # registers routes (references CONFIG/QUEUE at call time)
|
|
|
|
export run
|
|
|
|
# When stderr is a live terminal Julia flushes each write, but when it's
|
|
# redirected to a file or pipe (a log file, `tee`, journald, a container log
|
|
# driver) Julia block-buffers it — so a long-running server's logs sit unseen in
|
|
# the buffer until it fills or the process exits, making it look like nothing is
|
|
# happening. This wrapper delegates to a normal logger and flushes after every
|
|
# message so output appears immediately wherever stderr is pointed.
|
|
struct FlushLogger{L<:AbstractLogger} <: AbstractLogger
|
|
inner::L
|
|
end
|
|
Logging.min_enabled_level(f::FlushLogger) = Logging.min_enabled_level(f.inner)
|
|
Logging.shouldlog(f::FlushLogger, args...) = Logging.shouldlog(f.inner, args...)
|
|
Logging.catch_exceptions(f::FlushLogger) = Logging.catch_exceptions(f.inner)
|
|
function Logging.handle_message(f::FlushLogger, args...; kwargs...)
|
|
Logging.handle_message(f.inner, args...; kwargs...)
|
|
flush(f.inner.stream)
|
|
return nothing
|
|
end
|
|
|
|
"""
|
|
run(; overrides...)
|
|
|
|
Start the file server: build config, ensure directories, recover any leftover
|
|
spooled files, spawn the worker pool, then serve HTTP until interrupted
|
|
(Ctrl-C / SIGINT or SIGTERM). On shutdown it stops accepting uploads, drains the
|
|
queue, waits for workers to finish in-flight files, and exits cleanly.
|
|
|
|
Keyword `overrides` (e.g. `port=9000`) take precedence over environment vars.
|
|
"""
|
|
function run(; overrides...)
|
|
# By default a Julia script exits immediately on SIGINT (Ctrl-C). Disable
|
|
# that so the interrupt surfaces as a catchable InterruptException, letting
|
|
# us drain the queue gracefully below.
|
|
Base.exit_on_sigint(false)
|
|
|
|
# Flush every log message so output is visible in real time even when stderr
|
|
# is redirected to a file/pipe (see FlushLogger). Set before anything logs.
|
|
global_logger(FlushLogger(ConsoleLogger(stderr)))
|
|
|
|
cfg = config_from_env(; overrides...)
|
|
ensure_dirs(cfg)
|
|
|
|
# All pools draw from the same OS threads. Warn on the *combined* size (still
|
|
# allowed): oversubscription just means tasks share threads, not a failure.
|
|
total_workers = cfg.worker_count + cfg.known_worker_count + cfg.unknown_worker_count + cfg.text_worker_count
|
|
if total_workers > Threads.nthreads()
|
|
@warn "combined worker count exceeds available threads; workers will share threads (start Julia with -t N for real parallelism)" classify_workers=cfg.worker_count known_workers=cfg.known_worker_count unknown_workers=cfg.unknown_worker_count text_workers=cfg.text_worker_count total=total_workers nthreads=Threads.nthreads()
|
|
end
|
|
|
|
# exiftool is a hard prerequisite for stage-2 enrichment. Fail fast at
|
|
# startup rather than discover it missing on the first known file.
|
|
assert_exiftool()
|
|
|
|
# github-linguist powers stage-4 *programming*-language detection, but it's
|
|
# best-effort (natural-language enrichment stands on its own), so a missing
|
|
# binary is a warning, not a fatal error — per-file lookups degrade to none.
|
|
linguist_available() || @warn "github-linguist not found on PATH; stage-4 text files will have no programming language (install it to enable)"
|
|
|
|
queue = ChannelQueue(cfg.queue_capacity)
|
|
known_queue = ChannelQueue(cfg.known_queue_capacity)
|
|
unknown_queue = ChannelQueue(cfg.unknown_queue_capacity)
|
|
text_queue = ChannelQueue(cfg.text_queue_capacity)
|
|
CONFIG[] = cfg
|
|
QUEUE[] = queue
|
|
KNOWN_QUEUE[] = known_queue
|
|
UNKNOWN_QUEUE[] = unknown_queue
|
|
TEXT_QUEUE[] = text_queue
|
|
|
|
# Load the classifier before serving. Fail fast: a server that silently
|
|
# doesn't classify is a worse surprise than a clear startup error.
|
|
CLASSIFIER[] = load_classifier(cfg.model_path)
|
|
@info "loaded classifier" path=cfg.model_path
|
|
|
|
# Build the natural-language detector once (it loads the whatlang n-gram
|
|
# model) and share it read-only across the stage-4 pool, like the classifier.
|
|
DETECTOR[] = LanguageDetector()
|
|
@info "loaded language detector"
|
|
|
|
# Stage-aware recovery: re-drive each stage's leftovers onto its own queue so
|
|
# files resume where they were, not from scratch. spool/ → stage-1,
|
|
# known/ → stage-2, unknown/ → stage-3.
|
|
recovered = recover_dir!(cfg.spool_dir, queue)
|
|
recovered_known = recover_dir!(cfg.known_dir, known_queue)
|
|
recovered_unknown = recover_dir!(cfg.unknown_dir, unknown_queue)
|
|
recovered_text = recover_dir!(cfg.text_dir, text_queue)
|
|
@info "starting file-server" host=cfg.host port=cfg.port workers=cfg.worker_count known_workers=cfg.known_worker_count unknown_workers=cfg.unknown_worker_count text_workers=cfg.text_worker_count capacity=cfg.queue_capacity known_capacity=cfg.known_queue_capacity unknown_capacity=cfg.unknown_queue_capacity text_capacity=cfg.text_queue_capacity recovered=recovered recovered_known=recovered_known recovered_unknown=recovered_unknown recovered_text=recovered_text
|
|
|
|
# Zero the counters here, not at module load: `since` should mean "serving
|
|
# since", so a scrape's totals cover the run, not the minutes spent loading
|
|
# the classifier. Nothing has been processed yet — recovery only enqueues.
|
|
reset_metrics!()
|
|
|
|
# Each pool gets its stage's counters (src/stats.jl); `worker_loop` records
|
|
# into them, `GET /stats` reads them out. Stage 1 and 3 also hand theirs to
|
|
# their handler, which charges time parked on a full downstream queue to
|
|
# `blocked_ns` so it isn't mistaken for work.
|
|
st = METRICS.stages
|
|
workers = [Threads.@spawn worker_loop(i, cfg, queue,
|
|
(job, c, wid) -> handle_classify_job(job, c, wid, known_queue, unknown_queue, st.classify),
|
|
st.classify)
|
|
for i in 1:cfg.worker_count]
|
|
known_workers = [Threads.@spawn worker_loop(i, cfg, known_queue, handle_known_job, st.enrich)
|
|
for i in 1:cfg.known_worker_count]
|
|
unknown_workers = [Threads.@spawn worker_loop(i, cfg, unknown_queue,
|
|
(job, c, wid) -> handle_unknown_job(job, c, wid, text_queue, st.triage),
|
|
st.triage)
|
|
for i in 1:cfg.unknown_worker_count]
|
|
text_workers = [Threads.@spawn worker_loop(i, cfg, text_queue,
|
|
(job, c, wid) -> handle_text_job(job, c, wid, DETECTOR[]),
|
|
st.language)
|
|
for i in 1:cfg.text_worker_count]
|
|
|
|
register_routes()
|
|
# `handler` replaces Oxygen's root stream handler so POST /upload can read its
|
|
# body incrementally instead of having it buffered into memory first; every
|
|
# other route still goes through Oxygen (see `root_stream_handler`).
|
|
serve(; host = cfg.host, port = cfg.port, async = true, show_banner = false,
|
|
handler = root_stream_handler)
|
|
|
|
# Idempotent graceful drain: stop accepting uploads, let workers finish the
|
|
# buffered jobs, then exit. Called from two places:
|
|
# * the `finally` below, for SIGINT (Ctrl-C) and normal return, and
|
|
# * an `atexit` hook, for SIGTERM (systemd/Docker/k8s `stop`).
|
|
# We can't intercept SIGTERM directly — Julia blocks it on worker threads and
|
|
# handles it in its own runtime, so a user signal() handler never fires. But
|
|
# Julia's SIGTERM path runs `atexit` hooks, which gives us a reliable seam.
|
|
drained = Threads.Atomic{Bool}(false)
|
|
function drain()
|
|
Threads.atomic_xchg!(drained, true) && return # run at most once
|
|
@info "draining queues and stopping workers"
|
|
terminate() # 1. stop accepting new HTTP requests
|
|
close!(queue) # 2. no new classify jobs; stage-1 drains buffered
|
|
foreach(wait, workers) # 3. wait out stage-1 — the ONLY producer of BOTH the
|
|
# known and unknown queues — so nothing else enqueues
|
|
close!(known_queue) # 4. now safe to close the queues stage-1 fed
|
|
close!(unknown_queue)
|
|
foreach(wait, known_workers) # 5. wait out stage-2 (terminal) and stage-3 — stage-3 is
|
|
foreach(wait, unknown_workers) # the ONLY producer of the text queue
|
|
close!(text_queue) # 6. now safe to close the queue stage-3 fed
|
|
foreach(wait, text_workers) # 7. wait out stage-4
|
|
@info "shutdown complete"
|
|
end
|
|
atexit(drain)
|
|
|
|
try
|
|
while true
|
|
sleep(0.5) # interruptible; SIGINT throws in here
|
|
end
|
|
catch e
|
|
e isa InterruptException || rethrow(e)
|
|
@info "shutdown requested (SIGINT)"
|
|
finally
|
|
drain()
|
|
end
|
|
|
|
return nothing
|
|
end
|
|
|
|
end # module
|