Files
10Backward/docs/architecture/pipeline-runner.md
T
2026-08-04 18:35:22 +00:00

5.3 KiB

type, title, description, tags, timestamp
type title description tags timestamp
architecture Pipeline Runner How tenbackward.main synchronizes the blog, discovers anniversary candidates, applies deduplication, retries failures, and emits a run summary.
pipeline
runner
retry
2026-08-04T17:51:00Z

Purpose

tenbackward.main is the entry point executed by cron. The pipeline first ensures that the configured blog repository is available and current, then scans Jekyll posts for today's ten-year anniversary, filters already-recorded paths, and persists newly discovered identifiers. Job 1086 wires the anniversary matcher into the runner; Mastodon publishing remains a future step.

Call Flow

main.main()
  ├── configure_json_logging()
  ├── load_config()  ── raises ConfigError → exit 2
  ├── config.data_dir.mkdir(parents=True, exist_ok=True)
  ├── ensure_repo(config.data_dir, blog_repo_url, blog_dir, max_retries)
  │       └── BlogRepoError → log blog_repo_error → exit 1
  ├── log_startup(version, site_url, run_at, tz, hashtags, throwback_prefix)
  ├── result = _run_with_retry(config)
  │       ├── for attempt in 1 .. max_retries+1:
  │       │     try: return _run_once(config)
  │       │     └── _run_once → ensure_repo → iter_anniversary_paths
  │       │         → PostedStore deduplication → mark_posted_many
  │       ├── log_error("pipeline_error", exc=exc, attempt=attempt, max_attempts=attempts)
  │       ├── log_error("pipeline_failed", exc=last_exc, attempts=attempts)
  │       └── return None
  ├── if result is None: return 1
  └── log_run_summary(scanned, matched, posted, skipped, posted_ids)

Extension Point: _iter_candidates

def _iter_candidates(config: Config) -> Iterable[str]: ...

Job 1086 wires _iter_candidates to the anniversary matcher. The matcher is now active; it discovers matching Jekyll posts after the repository is synchronized.

Blog Synchronization

ensure_repo() runs before candidate discovery. An empty target is cloned from BLOG_REPO_URL; an existing repository is fetched and fast-forwarded. Transient Git failures use exponential backoff, while local modifications fail immediately with BlogRepoError. The same synchronization occurs once in main() before startup and again inside _run_once() as the retryable pipeline boundary.

_iter_candidates(config) delegates to iter_anniversary_paths() with config.blog_dir / "_posts" / "blog" and config.site_url. The matcher returns relative Markdown paths, such as 2016/2016-08-04-example.md, which are used as stable deduplication IDs. See Anniversary Matching for the file and front matter rules.

_run_once — Summary Counters

_run_once returns a 5-tuple:

(scanned, matched, posted, skipped, posted_ids)
Counter Meaning
scanned Items yielded by _iter_candidates.
matched Items that survived the dedupe-vs-already_posted check.
posted Items newly recorded in posted.json during this run.
skipped Items that matched but were already in posted.json (deduped).
posted_ids The list of post identifiers written to state.

PostedStore.mark_posted_many is called only when posted_ids is non-empty, so a no-op run does not touch posted.json on disk. The store persists the list under a "posted" key ({"posted": ["2014/2014-08-04-foo.md", ...]}) and serialises concurrent runs with an fcntl.flock.

Retry Behaviour (_run_with_retry)

  • attempts = max(1, config.max_retries + 1) — at least one attempt even if MAX_RETRIES=0.
  • Any exception inside _run_once is caught (noqa: BLE001 — intentional, the whole pass is opaque to the runner).
  • Each failed attempt is recorded via log_error("pipeline_error", exc=exc, attempt=attempt, max_attempts=attempts).
  • When the budget is exhausted, the runner emits log_error("pipeline_failed", exc=last_exc, attempts=attempts) and returns None so main can translate it to exit 1.

Silent-on-No-Matches Contract

  • run_complete is emitted for a successful pass, including when all discovered candidates were already posted.
  • A run with zero candidates emits only startup; no run_complete line is emitted.

Example Sequence (Healthy Run)

  1. startup line at INFO.
  2. _run_with_retry returns (0, 0, 0, 0, []) (no candidates yet because _iter_candidates is empty) → main exits 0 without a run_complete line.
  3. Next-day tick repeats the cycle.

Example Sequence (Future Blog Step in Place)

  1. startup line at INFO.
  2. _iter_candidates yields [id-1, id-2]. id-1 is already in posted.json, so skipped += 1. id-2 is new and appended to posted_ids and persisted.
  3. run_complete line at INFO with scanned=2, matched=2, posted=1, skipped=1, posted_ids=["id-2"].

Related