rustio_admin/admin/
feature_flags.rs1use 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
37const 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
59pub async fn ensure_table(db: &Db) -> Result<()> {
62 sqlx::query(CREATE_TABLE_SQL).execute(db.pool()).await?;
63 Ok(())
64}
65
66#[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
79pub 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
112pub fn invalidate_cache() {
117 FLAG_CACHE.clear();
118}
119
120pub(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
142pub(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
159pub(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 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}