Feature Specification: Scheduler & Pipeline Assembly
Feature Branch: 007-scheduler-pipeline
Created: 2026-05-29
Status: Brownfield (documents existing behaviour)
Input: The scheduling entry point and pipeline assembly capability. Covers the run-once CLI entry point with startup jitter, signal handling, hard timeout scaffolding, and observability teardown, as well as the dependency injection/composition root that wires the full scrape-to-notify pipeline.
User Scenarios & Testing (mandatory)
User Story 1 - Scheduled Pipeline Run (Priority: P1)
An operator (or external scheduler such as Railway cron) invokes the scraper entry point. The system validates required configuration, applies a random startup delay to avoid synchronized load on upstream services, assembles the full collection pipeline, runs it to completion, and records observability data before exiting.
Why this priority: This is the core execution path — everything else exists to support or observe this run.
Independent Test: Can be fully tested by invoking the entry point with a valid DATABASE_URL and verifying the pipeline executes end-to-end, logs carry a correlation ID, and observability metrics are emitted.
Acceptance Scenarios:
- Given
DATABASE_URLis set and at least one scraper source is due, When the entry point is invoked, Then the pipeline discovers, fetches, processes, and analyzes articles, publishes aPipelineCompletedEvent, and exits with code 0. - Given
DATABASE_URLis set and no scraper sources are due, When the entry point is invoked, Then the pipeline publishesPipelineCompletedEventimmediately with zero articles and exits cleanly. - Given
DATABASE_URLis NOT set, When the entry point is invoked, Then the system raises an error before any pipeline assembly begins. - Given
RUN_IMMEDIATELYis NOT set, When the entry point is invoked, Then the system sleeps for a random duration between 0 and 180 seconds before proceeding. - Given
RUN_IMMEDIATELYis set to any value, When the entry point is invoked, Then the system skips the startup jitter sleep entirely.
User Story 2 - Pipeline Assembly & Event Wiring (Priority: P2)
The composition root assembles all repositories, the in-process event bus, LLM services with rate limiting, use cases, and event handler subscriptions so that a scraped article flows through the full pipeline: scrape → dedup → process → analyze → normalize tags → translate → notify.
Why this priority: The wiring defines the entire data flow. Without correct assembly, no downstream capability works.
Independent Test: Can be tested by verifying that build_collection_pipeline() returns a pipeline whose event bus has the expected handler subscriptions and that calling pipeline.run() triggers the full chain when articles are discovered.
Acceptance Scenarios:
- Given the database has active LLM providers configured, When
build_collection_pipeline()is called, Then it returns aCollectionPipelinewith aResilientLLMServicecontaining all active providers sorted by priority, each paired with a rate-limit strategy. - Given no active LLM providers exist in the database, When
build_collection_pipeline()is called, Then it raises aValueError. - Given the pipeline is assembled, When an
ArticleScrapedEventis published, Then the event bus dispatches it toArticleScrapedHandler, which processes the article and publishesArticleProcessedEvent. - Given an article has been analyzed, When
AnalysisCompletedEventis published, Then tag normalization runs, and upon completion, translation is triggered for each configured language. - Given any step in the pipeline fails (analysis, tag normalization, or translation), When the corresponding failed event is published, Then
FailedTaskPersistenceHandlerpersists the failure as aFailedTaskentity.
User Story 3 - Due Source Selection (Priority: P2)
The pipeline determines which scraper sources are due for a run by checking their active status and whether enough time has elapsed since their last scrape, applying a tolerance window.
Why this priority: Source selection gates all pipeline activity — if no sources are due, the pipeline does no work.
Independent Test: Can be tested by setting up scraper sources with various last_scraped_at values and frequencies, then verifying that get_active_due() returns only the expected sources.
Acceptance Scenarios:
- Given a scraper source is active and has never been scraped, When the pipeline checks for due sources, Then that source is included.
- Given a scraper source is active and was last scraped 5 hours ago with a 4-hour frequency, When the pipeline checks for due sources, Then that source is included (elapsed time exceeds frequency minus 30-minute tolerance).
- Given a scraper source is active and was last scraped 3.5 hours ago with a 4-hour frequency, When the pipeline checks for due sources, Then that source is NOT included (3.5h < 4h − 30min = 3.5h boundary; effectively excluded since elapsed must exceed the adjusted interval).
- Given a scraper source is NOT active, When the pipeline checks for due sources, Then that source is excluded regardless of its last-scraped time.
User Story 4 - Run Observability & Teardown (Priority: P3)
Each pipeline run emits structured log entries with a correlation ID, increments OpenTelemetry counters, records execution duration, and flushes all observability data before the process exits.
Why this priority: Observability is essential for production monitoring but does not affect the functional outcome of the pipeline.
Independent Test: Can be tested by running the entry point and verifying that log output contains run_id and correlation_id fields, the SCRAPER_RUNS counter increments, and the SCRAPER_DURATION histogram records a value.
Acceptance Scenarios:
- Given the entry point starts, When the run context is initialized, Then a unique
run_idandcorrelation_idare generated and bound to all subsequent log entries. - Given
SENTRY_DSNis set, When the module is imported, Then Sentry is initialized with a trace sample rate of 10%. - Given
SENTRY_DSNis NOT set, When the module is imported, Then Sentry is not initialized. - Given a pipeline run completes (successfully or with error), When the finally block executes, Then OTel metrics are flushed via
push_metrics()and traces are flushed viashutdown_tracing(). - Given a pipeline run completes, When the finally block executes, Then the
SCRAPER_DURATIONhistogram records the elapsed wall-clock time of the run.
User Story 5 - Signal Handling & Hard Timeout (Priority: P3)
The system registers handlers for SIGTERM and SIGINT that log the signal and set a shutdown flag, and defines a 50-minute timeout check function, but neither mechanism currently interrupts pipeline execution.
Why this priority: These are scaffolding for future graceful shutdown. Documenting the current state (signals logged but not acted upon, timeout defined but not enforced) is important for brownfield accuracy.
Independent Test: Can be tested by sending SIGTERM to a running process and verifying that the signal handler logs a warning, and by calling check_timeout() with various elapsed times.
Acceptance Scenarios:
- Given the entry point is running, When SIGTERM or SIGINT is received, Then the signal handler logs
shutdown_signal_receivedwith the signal number and sets the_shutdown_requestedflag toTrue. - Given the
_shutdown_requestedflag is set, When the pipeline is running, Then the pipeline continues to completion (the flag is not checked by any pipeline component). - Given
check_timeout()is called with an elapsed time of 3000 seconds or more, Then it returnsTrue. - Given
check_timeout()is called with an elapsed time less than 3000 seconds, Then it returnsFalse. - Given the entry point is running, When 50 minutes have elapsed, Then the pipeline is NOT interrupted by the timeout mechanism (the function exists but is never called in the runtime path).
User Story 6 - Standalone Translation Pipeline (Priority: P4)
A lighter assembly path allows running translation independently of the collection pipeline, enabling re-translation of existing analyses without re-scraping.
Why this priority: This is a secondary entry point used for maintenance/backfill, not the primary execution path.
Independent Test: Can be tested by calling build_translation_pipeline() and verifying it returns a dict containing the translation use case and required repositories.
Acceptance Scenarios:
- Given the database has existing analyses, When
build_translation_pipeline()is called, Then it returns a dict withuse_case,tag_use_case,session,analyses_translation_repository, andtag_translation_repositorykeys. - Given a standalone translation run is invoked, When it completes, Then translations are persisted for each configured language without triggering scraping or analysis.
Edge Cases
- What happens when the OTel push fails during teardown? → The
push_metrics()andshutdown_tracing()calls are wrapped in try/except, so a flush failure does not prevent process exit. - What happens when
DATABASE_URLpoints to an unreachable database? → The pipeline will fail duringinit_db()orget_session()with a connection error; no special retry logic exists. - What happens when all LLM providers' API keys are missing from the environment? →
build_llm_service()reads each provider'sapi_key_envand looks up the corresponding environment variable; if the key is missing, the provider may fail on first use, andResilientLLMServicewill fall back to the next provider. - What happens when the startup jitter sleep is interrupted by a signal? → The
time.sleep()call is not wrapped in signal-aware logic; the signal handler will set the flag, but the sleep will not be interrupted early. - What happens when the same source is scraped twice in quick succession? →
mark_scraped()setslast_scraped_at, and the nextget_active_due()call will exclude it until the frequency interval (minus tolerance) has elapsed. - What happens when
InMemoryEventBushandler raises an exception? → The event bus propagates the exception synchronously, which can halt the pipeline run. There is no dead-letter or retry mechanism at the event bus level.
Requirements (mandatory)
Functional Requirements
- FR-001: The system MUST validate that
DATABASE_URLis set before any pipeline assembly begins. - FR-002: The system MUST apply a random startup delay between 0 and 180 seconds unless the
RUN_IMMEDIATELYenvironment variable is set. - FR-003: The system MUST generate a unique
run_idandcorrelation_idfor each execution and bind them to all structured log output. - FR-004: The system MUST assemble the full collection pipeline with all repositories, the in-process event bus, LLM services, use cases, and event handler subscriptions via the composition root.
- FR-005: The system MUST select only scraper sources that are active and due (never scraped or frequency interval elapsed minus 30-minute tolerance).
- FR-006: The system MUST execute the pipeline as a single run and exit — there is no in-process scheduler or loop.
- FR-007: The system MUST register signal handlers for SIGTERM and SIGINT that log the received signal.
- FR-008: The system MUST flush OpenTelemetry metrics and traces in a finally block after pipeline execution, regardless of success or failure.
- FR-009: The system MUST record the wall-clock duration of each run in an observability histogram.
- FR-010: The system MUST increment a
SCRAPER_RUNScounter at the start of each execution. - FR-011: The system MUST initialize Sentry at import time if
SENTRY_DSNis set, with a trace sample rate of 10%. - FR-012: The system MUST raise an error if no active LLM providers are configured in the database.
- FR-013: The system MUST persist failed tasks (analysis failure, tag normalization failure, translation failure, discover failure) via
FailedTaskPersistenceHandler. - FR-014: The system MUST provide a standalone translation pipeline assembly that returns a dict with use cases and repositories, independent of the collection pipeline.
- FR-015: The system MUST use a single database session (NullPool) for the entire pipeline lifecycle.
Key Entities
- Run Context: The execution context for a single pipeline invocation, carrying a
run_idandcorrelation_idbound to logs and traces. - ScraperSetting (Due Source): A configured scraper source with an active flag, frequency interval, and last-scraped timestamp. Due sources are selected by the pipeline before discovery.
- CollectionPipeline: The orchestrator that discovers due sources, runs the scrape executor, publishes events, and marks sources as scraped.
- InMemoryEventBus: Synchronous in-process event bus that dispatches domain events to registered handlers, forming the pipeline chain.
- ResilientLLMService: LLM service that holds an ordered list of provider-rate-limit pairs and falls back on exhaustion or error.
- FailedTask: A persisted record of a pipeline step failure (analysis, tag normalization, translation, or discover) for later retry.
Success Criteria (mandatory)
Measurable Outcomes
- SC-001: Each pipeline run produces log output containing a unique
correlation_idthat is consistent across all log entries for that run. - SC-002: The pipeline only scrapes sources that meet the due criteria (active + interval elapsed with tolerance), never re-scraping a source prematurely.
- SC-003: Every pipeline run emits the
SCRAPER_RUNScounter increment andSCRAPER_DURATIONhistogram recording, regardless of whether articles are found. - SC-004: All observability data (metrics and traces) is flushed before the process exits, even when the pipeline encounters an error.
- SC-005: A run with
RUN_IMMEDIATELYset bypasses the startup jitter entirely, completing the configuration-to-execution transition without delay. - SC-006: When no sources are due, the pipeline completes in under 5 seconds (no discovery, fetch, or processing overhead).
- SC-007: Failed tasks are persisted so that at least 95% of pipeline errors are recoverable via the retry mechanism.
Assumptions
- The system relies on external scheduling (Railway cron, Kubernetes CronJob, system cron, or manual invocation) to trigger runs — there is no in-process scheduler.
- The startup jitter exists primarily to avoid synchronized load on arXiv's API at the top of the hour; other upstream services benefit incidentally.
- The 50-minute hard timeout (
check_timeout()) and the_shutdown_requestedsignal flag are scaffolding for future graceful shutdown and are currently not enforced at runtime. This spec documents the current state. - The single-session strategy (one SQLAlchemy session for the entire pipeline) means there is no per-article transaction isolation; a failure mid-pipeline does not roll back earlier work.
- The composition root reads LLM provider configuration from the database at assembly time; changing provider configuration requires a new pipeline run to take effect.
- The
InMemoryEventBusis synchronous — all handlers execute in the same process and thread. Handler exceptions propagate and can halt the pipeline. - Sentry initialization happens at module import time, not at pipeline run time; it applies to the entire process lifetime.