Add a [cdn] section and purge the edge after publishing

The site is about to sit behind Cloudflare, where the public pages will be
allowed to live at the edge for a day (`s-maxage`, next commit). That is only
correct if the edge is emptied the moment a new issue lands, so the origin now
does the emptying itself rather than leaving it to an operator to remember.

`[cdn]` is inert by default: with no `provider` nothing is called and no token
is needed, so an origin with no CDN behaves exactly as before. Setting
`provider = "cloudflare"` without both a zone id and `DAILY_EPUB_CDN__API_TOKEN`
is a config error — a half-configured purge would publish into a stale edge and
say nothing.

The purge is `purge_everything` on purpose. A new issue changes more than its
own page: `/`, `/issues`, `/feed.xml`, `/issues.json`, and the previous issue's
page too, whose "latest" nav marker moves. A per-URL list of that set is exactly
the kind of thing that silently rots, and everything expensive at the edge is
content-hashed, so refilling it costs one origin fetch.

A purge failure is logged at warn and never fails the run: the paper is already
published and recorded by then, and a few stale hours are not worth failing over.
`daily-epub cdn purge` runs the same code by hand; it takes no run lock because
it touches neither the database nor the publish directories.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
This commit is contained in:
2026-09-04 21:28:48 +00:00
co-authored by Claude Fable 5.1
parent 2388d7bd02
commit 41fe61a691
7 changed files with 580 additions and 1 deletions
+22
View File
@@ -64,6 +64,15 @@ enum Command {
/// Operator jobs (what `daily-epub-job@<name>.service` runs).
#[command(subcommand)]
Job(JobCommand),
/// CDN cache maintenance.
#[command(subcommand)]
Cdn(CdnCommand),
}
#[derive(Debug, Subcommand)]
enum CdnCommand {
/// Purge the whole edge cache for the configured zone.
Purge,
}
#[derive(Debug, Subcommand)]
@@ -369,6 +378,17 @@ async fn main() -> Result<()> {
db.migrate().await?;
println!("migrations up to date: {}", config.database_path.display());
}
Command::Cdn(CdnCommand::Purge) => {
// No database, no files, no provider budgets: nothing the run lock
// protects, so `generate` and a purge may safely overlap.
match daily_epub::cdn::purge_all(&config).await {
Ok(outcome) => println!("{outcome}"),
Err(error) => {
eprintln!("cdn purge failed: {error}");
std::process::exit(1);
}
}
}
Command::Config(ConfigCommand::Check) => {
// Reaching here means `Config::load` already validated it; a bad
// config exited non-zero above. Nothing is opened, nothing locked.
@@ -426,6 +446,7 @@ fn lock_holder(command: &Command) -> Option<&'static str> {
| Command::Features(FeaturesCommand::Prune)
| Command::Db(_)
| Command::Config(_)
| Command::Cdn(_)
| Command::Users(_) => None,
}
}
@@ -1101,6 +1122,7 @@ mod tests {
vec!["db", "migrate"],
vec!["features", "prune"],
vec!["config", "check"],
vec!["cdn", "purge"],
vec!["job", "run", "features-prune"],
vec!["job", "run", "not-a-job"],
] {