Route by queue, not by directory: stop moving files between stages
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.
This commit is contained in:
@@ -8,7 +8,8 @@ using JSON3
|
||||
using FileServer: Job, Config, ChannelQueue, enqueue!, dequeue!, close!, length,
|
||||
sanitize_filename, recover_dir!, normalize_metadata,
|
||||
build_metadata, finalize_known!, run_exiftool,
|
||||
is_binary, handle_unknown_job, worker_loop,
|
||||
is_binary, handle_unknown_job, handle_classify_job, worker_loop,
|
||||
load_classifier, CLASSIFIER,
|
||||
capacity, StageStats, IntakeStats, Metrics, METRICS, reset_metrics!,
|
||||
record_job!, enqueue_blocking!, stats_snapshot, STAGE_KEYS,
|
||||
detect_natural_language, run_linguist, detect_programming_language,
|
||||
@@ -70,10 +71,7 @@ end
|
||||
function tmp_config(root; kwargs...)
|
||||
cfg = Config(;
|
||||
spool_dir = joinpath(root, "spool"),
|
||||
known_dir = joinpath(root, "known"),
|
||||
unknown_dir = joinpath(root, "unknown"),
|
||||
binary_dir = joinpath(root, "binary"),
|
||||
text_dir = joinpath(root, "text"),
|
||||
done_dir = joinpath(root, "done"),
|
||||
text_done_dir = joinpath(root, "text_done"),
|
||||
failed_dir = joinpath(root, "failed"),
|
||||
@@ -279,7 +277,7 @@ end
|
||||
mktempdir() do root
|
||||
cfg = tmp_config(root; exiftool_timeout=5)
|
||||
# Point at a nonexistent file → exiftool exits non-zero → degraded.
|
||||
job = Job("id-3", "gone.dat", joinpath(cfg.known_dir, "id-3-gone.dat"), 99, 0.0)
|
||||
job = Job("id-3", "gone.dat", joinpath(cfg.spool_dir, "id-3-gone.dat"), 99, 0.0)
|
||||
m = build_metadata(job, cfg)
|
||||
@test m.error !== nothing
|
||||
@test m.file_type === nothing
|
||||
@@ -341,7 +339,7 @@ end
|
||||
mktempdir() do root
|
||||
cfg = tmp_config(root)
|
||||
# A real known-stage file to enrich.
|
||||
src = joinpath(cfg.known_dir, "id-9-pixel.png")
|
||||
src = joinpath(cfg.spool_dir, "id-9-pixel.png")
|
||||
write(src, PNG_1x1)
|
||||
job = Job("id-9", "pixel.png", src, Base.length(PNG_1x1), 0.0)
|
||||
|
||||
@@ -425,7 +423,7 @@ end
|
||||
stats = StageStats()
|
||||
|
||||
# A binary file (embedded NUL) lands in binary/ and is NOT enqueued.
|
||||
bpath = joinpath(cfg.unknown_dir, "id-b-blob.dat")
|
||||
bpath = joinpath(cfg.spool_dir, "id-b-blob.dat")
|
||||
write(bpath, UInt8[0x00, 0xFF, 0x10])
|
||||
bjob = Job("id-b", "blob.dat", bpath, filesize(bpath), 0.0)
|
||||
handle_unknown_job(bjob, cfg, 1, text_queue, stats)
|
||||
@@ -433,19 +431,49 @@ end
|
||||
@test !isfile(bpath)
|
||||
@test length(text_queue) == 0
|
||||
|
||||
# A text file lands in text/ AND is routed onto the stage-4 queue,
|
||||
# with its path updated to the new text/ location.
|
||||
tpath = joinpath(cfg.unknown_dir, "id-t-notes.log")
|
||||
# A text file is routed onto the stage-4 queue and *does not move*:
|
||||
# stage 4 is still to come, so the bytes stay in spool/ and the same
|
||||
# reference is handed on. Regression guard against re-introducing an
|
||||
# intermediate hop — the queue, not a directory, is what records that
|
||||
# this file has cleared triage.
|
||||
tpath = joinpath(cfg.spool_dir, "id-t-notes.log")
|
||||
write(tpath, "just some log text\n")
|
||||
tjob = Job("id-t", "notes.log", tpath, filesize(tpath), 0.0)
|
||||
handle_unknown_job(tjob, cfg, 1, text_queue, stats)
|
||||
moved = joinpath(cfg.text_dir, "id-t-notes.log")
|
||||
@test isfile(moved)
|
||||
@test !isfile(tpath)
|
||||
@test isfile(tpath) # stayed put
|
||||
@test length(text_queue) == 1
|
||||
routed = dequeue!(text_queue)
|
||||
@test routed.id == "id-t"
|
||||
@test routed.path == moved
|
||||
@test routed.path == tpath # reference unchanged
|
||||
end
|
||||
end
|
||||
|
||||
@testset "handle_classify_job: routes by enqueue only, never moves the file" begin
|
||||
# Stage 1's entire job is picking a queue. Whichever way the classifier
|
||||
# votes, the bytes must stay where intake wrote them and the *same* Job
|
||||
# must be handed on — the queue records the phase, not a directory.
|
||||
# Asserted against both queues at once so the test doesn't depend on how
|
||||
# the committed model happens to label these bytes.
|
||||
CLASSIFIER[] = load_classifier(joinpath(@__DIR__, "..", "model", "classifier.jld2"))
|
||||
mktempdir() do root
|
||||
cfg = tmp_config(root)
|
||||
known_queue, unknown_queue = ChannelQueue(10), ChannelQueue(10)
|
||||
stats = StageStats()
|
||||
|
||||
path = joinpath(cfg.spool_dir, "id-c-pixel.png")
|
||||
write(path, PNG_1x1)
|
||||
job = Job("id-c", "pixel.png", path, Base.length(PNG_1x1), 0.0)
|
||||
|
||||
handle_classify_job(job, cfg, 1, known_queue, unknown_queue, stats)
|
||||
|
||||
@test isfile(path) # stayed put
|
||||
@test length(known_queue) + length(unknown_queue) == 1 # routed exactly once
|
||||
routed = length(known_queue) == 1 ? dequeue!(known_queue) : dequeue!(unknown_queue)
|
||||
@test routed.path == path # reference unchanged
|
||||
@test routed.id == "id-c"
|
||||
# No intermediate directory was invented alongside spool/.
|
||||
@test !isdir(joinpath(root, "known"))
|
||||
@test !isdir(joinpath(root, "unknown"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -503,7 +531,7 @@ end
|
||||
cfg = tmp_config(root)
|
||||
d = LanguageDetector()
|
||||
|
||||
src = joinpath(cfg.text_dir, "id-x-script.py")
|
||||
src = joinpath(cfg.spool_dir, "id-x-script.py")
|
||||
write(src, join(["# a short program in English prose comment",
|
||||
"import sys",
|
||||
"def greet(name):",
|
||||
@@ -542,7 +570,7 @@ end
|
||||
cfg = tmp_config(root)
|
||||
d = LanguageDetector()
|
||||
|
||||
src = joinpath(cfg.text_dir, "id-h-readme.md")
|
||||
src = joinpath(cfg.spool_dir, "id-h-readme.md")
|
||||
write(src, "# Project\n\nThis project does something useful and interesting for everyone.\n")
|
||||
job = Job("id-h", "readme.md", src, filesize(src), 0.0)
|
||||
|
||||
@@ -739,6 +767,39 @@ end
|
||||
end
|
||||
end
|
||||
|
||||
@testset "recover_dir!: blocks on a full queue, recovers every file" begin
|
||||
# The regression this guards is data loss, not slowness. `spool/` now
|
||||
# holds every in-flight file at every stage, so a crash under load leaves
|
||||
# far more leftovers than one queue's capacity — and the old non-blocking
|
||||
# recovery warned and abandoned the excess, which is exactly the work
|
||||
# recovery exists to save. With the pool live, recovery must park until
|
||||
# the consumer makes room and come back with all of it.
|
||||
mktempdir() do root
|
||||
dir = joinpath(root, "spool"); mkpath(dir)
|
||||
n_files = 25
|
||||
for i in 1:n_files
|
||||
write(joinpath(dir, string("id-", lpad(i, 3, '0'), "-f.dat")), "x")
|
||||
end
|
||||
|
||||
q = ChannelQueue(4) # deliberately far smaller than n_files
|
||||
drained = Job[]
|
||||
consumer = Threads.@spawn begin
|
||||
while Base.length(drained) < n_files
|
||||
job = dequeue!(q)
|
||||
job === nothing && break
|
||||
push!(drained, job)
|
||||
end
|
||||
end
|
||||
|
||||
n = recover_dir!(dir, q) # would strand 21 files if it didn't block
|
||||
wait(consumer)
|
||||
|
||||
@test n == n_files
|
||||
@test Base.length(drained) == n_files
|
||||
@test Base.length(Set(j.path for j in drained)) == n_files # no duplicates
|
||||
end
|
||||
end
|
||||
|
||||
# Helper: write a "file" of raw bytes into a dir with a UUID-ish unique name,
|
||||
# returning its path. Mirrors what stage-3 deposits into binary/.
|
||||
function drop_binary(dir, bytes; name=string(rand(UInt128)))
|
||||
@@ -1012,7 +1073,7 @@ end
|
||||
text_queue = ChannelQueue(1)
|
||||
@test enqueue!(text_queue, Job("filler", "f", "/tmp/f", 1, 0.0))
|
||||
|
||||
p = joinpath(cfg.unknown_dir, "id-t-notes.log")
|
||||
p = joinpath(cfg.spool_dir, "id-t-notes.log")
|
||||
write(p, "plain text\n")
|
||||
job = Job("id-t", "notes.log", p, filesize(p), 0.0)
|
||||
|
||||
@@ -1024,7 +1085,7 @@ end
|
||||
wait(drainer)
|
||||
@test stats.blocked_ns[] > 50_000_000
|
||||
@test length(text_queue) == 1 # the file did get through
|
||||
@test isfile(joinpath(cfg.text_dir, "id-t-notes.log"))
|
||||
@test isfile(p) # and stayed in spool/
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user