Skip to main content

sofie_db/search/
indexing.rs

1//! Jobs keeping a search engine in step after each commit. A job reads the record as it is
2//! then, so a retried or late job writes the latest state.
3
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use serde_json::{Value, json};
8use sofie_core::{Cast, ColumnReader, Error, Result, SqlType, SqlValue, Uuid};
9
10use super::SEARCH_COLUMN;
11use super::engine::{Document, Installed, SearchEngine, SearchIndex};
12use crate::worker::{JobContext, Worker};
13use crate::{Db, MessageKind, Outcome, Plan, Resolve, Step, Write, Writes, query_decoded};
14
15const INDEX_JOB: &str = "sofie.search.index";
16const REINDEX_JOB: &str = "sofie.search.reindex_page";
17const FORGET_JOB: &str = "sofie.search.forget";
18pub const REINDEX_TASK: &str = "sofie.search.reindex";
19const PAGE: usize = 500;
20const INDEX_TRIES: usize = 3;
21
22/// On the worker of a database a search engine is installed on.
23#[must_use]
24pub fn index_searches(worker: Worker) -> Worker {
25    worker
26        .job(INDEX_JOB, index_record)
27        .job(REINDEX_JOB, reindex_page)
28        .job(FORGET_JOB, forget_documents)
29}
30
31pub(crate) fn changes_document(write: &Write<'_>) -> bool {
32    let table = write.table;
33    std::iter::once(SEARCH_COLUMN)
34        .chain(table.hiding())
35        .chain(table.owner)
36        .any(|column| write.value(column) != write.loaded(column))
37}
38
39pub(crate) fn queue(write: &Write<'_>, plan: &mut Plan<'_>) -> Result<()> {
40    let job = index_job(write.table.schema, write.table.name, write.id);
41    plan.queue(MessageKind::Job, [job])
42}
43
44fn index_job(module: &str, aggregate: &str, id: Uuid) -> (&'static str, Value) {
45    let payload = json!({ "module": module, "aggregate": aggregate, "id": id });
46    (INDEX_JOB, payload)
47}
48
49/// In the prune's own commit: the rows are gone, so no later prune or reindex would find them.
50#[cfg(feature = "retention")]
51pub(crate) fn queue_forget(index: &SearchIndex, ids: &[Uuid], plan: &mut Plan<'_>) -> Result<()> {
52    let payload = json!({ "module": index.module, "aggregate": index.name, "ids": ids });
53    plan.queue(MessageKind::Job, [(FORGET_JOB, payload)])
54}
55
56#[cfg(feature = "retention")]
57pub(crate) fn index_of(db: &Db, module: &str, name: &str) -> Option<SearchIndex> {
58    db.search.get()?.find(module, name).cloned()
59}
60
61struct Forgetting {
62    indexing: Indexing,
63    ids: Vec<Uuid>,
64}
65
66impl Resolve<JobContext> for Forgetting {
67    fn resolve(context: &mut JobContext) -> impl Future<Output = Result<Self>> + Send {
68        let ids = context
69            .payload
70            .get("ids")
71            .cloned()
72            .map(serde_json::from_value::<Vec<Uuid>>)
73            .transpose()
74            .map_err(|error| Error::internal("reading a forgetting job's ids", error))
75            .and_then(|ids| {
76                ids.ok_or_else(|| Error::internal_message("a forgetting job names no ids"))
77            });
78        let forgetting = ids
79            .and_then(|ids| Indexing::read(context, "id").map(|indexing| Self { indexing, ids }));
80        std::future::ready(forgetting)
81    }
82}
83
84async fn forget_documents(job: Forgetting) -> Result<()> {
85    let Forgetting { indexing, ids } = job;
86    indexing.engine.remove(&indexing.index, &ids).await
87}
88
89struct Indexing {
90    db: Db,
91    engine: Arc<dyn SearchEngine>,
92    index: SearchIndex,
93    id: Option<Uuid>,
94}
95
96impl Indexing {
97    fn read(context: &JobContext, id_key: &str) -> Result<Self> {
98        let installed = Installed::of(&context.db)?;
99        let text = |key: &str| {
100            context
101                .payload
102                .get(key)
103                .and_then(Value::as_str)
104                .ok_or_else(|| {
105                    Error::internal_message(format!("an indexing job's payload has no `{key}`"))
106                })
107        };
108        let index = installed
109            .index(text("module")?, text("aggregate")?)?
110            .clone();
111        let id = match context.payload.get(id_key) {
112            None | Some(Value::Null) => None,
113            Some(_) => Some(text(id_key)?.parse().map_err(|_| {
114                Error::internal_message(format!("an indexing job's `{id_key}` is not an id"))
115            })?),
116        };
117        Ok(Self {
118            db: context.db.clone(),
119            engine: Arc::clone(&installed.engine),
120            index,
121            id,
122        })
123    }
124}
125
126struct OneRecord(Indexing);
127
128impl Resolve<JobContext> for OneRecord {
129    fn resolve(context: &mut JobContext) -> impl Future<Output = Result<Self>> + Send {
130        std::future::ready(Indexing::read(context, "id").map(Self))
131    }
132}
133
134struct NextPage(Indexing);
135
136impl Resolve<JobContext> for NextPage {
137    fn resolve(context: &mut JobContext) -> impl Future<Output = Result<Self>> + Send {
138        std::future::ready(Indexing::read(context, "after").map(Self))
139    }
140}
141
142/// Read again after each write: a job for a later version may have landed first, and this one's
143/// write then undid it. A record rewritten under every try is a conflict, retried later.
144async fn index_record(job: OneRecord) -> Result<()> {
145    let OneRecord(job) = job;
146    let id = job
147        .id
148        .ok_or_else(|| Error::internal_message("an indexing job names no record"))?;
149    let mut records = read_records(&job.db, &job.index, Records::One(id)).await?;
150    for _ in 0..INDEX_TRIES {
151        rewrite_stale(&job.db, &job.index, &records).await?;
152        let indexed = records.first().map(|record| record.version.clone());
153        match records.into_iter().next() {
154            Some(record) if !record.hidden => job.engine.put(&job.index, &[record.document]).await,
155            _ => job.engine.remove(&job.index, &[id]).await,
156        }?;
157        records = read_records(&job.db, &job.index, Records::One(id)).await?;
158        if records.first().map(|record| record.version.clone()) == indexed {
159            return Ok(());
160        }
161    }
162    Err(Error::Conflict)
163}
164
165async fn reindex_page(job: NextPage) -> Result<Reindexing> {
166    let NextPage(job) = job;
167    let records = read_records(&job.db, &job.index, Records::After(job.id)).await?;
168    rewrite_stale(&job.db, &job.index, &records).await?;
169    let last = records.last().map(|record| record.document.id);
170    let full = records.len() == PAGE;
171    let written: Vec<(Uuid, SqlValue)> = records
172        .iter()
173        .map(|record| (record.document.id, record.version.clone()))
174        .collect();
175    let (hidden, shown): (Vec<Record>, Vec<Record>) =
176        records.into_iter().partition(|record| record.hidden);
177    let documents: Vec<Document> = shown.into_iter().map(|record| record.document).collect();
178    if !documents.is_empty() {
179        job.engine.put(&job.index, &documents).await?;
180    }
181    let removed: Vec<Uuid> = hidden.iter().map(|record| record.document.id).collect();
182    if !removed.is_empty() {
183        job.engine.remove(&job.index, &removed).await?;
184    }
185    let moved = moved_since(&job.db, &job.index, &written).await?;
186    let pages = match last {
187        Some(last) if full => vec![(job.index.clone(), Some(last))],
188        _ => Vec::new(),
189    };
190    Ok(Reindexing {
191        pages,
192        moved: moved
193            .into_iter()
194            .map(|id| (job.index.clone(), id))
195            .collect(),
196    })
197}
198
199/// A commit between the page's read and its write was undone by it: index those records again.
200async fn moved_since(
201    db: &Db,
202    index: &SearchIndex,
203    written: &[(Uuid, SqlValue)],
204) -> Result<Vec<Uuid>> {
205    let (Some((first, _)), Some((last, _))) = (written.first(), written.last()) else {
206        return Ok(Vec::new());
207    };
208    let dialect = db.driver().dialect();
209    let id = dialect.quote("id");
210    let sql = format!(
211        "SELECT {id}, {} FROM {} WHERE {id} >= {} AND {id} <= {}",
212        dialect.quote("version"),
213        dialect.qualified(index.module, index.name),
214        dialect.placeholder(1),
215        dialect.placeholder(2),
216    );
217    let params = [SqlValue::Uuid(*first), SqlValue::Uuid(*last)];
218    let types = vec![Some(SqlType::Uuid), Some(SqlType::Int)];
219    let now: HashMap<Uuid, SqlValue> = query_decoded(db.driver(), &sql, &params, types, |row| {
220        Ok((Uuid::decode(row)?, SqlValue::Int(i64::decode(row)?)))
221    })
222    .await?
223    .into_iter()
224    .collect();
225    Ok(written
226        .iter()
227        .filter(|(id, version)| now.get(id) != Some(version))
228        .map(|(id, _)| *id)
229        .collect())
230}
231
232/// Each stored `search_text` that differs from the recomputed text, unless the record changed
233/// since it was read: that commit wrote its own.
234async fn rewrite_stale(db: &Db, index: &SearchIndex, records: &[Record]) -> Result<()> {
235    let dialect = db.driver().dialect();
236    let sql = format!(
237        "UPDATE {} SET {} = {} WHERE {} = {} AND {} = {}",
238        dialect.qualified(index.module, index.name),
239        dialect.quote(SEARCH_COLUMN),
240        dialect.placeholder(1),
241        dialect.quote("id"),
242        dialect.placeholder(2),
243        dialect.quote("version"),
244        dialect.placeholder(3),
245    );
246    let steps: Vec<Step> = records
247        .iter()
248        .filter(|record| record.stale)
249        .map(|record| {
250            let params = vec![
251                SqlValue::Text(record.document.text.clone()),
252                SqlValue::Uuid(record.document.id),
253                record.version.clone(),
254            ];
255            Step::statement(sql.clone(), params, None)
256        })
257        .collect();
258    if steps.is_empty() {
259        return Ok(());
260    }
261    db.driver().transaction(steps).await
262}
263
264pub struct SearchIndexes(Vec<SearchIndex>);
265
266impl<C: Writes> Resolve<C> for SearchIndexes {
267    fn resolve(context: &mut C) -> impl Future<Output = Result<Self>> + Send {
268        let indexes = Installed::of(context.db()).map(|installed| installed.indexes.clone());
269        std::future::ready(indexes.map(Self))
270    }
271}
272
273/// Each page queues the next, as a mirror's backfill does, so a stopped worker resumes.
274pub async fn reindex(indexes: SearchIndexes) -> Result<Reindexing> {
275    let SearchIndexes(indexes) = indexes;
276    Ok(Reindexing {
277        pages: indexes.into_iter().map(|index| (index, None)).collect(),
278        moved: Vec::new(),
279    })
280}
281
282pub struct Reindexing {
283    pages: Vec<(SearchIndex, Option<Uuid>)>,
284    moved: Vec<(SearchIndex, Uuid)>,
285}
286
287impl Outcome for Reindexing {
288    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
289        let pages = self.pages.iter().map(|(index, after)| {
290            let payload = json!({
291                "module": index.module,
292                "aggregate": index.name,
293                "after": after,
294            });
295            (REINDEX_JOB, payload)
296        });
297        let records = self
298            .moved
299            .iter()
300            .map(|(index, id)| index_job(index.module, index.name, *id));
301        plan.queue(MessageKind::Job, pages.chain(records))
302    }
303}
304
305enum Records {
306    One(Uuid),
307    After(Option<Uuid>),
308}
309
310struct Record {
311    document: Document,
312    hidden: bool,
313    version: SqlValue,
314    stale: bool,
315}
316
317/// Deleted records too, so a job can remove them. The text is recomputed from the fields searched
318/// now, so one a field no longer searched (made `#[sensitive]`) still held leaves the engine.
319async fn read_records(db: &Db, index: &SearchIndex, records: Records) -> Result<Vec<Record>> {
320    let dialect = db.driver().dialect();
321    let text_columns = super::text_columns(index.fields);
322    let mut columns = vec!["id", "version", SEARCH_COLUMN];
323    let mut types = vec![Some(SqlType::Uuid), Some(SqlType::Int), Some(SqlType::Text)];
324    if let Some(owner) = index.owner {
325        columns.push(owner);
326        types.push(Some(SqlType::Uuid));
327    }
328    if let Some(hidden) = index.hidden {
329        columns.push(hidden);
330        types.push(None);
331    }
332    columns.extend(text_columns.iter().map(|(column, _)| column.as_str()));
333    types.extend(text_columns.iter().map(|_| None));
334    let selected: Vec<String> = columns.iter().map(|column| dialect.quote(column)).collect();
335    let (id, first) = (dialect.quote("id"), dialect.placeholder(1));
336    let (rows, params) = match records {
337        Records::One(record) => (
338            format!("WHERE {id} = {first}"),
339            vec![SqlValue::Uuid(record)],
340        ),
341        Records::After(Some(after)) => (
342            format!("WHERE {id} > {first} ORDER BY {id} LIMIT {PAGE}"),
343            vec![SqlValue::Uuid(after)],
344        ),
345        Records::After(None) => (format!("ORDER BY {id} LIMIT {PAGE}"), Vec::new()),
346    };
347    let sql = format!(
348        "SELECT {} FROM {} {rows}",
349        selected.join(", "),
350        dialect.qualified(index.module, index.name)
351    );
352    query_decoded(db.driver(), &sql, &params, types, |row| {
353        decode_record(index, &text_columns, row)
354    })
355    .await
356}
357
358fn decode_record(
359    index: &SearchIndex,
360    text_columns: &[(String, SqlType)],
361    row: &mut dyn ColumnReader,
362) -> Result<Record> {
363    let id = Uuid::decode(row)?;
364    let version = SqlValue::Int(i64::decode(row)?);
365    let stored = String::decode(row)?;
366    let owner = match index.owner {
367        Some(_) => Option::<Uuid>::decode(row)?,
368        None => None,
369    };
370    let hidden = match index.hidden {
371        Some(_) => {
372            let hidden = !row.is_null()?;
373            row.skip()?;
374            hidden
375        }
376        None => false,
377    };
378    let values = text_columns
379        .iter()
380        .map(|(_, ty)| searched_value(*ty, row))
381        .collect::<Result<Vec<_>>>()?;
382    let text = if index.fields.is_empty() {
383        stored.clone()
384    } else {
385        super::search_text(index.fields, |column| {
386            let position = text_columns.iter().position(|(name, _)| name == column)?;
387            values.get(position)
388        })
389    };
390    Ok(Record {
391        stale: text != stored,
392        document: Document { id, text, owner },
393        hidden,
394        version,
395    })
396}
397
398/// Only text and ids make words; any other value reads as nothing.
399fn searched_value(ty: SqlType, row: &mut dyn ColumnReader) -> Result<SqlValue> {
400    if row.is_null()? {
401        row.skip()?;
402        return Ok(SqlValue::Null);
403    }
404    match ty {
405        SqlType::Text => Ok(SqlValue::Text(row.text()?.to_owned())),
406        SqlType::Uuid => Ok(SqlValue::Uuid(row.uuid()?)),
407        _ => {
408            row.skip()?;
409            Ok(SqlValue::Null)
410        }
411    }
412}