cloudillo_core/maintenance.rs
1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Nightly metadata-database maintenance.
5//!
6//! Two steps, in order:
7//!
8//! 1. FTS merge — folds the many small segments a day of index writes leaves
9//! behind, and clears the tombstones the contentless index accumulates on
10//! every delete.
11//! 2. space reclaim — checkpoints the WAL, refreshes query statistics, and
12//! rewrites the database only when enough of it is dead space to be worth
13//! the write lock.
14//!
15//! Not per tenant. `meta.db` is one global file and both FTS indexes span every
16//! tenant, so a tenant loop would repeat identical whole-database work.
17
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22
23use crate::prelude::*;
24use crate::scheduler::{Task, TaskId};
25
26/// Settings are global-scope, so they are read against the shared tenant.
27const SHARED_TN: TnId = TnId(0);
28const DEFAULT_CRON: &str = "20 4 * * *";
29const DEFAULT_MIN_FREE_PCT: i64 = 20;
30
31/// Meta-database maintenance. Scheduled via cron at process start, and on
32/// demand from `POST /api/admin/db-maintenance`.
33#[derive(Debug, Default, Serialize, Deserialize)]
34pub struct DbMaintenanceTask {
35 /// Tenant to push the outcome to when the sweep ends. `None` — the nightly
36 /// run — stays silent: nobody asked for it, so nobody is waiting.
37 ///
38 /// **Not serialized**, so it is not part of the task's stored parameters.
39 /// The sweep is node-wide; which tenant happened to press the button is a
40 /// property of *this* request, not of the work. Serialized, two tenants
41 /// triggering the same `core.db_maintenance:manual` key would produce two
42 /// different parameter strings and send the scheduler down its "parameters
43 /// changed" path. Identical parameters take the "already exists" branch and
44 /// hand the second caller the running task's id.
45 ///
46 /// The cost is that a task rebuilt from its persisted row after a restart
47 /// notifies nobody. That is the right outcome anyway: the browser that
48 /// asked is long gone, and the notification is fire-and-forget.
49 #[serde(default, skip_serializing)]
50 pub notify_tn: Option<TnId>,
51}
52
53#[async_trait]
54impl Task<App> for DbMaintenanceTask {
55 fn kind() -> &'static str {
56 "core.db_maintenance"
57 }
58 fn kind_of(&self) -> &'static str {
59 Self::kind()
60 }
61
62 fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
63 // Rows persisted before this task carried any context have an empty ctx.
64 if ctx.is_empty() {
65 Ok(Arc::new(Self::default()))
66 } else {
67 Ok(Arc::new(serde_json::from_str::<Self>(ctx)?))
68 }
69 }
70
71 fn serialize(&self) -> String {
72 serde_json::to_string(self).unwrap_or_default()
73 }
74
75 // Deliberately no retry policy, matching `file.gc`: a retry storm of VACUUMs
76 // — each holding the single write connection — is far worse than a skipped
77 // night, and the next tick is only 24 hours away. Each step therefore
78 // warns and continues instead of aborting the one after it.
79 async fn run(&self, app: &App) -> ClResult<()> {
80 let min_free_pct = app
81 .settings
82 .get_int_opt(SHARED_TN, "core.vacuum_min_free_pct")
83 .await
84 .ok()
85 .flatten()
86 .unwrap_or(DEFAULT_MIN_FREE_PCT);
87
88 if let Err(e) = app.meta_adapter.optimize_search_index(false).await {
89 warn!(error = %e, "db_maintenance: FTS merge failed");
90 }
91
92 let report = match app.meta_adapter.reclaim_space(min_free_pct).await {
93 Ok(report) => {
94 info!(
95 page_size = report.page_size,
96 page_count = report.page_count,
97 freelist_count = report.freelist_count,
98 vacuumed = report.vacuumed,
99 min_free_pct,
100 "db_maintenance: sweep complete"
101 );
102 Some(report)
103 }
104 Err(e) => {
105 warn!(error = %e, "db_maintenance: space reclaim failed");
106 None
107 }
108 };
109
110 // The document stores. Both only return space already dead inside their
111 // files, so the numbers stay small until CRDT update-log growth is
112 // addressed separately — logging before/after is what makes that visible.
113 for (what, result) in [
114 ("rtdb", app.rtdb_adapter.compact_storage().await),
115 ("crdt", app.crdt_adapter.compact_storage().await),
116 ] {
117 match result {
118 Ok(r) => info!(
119 store = what,
120 files = r.files,
121 bytes_before = r.bytes_before,
122 bytes_after = r.bytes_after,
123 "db_maintenance: compacted"
124 ),
125 Err(e) => warn!(store = what, error = %e, "db_maintenance: compaction failed"),
126 }
127 }
128
129 // Fire-and-forget, like the reindex notification: a run that took
130 // minutes usually has nobody connected any more, and a dropped message
131 // must not fail the task.
132 if let Some(tn_id) = self.notify_tn {
133 let data = match report {
134 Some(r) => serde_json::json!({
135 "ok": true,
136 "vacuumed": r.vacuumed,
137 "pageSize": r.page_size,
138 "pageCount": r.page_count,
139 "freelistCount": r.freelist_count,
140 }),
141 None => serde_json::json!({ "ok": false }),
142 };
143 let msg =
144 crate::ws_broadcast::BroadcastMessage::new("DB_MAINTENANCE_DONE", data, "system");
145 let delivered = app.broadcast.send_to_tenant(tn_id, msg).await;
146 debug!(tn_id = %tn_id, delivered, "db_maintenance outcome broadcast");
147 }
148 Ok(())
149 }
150}
151
152/// Register the maintenance task kind with the scheduler.
153///
154/// Must run before the scheduler loads persisted tasks — an unregistered kind
155/// cannot be rebuilt from its stored row.
156pub fn init(app: &App) -> ClResult<()> {
157 app.scheduler.register::<DbMaintenanceTask>()?;
158 Ok(())
159}
160
161/// Schedule the nightly run.
162///
163/// Reads `core.db_maintenance_cron` (default `20 4 * * *` — 20 minutes after the
164/// file GC's default 4am slot, so the two do not overlap).
165///
166/// Note: the cron expression is read once during boot. Changing it at runtime
167/// requires a process restart to take effect. `core.vacuum_min_free_pct` is
168/// re-read on every tick.
169pub async fn schedule(app: &App) -> ClResult<()> {
170 let cron = app
171 .settings
172 .get_string_opt(SHARED_TN, "core.db_maintenance_cron")
173 .await
174 .ok()
175 .flatten()
176 .unwrap_or_else(|| DEFAULT_CRON.to_string());
177
178 // `notify_tn: None` — the nightly run reports to the log, not to a browser.
179 let task: Arc<dyn Task<App>> = Arc::new(DbMaintenanceTask::default());
180 app.scheduler
181 .task(task)
182 .key("core.db_maintenance")
183 .cron(cron)
184 .run_on_startup()
185 .schedule()
186 .await?;
187 Ok(())
188}
189
190// vim: ts=4