Skip to main content

cloudillo_search/
lib.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Full-text search for Cloudillo.
5//!
6//! # Where the index lives, and why
7//!
8//! Everything searchable — files, deep document parts, actions, profiles —
9//! lands in one `search_docs` table in the meta SQLite database, mirrored into
10//! an FTS5 virtual table. One table means one query surface and one ABAC
11//! predicate shape; FTS5 supplies `bm25()` ranking and `snippet()` for free;
12//! and RTDB, CRDT and blob content can all funnel through the same
13//! `serde_json::Value` → text extractor.
14//!
15//! # Rule-driven indexing
16//!
17//! No indexing-specific Rust is needed to index new content. What text an object
18//! carries is *declared* next to the thing it describes, in one extraction
19//! language ([`rules`], [`extract`]) shared by both sources of rules:
20//!
21//! - **Documents** — an app registers a *document-format manifest* ([`format`])
22//!   naming which RTDB/CRDT collections and fields carry text and what the
23//!   deep-link key is. [`indexer`] applies it to an exported document, so a hit
24//!   points at a subpage rather than at the whole file.
25//! - **Actions** — an action type's DSL definition carries a `search` block.
26//!   [`objects`] applies it. A type without one is not indexed; that absence is
27//!   the whole allowlist.
28//!
29//! Files and profiles keep a fixed mapping in [`objects`] rather than a
30//! manifest: their schema is server-owned and not app-extensible, so there is
31//! nothing for an app to declare.
32//!
33//! # Who writes which rows
34//!
35//! Every row is written from Rust, on the scheduler, after a debounce:
36//! [`objects`] owns the whole-object `'F'`/`'P'`/`'A'` rows and [`indexer`] the
37//! deep `'D'` parts. Neither decides who may see a hit: the meta adapter derives
38//! every row's ACL columns from its source table in SQL, in the same transaction
39//! as the write, overwriting whatever this crate passed in. The ACL values this
40//! crate supplies are advisory — SQL is the authority, so a visibility flip
41//! landing mid-debounce cannot be undone by the stale value that run carries.
42//!
43//! # Module map
44//!
45//! | Module | Responsibility |
46//! |---|---|
47//! | [`rules`] | Manifest JSON → validated `IndexRules` / `ActionSearchRules` |
48//! | [`extract`] | `serde_json::Value` → plain text |
49//! | [`prune`] | Deleting manifest-named nodes from a document before extraction |
50//! | [`objects`] | File / profile / action → its one index row; the debounced per-object task |
51//! | [`indexer`] | Stored document → deep index rows; the debounced per-document task |
52//! | [`crdt`] | Yjs document → the same JSON shape RTDB exports |
53//! | [`reindex`] | Bulk sweeps: startup backfill, weekly cron, rules-changed |
54//! | [`format`] | `/api/doc-formats` handlers |
55//! | [`admin`] | `POST /api/search/reindex` |
56//! | [`handler`] | `GET /api/search` |
57
58pub mod admin;
59pub mod crdt;
60pub mod extract;
61pub mod format;
62pub mod handler;
63pub mod indexer;
64pub mod objects;
65mod prelude;
66pub mod prune;
67pub mod reindex;
68pub mod rules;
69pub mod settings;
70
71pub use settings::register_settings;
72
73use cloudillo_core::app::App;
74use cloudillo_types::{error::ClResult, types::TnId};
75
76/// Revision of this build's extraction semantics.
77///
78/// Bump it by hand whenever what the extractor produces changes — a new extract
79/// mode applied to an existing manifest, a changed file/profile mapping, a new
80/// `search` block on an action type. A tenant whose stored revision differs gets
81/// one full sweep on the next startup and then stops re-extracting; see
82/// [`reindex`].
83pub const INDEX_REV: u32 = 3;
84
85/// Serialises document materialisation across the whole process.
86///
87/// Materialising a document is memory-unbounded: `export_all` hands back the
88/// whole document set owned, before any of `indexer::build_parts`' budgets
89/// apply. One at a time is affordable; several at once puts the node out of
90/// memory. The [`reindex`] sweep is already sequential — this bounds the
91/// scheduler-run `IndexDocumentTask`s, which run concurrently.
92///
93/// Held across the export → prune → `build_parts` span only, never across the
94/// meta-adapter write that follows.
95pub static MATERIALIZE_PERMIT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
96
97/// Whether this tenant keeps the extracted plain text alongside its index.
98///
99/// `true` (the default) routes its rows to the external-content `search_fts`,
100/// where `snippet()` works; `false` routes them to the contentless
101/// `search_fts_cl`, which indexes the same text but stores none of it.
102///
103/// Read on every index run and every query — cheap, since the settings service
104/// caches. A read failure falls back to the default: a tenant whose setting
105/// cannot be read is better indexed the ordinary way than not at all.
106///
107/// A flip migrates nothing by itself. Objects move as they are re-indexed, and
108/// the whole tenant on the next `reindex`, which [`reindex::index_stamp`] makes
109/// stale as soon as this value changes.
110pub async fn store_text(app: &App, tn_id: TnId) -> bool {
111	app.settings.get_bool(tn_id, "search.store_text").await.unwrap_or(true)
112}
113
114/// Register the search subsystem's scheduler tasks.
115///
116/// Must run during app initialization, before the scheduler loads persisted
117/// tasks — an unregistered task kind cannot be rebuilt from its stored row.
118pub fn init(app: &App) -> ClResult<()> {
119	app.scheduler.register::<indexer::IndexDocumentTask>()?;
120	app.scheduler.register::<objects::IndexObjectTask>()?;
121	app.scheduler.register::<reindex::ReindexTask>()?;
122	Ok(())
123}
124
125/// Schedule the recurring index maintenance.
126///
127/// Separate from [`init`] because it writes to the task store, so it belongs
128/// with the other `schedule_recurring` calls rather than with registration.
129///
130/// Two sweeps, not one with `run_on_startup`, because they answer different
131/// questions and a single task cannot tell which of its triggers fired. The
132/// weekly one always rebuilds — it is the safety net for a write path that
133/// forgot to ask for an index update. The startup one rebuilds only a tenant
134/// whose stored [`INDEX_REV`] is behind this build, so an ordinary restart costs
135/// one reaping DELETE per tenant instead of re-extracting everything.
136pub async fn schedule_recurring(app: &App) -> ClResult<()> {
137	// Two tries, not the default ten: the retry unit is a re-sweep of every file,
138	// profile and action of *every tenant on the node*, and only an aborted sweep
139	// gets this far (a per-object failure still completes the run). A retry storm
140	// costs more than a skipped run, and the next weekly sweep is the safety net.
141	let retry = || cloudillo_core::scheduler::RetryPolicy::new((60, 3600), 2);
142
143	app.scheduler
144		.task(std::sync::Arc::new(reindex::ReindexTask { scope: reindex::ReindexScope::All }))
145		.key("search.reindex:all")
146		// Sunday 03:30, when a full-index rebuild is least likely to compete
147		// with interactive traffic.
148		.weekly_at(0, 3, 30)
149		.with_retry(retry())
150		.schedule()
151		.await?;
152
153	// Delayed rather than immediate: startup is already the busiest moment a
154	// node has, and nothing is lost by letting it settle first.
155	app.scheduler
156		.task(std::sync::Arc::new(reindex::ReindexTask { scope: reindex::ReindexScope::Startup }))
157		.key("search.reindex:startup")
158		.with_retry(retry())
159		.after(30)
160		.await?;
161	Ok(())
162}
163
164// vim: ts=4