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