138 lines
6.1 KiB
Markdown
138 lines
6.1 KiB
Markdown
---
|
|
type: architecture
|
|
title: Pipeline Runner
|
|
description: How tenbackward.main synchronizes the blog, discovers anniversary candidates, applies deduplication, retries failures, and emits a run summary.
|
|
tags: [pipeline, runner, retry]
|
|
timestamp: 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, publishes a single combined Mastodon status for the new matches, and
|
|
finally persists the freshly published identifiers.
|
|
|
|
|
|
# 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 → find_anniversary_matches
|
|
│ │ → PostedStore deduplication → publish_mastodon
|
|
│ │ → mark_posted_many # state only after publish OK
|
|
│ ├── 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`
|
|
|
|
```python
|
|
def _iter_candidates(config: Config) -> Iterable[MatchedPost]: ...
|
|
```
|
|
|
|
`_iter_candidates` delegates to `find_anniversary_matches()` with
|
|
`config.blog_dir / "_posts" / "blog"` and `config.site_url`. The matcher
|
|
returns full `MatchedPost` values (relative path, title, date, canonical
|
|
URL); the runner uses `MatchedPost.path` as a stable deduplication ID and
|
|
passes the full objects to `publish_mastodon()` for status composition.
|
|
See [Anniversary Matching](/architecture/anniversary-matching.md) for the
|
|
file and front matter rules.
|
|
|
|
## 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.
|
|
|
|
# `_run_once` — Summary Counters
|
|
|
|
`_run_once` returns a 5-tuple:
|
|
|
|
```python
|
|
(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`.
|
|
|
|
# Publish Boundary
|
|
|
|
`publish_mastodon()` is the single success boundary. The runner calls it
|
|
**after** dedupe and **before** `mark_posted_many`. If the call raises a
|
|
`PublishError`, the runner treats it like any other pipeline exception —
|
|
the retry loop in `_run_with_retry` re-attempts, and no identifier is
|
|
written to `posted.json`. Identical-day matches are published as **one**
|
|
combined status: prefix line + one `title\nurl` block per post + hashtags
|
|
line, sorted by `(date, path)` for deterministic ordering. The default
|
|
prefix is `Heute vor 10 Jahren:` (overridable via `THROWNBACK_PREFIX`).
|
|
Generated statuses are validated against `MASTODON_STATUS_LIMIT` (500
|
|
characters); the runner fails safely (raises) rather than truncating.
|
|
|
|
# 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
|
|
|
|
* [Anniversary Matching](/architecture/anniversary-matching.md)
|
|
* [Logging & Run Summary](/architecture/logging.md)
|
|
* [Config Schema](/architecture/config-schema.md)
|
|
* [Cron Lifecycle](/operations/cron-lifecycle.md)
|