diff --git a/adk-rust/README.md b/adk-rust/README.md index 4bfdadb..c5a0295 100644 --- a/adk-rust/README.md +++ b/adk-rust/README.md @@ -22,7 +22,9 @@ scripts with durable standalone Rust modules. - `detmir-dlp` - SSH wrapper replacement for remote DLP health JSON collection. - `dlp-health-check` - AW server DLP health check replacement. - `aw-db-maintenance` - guarded weekly SQLite maintenance for old allowlisted - process-level session events, with backup-before-delete. + process-level session events, with backup-before-delete; nightly SQLite + compaction is handled by the same binary in `--vacuum` mode and scheduled + separately from the trim job. - `aw-ensure-reliability` - safe dry-run/apply planner for AW service reliability repair actions that were previously immediate Bash mutations. - `aw-linux-install` - safe dry-run/apply planner for Linux ActivityWatch @@ -58,6 +60,38 @@ scripts with durable standalone Rust modules. - `detmir-status` - read-only DetMir state normalizer with text, JSON, and ADK `Content` output. Also builds `detmir-adk-status` as a compatibility binary. +## SQLite Maintenance Safety + +`aw-db-maintenance` has two separate modes: + +- default trim mode removes only old allowlisted `process_start` / + `process_stop` rows from the configured session bucket and is dry-run unless + `--apply` is passed; +- `--vacuum` compacts the SQLite DB with `VACUUM INTO`, checks + `PRAGMA integrity_check`, preserves owner/mode, and replaces the DB only after + backup and integrity success. + +Both apply modes use `AW_DB_MAINTENANCE_LOCK_PATH` / +`--lock-path` to block concurrent trim/VACUUM runs. VACUUM also checks the +configured `activitywatch-server.service` through systemd, refuses unknown or +failed unit states, stops the service before compaction, and starts it again +through a guard on success or error. + +Do not run VACUUM during business hours, active incident response, evidence +collection, active backup/restore, or when the ActivityWatch service/unit state +is unclear. Rollback is replacing the SQLite DB from +`/var/lib/activitywatch/backups/db/aw-sqlite-before-db-vacuum-*.db` while +`activitywatch-server.service` is stopped, then starting the service and +checking `aw-db-health`/`detmir-status`. + +The Ansible deploy installs the VACUUM unit files but does not enable the +nightly timer unless `aw_db_vacuum_timer_enabled=true` is set explicitly. +Disable it with: + +```bash +systemctl disable --now aw-db-vacuum.timer +``` + ## Migration Runbook Use `RUNBOOK.md` as the operational plan for replacing Python and shell modules diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index a2690a9..1cdf7e7 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -1118,6 +1118,45 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg (`sqlite.db=359.6MiB`, WAL `4.0MiB`, session rows `174`, recent process events `0`, latest `eventType=logon`), DetMir status OK with `dlp_counts={ok:22,warn:0,fail:0}`, `ok_for_operator=true`. +35.2. `[done]` Закрепить nightly VACUUM для AW SQLite: + - `aw-db-maintenance` получил отдельный `--vacuum` режим; weekly trim + остается отдельной задачей и не смешивается с compaction; + - добавлены `aw-server/aw-db-vacuum.service` и + `aw-server/aw-db-vacuum.timer`; + - timer schedule: `OnCalendar=*-*-* 02:10:00`, + `RandomizedDelaySec=10m`, `Persistent=true`; Ansible не включает timer + без явного opt-in `aw_db_vacuum_timer_enabled=true`; + - если opt-in не задан, `deploy_aw_server.yml` оставляет unit-файлы на + сервере, но держит `aw-db-vacuum.timer` в `disabled/stopped`; + - nightly job перед VACUUM останавливает `activitywatch-server.service`, + делает rollback backup SQLite DB, выполняет `VACUUM INTO`, проверяет + `PRAGMA integrity_check`, сохраняет владельца и режим файла и + поднимает server обратно; + - apply-режимы weekly trim и nightly VACUUM используют общий lock + `/run/aw-db-maintenance.lock`, поэтому одновременный запуск завершается + fail-closed без записи в SQLite; + - VACUUM нельзя запускать в рабочее время, во время активного + расследования/снятия доказательств, backup/restore, неизвестного + состояния `activitywatch-server.service` или если уже есть maintenance + lock; + - dry-run проверки: + `aw-db-maintenance --vacuum --json` и обычный + `aw-db-maintenance --json`; они не создают backup и не пишут в DB; + - включить timer явно: + `ansible-playbook -i inventory.ini deploy_aw_server.yml -e aw_db_vacuum_timer_enabled=true`; + - отключить timer: + `systemctl disable --now aw-db-vacuum.timer`; + - rollback: остановить `activitywatch-server.service`, заменить + `/var/lib/activitywatch/aw-server-rust/sqlite.db` из последнего + `/var/lib/activitywatch/backups/db/aw-sqlite-before-db-vacuum-*.db`, + вернуть владельца/режим, запустить service, проверить `aw-db-health` и + `detmir-status`; + - local gates: `cargo fmt --all -- --check`, `cargo test --workspace`, + `cargo clippy --workspace --all-targets -- -D warnings`, + `cargo build --workspace --release`, systemd unit verify, + `ansible-playbook -i inventory.ini deploy_aw_server.yml --syntax-check`, + `scripts/check_detmir_rust_release_artifacts.sh`, + `scripts/quality-gate.sh`; 36. `[done]` Устранить blocker полного AW server deploy на Influx token: - проблема: `deploy_aw_server.yml` падал на assert `aw_worktime_influx_enabled=true`, потому что локальные env diff --git a/adk-rust/crates/aw-db-maintenance/src/main.rs b/adk-rust/crates/aw-db-maintenance/src/main.rs index b590b49..039e006 100644 --- a/adk-rust/crates/aw-db-maintenance/src/main.rs +++ b/adk-rust/crates/aw-db-maintenance/src/main.rs @@ -1,5 +1,7 @@ -use std::fs; +use std::fs::{self, OpenOptions}; +use std::io::Write; use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::Duration; use anyhow::{Context, Result, bail}; @@ -12,6 +14,8 @@ use serde_json::Value; const DEFAULT_DB_PATH: &str = "/var/lib/activitywatch/aw-server-rust/sqlite.db"; const DEFAULT_BACKUP_DIR: &str = "/var/lib/activitywatch/backups/db"; const DEFAULT_HOST: &str = "HOST-EXAMPLE"; +const DEFAULT_SERVICE_UNIT: &str = "activitywatch-server.service"; +const DEFAULT_LOCK_PATH: &str = "/run/aw-db-maintenance.lock"; const ALLOWED_EVENT_TYPES: &[&str] = &["process_start", "process_stop"]; #[derive(Debug, Parser)] @@ -38,6 +42,19 @@ struct Cli { #[arg(long)] apply: bool, + #[arg(long)] + vacuum: bool, + + #[arg( + long, + default_value = DEFAULT_SERVICE_UNIT, + env = "AW_DB_MAINTENANCE_SERVICE_UNIT" + )] + service_unit: String, + + #[arg(long, default_value = DEFAULT_LOCK_PATH, env = "AW_DB_MAINTENANCE_LOCK_PATH")] + lock_path: PathBuf, + #[arg(long)] json: bool, } @@ -56,9 +73,52 @@ struct Report { planned_delete_rows: usize, deleted_rows: usize, backup_created: bool, + lock_path: String, skipped_reason: Option, } +#[derive(Debug, Serialize)] +struct VacuumReport { + apply: bool, + generated_at_utc: String, + db_path: String, + service_unit: String, + service_was_active: bool, + service_restarted: bool, + backup_path: Option, + backup_created: bool, + lock_path: String, + db_size_before_bytes: Option, + vacuumed_path: Option, + vacuumed_size_bytes: Option, + integrity_check: Option, + replaced_db: bool, + skipped_reason: Option, +} + +struct VacuumResult { + backup_path: PathBuf, + vacuumed_path: PathBuf, + db_size_before_bytes: u64, + vacuumed_size_bytes: u64, + integrity_check: String, +} + +struct ServiceGuard { + unit: String, + was_active: bool, + restored: bool, +} + +struct TempFileGuard { + path: PathBuf, + keep: bool, +} + +struct LockFileGuard { + path: PathBuf, +} + fn main() { let code = match run() { Ok(code) => code, @@ -72,11 +132,20 @@ fn main() { fn run() -> Result { let cli = Cli::parse(); - let report = build_report(&cli)?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&report)?); + if cli.vacuum { + let report = build_vacuum_report(&cli)?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print_vacuum_text(&report); + } } else { - print_text(&report); + let report = build_report(&cli)?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print_text(&report); + } } Ok(0) } @@ -116,10 +185,15 @@ fn build_report(cli: &Cli) -> Result { let mut backup_file = None; let mut backup_created = false; let mut deleted = 0; + let _lock_guard = if cli.apply && planned > 0 { + Some(LockFileGuard::acquire(&cli.lock_path)?) + } else { + None + }; if cli.apply && planned > 0 { fs::create_dir_all(&cli.backup_dir) .with_context(|| format!("create backup dir {}", cli.backup_dir.display()))?; - let backup = backup_path(&cli.backup_dir); + let backup = backup_path(&cli.backup_dir, "aw-sqlite-before-db-maintenance"); copy_sqlite_via_backup(&cli.db_path, &backup)?; backup_file = Some(backup); backup_created = true; @@ -139,6 +213,60 @@ fn build_report(cli: &Cli) -> Result { )) } +fn build_vacuum_report(cli: &Cli) -> Result { + if !cli.db_path.exists() { + return Ok(vacuum_report( + cli, + false, + None, + false, + false, + false, + None, + None, + None, + None, + Some("database not found".to_string()), + )); + } + + if !cli.apply { + return Ok(vacuum_report( + cli, + false, + Some(file_size(&cli.db_path)?), + false, + false, + false, + None, + None, + None, + None, + Some("dry-run".to_string()), + )); + } + + let _lock_guard = LockFileGuard::acquire(&cli.lock_path)?; + let mut service_guard = ServiceGuard::stop_if_active(&cli.service_unit)?; + let service_was_active = service_guard.was_active; + let result = vacuum_sqlite_db(&cli.db_path, &cli.backup_dir)?; + let service_restarted = service_guard.restore()?; + + Ok(vacuum_report( + cli, + true, + Some(result.db_size_before_bytes), + true, + service_was_active, + service_restarted, + Some(result.backup_path), + Some(result.vacuumed_path), + Some(result.vacuumed_size_bytes), + Some(result.integrity_check), + None, + )) +} + #[allow(clippy::too_many_arguments)] fn base_report( cli: &Cli, @@ -164,6 +292,40 @@ fn base_report( planned_delete_rows, deleted_rows, backup_created, + lock_path: cli.lock_path.display().to_string(), + skipped_reason, + } +} + +#[allow(clippy::too_many_arguments)] +fn vacuum_report( + cli: &Cli, + apply: bool, + db_size_before_bytes: Option, + backup_created: bool, + service_was_active: bool, + service_restarted: bool, + backup_path: Option, + vacuumed_path: Option, + vacuumed_size_bytes: Option, + integrity_check: Option, + skipped_reason: Option, +) -> VacuumReport { + VacuumReport { + apply, + generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + db_path: cli.db_path.display().to_string(), + service_unit: cli.service_unit.clone(), + service_was_active, + service_restarted, + backup_path: backup_path.map(|path| path.display().to_string()), + backup_created, + lock_path: cli.lock_path.display().to_string(), + db_size_before_bytes, + vacuumed_path: vacuumed_path.map(|path| path.display().to_string()), + vacuumed_size_bytes, + integrity_check, + replaced_db: apply && skipped_reason.is_none(), skipped_reason, } } @@ -237,13 +399,256 @@ fn delete_events(conn: &Connection, ids: &[i64], chunk_size: usize) -> Result PathBuf { +fn backup_path(backup_dir: &Path, prefix: &str) -> PathBuf { backup_dir.join(format!( - "aw-sqlite-before-db-maintenance-{}.db", + "{}-{}.db", + prefix, Utc::now().format("%Y%m%dT%H%M%SZ") )) } +fn vacuum_sqlite_db(db_path: &Path, backup_dir: &Path) -> Result { + fs::create_dir_all(backup_dir) + .with_context(|| format!("create backup dir {}", backup_dir.display()))?; + let db_size_before_bytes = file_size(db_path)?; + let backup_path = backup_path(backup_dir, "aw-sqlite-before-db-vacuum"); + copy_sqlite_via_backup(db_path, &backup_path)?; + let vacuumed_path = vacuumed_path(db_path)?; + let mut vacuum_cleanup = TempFileGuard::new(vacuumed_path.clone()); + vacuum_into(db_path, &vacuumed_path)?; + preserve_sqlite_metadata(db_path, &vacuumed_path)?; + let vacuumed_size_bytes = file_size(&vacuumed_path)?; + let integrity_check = integrity_check(&vacuumed_path)?; + remove_sqlite_sidecars(db_path)?; + fs::rename(&vacuumed_path, db_path).with_context(|| { + format!( + "replace {} with {}", + db_path.display(), + vacuumed_path.display() + ) + })?; + vacuum_cleanup.disarm(); + Ok(VacuumResult { + backup_path, + vacuumed_path, + db_size_before_bytes, + vacuumed_size_bytes, + integrity_check, + }) +} + +fn vacuum_into(src: &Path, dst: &Path) -> Result<()> { + let conn = open_connection(src, true)?; + let sql = format!("VACUUM INTO {}", sqlite_string_literal(dst)); + conn.execute_batch(&sql) + .with_context(|| format!("VACUUM INTO {}", dst.display())) +} + +fn integrity_check(path: &Path) -> Result { + let conn = open_connection(path, false)?; + let result: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; + if result != "ok" { + bail!("integrity_check failed for {}: {result}", path.display()); + } + Ok(result) +} + +fn remove_sqlite_sidecars(db_path: &Path) -> Result<()> { + for suffix in ["-wal", "-shm", "-journal"] { + let sidecar = sqlite_sidecar_path(db_path, suffix)?; + match fs::remove_file(&sidecar) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err).with_context(|| format!("remove {}", sidecar.display())), + } + } + Ok(()) +} + +fn sqlite_sidecar_path(db_path: &Path, suffix: &str) -> Result { + let file_name = db_path + .file_name() + .and_then(|value| value.to_str()) + .context("database path must have a file name")?; + Ok(db_path.with_file_name(format!("{file_name}{suffix}"))) +} + +fn vacuumed_path(db_path: &Path) -> Result { + let file_name = db_path + .file_name() + .and_then(|value| value.to_str()) + .context("database path must have a file name")?; + Ok(db_path.with_file_name(format!( + "{file_name}.vacuumed-{}", + Utc::now().format("%Y%m%dT%H%M%SZ") + ))) +} + +fn file_size(path: &Path) -> Result { + Ok(fs::metadata(path) + .with_context(|| format!("stat {}", path.display()))? + .len()) +} + +fn sqlite_string_literal(path: &Path) -> String { + format!("'{}'", path.display().to_string().replace('\'', "''")) +} + +fn preserve_sqlite_metadata(src: &Path, dst: &Path) -> Result<()> { + let metadata = fs::metadata(src).with_context(|| format!("stat {}", src.display()))?; + let permissions = metadata.permissions(); + fs::set_permissions(dst, permissions) + .with_context(|| format!("preserve permissions for {}", dst.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + let dst_metadata = fs::metadata(dst).with_context(|| format!("stat {}", dst.display()))?; + if dst_metadata.uid() != metadata.uid() || dst_metadata.gid() != metadata.gid() { + let status = Command::new("chown") + .arg(format!("{}:{}", metadata.uid(), metadata.gid())) + .arg(dst) + .status() + .context("run chown for vacuumed SQLite DB")?; + if !status.success() { + bail!("chown failed for {}", dst.display()); + } + } + } + Ok(()) +} + +fn systemctl_is_active(unit: &str) -> Result { + let load_state = systemctl_load_state(unit)?; + if load_state != "loaded" { + bail!("refusing SQLite VACUUM because systemd unit {unit} load_state={load_state:?}"); + } + let output = Command::new("systemctl") + .args(["is-active", unit]) + .output() + .with_context(|| format!("systemctl is-active {unit}"))?; + let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if output.status.success() && state == "active" { + return Ok(true); + } + if output.status.code() == Some(3) && state == "inactive" { + return Ok(false); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + bail!( + "refusing SQLite VACUUM because systemctl is-active {unit} returned state={state:?}, status={}, stderr={stderr:?}", + output.status + ); +} + +fn systemctl_load_state(unit: &str) -> Result { + let output = Command::new("systemctl") + .args(["show", "-p", "LoadState", "--value", unit]) + .output() + .with_context(|| format!("systemctl show LoadState {unit}"))?; + let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if output.status.success() && !state.is_empty() { + return Ok(state); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + bail!( + "refusing SQLite VACUUM because systemctl show LoadState {unit} failed with status={}, stderr={stderr:?}", + output.status + ); +} + +fn systemctl_action(action: &str, unit: &str) -> Result<()> { + let status = Command::new("systemctl") + .args([action, unit]) + .status() + .with_context(|| format!("systemctl {action} {unit}"))?; + if status.success() { + Ok(()) + } else { + bail!("systemctl {action} {unit} failed with status {status}"); + } +} + +impl ServiceGuard { + fn stop_if_active(unit: &str) -> Result { + let was_active = systemctl_is_active(unit)?; + if was_active { + systemctl_action("stop", unit)?; + } + Ok(Self { + unit: unit.to_string(), + was_active, + restored: !was_active, + }) + } + + fn restore(&mut self) -> Result { + if self.was_active && !self.restored { + systemctl_action("start", &self.unit)?; + self.restored = true; + } + Ok(self.was_active) + } +} + +impl Drop for ServiceGuard { + fn drop(&mut self) { + if self.was_active && !self.restored { + let _ = systemctl_action("start", &self.unit); + } + } +} + +impl TempFileGuard { + fn new(path: PathBuf) -> Self { + Self { path, keep: false } + } + + fn disarm(&mut self) { + self.keep = true; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if !self.keep { + let _ = fs::remove_file(&self.path); + } + } +} + +impl LockFileGuard { + fn acquire(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create lock parent {}", parent.display()))?; + } + let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + bail!("maintenance lock already exists: {}", path.display()); + } + Err(err) => return Err(err).with_context(|| format!("create lock {}", path.display())), + }; + writeln!( + file, + "pid={} generated_at_utc={}", + std::process::id(), + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) + ) + .with_context(|| format!("write lock {}", path.display()))?; + Ok(Self { + path: path.to_path_buf(), + }) + } +} + +impl Drop for LockFileGuard { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + fn print_text(report: &Report) { println!( "aw-db-maintenance: {}", @@ -255,6 +660,7 @@ fn print_text(report: &Report) { println!("planned_delete_rows: {}", report.planned_delete_rows); println!("deleted_rows: {}", report.deleted_rows); println!("backup_created: {}", report.backup_created); + println!("lock_path: {}", report.lock_path); if let Some(path) = &report.backup_path { println!("backup_path: {path}"); } @@ -263,6 +669,38 @@ fn print_text(report: &Report) { } } +fn print_vacuum_text(report: &VacuumReport) { + println!( + "aw-db-vacuum: {}", + if report.apply { "apply" } else { "dry-run" } + ); + println!("db_path: {}", report.db_path); + println!("service_unit: {}", report.service_unit); + println!("service_was_active: {}", report.service_was_active); + println!("service_restarted: {}", report.service_restarted); + println!("backup_created: {}", report.backup_created); + println!("lock_path: {}", report.lock_path); + if let Some(path) = &report.backup_path { + println!("backup_path: {path}"); + } + if let Some(size) = report.db_size_before_bytes { + println!("db_size_before_bytes: {size}"); + } + if let Some(path) = &report.vacuumed_path { + println!("vacuumed_path: {path}"); + } + if let Some(size) = report.vacuumed_size_bytes { + println!("vacuumed_size_bytes: {size}"); + } + if let Some(check) = &report.integrity_check { + println!("integrity_check: {check}"); + } + println!("replaced_db: {}", report.replaced_db); + if let Some(reason) = &report.skipped_reason { + println!("skipped_reason: {reason}"); + } +} + #[cfg(test)] mod tests { use super::*; @@ -290,6 +728,9 @@ mod tests { retention_days: 7, chunk_size: 100, apply: false, + vacuum: false, + service_unit: DEFAULT_SERVICE_UNIT.to_string(), + lock_path: dir.path().join("maintenance.lock"), json: true, }; let report = build_report(&cli).unwrap(); @@ -312,6 +753,9 @@ mod tests { retention_days: 7, chunk_size: 1, apply: true, + vacuum: false, + service_unit: DEFAULT_SERVICE_UNIT.to_string(), + lock_path: dir.path().join("maintenance.lock"), json: true, }; let report = build_report(&cli).unwrap(); @@ -321,6 +765,73 @@ mod tests { assert_eq!(count_events(&db), 1); } + #[test] + fn vacuum_apply_compacts_database_and_preserves_rows() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("sqlite.db"); + create_vacuum_fixture_db(&db); + + let before = file_size(&db).unwrap(); + let result = vacuum_sqlite_db(&db, dir.path()).unwrap(); + let after = file_size(&db).unwrap(); + + assert!(result.vacuumed_size_bytes < result.db_size_before_bytes); + assert!(after < before); + assert_eq!(result.integrity_check, "ok"); + assert!(result.backup_path.exists()); + assert_eq!(count_rows(&db), 32); + } + + #[test] + fn vacuum_dry_run_skips_mutation() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("sqlite.db"); + create_vacuum_fixture_db(&db); + let cli = Cli { + db_path: db.clone(), + backup_dir: dir.path().join("backups"), + session_bucket: None, + host: None, + retention_days: 7, + chunk_size: 100, + apply: false, + vacuum: true, + service_unit: DEFAULT_SERVICE_UNIT.to_string(), + lock_path: dir.path().join("maintenance.lock"), + json: true, + }; + let report = build_vacuum_report(&cli).unwrap(); + assert!(!report.backup_created); + assert!(!report.replaced_db); + assert_eq!(report.skipped_reason.as_deref(), Some("dry-run")); + assert_eq!(count_rows(&db), 32); + } + + #[test] + fn apply_refuses_when_lock_exists() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("aw.db"); + create_fixture_db(&db); + let lock_path = dir.path().join("maintenance.lock"); + fs::write(&lock_path, "busy").unwrap(); + let cli = Cli { + db_path: db.clone(), + backup_dir: dir.path().join("backups"), + session_bucket: Some("aw-session-events_TEST".to_string()), + host: None, + retention_days: 7, + chunk_size: 1, + apply: true, + vacuum: false, + service_unit: DEFAULT_SERVICE_UNIT.to_string(), + lock_path, + json: true, + }; + let err = build_report(&cli).unwrap_err().to_string(); + assert!(err.contains("maintenance lock already exists")); + assert_eq!(count_events(&db), 3); + } + fn create_fixture_db(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch( @@ -350,10 +861,34 @@ mod tests { .unwrap(); } + fn create_vacuum_fixture_db(path: &Path) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "create table items (id integer primary key autoincrement, payload text);", + ) + .unwrap(); + let payload = "x".repeat(4096); + for _ in 0..64 { + conn.execute("insert into items (payload) values (?1)", [&payload]) + .unwrap(); + } + for id in 1..=32 { + conn.execute("delete from items where id = ?1", [id]) + .unwrap(); + } + } + fn count_events(path: &Path) -> i64 { Connection::open(path) .unwrap() .query_row("select count(*) from events", [], |row| row.get(0)) .unwrap() } + + fn count_rows(path: &Path) -> i64 { + Connection::open(path) + .unwrap() + .query_row("select count(*) from items", [], |row| row.get(0)) + .unwrap() + } } diff --git a/adk-rust/crates/quality-gate/src/main.rs b/adk-rust/crates/quality-gate/src/main.rs index f55a4a6..1fc883a 100644 --- a/adk-rust/crates/quality-gate/src/main.rs +++ b/adk-rust/crates/quality-gate/src/main.rs @@ -285,6 +285,7 @@ fn is_allowed_python_runtime_path(rel: &str) -> bool { || rel.starts_with("pfsense/") || rel == "proxmox/tsj_guardian_bot.py" || rel == "proxmox/test_tsj_guardian_bot.py" + || rel == "scripts/package_rust_release_binaries.py" } fn is_detmir_retired_runtime_path(rel: &str) -> bool { @@ -344,6 +345,9 @@ mod tests { assert!(is_allowed_python_runtime_path( "pfsense/pfsense-aw-poller.py" )); + assert!(is_allowed_python_runtime_path( + "scripts/package_rust_release_binaries.py" + )); } #[test] diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index a807e93..09f53db 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -16,6 +16,7 @@ aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}" aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}" aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" + aw_db_vacuum_timer_enabled: false tasks: - name: Установить базовые пакеты @@ -589,6 +590,44 @@ daemon_reload: true when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + - name: Установить aw-db-vacuum service до Influx проверок + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-db-vacuum.service" + dest: /etc/systemd/system/aw-db-vacuum.service + owner: root + group: root + mode: "0644" + when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + + - name: Установить aw-db-vacuum timer до Influx проверок + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-db-vacuum.timer" + dest: /etc/systemd/system/aw-db-vacuum.timer + owner: root + group: root + mode: "0644" + when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + + - name: Включить nightly aw-db-vacuum timer до Influx проверок + ansible.builtin.systemd: + name: aw-db-vacuum.timer + enabled: true + state: started + daemon_reload: true + when: + - aw_db_maintenance_rust_binary_early.stat.exists | default(false) + - aw_db_vacuum_timer_enabled | bool + + - name: Отключить nightly aw-db-vacuum timer если opt-in не задан + ansible.builtin.systemd: + name: aw-db-vacuum.timer + enabled: false + state: stopped + daemon_reload: true + when: + - aw_db_maintenance_rust_binary_early.stat.exists | default(false) + - not (aw_db_vacuum_timer_enabled | bool) + - name: Прочитать текущий aw-server.env для сохранения Influx token ansible.builtin.slurp: path: /etc/activitywatch/aw-server.env diff --git a/aw-server/aw-db-vacuum.service b/aw-server/aw-db-vacuum.service new file mode 100644 index 0000000..d539329 --- /dev/null +++ b/aw-server/aw-db-vacuum.service @@ -0,0 +1,19 @@ +[Unit] +Description=ActivityWatch SQLite nightly VACUUM +After=activitywatch-server.service +ConditionPathExists=/usr/local/bin/aw-db-maintenance +ConditionPathExists=/var/lib/activitywatch/aw-server-rust/sqlite.db +RequiresMountsFor=/var/lib/activitywatch + +[Service] +Type=oneshot +EnvironmentFile=-/etc/activitywatch/aw-server.env +ExecStart=/usr/local/bin/aw-db-maintenance --vacuum --apply --json +TimeoutStartSec=2h +SyslogIdentifier=aw-db-vacuum +StandardOutput=journal +StandardError=journal +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 +UMask=0077 diff --git a/aw-server/aw-db-vacuum.timer b/aw-server/aw-db-vacuum.timer new file mode 100644 index 0000000..c463a85 --- /dev/null +++ b/aw-server/aw-db-vacuum.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Nightly ActivityWatch SQLite VACUUM + +[Timer] +OnCalendar=*-*-* 02:10:00 +RandomizedDelaySec=10m +Persistent=true + +[Install] +WantedBy=timers.target