//! Whether the platform changelog is published, for the footer link. //! //! `base.html` renders the site footer on every page and cannot await a query, //! so the answer lives in a process-global flag that a background task //! refreshes. `/changelog` resolves through //! `db::projects::get_public_project_by_slug(CHANGELOG_PROJECT_SLUG)`; when no //! such published project exists the route 404s, which turned the footer link //! into a sitewide dead end. //! //! The flag starts `false`, so until the first refresh lands the footer omits a //! link we cannot yet prove resolves. The route is unchanged: this only decides //! whether visitors are pointed at it. Publish a changelog project and the link //! comes back on its own within `REFRESH_INTERVAL_SECS`. //! //! Per-process, not per-instance-cluster: the refresher runs in every instance //! rather than under the scheduler's advisory lock, so a second instance in a //! rolling deploy does not serve a stale footer. use std::sync::atomic::{AtomicBool, Ordering}; use sqlx::PgPool; use tokio::sync::watch; use tokio::task::JoinHandle; use crate::constants; use crate::db::{self, Slug}; static PUBLISHED: AtomicBool = AtomicBool::new(false); /// How often each instance re-checks whether the changelog project is public. /// One indexed lookup per minute per instance, cheap enough that the link's /// reappearance is prompt without a write-path hook in every publish route. const REFRESH_INTERVAL_SECS: u64 = 60; /// Whether the footer should link to `/changelog`. Called from `base.html`. pub fn is_published() -> bool { PUBLISHED.load(Ordering::Relaxed) } /// Re-read the changelog project's visibility into the flag. /// /// A failed query keeps the previous value rather than hiding the link: a /// transient DB error is not evidence that the changelog went away. #[tracing::instrument(skip_all)] pub async fn refresh(pool: &PgPool) { let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned()); match db::projects::get_public_project_by_slug(pool, &slug).await { Ok(project) => PUBLISHED.store(project.is_some(), Ordering::Relaxed), Err(e) => { tracing::warn!(error = ?e, "changelog visibility refresh failed, keeping last value"); } } } /// Spawn the per-instance refresh loop. Drop the shutdown sender to stop it. /// Refreshes once immediately so the first rendered page is already accurate. pub fn spawn_refresher(pool: PgPool, mut shutdown_rx: watch::Receiver<()>) -> JoinHandle<()> { tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(REFRESH_INTERVAL_SECS)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { tokio::select! { _ = interval.tick() => refresh(&pool).await, _ = shutdown_rx.changed() => break, } } }) } /// Force the flag for tests that render the footer without a database. #[doc(hidden)] pub fn set_published_for_test(published: bool) { PUBLISHED.store(published, Ordering::Relaxed); }