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
Generated
+1
View File
@@ -2751,6 +2751,7 @@ name = "portal"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap",
"async-nats", "async-nats",
"aws-sdk-s3", "aws-sdk-s3",
"axum", "axum",
+2
View File
@@ -22,6 +22,7 @@ tower = { version = "0.5", optional = true }
tower-http = { version = "0.6", features = ["fs"], optional = true } tower-http = { version = "0.6", features = ["fs"], optional = true }
tower-sessions = { version = "0.14", optional = true } tower-sessions = { version = "0.14", optional = true }
async-nats = { version = "0.38", optional = true } async-nats = { version = "0.38", optional = true }
arc-swap = { version = "1", optional = true }
url = { version = "2", optional = true } url = { version = "2", optional = true }
serde_yaml = { version = "0.9", optional = true } serde_yaml = { version = "0.9", optional = true }
sha2 = { version = "0.10", optional = true } sha2 = { version = "0.10", optional = true }
@@ -76,6 +77,7 @@ ssr = [
"dep:tower-http", "dep:tower-http",
"dep:tower-sessions", "dep:tower-sessions",
"dep:async-nats", "dep:async-nats",
"dep:arc-swap",
"dep:url", "dep:url",
"dep:serde_yaml", "dep:serde_yaml",
"dep:sha2", "dep:sha2",
+2
View File
@@ -96,7 +96,9 @@ pub async fn transition_answer(
let state = expect_context::<AppState>(); let state = expect_context::<AppState>();
let question = state let question = state
.questions .questions
.load()
.get(&question_id) .get(&question_id)
.cloned()
.ok_or_else(|| ServerFnError::new("unknown question"))?; .ok_or_else(|| ServerFnError::new("unknown question"))?;
let feature = question let feature = question
.alternatives .alternatives
+4 -2
View File
@@ -742,7 +742,7 @@ fn NotFound() -> impl IntoView {
pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnError> { pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnError> {
use crate::server::AppState; use crate::server::AppState;
let state = expect_context::<AppState>(); let state = expect_context::<AppState>();
Ok(state.questions.get(&path).cloned()) Ok(state.questions.load().get(&path).cloned())
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -768,7 +768,9 @@ pub async fn submit_answer(
let state = expect_context::<AppState>(); let state = expect_context::<AppState>();
let question = state let question = state
.questions .questions
.load()
.get(&question_id) .get(&question_id)
.cloned()
.ok_or_else(|| ServerFnError::new("unknown question"))?; .ok_or_else(|| ServerFnError::new("unknown question"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?; let session: tower_sessions::Session = leptos_axum::extract().await?;
@@ -776,7 +778,7 @@ pub async fn submit_answer(
.get::<User>(SESSION_USER_KEY) .get::<User>(SESSION_USER_KEY)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
if !is_qualified(user.as_ref(), question) { if !is_qualified(user.as_ref(), &question) {
return Err(ServerFnError::new("not authorized for this question")); return Err(ServerFnError::new("not authorized for this question"));
} }
+47 -4
View File
@@ -146,10 +146,10 @@ impl Requirement {
/// Fetches every `*.yaml` file under `subdir` in a Gitea repo as a /// 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 /// `Question`, keyed by its own `id`. `repo_url` is the repo's normal
/// browser URL (e.g. `https://project.uhhm.no/uhhm/questions`) - the /// 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 /// Gitea host, owner and repo name are all read from it. Called once at
/// startup, over Gitea's public contents API (no auth - the content /// startup, and again on every `CONTENT_RELOAD_SUBJECT` message (see
/// repo is public); no hot-reload yet - restart the process (or add /// `watch_for_reload`), over Gitea's public contents API (no auth - the
/// polling later) to pick up content changes. /// content repo is public).
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub async fn load_questions_from_gitea( pub async fn load_questions_from_gitea(
repo_url: &str, repo_url: &str,
@@ -218,3 +218,46 @@ pub async fn load_questions_from_gitea(
} }
Ok(out) 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");
}
}
}
}
+10 -1
View File
@@ -30,6 +30,7 @@ async fn main() -> anyhow::Result<()> {
let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string()); let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string());
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?; let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content"); tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content");
let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
let nats_url = let nats_url =
std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string()); std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string());
@@ -56,11 +57,19 @@ async fn main() -> anyhow::Result<()> {
tracing::warn!("GARAGE_* env vars not set - file upload fields will fail closed"); tracing::warn!("GARAGE_* env vars not set - file upload fields will fail closed");
} }
tokio::spawn(content::watch_for_reload(
nats.clone(),
content_repo,
content_branch,
"questions".to_string(),
questions.clone(),
));
let state = AppState { let state = AppState {
leptos_options: leptos_options.clone(), leptos_options: leptos_options.clone(),
nats, nats,
jetstream, jetstream,
questions: std::sync::Arc::new(questions), questions,
oidc: oidc_state, oidc: oidc_state,
garage, garage,
}; };
+2
View File
@@ -22,7 +22,9 @@ pub async fn get_resource(
let state = expect_context::<AppState>(); let state = expect_context::<AppState>();
let question = state let question = state
.questions .questions
.load()
.get(&question_id) .get(&question_id)
.cloned()
.ok_or_else(|| ServerFnError::new("unknown question"))?; .ok_or_else(|| ServerFnError::new("unknown question"))?;
let feature = question let feature = question
.alternatives .alternatives
+6 -1
View File
@@ -2,6 +2,7 @@
pub mod oidc; pub mod oidc;
use arc_swap::ArcSwap;
use axum::extract::FromRef; use axum::extract::FromRef;
use leptos::prelude::LeptosOptions; use leptos::prelude::LeptosOptions;
use std::collections::HashMap; use std::collections::HashMap;
@@ -16,7 +17,11 @@ pub struct AppState {
/// JetStream context - source of every NATS KV bucket this app /// JetStream context - source of every NATS KV bucket this app
/// reads/writes (applicants, and whatever a `ResourceSpec` names). /// reads/writes (applicants, and whatever a `ResourceSpec` names).
pub jetstream: async_nats::jetstream::Context, pub jetstream: async_nats::jetstream::Context,
pub questions: Arc<HashMap<String, Question>>, /// Swapped out wholesale on a `content::CONTENT_RELOAD_SUBJECT`
/// message (see `content::watch_for_reload`) - readers never hold
/// a lock, just an atomic pointer load, so a reload never blocks or
/// is blocked by an in-flight request.
pub questions: Arc<ArcSwap<HashMap<String, Question>>>,
pub oidc: Arc<oidc::Oidc>, pub oidc: Arc<oidc::Oidc>,
/// `None` when `GARAGE_*` env vars aren't set - uploads are the one /// `None` when `GARAGE_*` env vars aren't set - uploads are the one
/// optional feature, everything else works without Garage. /// optional feature, everything else works without Garage.