Skip to main content

rustio_admin/admin/
feature_flags.rs

1//! `rustio_feature_flags` — a tiny project-side feature-flag
2//! surface. Projects flip a named flag from the admin UI; project
3//! code reads it via [`feature_enabled`] without paying for a
4//! Postgres round-trip on every call.
5//!
6//! Schema:
7//!
8//! ```sql
9//! CREATE TABLE rustio_feature_flags (
10//!     key         TEXT        PRIMARY KEY,
11//!     enabled     BOOLEAN     NOT NULL DEFAULT FALSE,
12//!     description TEXT        NOT NULL DEFAULT '',
13//!     created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
14//!     updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
15//! );
16//! ```
17//!
18//! Reads go through a process-local 60-second cache (mirrors the
19//! permissions cache pattern). The cache is per-key — a single
20//! flag flip refreshes only that key's entry.
21//!
22//! The admin UI at `/admin/feature_flags` lets administrators
23//! create / toggle / describe flags. Project code calls
24//! [`feature_enabled`] from request handlers, background jobs,
25//! migrations — anywhere a `Db` is in scope.
26
27use std::time::{Duration, Instant};
28
29use chrono::{DateTime, Utc};
30use dashmap::DashMap;
31use once_cell::sync::Lazy;
32use sqlx::Row as _;
33
34use crate::error::Result;
35use crate::orm::Db;
36
37/// Cache TTL for `feature_enabled` lookups. Matches the
38/// permissions cache — short enough that a flag flip propagates
39/// across a fleet within a minute, long enough that hot-path
40/// reads stay process-local.
41const FLAG_CACHE_TTL: Duration = Duration::from_secs(60);
42
43#[derive(Debug, Clone, Copy)]
44struct CacheEntry {
45    enabled: bool,
46    expires: Instant,
47}
48
49static FLAG_CACHE: Lazy<DashMap<String, CacheEntry>> = Lazy::new(DashMap::new);
50
51pub(crate) const CREATE_TABLE_SQL: &str = "CREATE TABLE IF NOT EXISTS rustio_feature_flags (
52    key         TEXT        PRIMARY KEY,
53    enabled     BOOLEAN     NOT NULL DEFAULT FALSE,
54    description TEXT        NOT NULL DEFAULT '',
55    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
56    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
57)";
58
59// public:
60/// Ensure the `rustio_feature_flags` table exists. Idempotent.
61pub async fn ensure_table(db: &Db) -> Result<()> {
62    sqlx::query(CREATE_TABLE_SQL).execute(db.pool()).await?;
63    Ok(())
64}
65
66// public:
67/// One feature flag's stored state. Surfaced by [`list_flags`]
68/// for the admin UI; project code reads booleans via
69/// [`feature_enabled`].
70#[derive(Debug, Clone)]
71pub struct FeatureFlag {
72    pub key: String,
73    pub enabled: bool,
74    pub description: String,
75    pub created_at: DateTime<Utc>,
76    pub updated_at: DateTime<Utc>,
77}
78
79// public:
80/// `true` when the named flag is set and `enabled = TRUE`.
81/// Missing keys read as `false`. Reads go through a 60-second
82/// per-key cache so hot paths stay cheap.
83///
84/// **Stability:** the key is the public contract between project
85/// code and the operator. Treat key strings as snake_case
86/// identifiers (e.g. `"new_signup_flow"`) — the table accepts
87/// any TEXT, but a stable convention makes the admin UI legible.
88pub async fn feature_enabled(db: &Db, key: &str) -> bool {
89    if let Some(entry) = FLAG_CACHE.get(key) {
90        if entry.expires > Instant::now() {
91            return entry.enabled;
92        }
93    }
94    let enabled: Option<bool> =
95        sqlx::query_scalar("SELECT enabled FROM rustio_feature_flags WHERE key = $1")
96            .bind(key)
97            .fetch_optional(db.pool())
98            .await
99            .ok()
100            .flatten();
101    let enabled = enabled.unwrap_or(false);
102    FLAG_CACHE.insert(
103        key.to_string(),
104        CacheEntry {
105            enabled,
106            expires: Instant::now() + FLAG_CACHE_TTL,
107        },
108    );
109    enabled
110}
111
112// public:
113/// Drop every cached entry. Called by [`set_flag`] /
114/// [`create_flag`] so a fresh write is observable on the next
115/// read without waiting for the TTL.
116pub fn invalidate_cache() {
117    FLAG_CACHE.clear();
118}
119
120/// List every flag, newest-created first. Powers the admin UI.
121pub(crate) async fn list_flags(db: &Db) -> Result<Vec<FeatureFlag>> {
122    ensure_table(db).await?;
123    let rows = sqlx::query(
124        "SELECT key, enabled, description, created_at, updated_at \
125         FROM rustio_feature_flags ORDER BY created_at DESC",
126    )
127    .fetch_all(db.pool())
128    .await?;
129    let out = rows
130        .iter()
131        .map(|r| FeatureFlag {
132            key: r.try_get("key").unwrap_or_default(),
133            enabled: r.try_get("enabled").unwrap_or(false),
134            description: r.try_get("description").unwrap_or_default(),
135            created_at: r.try_get("created_at").unwrap_or_else(|_| Utc::now()),
136            updated_at: r.try_get("updated_at").unwrap_or_else(|_| Utc::now()),
137        })
138        .collect();
139    Ok(out)
140}
141
142/// Create a new flag with the given description. Initial state
143/// is `enabled = FALSE`. Idempotent on the key (no-op when the
144/// key already exists).
145pub(crate) async fn create_flag(db: &Db, key: &str, description: &str) -> Result<()> {
146    ensure_table(db).await?;
147    sqlx::query(
148        "INSERT INTO rustio_feature_flags (key, enabled, description) \
149         VALUES ($1, FALSE, $2) ON CONFLICT (key) DO NOTHING",
150    )
151    .bind(key)
152    .bind(description)
153    .execute(db.pool())
154    .await?;
155    invalidate_cache();
156    Ok(())
157}
158
159/// Flip one flag's `enabled` state to the supplied value. Bumps
160/// `updated_at`. Missing keys silently no-op (the admin UI only
161/// surfaces existing rows).
162pub(crate) async fn set_flag(db: &Db, key: &str, enabled: bool) -> Result<()> {
163    ensure_table(db).await?;
164    sqlx::query(
165        "UPDATE rustio_feature_flags \
166         SET enabled = $1, updated_at = NOW() \
167         WHERE key = $2",
168    )
169    .bind(enabled)
170    .bind(key)
171    .execute(db.pool())
172    .await?;
173    invalidate_cache();
174    Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn flag_cache_ttl_is_60_seconds() {
183        // Locked-in constant — the admin UI's "may take up to a
184        // minute to propagate" copy depends on it. Bumping the
185        // TTL is fine, but update the help text too.
186        assert_eq!(FLAG_CACHE_TTL, Duration::from_secs(60));
187    }
188
189    #[test]
190    fn invalidate_cache_clears_every_entry() {
191        FLAG_CACHE.insert(
192            "key_a".into(),
193            CacheEntry {
194                enabled: true,
195                expires: Instant::now() + Duration::from_secs(60),
196            },
197        );
198        FLAG_CACHE.insert(
199            "key_b".into(),
200            CacheEntry {
201                enabled: false,
202                expires: Instant::now() + Duration::from_secs(60),
203            },
204        );
205        invalidate_cache();
206        assert!(FLAG_CACHE.get("key_a").is_none());
207        assert!(FLAG_CACHE.get("key_b").is_none());
208    }
209}