Skip to content

Components and layering

flowchart TB
    CLI["crawl-cli (composition root)"]
    APP["crawl-application"]
    DOMAIN["crawl-domain"]
    PROTO["crawl-protocol"]
    INFRA["crawl-infrastructure"]
    PY["Python worker"]

    CLI --> APP
    CLI --> INFRA
    APP --> DOMAIN
    APP --> PROTO
    INFRA --> APP
    INFRA --> DOMAIN
    INFRA --> PROTO
    INFRA <-->|versioned JSONL| PY

The dependency rule points inward. crawl-domain knows nothing of the CLI, Tokio, subprocesses, CSV, or the registry.

Ports and adapters

Port (application) Adapter (infrastructure)
PluginRegistry FileRegistry - one atomic JSON document
DirectoryWalker FilesystemWalker - ignore walker on the blocking pool
WorkerFactory / WorkerHandle ProcessWorkerFactory - Tokio child processes
ReportWriter CsvReportWriter - temp-then-promote streaming CSV
EventSink TracingEventSink - structured tracing events

Async boundary

Async is confined to channels, subprocess I/O, and traversal. Every decision - manifest validation, schema rules, retry eligibility, threshold arithmetic, state transitions, configuration precedence—is an ordinary synchronous function, and therefore unit-testable without a runtime.

Pipeline invariants

The coordinator holds four invariants structurally rather than by convention:

active_workers          <= configured_workers   # one task per slot, never cloned
queued_tasks            <= queue_capacity       # bounded channel, single producer
active_request_per_worker <= 1                  # a slot owns its handle exclusively
attempts                <= 1 + max_retries      # retries happen in place

Retrying in place—inside the slot that already owns the task—rather than re-queueing is deliberate: a retry that re-entered a full bounded queue could deadlock against the workers meant to drain it.