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:
76
README.md
76
README.md
@@ -11,9 +11,9 @@ classifier that labels it **known** (a file type resembling the training set) or
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The pipeline is two stages, each with its own bounded queue and its own worker
|
The pipeline is three stages, each with its own bounded queue and its own worker
|
||||||
pool (tuned independently, since classification is CPU-bound and enrichment is
|
pool (tuned independently, since classification is CPU-bound, enrichment is
|
||||||
process-/IO-bound):
|
process-/IO-bound, and content triage is cheap IO):
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /upload (multipart)
|
POST /upload (multipart)
|
||||||
@@ -35,21 +35,25 @@ process-/IO-bound):
|
|||||||
│
|
│
|
||||||
┌────────────┴────────────┐
|
┌────────────┴────────────┐
|
||||||
:unknown :known
|
:unknown :known
|
||||||
│ │ move to data/known/, then
|
│ move to data/unknown/, │ move to data/known/, then
|
||||||
▼ ▼ enqueue (blocking backpressure)
|
▼ then enqueue (blocking) ▼ enqueue (blocking backpressure)
|
||||||
data/unknown/<uuid>-<name> ┌────────────────────┐
|
┌────────────────────┐ ┌────────────────────┐
|
||||||
(parked; future pipeline) │ known queue │ enrichment
|
│ unknown queue │ │ known queue │ enrichment
|
||||||
└─────────┬──────────┘
|
└─────────┬──────────┘ └─────────┬──────────┘
|
||||||
│ dequeue
|
│ dequeue │ dequeue
|
||||||
┌───────────────────┼───────────────────┐
|
┌────────┼────────┐ ┌───────────┼───────────┐
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼ ▼
|
||||||
known wkr 1 known wkr 2 … known wkr M
|
unk 1 unk 2 … unk K known wkr 1 known wkr 2 … known wkr M
|
||||||
│ exiftool → normalized sidecar
|
│ binary-vs-text sniff │ exiftool → normalized sidecar
|
||||||
success ────┴──► data/done/<uuid>-<name>
|
├─► data/binary/<uuid>-<name> success ──┴──► data/done/<uuid>-<name>
|
||||||
data/done/<uuid>-<name>.meta.json (sidecar-first commit)
|
└─► data/text/<uuid>-<name> data/done/<uuid>-<name>.meta.json
|
||||||
failure ───────► data/failed/<uuid>-<name>
|
(sidecar-first commit)
|
||||||
|
failure ───────► data/failed/<uuid>-<name>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Stages 2 (enrichment) and 3 (content triage) run in parallel: stage 1 feeds both
|
||||||
|
the known and unknown queues.
|
||||||
|
|
||||||
Key properties:
|
Key properties:
|
||||||
|
|
||||||
- **Fast intake:** the queue only ever carries small references; file bytes live
|
- **Fast intake:** the queue only ever carries small references; file bytes live
|
||||||
@@ -58,13 +62,15 @@ Key properties:
|
|||||||
is full, uploads get `503 Service Unavailable`. When the *known* queue is full,
|
is full, uploads get `503 Service Unavailable`. When the *known* queue is full,
|
||||||
the stage-1 worker blocks and retries (a classified file is never dropped).
|
the stage-1 worker blocks and retries (a classified file is never dropped).
|
||||||
- **Crash-resilient:** files survive on disk. On startup, recovery is
|
- **Crash-resilient:** files survive on disk. On startup, recovery is
|
||||||
stage-aware: leftovers in `data/spool/` re-enter classification and leftovers
|
stage-aware: leftovers in `data/spool/` re-enter classification, `data/known/`
|
||||||
in `data/known/` re-enter enrichment (`recovered` / `recovered_known` in the
|
re-enter enrichment, and `data/unknown/` re-enter content triage (`recovered` /
|
||||||
log), so a file resumes at its correct stage instead of restarting from scratch.
|
`recovered_known` / `recovered_unknown` in the log), so a file resumes at its
|
||||||
|
correct stage instead of restarting from scratch.
|
||||||
- **Graceful shutdown:** SIGINT (Ctrl-C) and SIGTERM (systemd/Docker/k8s `stop`)
|
- **Graceful shutdown:** SIGINT (Ctrl-C) and SIGTERM (systemd/Docker/k8s `stop`)
|
||||||
both stop accepting uploads, then drain the stages *in order* — close the
|
both stop accepting uploads, then drain the stages *in order* — close the
|
||||||
stage-1 queue and wait out the classify workers (the only producer of the known
|
stage-1 queue and wait out the classify workers (the only producer of the known
|
||||||
queue) before closing the known queue and waiting out the enrich workers.
|
*and* unknown queues) before closing those two queues and waiting out the
|
||||||
|
enrich and content-triage workers.
|
||||||
(See "Shutdown" below for one cosmetic caveat on SIGTERM.)
|
(See "Shutdown" below for one cosmetic caveat on SIGTERM.)
|
||||||
- **Safe filenames:** client-supplied names are sanitized and prefixed with a
|
- **Safe filenames:** client-supplied names are sanitized and prefixed with a
|
||||||
server-minted UUID before touching the filesystem (no path traversal).
|
server-minted UUID before touching the filesystem (no path traversal).
|
||||||
@@ -111,6 +117,21 @@ file's presence there always implies its sidecar is already present; a crash in
|
|||||||
between leaves only a harmless orphan sidecar, and recovery re-enriches
|
between leaves only a harmless orphan sidecar, and recovery re-enriches
|
||||||
idempotently.
|
idempotently.
|
||||||
|
|
||||||
|
### Content triage (stage 3)
|
||||||
|
|
||||||
|
Files the classifier labels **unknown** are handed to a third pool that sorts
|
||||||
|
them into two coarse buckets so downstream tooling can treat them differently:
|
||||||
|
|
||||||
|
- **`data/binary/`** — the file looks like binary data.
|
||||||
|
- **`data/text/`** — the file looks like text.
|
||||||
|
|
||||||
|
The test is the classic **NUL-byte sniff** (the same heuristic `git` and
|
||||||
|
`file(1)` use): read the first 8000 bytes and, if any is NUL, call it binary,
|
||||||
|
else text. It's cheap (no full read) and reliable in practice — text encodings
|
||||||
|
don't embed NUL bytes, while binary formats almost always do near the start. An
|
||||||
|
empty file has no NUL, so it's treated as text. This is deliberately simple for
|
||||||
|
now; richer handling can hang off either bucket later (`src/content.jl`).
|
||||||
|
|
||||||
## The queue seam (→ RabbitMQ later)
|
## The queue seam (→ RabbitMQ later)
|
||||||
|
|
||||||
The HTTP handler and workers only ever call `enqueue!`, `dequeue!`, and
|
The HTTP handler and workers only ever call `enqueue!`, `dequeue!`, and
|
||||||
@@ -199,17 +220,21 @@ init, so the artifact is exactly regenerable from the same inputs.
|
|||||||
| `FS_QUEUE_CAPACITY` | `1000` | Max pending intake jobs before `503` |
|
| `FS_QUEUE_CAPACITY` | `1000` | Max pending intake jobs before `503` |
|
||||||
| `FS_KNOWN_WORKERS` | `nthreads()` | Stage-2 (enrichment) worker tasks |
|
| `FS_KNOWN_WORKERS` | `nthreads()` | Stage-2 (enrichment) worker tasks |
|
||||||
| `FS_KNOWN_QUEUE_CAPACITY` | `1000` | Max pending enrichment jobs (then backpressure) |
|
| `FS_KNOWN_QUEUE_CAPACITY` | `1000` | Max pending enrichment jobs (then backpressure) |
|
||||||
|
| `FS_UNKNOWN_WORKERS` | `nthreads()` | Stage-3 (content triage) worker tasks |
|
||||||
|
| `FS_UNKNOWN_QUEUE_CAPACITY` | `1000` | Max pending triage jobs (then backpressure) |
|
||||||
| `FS_SPOOL_DIR` | `data/spool` | Incoming files (pending classification) |
|
| `FS_SPOOL_DIR` | `data/spool` | Incoming files (pending classification) |
|
||||||
| `FS_KNOWN_DIR` | `data/known` | Classified-known, awaiting enrichment |
|
| `FS_KNOWN_DIR` | `data/known` | Classified-known, awaiting enrichment |
|
||||||
| `FS_UNKNOWN_DIR` | `data/unknown` | Classified-unknown, parked for a future pool |
|
| `FS_UNKNOWN_DIR` | `data/unknown` | Classified-unknown, awaiting content triage |
|
||||||
|
| `FS_BINARY_DIR` | `data/binary` | Stage-3 sink: unknown files that look binary |
|
||||||
|
| `FS_TEXT_DIR` | `data/text` | Stage-3 sink: unknown files that look like text |
|
||||||
| `FS_DONE_DIR` | `data/done` | Enriched known files (+ `.meta.json`) |
|
| `FS_DONE_DIR` | `data/done` | Enriched known files (+ `.meta.json`) |
|
||||||
| `FS_FAILED_DIR` | `data/failed` | Files whose processing threw |
|
| `FS_FAILED_DIR` | `data/failed` | Files whose processing threw |
|
||||||
| `FS_MODEL_PATH` | `model/classifier.jld2` | Classifier artifact loaded at startup |
|
| `FS_MODEL_PATH` | `model/classifier.jld2` | Classifier artifact loaded at startup |
|
||||||
| `FS_EXIFTOOL_TIMEOUT` | `30` | Seconds before a stuck exiftool is killed |
|
| `FS_EXIFTOOL_TIMEOUT` | `30` | Seconds before a stuck exiftool is killed |
|
||||||
|
|
||||||
> To get real parallelism, start Julia with enough threads (`-t N`) to cover both
|
> To get real parallelism, start Julia with enough threads (`-t N`) to cover all
|
||||||
> pools. If `FS_WORKERS + FS_KNOWN_WORKERS` exceeds available threads you'll get a
|
> pools. If `FS_WORKERS + FS_KNOWN_WORKERS + FS_UNKNOWN_WORKERS` exceeds available
|
||||||
> warning (non-fatal) and workers will share threads.
|
> threads you'll get a warning (non-fatal) and workers will share threads.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -242,7 +267,8 @@ src/
|
|||||||
model.jl NN architecture + byte→feature mapping (shared with trainer)
|
model.jl NN architecture + byte→feature mapping (shared with trainer)
|
||||||
classify.jl load artifact + classify a file at inference time
|
classify.jl load artifact + classify a file at inference time
|
||||||
metadata.jl exiftool extraction + normalized sidecar (stage 2)
|
metadata.jl exiftool extraction + normalized sidecar (stage 2)
|
||||||
worker.jl parametrized worker loop + classify/enrich handlers
|
content.jl binary-vs-text sniff for unknown files (stage 3)
|
||||||
|
worker.jl parametrized worker loop + classify/enrich/triage handlers
|
||||||
server.jl HTTP routes/handlers
|
server.jl HTTP routes/handlers
|
||||||
bin/
|
bin/
|
||||||
server.jl entry point
|
server.jl entry point
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ include("spool.jl")
|
|||||||
include("model.jl") # build_model() + read_features(); shared with bin/train.jl
|
include("model.jl") # build_model() + read_features(); shared with bin/train.jl
|
||||||
include("classify.jl") # Classifier + load_classifier/classify (needs model.jl)
|
include("classify.jl") # Classifier + load_classifier/classify (needs model.jl)
|
||||||
include("metadata.jl") # exiftool extraction + sidecar enrichment (stage 2)
|
include("metadata.jl") # exiftool extraction + sidecar enrichment (stage 2)
|
||||||
|
include("content.jl") # binary-vs-text triage for unknown files (stage 3)
|
||||||
include("worker.jl")
|
include("worker.jl")
|
||||||
|
|
||||||
# Globals the HTTP handlers read at request time. Set once in `run`, before the
|
# Globals the HTTP handlers read at request time. Set once in `run`, before the
|
||||||
@@ -23,6 +24,7 @@ include("worker.jl")
|
|||||||
const CONFIG = Ref{Config}()
|
const CONFIG = Ref{Config}()
|
||||||
const QUEUE = Ref{ChannelQueue}() # stage-1 (classification) queue; HTTP intake enqueues here
|
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 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 CLASSIFIER = Ref{Classifier}() # loaded once at startup, shared read-only across workers
|
const CLASSIFIER = Ref{Classifier}() # loaded once at startup, shared read-only across workers
|
||||||
|
|
||||||
include("server.jl") # registers routes (references CONFIG/QUEUE at call time)
|
include("server.jl") # registers routes (references CONFIG/QUEUE at call time)
|
||||||
@@ -70,22 +72,24 @@ function run(; overrides...)
|
|||||||
cfg = config_from_env(; overrides...)
|
cfg = config_from_env(; overrides...)
|
||||||
ensure_dirs(cfg)
|
ensure_dirs(cfg)
|
||||||
|
|
||||||
# Both pools draw from the same OS threads. Warn on the *combined* size (still
|
# All pools draw from the same OS threads. Warn on the *combined* size (still
|
||||||
# allowed): oversubscription just means tasks share threads, not a failure.
|
# allowed): oversubscription just means tasks share threads, not a failure.
|
||||||
total_workers = cfg.worker_count + cfg.known_worker_count
|
total_workers = cfg.worker_count + cfg.known_worker_count + cfg.unknown_worker_count
|
||||||
if total_workers > Threads.nthreads()
|
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 total=total_workers nthreads=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 total=total_workers nthreads=Threads.nthreads()
|
||||||
end
|
end
|
||||||
|
|
||||||
# exiftool is a hard prerequisite for stage-2 enrichment. Fail fast at
|
# exiftool is a hard prerequisite for stage-2 enrichment. Fail fast at
|
||||||
# startup rather than discover it missing on the first known file.
|
# startup rather than discover it missing on the first known file.
|
||||||
assert_exiftool()
|
assert_exiftool()
|
||||||
|
|
||||||
queue = ChannelQueue(cfg.queue_capacity)
|
queue = ChannelQueue(cfg.queue_capacity)
|
||||||
known_queue = ChannelQueue(cfg.known_queue_capacity)
|
known_queue = ChannelQueue(cfg.known_queue_capacity)
|
||||||
CONFIG[] = cfg
|
unknown_queue = ChannelQueue(cfg.unknown_queue_capacity)
|
||||||
QUEUE[] = queue
|
CONFIG[] = cfg
|
||||||
KNOWN_QUEUE[] = known_queue
|
QUEUE[] = queue
|
||||||
|
KNOWN_QUEUE[] = known_queue
|
||||||
|
UNKNOWN_QUEUE[] = unknown_queue
|
||||||
|
|
||||||
# Load the classifier before serving. Fail fast: a server that silently
|
# Load the classifier before serving. Fail fast: a server that silently
|
||||||
# doesn't classify is a worse surprise than a clear startup error.
|
# doesn't classify is a worse surprise than a clear startup error.
|
||||||
@@ -93,17 +97,20 @@ function run(; overrides...)
|
|||||||
@info "loaded classifier" path=cfg.model_path
|
@info "loaded classifier" path=cfg.model_path
|
||||||
|
|
||||||
# Stage-aware recovery: re-drive each stage's leftovers onto its own queue so
|
# Stage-aware recovery: re-drive each stage's leftovers onto its own queue so
|
||||||
# files resume where they were, not from scratch. (unknown/ has no consumer
|
# files resume where they were, not from scratch. spool/ → stage-1,
|
||||||
# yet, so it isn't recovered — it just accumulates for a future pool.)
|
# known/ → stage-2, unknown/ → stage-3.
|
||||||
recovered = recover_dir!(cfg.spool_dir, queue)
|
recovered = recover_dir!(cfg.spool_dir, queue)
|
||||||
recovered_known = recover_dir!(cfg.known_dir, known_queue)
|
recovered_known = recover_dir!(cfg.known_dir, known_queue)
|
||||||
@info "starting file-server" host=cfg.host port=cfg.port workers=cfg.worker_count known_workers=cfg.known_worker_count capacity=cfg.queue_capacity known_capacity=cfg.known_queue_capacity recovered=recovered recovered_known=recovered_known
|
recovered_unknown = recover_dir!(cfg.unknown_dir, unknown_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 capacity=cfg.queue_capacity known_capacity=cfg.known_queue_capacity unknown_capacity=cfg.unknown_queue_capacity recovered=recovered recovered_known=recovered_known recovered_unknown=recovered_unknown
|
||||||
|
|
||||||
workers = [Threads.@spawn worker_loop(i, cfg, queue,
|
workers = [Threads.@spawn worker_loop(i, cfg, queue,
|
||||||
(job, c, wid) -> handle_classify_job(job, c, wid, known_queue))
|
(job, c, wid) -> handle_classify_job(job, c, wid, known_queue, unknown_queue))
|
||||||
for i in 1:cfg.worker_count]
|
for i in 1:cfg.worker_count]
|
||||||
known_workers = [Threads.@spawn worker_loop(i, cfg, known_queue, handle_known_job)
|
known_workers = [Threads.@spawn worker_loop(i, cfg, known_queue, handle_known_job)
|
||||||
for i in 1:cfg.known_worker_count]
|
for i in 1:cfg.known_worker_count]
|
||||||
|
unknown_workers = [Threads.@spawn worker_loop(i, cfg, unknown_queue, handle_unknown_job)
|
||||||
|
for i in 1:cfg.unknown_worker_count]
|
||||||
|
|
||||||
register_routes()
|
register_routes()
|
||||||
serve(; host = cfg.host, port = cfg.port, async = true, show_banner = false)
|
serve(; host = cfg.host, port = cfg.port, async = true, show_banner = false)
|
||||||
@@ -121,10 +128,12 @@ function run(; overrides...)
|
|||||||
@info "draining queues and stopping workers"
|
@info "draining queues and stopping workers"
|
||||||
terminate() # 1. stop accepting new HTTP requests
|
terminate() # 1. stop accepting new HTTP requests
|
||||||
close!(queue) # 2. no new classify jobs; stage-1 drains buffered
|
close!(queue) # 2. no new classify jobs; stage-1 drains buffered
|
||||||
foreach(wait, workers) # 3. wait out stage-1 — the ONLY producer of the
|
foreach(wait, workers) # 3. wait out stage-1 — the ONLY producer of BOTH the
|
||||||
# known queue — so nothing else will be enqueued
|
# known and unknown queues — so nothing else enqueues
|
||||||
close!(known_queue) # 4. now safe to close stage-2's queue
|
close!(known_queue) # 4. now safe to close the downstream queues
|
||||||
foreach(wait, known_workers) # 5. wait out stage-2
|
close!(unknown_queue)
|
||||||
|
foreach(wait, known_workers) # 5. wait out stage-2 and stage-3
|
||||||
|
foreach(wait, unknown_workers)
|
||||||
@info "shutdown complete"
|
@info "shutdown complete"
|
||||||
end
|
end
|
||||||
atexit(drain)
|
atexit(drain)
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ Base.@kwdef struct Config
|
|||||||
# the two pools are tuned independently.
|
# the two pools are tuned independently.
|
||||||
known_worker_count::Int = Threads.nthreads()
|
known_worker_count::Int = Threads.nthreads()
|
||||||
known_queue_capacity::Int = 1000
|
known_queue_capacity::Int = 1000
|
||||||
|
# Stage 3 (content triage) also gets its own pool + queue: sorting an
|
||||||
|
# unrecognized file into binary/ vs text/ is cheap I/O, tuned independently
|
||||||
|
# of the classify and enrich pools.
|
||||||
|
unknown_worker_count::Int = Threads.nthreads()
|
||||||
|
unknown_queue_capacity::Int = 1000
|
||||||
spool_dir::String = "data/spool" # files land here on intake (pending classification)
|
spool_dir::String = "data/spool" # files land here on intake (pending classification)
|
||||||
known_dir::String = "data/known" # classified-known, awaiting enrichment (stage 2)
|
known_dir::String = "data/known" # classified-known, awaiting enrichment (stage 2)
|
||||||
unknown_dir::String = "data/unknown" # classified-unknown, parked for a future pipeline
|
unknown_dir::String = "data/unknown" # classified-unknown, awaiting content triage (stage 3)
|
||||||
|
binary_dir::String = "data/binary" # stage-3 sink: unknown files that look like binary data
|
||||||
|
text_dir::String = "data/text" # stage-3 sink: unknown files that look like text
|
||||||
done_dir::String = "data/done" # fully enriched known files (+ .meta.json sidecars)
|
done_dir::String = "data/done" # fully enriched known files (+ .meta.json sidecars)
|
||||||
failed_dir::String = "data/failed" # files move here if a worker throws
|
failed_dir::String = "data/failed" # files move here if a worker throws
|
||||||
model_path::String = "model/classifier.jld2" # committed classifier artifact, loaded at startup
|
model_path::String = "model/classifier.jld2" # committed classifier artifact, loaded at startup
|
||||||
@@ -31,34 +38,42 @@ and for `FileServer.run(; port=...)`).
|
|||||||
Recognised variables:
|
Recognised variables:
|
||||||
FS_HOST, FS_PORT, FS_WORKERS, FS_QUEUE_CAPACITY,
|
FS_HOST, FS_PORT, FS_WORKERS, FS_QUEUE_CAPACITY,
|
||||||
FS_KNOWN_WORKERS, FS_KNOWN_QUEUE_CAPACITY,
|
FS_KNOWN_WORKERS, FS_KNOWN_QUEUE_CAPACITY,
|
||||||
FS_SPOOL_DIR, FS_KNOWN_DIR, FS_UNKNOWN_DIR, FS_DONE_DIR, FS_FAILED_DIR,
|
FS_UNKNOWN_WORKERS, FS_UNKNOWN_QUEUE_CAPACITY,
|
||||||
FS_MODEL_PATH, FS_EXIFTOOL_TIMEOUT
|
FS_SPOOL_DIR, FS_KNOWN_DIR, FS_UNKNOWN_DIR, FS_BINARY_DIR, FS_TEXT_DIR,
|
||||||
|
FS_DONE_DIR, FS_FAILED_DIR, FS_MODEL_PATH, FS_EXIFTOOL_TIMEOUT
|
||||||
"""
|
"""
|
||||||
function config_from_env(; host=nothing, port=nothing, worker_count=nothing,
|
function config_from_env(; host=nothing, port=nothing, worker_count=nothing,
|
||||||
queue_capacity=nothing, known_worker_count=nothing,
|
queue_capacity=nothing, known_worker_count=nothing,
|
||||||
known_queue_capacity=nothing, spool_dir=nothing,
|
known_queue_capacity=nothing, unknown_worker_count=nothing,
|
||||||
known_dir=nothing, unknown_dir=nothing, done_dir=nothing,
|
unknown_queue_capacity=nothing, spool_dir=nothing,
|
||||||
|
known_dir=nothing, unknown_dir=nothing, binary_dir=nothing,
|
||||||
|
text_dir=nothing, done_dir=nothing,
|
||||||
failed_dir=nothing, model_path=nothing, exiftool_timeout=nothing)
|
failed_dir=nothing, model_path=nothing, exiftool_timeout=nothing)
|
||||||
Config(
|
Config(
|
||||||
host = something(host, get(ENV, "FS_HOST", "127.0.0.1")),
|
host = something(host, get(ENV, "FS_HOST", "127.0.0.1")),
|
||||||
port = something(port, parse(Int, get(ENV, "FS_PORT", "8080"))),
|
port = something(port, parse(Int, get(ENV, "FS_PORT", "8080"))),
|
||||||
worker_count = something(worker_count, parse(Int, get(ENV, "FS_WORKERS", string(Threads.nthreads())))),
|
worker_count = something(worker_count, parse(Int, get(ENV, "FS_WORKERS", string(Threads.nthreads())))),
|
||||||
queue_capacity = something(queue_capacity, parse(Int, get(ENV, "FS_QUEUE_CAPACITY", "1000"))),
|
queue_capacity = something(queue_capacity, parse(Int, get(ENV, "FS_QUEUE_CAPACITY", "1000"))),
|
||||||
known_worker_count = something(known_worker_count, parse(Int, get(ENV, "FS_KNOWN_WORKERS", string(Threads.nthreads())))),
|
known_worker_count = something(known_worker_count, parse(Int, get(ENV, "FS_KNOWN_WORKERS", string(Threads.nthreads())))),
|
||||||
known_queue_capacity = something(known_queue_capacity, parse(Int, get(ENV, "FS_KNOWN_QUEUE_CAPACITY", "1000"))),
|
known_queue_capacity = something(known_queue_capacity, parse(Int, get(ENV, "FS_KNOWN_QUEUE_CAPACITY", "1000"))),
|
||||||
spool_dir = something(spool_dir, get(ENV, "FS_SPOOL_DIR", "data/spool")),
|
unknown_worker_count = something(unknown_worker_count, parse(Int, get(ENV, "FS_UNKNOWN_WORKERS", string(Threads.nthreads())))),
|
||||||
known_dir = something(known_dir, get(ENV, "FS_KNOWN_DIR", "data/known")),
|
unknown_queue_capacity = something(unknown_queue_capacity, parse(Int, get(ENV, "FS_UNKNOWN_QUEUE_CAPACITY", "1000"))),
|
||||||
unknown_dir = something(unknown_dir, get(ENV, "FS_UNKNOWN_DIR", "data/unknown")),
|
spool_dir = something(spool_dir, get(ENV, "FS_SPOOL_DIR", "data/spool")),
|
||||||
done_dir = something(done_dir, get(ENV, "FS_DONE_DIR", "data/done")),
|
known_dir = something(known_dir, get(ENV, "FS_KNOWN_DIR", "data/known")),
|
||||||
failed_dir = something(failed_dir, get(ENV, "FS_FAILED_DIR", "data/failed")),
|
unknown_dir = something(unknown_dir, get(ENV, "FS_UNKNOWN_DIR", "data/unknown")),
|
||||||
model_path = something(model_path, get(ENV, "FS_MODEL_PATH", "model/classifier.jld2")),
|
binary_dir = something(binary_dir, get(ENV, "FS_BINARY_DIR", "data/binary")),
|
||||||
exiftool_timeout = something(exiftool_timeout, parse(Int, get(ENV, "FS_EXIFTOOL_TIMEOUT", "30"))),
|
text_dir = something(text_dir, get(ENV, "FS_TEXT_DIR", "data/text")),
|
||||||
|
done_dir = something(done_dir, get(ENV, "FS_DONE_DIR", "data/done")),
|
||||||
|
failed_dir = something(failed_dir, get(ENV, "FS_FAILED_DIR", "data/failed")),
|
||||||
|
model_path = something(model_path, get(ENV, "FS_MODEL_PATH", "model/classifier.jld2")),
|
||||||
|
exiftool_timeout = something(exiftool_timeout, parse(Int, get(ENV, "FS_EXIFTOOL_TIMEOUT", "30"))),
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
"Create all the pipeline-stage directories if they don't already exist."
|
"Create all the pipeline-stage directories if they don't already exist."
|
||||||
function ensure_dirs(cfg::Config)
|
function ensure_dirs(cfg::Config)
|
||||||
for d in (cfg.spool_dir, cfg.known_dir, cfg.unknown_dir, cfg.done_dir, cfg.failed_dir)
|
for d in (cfg.spool_dir, cfg.known_dir, cfg.unknown_dir, cfg.binary_dir,
|
||||||
|
cfg.text_dir, cfg.done_dir, cfg.failed_dir)
|
||||||
mkpath(d)
|
mkpath(d)
|
||||||
end
|
end
|
||||||
return nothing
|
return nothing
|
||||||
|
|||||||
24
src/content.jl
Normal file
24
src/content.jl
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Stage-3 content triage for unknown files.
|
||||||
|
#
|
||||||
|
# A file that stage-1 couldn't recognize is still sorted into one of two coarse
|
||||||
|
# buckets so downstream tooling can treat them differently: `text/` for
|
||||||
|
# human-readable content, `binary/` for everything else. The test is the classic
|
||||||
|
# "NUL byte in the first sniff window" heuristic that git and file(1) use — cheap
|
||||||
|
# (no full read), and reliable in practice: text encodings don't embed NUL bytes,
|
||||||
|
# while binary formats almost always do near the start.
|
||||||
|
|
||||||
|
const CONTENT_SNIFF_BYTES = 8000
|
||||||
|
|
||||||
|
"""
|
||||||
|
is_binary(path) -> Bool
|
||||||
|
|
||||||
|
Classify a file as binary (`true`) or text (`false`) by sniffing its first
|
||||||
|
`CONTENT_SNIFF_BYTES` bytes for a NUL byte. An empty file has no NUL, so it is
|
||||||
|
treated as text.
|
||||||
|
"""
|
||||||
|
function is_binary(path::AbstractString)::Bool
|
||||||
|
open(path, "r") do io
|
||||||
|
chunk = read(io, CONTENT_SNIFF_BYTES)
|
||||||
|
return any(==(0x00), chunk)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,48 +1,54 @@
|
|||||||
# Worker tasks: pull jobs off a queue and process them. The loop scaffolding
|
# 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
|
# (dequeue-until-drained, try/catch, quarantine-on-throw) is identical for every
|
||||||
# stage, so `worker_loop` is parametrized with a `handler` and reused. Today
|
# 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 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
|
# Adding a stage later is just another queue + pool + handler; the loop below
|
||||||
# another queue + pool + handler; the loop below doesn't change.
|
# doesn't change.
|
||||||
|
|
||||||
# How long a stage-1 worker backs off before retrying an enqueue onto a full
|
# 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
|
# downstream queue (known or unknown). Blocking backpressure: a classified file
|
||||||
# stage-1 worker parks until stage 2 makes room. Keeps intake decoupled — the
|
# is never dropped, so the stage-1 worker parks until the next stage makes room.
|
||||||
# HTTP path's `enqueue!` stays non-blocking; only this worker-to-worker handoff
|
# Keeps intake decoupled — the HTTP path's `enqueue!` stays non-blocking; only
|
||||||
# blocks.
|
# this worker-to-worker handoff blocks.
|
||||||
const KNOWN_ENQUEUE_RETRY_SECONDS = 0.05
|
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:
|
Stage 1. Classify the spooled file and route it to the next stage's queue,
|
||||||
* `:unknown` → move to `unknown/` (parked for a future pipeline; terminal here).
|
retrying on a full queue rather than dropping the file:
|
||||||
* `:known` → move to `known/`, then enqueue onto the known queue for stage 2,
|
* `:known` → move to `known/`, enqueue onto the known queue for stage 2.
|
||||||
retrying on a full queue rather than dropping the file.
|
* `: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`.
|
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)
|
classification = classify(CLASSIFIER[], job.path)
|
||||||
@info "classified file" worker=worker_id id=job.id name=job.original_name size=job.size classification=classification
|
@info "classified file" worker=worker_id id=job.id name=job.original_name size=job.size classification=classification
|
||||||
|
|
||||||
if classification === :known
|
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)
|
dest = move_to(cfg.known_dir, job)
|
||||||
routed = Job(job.id, job.original_name, dest, job.size, job.received_at)
|
routed = Job(job.id, job.original_name, dest, job.size, job.received_at)
|
||||||
while !enqueue!(known_queue, routed)
|
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
|
end
|
||||||
@info "routed to enrichment" worker=worker_id id=job.id dest=dest
|
@info "routed to enrichment" worker=worker_id id=job.id dest=dest
|
||||||
else
|
else
|
||||||
dest = move_to(cfg.unknown_dir, job)
|
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
|
end
|
||||||
return nothing
|
return nothing
|
||||||
end
|
end
|
||||||
@@ -63,6 +69,21 @@ function handle_known_job(job::Job, cfg::Config, worker_id::Int)
|
|||||||
return nothing
|
return nothing
|
||||||
end
|
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)
|
worker_loop(worker_id, cfg, queue, handler)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ using JSON3
|
|||||||
# directly rather than only through the HTTP surface.
|
# directly rather than only through the HTTP surface.
|
||||||
using FileServer: Job, Config, ChannelQueue, enqueue!, dequeue!, length,
|
using FileServer: Job, Config, ChannelQueue, enqueue!, dequeue!, length,
|
||||||
sanitize_filename, recover_dir!, normalize_metadata,
|
sanitize_filename, recover_dir!, normalize_metadata,
|
||||||
build_metadata, finalize_known!, run_exiftool
|
build_metadata, finalize_known!, run_exiftool,
|
||||||
|
is_binary, handle_unknown_job
|
||||||
|
|
||||||
# A minimal, valid 1×1 PNG. Lets the real-exiftool tests assert stable facts
|
# A minimal, valid 1×1 PNG. Lets the real-exiftool tests assert stable facts
|
||||||
# (FileType == "PNG", 1×1 dimensions) that don't drift across exiftool versions.
|
# (FileType == "PNG", 1×1 dimensions) that don't drift across exiftool versions.
|
||||||
@@ -21,6 +22,8 @@ function tmp_config(root; kwargs...)
|
|||||||
spool_dir = joinpath(root, "spool"),
|
spool_dir = joinpath(root, "spool"),
|
||||||
known_dir = joinpath(root, "known"),
|
known_dir = joinpath(root, "known"),
|
||||||
unknown_dir = joinpath(root, "unknown"),
|
unknown_dir = joinpath(root, "unknown"),
|
||||||
|
binary_dir = joinpath(root, "binary"),
|
||||||
|
text_dir = joinpath(root, "text"),
|
||||||
done_dir = joinpath(root, "done"),
|
done_dir = joinpath(root, "done"),
|
||||||
failed_dir = joinpath(root, "failed"),
|
failed_dir = joinpath(root, "failed"),
|
||||||
kwargs...,
|
kwargs...,
|
||||||
@@ -138,6 +141,52 @@ end
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@testset "is_binary: NUL-byte sniff" begin
|
||||||
|
mktempdir() do root
|
||||||
|
# Plain text → text.
|
||||||
|
txt = joinpath(root, "notes.txt")
|
||||||
|
write(txt, "hello, world\nsecond line\n")
|
||||||
|
@test is_binary(txt) == false
|
||||||
|
|
||||||
|
# A NUL byte anywhere in the sniff window → binary.
|
||||||
|
bin = joinpath(root, "blob.dat")
|
||||||
|
write(bin, UInt8[0x01, 0x02, 0x00, 0x03])
|
||||||
|
@test is_binary(bin) == true
|
||||||
|
|
||||||
|
# Empty file has no NUL → treated as text.
|
||||||
|
empty = joinpath(root, "empty")
|
||||||
|
write(empty, UInt8[])
|
||||||
|
@test is_binary(empty) == false
|
||||||
|
|
||||||
|
# A NUL past the sniff window is not seen → still text.
|
||||||
|
far = joinpath(root, "far.txt")
|
||||||
|
write(far, vcat(fill(UInt8('a'), FileServer.CONTENT_SNIFF_BYTES), UInt8[0x00]))
|
||||||
|
@test is_binary(far) == false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@testset "handle_unknown_job: routes to binary/ and text/" begin
|
||||||
|
mktempdir() do root
|
||||||
|
cfg = tmp_config(root)
|
||||||
|
|
||||||
|
# A binary file (embedded NUL) lands in binary/.
|
||||||
|
bpath = joinpath(cfg.unknown_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)
|
||||||
|
@test isfile(joinpath(cfg.binary_dir, "id-b-blob.dat"))
|
||||||
|
@test !isfile(bpath)
|
||||||
|
|
||||||
|
# A text file lands in text/.
|
||||||
|
tpath = joinpath(cfg.unknown_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)
|
||||||
|
@test isfile(joinpath(cfg.text_dir, "id-t-notes.log"))
|
||||||
|
@test !isfile(tpath)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@testset "recover_dir!: re-enqueues work, skips sidecars" begin
|
@testset "recover_dir!: re-enqueues work, skips sidecars" begin
|
||||||
mktempdir() do root
|
mktempdir() do root
|
||||||
dir = joinpath(root, "known"); mkpath(dir)
|
dir = joinpath(root, "known"); mkpath(dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user