Add stage-3 content triage: sort unknown files into binary/ and text/

Unknown files are no longer terminal. Stage 1 now routes :unknown onto a
dedicated queue (with the same blocking backpressure as the known queue),
and a third worker pool sorts each file into data/binary/ or data/text/
using a NUL-byte sniff of the first 8000 bytes.

- content.jl: is_binary content sniff (stage 3)
- worker.jl: handle_unknown_job; stage-1 routes unknown with backpressure;
  KNOWN_ENQUEUE_RETRY_SECONDS -> ROUTE_ENQUEUE_RETRY_SECONDS (serves both)
- config.jl: unknown_worker_count/queue_capacity, binary_dir, text_dir + env
- FileServer.jl: unknown queue, pool, stage-aware recovery, drain ordering
- tests for is_binary and handle_unknown_job; tmp_config isolates new dirs
- README: three-stage pipeline
This commit is contained in:
2026-07-02 16:54:17 -04:00
parent 2a46f5021a
commit e42e8ef8af
6 changed files with 227 additions and 83 deletions

View File

@@ -1,48 +1,54 @@
# 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 two stages:
# there are three stages:
#
# stage 1 handle_classify_job spool/ → classify → known/ (+known queue) | unknown/
# stage 1 handle_classify_job spool/ → classify → known/ (+known queue) | unknown/ (+unknown queue)
# stage 2 handle_known_job known/ → exiftool enrich → done/ (+ .meta.json)
# stage 3 handle_unknown_job unknown/ → binary-vs-text sniff → binary/ | text/
#
# Adding a stage later (e.g. an unknown-file pool consuming unknown/) is just
# another queue + pool + handler; the loop below doesn't change.
# Adding a stage later is just another queue + pool + handler; the loop below
# doesn't change.
# How long a stage-1 worker backs off before retrying an enqueue onto a full
# known queue. Blocking backpressure: a classified file is never dropped, so the
# stage-1 worker parks until stage 2 makes room. Keeps intake decoupled — the
# HTTP path's `enqueue!` stays non-blocking; only this worker-to-worker handoff
# blocks.
const KNOWN_ENQUEUE_RETRY_SECONDS = 0.05
# downstream queue (known or unknown). Blocking backpressure: a classified file
# is never dropped, so the stage-1 worker parks until the next stage makes room.
# Keeps intake decoupled — the HTTP path's `enqueue!` stays non-blocking; only
# this worker-to-worker handoff blocks.
const ROUTE_ENQUEUE_RETRY_SECONDS = 0.05
"""
handle_classify_job(job, cfg, worker_id, known_queue)
handle_classify_job(job, cfg, worker_id, known_queue, unknown_queue)
Stage 1. Classify the spooled file and route it:
* `:unknown` → move to `unknown/` (parked for a future pipeline; terminal here).
* `:known` → move to `known/`, then enqueue onto the known queue for stage 2,
retrying on a full queue rather than dropping the file.
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` → move to `known/`, enqueue onto the known queue for stage 2.
* `:unknown` → move to `unknown/`, enqueue onto the unknown queue for stage 3.
In both cases move first so the file physically lives in its stage dir before the
reference is visible downstream; the moved path becomes the routed job's location.
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)
function handle_classify_job(job::Job, cfg::Config, worker_id::Int,
known_queue::JobQueue, unknown_queue::JobQueue)
classification = classify(CLASSIFIER[], job.path)
@info "classified file" worker=worker_id id=job.id name=job.original_name size=job.size classification=classification
if classification === :known
# Move first so the file physically lives in known/ before the reference
# is visible to stage 2; then hand off. The moved path becomes the job's
# new location for the known queue.
dest = move_to(cfg.known_dir, job)
routed = Job(job.id, job.original_name, dest, job.size, job.received_at)
while !enqueue!(known_queue, routed)
sleep(KNOWN_ENQUEUE_RETRY_SECONDS) # known queue full → back off, don't drop
sleep(ROUTE_ENQUEUE_RETRY_SECONDS) # known queue full → back off, don't drop
end
@info "routed to enrichment" worker=worker_id id=job.id dest=dest
else
dest = move_to(cfg.unknown_dir, job)
@info "parked unknown" worker=worker_id id=job.id dest=dest
routed = Job(job.id, job.original_name, dest, job.size, job.received_at)
while !enqueue!(unknown_queue, routed)
sleep(ROUTE_ENQUEUE_RETRY_SECONDS) # unknown queue full → back off, don't drop
end
@info "routed to content triage" worker=worker_id id=job.id dest=dest
end
return nothing
end
@@ -63,6 +69,21 @@ function handle_known_job(job::Job, cfg::Config, worker_id::Int)
return nothing
end
"""
handle_unknown_job(job, cfg, worker_id)
Stage 3. Sort an unrecognized file into a coarse content bucket by sniffing its
first bytes: `binary/` if it looks like binary data, `text/` otherwise. Terminal
— there is no further stage. Simple by design for now; richer handling can hang
off either bucket later.
"""
function handle_unknown_job(job::Job, cfg::Config, worker_id::Int)
binary = is_binary(job.path)
dest = move_to(binary ? cfg.binary_dir : cfg.text_dir, job)
@info "sorted unknown" worker=worker_id id=job.id name=job.original_name kind=(binary ? :binary : :text) dest=dest
return nothing
end
"""
worker_loop(worker_id, cfg, queue, handler)