A file is now written once into spool/ and stays there for its whole time in
flight. Stages 1 and 3 hand work on by enqueueing the same Job reference, so
job.path is constant from intake until commit. The three inter-stage renames
(spool->known, spool->unknown, unknown->text) are gone, along with the
known/, unknown/ and text/ directories and their FS_*_DIR settings.
Terminal moves stay: done/, text_done/, binary/ and failed/ still receive the
file, and binary/ in particular must, since it is the corpus the offline
stage-5 discovery sweep reads.
Measured by bin/bench_stage1.jl (2000 x 64 KiB, min of 5): the removed rename
cost 11.6 us per file, the equal of the classifier itself. One stage-1 worker
goes from ~28.6 us/file (~35k files/s) to 11.70 us (85.4k files/s); 16 workers
reach 333k files/s. What is left is classify 10.30 us, the queue handoff
0.12 us, and the disabled @debug lines 0.29 us.
The stage a file has reached now lives only in the queue holding its
reference, and the queues are in-process, so a crash loses it: everything in
spool/ replays from stage 1. That is safe rather than merely tolerable —
classification and the UTF-8 sniff are pure functions of the file's bytes and
the terminal commits rename with force=true, so a replayed file lands where it
would have landed and overwrites its own sidecar. The per-stage directories
were standing in for a durable queue, and charging every file a rename per
stage to do it; src/queue.jl already defines the seam where a broker-backed
JobQueue restores exact resume properly.
Recovery had to change to match. All leftovers now funnel onto the single
stage-1 queue, so the old non-blocking recover_dir! would have capped a
4000-file recovery at 1000 and abandoned the rest. It now blocks on a full
queue, and run() spawns the worker pools before recovering so the consumers
drain it as we fill; reset_metrics! moves above the spawn accordingly.
enqueue_blocking! takes stats::Union{StageStats,Nothing} so recovery reuses
the never-drop retry without charging its wait to a stage's blocked_ns, which
would drive /stats utilization negative.
Tests assert the new invariant positively (the file stays put, the routed
reference is unchanged, no intermediate directory appears) and cover recovery
of more files than the queue can hold. Verified end to end against a real
server: 40 leftovers, stage-1 capacity 3, all recovered and drained to
text_done/ with spool/ and failed/ empty.
197 lines
10 KiB
Julia
197 lines
10 KiB
Julia
# Worker tasks: pull jobs off a queue and process them. The loop scaffolding
|
|
# (dequeue-until-drained, try/catch, quarantine-on-throw) is identical for every
|
|
# stage, so `worker_loop` is parametrized with a `handler` and reused. Today
|
|
# there are four stages:
|
|
#
|
|
# stage 1 handle_classify_job classify → known queue | unknown queue
|
|
# stage 2 handle_known_job exiftool enrich → done/ (+ .meta.json)
|
|
# stage 3 handle_unknown_job binary-vs-text sniff → binary/ | text queue
|
|
# stage 4 handle_text_job language enrich → text_done/ (+ .meta.json)
|
|
#
|
|
# Adding a stage later is just another queue + pool + handler; the loop below
|
|
# doesn't change.
|
|
#
|
|
# A file does not move between stages. It is written once into `spool/` at
|
|
# intake and stays there for its whole in-flight life; only the small `Job`
|
|
# reference travels, and `job.path` is therefore constant from intake until the
|
|
# file is committed. The stage a file has reached lives in the queue holding its
|
|
# reference, not in which directory the bytes sit — so the intermediate hops
|
|
# (spool→known, spool→unknown, unknown→text) are three renames per file that buy
|
|
# nothing on the live path. What remains is one move at the end: into a terminal
|
|
# sink (`done/`, `text_done/`, `binary/`) or into `failed/` on a throw.
|
|
#
|
|
# The cost is on restart. The queues are in-process (src/queue.jl), so a crash
|
|
# loses them, and recovery can only re-drive everything in `spool/` from stage 1.
|
|
# That is safe — classification and the UTF-8 sniff are pure functions of the
|
|
# file's bytes and every commit is idempotent — but it redoes work the old
|
|
# directory-per-stage layout could skip. The durable fix is the queue seam, not
|
|
# the directories: a broker-backed `JobQueue` restores exact resume for free.
|
|
#
|
|
# (`ROUTE_ENQUEUE_RETRY_SECONDS`, the backoff the routing handoffs below use,
|
|
# now lives in src/queue.jl, since startup recovery shares it.)
|
|
|
|
# Per-file logging in stage 1 is `@debug`, not `@info`, because it is the
|
|
# stage's dominant cost. Measured by bin/bench_stage1.jl (2000 x 64 KiB files,
|
|
# min of 5 trials): a formatted pair of log lines costs 71.2 µs per file, against
|
|
# the 11.7 µs the whole handler takes with them switched off — six times the rest
|
|
# of the stage put together. Nearly all of it is `ConsoleLogger` formatting
|
|
# (64.2 µs); the FlushLogger's per-message flush is only ~7 µs on top. Switching
|
|
# them back on with `JULIA_DEBUG=FileServer` takes the handler to 106.3 µs, i.e.
|
|
# from 85.4k files/s down to 9.4k on one worker.
|
|
#
|
|
# With logging off the stage is the classifier and nothing else: classify 10.30 µs
|
|
# (of which read_features is 7.82 µs), the queue handoff 0.12 µs, the disabled
|
|
# `@debug` lines 0.29 µs — 10.7 µs of the 11.7 µs total. Removing the inter-stage
|
|
# rename is what left it that way: that rename was 11.6 µs per file, so it was
|
|
# the equal of the classifier, and dropping it took one worker from ~35k to
|
|
# ~85k files/s (16 workers: ~333k files/s).
|
|
#
|
|
# `@debug` is compiled to a min-level check that doesn't evaluate its arguments,
|
|
# so a disabled line costs ~0.15 µs rather than ~36 µs. The messages are still
|
|
# there when wanted: run with `JULIA_DEBUG=FileServer` to get them back. Errors,
|
|
# quarantines and lifecycle events stay at `@error`/`@info` — they are rare and
|
|
# their cost doesn't scale with throughput. `GET /stats` (src/stats.jl) is the
|
|
# per-file observability that survives, and it is counted, not formatted.
|
|
|
|
"""
|
|
handle_classify_job(job, cfg, worker_id, known_queue, unknown_queue)
|
|
|
|
Stage 1. Classify the spooled file and route it to the next stage's queue,
|
|
retrying on a full queue rather than dropping the file:
|
|
* `:known` → enqueue onto the known queue for stage 2.
|
|
* `:unknown` → enqueue onto the unknown queue for stage 3.
|
|
|
|
Routing is the enqueue and nothing else: the file stays where intake wrote it and
|
|
the *same* `Job` is handed on, so `job.path` still points at it. Reaching stage 2
|
|
is a fact about which queue holds the reference, not about which directory holds
|
|
the bytes.
|
|
|
|
Sub-`MIN_FILE_BYTES` files short-circuit to `:unknown` inside `classify`.
|
|
"""
|
|
function handle_classify_job(job::Job, cfg::Config, worker_id::Int,
|
|
known_queue::JobQueue, unknown_queue::JobQueue,
|
|
stats::StageStats)
|
|
classification = classify(CLASSIFIER[], job.path)
|
|
@debug "classified file" worker=worker_id id=job.id name=job.original_name size=job.size classification=classification
|
|
|
|
if classification === :known
|
|
# known queue full → park and retry, don't drop (time charged to blocked_ns)
|
|
enqueue_blocking!(known_queue, job, stats;
|
|
retry_seconds = ROUTE_ENQUEUE_RETRY_SECONDS)
|
|
@debug "routed to enrichment" worker=worker_id id=job.id path=job.path
|
|
else
|
|
enqueue_blocking!(unknown_queue, job, stats;
|
|
retry_seconds = ROUTE_ENQUEUE_RETRY_SECONDS)
|
|
@debug "routed to content triage" worker=worker_id id=job.id path=job.path
|
|
end
|
|
return nothing
|
|
end
|
|
|
|
"""
|
|
handle_known_job(job, cfg, worker_id)
|
|
|
|
Stage 2. Extract metadata (exiftool, with timeout) and enrich: build the
|
|
normalized sidecar and commit both to `done/` sidecar-first — the file's one and
|
|
only move, straight out of `spool/`. Extraction
|
|
failure/timeout yields a *degraded* sidecar (the file is still a wanted known
|
|
file), so the only way to land in `failed/` is a genuine I/O error writing the
|
|
sidecar or moving the file — handled by `worker_loop`'s quarantine.
|
|
"""
|
|
function handle_known_job(job::Job, cfg::Config, worker_id::Int)
|
|
meta = build_metadata(job, cfg)
|
|
file_dest, sidecar = finalize_known!(cfg, job, meta)
|
|
@info "enriched" worker=worker_id id=job.id dest=file_dest sidecar=basename(sidecar) file_type=meta.file_type created_by=meta.created_by degraded=(meta.error !== nothing)
|
|
return nothing
|
|
end
|
|
|
|
"""
|
|
handle_unknown_job(job, cfg, worker_id, text_queue)
|
|
|
|
Stage 3. Sort an unrecognized file into a coarse content bucket by sniffing its
|
|
first bytes.
|
|
|
|
The two outcomes are asymmetric, because one is terminal and one is not. Binary
|
|
is the end of the live path, so the file is committed to `binary/` — which is
|
|
also where the offline stage-5 discovery sweep reads its corpus, so the move is
|
|
load-bearing, not bookkeeping. Text has a stage 4 still to come, so nothing moves:
|
|
the same `Job` goes onto the language-enrichment queue, retrying on a full queue
|
|
rather than dropping the file (the blocking backpressure stage 1 also uses).
|
|
"""
|
|
function handle_unknown_job(job::Job, cfg::Config, worker_id::Int,
|
|
text_queue::JobQueue, stats::StageStats)
|
|
if is_binary(job.path)
|
|
dest = move_to(cfg.binary_dir, job)
|
|
@info "sorted unknown" worker=worker_id id=job.id name=job.original_name kind=:binary dest=dest
|
|
else
|
|
# text queue full → park and retry, don't drop (time charged to blocked_ns)
|
|
enqueue_blocking!(text_queue, job, stats;
|
|
retry_seconds = ROUTE_ENQUEUE_RETRY_SECONDS)
|
|
@info "routed to language enrichment" worker=worker_id id=job.id path=job.path
|
|
end
|
|
return nothing
|
|
end
|
|
|
|
"""
|
|
handle_text_job(job, cfg, worker_id, detector)
|
|
|
|
Stage 4. Enrich a text file with its natural language (via `detector`) and
|
|
programming language (via github-linguist): build the sidecar and commit both to
|
|
`text_done/` sidecar-first — the file's one and only move, straight out of
|
|
`spool/`. Detection failure yields a *degraded* sidecar (the
|
|
file is still wanted text), so the only way to land in `failed/` is a genuine I/O
|
|
error committing — handled by `worker_loop`'s quarantine.
|
|
"""
|
|
function handle_text_job(job::Job, cfg::Config, worker_id::Int, detector)
|
|
meta = build_text_metadata(detector, job, cfg)
|
|
file_dest, sidecar = finalize_text!(cfg, job, meta)
|
|
@info "enriched text" worker=worker_id id=job.id dest=file_dest sidecar=basename(sidecar) language=meta.language confidence=meta.language_confidence programming_language=meta.programming_language degraded=(meta.error !== nothing)
|
|
return nothing
|
|
end
|
|
|
|
"""
|
|
worker_loop(worker_id, cfg, queue, handler, stats)
|
|
|
|
Consume jobs from `queue` until it is closed and drained, running `handler` on
|
|
each. A failure on one job is logged and the file quarantined in `failed/` — it
|
|
must never kill the worker, or the pool would silently shrink.
|
|
|
|
This loop is also where per-stage metrics are recorded (`stats`, see
|
|
src/stats.jl). Instrumenting here rather than in each handler means every stage
|
|
is measured the same way, by construction, and a new stage is measured the
|
|
moment it is wired up — there is no per-handler bookkeeping to forget.
|
|
|
|
The timed region is the handler alone, excluding the `dequeue!` above it: time
|
|
parked waiting for work is idleness, and counting it as service time would make
|
|
an idle stage look as busy as a saturated one. A quarantined job still counts
|
|
its time — the work was done, it just ended in `failed/`.
|
|
"""
|
|
function worker_loop(worker_id::Int, cfg::Config, queue::JobQueue, handler,
|
|
stats::StageStats)
|
|
@info "worker started" worker=worker_id
|
|
while true
|
|
job = dequeue!(queue)
|
|
job === nothing && break # queue closed and drained → exit
|
|
Threads.atomic_add!(stats.in_flight, 1)
|
|
t0 = time_ns()
|
|
ok = true
|
|
try
|
|
handler(job, cfg, worker_id)
|
|
catch e
|
|
ok = false
|
|
@error "processing failed" worker=worker_id id=job.id name=job.original_name exception=(e, catch_backtrace())
|
|
try
|
|
move_to(cfg.failed_dir, job)
|
|
catch e2
|
|
@error "could not quarantine failed file" worker=worker_id id=job.id path=job.path exception=(e2, catch_backtrace())
|
|
end
|
|
finally
|
|
# In a `finally` so an InterruptException during shutdown can't leave
|
|
# in_flight permanently above zero, which would read as a stuck job.
|
|
record_job!(stats, ok, job.size, Int(time_ns() - t0))
|
|
Threads.atomic_sub!(stats.in_flight, 1)
|
|
end
|
|
end
|
|
@info "worker stopped" worker=worker_id
|
|
return nothing
|
|
end
|