Skip to main content

omni_dev/daemon/services/
snowflake.rs

1//! The Snowflake daemon service.
2//!
3//! A thin adapter that hosts the account-agnostic [`SnowflakeEngine`] under the
4//! daemon's lifecycle and exposes query/sessions/disconnect over the control
5//! socket, plus a tray submenu.
6//!
7//! All real work (lazy multiplexed auth, per-query `USE …`, heartbeats, the
8//! arbitrary-schema → JSON mapping) lives in [`crate::snowflake`]; this adapter
9//! only routes ops and renders the menu/status. Unlike the bridge it persists no
10//! secret to disk — sessions live only in memory.
11
12use anyhow::{anyhow, bail, Context, Result};
13use async_trait::async_trait;
14use chrono::Utc;
15use serde_json::{json, Value};
16
17use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
18use crate::snowflake::session::SessionInfo;
19use crate::snowflake::{QueryRequest, SnowflakeEngine, SnowflakeEngineConfig};
20
21/// The Snowflake service name (the control-socket routing key).
22pub const SERVICE_NAME: &str = "snowflake";
23
24/// Hosts a [`SnowflakeEngine`] as a [`DaemonService`].
25pub struct SnowflakeService {
26    engine: SnowflakeEngine,
27}
28
29impl SnowflakeService {
30    /// Creates the service and starts the engine's background keep-alive
31    /// heartbeat. Cheap — no eager auth or I/O; each `(account, user)` session
32    /// is authenticated lazily on its first query, and the heartbeat only
33    /// touches sessions that exist.
34    #[must_use]
35    pub fn new(config: SnowflakeEngineConfig) -> Self {
36        let engine = SnowflakeEngine::new(config);
37        engine.start_heartbeat();
38        Self { engine }
39    }
40}
41
42#[async_trait]
43impl DaemonService for SnowflakeService {
44    fn name(&self) -> &'static str {
45        SERVICE_NAME
46    }
47
48    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
49        match op {
50            "query" => {
51                let req: QueryRequest =
52                    serde_json::from_value(payload).context("invalid `query` payload")?;
53                if req.sql.trim().is_empty() {
54                    bail!("`query` requires a non-empty `sql`");
55                }
56                self.engine.query(req).await
57            }
58            "sessions" => Ok(json!({ "sessions": self.engine.sessions() })),
59            "disconnect" => {
60                let account = payload
61                    .get("account")
62                    .and_then(Value::as_str)
63                    .ok_or_else(|| anyhow!("`disconnect` requires `account`"))?;
64                let user = payload
65                    .get("user")
66                    .and_then(Value::as_str)
67                    .ok_or_else(|| anyhow!("`disconnect` requires `user`"))?;
68                Ok(json!({ "disconnected": self.engine.disconnect(account, user) }))
69            }
70            other => bail!("unknown snowflake op: {other}"),
71        }
72    }
73
74    fn menu(&self) -> MenuSnapshot {
75        let sessions = self.engine.sessions();
76        let items = if sessions.is_empty() {
77            vec![MenuItem::Label("No sessions".to_string())]
78        } else {
79            session_menu_items(&sessions)
80        };
81        MenuSnapshot {
82            title: "Snowflake".to_string(),
83            items,
84        }
85    }
86
87    async fn menu_action(&self, action_id: &str) -> Result<()> {
88        if action_id == "disconnect-all" {
89            self.engine.disconnect_all();
90            return Ok(());
91        }
92        if let Some(id) = action_id.strip_prefix("disconnect:") {
93            let id: u64 = id
94                .parse()
95                .with_context(|| format!("invalid session id in action {action_id}"))?;
96            self.engine.disconnect_by_id(id);
97            return Ok(());
98        }
99        bail!("unknown snowflake menu action: {action_id}")
100    }
101
102    async fn status(&self) -> ServiceStatus {
103        let sessions = self.engine.sessions();
104        let live: usize = sessions.iter().map(|s| s.sessions).sum();
105        ServiceStatus {
106            name: SERVICE_NAME.to_string(),
107            healthy: true,
108            summary: format!("{} pool(s), {live} session(s)", sessions.len()),
109            detail: json!({ "sessions": sessions }),
110        }
111    }
112
113    async fn shutdown(&self) {
114        self.engine.shutdown().await;
115    }
116}
117
118/// Builds the tray items for a non-empty session list: a label per pool, an
119/// indented label per authenticated session (with what it's doing), a separator,
120/// and the per-pool + "Disconnect all" actions.
121fn session_menu_items(sessions: &[SessionInfo]) -> Vec<MenuItem> {
122    let mut items = Vec::new();
123    for session in sessions {
124        items.push(MenuItem::Label(format!(
125            "{} · {} · {}/{} sessions · {} queries",
126            session.account,
127            session.user,
128            session.sessions,
129            session.max_sessions,
130            session.query_count
131        )));
132        // One line per individual authenticated session (auth), with what it's
133        // doing: the running query + elapsed when busy, else idle time.
134        for member in &session.members {
135            let state = if let Some(running) = &member.running {
136                let secs = (Utc::now() - running.started_at).num_seconds().max(0);
137                format!("running {secs}s: {}", running.sql)
138            } else if member.busy {
139                "busy".to_string()
140            } else {
141                let idle = (Utc::now() - member.last_used).num_seconds().max(0);
142                format!("idle {idle}s · {} queries", member.query_count)
143            };
144            items.push(MenuItem::Label(format!(
145                "    #{} {} · {state}",
146                member.id,
147                member.context.summary(),
148            )));
149        }
150    }
151    items.push(MenuItem::Separator);
152    for session in sessions {
153        items.push(MenuItem::Action(MenuAction {
154            id: format!("disconnect:{}", session.id),
155            label: format!("Disconnect {} · {}", session.account, session.user),
156            enabled: true,
157        }));
158    }
159    items.push(MenuItem::Action(MenuAction {
160        id: "disconnect-all".to_string(),
161        label: "Disconnect all".to_string(),
162        enabled: true,
163    }));
164    items
165}
166
167#[cfg(test)]
168#[allow(clippy::unwrap_used, clippy::expect_used)]
169mod tests {
170    use super::*;
171
172    /// A service with no resolvable defaults, so query resolution fails before
173    /// any network/auth — keeping these tests offline and deterministic.
174    fn offline_service() -> SnowflakeService {
175        SnowflakeService::new(SnowflakeEngineConfig::default())
176    }
177
178    #[tokio::test]
179    async fn name_and_unknown_op() {
180        let svc = offline_service();
181        assert_eq!(svc.name(), "snowflake");
182        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
183    }
184
185    #[tokio::test]
186    async fn sessions_op_is_empty_initially() {
187        let svc = offline_service();
188        let payload = svc.handle("sessions", Value::Null).await.unwrap();
189        assert_eq!(payload, json!({ "sessions": [] }));
190    }
191
192    #[tokio::test]
193    async fn empty_sql_is_rejected_before_auth() {
194        let svc = offline_service();
195        assert!(svc.handle("query", json!({ "sql": "   " })).await.is_err());
196    }
197
198    #[tokio::test]
199    async fn query_without_account_errors_not_panics() {
200        let svc = offline_service();
201        // Non-empty SQL but no resolvable account: errors on resolution, no auth.
202        let err = svc
203            .handle("query", json!({ "sql": "SELECT 1" }))
204            .await
205            .unwrap_err();
206        assert!(err.to_string().contains("account"));
207    }
208
209    #[tokio::test]
210    async fn disconnect_requires_account_and_user() {
211        let svc = offline_service();
212        assert!(svc.handle("disconnect", json!({})).await.is_err());
213        assert!(svc
214            .handle("disconnect", json!({ "account": "ACCT" }))
215            .await
216            .is_err());
217        // Both present: evicts nothing on an empty engine, but succeeds.
218        let payload = svc
219            .handle("disconnect", json!({ "account": "ACCT", "user": "me" }))
220            .await
221            .unwrap();
222        assert_eq!(payload, json!({ "disconnected": false }));
223    }
224
225    #[tokio::test]
226    async fn menu_and_status_shape_with_no_sessions() {
227        let svc = offline_service();
228        let menu = svc.menu();
229        assert_eq!(menu.title, "Snowflake");
230        assert!(matches!(
231            menu.items.first(),
232            Some(MenuItem::Label(text)) if text == "No sessions"
233        ));
234        let status = svc.status().await;
235        assert_eq!(status.name, "snowflake");
236        assert!(status.healthy);
237        assert_eq!(status.summary, "0 pool(s), 0 session(s)");
238    }
239
240    #[tokio::test]
241    async fn menu_actions_route_and_reject_unknown() {
242        let svc = offline_service();
243        // Both forms are no-ops on an empty engine but must not error.
244        svc.menu_action("disconnect-all").await.unwrap();
245        svc.menu_action("disconnect:7").await.unwrap();
246        assert!(svc.menu_action("disconnect:not-a-number").await.is_err());
247        assert!(svc.menu_action("bogus").await.is_err());
248        svc.shutdown().await;
249    }
250
251    #[test]
252    fn session_menu_items_render_each_member_state_and_actions() {
253        use crate::snowflake::session::{MemberInfo, QueryContext, RunningQuery};
254
255        let now = Utc::now();
256        let wh_ctx = QueryContext {
257            warehouse: Some("WH".to_string()),
258            role: Some("R".to_string()),
259            ..QueryContext::default()
260        };
261        let sessions = vec![SessionInfo {
262            id: 5,
263            account: "ACME".to_string(),
264            user: "me".to_string(),
265            created_at: now,
266            last_used: now,
267            query_count: 9,
268            sessions: 3,
269            max_sessions: 4,
270            members: vec![
271                MemberInfo {
272                    id: 1,
273                    busy: true,
274                    context: wh_ctx,
275                    last_used: now,
276                    query_count: 3,
277                    running: Some(RunningQuery {
278                        sql: "SELECT 42".to_string(),
279                        started_at: now,
280                    }),
281                },
282                MemberInfo {
283                    id: 2,
284                    busy: true,
285                    context: QueryContext::default(),
286                    last_used: now,
287                    query_count: 1,
288                    running: None,
289                },
290                MemberInfo {
291                    id: 3,
292                    busy: false,
293                    context: QueryContext::default(),
294                    last_used: now,
295                    query_count: 0,
296                    running: None,
297                },
298            ],
299        }];
300
301        let items = session_menu_items(&sessions);
302        let labels: Vec<&str> = items
303            .iter()
304            .filter_map(|i| match i {
305                MenuItem::Label(t) => Some(t.as_str()),
306                _ => None,
307            })
308            .collect();
309        assert!(labels
310            .iter()
311            .any(|l| l.contains("ACME · me · 3/4 sessions · 9 queries")));
312        assert!(labels
313            .iter()
314            .any(|l| l.contains("running") && l.contains("SELECT 42") && l.contains("WH/R")));
315        assert!(labels.iter().any(|l| l.contains("busy")));
316        assert!(labels
317            .iter()
318            .any(|l| l.contains("idle") && l.contains("(default)")));
319
320        assert!(items.iter().any(|i| matches!(i, MenuItem::Separator)));
321        let action_ids: Vec<&str> = items
322            .iter()
323            .filter_map(|i| match i {
324                MenuItem::Action(a) => Some(a.id.as_str()),
325                _ => None,
326            })
327            .collect();
328        assert!(action_ids.contains(&"disconnect:5"));
329        assert!(action_ids.contains(&"disconnect-all"));
330    }
331}