Add stage-2 metadata enrichment pipeline for known files

Known-classified files now flow to a second queue with its own worker pool
that extracts metadata via exiftool and writes a normalized JSON sidecar
next to the file in done/, leaving the original bytes untouched.

- Two-stage pipeline: spool/ → classify → known/ → enrich → done/;
  unknowns park in unknown/ as a seam for a future pool
- src/metadata.jl: exiftool -json -G -n with timeout, normalized schema
  (file_type, mime_type, author, created_by, dimensions, ...) + raw dump;
  degraded sidecar on extraction failure rather than quarantine
- Sidecar-first commit so a file in done/ always has its sidecar
- Parametrized worker_loop with classify/enrich handlers; blocking
  backpressure on a full known queue (never drop a classified file)
- Stage-aware recovery: spool/ and known/ resume at their correct stage
- Ordered drain: close stage-1 and wait its workers (the known queue's
  only producer) before closing the known queue
- exiftool required at startup (fail-fast); new FS_KNOWN_*/FS_UNKNOWN_DIR/
  FS_EXIFTOOL_TIMEOUT config knobs; combined-pool thread warning
This commit is contained in:
2026-07-02 16:29:08 -04:00
parent 842668b2ac
commit 1c7d7d6cad
6 changed files with 416 additions and 83 deletions

127
README.md
View File

