Skip to main content

cloudillo_search/
reindex.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Bulk (re)indexing sweeps.
5//!
6//! # What a sweep is for, and what it is not
7//!
8//! Every index row has a live write path: [`crate::objects`] is scheduled per
9//! changed file, profile and action, and [`crate::indexer`] per edited document.
10//! A sweep exists for what those cannot cover:
11//!
12//! - **`Startup`** — a pass on every boot. Cheap when the tenant's stored
13//!   [`crate::INDEX_REV`] already matches this build (it reaps orphans and stops)
14//!   and a full rebuild when it does not, which is how a changed extractor, a
15//!   new action manifest or a database that never had an index all converge.
16//! - **`All`** — a weekly cron, always a full sweep. The safety net for what a
17//!   SQL trigger would have covered: a write path can forget to ask for an index
18//!   update, and only a sweep that re-reads the source tables will notice.
19//! - **`ContentType`** — one format's index rules changed, so every document of
20//!   that type must be rebuilt against the new rules.
21//! - **`Tenant`** — one tenant's full sweep, used by `All` and available alone.
22//!
23//! Sweeps are idempotent: each object is replaced wholesale, so a partial run
24//! leaves some rows stale until the next one rather than corrupting anything.
25//!
26//! # Who hears about the outcome
27//!
28//! `Tenant` is the only scope that reports back to the user, as a
29//! `SEARCH_REINDEX_DONE` message on the tenant's WebSocket bus — it is the only
30//! scope a person can ask for (`POST /api/search/reindex`), so it is the only one
31//! anybody is waiting on. `All` and `Startup` are the server's own housekeeping
32//! and stay silent: a toast for a sweep nobody requested is an unexplained
33//! interruption. Exactly one message is sent per rebuild — on success, or on the
34//! first failure (saying a retry is coming), never on the retries that follow.
35
36use std::sync::Arc;
37
38use async_trait::async_trait;
39use cloudillo_core::scheduler::{Task, TaskId};
40use cloudillo_types::meta_adapter::{
41	ListActionOptions, ListFileOptions, ListProfileOptions, ListTenantsMetaOptions,
42};
43use serde::{Deserialize, Serialize};
44
45use crate::{indexer, objects, prelude::*};
46
47/// Rows fetched per page while sweeping.
48const PAGE: u32 = 200;
49/// Hard cap on pages, so a cursor that fails to advance cannot loop forever.
50const MAX_PAGES: u32 = 5000;
51
52/// `tenant_data` key holding the [`crate::INDEX_REV`] a tenant was last fully
53/// swept at.
54const INDEX_REV_KEY: &str = "search.index_rev";
55
56/// What one sweep touched. Logged at `info` on every completed run, because a
57/// sweep that silently does nothing and a sweep that rebuilt the whole tenant
58/// otherwise look identical from outside — and the difference is exactly what an
59/// operator needs when a search comes up empty.
60#[derive(Debug, Default, Clone, Copy)]
61pub struct SweepStats {
62	/// Files whose own `'F'` row was rewritten.
63	pub files: u64,
64	/// Files whose deep `'D'` parts were re-exported and rebuilt.
65	pub documents: u64,
66	pub profiles: u64,
67	pub actions: u64,
68	/// Objects that failed and were skipped.
69	///
70	/// Reported and logged, but **not** an error: a sweep that traversed
71	/// everything and could not index three objects has still done everything a
72	/// re-run would do. As a failure, one permanently broken object (a corrupt
73	/// redb document, an oversized CRDT log) would hold back `INDEX_REV_KEY`
74	/// forever, costing a whole-node re-sweep on every retry and a full tenant
75	/// rebuild on every boot. Only an *aborted* sweep — a listing or paging error,
76	/// which propagates with `?` — leaves the stamp untouched.
77	pub failed: u64,
78}
79
80impl SweepStats {
81	fn add(&mut self, other: Self) {
82		self.files += other.files;
83		self.documents += other.documents;
84		self.profiles += other.profiles;
85		self.actions += other.actions;
86		self.failed += other.failed;
87	}
88}
89
90/// What a sweep covers.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(tag = "scope", rename_all = "camelCase")]
93pub enum ReindexScope {
94	/// Every tenant on this node, unconditionally.
95	All,
96	/// Every tenant on this node, but only those whose stored index revision is
97	/// behind this build. The rest are merely reaped.
98	Startup,
99	/// One tenant's whole-object rows and every deep document.
100	Tenant { tn_id: TnId },
101	/// Every document of one content type in one tenant.
102	ContentType { tn_id: TnId, content_type: Box<str> },
103}
104
105/// Queue a rebuild of every document of `content_type`, after its index rules
106/// changed.
107///
108/// Fallible and awaited, not fire-and-forget, and both callers
109/// (`format::put_doc_format` and `format::delete_doc_format`) run it *before*
110/// `delete_deep_search_by_content_type`. The destructive step must not happen
111/// alone: dropped rows with no scheduled sweep are unfindable until the weekly
112/// `All` cron, whereas a sweep that finds the rows still present is a harmless
113/// no-op rebuild. Spawning instead leaves a window — SIGTERM, or a task-store
114/// write error — where the delete has committed and the sweep has not persisted.
115///
116/// The retry policy covers the other half: once the row is there, a sweep that
117/// fails still comes back.
118pub async fn schedule_content_type(app: &App, tn_id: TnId, content_type: &str) -> ClResult<()> {
119	let key = format!("search.reindex:{}:ct:{}", tn_id.0, content_type);
120	let task = ReindexTask {
121		scope: ReindexScope::ContentType { tn_id, content_type: content_type.into() },
122	};
123	app.scheduler
124		.task(Arc::new(task))
125		.key(key)
126		.with_retry(cloudillo_core::scheduler::RetryPolicy::default())
127		.after(5)
128		.await
129		.inspect_err(|e| {
130			warn!(tn_id = %tn_id, %content_type, error = %e,
131				"Failed to schedule search reindex");
132		})?;
133	Ok(())
134}
135
136/// Rebuild every index row of one tenant.
137///
138/// All four sub-steps are always attempted — one failing must not cost the
139/// others their run — but the first error is returned rather than swallowed. A
140/// sweep that logged and returned `Ok` would have the scheduler record a
141/// successful run and never retry, so a systematically broken step would warn
142/// weekly into the void.
143///
144/// "Failing" here means the step **aborted**: a listing or paging error, which
145/// propagates out of `reindex_files`/`_profiles`/`_actions` with `?`. A step that
146/// walked its whole set and skipped some objects returns `Ok` with
147/// [`SweepStats::failed`] set, and the stamp below is written — a completed sweep
148/// indexed everything it could at this build's stamp, and re-running it will not
149/// do better. `failed` stays in the logs and in the `ReindexResponse`.
150pub async fn reindex_tenant(app: &App, tn_id: TnId) -> ClResult<SweepStats> {
151	info!(tn_id = %tn_id, index_rev = crate::INDEX_REV, "Search reindex starting");
152	let started = std::time::Instant::now();
153
154	let mut stats = SweepStats::default();
155	let mut failure: Option<Error> = None;
156	let mut record =
157		|label: &str, result: ClResult<SweepStats>, stats: &mut SweepStats| match result {
158			Ok(step) => stats.add(step),
159			Err(e) => {
160				warn!(tn_id = %tn_id, step = label, error = %e, "Search reindex step failed");
161				failure.get_or_insert(e);
162			}
163		};
164
165	// Files first: a file's own row and its deep parts come out of the same page,
166	// so the sweep pays for one listing rather than two.
167	record("files", reindex_files(app, tn_id).await, &mut stats);
168	record("profiles", reindex_profiles(app, tn_id).await, &mut stats);
169	record("actions", reindex_actions(app, tn_id).await, &mut stats);
170	// Last, so it only ever removes rows this run had its chance to write.
171	record(
172		"reap",
173		app.meta_adapter
174			.reap_search_orphans(tn_id)
175			.await
176			.map(|()| SweepStats::default()),
177		&mut stats,
178	);
179
180	// Logged before the early return so a failed sweep still says how far it got —
181	// "12 of 40000 files" and "0 of 40000" call for very different next steps.
182	info!(
183		tn_id = %tn_id,
184		files = stats.files,
185		documents = stats.documents,
186		profiles = stats.profiles,
187		actions = stats.actions,
188		failed = stats.failed,
189		elapsed_ms = started.elapsed().as_millis(),
190		"Search reindex finished"
191	);
192
193	if let Some(e) = failure {
194		return Err(e);
195	}
196	app.meta_adapter
197		.write_tenant_data(tn_id, INDEX_REV_KEY, Some(&index_stamp(app, tn_id).await))
198		.await?;
199	Ok(stats)
200}
201
202/// The stamp a tenant's index was last built at.
203///
204/// [`crate::INDEX_REV`] alone is not enough, for two reasons, and both are folded
205/// in here rather than given persistence of their own — `Startup` already reads
206/// this stamp for every tenant on every boot, so anything that belongs in it
207/// self-heals with one sweep and no new machinery.
208///
209/// - `search.store_text` decides which of the two FTS tables a tenant's rows live
210///   in, so a flip has to invalidate the tenant exactly the way a revision bump
211///   does. There is no settings-change hook to hang that on —
212///   `SettingsService::set` only drops its cache entry.
213/// - `bundled_apps.rules_hash` covers the index rules this build's bundle
214///   declares. They are not written per tenant, so a bundle whose rules changed
215///   leaves every tenant indexed against the old ones with nothing else to
216///   notice: `INDEX_REV` did not move, and no PUT arrives to schedule the
217///   content-type sweep. It hashes the `search` blocks only, so a cosmetic
218///   manifest edit does not re-index the node.
219async fn index_stamp(app: &App, tn_id: TnId) -> String {
220	let store_text = crate::store_text(app, tn_id).await;
221	format!("{}:{}:{}", crate::INDEX_REV, u8::from(store_text), app.bundled_apps.rules_hash)
222}
223
224/// The startup pass for one tenant: a full sweep only if this build extracts
225/// differently from whatever last swept it.
226///
227/// Without this gate a restart would re-read and re-extract every object in every
228/// tenant — work proportional to the whole dataset.
229async fn reindex_tenant_if_stale(app: &App, tn_id: TnId) -> ClResult<SweepStats> {
230	let stored = app.meta_adapter.read_tenant_data(tn_id, INDEX_REV_KEY).await?;
231	let stamp = index_stamp(app, tn_id).await;
232	if stored.as_deref() == Some(stamp.as_str()) {
233		app.meta_adapter.reap_search_orphans(tn_id).await?;
234		return Ok(SweepStats::default());
235	}
236	info!(tn_id = %tn_id, index_stamp = %stamp, stored = ?stored,
237		"Search index revision changed; rebuilding");
238	reindex_tenant(app, tn_id).await
239}
240
241/// Re-index every file of a tenant, and the deep parts of the ones backed by a
242/// document store.
243///
244/// Two server-only listing flags widen it to every row in the table, because a
245/// sweep that cannot see a file cannot *remove* what a forgotten hook left
246/// behind — the failure mode the weekly sweep exists for.
247/// `include_tree_children` takes in document-tree parts (hidden by the default
248/// listing, which shows containers rather than their parts); `sweep_all` takes in
249/// trashed, managed, hidden and soft-deleted rows. Managed and hidden files are
250/// indexable and get re-verified like any other; trashed and deleted ones resolve
251/// to `part = None` and have their rows dropped.
252async fn reindex_files(app: &App, tn_id: TnId) -> ClResult<SweepStats> {
253	page_files(app, tn_id, None).await
254}
255
256/// Deep-index every document of one content type — the "rules changed" case,
257/// where touching unrelated files would be wasted work.
258///
259/// Narrows by `file_type` as well, because a content type is only ever backed by
260/// one store and listing blobs of the same type would find nothing to export.
261async fn reindex_documents(app: &App, tn_id: TnId, content_type: &str) -> ClResult<SweepStats> {
262	page_files(app, tn_id, Some(content_type)).await
263}
264
265/// Walk a tenant's files a page at a time.
266///
267/// `only_content_type` selects both the filter and the work: `None` is the full
268/// sweep — every file's own `'F'` row, plus the deep `'D'` parts of the ones
269/// backed by a document store — while `Some(ct)` rebuilds only the deep parts of
270/// that one content type, whose `'F'` rows did not change when its rules did.
271///
272/// A cursor beats `offset`: the sweep writes while it walks, and offsets would
273/// skip rows as the set shifts. Per-file failures are counted and reported at
274/// the end rather than aborting, for the reason given on [`reindex_tenant`].
275async fn page_files(
276	app: &App,
277	tn_id: TnId,
278	only_content_type: Option<&str>,
279) -> ClResult<SweepStats> {
280	let whole_rows = only_content_type.is_none();
281	let mut stats = SweepStats::default();
282	let mut cursor: Option<String> = None;
283	let mut hit_cap = true;
284	for _ in 0..MAX_PAGES {
285		let opts = ListFileOptions {
286			limit: Some(PAGE),
287			cursor: cursor.clone(),
288			file_type: only_content_type
289				.map(|_| vec![indexer::STORE_RTDB.to_owned(), indexer::STORE_CRDT.to_owned()]),
290			content_type: only_content_type.map(|ct| vec![ct.to_owned()]),
291			include_tree_children: true,
292			// See `reindex_files`: without the rows a browse listing hides, the
293			// sweep can only ever add index rows, never remove one.
294			sweep_all: true,
295			..Default::default()
296		};
297		let files = app.meta_adapter.list_files(tn_id, &opts).await?;
298		if files.is_empty() {
299			hit_cap = false;
300			break;
301		}
302
303		for file in &files {
304			let deep =
305				matches!(file.file_tp.as_deref(), Some(indexer::STORE_RTDB | indexer::STORE_CRDT));
306			if let Err(e) = index_one_file(app, tn_id, file, whole_rows).await {
307				warn!(tn_id = %tn_id, file_id = %file.file_id, error = %e,
308					"Search reindex: file failed");
309				stats.failed += 1;
310				continue;
311			}
312			stats.files += u64::from(whole_rows);
313			stats.documents += u64::from(deep);
314		}
315
316		if files.len() < PAGE as usize {
317			hit_cap = false;
318			break;
319		}
320		let Some(last) = files.last() else {
321			hit_cap = false;
322			break;
323		};
324		cursor = Some(
325			cloudillo_types::types::CursorData::new(
326				"created",
327				last.created_at.0.into(),
328				&last.file_id,
329			)
330			.encode(),
331		);
332	}
333	if hit_cap {
334		warn!(tn_id = %tn_id, "Search reindex: file sweep hit the page cap");
335	}
336	Ok(stats)
337}
338
339/// One file's share of a sweep: its own row when `whole_row`, and its deep parts
340/// whenever it is backed by a document store.
341async fn index_one_file(
342	app: &App,
343	tn_id: TnId,
344	file: &cloudillo_types::meta_adapter::FileView,
345	whole_row: bool,
346) -> ClResult<()> {
347	if whole_row {
348		objects::index_file_row(app, tn_id, file).await?;
349	}
350	if matches!(file.file_tp.as_deref(), Some(indexer::STORE_RTDB | indexer::STORE_CRDT)) {
351		indexer::index_document(app, tn_id, &file.file_id).await?;
352	}
353	Ok(())
354}
355
356/// Re-index every profile of a tenant, paging on `id_tag`.
357async fn reindex_profiles(app: &App, tn_id: TnId) -> ClResult<SweepStats> {
358	let mut stats = SweepStats::default();
359	let mut after: Option<String> = None;
360	let mut hit_cap = true;
361	for _ in 0..MAX_PAGES {
362		let opts = ListProfileOptions {
363			limit: Some(PAGE),
364			after_id_tag: after.clone(),
365			..Default::default()
366		};
367		let profiles = app.meta_adapter.list_profiles(tn_id, &opts).await?;
368		let Some(last) = profiles.last() else {
369			hit_cap = false;
370			break;
371		};
372		after = Some(last.id_tag.to_string());
373
374		for profile in &profiles {
375			if let Err(e) = objects::index_profile_row(app, tn_id, profile).await {
376				warn!(tn_id = %tn_id, id_tag = %profile.id_tag, error = %e,
377					"Search reindex: profile failed");
378				stats.failed += 1;
379			} else {
380				stats.profiles += 1;
381			}
382		}
383		if profiles.len() < PAGE as usize {
384			hit_cap = false;
385			break;
386		}
387	}
388	if hit_cap {
389		warn!(tn_id = %tn_id, "Search reindex: profile sweep hit the page cap");
390	}
391	Ok(stats)
392}
393
394/// Every status an `actions` row can carry: `'A'` active, `'P'` pending (not yet
395/// finalized), `'R'` draft, `'D'` soft-deleted, `'V'` inbound-verifying and `'F'`
396/// permanently failed.
397///
398/// Spelled out so the sweep sees *all* of them. An absent `status` filter is not
399/// "no filter" in the meta adapter — `push_action_filters` reads it as the
400/// client-facing default `NOT IN ('D', 'V', 'F')`, which hides exactly the rows
401/// the sweep has to visit in order to *un*-index them.
402const ALL_ACTION_STATUSES: [&str; 6] = ["A", "P", "R", "D", "V", "F"];
403
404/// Re-index every action of a tenant.
405///
406/// The listing already hands back a hydrated `ActionView`, so this costs one
407/// query per page rather than one per action.
408///
409/// Both filters that could narrow the listing are deliberately widened to
410/// see-everything, for the same reason: an internal sweep that cannot see a row
411/// cannot correct that row's index entry.
412///
413/// - `visibility_guard` is left `Patch::Undefined`, so filtering by a viewer
414///   does not leave exactly the private rows unindexed.
415/// - `status` is [`ALL_ACTION_STATUSES`], so retracted rows are visited too.
416///   Nothing else would ever un-index them: `reap_search_orphans` only drops rows
417///   whose `actions` row is physically gone, and a soft delete leaves it in
418///   place. `objects::index_action_row` decides indexability itself — its
419///   `is_live` check routes anything but an Active, non-tombstone row to the
420///   `part = None` deletion path.
421async fn reindex_actions(app: &App, tn_id: TnId) -> ClResult<SweepStats> {
422	let mut stats = SweepStats::default();
423	let mut cursor: Option<String> = None;
424	let mut hit_cap = true;
425	for _ in 0..MAX_PAGES {
426		let opts = ListActionOptions {
427			limit: Some(PAGE),
428			cursor: cursor.clone(),
429			sort: Some("created".to_owned()),
430			status: Some(ALL_ACTION_STATUSES.iter().map(|s| (*s).to_owned()).collect()),
431			..Default::default()
432		};
433		let actions = app.meta_adapter.list_actions(tn_id, &opts).await?;
434		let Some(last) = actions.last() else {
435			hit_cap = false;
436			break;
437		};
438		cursor = Some(
439			cloudillo_types::types::CursorData::new(
440				"created",
441				last.created_at.0.into(),
442				&last.action_id,
443			)
444			.encode(),
445		);
446
447		for action in &actions {
448			if let Err(e) = objects::index_action_row(app, tn_id, action).await {
449				warn!(tn_id = %tn_id, action_id = %action.action_id, error = %e,
450					"Search reindex: action failed");
451				stats.failed += 1;
452			} else {
453				stats.actions += 1;
454			}
455		}
456		if actions.len() < PAGE as usize {
457			hit_cap = false;
458			break;
459		}
460	}
461	if hit_cap {
462		warn!(tn_id = %tn_id, "Search reindex: action sweep hit the page cap");
463	}
464	Ok(stats)
465}
466
467/// The scheduled sweep. See the module docs for the four scopes.
468#[derive(Debug, Serialize, Deserialize)]
469pub struct ReindexTask {
470	#[serde(flatten)]
471	pub scope: ReindexScope,
472}
473
474/// Push the outcome of a user-requested rebuild to the tenant's open tabs.
475///
476/// Fire-and-forget by design: nobody being connected is the normal case for a
477/// sweep that ran for minutes, and a dropped notification must never fail the
478/// task or block its retry.
479async fn notify_reindex(app: &App, tn_id: TnId, data: serde_json::Value) {
480	let msg =
481		cloudillo_core::ws_broadcast::BroadcastMessage::new("SEARCH_REINDEX_DONE", data, "system");
482	let delivered = app.broadcast.send_to_tenant(tn_id, msg).await;
483	debug!(tn_id = %tn_id, delivered, "Search reindex outcome broadcast");
484}
485
486impl ReindexTask {
487	/// The failure half of [`notify_reindex`], shared by both scheduler hooks.
488	/// Silent for every scope but `Tenant` — see the module docs.
489	async fn notify_failure(&self, app: &App, will_retry: bool, error: &str) {
490		let ReindexScope::Tenant { tn_id } = self.scope else { return };
491		notify_reindex(
492			app,
493			tn_id,
494			serde_json::json!({ "ok": false, "willRetry": will_retry, "error": error }),
495		)
496		.await;
497	}
498}
499
500#[async_trait]
501impl Task<App> for ReindexTask {
502	fn kind() -> &'static str {
503		"search.reindex"
504	}
505
506	fn kind_of(&self) -> &'static str {
507		Self::kind()
508	}
509
510	fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
511		Ok(Arc::new(serde_json::from_str::<Self>(ctx)?))
512	}
513
514	fn serialize(&self) -> String {
515		// Built by hand rather than via `to_string().unwrap_or(…)`, like the sibling
516		// tasks in `objects` and `indexer`. A string fallback has to name *some*
517		// scope, and every scope that always parses is broader than the one that was
518		// asked for: `{"scope":"all"}` turns one tenant's rebuild into a sweep of
519		// every tenant on the node, persisted as a row that no longer describes the
520		// request behind it. This shape cannot fail, so there is no fallback to get
521		// wrong.
522		//
523		// Mirrors `ReindexScope`'s internally-tagged representation exactly:
524		// `rename_all = "camelCase"` renames variants, not their fields, so the
525		// field keys stay `tn_id` / `content_type`. The round-trip test below pins
526		// this against the derive.
527		let mut obj = serde_json::Map::with_capacity(3);
528		match &self.scope {
529			ReindexScope::All => {
530				obj.insert("scope".into(), "all".into());
531			}
532			ReindexScope::Startup => {
533				obj.insert("scope".into(), "startup".into());
534			}
535			ReindexScope::Tenant { tn_id } => {
536				obj.insert("scope".into(), "tenant".into());
537				obj.insert("tn_id".into(), tn_id.0.into());
538			}
539			ReindexScope::ContentType { tn_id, content_type } => {
540				obj.insert("scope".into(), "contentType".into());
541				obj.insert("tn_id".into(), tn_id.0.into());
542				obj.insert("content_type".into(), content_type.as_ref().into());
543			}
544		}
545		serde_json::Value::Object(obj).to_string()
546	}
547
548	async fn run(&self, app: &App) -> ClResult<()> {
549		match &self.scope {
550			ReindexScope::All => every_tenant(app, false).await,
551			ReindexScope::Startup => every_tenant(app, true).await,
552			ReindexScope::Tenant { tn_id } => {
553				// The only scope that reports back — see the module docs. `?`
554				// short-circuits on failure; the hooks below own that path.
555				let started = std::time::Instant::now();
556				let stats = reindex_tenant(app, *tn_id).await?;
557				notify_reindex(
558					app,
559					*tn_id,
560					serde_json::json!({
561						"ok": true,
562						"files": stats.files,
563						"documents": stats.documents,
564						"profiles": stats.profiles,
565						"actions": stats.actions,
566						"failed": stats.failed,
567						"indexRev": crate::INDEX_REV,
568						"elapsedMs": u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
569					}),
570				)
571				.await;
572				Ok(())
573			}
574			ReindexScope::ContentType { tn_id, content_type } => {
575				let stats = reindex_documents(app, *tn_id, content_type).await?;
576				info!(tn_id = %tn_id, %content_type, documents = stats.documents,
577					"Search reindex finished for one content type");
578				Ok(())
579			}
580		}
581	}
582
583	/// First failure only: one message, then silence while the scheduler retries.
584	async fn on_attempt_failed(&self, app: &App, attempt: u16, error: &str) {
585		if attempt == 0 {
586			self.notify_failure(app, true, error).await;
587		}
588	}
589
590	/// Reached either after the retries are exhausted — in which case
591	/// `on_attempt_failed` already spoke at attempt 0 and we stay quiet — or
592	/// immediately for a non-retryable error, which never went through a retry.
593	async fn on_failed(&self, app: &App, attempts: u16, error: &str) {
594		if attempts == 0 {
595			self.notify_failure(app, false, error).await;
596		}
597	}
598}
599
600/// Sweep every tenant on the node. One bad tenant must not abort the loop, but
601/// the task as a whole has to report failure or the scheduler will never retry.
602///
603/// "Bad" means a tenant whose sweep **aborted**. A tenant that completed with
604/// skipped objects counts as a success here and is visible through
605/// `objects_failed` in the summary below — see [`SweepStats::failed`] for why a
606/// single unindexable object must not make the whole node's sweep retryable.
607async fn every_tenant(app: &App, only_if_stale: bool) -> ClResult<()> {
608	let tenants = app.meta_adapter.list_tenants(&ListTenantsMetaOptions::default()).await?;
609	let started = std::time::Instant::now();
610	let mut total = SweepStats::default();
611	let mut failed = 0usize;
612	for tenant in &tenants {
613		let result = if only_if_stale {
614			reindex_tenant_if_stale(app, tenant.tn_id).await
615		} else {
616			reindex_tenant(app, tenant.tn_id).await
617		};
618		match result {
619			Ok(stats) => total.add(stats),
620			Err(e) => {
621				warn!(tn_id = %tenant.tn_id, error = %e, "Search reindex: tenant failed");
622				failed += 1;
623			}
624		}
625	}
626
627	// After the loop, not inside it: both FTS tables are database-wide, so
628	// optimizing per tenant would redo the same whole-index work once per tenant.
629	// A rebuild leaves a lot of small segments (and, on the contentless table,
630	// a tombstone per deleted row) — this is where merging them pays.
631	//
632	// Only when something was actually rebuilt, though. An `only_if_stale` sweep
633	// in which every tenant short-circuited wrote no rows, so there are no new
634	// segments to merge and a node-wide index rewrite 30 s after every boot buys
635	// nothing; the nightly maintenance task covers steady-state merging.
636	let did_work =
637		total.files > 0 || total.documents > 0 || total.profiles > 0 || total.actions > 0;
638	if did_work && let Err(e) = app.meta_adapter.optimize_search_index(true).await {
639		warn!(error = %e, "Search reindex: FTS optimize failed");
640	}
641
642	info!(
643		tenants = tenants.len(),
644		tenants_failed = failed,
645		files = total.files,
646		documents = total.documents,
647		profiles = total.profiles,
648		actions = total.actions,
649		objects_failed = total.failed,
650		elapsed_ms = started.elapsed().as_millis(),
651		startup_gated = only_if_stale,
652		optimized = did_work,
653		"Search reindex sweep finished"
654	);
655	if failed > 0 {
656		return Err(Error::Internal(format!(
657			"search reindex failed for {failed} of {} tenants",
658			tenants.len()
659		)));
660	}
661	Ok(())
662}
663
664#[cfg(test)]
665mod tests {
666	use super::*;
667
668	/// The hand-built `serialize` must describe exactly what the derive would, for
669	/// every scope — otherwise a row written by one and read by the other is a task
670	/// the scheduler can never rebuild. Compared as parsed JSON rather than as
671	/// bytes because key order carries no meaning here: every reader parses the row,
672	/// so the two writers need only agree on the key/value set.
673	#[test]
674	fn every_scope_serializes_exactly_as_the_derive_would() {
675		let scopes = [
676			ReindexScope::All,
677			ReindexScope::Startup,
678			ReindexScope::Tenant { tn_id: TnId(7) },
679			ReindexScope::ContentType { tn_id: TnId(7), content_type: "cloudillo/notillo".into() },
680		];
681		for scope in scopes {
682			let task = ReindexTask { scope };
683			let derived = serde_json::to_string(&task).expect("derive serializes");
684			let ours = <ReindexTask as Task<App>>::serialize(&task);
685			assert_eq!(
686				serde_json::from_str::<serde_json::Value>(&ours).expect("ours parses"),
687				serde_json::from_str::<serde_json::Value>(&derived).expect("derived parses"),
688				"hand-built form {ours} drifted from the derive's {derived}"
689			);
690		}
691	}
692
693	/// And it must round-trip back into the *same* scope. The old fallback widened a
694	/// `Tenant` request into a whole-node `All` sweep; nothing here may do that.
695	#[test]
696	fn a_persisted_task_rebuilds_with_the_scope_it_was_created_with() {
697		let task = ReindexTask { scope: ReindexScope::Tenant { tn_id: TnId(42) } };
698		let stored = <ReindexTask as Task<App>>::serialize(&task);
699		let back: ReindexTask = serde_json::from_str(&stored).expect("round-trips");
700		assert!(
701			matches!(back.scope, ReindexScope::Tenant { tn_id } if tn_id == TnId(42)),
702			"got {:?}",
703			back.scope
704		);
705	}
706}
707
708// vim: ts=4