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:
127
README.md
127
README.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user