@@ -11,6 +11,10 @@ classifier that labels it **known** (a file type resembling the training set) or
## Architecture
The pipeline is two stages, each with its own bounded queue and its own worker
pool (tuned independently, since classification is CPU-bound and enrichment is
process-/IO-bound):
```
POST /upload (multipart)
@@ -21,33 +25,92 @@ classifier that labels it **known** (a file type resembling the training set) or
└────────┬─────────┘ enqueue reference (non-blocking)
│ │
▼ ▼
202 + job IDs ┌───────────────┐
(503 if full) │ work queue bounded, thread-safe
│ (Channel-ish)│
└───────┬───────┘
│ dequeue
┌───────────────┼───────────────┐
▼ ▼
worker 1 worker 2 … worker N (Threads.@spawn)
success ────┴──► data/done/<uuid>-<name>
failure ───────► data/failed/<uuid>-<name>
202 + job IDs ┌────────────────────┐
(503 if full) │ stage-1 queue │ classification
└─────────┬──────────┘
│ dequeue
┌───────────────────┼───────────────────┐
▼ ▼
classify wkr 1 classify wkr 2 classify wkr N
┌────────────┴────────────┐
:unknown :known
│ move to data/known/, then
▼ ▼ enqueue (blocking backpressure)
data/unknown/<uuid>-<name> ┌────────────────────┐
(parked; future pipeline) │ known queue │ enrichment
└─────────┬──────────┘
│ dequeue
┌───────────────────┼───────────────────┐
▼ ▼ ▼
known wkr 1 known wkr 2 … known wkr M
│ exiftool → normalized sidecar
success ────┴──► data/done/<uuid>-<name>
data/done/<uuid>-<name>.meta.json (sidecar-first commit)
failure ───────► data/failed/<uuid>-<name>
```
Key properties:
- **Fast intake:** the queue only ever carries small references; file bytes live
on disk, so memory stays flat regardless of file size.
- **Backpressure:** the queue is bounded (default 1000). When full, uploads get
`503 Service Unavailable` instead of silently piling up.
- **Crash-resilient:** files survive on disk. On startup, anything left in
`data/spool/` is re-enqueued (`recovered = N` in the log).
- **Backpressure:** each queue is bounded (default 1000). When the *intake* queue
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).
- **Crash-resilient:** files survive on disk. On startup, recovery is
stage-aware: leftovers in `data/spool/` re-enter classification and leftovers
in `data/known/` re-enter enrichment (`recovered` / `recovered_known` 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`)
both stop accepting uploads, drain the queue, wait for in-flight files to
finish, then exit. (See "Shutdown" below for one cosmetic caveat on SIGTERM.)
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
queue) before closing the known queue and waiting out the enrich workers.
(See "Shutdown" below for one cosmetic caveat on SIGTERM.)
- **Safe filenames:** client-supplied names are sanitized and prefixed with a
server-minted UUID before touching the filesystem (no path traversal).
### Metadata enrichment (stage 2)
Files the classifier labels **known** are handed to a second pool that extracts
metadata with [`exiftool`](https://exiftool.org/) (`exiftool -json -G -n`) —
chosen because no native Julia library comes close to its multi-format coverage.
The output is normalized into a small, stable, documented schema and written as a
JSON **sidecar** next to the file in `data/done/`, e.g.
`data/done/<uuid>-<name>.meta.json`. The original bytes are never modified.
> **Prerequisite:** `exiftool` must be on `PATH` (e.g. `apt install
> libimage-exiftool-perl`). The server **fails fast at startup** if it's missing.
Sidecar top-level fields (all nullable — present only when available), plus the
complete raw `exiftool` object under `raw`:
| Field | Meaning |
|---|---|
| `id`, `original_name` | job id and client-supplied name |
| `file_type`, `mime_type` | e.g. `PDF` / `application/pdf` |
| `file_size` | bytes (authoritative, from intake — not exiftool) |
| `created_date`, `modified_date` | content timestamps |
| `author` | person (`Author`/`Artist`/`By-line`) |
| `created_by` | authoring app/tool (`Producer`/`CreatorTool`/`Creator`/`Software`/…) |
| `dimensions` | `{width, height}` for media |
| `duration` | seconds, for audio/video |
| `page_count` | for documents |
| `error` | set on a *degraded* sidecar (see below) |
| `raw` | full `exiftool` output |
Each normalized field is a coalesce over a priority list of exiftool tags
(`src/metadata.jl`); extend a field by appending tag names. If extraction fails
or `exiftool` times out (`FS_EXIFTOOL_TIMEOUT`, default 30s), the file still
completes to `data/done/` with a **degraded sidecar**`file_size`/`file_type`
plus an `error` note — rather than being quarantined, because it's still a wanted
known file. Only genuine I/O errors (can't write the sidecar or move the file)
send it to `data/failed/`.
The sidecar is committed **before** the file is moved into `data/done/`, so a
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
idempotently.
## The queue seam (→ RabbitMQ later)
The HTTP handler and workers only ever call `enqueue!`, `dequeue!`, and
@@ -130,18 +193,23 @@ init, so the artifact is exactly regenerable from the same inputs.
| Variable | Default | Meaning |
|---------------------|----------------|------------------------------------------|
| `FS_HOST` | `127.0.0.1` | Bind address |
| `FS_PORT` | `8080` | Port |
| `FS_WORKERS` | `nthreads()` | Number of worker tasks |
| `FS_QUEUE_CAPACITY` | `1000` | Max pending jobs before `503` |
| `FS_SPOOL_DIR` | `data/spool` | Incoming files (pending) |
| `FS_DONE_DIR` | `data/done` | Files after successful processing |
| `FS_FAILED_DIR` | `data/failed` | Files whose processing threw |
| `FS_MODEL_PATH` | `model/classifier.jld2` | Classifier artifact loaded at startup |
| `FS_HOST` | `127.0.0.1` | Bind address |
| `FS_PORT` | `8080` | Port |
| `FS_WORKERS` | `nthreads()` | Stage-1 (classification) worker tasks |
| `FS_QUEUE_CAPACITY` | `1000` | Max pending intake jobs before `503` |
| `FS_KNOWN_WORKERS` | `nthreads()` | Stage-2 (enrichment) worker tasks |
| `FS_KNOWN_QUEUE_CAPACITY` | `1000` | Max pending enrichment jobs (then backpressure) |
| `FS_SPOOL_DIR` | `data/spool` | Incoming files (pending classification) |
| `FS_KNOWN_DIR` | `data/known` | Classified-known, awaiting enrichment |
| `FS_UNKNOWN_DIR` | `data/unknown` | Classified-unknown, parked for a future pool |
| `FS_DONE_DIR` | `data/done` | Enriched known files (+ `.meta.json`) |
| `FS_FAILED_DIR` | `data/failed` | Files whose processing threw |
| `FS_MODEL_PATH` | `model/classifier.jld2` | Classifier artifact loaded at startup |
| `FS_EXIFTOOL_TIMEOUT` | `30` | Seconds before a stuck exiftool is killed |
> To get real parallelism, start Julia with enough threads (`-t N`) to match
> `FS_WORKERS`. If `FS_WORKERS` exceeds available threads you'll get a warning
> and workers will share threads.
> To get real parallelism, start Julia with enough threads (`-t N`) to cover both
> pools. If `FS_WORKERS + FS_KNOWN_WORKERS` exceeds available threads you'll get a
> warning (non-fatal) and workers will share threads.
## Usage
@@ -173,7 +241,8 @@ src/
spool.jl filename sanitizing, spool/move, startup recovery
model.jl NN architecture + byte→feature mapping (shared with trainer)
classify.jl load artifact + classify a file at inference time
worker.jl worker loop + per-job processing (classify + move)
metadata.jl exiftool extraction + normalized sidecar (stage 2)
worker.jl parametrized worker loop + classify/enrich handlers
server.jl HTTP routes/handlers
bin/
server.jl entry point