Skip to main content

max / makenotwork

Commit changelog.rs, which lib.rs already declares 3e3b1d15 added the module declaration and the main.rs spawn without staging the file, so main did not build from a clean checkout.
Author: Max Johnson <me@maxj.phd> · 2026-07-30 20:01 UTC
Signed with PGP, not checked
Commit: d2582c1a73bc671f783871987f94eae5ddad7e53
Parent: 3e3b1d1
1 file changed, +76 insertions, -0 deletions
@@ -1,0 +1,76 @@
1 + //! Whether the platform changelog is published, for the footer link.
2 + //!
3 + //! `base.html` renders the site footer on every page and cannot await a query,
4 + //! so the answer lives in a process-global flag that a background task
5 + //! refreshes. `/changelog` resolves through
6 + //! `db::projects::get_public_project_by_slug(CHANGELOG_PROJECT_SLUG)`; when no
7 + //! such published project exists the route 404s, which turned the footer link
8 + //! into a sitewide dead end.
9 + //!
10 + //! The flag starts `false`, so until the first refresh lands the footer omits a
11 + //! link we cannot yet prove resolves. The route is unchanged: this only decides
12 + //! whether visitors are pointed at it. Publish a changelog project and the link
13 + //! comes back on its own within `REFRESH_INTERVAL_SECS`.
14 + //!
15 + //! Per-process, not per-instance-cluster: the refresher runs in every instance
16 + //! rather than under the scheduler's advisory lock, so a second instance in a
17 + //! rolling deploy does not serve a stale footer.
18 +
19 + use std::sync::atomic::{AtomicBool, Ordering};
20 +
21 + use sqlx::PgPool;
22 + use tokio::sync::watch;
23 + use tokio::task::JoinHandle;
24 +
25 + use crate::constants;
26 + use crate::db::{self, Slug};
27 +
28 + static PUBLISHED: AtomicBool = AtomicBool::new(false);
29 +
30 + /// How often each instance re-checks whether the changelog project is public.
31 + /// One indexed lookup per minute per instance, cheap enough that the link's
32 + /// reappearance is prompt without a write-path hook in every publish route.
33 + const REFRESH_INTERVAL_SECS: u64 = 60;
34 +
35 + /// Whether the footer should link to `/changelog`. Called from `base.html`.
36 + pub fn is_published() -> bool {
37 + PUBLISHED.load(Ordering::Relaxed)
38 + }
39 +
40 + /// Re-read the changelog project's visibility into the flag.
41 + ///
42 + /// A failed query keeps the previous value rather than hiding the link: a
43 + /// transient DB error is not evidence that the changelog went away.
44 + #[tracing::instrument(skip_all)]
45 + pub async fn refresh(pool: &PgPool) {
46 + let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
47 + match db::projects::get_public_project_by_slug(pool, &slug).await {
48 + Ok(project) => PUBLISHED.store(project.is_some(), Ordering::Relaxed),
49 + Err(e) => {
50 + tracing::warn!(error = ?e, "changelog visibility refresh failed, keeping last value");
51 + }
52 + }
53 + }
54 +
55 + /// Spawn the per-instance refresh loop. Drop the shutdown sender to stop it.
56 + /// Refreshes once immediately so the first rendered page is already accurate.
57 + pub fn spawn_refresher(pool: PgPool, mut shutdown_rx: watch::Receiver<()>) -> JoinHandle<()> {
58 + tokio::spawn(async move {
59 + let mut interval =
60 + tokio::time::interval(std::time::Duration::from_secs(REFRESH_INTERVAL_SECS));
61 + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
62 +
63 + loop {
64 + tokio::select! {
65 + _ = interval.tick() => refresh(&pool).await,
66 + _ = shutdown_rx.changed() => break,
67 + }
68 + }
69 + })
70 + }
71 +
72 + /// Force the flag for tests that render the footer without a database.
73 + #[doc(hidden)]
74 + pub fn set_published_for_test(published: bool) {
75 + PUBLISHED.store(published, Ordering::Relaxed);
76 + }