omni_dev/daemon/services/
snowflake.rs1use 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
21pub const SERVICE_NAME: &str = "snowflake";
23
24pub struct SnowflakeService {
26 engine: SnowflakeEngine,
27}
28
29impl SnowflakeService {
30 #[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
118fn 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 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 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 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 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 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}