Hot-reload content on a NATS trigger instead of requiring a restart
Deploy / deploy (push) Successful in 29s

questions is now Arc<ArcSwap<HashMap<...>>> - readers do a lock-free
atomic load (state.questions.load().get(&id).cloned()), never blocking
on or blocked by a reload. content::watch_for_reload subscribes to
portal.content.reload (published by the questions repo's own CI after
it lints a push - see that repo's lint-and-reload.yml) and swaps in a
freshly re-fetched HashMap on each message. A fetch/parse failure logs
and keeps serving the last-good content rather than clearing it.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-05 07:20:40 +02:00
parent b0086b2ed9
commit 18025bf870
8 changed files with 74 additions and 8 deletions
+47 -4
View File
@@ -146,10 +146,10 @@ impl Requirement {
/// Fetches every `*.yaml` file under `subdir` in a Gitea repo as a
/// `Question`, keyed by its own `id`. `repo_url` is the repo's normal
/// browser URL (e.g. `https://project.uhhm.no/uhhm/questions`) - the
/// Gitea host, owner and repo name are all read from it. Runs once at
/// startup, over Gitea's public contents API (no auth - the content
/// repo is public); no hot-reload yet - restart the process (or add
/// polling later) to pick up content changes.
/// Gitea host, owner and repo name are all read from it. Called once at
/// startup, and again on every `CONTENT_RELOAD_SUBJECT` message (see
/// `watch_for_reload`), over Gitea's public contents API (no auth - the
/// content repo is public).
#[cfg(feature = "ssr")]
pub async fn load_questions_from_gitea(
repo_url: &str,
@@ -218,3 +218,46 @@ pub async fn load_questions_from_gitea(
}
Ok(out)
}
/// Published by the content repo's own CI (after it lints a push) to
/// tell every running instance to pick up the change - a plain fire
/// and forget NATS publish, no payload, matching `events.rs`'s
/// `ANSWERS_SUBJECT` pattern.
#[cfg(feature = "ssr")]
pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload";
/// Runs for the life of the process: re-fetches `repo_url`/`branch` and
/// atomically swaps it into `questions` on every `CONTENT_RELOAD_SUBJECT`
/// message. A fetch/parse failure logs and keeps serving the last-good
/// content rather than clearing it - a bad push to the content repo
/// (which should already have been caught by its own lint step) doesn't
/// take the site down.
#[cfg(feature = "ssr")]
pub async fn watch_for_reload(
nats: async_nats::Client,
repo_url: String,
branch: String,
subdir: String,
questions: std::sync::Arc<arc_swap::ArcSwap<std::collections::HashMap<String, Question>>>,
) {
let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
Ok(sub) => sub,
Err(e) => {
tracing::error!(error = %e, "failed to subscribe to content reload subject");
return;
}
};
use futures::StreamExt;
while sub.next().await.is_some() {
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
Ok(loaded) => {
let count = loaded.len();
questions.store(std::sync::Arc::new(loaded));
tracing::info!(count, "reloaded content");
}
Err(e) => {
tracing::error!(error = %e, "content reload failed, keeping last-good content");
}
}
}
}