1use rusqlite::Connection;
36
37pub const SCHEMA_V11: &str = "
43 CREATE TABLE IF NOT EXISTS raw_partitions (
44 partition_name TEXT PRIMARY KEY,
45 date_ymd TEXT NOT NULL,
46 created_ms INTEGER NOT NULL
47 ) WITHOUT ROWID;
48";
49
50fn ymd_from_ms(ms: i64) -> String {
52 let secs = (ms / 1000).max(0) as u64;
53 let days = secs / 86400;
54 let z = days as i64 + 719468;
56 let era = if z >= 0 { z } else { z - 146096 } / 146097;
57 let doe = (z - era * 146097) as u64;
58 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
59 let y = yoe as i64 + era * 400;
60 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
61 let mp = (5 * doy + 2) / 153;
62 let d = doy - (153 * mp + 2) / 5 + 1;
63 let m = if mp < 10 { mp + 3 } else { mp - 9 };
64 let year = if m <= 2 { y + 1 } else { y };
65 format!("{:04}{:02}{:02}", year, m, d)
66}
67
68pub fn maybe_rotate(conn: &Connection, now_ms: i64) -> rusqlite::Result<Option<String>> {
80 let prev_day_boundary = day_boundary_ms(now_ms);
82 let has_prev_day: i64 = conn.query_row(
83 "SELECT COUNT(*) FROM raw WHERE ts_ms < ?1",
84 [prev_day_boundary],
85 |r| r.get(0),
86 )?;
87 if has_prev_day == 0 {
88 return Ok(None);
89 }
90 let oldest_ms: i64 = conn.query_row(
92 "SELECT MIN(ts_ms) FROM raw WHERE ts_ms < ?1",
93 [prev_day_boundary],
94 |r| r.get(0),
95 )?;
96 let partition_date = ymd_from_ms(oldest_ms);
97 let partition_name = format!("raw_{partition_date}");
98 let partition_exists: i64 = conn.query_row(
101 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
102 [&partition_name],
103 |r| r.get(0),
104 )?;
105 let tx = conn.unchecked_transaction()?;
106 if partition_exists > 0 {
107 tx.execute_batch(&format!(
110 "INSERT INTO {partition_name} (ts_ms, listener, route, consumer,
111 upstream, method, status, status_class, duration_ms, attempts,
112 rate_limited, broken, shed, dims, request_id, correlation_id,
113 request_headers_redacted, auth_identity)
114 SELECT ts_ms, listener, route, consumer, upstream, method, status,
115 status_class, duration_ms, attempts, rate_limited, broken, shed,
116 dims, request_id, correlation_id, request_headers_redacted,
117 auth_identity
118 FROM raw WHERE ts_ms < {prev_day_boundary};
119 DELETE FROM raw WHERE ts_ms < {prev_day_boundary};"
120 ))?;
121 } else {
122 tx.execute_batch(&format!(
126 "CREATE TABLE {partition_name} AS
127 SELECT * FROM raw WHERE ts_ms < {prev_day_boundary};
128 CREATE INDEX idx_{partition_name}_ts ON {partition_name}(ts_ms);
129 CREATE INDEX idx_{partition_name}_corr
130 ON {partition_name}(correlation_id, ts_ms);
131 DELETE FROM raw WHERE ts_ms < {prev_day_boundary};"
132 ))?;
133 tx.execute(
134 "INSERT OR REPLACE INTO raw_partitions (partition_name, date_ymd, created_ms)
135 VALUES (?1, ?2, ?3)",
136 rusqlite::params![partition_name, partition_date, now_ms],
137 )?;
138 }
139 tx.commit()?;
140 refresh_raw_all_view(conn)?;
142 tracing::info!(
143 code = "analytics_partition_rotated",
144 partition = %partition_name,
145 "raw table rotated to partition {partition_name}"
146 );
147 Ok(Some(partition_name))
148}
149
150fn day_boundary_ms(now_ms: i64) -> i64 {
152 let day_ms = 86_400_000i64;
153 (now_ms / day_ms) * day_ms
154}
155
156pub fn refresh_raw_all_view(conn: &Connection) -> rusqlite::Result<()> {
160 let mut names: Vec<String> = Vec::new();
161 {
162 let mut stmt =
163 conn.prepare("SELECT partition_name FROM raw_partitions ORDER BY partition_name")?;
164 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
165 for row in rows {
166 names.push(row?);
167 }
168 }
169 let mut sql = String::from("DROP VIEW IF EXISTS raw_all; CREATE VIEW raw_all AS ");
170 sql.push_str("SELECT * FROM raw");
171 for name in &names {
172 sql.push_str(&format!(" UNION ALL SELECT * FROM {name}"));
173 }
174 conn.execute_batch(&sql)?;
175 Ok(())
176}
177
178pub fn drop_expired_partitions(
183 conn: &Connection,
184 raw_keep_ms: i64,
185 now_ms: i64,
186) -> rusqlite::Result<usize> {
187 let cutoff = now_ms.saturating_sub(raw_keep_ms);
188 let cutoff_ymd = ymd_from_ms(cutoff);
189 let mut expired: Vec<String> = Vec::new();
192 {
193 let mut stmt = conn.prepare(
194 "SELECT partition_name FROM raw_partitions
195 WHERE date_ymd < ?1 ORDER BY partition_name",
196 )?;
197 let rows = stmt.query_map([&cutoff_ymd], |r| r.get::<_, String>(0))?;
198 for row in rows {
199 expired.push(row?);
200 }
201 }
202 if expired.is_empty() {
203 return Ok(0);
204 }
205 let tx = conn.unchecked_transaction()?;
206 let mut dropped = 0usize;
207 for name in &expired {
208 tx.execute_batch(&format!("DROP TABLE IF EXISTS {name}"))?;
209 tx.execute(
210 "DELETE FROM raw_partitions WHERE partition_name = ?1",
211 [name],
212 )?;
213 dropped += 1;
214 tracing::info!(
215 code = "analytics_partition_dropped",
216 partition = %name,
217 "expired raw partition dropped"
218 );
219 }
220 tx.commit()?;
221 refresh_raw_all_view(conn)?;
222 Ok(dropped)
223}
224
225pub fn list_partitions(conn: &Connection) -> rusqlite::Result<Vec<String>> {
228 let mut names: Vec<String> = vec!["raw".to_string()];
229 let mut stmt =
230 conn.prepare("SELECT partition_name FROM raw_partitions ORDER BY partition_name")?;
231 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
232 for row in rows {
233 names.push(row?);
234 }
235 Ok(names)
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 fn open_test_db() -> Connection {
243 let conn = Connection::open_in_memory().unwrap();
244 conn.execute_batch(SCHEMA_V11).unwrap();
245 conn.execute_batch(
246 "CREATE TABLE raw (
247 id INTEGER PRIMARY KEY AUTOINCREMENT,
248 ts_ms INTEGER NOT NULL,
249 listener TEXT NOT NULL,
250 route TEXT NOT NULL,
251 consumer TEXT NOT NULL,
252 upstream TEXT NOT NULL,
253 method TEXT NOT NULL,
254 status INTEGER NOT NULL,
255 status_class TEXT NOT NULL,
256 duration_ms REAL NOT NULL,
257 attempts INTEGER NOT NULL,
258 rate_limited INTEGER NOT NULL,
259 broken INTEGER NOT NULL,
260 shed INTEGER NOT NULL,
261 dims TEXT NOT NULL DEFAULT '{}',
262 request_id TEXT NOT NULL DEFAULT '',
263 correlation_id TEXT NOT NULL DEFAULT '',
264 request_headers_redacted TEXT,
265 auth_identity TEXT
266 );
267 CREATE INDEX idx_raw_ts ON raw(ts_ms);
268 CREATE INDEX idx_raw_correlation ON raw(correlation_id, ts_ms);",
269 )
270 .unwrap();
271 conn
272 }
273
274 #[test]
275 fn ymd_from_ms_basic() {
276 assert_eq!(ymd_from_ms(1767225600000), "20260101");
278 assert_eq!(ymd_from_ms(1767312000000), "20260102");
281 }
282
283 #[test]
284 fn day_boundary_aligns_to_midnight() {
285 let noon = 1767268800000i64;
287 let boundary = day_boundary_ms(noon);
288 assert_eq!(boundary, 1767225600000); }
290
291 #[test]
292 fn no_rotation_when_raw_is_empty() {
293 let conn = open_test_db();
294 let result = maybe_rotate(&conn, 1767225600000).unwrap();
295 assert!(result.is_none());
296 }
297
298 #[test]
299 fn no_rotation_when_all_rows_are_today() {
300 let conn = open_test_db();
301 let now = 1767268800000i64; conn.execute(
303 "INSERT INTO raw (ts_ms, listener, route, consumer, upstream, method,
304 status, status_class, duration_ms, attempts, rate_limited, broken,
305 shed, dims) VALUES (?1, 'l', 'r', 'c', 'u', 'GET', 200, '2xx',
306 1.0, 1, 0, 0, 0, '{}')",
307 [now],
308 )
309 .unwrap();
310 let result = maybe_rotate(&conn, now).unwrap();
311 assert!(result.is_none());
312 }
313
314 #[test]
315 fn rotation_moves_old_rows_to_partition() {
316 let conn = open_test_db();
317 let day1_morning = 1767225600000i64; let day1_noon = 1767268800000i64; let day2_morning = 1767312000000i64; conn.execute(
322 "INSERT INTO raw (ts_ms, listener, route, consumer, upstream, method,
323 status, status_class, duration_ms, attempts, rate_limited, broken,
324 shed, dims) VALUES (?1, 'l', 'r', 'c', 'u', 'GET', 200, '2xx',
325 1.0, 1, 0, 0, 0, '{}')",
326 [day1_morning],
327 )
328 .unwrap();
329 conn.execute(
330 "INSERT INTO raw (ts_ms, listener, route, consumer, upstream, method,
331 status, status_class, duration_ms, attempts, rate_limited, broken,
332 shed, dims) VALUES (?1, 'l', 'r', 'c', 'u', 'GET', 200, '2xx',
333 1.0, 1, 0, 0, 0, '{}')",
334 [day1_noon],
335 )
336 .unwrap();
337 let result = maybe_rotate(&conn, day2_morning).unwrap();
341 assert_eq!(result.as_deref(), Some("raw_20260101"));
342 let remaining: i64 = conn
344 .query_row("SELECT COUNT(*) FROM raw", [], |r| r.get(0))
345 .unwrap();
346 assert_eq!(remaining, 0);
347 let partitioned: i64 = conn
349 .query_row("SELECT COUNT(*) FROM raw_20260101", [], |r| r.get(0))
350 .unwrap();
351 assert_eq!(partitioned, 2);
352 let total: i64 = conn
354 .query_row("SELECT COUNT(*) FROM raw_all", [], |r| r.get(0))
355 .unwrap();
356 assert_eq!(total, 2);
357 }
358
359 #[test]
360 fn drop_expired_partitions() {
361 let conn = open_test_db();
362 conn.execute_batch(
364 "CREATE TABLE raw_20251231 (ts_ms INTEGER NOT NULL);
365 INSERT INTO raw_partitions (partition_name, date_ymd, created_ms)
366 VALUES ('raw_20251231', '20251231', 1767139200000);",
367 )
368 .unwrap();
369 refresh_raw_all_view(&conn).unwrap();
370 let now = 1767312000000i64;
374 let dropped = super::drop_expired_partitions(&conn, 86_400_000, now).unwrap();
375 assert_eq!(dropped, 1);
376 let exists: i64 = conn
378 .query_row(
379 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='raw_20251231'",
380 [],
381 |r| r.get(0),
382 )
383 .unwrap();
384 assert_eq!(exists, 0);
385 }
386
387 #[test]
388 fn list_partitions_includes_raw() {
389 let conn = open_test_db();
390 conn.execute_batch(
391 "CREATE TABLE raw_20260101 (ts_ms INTEGER NOT NULL);
392 INSERT INTO raw_partitions (partition_name, date_ymd, created_ms)
393 VALUES ('raw_20260101', '20260101', 1767225600000);",
394 )
395 .unwrap();
396 let parts = list_partitions(&conn).unwrap();
397 assert_eq!(parts, vec!["raw", "raw_20260101"]);
398 }
399}