Skip to main content

autumn_search/
source.rs

1//! Where reindex and backfill read records from.
2//!
3//! The reindex job deliberately does **not** carry the record in its payload.
4//! It carries `(index, id)` and re-reads the row, which is what makes the
5//! operation idempotent and self-healing: a replayed job, a lost delete, or a
6//! row changed by direct SQL all converge to the same index state. The
7//! backfill walks the same source, so bootstrapping and incremental sync can
8//! never disagree about how a record is extracted.
9//!
10//! [`crate::PostgresSearchStore`] implements this generically from the
11//! [`IndexDefinition`] alone — no per-model generated code.
12
13use std::collections::BTreeMap;
14use std::sync::RwLock;
15
16use autumn_web::search::{IndexDefinition, SearchDocument, SearchIndexed};
17
18use crate::backend::BoxFuture;
19use crate::error::SearchResult;
20
21/// Reads records out of the system of record and extracts them into
22/// [`SearchDocument`]s.
23pub trait DocumentSource: Send + Sync + 'static {
24    /// Fetch the documents for `ids`.
25    ///
26    /// Ids with no row are simply **absent** from the result — that absence is
27    /// the signal the reindex job uses to delete a document, so it must not be
28    /// an error.
29    fn fetch<'a>(
30        &'a self,
31        definition: &'a IndexDefinition,
32        ids: &'a [i64],
33    ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>>;
34
35    /// Return up to `limit` documents whose id is greater than `after`,
36    /// ordered by ascending id.
37    ///
38    /// Keyset pagination rather than `OFFSET`: a backfill of a live table must
39    /// not skip or repeat rows when concurrent writes shift offsets.
40    fn scan<'a>(
41        &'a self,
42        definition: &'a IndexDefinition,
43        after: Option<i64>,
44        limit: usize,
45    ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>>;
46}
47
48/// An in-memory [`DocumentSource`] for tests and for apps that already hold
49/// their corpus in process.
50///
51/// Also records how many times each id was fetched, so a test can assert that
52/// an operation did **not** re-read the source.
53#[derive(Debug, Default)]
54pub struct MemoryDocumentSource {
55    documents: RwLock<BTreeMap<(String, i64), SearchDocument>>,
56    fetches: RwLock<BTreeMap<i64, usize>>,
57}
58
59impl MemoryDocumentSource {
60    /// Create an empty source.
61    #[must_use]
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Insert or replace `record`'s document.
67    pub fn upsert<M: SearchIndexed>(&self, record: &M) {
68        self.upsert_document(record.search_document());
69    }
70
71    /// Insert or replace an already-extracted document.
72    pub fn upsert_document(&self, document: SearchDocument) {
73        // `PoisonError::into_inner` rather than `if let Ok(...)`: swallowing a
74        // poisoned lock would make every subsequent write a silent no-op that
75        // still looks like a success.
76        let mut guard = self
77            .documents
78            .write()
79            .unwrap_or_else(std::sync::PoisonError::into_inner);
80        guard.insert((document.index.to_owned(), document.id), document);
81    }
82
83    /// Remove a record from every index in this source.
84    pub fn remove(&self, id: i64) {
85        self.documents
86            .write()
87            .unwrap_or_else(std::sync::PoisonError::into_inner)
88            .retain(|(_, key), _| *key != id);
89    }
90
91    /// How many times `id` has been fetched.
92    #[must_use]
93    pub fn fetch_calls_for(&self, id: i64) -> usize {
94        self.fetches
95            .read()
96            .unwrap_or_else(std::sync::PoisonError::into_inner)
97            .get(&id)
98            .copied()
99            .unwrap_or(0)
100    }
101
102    fn record_fetch(&self, ids: &[i64]) {
103        let mut guard = self
104            .fetches
105            .write()
106            .unwrap_or_else(std::sync::PoisonError::into_inner);
107        for id in ids {
108            guard
109                .entry(*id)
110                .and_modify(|count| *count = count.saturating_add(1))
111                .or_insert(1);
112        }
113    }
114}
115
116impl DocumentSource for MemoryDocumentSource {
117    fn fetch<'a>(
118        &'a self,
119        definition: &'a IndexDefinition,
120        ids: &'a [i64],
121    ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>> {
122        Box::pin(async move {
123            self.record_fetch(ids);
124            let guard = self
125                .documents
126                .read()
127                .unwrap_or_else(std::sync::PoisonError::into_inner);
128            Ok(ids
129                .iter()
130                .filter_map(|id| guard.get(&(definition.name.to_owned(), *id)).cloned())
131                .collect())
132        })
133    }
134
135    fn scan<'a>(
136        &'a self,
137        definition: &'a IndexDefinition,
138        after: Option<i64>,
139        limit: usize,
140    ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>> {
141        Box::pin(async move {
142            let guard = self
143                .documents
144                .read()
145                .unwrap_or_else(std::sync::PoisonError::into_inner);
146            Ok(guard
147                .iter()
148                .filter(|((index, id), _)| {
149                    index == definition.name && after.is_none_or(|after| *id > after)
150                })
151                .take(limit)
152                .map(|(_, document)| document.clone())
153                .collect())
154        })
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use autumn_web::search::SearchIndexField;
161
162    use super::*;
163
164    const FIELDS: &[SearchIndexField] = &[SearchIndexField::new("title", 'A')];
165
166    fn definition() -> IndexDefinition {
167        IndexDefinition::new("articles", "simple", FIELDS, None, false)
168    }
169
170    fn document(id: i64) -> SearchDocument {
171        SearchDocument::new("articles", id).with_field("title", 'A', format!("record {id}"))
172    }
173
174    #[tokio::test]
175    async fn fetching_a_missing_id_is_an_absence_not_an_error() {
176        let source = MemoryDocumentSource::new();
177        source.upsert_document(document(1));
178
179        let found = source
180            .fetch(&definition(), &[1, 2])
181            .await
182            .expect("fetch must not error on a missing row");
183        assert_eq!(found.len(), 1);
184        assert_eq!(found[0].id, 1);
185    }
186
187    #[tokio::test]
188    async fn scanning_walks_ids_in_order_via_keyset_pagination() {
189        let source = MemoryDocumentSource::new();
190        for id in 1..=5 {
191            source.upsert_document(document(id));
192        }
193
194        let first = source.scan(&definition(), None, 2).await.expect("scan");
195        assert_eq!(first.iter().map(|d| d.id).collect::<Vec<_>>(), vec![1, 2]);
196
197        let second = source.scan(&definition(), Some(2), 2).await.expect("scan");
198        assert_eq!(second.iter().map(|d| d.id).collect::<Vec<_>>(), vec![3, 4]);
199
200        let last = source.scan(&definition(), Some(5), 2).await.expect("scan");
201        assert!(last.is_empty());
202    }
203
204    #[tokio::test]
205    async fn scanning_is_scoped_to_one_index() {
206        let source = MemoryDocumentSource::new();
207        source.upsert_document(document(1));
208        source.upsert_document(SearchDocument::new("notes", 1).with_field("title", 'A', "note"));
209
210        let articles = source.scan(&definition(), None, 10).await.expect("scan");
211        assert_eq!(articles.len(), 1);
212        assert_eq!(articles[0].index, "articles");
213    }
214
215    #[tokio::test]
216    async fn fetch_calls_are_counted_per_id() {
217        let source = MemoryDocumentSource::new();
218        source.upsert_document(document(1));
219        assert_eq!(source.fetch_calls_for(1), 0);
220        source.fetch(&definition(), &[1]).await.expect("fetch");
221        source.fetch(&definition(), &[1]).await.expect("fetch");
222        assert_eq!(source.fetch_calls_for(1), 2);
223        assert_eq!(source.fetch_calls_for(9), 0);
224    }
225}