Skip to main content

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