1use 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#[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#[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
142async 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
199async 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, ¶ms, 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
232async 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
273pub 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
317async 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, ¶ms, 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
398fn 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}