Files
file-server/src/language.jl
Jeffrey Ward 4a22123001 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.
2026-08-07 13:21:49 -04:00

156 lines
6.3 KiB
Julia

# Stage-4 language enrichment for text files.
#
# A file that stage-3 sorted as text is human-readable, but we don't yet know
# *what* it is. This stage answers two questions and records them in a
# `.meta.json` sidecar, exactly like the stage-2 known-file enrichment:
#
# * natural language — via Languages.jl's `LanguageDetector` (a Julia port of
# the `whatlang` n-gram model): English vs. French vs. Japanese, plus a
# confidence score. Pure Julia, no subprocess.
# * programming language — via the `github-linguist` CLI, which recognizes
# source and markup by extension + content heuristics. There is no
# comparable native Julia library, so we shell out (mirroring stage-2's
# exiftool dependency).
#
# Neither detector failing quarantines the file: a text file is wanted whether
# or not we can name its language, so a failure yields a *degraded* sidecar
# (what we know plus an `error` note), just like stage 2.
#
# The github-linguist quirk that shapes this code: run against a path *inside* a
# git repository, linguist reads the file's committed git blob, not the bytes on
# disk — and an untracked file (which every file under `data/` is) has no blob,
# so it crashes. We sidestep this by copying the file to a fresh temp dir outside
# any repo (preserving its name so linguist's extension heuristics still fire)
# and pointing linguist there.
# How much of a text file to feed the natural-language detector. The whatlang
# model saturates quickly, so a bounded prefix keeps memory flat on huge logs
# while still giving the detector plenty of signal.
const LANG_SAMPLE_BYTES = 65_536
"Return true if the `github-linguist` binary is on PATH."
function linguist_available()
try
Base.run(pipeline(`github-linguist --version`; stdout=devnull, stderr=devnull))
return true
catch
return false
end
end
"""
read_text_sample(path) -> String
Read up to `LANG_SAMPLE_BYTES` of `path` as UTF-8 text, trimming a multi-byte
character the window may have cut in half (reusing stage-3's `trim_truncated_utf8`)
so the tail isn't misread as garbage.
"""
function read_text_sample(path::AbstractString)::String
open(path, "r") do io
chunk = read(io, LANG_SAMPLE_BYTES)
return String(copy(trim_truncated_utf8(chunk)))
end
end
"""
detect_natural_language(detector, text) -> (name, code, confidence)
Run the `LanguageDetector` on `text`, returning the language's English name
(e.g. `"English"`), its ISO 639-3 code (e.g. `"eng"`), and the model's
confidence in `[0,1]`. Returns `(nothing, nothing, nothing)` when there is no
usable text (empty/whitespace) or the detector errors — the caller records that
as a degraded result rather than failing the file.
"""
function detect_natural_language(detector, text::AbstractString)
isempty(strip(text)) && return (nothing, nothing, nothing)
try
lang, _script, confidence = detector(text)
return (Languages.english_name(lang), Languages.isocode(lang), confidence)
catch
return (nothing, nothing, nothing)
end
end
"""
run_linguist(path, timeout) -> Union{String,Nothing}
Ask `github-linguist --json` for the programming/markup language of the file at
`path`, returning the language name (e.g. `"Python"`, `"Markdown"`) or `nothing`
when linguist can't identify one. Plain prose reports as `"Text"` and
unrecognized content as JSON `null`; both collapse to `nothing` here, since only
a real programming/markup language is worth recording.
`path` MUST be outside any git repository — see the module header for why.
"""
function run_linguist(path::AbstractString, timeout::Integer)
bytes = run_with_timeout(`github-linguist --json $path`, timeout)
bytes === nothing && return nothing
parsed = try
JSON3.read(String(bytes))
catch
return nothing
end
# linguist --json emits a single object keyed by the file path; pull the one
# entry rather than depend on the exact key spelling.
isempty(parsed) && return nothing
entry = first(values(parsed))
lang = get(entry, :language, nothing)
(lang === nothing || lang == "Text") && return nothing
return String(lang)
end
"""
detect_programming_language(job, cfg) -> Union{String,Nothing}
Programming/markup language of a text file, or `nothing`. Copies the file to a
throwaway temp dir *outside* the git repo — under its sanitized original name so
linguist's extension heuristics still apply — runs linguist there, and cleans up.
"""
function detect_programming_language(job::Job, cfg::Config)
lang = nothing
mktempdir() do dir # tempdir() → /tmp, outside the repo
safe = sanitize_filename(job.original_name)
tmp = joinpath(dir, safe)
cp(job.path, tmp; force=true)
lang = run_linguist(tmp, cfg.linguist_timeout)
end
return lang
end
"""
build_text_metadata(detector, job, cfg) -> NamedTuple
Build the stage-4 sidecar payload for a text file: its natural language (name +
ISO code + confidence) and programming/markup language, plus the Job's
authoritative id/name/size. `error` is set only when natural-language detection
produced nothing usable (the file is still enriched and committed); programming
language is best-effort and its absence is normal, not an error.
"""
function build_text_metadata(detector, job::Job, cfg::Config)
text = read_text_sample(job.path)
name, code, confidence = detect_natural_language(detector, text)
programming_language = detect_programming_language(job, cfg)
return (
id = job.id,
original_name = job.original_name,
file_size = job.size, # authoritative, from intake
content_type = "text",
language = name,
language_code = code,
language_confidence = confidence,
programming_language = programming_language,
error = name === nothing ? "language detection produced no result" : nothing,
)
end
"""
finalize_text!(cfg, job, meta) -> (file_dest, sidecar_dest)
Commit an enriched text file (stage 4) to `text_done/` via the shared
sidecar-first `commit_enriched!`, giving text files the same crash-safe
"file implies sidecar" guarantee as stage-2 known files.
"""
finalize_text!(cfg::Config, job::Job, meta) = commit_enriched!(cfg.text_done_dir, job, meta)