use super::QueueWorker;
use crate::error::DbError;
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn parse_interval_secs(s: &str) -> Option<u64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (num_part, mult): (&str, u64) = match s.chars().last() {
Some('s') | Some('S') => (&s[..s.len() - 1], 1),
Some('m') | Some('M') => (&s[..s.len() - 1], 60),
Some('h') | Some('H') => (&s[..s.len() - 1], 3600),
Some('d') | Some('D') => (&s[..s.len() - 1], 86400),
Some(c) if c.is_ascii_digit() => (s, 1),
_ => return None,
};
let n: u64 = num_part.trim().parse().ok()?;
let secs = n.checked_mul(mult)?;
(secs > 0).then_some(secs)
}
const MV_REFRESH_TIMEOUT_SECS: u64 = 30;
pub(crate) fn refresh_principal(view: &serde_json::Value) -> (crate::sdbql::QueryPrincipal, bool) {
let owner = view.get("owner");
let user = owner.and_then(|o| o.get("user")).and_then(|u| u.as_str());
match user {
Some(user) if !user.is_empty() => {
let roles = owner
.and_then(|o| o.get("roles"))
.and_then(|r| r.as_array())
.map(|a| {
a.iter()
.filter_map(|r| r.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
(crate::sdbql::QueryPrincipal::from_roles(user, roles), true)
}
_ => (
crate::sdbql::QueryPrincipal::from_roles(
LEGACY_REFRESH_USER,
vec!["viewer".to_string()],
),
false,
),
}
}
const LEGACY_REFRESH_USER: &str = "_mv_refresh_legacy";
static WARNED_NO_OWNER: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
impl QueueWorker {
pub(crate) async fn check_materialized_views(&self) {
let _lock = match self.claiming_lock.try_lock() {
Ok(l) => l,
Err(_) => return,
};
let now = now_secs();
let mut due_views: Vec<(String, String, serde_json::Value)> = Vec::new();
for db_name in self.storage.list_databases() {
let views_coll_name = format!("{}:_views", db_name);
let views_coll = match self.storage.get_collection(&views_coll_name) {
Ok(c) => c,
Err(_) => continue, };
for doc in views_coll.scan(None) {
let value = doc.to_value();
if value.get("type").and_then(|t| t.as_str()) != Some("materialized") {
continue;
}
let interval = match value
.get("refresh_schedule")
.and_then(|s| s.as_str())
.and_then(parse_interval_secs)
{
Some(i) => i,
None => continue, };
let view_key = doc.key.clone();
let due_key = format!("{}:{}", db_name, view_key);
{
let mut due = self.mv_next_due.lock().unwrap();
match due.get(&due_key) {
Some(&next) if now < next => continue,
_ => {
due.insert(due_key.clone(), now + interval);
}
}
}
due_views.push((db_name.clone(), view_key, value));
}
}
for (db_name, view_key, value) in due_views {
let (principal, has_owner) = refresh_principal(&value);
if !has_owner {
let due_key = format!("{}:{}", db_name, view_key);
let first = WARNED_NO_OWNER
.get_or_init(Default::default)
.lock()
.map(|mut w| w.insert(due_key))
.unwrap_or(false);
if first {
tracing::warn!(
"MV refresh worker: view '{}' in '{}' has no recorded owner; \
refreshing it as a read-only principal. Recreate the view to \
refresh it under its creator's permissions.",
view_key,
db_name
);
}
}
match self.refresh_view(&db_name, &view_key, principal).await {
Ok(()) => tracing::debug!(
"MV refresh worker: refreshed '{}' in '{}'",
view_key,
db_name
),
Err(e) => tracing::warn!(
"MV refresh worker: failed to refresh '{}' in '{}': {}",
view_key,
db_name,
e
),
}
}
}
async fn refresh_view(
&self,
db_name: &str,
view_name: &str,
principal: crate::sdbql::QueryPrincipal,
) -> Result<(), DbError> {
let sql = format!("REFRESH MATERIALIZED VIEW `{}`", view_name.replace('`', ""));
let query = crate::sdbql::parser::parse(&sql)?;
let storage = self.storage.clone();
let db_name = db_name.to_string();
tokio::task::spawn_blocking(move || {
let executor = crate::sdbql::QueryExecutor::with_database(&storage, db_name)
.with_principal(principal)
.with_timeout(std::time::Duration::from_secs(MV_REFRESH_TIMEOUT_SECS));
executor.execute(&query).map(|_| ())
})
.await
.map_err(|e| DbError::InternalError(format!("MV refresh task failed: {}", e)))?
}
}
#[cfg(test)]
mod tests {
use super::{parse_interval_secs, refresh_principal};
use serde_json::json;
#[test]
fn test_refresh_principal_uses_recorded_owner() {
let (p, has_owner) =
refresh_principal(&json!({"owner": {"user": "alice", "roles": ["editor"]}}));
assert!(has_owner);
assert_eq!(p.user, "alice");
assert!(p.can_write);
assert!(!p.can_admin);
}
#[test]
fn test_refresh_principal_legacy_row_is_read_only() {
let (p, has_owner) = refresh_principal(&json!({"type": "materialized"}));
assert!(!has_owner);
assert!(p.can_read);
assert!(!p.can_write);
assert!(!p.can_admin);
let (_, has_owner) = refresh_principal(&json!({"owner": {"user": ""}}));
assert!(!has_owner);
}
#[test]
fn test_parse_interval_secs() {
assert_eq!(parse_interval_secs("30s"), Some(30));
assert_eq!(parse_interval_secs("5m"), Some(300));
assert_eq!(parse_interval_secs("1h"), Some(3600));
assert_eq!(parse_interval_secs("2d"), Some(172800));
assert_eq!(parse_interval_secs("45"), Some(45)); assert_eq!(parse_interval_secs(" 10m "), Some(600));
assert_eq!(parse_interval_secs("0s"), None); assert_eq!(parse_interval_secs(""), None);
assert_eq!(parse_interval_secs("abc"), None);
assert_eq!(parse_interval_secs("5x"), None); }
}