Skip to main content

cloudillo_search/
indexer.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Turning a stored document into `search_docs` rows.
5//!
6//! # Debounce
7//!
8//! Every RTDB commit calls [`schedule`], which enqueues a `search.index` task
9//! keyed `"search.index:{tn_id}:{file_id}"` with a quiet delay. The scheduler's
10//! own key dedup bumps an existing task's `next_at` forward instead of queuing
11//! a second one, so a typing burst collapses into exactly one index run — the
12//! same mechanism the STAT emitter relies on.
13//!
14//! Two layers, because they bound different things. The scheduler's key dedup
15//! bounds how often the *index* runs. It does not bound how often the scheduler
16//! is *touched* — each `schedule` call is a `find_by_key` on the read pool plus
17//! an `update_task` on meta.db's single write connection — so an in-process
18//! throttle ([`THROTTLE_SECS`]) bounds that in front of it.
19//!
20//! # Full re-export, not diffing
21//!
22//! Each run re-exports the whole document and replaces every row for it. App
23//! documents are small, the debounce keeps this off the hot path, and
24//! correctness needs no reasoning about partial state. Revisit only if
25//! profiling says so.
26
27use std::{
28	collections::HashMap,
29	sync::{Arc, LazyLock, Mutex},
30	time::{Duration, Instant},
31};
32
33use async_trait::async_trait;
34use cloudillo_core::scheduler::{Task, TaskId};
35use cloudillo_types::meta_adapter::{SearchObject, SearchPart};
36use serde::{Deserialize, Serialize};
37
38use crate::{
39	extract::{TextSink, extract_fields, resolve_str},
40	prelude::*,
41	rules::{DOC_ID, IndexRules, PartRule},
42};
43
44/// Seconds of quiet before a modified document is indexed.
45pub const DEBOUNCE_SECS: i64 = 30;
46
47/// `obj_tp` for a whole file row.
48pub const OBJ_FILE: char = 'F';
49/// `obj_tp` for a deep sub-part of a document.
50pub const OBJ_DOC: char = 'D';
51
52/// How many attached contributions `build_parts` will hold in memory at once.
53///
54/// A count bound alongside the char budget, because the two fail differently: a
55/// document of a million one-character blocks never exhausts `max_total_chars`
56/// but does exhaust memory. Not a manifest knob — an app cannot usefully raise
57/// or lower a bound that exists to protect the node, and every manifest field is
58/// a field the validator has to defend.
59///
60/// Generous against `max_parts` (5000 owners), so a document within the manifest
61/// limits never meets it.
62const MAX_CONTRIBUTIONS: usize = 100_000;
63
64/// `files.file_tp` values with a live document store behind them. Blobs are
65/// absent on purpose: extracting text from a PDF or a docx is a separate,
66/// heavier job than reading a structured document back.
67pub const STORE_RTDB: &str = "RTDB";
68pub const STORE_CRDT: &str = "CRDT";
69
70/// Minimum seconds between two `search.index` scheduler round-trips for the same
71/// document.
72///
73/// Each [`schedule`] call is a `find_by_key` on the read pool plus an
74/// `update_task` on meta.db's **single** write connection; without this, every
75/// commit of a collaborative typing burst pays both. Must stay below
76/// [`DEBOUNCE_SECS`]: the last send of a burst sets `next_at = send +
77/// DEBOUNCE_SECS`, which is later than any edit suppressed within
78/// `THROTTLE_SECS` of it, so the run still sees every edit — the task re-reads
79/// live document state anyway. At or above it, a burst's final edits could go
80/// unindexed until an unrelated later edit.
81const THROTTLE_SECS: u64 = 5;
82const _: () = assert!(THROTTLE_SECS < DEBOUNCE_SECS as u64);
83
84/// Past this many tracked documents, [`should_schedule`] prunes entries older
85/// than the debounce window. A stale entry is harmless — dropping one costs at
86/// most one extra scheduler round-trip — so the map only needs to not grow
87/// without bound.
88const THROTTLE_MAP_CAP: usize = 4096;
89
90/// `(tn_id, file_id)` — the same identity the scheduler key carries.
91type ThrottleKey = (u32, Box<str>);
92
93type ThrottleMap = HashMap<ThrottleKey, Instant>;
94
95/// Last time a `search.index` task was (re)scheduled per document. Process-wide
96/// because [`schedule`] has no per-connection object to hang state on, unlike
97/// `record_file_modification_throttled` in the websocket crates.
98static LAST_SCHEDULED: LazyLock<Mutex<ThrottleMap>> = LazyLock::new(|| Mutex::new(HashMap::new()));
99
100/// Whether this commit should reach the scheduler, recording it if so.
101///
102/// Poison recovery rather than `lock!`: [`schedule`] returns `()` and is
103/// documented fire-and-forget, so there is no error channel to propagate a
104/// poisoned mutex through, and the map holds nothing whose loss matters.
105fn should_schedule(now: Instant, tn_id: TnId, file_id: &str) -> bool {
106	let mut map = match LAST_SCHEDULED.lock() {
107		Ok(g) => g,
108		Err(poisoned) => poisoned.into_inner(),
109	};
110	should_schedule_in(&mut map, now, tn_id, file_id)
111}
112
113/// The throttle decision itself, against an explicit map.
114///
115/// Split out from [`should_schedule`] so the tests drive a local map: the prune
116/// below drops *every* entry older than the window, including ones another test
117/// running in parallel had just recorded in the process-wide map.
118fn should_schedule_in(map: &mut ThrottleMap, now: Instant, tn_id: TnId, file_id: &str) -> bool {
119	let key: ThrottleKey = (tn_id.0, Box::from(file_id));
120	if let Some(last) = map.get(&key)
121		&& now.duration_since(*last) < Duration::from_secs(THROTTLE_SECS)
122	{
123		return false;
124	}
125	if map.len() >= THROTTLE_MAP_CAP {
126		let window = Duration::from_secs(DEBOUNCE_SECS as u64);
127		map.retain(|_, last| now.duration_since(*last) < window);
128	}
129	map.insert(key, now);
130	true
131}
132
133/// Ask for `file_id` to be indexed once the document goes quiet.
134///
135/// Fire-and-forget: failures are logged, never propagated. A missed index run
136/// costs a stale search result, which must not fail the user's write.
137pub fn schedule(app: &App, tn_id: TnId, file_id: &str) {
138	// Decided before the spawn, so a suppressed commit costs no task either.
139	if !should_schedule(Instant::now(), tn_id, file_id) {
140		return;
141	}
142	let app = app.clone();
143	let file_id: Box<str> = file_id.into();
144	tokio::spawn(async move {
145		let key = format!("search.index:{}:{}", tn_id.0, file_id);
146		let task = IndexDocumentTask { tn_id, file_id: file_id.clone() };
147		if let Err(e) = app.scheduler.task(Arc::new(task)).key(key).after(DEBOUNCE_SECS).await {
148			warn!(tn_id = %tn_id, file_id = %file_id, error = %e,
149				"Failed to schedule search index task");
150		}
151	});
152}
153
154/// Index one document now, bypassing the debounce. Used by the task body and
155/// by the reindex sweep.
156pub async fn index_document(app: &App, tn_id: TnId, file_id: &str) -> ClResult<()> {
157	let Some(file) = app.meta_adapter.read_file(tn_id, file_id).await? else {
158		return forget(app, tn_id, file_id).await;
159	};
160	// The same rule the `'F'` row uses, not just the deleted half of it: the sweep
161	// visits the trash, and `reindex::index_one_file` calls
162	// `objects::index_file_row` (which drops both the `'F'` and the `'D'` rows of a
163	// trashed file) immediately before this. A narrower guard here would rebuild
164	// the `'D'` rows it had just deleted.
165	if !crate::objects::is_indexable(&file) {
166		return forget(app, tn_id, file_id).await;
167	}
168
169	// The file's own 'F' row is [`crate::objects`]'s job, so this path only ever
170	// writes deep 'D' parts.
171	//
172	// Deep indexing needs both a live document store and a format claiming it.
173	let content_type = file.content_type.as_deref();
174	let store_tp = file.file_tp.as_deref();
175	let rules = match (content_type, store_tp) {
176		(Some(ct), Some(STORE_RTDB | STORE_CRDT)) => read_rules(app, tn_id, ct).await,
177		_ => None,
178	};
179	let Some(rules) = rules else {
180		return app.meta_adapter.delete_search_object(tn_id, OBJ_DOC, file_id).await;
181	};
182
183	// Materialising the document is the memory-unbounded step, so it runs under
184	// the process-wide permit and `docs` is dropped before the permit is released
185	// — see [`crate::MATERIALIZE_PERMIT`]. `BuiltPart` owns its strings, so
186	// nothing borrows the export past the end of this block. Do not widen the
187	// block to cover the adapter write below: the permit must not be held across
188	// a database round trip.
189	let parts = {
190		let _permit = crate::MATERIALIZE_PERMIT
191			.acquire()
192			.await
193			.map_err(|e| Error::Internal(format!("search index permit closed: {e}")))?;
194
195		// Both stores hand back the same `"{collection}/{doc_id}"` shape, so the
196		// manifest and everything below it are store-agnostic.
197		let mut docs = if store_tp == Some(STORE_CRDT) {
198			crate::crdt::export_all(app, tn_id, file_id).await?
199		} else {
200			app.rtdb_adapter.export_all(tn_id, file_id).await?
201		};
202		// The CPU-bound half of a run: up to `MAX_PRUNE_RULES` JSONPath traversals
203		// per exported document, then two full walks of up to `MAX_CONTRIBUTIONS`
204		// contributions. Inline, that stalls the single async worker thread for as
205		// long as a large document takes. One closure, so `docs` never crosses the
206		// boundary twice.
207		//
208		// Pruning runs once up front: `build_parts` extracts each document twice —
209		// the emitting pass and the `attachTo` fold — and both must see the same
210		// pruned JSON, so it cannot live inside either pass.
211		let owned_id: Box<str> = file_id.into();
212		app.worker
213			.run_slow(move || {
214				crate::prune::prune_docs(&rules, &mut docs, tn_id, &owned_id);
215				build_parts(&rules, &docs, tn_id, &owned_id)
216			})
217			.await
218			.map_err(|e| Error::Internal(format!("Worker pool failed extracting doc: {e}")))?
219	};
220	// Extraction above is deliberately mode-blind — full body text either way.
221	// The setting only decides which index that text lands in.
222	let fts_cl = !crate::store_text(app, tn_id).await;
223
224	app.meta_adapter
225		.replace_search_object(
226			tn_id,
227			&SearchObject {
228				obj_tp: OBJ_DOC,
229				obj_id: file_id,
230				content_type,
231				// The raw `files.owner_tag` column — NULL for a tenant-owned
232				// file — not the resolved `file.owner`, whose fallback chain
233				// answers the tenant's own profile. The `'F'` row carries the
234				// raw value, so anything else makes the two rows of one
235				// document disagree and turns the adapter's no-op guard into a
236				// full FTS rewrite per part.
237				owner_tag: file.owner_tag.as_deref(),
238				visibility: file.visibility,
239				// Deep parts inherit the container's tree root so a file-scoped
240				// token can prefilter them in SQL. A standalone document is its
241				// own root.
242				root_id: Some(file.root_id.as_deref().unwrap_or(file_id)),
243				created_at: Some(file.created_at),
244				fts_cl,
245			},
246			&parts.iter().map(BuiltPart::as_search_part).collect::<Vec<_>>(),
247		)
248		.await
249}
250
251/// Drop the deep parts of a file that is gone or deleted.
252///
253/// [`crate::objects::index_file`] clears both the `'F'` row and these `'D'` rows
254/// when it sees the same thing — this is the belt-and-braces path for a file
255/// that vanished between the commit hook and this task firing.
256async fn forget(app: &App, tn_id: TnId, file_id: &str) -> ClResult<()> {
257	app.meta_adapter.delete_search_object(tn_id, OBJ_DOC, file_id).await
258}
259
260/// Load and parse the index manifest claiming `content_type`.
261///
262/// Through `doc_format::resolve`, so a content type the tenant never registered
263/// still indexes off the manifest this build bundles.
264///
265/// A malformed manifest degrades to "no deep indexing" rather than failing the
266/// run — the `'F'` row is still worth having.
267async fn read_rules(app: &App, tn_id: TnId, content_type: &str) -> Option<IndexRules> {
268	let fmt = cloudillo_core::doc_format::resolve(app, tn_id, content_type)
269		.await
270		.inspect_err(|e| warn!(content_type, error = %e, "Cannot read doc format"))
271		.ok()??;
272	let search = fmt.search.as_ref()?;
273	IndexRules::parse(search)
274		.inspect_err(|e| warn!(content_type, error = %e, "Invalid search manifest"))
275		.ok()
276}
277
278/// An index row under construction. Owns its strings because the parts are
279/// assembled from many source documents before any of them is written.
280#[derive(Debug)]
281struct BuiltPart {
282	part_id: String,
283	part_kind: String,
284	parent_part: Option<String>,
285	anchor_id: Option<String>,
286	title: Option<String>,
287	tags: Option<String>,
288	body: String,
289	/// `body.chars().count()`, maintained incrementally. Pass 2 folds one
290	/// contribution at a time and needs the current length for each; recomputing
291	/// it per contribution made a page with thousands of blocks quadratic.
292	body_chars: usize,
293}
294
295impl BuiltPart {
296	fn as_search_part(&self) -> SearchPart<'_> {
297		SearchPart {
298			part_id: &self.part_id,
299			part_kind: Some(&self.part_kind),
300			parent_part: self.parent_part.as_deref(),
301			anchor_id: self.anchor_id.as_deref(),
302			title: self.title.as_deref(),
303			body: (!self.body.is_empty()).then_some(self.body.as_str()),
304			tags: self.tags.as_deref(),
305		}
306	}
307}
308
309/// A pending contribution from an attached part, before it is folded into its
310/// owner's body.
311struct Contribution {
312	owner: String,
313	sort_key: Vec<String>,
314	anchor: Option<String>,
315	text: String,
316}
317
318/// Apply `rules` to an exported document set.
319///
320/// The caller is expected to have run [`crate::prune::prune_docs`] over `docs`
321/// already: both passes below extract the same documents, so pruning has to
322/// happen once, before either sees them.
323///
324/// Two passes: emitting parts first (so every owner row exists), then attached
325/// parts folded into them in `order` order. Contributions naming an owner that
326/// does not exist are dropped — an orphan block has no page to deep-link to.
327fn build_parts(
328	rules: &IndexRules,
329	docs: &[(Box<str>, serde_json::Value)],
330	tn_id: TnId,
331	file_id: &str,
332) -> Vec<BuiltPart> {
333	let mut parts: Vec<BuiltPart> = Vec::new();
334	// part_id -> index into `parts`, per emitting kind.
335	let mut index: HashMap<(&str, String), usize> = HashMap::new();
336	// Chars written across the whole document, capping the total index cost of
337	// one file however its parts are distributed.
338	let mut total_used: usize = 0;
339	let mut truncated = false;
340
341	// Pass 1 — emitting parts.
342	for (path, doc) in docs {
343		let Some((kind, doc_id)) = split_path(path) else { continue };
344		let Some(rule) = rules.owner_rule(kind) else { continue };
345		if parts.len() >= rules.limits.max_parts {
346			truncated = true;
347			break;
348		}
349		// Budget every sink against what the whole document has left, not just
350		// against the per-part maximum: with the clamped manifest maxima
351		// (`max_parts` 5000 × `max_body_chars` 100_000) the emitting pass alone
352		// could otherwise produce half a gigabyte of index text before `max_parts`
353		// stopped it. Charged in sequence — each field takes what the one before
354		// left — so the three together cannot exceed the remainder either.
355		let mut left = rules.limits.max_total_chars.saturating_sub(total_used);
356		if left == 0 {
357			truncated = true;
358			break;
359		}
360
361		let mut title = TextSink::new(rules.limits.max_body_chars.min(1024).min(left));
362		extract_fields(doc, &rule.title, &mut title);
363		left -= title.len_chars();
364		let mut tags = TextSink::new(1024.min(left));
365		extract_fields(doc, &rule.tags, &mut tags);
366		left -= tags.len_chars();
367		let mut body = TextSink::new(rules.limits.max_body_chars.min(left));
368		extract_fields(doc, &rule.body, &mut body);
369
370		truncated |= title.truncated() || tags.truncated() || body.truncated();
371
372		index.insert((kind, doc_id.to_owned()), parts.len());
373		let body = body.into_string();
374		let body_chars = body.chars().count();
375		total_used += title.len_chars() + tags.len_chars() + body_chars;
376		parts.push(BuiltPart {
377			// Namespaced by kind because `idx_search_docs_key` is UNIQUE on
378			// `(tn_id, obj_tp, obj_id, part_id)` and does *not* include
379			// `part_kind`: two emitting rules over documents sharing an id would
380			// otherwise collide and abort the whole object's INSERT. CRDT roots
381			// make that certain — `crdt::collect_root` names array entries by
382			// position, so two root arrays both export `…/0`. This fixes the
383			// collision, not the instability of those positional ids.
384			// `handler::strip_kind` takes the prefix back off for the wire.
385			part_id: format!("{kind}/{doc_id}"),
386			part_kind: kind.to_owned(),
387			// Namespaced the same way, or it would stop matching the sibling
388			// `part_id`s it names.
389			parent_part: rule
390				.parent
391				.as_deref()
392				.and_then(|f| resolve_str(doc, f))
393				.map(|p| format!("{kind}/{p}")),
394			// A bare id: this is an anchor inside the document, not a
395			// `search_docs` key.
396			anchor_id: anchor_of(rule, doc, doc_id),
397			title: (!title.is_empty()).then(|| title.into_string()),
398			tags: (!tags.is_empty()).then(|| tags.into_string()),
399			body,
400			body_chars,
401		});
402	}
403
404	// Pass 2 — attached parts, collected then sorted so the assembled body
405	// follows the app's reading order rather than redb key order.
406	//
407	// Bounded on the way *in*, not on the way out: the fold below stops at
408	// `max_total_chars`, but collecting first meant every contribution was
409	// extracted and held as an owned `String` before a single char was charged —
410	// a notillo document with 200k blocks materialised 200k of them.
411	//
412	// Two bounds, because either alone is evadable: `pending_chars` against the
413	// budget the fold could still spend, and `MAX_CONTRIBUTIONS` against a
414	// document of very many tiny blocks whose chars never add up to the cap.
415	//
416	// The caveat pass 1 already accepts: `sort_key` ordering decides *which*
417	// contributions survive the fold, so a collection cut short here may keep
418	// different ones than an uncut collection would have. Finding out which means
419	// reading the whole document into memory — the cost being avoided.
420	let mut pending: HashMap<&str, Vec<Contribution>> = HashMap::new();
421	let mut pending_chars: usize = 0;
422	let mut pending_count: usize = 0;
423	let budget_left = rules.limits.max_total_chars.saturating_sub(total_used);
424	'collect: for (path, doc) in docs {
425		let Some((kind, doc_id)) = split_path(path) else { continue };
426		for rule in rules.parts.iter().filter(|p| p.kind == kind) {
427			let Some(attach) = &rule.attach_to else { continue };
428			let Some(owner) = resolve_str(doc, &attach.field) else { continue };
429
430			// Resolve the owner *before* allocating a sink for the text. The fold
431			// drops a contribution whose owner does not exist, so extracting an
432			// orphan's body is pure cost — and an app that names a deleted page
433			// can produce a great many of them.
434			let key = (attach.kind.as_str(), owner);
435			if !index.contains_key(&key) {
436				continue;
437			}
438
439			if pending_chars >= budget_left || pending_count >= MAX_CONTRIBUTIONS {
440				truncated = true;
441				break 'collect;
442			}
443
444			let mut text = TextSink::new(rules.limits.max_body_chars);
445			extract_fields(doc, &rule.body, &mut text);
446			if text.is_empty() {
447				continue;
448			}
449			pending_chars = pending_chars.saturating_add(text.len_chars());
450			pending_count += 1;
451			let (owner_kind, owner) = key;
452			pending.entry(owner_kind).or_default().push(Contribution {
453				owner,
454				sort_key: rule.order.iter().map(|f| sort_key_of(doc, f, doc_id)).collect(),
455				anchor: anchor_of(rule, doc, doc_id),
456				text: text.into_string(),
457			});
458		}
459	}
460
461	// Sorted, because iterating the `HashMap` directly made which owner kind
462	// consumed the `max_total_chars` remainder first vary from run to run — and
463	// with it the indexed text of a document that hits the cap.
464	let mut owner_kinds: Vec<&str> = pending.keys().copied().collect();
465	owner_kinds.sort_unstable();
466	for owner_kind in owner_kinds {
467		let Some(mut contributions) = pending.remove(owner_kind) else { continue };
468		contributions.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
469		for c in contributions {
470			let Some(&i) = index.get(&(owner_kind, c.owner)) else { continue };
471			let Some(part) = parts.get_mut(i) else { continue };
472			let wanted = c.text.chars().count();
473			// The separator is charged to the budget rather than added outside it;
474			// appended after the `room` clamp it would push an assembled body one
475			// char over the manifest's `max_body_chars`.
476			let sep = usize::from(!part.body.is_empty());
477			let room = rules
478				.limits
479				.max_body_chars
480				.saturating_sub(part.body_chars)
481				.min(rules.limits.max_total_chars.saturating_sub(total_used))
482				.saturating_sub(sep);
483			if room == 0 {
484				truncated = true;
485				continue;
486			}
487			if sep == 1 {
488				part.body.push(' ');
489				part.body_chars += 1;
490				total_used += 1;
491			}
492			part.body.extend(c.text.chars().take(room));
493			part.body_chars += wanted.min(room);
494			total_used += wanted.min(room);
495			truncated |= wanted > room;
496			// The anchor names the *first* contributing child, so a hit can
497			// jump straight to it.
498			if part.anchor_id.is_none() {
499				part.anchor_id = c.anchor;
500			}
501		}
502	}
503
504	// Rows with no text at all would only dilute `bm25()`.
505	parts.retain(|p| !p.body.is_empty() || p.title.is_some() || p.tags.is_some());
506
507	if truncated {
508		warn!(tn_id = %tn_id, file_id, max_parts = rules.limits.max_parts,
509			max_body_chars = rules.limits.max_body_chars,
510			max_total_chars = rules.limits.max_total_chars,
511			total_used,
512			"Search index truncated: document exceeds manifest limits");
513	}
514	parts
515}
516
517/// Resolve a rule's `anchor` — either the document's own id or one of its
518/// fields.
519fn anchor_of(rule: &PartRule, doc: &serde_json::Value, doc_id: &str) -> Option<String> {
520	match rule.anchor.as_deref()? {
521		DOC_ID => Some(doc_id.to_owned()),
522		field => resolve_str(doc, field),
523	}
524}
525
526/// Build one component of a sort key.
527///
528/// A numeric order field is encoded so that comparing the *strings*
529/// lexicographically — which is what the `Vec<String>` ordering does — gives
530/// exactly the same answer as comparing the `f64`s. A bare decimal rendering
531/// would not: `"10" < "9"`.
532///
533/// The encoding is IEEE-754 total order. `f64::to_bits` already orders positives
534/// correctly as `u64` except for the sign bit, so flipping the sign bit on a
535/// positive and inverting every bit on a negative yields a `u64` whose ordering
536/// matches the float's; sixteen hex digits are then a fixed-width, order-
537/// preserving string.
538///
539/// A decimal rendering such as `format!("{:020.4}", n + 1e12)` loses the
540/// fractional order a float `o` field exists to carry — the `.4` rounds, and the
541/// `1e12` offset lands in a range where `f64` resolution is already ~1.2e-4. Two
542/// blocks bisected to `1.00006` and `1.00012` collapsed to one key, and the
543/// stable sort then fell back to redb export order.
544///
545/// `NaN` has no position on the number line, so it sorts to the very end
546/// (all-ones key); reachable only through a string-valued field, but explicit
547/// because `to_bits` on a NaN is otherwise a plausible-looking mid-range key.
548/// `-0.0` sorts before `0.0`, consistent with the total order and harmless.
549fn sort_key_of(doc: &serde_json::Value, field: &str, doc_id: &str) -> String {
550	if field == DOC_ID {
551		return doc_id.to_owned();
552	}
553	let Some(raw) = resolve_str(doc, field) else { return String::new() };
554	raw.parse::<f64>().map_or(raw, |n| {
555		if n.is_nan() {
556			return "f".repeat(16);
557		}
558		let bits = n.to_bits();
559		let key = if n.is_sign_negative() { !bits } else { bits ^ (1 << 63) };
560		format!("{key:016x}")
561	})
562}
563
564/// Split an `export_all` path into `(collection, doc_id)`, mirroring the redb
565/// adapter's own `storage::parse_path`.
566pub(crate) fn split_path(path: &str) -> Option<(&str, &str)> {
567	let (doc_id, collection) = {
568		let mut it = path.rsplitn(2, '/');
569		(it.next()?, it.next()?)
570	};
571	(!collection.is_empty() && !doc_id.is_empty()).then_some((collection, doc_id))
572}
573
574/// Scheduled per-document index run. See the module docs for the debounce.
575#[derive(Debug, Serialize, Deserialize)]
576pub struct IndexDocumentTask {
577	pub tn_id: TnId,
578	pub file_id: Box<str>,
579}
580
581#[async_trait]
582impl Task<App> for IndexDocumentTask {
583	fn kind() -> &'static str {
584		"search.index"
585	}
586
587	fn kind_of(&self) -> &'static str {
588		Self::kind()
589	}
590
591	fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
592		Ok(Arc::new(serde_json::from_str::<Self>(ctx)?))
593	}
594
595	fn serialize(&self) -> String {
596		// Built by hand rather than via `to_string().unwrap_or("{}")`: "{}"
597		// does not deserialize back into this type, so a fallback would poison
598		// the persisted task row and log forever on retry.
599		let mut obj = serde_json::Map::with_capacity(2);
600		obj.insert("tn_id".into(), self.tn_id.0.into());
601		obj.insert("file_id".into(), self.file_id.as_ref().into());
602		serde_json::Value::Object(obj).to_string()
603	}
604
605	async fn run(&self, app: &App) -> ClResult<()> {
606		index_document(app, self.tn_id, &self.file_id).await
607	}
608}
609
610#[cfg(test)]
611mod tests {
612	use super::*;
613
614	fn notillo_rules() -> IndexRules {
615		IndexRules::parse(&serde_json::json!({
616			"v": 1,
617			"parts": [
618				{ "kind": "p", "title": ["ti"], "tags": ["tg"], "parent": "pp" },
619				{ "kind": "b", "attachTo": { "kind": "p", "field": "p" },
620				  "anchor": "docId", "order": ["o"],
621				  "body": [{ "field": "c", "extract": "text", "excludeKeys": ["l"] }] }
622			]
623		}))
624		.expect("rules")
625	}
626
627	fn docs() -> Vec<(Box<str>, serde_json::Value)> {
628		vec![
629			("p/page1".into(), serde_json::json!({ "ti": "Bevezetés", "tg": ["munka"] })),
630			("p/page2".into(), serde_json::json!({ "ti": "Részletek", "pp": "page1" })),
631			// Deliberately out of reading order in the export.
632			("b/blockB".into(), serde_json::json!({ "p": "page1", "o": 10, "c": ["második"] })),
633			("b/blockA".into(), serde_json::json!({ "p": "page1", "o": 2, "c": ["első"] })),
634			("b/blockC".into(), serde_json::json!({ "p": "page2", "o": 1, "c": ["külön"] })),
635			// Orphan: names a page that does not exist.
636			("b/orphan".into(), serde_json::json!({ "p": "gone", "o": 1, "c": ["árva"] })),
637		]
638	}
639
640	fn build() -> Vec<BuiltPart> {
641		build_parts(&notillo_rules(), &docs(), TnId(1), "f1~doc")
642	}
643
644	#[test]
645	fn emits_one_row_per_page_with_block_text_folded_in() {
646		let parts = build();
647		assert_eq!(parts.len(), 2, "one row per page, none per block");
648
649		// `part_id` is namespaced by kind; the wire strips it again.
650		let page1 = parts.iter().find(|p| p.part_id == "p/page1").expect("page1");
651		assert_eq!(page1.title.as_deref(), Some("Bevezetés"));
652		assert_eq!(page1.tags.as_deref(), Some("munka"));
653		assert_eq!(page1.part_kind, "p");
654		// Blocks are folded in `order` order, not export order.
655		assert_eq!(page1.body, "első második");
656
657		let page2 = parts.iter().find(|p| p.part_id == "p/page2").expect("page2");
658		assert_eq!(page2.parent_part.as_deref(), Some("p/page1"));
659		assert_eq!(page2.body, "külön");
660	}
661
662	#[test]
663	fn anchor_points_at_the_first_contributing_block() {
664		let parts = build();
665		let page1 = parts.iter().find(|p| p.part_id == "p/page1").expect("page1");
666		assert_eq!(page1.anchor_id.as_deref(), Some("blockA"), "anchor must follow reading order");
667	}
668
669	#[test]
670	fn orphan_contributions_are_dropped() {
671		let parts = build();
672		assert!(
673			parts.iter().all(|p| !p.body.contains("árva")),
674			"a block naming a missing page must not leak into another page"
675		);
676	}
677
678	#[test]
679	fn numeric_order_sorts_numerically_not_lexicographically() {
680		let docs = vec![
681			("p/page1".into(), serde_json::json!({ "ti": "T" })),
682			("b/b1".into(), serde_json::json!({ "p": "page1", "o": 9, "c": ["nine"] })),
683			("b/b2".into(), serde_json::json!({ "p": "page1", "o": 10, "c": ["ten"] })),
684		];
685		let parts = build_parts(&notillo_rules(), &docs, TnId(1), "f1~doc");
686		assert_eq!(parts[0].body, "nine ten");
687	}
688
689	#[test]
690	fn negative_and_fractional_order_values_sort_correctly() {
691		let docs = vec![
692			("p/page1".into(), serde_json::json!({ "ti": "T" })),
693			("b/b1".into(), serde_json::json!({ "p": "page1", "o": 1.5, "c": ["mid"] })),
694			("b/b2".into(), serde_json::json!({ "p": "page1", "o": -3, "c": ["first"] })),
695			("b/b3".into(), serde_json::json!({ "p": "page1", "o": 2, "c": ["last"] })),
696			("b/b4".into(), serde_json::json!({ "p": "page1", "o": 0.0, "c": ["zero"] })),
697			("b/b5".into(), serde_json::json!({ "p": "page1", "o": -0.0, "c": ["negzero"] })),
698		];
699		let parts = build_parts(&notillo_rules(), &docs, TnId(1), "f1~doc");
700		assert_eq!(parts[0].body, "first negzero zero mid last");
701	}
702
703	/// Repeated "insert between these two siblings" edits bisect the gap until the
704	/// difference is finer than a rounded decimal key could hold.
705	#[test]
706	fn bisected_order_values_keep_their_order() {
707		let docs = vec![
708			("p/page1".into(), serde_json::json!({ "ti": "T" })),
709			("b/b1".into(), serde_json::json!({ "p": "page1", "o": 1.00012, "c": ["second"] })),
710			("b/b2".into(), serde_json::json!({ "p": "page1", "o": 1.00006, "c": ["first"] })),
711		];
712		let parts = build_parts(&notillo_rules(), &docs, TnId(1), "f1~doc");
713		assert_eq!(parts[0].body, "first second");
714	}
715
716	#[test]
717	fn sort_keys_of_near_identical_order_values_stay_distinct() {
718		let key = |n: f64| sort_key_of(&serde_json::json!({ "o": n }), "o", "f1~doc");
719		assert_ne!(key(1.00006), key(1.00012));
720		assert!(key(1.00006) < key(1.00012));
721		assert!(key(-3.0) < key(0.0));
722		assert!(key(0.0) < key(1.5));
723
724		// NaN sorts to the end. Reachable only through a string field: JSON has no
725		// NaN, so `json!(f64::NAN)` is null and never gets this far.
726		let nan = sort_key_of(&serde_json::json!({ "o": "NaN" }), "o", "f1~doc");
727		assert!(nan > key(f64::MAX));
728	}
729
730	#[test]
731	fn body_is_capped_at_the_manifest_limit() {
732		let rules = IndexRules::parse(&serde_json::json!({
733			"parts": [
734				{ "kind": "p", "title": ["ti"] },
735				{ "kind": "b", "attachTo": { "kind": "p", "field": "p" }, "body": ["c"] }
736			],
737			"limits": { "maxBodyChars": 10 }
738		}))
739		.expect("rules");
740		let docs = vec![
741			("p/page1".into(), serde_json::json!({ "ti": "T" })),
742			("b/b1".into(), serde_json::json!({ "p": "page1", "c": "0123456789abcdef" })),
743		];
744		let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
745		assert!(parts[0].body.chars().count() <= 10, "got {:?}", parts[0].body);
746	}
747
748	#[test]
749	fn max_total_chars_bounds_the_emitting_pass_too() {
750		// Charged for attached contributions only, `max_parts` × `max_body_chars`
751		// would be the real ceiling on one document — half a gigabyte at the
752		// clamped maxima.
753		let rules = IndexRules::parse(&serde_json::json!({
754			"parts": [{ "kind": "p", "title": ["ti"], "body": ["c"] }],
755			"limits": { "maxParts": 100, "maxBodyChars": 20, "maxTotalChars": 30 }
756		}))
757		.expect("rules");
758		let docs: Vec<(Box<str>, serde_json::Value)> = (0..10)
759			.map(|i| {
760				(
761					format!("p/page{i}").into(),
762					serde_json::json!({ "ti": format!("T{i}"), "c": "0123456789" }),
763				)
764			})
765			.collect();
766		let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
767
768		let emitted: usize = parts
769			.iter()
770			.map(|p| {
771				p.title.as_deref().unwrap_or_default().chars().count()
772					+ p.tags.as_deref().unwrap_or_default().chars().count()
773					+ p.body.chars().count()
774			})
775			.sum();
776		assert!(emitted <= 30, "emitted {emitted} chars past a 30-char total budget");
777		assert!(parts.len() < 10, "the pass must stop before every page, not after it");
778	}
779
780	/// Pass 2 stops **collecting** once the fold's remaining budget is already
781	/// spoken for, rather than materialising every contribution in the document
782	/// and discarding the surplus afterwards.
783	///
784	/// The bound is on memory, so what makes it observable from outside is its
785	/// documented side effect: with the export arriving in the opposite order to
786	/// `order`, the contributions that survive are the ones *seen* first, not the
787	/// ones that would have sorted first. An unbounded collection folds
788	/// `blokk001…` — this one folds `blokk196…`, five blocks off the front of the
789	/// export, and never allocates the other 195.
790	#[test]
791	fn attached_contributions_stop_being_collected_once_the_budget_is_spent() {
792		let rules = IndexRules::parse(&serde_json::json!({
793			"parts": [
794				{ "kind": "p", "title": ["ti"] },
795				{ "kind": "b", "attachTo": { "kind": "p", "field": "p" },
796				  "order": ["o"], "body": ["c"] }
797			],
798			"limits": { "maxParts": 100, "maxBodyChars": 50, "maxTotalChars": 40 }
799		}))
800		.expect("rules");
801
802		let mut docs: Vec<(Box<str>, serde_json::Value)> =
803			vec![("p/page1".into(), serde_json::json!({ "ti": "T" }))];
804		for i in (1..=200).rev() {
805			docs.push((
806				format!("b/b{i:03}").into(),
807				serde_json::json!({ "p": "page1", "o": i, "c": format!("blokk{i:03}") }),
808			));
809		}
810
811		let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
812		assert_eq!(parts.len(), 1);
813		let emitted = parts[0].title.as_deref().unwrap_or_default().chars().count()
814			+ parts[0].body.chars().count();
815		assert!(emitted <= 40, "emitted {emitted} chars past a 40-char total budget");
816
817		assert!(
818			parts[0].body.contains("blokk196"),
819			"expected the first blocks off the export, got {:?}",
820			parts[0].body
821		);
822		assert!(
823			!parts[0].body.contains("blokk001"),
824			"the whole export was collected before anything was charged: {:?}",
825			parts[0].body
826		);
827
828		// Deterministic: `owner_kinds` is sorted and the export order is fixed, so
829		// where the cut falls must not vary between runs.
830		let again = build_parts(&rules, &docs, TnId(1), "f1~doc");
831		assert_eq!(parts[0].body, again[0].body);
832	}
833
834	/// The prune phase and `build_parts` are separate calls, and this is the only
835	/// test pinning that they belong together in that order.
836	///
837	/// Both assertions are needed: the exact body catches **over**-pruning (a
838	/// pattern that ate real text or a table row), the token loop catches
839	/// **under**-pruning (a flag that survived into the index).
840	#[test]
841	fn pruning_before_build_parts_keeps_style_flags_out_of_an_assembled_body() {
842		let rules = IndexRules::parse(&serde_json::json!({
843			"v": 1,
844			"parts": [
845				{ "kind": "p", "title": ["ti"], "tags": ["tg"], "parent": "pp" },
846				{ "kind": "b", "attachTo": { "kind": "p", "field": "p" },
847				  "anchor": "docId", "order": ["o"],
848				  "prune": ["$..c[0:][1:]", "$..cells[0:][0:][1:]"],
849				  "body": [{ "path": "c", "extract": "text", "keys": ["c", "cells", "wt"] }] }
850			]
851		}))
852		.expect("rules");
853
854		let mut docs: Vec<(Box<str>, serde_json::Value)> = vec![
855			("p/page1".into(), serde_json::json!({ "ti": "Bevezetés" })),
856			(
857				"b/b1".into(),
858				serde_json::json!({ "p": "page1", "o": 1,
859					"c": ["Sima ", ["félkövér", "b"], ["dőlt", "iu"]] }),
860			),
861			(
862				"b/b2".into(),
863				serde_json::json!({ "p": "page1", "o": 2,
864					"c": [["piros", "", { "tc": "#f00" }], " és ", ["busás", "bus"]] }),
865			),
866		];
867		crate::prune::prune_docs(&rules, &mut docs, TnId(1), "f1~doc");
868		let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
869
870		assert_eq!(parts.len(), 1);
871		assert_eq!(parts[0].body, "Sima félkövér dőlt piros és busás");
872		for token in parts[0].body.split_whitespace() {
873			assert!(
874				!matches!(token, "b" | "i" | "u" | "s" | "c" | "bi" | "iu" | "bus"),
875				"style flag {token:?} survived into {:?}",
876				parts[0].body
877			);
878		}
879	}
880
881	/// The opt-in contract every stored manifest depends on: a manifest that
882	/// declares no `prune` indexes byte-for-byte as it did before the phase existed.
883	#[test]
884	fn a_manifest_without_prune_indexes_exactly_as_before() {
885		let rules = notillo_rules();
886		let untouched = build_parts(&rules, &docs(), TnId(1), "f1~doc");
887
888		let mut docs = docs();
889		crate::prune::prune_docs(&rules, &mut docs, TnId(1), "f1~doc");
890		let after = build_parts(&rules, &docs, TnId(1), "f1~doc");
891
892		let fields = |ps: &[BuiltPart]| {
893			ps.iter()
894				.map(|p| (p.part_id.clone(), p.title.clone(), p.tags.clone(), p.body.clone()))
895				.collect::<Vec<_>>()
896		};
897		assert_eq!(fields(&after), fields(&untouched));
898	}
899
900	#[test]
901	fn max_parts_stops_the_emitting_pass() {
902		let rules = IndexRules::parse(&serde_json::json!({
903			"parts": [{ "kind": "p", "title": ["ti"] }],
904			"limits": { "maxParts": 2 }
905		}))
906		.expect("rules");
907		let docs: Vec<(Box<str>, serde_json::Value)> = (0..10)
908			.map(|i| {
909				(format!("p/page{i}").into(), serde_json::json!({ "ti": format!("Page {i}") }))
910			})
911			.collect();
912		assert_eq!(build_parts(&rules, &docs, TnId(1), "f1~doc").len(), 2);
913	}
914
915	#[test]
916	fn unknown_collections_and_malformed_paths_are_ignored() {
917		let docs = vec![
918			("p/page1".into(), serde_json::json!({ "ti": "T" })),
919			("z/other".into(), serde_json::json!({ "ti": "Not indexed" })),
920			("noslash".into(), serde_json::json!({ "ti": "Not indexed" })),
921		];
922		let parts = build_parts(&notillo_rules(), &docs, TnId(1), "f1~doc");
923		assert_eq!(parts.len(), 1);
924		assert_eq!(parts[0].part_id, "p/page1");
925	}
926
927	#[test]
928	fn two_emitting_kinds_sharing_a_doc_id_get_distinct_part_ids() {
929		// The CRDT case: `collect_root` names root-array entries by position, so
930		// two root arrays both export `…/0`. Without the kind namespace both rows
931		// would carry `part_id = "0"`, collide on `idx_search_docs_key`, and abort
932		// the whole object's INSERT — the document would never be indexed at all.
933		let rules = IndexRules::parse(&serde_json::json!({
934			"v": 1,
935			"parts": [{ "kind": "s", "title": ["ti"] }, { "kind": "n", "title": ["ti"] }]
936		}))
937		.expect("rules");
938		let docs = vec![
939			("s/0".into(), serde_json::json!({ "ti": "Diák" })),
940			("n/0".into(), serde_json::json!({ "ti": "Jegyzet" })),
941		];
942		let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
943		assert_eq!(parts.len(), 2);
944		let mut ids: Vec<&str> = parts.iter().map(|p| p.part_id.as_str()).collect();
945		ids.sort_unstable();
946		assert_eq!(ids, vec!["n/0", "s/0"]);
947	}
948
949	#[test]
950	fn textless_rows_are_dropped() {
951		let docs = vec![("p/empty".into(), serde_json::json!({ "x": 1 }))];
952		assert!(build_parts(&notillo_rules(), &docs, TnId(1), "f1~doc").is_empty());
953	}
954
955	#[test]
956	fn split_path_handles_nested_collections() {
957		assert_eq!(split_path("p/page1"), Some(("p", "page1")));
958		assert_eq!(split_path("a/b/doc"), Some(("a/b", "doc")));
959		assert_eq!(split_path("noslash"), None);
960		assert_eq!(split_path("/doc"), None);
961		assert_eq!(split_path("coll/"), None);
962	}
963	/// The scheduler round-trip throttle: one commit through, the immediate
964	/// follow-ups suppressed, and the map bounded.
965	///
966	/// Both throttle tests drive a local map rather than the process-wide
967	/// `LAST_SCHEDULED`: the prune in `should_schedule_in` is unconditional on
968	/// age, so the pruning test below would otherwise evict this test's entries
969	/// mid-run when the two interleave on separate threads.
970	#[test]
971	fn the_scheduler_throttle_suppresses_a_burst_but_not_the_next_window() {
972		let map = &mut ThrottleMap::new();
973		let tn_id = TnId(9_001);
974		let t0 = Instant::now();
975		assert!(
976			should_schedule_in(map, t0, tn_id, "f1~burst"),
977			"the first commit must reach the scheduler"
978		);
979		assert!(
980			!should_schedule_in(map, t0, tn_id, "f1~burst"),
981			"an immediate re-commit must be suppressed"
982		);
983		let inside = t0 + Duration::from_secs(THROTTLE_SECS - 1);
984		assert!(
985			!should_schedule_in(map, inside, tn_id, "f1~burst"),
986			"still inside the throttle window"
987		);
988		assert!(
989			should_schedule_in(map, t0 + Duration::from_secs(THROTTLE_SECS), tn_id, "f1~burst"),
990			"a commit a full window later must reach the scheduler again"
991		);
992		// Per document, not global.
993		assert!(should_schedule_in(map, t0, tn_id, "f1~other"));
994	}
995
996	/// The map is process-wide, so it must not grow with every document ever
997	/// edited. Entries older than the debounce window go when it hits the cap.
998	#[test]
999	fn the_throttle_map_is_pruned_past_its_cap() {
1000		let map = &mut ThrottleMap::new();
1001		let tn_id = TnId(9_002);
1002		let t0 = Instant::now();
1003		for i in 0..THROTTLE_MAP_CAP {
1004			should_schedule_in(map, t0, tn_id, &format!("f1~{i}"));
1005		}
1006		// One more, a full debounce window later: everything above is now stale.
1007		let later = t0 + Duration::from_secs(DEBOUNCE_SECS as u64 + 1);
1008		should_schedule_in(map, later, tn_id, "f1~last");
1009		assert_eq!(map.len(), 1, "the prune must drop entries older than the debounce window");
1010	}
1011}
1012
1013// vim: ts=4