autumn_search/postgres.rs
1//! The Postgres [`SearchBackend`] and [`DocumentSource`].
2//!
3//! Reuses the in-core FTS primitives from #842 — `to_tsvector` / `setweight` /
4//! `plainto_tsquery` / `ts_rank_cd` — and adds vectors, with `pgvector` when
5//! the extension is available.
6//!
7//! # Why a framework-owned index table
8//!
9//! Documents live in **one** table, `autumn_search_documents`, keyed
10//! `(index_name, record_id)`, rather than in each model's own `search_vector`
11//! column. That buys three things the per-table column cannot:
12//!
13//! - the index is **engine-agnostic** — swapping to Meilisearch changes the
14//! backend, not every model's migration;
15//! - it is **observable and repairable** — you can count, diff, and purge the
16//! index without touching the system of record;
17//! - a **backfill** and an incremental reindex write through the exact same
18//! path, so bootstrapping and steady state cannot disagree.
19//!
20//! The model's own `#[searchable]` column (and `#[repository(searchable)]`'s
21//! `search()`) is untouched: this subsumes #842 as one backend, it does not
22//! replace it.
23//!
24//! # Vectors: `pgvector` when present, portable when not
25//!
26//! `ensure_index` probes for the `vector` extension. When it is installed
27//! **and** `search.embedding_dimensions` is configured, embeddings are written
28//! to a `vector(N)` column with an ivfflat index and k-NN uses the `<=>`
29//! cosine-distance operator. Otherwise they are written to a portable
30//! `double precision[]` column and ranked with an `autumn_search_cosine()` SQL
31//! function created by the same migration. Same API, same ordering, different
32//! speed — so the plugin is deployable on a managed Postgres without
33//! `pgvector`, and gets the fast path for free where it exists.
34
35use std::sync::atomic::{AtomicBool, Ordering};
36use std::sync::{OnceLock, RwLock};
37
38use autumn_web::pagination::Page;
39use autumn_web::search::{IndexDefinition, SearchDocument};
40use diesel::sql_types::{BigInt, Double, Nullable, Text};
41use diesel_async::RunQueryDsl;
42use diesel_async::pooled_connection::deadpool::Pool;
43
44use crate::backend::{
45 BackendCapabilities, BoxFuture, IndexedDocument, KeywordQuery, SearchBackend, SearchFilter,
46 SearchHit, VectorQuery, empty_page,
47};
48use crate::error::{SearchError, SearchResult};
49use crate::source::DocumentSource;
50use crate::text::query_tokens;
51
52type RuntimePool = Pool<autumn_web::RuntimeConnection>;
53
54/// A `sql_query` with its parameters still to be bound.
55type BoxedQuery<'a> = diesel::query_builder::BoxedSqlQuery<
56 'a,
57 autumn_web::RuntimeBackend,
58 diesel::query_builder::SqlQuery,
59>;
60
61/// Physical table every index's documents live in.
62pub const DOCUMENTS_TABLE: &str = "autumn_search_documents";
63
64/// Ledger of deleted records, consulted by a watermarked (backfill) insert.
65pub const DELETES_TABLE: &str = "autumn_search_deletes";
66
67/// How long a delete stays in [`DELETES_TABLE`].
68///
69/// It only has to outlive the longest backfill that could still be holding a
70/// stale batch. Seven days is far beyond that and keeps the table small on a
71/// delete-heavy index; pruning runs once per process, at `ensure_index`.
72const DELETE_RETENTION: &str = "7 days";
73
74/// `ts_rank_cd`'s `{D, C, B, A}` weight array, matching
75/// [`weight_factor`](autumn_web::search::weight_factor).
76///
77/// Postgres' default is `{0.1, 0.2, 0.4, 1.0}`, but the framework's
78/// cross-backend ranking contract is `D:1, C:2, B:5, A:10` — `0.5` for `B`,
79/// not `0.4`. Leaving it defaulted means a query whose `B` and `C` matches
80/// compete ranks differently here than in `MemorySearchBackend`, which the two
81/// suites assert is the *same* contract. Kept as a literal (not formatted at
82/// call time) so the statement text is stable for the prepared-statement
83/// cache; `ts_rank_weights_track_the_framework_factors` fails if
84/// `weight_factor` changes without this.
85const TS_RANK_WEIGHTS: &str = "'{0.1, 0.2, 0.5, 1.0}'::float4[]";
86
87/// How embeddings are physically stored.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89#[non_exhaustive]
90pub enum VectorMode {
91 /// `pgvector` is installed and a dimension is configured: a `vector(N)`
92 /// column plus an ivfflat index, queried with `<=>`.
93 PgVector {
94 /// Declared embedding width.
95 dimensions: usize,
96 },
97 /// Portable fallback: `double precision[]` ranked by an
98 /// `autumn_search_cosine()` SQL function.
99 Array,
100}
101
102impl VectorMode {
103 /// Whether this mode uses the `pgvector` extension.
104 #[must_use]
105 pub const fn is_pgvector(self) -> bool {
106 matches!(self, Self::PgVector { .. })
107 }
108}
109
110// ── Rows ────────────────────────────────────────────────────────────────────
111
112#[derive(diesel::QueryableByName)]
113struct HitRow {
114 #[diesel(sql_type = BigInt)]
115 record_id: i64,
116 #[diesel(sql_type = Double)]
117 score: f64,
118}
119
120#[derive(diesel::QueryableByName)]
121struct CountRow {
122 #[diesel(sql_type = BigInt)]
123 total: i64,
124}
125
126#[derive(diesel::QueryableByName)]
127struct EmbeddingRow {
128 #[diesel(sql_type = Nullable<Text>)]
129 embedding: Option<String>,
130}
131
132#[derive(diesel::QueryableByName)]
133struct SourceRow {
134 #[diesel(sql_type = BigInt)]
135 id: i64,
136 #[diesel(sql_type = Text)]
137 fields: String,
138 #[diesel(sql_type = Nullable<Text>)]
139 tenant_id: Option<String>,
140}
141
142#[derive(diesel::QueryableByName)]
143struct WatermarkRow {
144 #[diesel(sql_type = Text)]
145 watermark: String,
146}
147
148#[derive(diesel::QueryableByName)]
149struct WidthRow {
150 #[diesel(sql_type = BigInt)]
151 width: i64,
152}
153
154#[derive(diesel::QueryableByName)]
155struct ExistsRow {
156 #[diesel(sql_type = diesel::sql_types::Bool)]
157 present: bool,
158}
159
160/// Which optional framework columns a model's table carries.
161///
162/// `bool_or` over zero rows is NULL, so both fields are nullable — a table
163/// that does not exist yet reads as "neither column".
164#[derive(diesel::QueryableByName)]
165struct OptionalColumnsRow {
166 #[diesel(sql_type = Nullable<diesel::sql_types::Bool>)]
167 has_tenant: Option<bool>,
168 #[diesel(sql_type = Nullable<diesel::sql_types::Bool>)]
169 has_deleted_at: Option<bool>,
170}
171
172// ── The store ───────────────────────────────────────────────────────────────
173
174/// Postgres-backed search storage.
175///
176/// Implements **both** [`SearchBackend`] (the index) and [`DocumentSource`]
177/// (reading records back out of their own tables), because both need the same
178/// connection pool and the same identifier-safety rules. `SearchPlugin::postgres()`
179/// installs one instance as both.
180///
181/// The pool is installed at startup ([`PostgresSearchStore::install_pool`]),
182/// so the store can be constructed by the plugin builder before an `AppState`
183/// exists.
184pub struct PostgresSearchStore {
185 pool: OnceLock<RuntimePool>,
186 /// Configured embedding width; `None` disables the `pgvector` fast path.
187 dimensions: Option<usize>,
188 /// Resolved at `ensure_index` time, once per process.
189 vector_mode: RwLock<Option<VectorMode>>,
190 /// Whether the shared DDL has already run in this process. The statements
191 /// are identical for every index, so running them per index is N× the work
192 /// and N× the concurrent-boot race window.
193 schema_ready: AtomicBool,
194 /// Cache of the optional framework columns each source table carries, so a
195 /// backfill does not re-probe `pg_attribute` on every batch.
196 optional_columns: RwLock<std::collections::HashMap<&'static str, OptionalColumns>>,
197 /// Declared width of the PHYSICAL `embedding_vec` column, if it exists —
198 /// which is NOT the same question as "is this process in pgvector mode".
199 ///
200 /// An earlier pgvector-enabled deployment can leave the column behind while
201 /// this process falls back to `Array` (dimensions unset, or a width
202 /// mismatch). Writes must still keep that column in step, or stale vectors
203 /// survive every upsert and a pgvector-mode reader — another replica, or
204 /// this one after a config change — silently ranks on obsolete content.
205 vector_column_width: RwLock<Option<usize>>,
206}
207
208/// Which optional framework columns one source table carries.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210struct OptionalColumns {
211 tenant_id: bool,
212 deleted_at: bool,
213}
214
215impl std::fmt::Debug for PostgresSearchStore {
216 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217 f.debug_struct("PostgresSearchStore")
218 .field("pool_installed", &self.pool.get().is_some())
219 .field("dimensions", &self.dimensions)
220 .field("vector_mode", &self.vector_mode())
221 // UFCS: `RunQueryDsl::load` is in scope and shadows `AtomicBool::load`.
222 .field(
223 "schema_ready",
224 &AtomicBool::load(&self.schema_ready, Ordering::SeqCst),
225 )
226 .finish_non_exhaustive()
227 }
228}
229
230impl PostgresSearchStore {
231 /// Create a store whose pool is installed later.
232 #[must_use]
233 pub fn new(dimensions: Option<usize>) -> Self {
234 Self {
235 pool: OnceLock::new(),
236 dimensions,
237 vector_mode: RwLock::new(None),
238 schema_ready: AtomicBool::new(false),
239 optional_columns: RwLock::new(std::collections::HashMap::new()),
240 vector_column_width: RwLock::new(None),
241 }
242 }
243
244 /// Install the application's connection pool. Idempotent; the first call
245 /// wins.
246 pub fn install_pool(&self, pool: RuntimePool) {
247 let _ = self.pool.set(pool);
248 }
249
250 /// The resolved vector storage mode, once `ensure_index` has run.
251 #[must_use]
252 pub fn vector_mode(&self) -> Option<VectorMode> {
253 self.vector_mode.read().ok().and_then(|guard| *guard)
254 }
255
256 /// Declared width of the physical `embedding_vec` column, if it exists.
257 ///
258 /// Independent of [`Self::vector_mode`]: the column can outlive the mode
259 /// that created it.
260 #[must_use]
261 pub fn vector_column_width(&self) -> Option<usize> {
262 self.vector_column_width
263 .read()
264 .ok()
265 .and_then(|guard| *guard)
266 }
267
268 /// Upsert `documents`, optionally refusing to clobber rows written since
269 /// `watermark`. The single write path behind both
270 /// [`SearchBackend::index`] and [`SearchBackend::index_unless_newer`], so
271 /// the conditional and unconditional forms cannot drift apart.
272 ///
273 /// Issues ONE statement per CHUNK of the batch (almost always exactly
274 /// one statement total), not one per document.
275 /// [`SearchClient::backfill`](crate::SearchClient::backfill) drives this
276 /// with up to `DEFAULT_BACKFILL_BATCH` (500) documents at a time, so a
277 /// per-document round trip turned a full-table backfill into one DB
278 /// round trip per row — invisible in a buffer ranking (each statement is
279 /// cheap) but dominant in `pg_stat_statements.calls`. `index_name` and
280 /// `language` are the same for every document in one call, so they are
281 /// bound ONCE (`$1`, `$2`) per chunk and referenced by every row, which
282 /// is also what keeps a chunk's statement shape — and so its place in
283 /// diesel's prepared-statement cache — independent of chunk size.
284 ///
285 /// `documents` is a caller-supplied slice on a PUBLIC trait method, not
286 /// something this type controls the shape of — [`SearchDocument::fields`]
287 /// is itself a public, uncapped `Vec` a hand-built document can push
288 /// arbitrarily many (even repeated) entries onto — so five defenses
289 /// apply before any SQL is built:
290 ///
291 /// - **duplicate `record_id`s are deduplicated** — a single statement's
292 /// `ON CONFLICT DO UPDATE` cannot affect the same conflict target
293 /// twice (Postgres: "ON CONFLICT DO UPDATE command cannot affect row a
294 /// second time"), so an un-deduplicated multi-row statement would turn
295 /// a batch a hand-built caller (or a third-party `DocumentSource`) is
296 /// entitled to send into a hard error. Which occurrence survives
297 /// depends on `watermark`, because the OLD per-document loop's
298 /// observable behavior differed between the two forms:
299 /// - **unconditional** (`watermark` is `None`): N sequential
300 /// UPSERTs with no guard, so the LAST occurrence's write is what a
301 /// later occurrence would overwrite. Deduplicate keeping the LAST.
302 /// - **watermark-guarded** (`index_unless_newer`): the FIRST
303 /// occurrence's UPSERT sets `updated_at = NOW()`, which is now
304 /// newer than `watermark` — so every LATER occurrence's own guard
305 /// (`WHERE updated_at <= watermark`) fails against that
306 /// just-written row and silently no-ops. Deduplicate keeping the
307 /// FIRST, to match.
308 /// - **every document's embedding width is validated BEFORE
309 /// deduplication** — a malformed duplicate that loses the
310 /// keep-first/keep-last coin flip would otherwise be silently
311 /// discarded, masking malformed embedder output the old per-document
312 /// loop always rejected with `DimensionMismatch` (#2311). Only the
313 /// pgvector-mode + physical-`embedding_vec`-column combination
314 /// rejects; other modes keep the row loop's existing behavior
315 /// (a stale column copy is repaired by `NULL`ing it, not by failing
316 /// the batch).
317 /// - **the batch is split into chunks sized so no single statement can
318 /// approach Postgres's 65,535 bind-parameter limit**, using each
319 /// document's ACTUAL bind count (not `definition.fields.len()`, which
320 /// undercounts a document built with repeated field entries) —
321 /// however large `BackfillOptions::batch_size`, however many fields
322 /// `definition` declares, or however a hand-built document's own
323 /// `fields` was constructed. Each chunk is still its own atomic
324 /// statement; a batch that needed more than one chunk (never true at
325 /// the framework's own 500-document default with a realistic field
326 /// count) is atomic per CHUNK, not across the whole call — a narrower
327 /// guarantee than the single-chunk case, and disclosed as such rather
328 /// than silently assumed.
329 /// - **a chunk also caps at `MAX_ROWS_PER_CHUNK` rows**, independent of
330 /// the parameter budget — a document with few or no weighted fields
331 /// consumes very few binds, so the param budget alone could still
332 /// admit thousands of rows into one statement. The watermark-guarded
333 /// shape joins rows with `UNION ALL`, and a deeply nested chain of
334 /// those risks Postgres' `max_stack_depth` during parsing even while
335 /// comfortably under the bind limit.
336 /// - **the deduplicated batch is sorted by `record_id`**, so every
337 /// writer of overlapping ids locks conflict rows in the SAME order.
338 /// The old per-document loop held at most one row lock at a time
339 /// (autocommit per statement); a multi-row statement can hold several
340 /// at once, so two batches indexing the same ids in different orders
341 /// could otherwise lock-and-wait on each other in a cycle — a
342 /// deadlock a per-document loop could never produce. A fixed order
343 /// across every caller makes that cycle impossible, and changes
344 /// nothing about the query's result, since each row's own
345 /// `ON CONFLICT` target is independent of row order.
346 #[allow(clippy::too_many_lines)] // one linear per-row statement builder, clearest unsplit
347 async fn write_documents(
348 &self,
349 definition: &IndexDefinition,
350 documents: &[IndexedDocument],
351 watermark: Option<&str>,
352 ) -> SearchResult<()> {
353 use std::fmt::Write as _;
354
355 // Leaves comfortable headroom under Postgres' real 65,535
356 // bind-parameter cap for the few shared params (`$1`/`$2`[/`$3`]).
357 const PARAM_BUDGET: usize = 60_000;
358
359 // A separate cap from `PARAM_BUDGET`: a document with few or no weighted
360 // fields consumes very few binds, so the param budget alone could still
361 // admit thousands of rows into one statement. The watermarked shape joins
362 // rows with `UNION ALL`, and a deeply nested `SELECT ... UNION ALL ...`
363 // chain risks Postgres's `max_stack_depth` during parsing and planning — a
364 // failure mode neither the old per-document loop nor the unconditional
365 // `VALUES` form, a flat list rather than a nested tree, has. Comfortably
366 // above the framework's own 500-document default batch, so it never engages
367 // for realistic use.
368 const MAX_ROWS_PER_CHUNK: usize = 1_000;
369
370 checked(definition)?;
371 if documents.is_empty() {
372 return Ok(());
373 }
374 let mut conn = self.conn().await?;
375 // Keyed on the PHYSICAL column, not on this process's mode. The
376 // column can outlive the mode that created it (dimensions unset, a
377 // width mismatch, a mixed fleet), and skipping it then leaves a
378 // stale `embedding_vec` behind after every upsert — which a
379 // pgvector-mode reader silently ranks on. `None` width means the
380 // column is absent and must stay out of the column list entirely.
381 let vector_width = self.vector_column_width();
382 let (vec_column, vec_update) = if vector_width.is_some() {
383 (
384 ", embedding_vec",
385 ", embedding_vec = EXCLUDED.embedding_vec",
386 )
387 } else {
388 ("", "")
389 };
390
391 // Every supplied document is validated BEFORE `dedupe_by_id`
392 // selects the duplicate winner: a malformed duplicate that loses the
393 // keep-first/keep-last coin flip would otherwise be silently
394 // discarded, masking malformed embedder output that the old
395 // per-document loop always rejected (#2311). This runs over the full
396 // supplied slice, not the survivors.
397 validate_embedding_widths(documents, vector_width, self.vector_mode())?;
398
399 // See the doc comment above for why the direction depends on
400 // `watermark`.
401 let mut kept_documents = dedupe_by_id(documents, watermark.is_some());
402
403 // Sorted by `record_id` so every writer of overlapping ids — concurrent
404 // callers, or successive backfill batches — locks conflict rows in the same
405 // order. The old per-document loop held at most one row lock at a time,
406 // autocommitting per statement, so it could never deadlock on row-lock
407 // order; a multi-row statement can hold several at once, and two batches
408 // indexing the same ids in different orders, `[1, 2]` against `[2, 1]`,
409 // could lock and wait on each other in a cycle. A fixed ascending order
410 // across every caller makes that impossible. Sorting after dedup, not
411 // before, because a canonical order only has to hold across the surviving
412 // ids, not whichever occurrence of a duplicate lost — and it changes nothing
413 // about the query result, since each row's `ON CONFLICT` target is
414 // independent of row order.
415 kept_documents.sort_unstable_by_key(|document| document.id());
416
417 let mut cursor = 0usize;
418 while cursor < kept_documents.len() {
419 // $1 index_name, $2 language[, $3 watermark] — shared by every row
420 // in this chunk's statement.
421 let mut binds = vec![
422 Bound::Text(definition.name.to_owned()),
423 Bound::Text(definition.language.to_owned()),
424 ];
425 let watermark_param = watermark.map(|value| {
426 binds.push(Bound::Text(value.to_owned()));
427 3usize
428 });
429 let mut next_param: usize = if watermark_param.is_some() { 4 } else { 3 };
430
431 let mut rows = Vec::new();
432 let mut record_ids = Vec::new();
433
434 while let Some(&document) = kept_documents.get(cursor) {
435 // This document's EXACT bind count: 5 fixed params, +1 for
436 // the vector literal if the column exists, +1 per field
437 // VALUE the document actually carries that `definition`
438 // also declares a weight for — mirrors the loop below
439 // exactly (same predicate, `definition.weight_of`), so a
440 // document with repeated field entries is measured
441 // precisely rather than assumed to carry at most one value
442 // per declared field.
443 let doc_param_count = 5usize
444 .saturating_add(usize::from(vector_width.is_some()))
445 .saturating_add(
446 document
447 .document
448 .fields
449 .iter()
450 .filter(|field| definition.weight_of(field.name).is_some())
451 .count(),
452 );
453 if !rows.is_empty()
454 && (next_param.saturating_add(doc_param_count) > PARAM_BUDGET
455 || rows.len() >= MAX_ROWS_PER_CHUNK)
456 {
457 // Would overflow this chunk's param budget or row-count cap:
458 // stop here without consuming `document`, leaving `cursor`
459 // untouched, and let the outer `while` start a fresh chunk for
460 // it. A chunk with zero rows so far always admits at least one
461 // document whatever its size, so a single document pathological
462 // enough to exceed the budget alone gets exactly the old
463 // per-document loop's behaviour: its own statement, which
464 // Postgres would equally have rejected before this change.
465 break;
466 }
467 cursor = cursor.saturating_add(1);
468
469 let record_id = document.id();
470 record_ids.push(record_id);
471 let record_id_param = format!("${next_param}");
472 binds.push(Bound::BigInt(record_id));
473 next_param = next_param.saturating_add(1);
474
475 let tenant_param = format!("${next_param}");
476 binds.push(Bound::NullableText(document.document.tenant_id.clone()));
477 next_param = next_param.saturating_add(1);
478
479 let fields_param = format!("${next_param}");
480 binds.push(Bound::Text(fields_json(document)?));
481 next_param = next_param.saturating_add(1);
482
483 let content_param = format!("${next_param}");
484 binds.push(Bound::Text(document.document.text()));
485 next_param = next_param.saturating_add(1);
486
487 let embedding_param = format!("${next_param}");
488 binds.push(Bound::NullableText(
489 document.embedding.as_deref().map(array_literal),
490 ));
491 next_param = next_param.saturating_add(1);
492
493 let vec_value = if let Some(width) = vector_width {
494 let literal = match document.embedding.as_deref() {
495 // A width the column cannot hold would fail the insert, and
496 // leaving the old value in place is exactly the staleness
497 // this avoids, so it never reaches SQL.
498 //
499 // Whether that is an error depends on which column this
500 // process actually reads. In pgvector mode k-NN runs off
501 // `embedding_vec`, so writing NULL would leave the row
502 // permanently invisible to semantic search while every write
503 // reported success — a silent hole, and the worst outcome
504 // available. Say so instead.
505 //
506 // Returning here leaves this chunk's statement unexecuted, so
507 // a document earlier in the same chunk never reaches SQL
508 // either, unlike the old per-document loop where earlier rows
509 // would already be committed. A prior chunk, if any, has
510 // already committed: see the doc comment above — atomic per
511 // chunk, not across the whole call.
512 Some(embedding) if embedding.len() != width => {
513 if self.vector_mode().is_some_and(VectorMode::is_pgvector) {
514 return Err(SearchError::DimensionMismatch {
515 expected: width,
516 actual: embedding.len(),
517 });
518 }
519 // Not in pgvector mode: `embedding_vec` is a
520 // leftover column from a previous width, k-NN runs
521 // off the portable `embedding` array (bound above,
522 // at full width), and NULLing the stale copy is the
523 // correct repair rather than a loss.
524 None
525 }
526 other => other.map(vector_literal),
527 };
528 let placeholder = format!(", ${next_param}::vector");
529 binds.push(Bound::NullableText(literal));
530 next_param = next_param.saturating_add(1);
531 placeholder
532 } else {
533 String::new()
534 };
535
536 // The weighted tsvector, built exactly as #842 does:
537 // `setweight(to_tsvector(<lang>, <value>), <weight>)` per field,
538 // concatenated. The weight letter is interpolated, so it comes from
539 // the validated definition rather than from the document: a
540 // hand-built document, or a third-party `DocumentSource`, can carry
541 // any `char`. A field the index does not declare is skipped.
542 let mut vector_sql = String::new();
543 for field in &document.document.fields {
544 let Some(weight) = definition.weight_of(field.name) else {
545 continue;
546 };
547 if !vector_sql.is_empty() {
548 vector_sql.push_str(" || ");
549 }
550 // `$2::text::regconfig`, not `$2::regconfig`: the same
551 // parameter is also the `language` column value, and the
552 // untyped form is "inconsistent types deduced for
553 // parameter" under any client that does not declare bind
554 // types.
555 let field_value_param = format!("${next_param}");
556 let _ = write!(
557 vector_sql,
558 "setweight(to_tsvector($2::text::regconfig, {field_value_param}), '{weight}')"
559 );
560 binds.push(Bound::Text(field.value.clone()));
561 next_param = next_param.saturating_add(1);
562 }
563 if vector_sql.is_empty() {
564 "to_tsvector($2::text::regconfig, '')".clone_into(&mut vector_sql);
565 }
566
567 let columns = format!(
568 "$1, {record_id_param}, {tenant_param}, $2, {fields_param}::jsonb, \
569 {content_param}, {vector_sql}, {embedding_param}::double precision[]{vec_value}"
570 );
571 rows.push(UpsertRow {
572 columns,
573 record_id_param,
574 });
575 }
576
577 // Only consulted by `upsert_sql` on the unconditional path (the
578 // ledger clear), but the slot number and the bind must agree either
579 // way, so both are decided here, once, right after the loop that
580 // fixed `next_param`.
581 let ids_param = next_param;
582 if watermark_param.is_none() {
583 binds.push(Bound::Ids(record_ids));
584 }
585
586 let sql = upsert_sql(&rows, vec_column, vec_update, ids_param, watermark_param);
587
588 bind_all(
589 diesel::sql_query(sql).into_boxed::<autumn_web::RuntimeBackend>(),
590 binds,
591 )
592 .execute(&mut conn)
593 .await
594 .map_err(SearchError::backend)?;
595 }
596
597 Ok(())
598 }
599
600 fn pool(&self) -> SearchResult<&RuntimePool> {
601 require_postgres_runtime()?;
602 self.pool.get().ok_or_else(|| {
603 SearchError::Backend(
604 "the search store has no database pool; is `database.primary_url` configured?"
605 .to_owned(),
606 )
607 })
608 }
609
610 async fn conn(
611 &self,
612 ) -> SearchResult<
613 diesel_async::pooled_connection::deadpool::Object<autumn_web::RuntimeConnection>,
614 > {
615 self.pool()?.get().await.map_err(SearchError::backend)
616 }
617}
618
619/// Narrow a SQL `double precision` score to the `f32` a [`SearchHit`] carries.
620///
621/// Relevance scores are ranking-only and comparable within a single result
622/// set, so `f32` precision is ample; the saturating cast keeps an out-of-range
623/// value finite rather than turning it into an infinity that would poison a
624/// sort.
625#[allow(
626 clippy::cast_possible_truncation,
627 reason = "scores are ranking-only; f32 precision is deliberate"
628)]
629const fn narrow_score(score: f64) -> f32 {
630 if score.is_finite() { score as f32 } else { 0.0 }
631}
632
633/// Validate a definition and return it, so no caller can interpolate an
634/// unvalidated identifier into SQL.
635fn checked(definition: &IndexDefinition) -> SearchResult<()> {
636 definition
637 .validate()
638 .map_err(|e| SearchError::InvalidIndex(e.to_string()))
639}
640
641/// Render an embedding as a Postgres `double precision[]` literal.
642///
643/// Values are `f32`, so every element is a finite decimal produced by Rust's
644/// own float formatting — there is no path for caller text to reach this
645/// string. Non-finite values are clamped to `0`, which keeps a malformed
646/// embedding from producing invalid SQL.
647fn array_literal(vector: &[f32]) -> String {
648 let mut out = String::with_capacity(vector.len().saturating_mul(8).saturating_add(2));
649 out.push('{');
650 for (index, value) in vector.iter().enumerate() {
651 if index > 0 {
652 out.push(',');
653 }
654 let value = if value.is_finite() { *value } else { 0.0 };
655 out.push_str(&value.to_string());
656 }
657 out.push('}');
658 out
659}
660
661/// Render an embedding as a `pgvector` literal (`[1,2,3]`).
662fn vector_literal(vector: &[f32]) -> String {
663 let mut out = String::with_capacity(vector.len().saturating_mul(8).saturating_add(2));
664 out.push('[');
665 for (index, value) in vector.iter().enumerate() {
666 if index > 0 {
667 out.push(',');
668 }
669 let value = if value.is_finite() { *value } else { 0.0 };
670 out.push_str(&value.to_string());
671 }
672 out.push(']');
673 out
674}
675
676/// Parse a `double precision[]` / `vector` text representation back to `f32`s.
677fn parse_vector(raw: &str) -> Vec<f32> {
678 raw.trim_matches(['{', '}', '[', ']'])
679 .split(',')
680 .filter(|part| !part.trim().is_empty())
681 .filter_map(|part| part.trim().parse::<f32>().ok())
682 .collect()
683}
684
685/// SQL fragment restricting a query to `filter`, plus the parameters it needs.
686///
687/// Only **literal, developer-controlled** values are interpolated: record ids
688/// (`i64`, formatted by Rust) and field names taken from `definition`, which
689/// `IndexDefinition::validate` has already checked are bare identifiers. Every
690/// caller-supplied *value* is bound, never interpolated.
691///
692/// An `equals` key that `definition` does not declare renders as `AND FALSE`
693/// rather than reaching SQL. A key is an identifier position, so an unchecked
694/// one would let request text close the quote and `OR` away every restriction
695/// that preceded it — a full widening past the visibility filter, not merely
696/// an injection. Failing closed also matches [`SearchFilter::permits`], which
697/// returns `false` for a field the document does not carry.
698fn filter_sql(
699 definition: &IndexDefinition,
700 filter: &SearchFilter,
701 next_param: &mut usize,
702 binds: &mut Vec<Bound>,
703) -> String {
704 use std::fmt::Write as _;
705
706 let mut sql = String::new();
707
708 if let Some(tenant) = &filter.tenant_id {
709 let _ = write!(sql, " AND tenant_id = ${next_param}");
710 binds.push(Bound::Text(tenant.clone()));
711 *next_param = next_param.saturating_add(1);
712 }
713 if let Some(allowed) = &filter.allowed_ids {
714 if allowed.is_empty() {
715 // Unreachable in practice (callers short-circuit on
716 // `matches_nothing`), but keep the SQL itself fail-closed.
717 sql.push_str(" AND FALSE");
718 } else {
719 // `= ANY($n)` with a bound array, not an interpolated `IN (…)`
720 // list: the literal form would mint a distinct prepared statement
721 // for every distinct id set.
722 let _ = write!(sql, " AND record_id = ANY(${next_param})");
723 binds.push(Bound::Ids(allowed.clone()));
724 *next_param = next_param.saturating_add(1);
725 }
726 }
727 if !filter.excluded_ids.is_empty() {
728 let _ = write!(sql, " AND NOT (record_id = ANY(${next_param}))");
729 binds.push(Bound::Ids(filter.excluded_ids.clone()));
730 *next_param = next_param.saturating_add(1);
731 }
732 for (field, value) in &filter.equals {
733 if field == crate::backend::TENANT_FILTER_KEY {
734 let _ = write!(sql, " AND tenant_id = ${next_param}");
735 } else if definition.has_field(field) {
736 // Allowlisted against the definition, whose field names
737 // `IndexDefinition::validate` has already checked are bare
738 // identifiers; the *value* is bound.
739 let _ = write!(sql, " AND fields ->> '{field}' = ${next_param}");
740 } else {
741 // Not a declared field: match nothing, bind nothing, and do NOT
742 // advance the parameter counter.
743 sql.push_str(" AND FALSE");
744 continue;
745 }
746 binds.push(Bound::Text(value.clone()));
747 *next_param = next_param.saturating_add(1);
748 }
749 sql
750}
751
752/// The ` AND <score> >= $n` fragment for a minimum-score bound, advancing
753/// `next_param` when one is emitted.
754///
755/// The threshold is **bound, never interpolated**. `min_score` can come
756/// straight off a request, and diesel's prepared-statement cache is keyed on
757/// SQL text and never evicts — so a formatted value mints a permanent
758/// statement per distinct threshold, and a caller sweeping values grows the
759/// cache without bound until the server dies. That is the same failure the
760/// LIMIT/OFFSET binds exist to prevent, and the same one that made
761/// interpolated ids an OOM on every backfill.
762///
763/// Emitted only when a bound was asked for, because it repeats the
764/// (non-trivial) score expression. A non-finite bound is dropped rather than
765/// bound: "no threshold" is the safe reading of "not a number", and `>= NaN`
766/// would silently match nothing.
767fn score_threshold_sql(score_expr: &str, min_score: Option<f32>, next_param: &mut usize) -> String {
768 if !min_score.is_some_and(f32::is_finite) {
769 return String::new();
770 }
771 let sql = format!(" AND {score_expr} >= ${next_param}::double precision");
772 *next_param = next_param.saturating_add(1);
773 sql
774}
775
776/// The pgvector `ORDER BY`, which decides whether the ivfflat index may serve
777/// the query — and for a **filtered** query it must not.
778///
779/// ivfflat picks its candidate lists by distance BEFORE the `WHERE` clause
780/// runs, so a selective tenant or visibility predicate can leave the probed
781/// lists holding few or none of the rows the caller is allowed to see. The
782/// query then returns short, or empty, while qualifying neighbours sit in
783/// unprobed lists. That is a wrong answer to an authorization-scoped query
784/// rather than an approximate one, and it would diverge from the array
785/// backend, which the two suites assert implements the same contract.
786///
787/// Ordering by the derived similarity is opaque to the planner, so it forces
788/// an exact scan: slower, and right. An unfiltered query keeps the
789/// index-friendly `<=>` form, which is where the fast path actually pays.
790fn pgvector_order_by(filtered: bool) -> String {
791 if filtered {
792 "ORDER BY (1 - (embedding_vec <=> $2::vector)) DESC, record_id ASC".to_owned()
793 } else {
794 "ORDER BY (embedding_vec <=> $2::vector) ASC, record_id ASC".to_owned()
795 }
796}
797
798/// The pgvector similarity scan's `WHERE` predicate on the embedding column.
799///
800/// Beyond the NULL check, it excludes zero-norm stored vectors: pgvector's
801/// `<=>` returns NaN for them, and NaN sorts FIRST under `DESC`, so a
802/// filtered query's NaN rows would consume `LIMIT` slots ahead of valid
803/// neighbours — the Rust-side `is_finite` filter runs after `LIMIT` and
804/// cannot give those slots back (#2313). NaN is not equal to itself, so a
805/// self-equality test on the distance drops exactly the NaN rows. (The
806/// portable `autumn_search_cosine` yields 0 for zero-norm vectors rather
807/// than NaN, so the array predicate needs no such guard.)
808fn pgvector_embedding_predicate() -> String {
809 "embedding_vec IS NOT NULL AND (embedding_vec <=> $2::vector) = (embedding_vec <=> $2::vector)"
810 .to_owned()
811}
812
813/// References into `documents` keeping only ONE occurrence of each
814/// `record_id` — the FIRST if `keep_first`, else the LAST — in ascending
815/// original-index order.
816///
817/// `documents` is a caller-supplied slice on a PUBLIC trait method
818/// (`SearchBackend::index` / `index_unless_newer`), so nothing guarantees
819/// unique ids — a hand-built batch, or a third-party `DocumentSource`, is
820/// entitled to send the same record twice. A single multi-row statement
821/// cannot tolerate that the way the old per-document loop did (N sequential
822/// UPSERTs), because Postgres rejects `ON CONFLICT DO UPDATE` acting on the
823/// same conflict target twice within one statement — see
824/// [`PostgresSearchStore::write_documents`]'s doc comment for why the
825/// surviving occurrence has to differ between the watermarked and
826/// unconditional forms to match the old loop's observable result.
827fn dedupe_by_id(documents: &[IndexedDocument], keep_first: bool) -> Vec<&IndexedDocument> {
828 let mut index_for_id: std::collections::HashMap<i64, usize> =
829 std::collections::HashMap::with_capacity(documents.len());
830 for (index, document) in documents.iter().enumerate() {
831 if keep_first {
832 index_for_id.entry(document.id()).or_insert(index);
833 } else {
834 index_for_id.insert(document.id(), index);
835 }
836 }
837 let mut kept_indices: Vec<usize> = index_for_id.into_values().collect();
838 kept_indices.sort_unstable();
839 kept_indices
840 .into_iter()
841 .filter_map(|index| documents.get(index))
842 .collect()
843}
844
845/// Reject a batch carrying a wrong-width embedding BEFORE [`dedupe_by_id`]
846/// selects the duplicate winner.
847///
848/// The old per-document loop validated every document as it wrote it, so a
849/// malformed embedder output always surfaced as
850/// [`SearchError::DimensionMismatch`]. Deduplication runs first now, and a
851/// malformed duplicate that loses the keep-first/keep-last coin flip is
852/// silently discarded — an unconditional batch `[bad-width id=1, valid id=1]`
853/// succeeds, and a watermark-guarded batch `[valid id=1, bad-width id=1]`
854/// succeeds too. Malformed output from the app's embedder is then masked
855/// rather than reported (#2311).
856///
857/// This mirrors the per-document check the row loop in `write_documents`
858/// still applies: only the pgvector-mode + physical-`embedding_vec`-column
859/// combination rejects a width mismatch. Every other combination keeps the
860/// loop's existing behavior — notably the portable/`Array` mode, where a
861/// stale `embedding_vec` copy left by a previous width is repaired by `NULL`ing
862/// it rather than failing the batch.
863fn validate_embedding_widths(
864 documents: &[IndexedDocument],
865 vector_width: Option<usize>,
866 vector_mode: Option<VectorMode>,
867) -> SearchResult<()> {
868 let Some(width) = vector_width else {
869 // No physical column: nothing to validate against.
870 return Ok(());
871 };
872 if !vector_mode.is_some_and(VectorMode::is_pgvector) {
873 // Only pgvector-mode writers run k-NN off `embedding_vec`; other
874 // modes write the portable `embedding` array at full width and let
875 // the row loop repair a stale column copy by NULLing it.
876 return Ok(());
877 }
878 for document in documents {
879 if let Some(embedding) = document.embedding.as_deref()
880 && embedding.len() != width
881 {
882 return Err(SearchError::DimensionMismatch {
883 expected: width,
884 actual: embedding.len(),
885 });
886 }
887 }
888 Ok(())
889}
890
891/// The `record_id` order `delete()` binds for its ledger `INSERT ...
892/// unnest($2)`.
893///
894/// The insert acquires its tombstone rows in array order, so the array must
895/// be ascending to match the unconditional batch's `ORDER BY record_id FOR
896/// UPDATE` tombstone clear — opposite orders on the two paths deadlock over
897/// the same tombstones (#2310). Kept as a pure helper so the ordering
898/// contract is unit-testable without a database.
899fn ascending_ids(ids: &[i64]) -> Vec<i64> {
900 let mut sorted = ids.to_vec();
901 sorted.sort_unstable();
902 sorted
903}
904
905/// One row's already-rendered column-value expression list, in the exact
906/// order `upsert_sql`'s INSERT column list declares (`index_name,
907/// record_id, tenant_id, language, fields, content, search_vector,
908/// embedding[, embedding_vec]`) — built by [`PostgresSearchStore::write_documents`],
909/// one per document in the batch.
910///
911/// `record_id_param` is carried alongside `columns` rather than parsed back
912/// out of it, because the watermark guard (below) needs this row's OWN
913/// `record_id` placeholder to restrict the tombstone check to this record,
914/// and every row's placeholder number differs.
915struct UpsertRow {
916 columns: String,
917 record_id_param: String,
918}
919
920/// The document upsert for a whole batch of rows in ONE statement, optionally
921/// guarded by a watermark in parameter slot `watermark_param`. `ids_param`
922/// names the slot bound to the array of every row's `record_id`, consulted
923/// only on the unconditional (ledger-clearing) path below.
924///
925/// One statement rather than one per document: `write_documents` used to
926/// issue this per document, which turned a 500-document backfill batch into
927/// 500 round trips — invisible in a buffer-cost ranking (each statement is
928/// cheap) but dominant in `pg_stat_statements.calls`. Batching costs nothing
929/// in complexity here because every row's SQL is still built exactly as
930/// before (same per-field `setweight`/`to_tsvector` expression, same casts);
931/// this function only decides how the per-row fragments are joined into one
932/// statement.
933///
934/// The guard goes on **both** arms, and the two failures are different:
935///
936/// - the `DO UPDATE` arm covers a record a concurrent reindex REWROTE — a
937/// stale backfill batch would otherwise put the old text back;
938/// - the insert arm covers one it DELETED. With the row gone there is no
939/// conflict, so an unguarded insert re-creates the record and resurrects
940/// something a user deleted. `autumn_search_deletes` exists precisely
941/// because, once the document is gone, it is the only evidence the insert
942/// path has that anything happened.
943///
944/// `INSERT ... SELECT ... UNION ALL SELECT ...` rather than a multi-row
945/// `VALUES` when guarding, because `VALUES` admits no predicate and the
946/// delete guard would have nowhere to attach. `ON CONFLICT` applies to the
947/// unioned `SELECT` form just the same, and each arm can carry its own
948/// `record_id` while all still sharing parameter slot `watermark_param` for
949/// the watermark itself — bound once, not once per row.
950fn upsert_sql(
951 rows: &[UpsertRow],
952 vec_column: &str,
953 vec_update: &str,
954 ids_param: usize,
955 watermark_param: Option<usize>,
956) -> String {
957 let (guard, values_source) = watermark_param.map_or_else(
958 || {
959 (
960 String::new(),
961 format!(
962 "VALUES {}",
963 rows.iter()
964 .map(|row| format!("({})", row.columns))
965 .collect::<Vec<_>>()
966 .join(", ")
967 ),
968 )
969 },
970 |slot| {
971 (
972 format!(" WHERE {DOCUMENTS_TABLE}.updated_at <= ${slot}::timestamptz"),
973 rows.iter()
974 .map(|row| {
975 format!(
976 "SELECT {} \
977 WHERE NOT EXISTS (SELECT 1 FROM {DELETES_TABLE} d \
978 WHERE d.index_name = $1 AND d.record_id = {} \
979 AND d.deleted_at > ${slot}::timestamptz)",
980 row.columns, row.record_id_param
981 )
982 })
983 .collect::<Vec<_>>()
984 .join(" UNION ALL "),
985 )
986 },
987 );
988 let upsert = format!(
989 "INSERT INTO {DOCUMENTS_TABLE} \
990 (index_name, record_id, tenant_id, language, fields, content, \
991 search_vector, embedding{vec_column}) \
992 {values_source} \
993 ON CONFLICT (index_name, record_id) DO UPDATE SET \
994 tenant_id = EXCLUDED.tenant_id, \
995 language = EXCLUDED.language, \
996 fields = EXCLUDED.fields, \
997 content = EXCLUDED.content, \
998 search_vector = EXCLUDED.search_vector, \
999 embedding = EXCLUDED.embedding{vec_update}, \
1000 updated_at = NOW(){guard}"
1001 );
1002
1003 if watermark_param.is_some() {
1004 // A watermarked (backfill) write leaves the ledger alone: its batch is
1005 // older than any tombstone by construction, so it has nothing to say
1006 // about whether the delete still stands.
1007 return upsert;
1008 }
1009
1010 // An unconditional write is the record coming back — a reindex read the row and found
1011 // it present, which supersedes any earlier delete — so the tombstone is cleared, or a
1012 // later backfill would skip a record that exists.
1013 //
1014 // In the same statement, for the reason the round before learned the hard way. As two
1015 // statements a delete can interleave: the upsert commits, the delete commits (document
1016 // removed, tombstone written), and then the trailing clear erases that newer tombstone
1017 // while the document stays absent, leaving a backfill able to see neither and resurrect
1018 // the record. One statement means the two possible serializations are "upsert+clear,
1019 // then delete" (document absent, tombstone present) and "delete, then upsert+clear"
1020 // (document present, no tombstone). Both are consistent; the inconsistent interleaving
1021 // cannot happen.
1022 //
1023 // `cleared` locks the ledger rows in ascending `record_id` order — via `ORDER BY`
1024 // plus `FOR UPDATE` in a `SELECT`, the only thing that actually controls Postgres's
1025 // row-lock acquisition order — matching `delete()`'s `INSERT ... unnest($2)` order.
1026 // A bare `DELETE ... WHERE record_id = ANY($n)` locks whatever rows its scan visits,
1027 // independent of the array's order, so racing an oppositely-ordered `delete()` over
1028 // the same tombstones could deadlock: each statement holds one ledger row and waits
1029 // for the other (#2310).
1030 format!(
1031 "WITH upserted AS ( \
1032 {upsert} \
1033 ), \
1034 cleared AS ( \
1035 SELECT record_id FROM {DELETES_TABLE} \
1036 WHERE index_name = $1 AND record_id = ANY(${ids_param}) \
1037 ORDER BY record_id \
1038 FOR UPDATE \
1039 ) \
1040 DELETE FROM {DELETES_TABLE} \
1041 WHERE index_name = $1 AND record_id IN (SELECT record_id FROM cleared)"
1042 )
1043}
1044
1045/// The bound value for [`score_threshold_sql`], if it emitted a fragment.
1046///
1047/// Kept next to it so the two cannot disagree about whether a parameter was
1048/// consumed — a mismatch would shift every later parameter by one.
1049fn score_threshold_bind(min_score: Option<f32>) -> Option<Bound> {
1050 min_score
1051 .filter(|min| min.is_finite())
1052 .map(|min| Bound::Double(f64::from(min)))
1053}
1054
1055/// One bound parameter.
1056///
1057/// Exists so a query can be assembled as `(sql, Vec<Bound>)` and bound in one
1058/// place, rather than every call site tracking two parallel lists of differing
1059/// SQL types.
1060#[derive(Debug, Clone, PartialEq)]
1061enum Bound {
1062 Text(String),
1063 NullableText(Option<String>),
1064 BigInt(i64),
1065 Double(f64),
1066 Ids(Vec<i64>),
1067}
1068
1069/// Bind `binds`, in order, onto a boxed query.
1070fn bind_all(mut query: BoxedQuery<'_>, binds: impl IntoIterator<Item = Bound>) -> BoxedQuery<'_> {
1071 for bound in binds {
1072 query = match bound {
1073 Bound::Text(value) => query.bind::<Text, _>(value),
1074 Bound::NullableText(value) => query.bind::<Nullable<Text>, _>(value),
1075 Bound::BigInt(value) => query.bind::<BigInt, _>(value),
1076 Bound::Double(value) => query.bind::<Double, _>(value),
1077 Bound::Ids(values) => bind_ids(query, values),
1078 };
1079 }
1080 query
1081}
1082
1083/// Refuse every use of this store on a `SQLite` build of autumn-web.
1084///
1085/// The store sends Postgres SQL. An app can build it and install a `SQLite`
1086/// pool without the plugin, so every trait method checks first, before any
1087/// early return, and the pool accessor checks too (#2539 §5).
1088#[allow(
1089 clippy::unnecessary_wraps,
1090 clippy::missing_const_for_fn,
1091 reason = "the SQLite arm of `backend_select!` returns an error"
1092)]
1093fn require_postgres_runtime() -> SearchResult<()> {
1094 autumn_web::backend_select! {
1095 pg => { Ok(()) },
1096 sqlite => {
1097 Err(SearchError::Backend(
1098 "PostgresSearchStore needs the Postgres backend, but this build of autumn-web \
1099 uses SQLite (`--features sqlite`)"
1100 .to_owned(),
1101 ))
1102 },
1103 }
1104}
1105
1106/// Bind an id list as a Postgres `BIGINT[]`.
1107///
1108/// `SQLite` has no array type. The store refuses every query on a `SQLite`
1109/// build ([`require_postgres_runtime`]), so that arm does not run. It binds
1110/// the ids as JSON text to keep the bind count correct and the crate free of
1111/// panics.
1112fn bind_ids(query: BoxedQuery<'_>, values: Vec<i64>) -> BoxedQuery<'_> {
1113 autumn_web::backend_select! {
1114 pg => { query.bind::<diesel::sql_types::Array<BigInt>, _>(values) },
1115 sqlite => { query.bind::<Text, _>(serde_json::Value::from(values).to_string()) },
1116 }
1117}
1118
1119impl SearchBackend for PostgresSearchStore {
1120 fn name(&self) -> &'static str {
1121 "postgres"
1122 }
1123
1124 fn capabilities(&self) -> BackendCapabilities {
1125 BackendCapabilities::default()
1126 .with_vector(true)
1127 .with_weighted_fields(true)
1128 .with_embedding_readback(true)
1129 .with_conditional_index(true)
1130 }
1131
1132 fn write_watermark(&self) -> BoxFuture<'_, SearchResult<Option<String>>> {
1133 Box::pin(async move {
1134 require_postgres_runtime()?;
1135 // The DATABASE's clock, not the process's. `updated_at` is set by
1136 // `NOW()` server-side, so comparing it against an app-side
1137 // timestamp would be at the mercy of clock skew between however
1138 // many app instances and the database — and the whole point of the
1139 // watermark is deciding which of two writes is newer.
1140 let mut conn = self.conn().await?;
1141 let row = diesel::sql_query("SELECT NOW()::text AS watermark")
1142 .get_result::<WatermarkRow>(&mut conn)
1143 .await
1144 .map_err(SearchError::backend)?;
1145 Ok(Some(row.watermark))
1146 })
1147 }
1148
1149 fn index_unless_newer<'a>(
1150 &'a self,
1151 definition: &'a IndexDefinition,
1152 documents: &'a [IndexedDocument],
1153 watermark: Option<&'a str>,
1154 ) -> BoxFuture<'a, SearchResult<()>> {
1155 Box::pin(async move {
1156 require_postgres_runtime()?;
1157 self.write_documents(definition, documents, watermark).await
1158 })
1159 }
1160
1161 fn ensure_index<'a>(
1162 &'a self,
1163 definition: &'a IndexDefinition,
1164 ) -> BoxFuture<'a, SearchResult<()>> {
1165 Box::pin(async move {
1166 require_postgres_runtime()?;
1167 checked(definition)?;
1168 let mut conn = self.conn().await?;
1169
1170 // The DDL is the same for every index, so run it once per process
1171 // rather than once per registered model.
1172 if !self.schema_ready.swap(true, Ordering::SeqCst)
1173 && let Err(error) = Self::apply_schema(&mut conn).await
1174 {
1175 // Let a later index retry the DDL rather than leaving the
1176 // process permanently believing the schema exists.
1177 self.schema_ready.store(false, Ordering::SeqCst);
1178 return Err(error);
1179 }
1180
1181 // Resolve the vector storage mode once per process. A managed
1182 // Postgres without `pgvector` must still work, so a failed
1183 // `CREATE EXTENSION` degrades rather than aborting boot.
1184 if self.vector_mode().is_none() {
1185 let mode = match self.dimensions {
1186 // Threaded `&mut conn` rather than acquiring a second
1187 // connection: holding two at boot deadlocks a pool of one.
1188 Some(dimensions) if self.try_enable_pgvector(&mut conn, dimensions).await? => {
1189 VectorMode::PgVector { dimensions }
1190 }
1191 _ => VectorMode::Array,
1192 };
1193 if let Ok(mut guard) = self.vector_mode.write() {
1194 *guard = Some(mode);
1195 }
1196
1197 // Probe the PHYSICAL column regardless of the resolved mode: it
1198 // may have been created by an earlier deployment, and writes
1199 // have to keep it in step either way.
1200 let width = Self::vector_column_width_of(&mut conn).await?;
1201 if let Ok(mut guard) = self.vector_column_width.write() {
1202 *guard = width;
1203 }
1204 tracing::info!(
1205 ?mode,
1206 vector_column_width = ?width,
1207 "autumn-search postgres vector storage resolved"
1208 );
1209
1210 // Prune the delete ledger once per process. An entry only has
1211 // to outlive the longest backfill that could still hold a
1212 // stale batch; past that it is dead weight on a delete-heavy
1213 // index. Best-effort — a failure here must not abort boot.
1214 if let Err(error) = diesel::sql_query(format!(
1215 "DELETE FROM {DELETES_TABLE} WHERE deleted_at < NOW() - INTERVAL '{DELETE_RETENTION}'"
1216 ))
1217 .execute(&mut conn)
1218 .await
1219 {
1220 tracing::warn!(%error, "autumn-search could not prune the delete ledger");
1221 }
1222 }
1223 Ok(())
1224 })
1225 }
1226
1227 fn index<'a>(
1228 &'a self,
1229 definition: &'a IndexDefinition,
1230 documents: &'a [IndexedDocument],
1231 ) -> BoxFuture<'a, SearchResult<()>> {
1232 Box::pin(async move {
1233 require_postgres_runtime()?;
1234 self.write_documents(definition, documents, None).await
1235 })
1236 }
1237
1238 fn delete<'a>(
1239 &'a self,
1240 definition: &'a IndexDefinition,
1241 ids: &'a [i64],
1242 ) -> BoxFuture<'a, SearchResult<()>> {
1243 Box::pin(async move {
1244 require_postgres_runtime()?;
1245 checked(definition)?;
1246 // `IN ()` is a syntax error, so an empty slice must never reach SQL.
1247 if ids.is_empty() {
1248 return Ok(());
1249 }
1250 let mut conn = self.conn().await?;
1251 // One statement, via a data-modifying CTE.
1252 //
1253 // Removing the document and recording the delete are two halves of one
1254 // fact, and a concurrent backfill must never observe the gap between
1255 // them: after the DELETE commits but before the ledger row exists, a
1256 // watermarked insert sees no document to conflict with and no delete to
1257 // be blocked by, so it re-creates the record — and writing the tombstone
1258 // afterwards does not take it back out. Two autocommitted statements have
1259 // exactly that window.
1260 //
1261 // A CTE rather than an explicit transaction, because a single statement is
1262 // atomic under autocommit with nothing to roll back, and it keeps this off
1263 // diesel-async's transaction API. Postgres runs a data-modifying CTE to
1264 // completion whether or not the outer query reads it, so the DELETE needs
1265 // no RETURNING. `deleted_at` is refreshed on a replayed delete, so a retry
1266 // cannot age out earlier than the delete it repeats.
1267 //
1268 // `doomed` locks its rows in ascending `record_id` order — matching
1269 // `write_documents`' and `clear`'s order — via `ORDER BY` plus `FOR
1270 // UPDATE` in a `SELECT`, the only thing that actually controls Postgres's
1271 // row-lock acquisition order. Binding a pre-sorted array to `record_id =
1272 // ANY($2)` does not: Postgres may satisfy that predicate with a
1273 // sequential, bitmap, or index scan and locks rows in whatever order the
1274 // scan visits them, independent of the array's order, so a bare `DELETE
1275 // ... WHERE record_id = ANY($2)` could lock out of order and deadlock
1276 // against a concurrent `write_documents` batch.
1277 //
1278 // The ledger half needs the same treatment in reverse: the `INSERT ...
1279 // unnest($2)` below acquires its tombstone rows in array order, so the
1280 // array is pre-sorted ascending to match the unconditional batch's
1281 // `ORDER BY record_id FOR UPDATE` tombstone clear. Opposite orders on
1282 // the two paths deadlock over the same tombstones (#2310).
1283 let sorted_ids = ascending_ids(ids);
1284 bind_all(
1285 diesel::sql_query(format!(
1286 "WITH doomed AS ( \
1287 SELECT record_id FROM {DOCUMENTS_TABLE} \
1288 WHERE index_name = $1 AND record_id = ANY($2) \
1289 ORDER BY record_id \
1290 FOR UPDATE \
1291 ), \
1292 removed AS ( \
1293 DELETE FROM {DOCUMENTS_TABLE} \
1294 WHERE index_name = $1 AND record_id IN (SELECT record_id FROM doomed) \
1295 ) \
1296 INSERT INTO {DELETES_TABLE} (index_name, record_id) \
1297 SELECT $1, unnest($2::bigint[]) \
1298 ON CONFLICT (index_name, record_id) DO UPDATE SET deleted_at = NOW()"
1299 ))
1300 .into_boxed::<autumn_web::RuntimeBackend>(),
1301 [
1302 Bound::Text(definition.name.to_owned()),
1303 Bound::Ids(sorted_ids),
1304 ],
1305 )
1306 .execute(&mut conn)
1307 .await
1308 .map_err(SearchError::backend)?;
1309 Ok(())
1310 })
1311 }
1312
1313 fn clear<'a>(&'a self, definition: &'a IndexDefinition) -> BoxFuture<'a, SearchResult<()>> {
1314 Box::pin(async move {
1315 require_postgres_runtime()?;
1316 checked(definition)?;
1317 let mut conn = self.conn().await?;
1318 // Documents and ledger in one statement, for the same reason `delete` uses
1319 // a CTE: a purge is a deliberate reset, and a concurrent write must not be
1320 // able to observe half of it. The ledger has to go too, or the rebuild that
1321 // follows would silently skip everything previously deleted.
1322 //
1323 // `doomed` locks its rows in ascending `record_id` order — `FOR UPDATE` on
1324 // a sorted `SELECT` acquires locks in the order rows are produced —
1325 // matching `write_documents`' and `delete`'s ascending order. A bare
1326 // `DELETE ... WHERE index_name = $1` would instead lock whatever rows its
1327 // scan visits, physical heap order for a sequential scan, which a
1328 // concurrent `write_documents` batch touching the same rows in ascending
1329 // order could deadlock against.
1330 diesel::sql_query(format!(
1331 "WITH doomed AS ( \
1332 SELECT record_id FROM {DOCUMENTS_TABLE} \
1333 WHERE index_name = $1 \
1334 ORDER BY record_id \
1335 FOR UPDATE \
1336 ), \
1337 removed AS ( \
1338 DELETE FROM {DOCUMENTS_TABLE} \
1339 WHERE index_name = $1 AND record_id IN (SELECT record_id FROM doomed) \
1340 ) \
1341 DELETE FROM {DELETES_TABLE} WHERE index_name = $1"
1342 ))
1343 .bind::<Text, _>(definition.name.to_owned())
1344 .execute(&mut conn)
1345 .await
1346 .map_err(SearchError::backend)?;
1347 Ok(())
1348 })
1349 }
1350
1351 fn keyword_search<'a>(
1352 &'a self,
1353 definition: &'a IndexDefinition,
1354 query: &'a KeywordQuery,
1355 ) -> BoxFuture<'a, SearchResult<Page<SearchHit>>> {
1356 Box::pin(async move {
1357 require_postgres_runtime()?;
1358 checked(definition)?;
1359 // Fail closed: a blank query and an impossible filter both return
1360 // an empty page having issued NO query — never a full scan.
1361 if query_tokens(&query.text).is_none() || query.filter.matches_nothing() {
1362 return Ok(empty_page(&query.page));
1363 }
1364
1365 let mut conn = self.conn().await?;
1366 // $1 index_name, $2 language, $3 query text.
1367 let mut param = 4_usize;
1368 let mut binds: Vec<Bound> = Vec::new();
1369 let predicate = filter_sql(definition, &query.filter, &mut param, &mut binds);
1370 let head = [
1371 Bound::Text(definition.name.to_owned()),
1372 Bound::Text(definition.language.to_owned()),
1373 Bound::Text(query.text.clone()),
1374 ];
1375
1376 // `plainto_tsquery` (not `websearch_to_tsquery`): the documented
1377 // cross-backend contract is "every token must match, operators are
1378 // not syntax". It is parameterized, so a hostile query string can
1379 // neither inject nor widen the result set.
1380 let where_clause = format!(
1381 "index_name = $1 \
1382 AND search_vector @@ plainto_tsquery($2::text::regconfig, $3){predicate}"
1383 );
1384
1385 let total = bind_all(
1386 diesel::sql_query(format!(
1387 "SELECT COUNT(*)::bigint AS total FROM {DOCUMENTS_TABLE} WHERE {where_clause}"
1388 ))
1389 .into_boxed::<autumn_web::RuntimeBackend>(),
1390 head.iter().cloned().chain(binds.iter().cloned()),
1391 )
1392 .get_result::<CountRow>(&mut conn)
1393 .await
1394 .map_err(SearchError::backend)?
1395 .total;
1396
1397 // LIMIT/OFFSET are BOUND: interpolating them would mint a distinct
1398 // prepared statement per page, which the unbounded statement cache
1399 // never evicts.
1400 let limit_param = param;
1401 let offset_param = param.saturating_add(1);
1402 let size = i64::from(query.page.size());
1403 let offset = i64::from(query.page.page().saturating_sub(1)).saturating_mul(size);
1404 let rows = bind_all(
1405 diesel::sql_query(format!(
1406 "SELECT record_id, \
1407 ts_rank_cd({TS_RANK_WEIGHTS}, search_vector, \
1408 plainto_tsquery($2::text::regconfig, $3))::double precision \
1409 AS score \
1410 FROM {DOCUMENTS_TABLE} WHERE {where_clause} \
1411 ORDER BY score DESC, record_id ASC \
1412 LIMIT ${limit_param} OFFSET ${offset_param}"
1413 ))
1414 .into_boxed::<autumn_web::RuntimeBackend>(),
1415 head.into_iter()
1416 .chain(binds)
1417 .chain([Bound::BigInt(size), Bound::BigInt(offset)]),
1418 )
1419 .load::<HitRow>(&mut conn)
1420 .await
1421 .map_err(SearchError::backend)?;
1422
1423 Ok(Page::new(
1424 rows.into_iter()
1425 .map(|row| {
1426 SearchHit::new(definition.name, row.record_id, narrow_score(row.score))
1427 })
1428 .collect(),
1429 total,
1430 &query.page,
1431 ))
1432 })
1433 }
1434
1435 fn vector_search<'a>(
1436 &'a self,
1437 definition: &'a IndexDefinition,
1438 query: &'a VectorQuery,
1439 ) -> BoxFuture<'a, SearchResult<Vec<SearchHit>>> {
1440 Box::pin(async move {
1441 require_postgres_runtime()?;
1442 checked(definition)?;
1443 if !definition.supports_vector_search() {
1444 return Err(SearchError::VectorUnsupported {
1445 index: definition.name.to_owned(),
1446 });
1447 }
1448 if query.filter.matches_nothing() || query.limit == 0 || query.vector.is_empty() {
1449 return Ok(Vec::new());
1450 }
1451
1452 let mut conn = self.conn().await?;
1453 // $1 index_name, $2 the query vector (bound as text, cast in SQL).
1454 let mut param = 3_usize;
1455 let mut binds: Vec<Bound> = Vec::new();
1456 let predicate = filter_sql(definition, &query.filter, &mut param, &mut binds);
1457
1458 let pgvector = self.vector_mode().is_some_and(VectorMode::is_pgvector);
1459 // A filter here is an AUTHORIZATION boundary as often as not — a
1460 // tenant, a visibility allowlist, a `similar_to` self-exclusion.
1461 let filtered = query.filter != SearchFilter::default();
1462 // Both modes score cosine similarity, so the two orderings agree —
1463 // pgvector's `<=>` is cosine distance, hence `1 - d`. The ordering differs
1464 // deliberately: an ivfflat index can serve only `ORDER BY col <=> const
1465 // ASC`. Ordering by the derived `1 - d` expression is opaque to the planner
1466 // and forces an exact full scan, which would make the whole pgvector fast
1467 // path buy nothing. So pgvector mode orders by distance ascending and
1468 // converts to similarity only in the select list.
1469 let (query_vector, distance, score_expr, order_by, embedding_predicate) = if pgvector {
1470 (
1471 vector_literal(&query.vector),
1472 "(embedding_vec <=> $2::vector)".to_owned(),
1473 "(1 - (embedding_vec <=> $2::vector))::double precision".to_owned(),
1474 // The ordering decides whether the ivfflat index can serve
1475 // this query, and for a filtered query it must not.
1476 //
1477 // ivfflat picks its candidate lists by distance before the
1478 // `WHERE` clause runs, so a selective tenant or visibility
1479 // predicate can leave the probed lists holding few or none of
1480 // the rows the caller may see, returning short or empty while
1481 // qualifying neighbours sit in unprobed lists. That is a wrong
1482 // answer to an authorization-scoped query, not merely an
1483 // approximate one, and it would differ from the array backend,
1484 // which the two suites assert implements the same contract.
1485 //
1486 // Ordering by the derived similarity is opaque to the planner,
1487 // so it forces an exact scan: slower, and right. An unfiltered
1488 // query keeps the index-friendly form, where the fast path pays.
1489 pgvector_order_by(filtered),
1490 pgvector_embedding_predicate(),
1491 )
1492 } else {
1493 let expr = "autumn_search_cosine(embedding, $2::double precision[])".to_owned();
1494 (
1495 array_literal(&query.vector),
1496 String::new(),
1497 expr.clone(),
1498 format!("ORDER BY {expr} DESC, record_id ASC"),
1499 // `unnest(a, b)` pads the shorter array with NULLs and silently
1500 // mis-scores, so a width mismatch is excluded rather than ranked;
1501 // pgvector's `<=>` errors instead, a documented divergence.
1502 //
1503 // The width is bound, not interpolated. It is the length of a
1504 // caller-supplied vector, so formatting it mints a permanent
1505 // prepared statement per distinct length in a cache that never
1506 // evicts — the same unbounded growth the ids, limits, offsets, and
1507 // score threshold are all bound to avoid. `array_length` returns
1508 // `integer`, hence the cast.
1509 {
1510 let slot = param;
1511 param = param.saturating_add(1);
1512 binds.push(Bound::BigInt(
1513 i64::try_from(query.vector.len()).unwrap_or(i64::MAX),
1514 ));
1515 format!(
1516 "embedding IS NOT NULL AND array_length(embedding, 1) = ${slot}::integer"
1517 )
1518 },
1519 )
1520 };
1521 let _ = distance;
1522
1523 let threshold = score_threshold_sql(&score_expr, query.min_score, &mut param);
1524
1525 let limit_param = param;
1526 let limit = i64::try_from(query.limit).unwrap_or(i64::MAX);
1527 let rows = bind_all(
1528 diesel::sql_query(format!(
1529 "SELECT record_id, {score_expr} AS score FROM {DOCUMENTS_TABLE} \
1530 WHERE index_name = $1 AND {embedding_predicate}{predicate}{threshold} \
1531 {order_by} LIMIT ${limit_param}"
1532 ))
1533 .into_boxed::<autumn_web::RuntimeBackend>(),
1534 [
1535 Bound::Text(definition.name.to_owned()),
1536 Bound::Text(query_vector),
1537 ]
1538 .into_iter()
1539 .chain(binds)
1540 // The threshold parameter, when one was emitted, sits between
1541 // the filter binds and the limit — matching the order `param`
1542 // handed the numbers out above.
1543 .chain(score_threshold_bind(query.min_score))
1544 .chain([Bound::BigInt(limit)]),
1545 )
1546 .load::<HitRow>(&mut conn)
1547 .await
1548 .map_err(SearchError::backend)?;
1549
1550 Ok(rows
1551 .into_iter()
1552 // A zero-norm vector makes pgvector's `<=>` return NaN, which
1553 // sorts FIRST under `DESC`. The `WHERE` predicate excludes
1554 // those rows before `LIMIT` (#2313); this filter stays as
1555 // defense-in-depth so a NaN score can never surface.
1556 .filter(|row| row.score.is_finite())
1557 .map(|row| SearchHit::new(definition.name, row.record_id, narrow_score(row.score)))
1558 .collect())
1559 })
1560 }
1561
1562 fn embedding<'a>(
1563 &'a self,
1564 definition: &'a IndexDefinition,
1565 id: i64,
1566 filter: &'a SearchFilter,
1567 ) -> BoxFuture<'a, SearchResult<Option<Vec<f32>>>> {
1568 Box::pin(async move {
1569 require_postgres_runtime()?;
1570 checked(definition)?;
1571 // A record the filter excludes reads back as absent — this is a
1572 // query like any other, and the seed id is caller-supplied.
1573 if filter.matches_nothing() {
1574 return Ok(None);
1575 }
1576 let mut conn = self.conn().await?;
1577 // $1 index_name, $2 record_id.
1578 let mut param = 3_usize;
1579 let mut binds: Vec<Bound> = Vec::new();
1580 let predicate = filter_sql(definition, filter, &mut param, &mut binds);
1581
1582 let rows = bind_all(
1583 diesel::sql_query(format!(
1584 "SELECT embedding::text AS embedding FROM {DOCUMENTS_TABLE} \
1585 WHERE index_name = $1 AND record_id = $2{predicate}"
1586 ))
1587 .into_boxed::<autumn_web::RuntimeBackend>(),
1588 [Bound::Text(definition.name.to_owned()), Bound::BigInt(id)]
1589 .into_iter()
1590 .chain(binds),
1591 )
1592 .load::<EmbeddingRow>(&mut conn)
1593 .await
1594 .map_err(SearchError::backend)?;
1595
1596 Ok(rows
1597 .into_iter()
1598 .next()
1599 .and_then(|row| row.embedding)
1600 .map(|raw| parse_vector(&raw)))
1601 })
1602 }
1603}
1604
1605impl PostgresSearchStore {
1606 /// Apply the shared DDL under an advisory lock.
1607 ///
1608 /// `CREATE TABLE/INDEX IF NOT EXISTS` and `CREATE OR REPLACE FUNCTION` are
1609 /// **not** race-free: concurrent boots hit `duplicate key value violates
1610 /// unique constraint "pg_type_typname_nsp_index"` and `tuple concurrently
1611 /// updated`. Without this, a rolling deploy would intermittently abort a
1612 /// replica's boot. A session-level advisory lock serializes them; it is
1613 /// released explicitly below and, if the process dies, when the connection
1614 /// closes.
1615 async fn apply_schema(
1616 conn: &mut diesel_async::pooled_connection::deadpool::Object<autumn_web::RuntimeConnection>,
1617 ) -> SearchResult<()> {
1618 const LOCK: &str = "hashtext('autumn_search_schema')::bigint";
1619
1620 diesel::sql_query(format!("SELECT pg_advisory_lock({LOCK})"))
1621 .execute(&mut *conn)
1622 .await
1623 .map_err(SearchError::backend)?;
1624
1625 let mut outcome = Ok(());
1626 for statement in SCHEMA_STATEMENTS {
1627 if let Err(error) = diesel::sql_query(*statement).execute(&mut *conn).await {
1628 outcome = Err(SearchError::backend(error));
1629 break;
1630 }
1631 }
1632
1633 // Released on every path, including the error one. Each DDL statement
1634 // is its own transaction under autocommit, so a failure above does not
1635 // leave the session unable to run this.
1636 let _ = diesel::sql_query(format!("SELECT pg_advisory_unlock({LOCK})"))
1637 .execute(&mut *conn)
1638 .await;
1639
1640 outcome
1641 }
1642
1643 /// Declared width of the physical `embedding_vec` column, or `None` when
1644 /// the column does not exist.
1645 ///
1646 /// `atttypmod` carries a `vector`'s declared dimension count. Resolved via
1647 /// `to_regclass` so it follows the same `search_path` the queries do.
1648 async fn vector_column_width_of(
1649 conn: &mut diesel_async::pooled_connection::deadpool::Object<autumn_web::RuntimeConnection>,
1650 ) -> SearchResult<Option<usize>> {
1651 let rows = diesel::sql_query(format!(
1652 "SELECT COALESCE(atttypmod, 0)::bigint AS width FROM pg_attribute \
1653 WHERE attrelid = to_regclass('{DOCUMENTS_TABLE}') \
1654 AND attname = 'embedding_vec' AND NOT attisdropped"
1655 ))
1656 .get_results::<WidthRow>(conn)
1657 .await
1658 .map_err(SearchError::backend)?;
1659
1660 Ok(rows
1661 .into_iter()
1662 .next()
1663 .and_then(|row| usize::try_from(row.width).ok()))
1664 }
1665
1666 /// Try to install `pgvector` and add the accelerated column + index.
1667 ///
1668 /// Returns `false` (rather than erroring) when the extension is not
1669 /// available: the portable array path is a complete implementation, so a
1670 /// managed Postgres without `pgvector` must boot, not crash.
1671 ///
1672 /// Takes the caller's connection: acquiring a second one while the caller
1673 /// holds the first deadlocks a pool of size 1 at boot.
1674 async fn try_enable_pgvector(
1675 &self,
1676 conn: &mut diesel_async::pooled_connection::deadpool::Object<autumn_web::RuntimeConnection>,
1677 dimensions: usize,
1678 ) -> SearchResult<bool> {
1679 if diesel::sql_query("CREATE EXTENSION IF NOT EXISTS vector")
1680 .execute(&mut *conn)
1681 .await
1682 .is_err()
1683 {
1684 tracing::info!(
1685 "pgvector is not available; autumn-search will store embeddings as \
1686 double precision[] and rank with autumn_search_cosine()"
1687 );
1688 return Ok(false);
1689 }
1690
1691 let present = diesel::sql_query(
1692 "SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') AS present",
1693 )
1694 .get_result::<ExistsRow>(&mut *conn)
1695 .await
1696 .map_err(SearchError::backend)?
1697 .present;
1698 if !present {
1699 return Ok(false);
1700 }
1701
1702 // `dimensions` is a `usize` from config, formatted by Rust — no caller
1703 // text reaches this statement.
1704 diesel::sql_query(format!(
1705 "ALTER TABLE {DOCUMENTS_TABLE} \
1706 ADD COLUMN IF NOT EXISTS embedding_vec vector({dimensions})"
1707 ))
1708 .execute(&mut *conn)
1709 .await
1710 .map_err(SearchError::backend)?;
1711
1712 // `ADD COLUMN IF NOT EXISTS` is a silent no-op against an existing
1713 // column of a DIFFERENT width, which would leave the store believing
1714 // it is in `PgVector{new}` while every insert fails with "expected
1715 // <old> dimensions". Verify what is actually there.
1716 let existing_width = Self::vector_column_width_of(conn)
1717 .await?
1718 .and_then(|w| i64::try_from(w).ok());
1719 if let Some(width) = existing_width
1720 && width > 0
1721 && usize::try_from(width).unwrap_or(0) != dimensions
1722 {
1723 tracing::warn!(
1724 configured = dimensions,
1725 existing = width,
1726 "autumn-search: the pgvector column has a different width than \
1727 `search.embedding_dimensions`; falling back to the portable \
1728 double precision[] path. Drop `autumn_search_documents.embedding_vec` \
1729 and re-run a full backfill to adopt the new width."
1730 );
1731 return Ok(false);
1732 }
1733
1734 // Adopt embeddings written while the store was in Array mode —
1735 // otherwise every pre-existing document silently vanishes from k-NN
1736 // (the query filters `embedding_vec IS NOT NULL`) until a full
1737 // backfill happens to run. Rows written *after* this point stay in step
1738 // via `index()`, which keys on the physical column rather than on the
1739 // mode.
1740 diesel::sql_query(format!(
1741 "UPDATE {DOCUMENTS_TABLE} SET embedding_vec = embedding::text::vector \
1742 WHERE embedding IS NOT NULL AND embedding_vec IS NULL \
1743 AND array_length(embedding, 1) = {dimensions}"
1744 ))
1745 .execute(&mut *conn)
1746 .await
1747 .map_err(SearchError::backend)?;
1748
1749 // Best-effort: an ivfflat index needs training data and fails on an
1750 // empty table in some versions. Its absence costs speed, not
1751 // correctness, so never fail boot for it.
1752 let _ = diesel::sql_query(format!(
1753 "CREATE INDEX IF NOT EXISTS autumn_search_documents_embedding_vec_idx \
1754 ON {DOCUMENTS_TABLE} USING ivfflat (embedding_vec vector_cosine_ops) \
1755 WITH (lists = 100)"
1756 ))
1757 .execute(&mut *conn)
1758 .await;
1759
1760 Ok(true)
1761 }
1762}
1763
1764impl DocumentSource for PostgresSearchStore {
1765 fn fetch<'a>(
1766 &'a self,
1767 definition: &'a IndexDefinition,
1768 ids: &'a [i64],
1769 ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>> {
1770 Box::pin(async move {
1771 require_postgres_runtime()?;
1772 checked(definition)?;
1773 if ids.is_empty() {
1774 return Ok(Vec::new());
1775 }
1776 self.load_documents(definition, Selection::Ids(ids)).await
1777 })
1778 }
1779
1780 fn scan<'a>(
1781 &'a self,
1782 definition: &'a IndexDefinition,
1783 after: Option<i64>,
1784 limit: usize,
1785 ) -> BoxFuture<'a, SearchResult<Vec<SearchDocument>>> {
1786 Box::pin(async move {
1787 require_postgres_runtime()?;
1788 checked(definition)?;
1789 self.load_documents(definition, Selection::After { after, limit })
1790 .await
1791 })
1792 }
1793}
1794
1795/// Which source rows to project.
1796enum Selection<'a> {
1797 /// Exactly these ids (a reindex).
1798 Ids(&'a [i64]),
1799 /// The next `limit` rows after `after`, by ascending id (a backfill).
1800 After { after: Option<i64>, limit: usize },
1801}
1802
1803impl PostgresSearchStore {
1804 /// The optional framework columns `definition`'s source table carries,
1805 /// cached per index.
1806 ///
1807 /// Resolved through `to_regclass`, which follows the connection's
1808 /// `search_path` exactly as the projection below does. An
1809 /// `information_schema` probe filtered on `current_schema()` would answer
1810 /// about a different table whenever the model lives in a non-first schema
1811 /// — silently blanking `tenant_id` and re-indexing soft-deleted rows.
1812 async fn optional_columns(
1813 &self,
1814 definition: &IndexDefinition,
1815 conn: &mut diesel_async::pooled_connection::deadpool::Object<autumn_web::RuntimeConnection>,
1816 ) -> SearchResult<OptionalColumns> {
1817 if let Ok(cache) = self.optional_columns.read()
1818 && let Some(columns) = cache.get(definition.name)
1819 {
1820 return Ok(*columns);
1821 }
1822
1823 let row = diesel::sql_query(
1824 "SELECT bool_or(attname = 'tenant_id') AS has_tenant, \
1825 bool_or(attname = 'deleted_at') AS has_deleted_at \
1826 FROM pg_attribute \
1827 WHERE attrelid = to_regclass($1) AND attnum > 0 AND NOT attisdropped",
1828 )
1829 .bind::<Text, _>(definition.name.to_owned())
1830 .get_result::<OptionalColumnsRow>(conn)
1831 .await
1832 .map_err(SearchError::backend)?;
1833
1834 let columns = OptionalColumns {
1835 tenant_id: row.has_tenant.unwrap_or(false),
1836 deleted_at: row.has_deleted_at.unwrap_or(false),
1837 };
1838 if let Ok(mut cache) = self.optional_columns.write() {
1839 cache.insert(definition.name, columns);
1840 }
1841 Ok(columns)
1842 }
1843
1844 /// Project a model's table into `(id, fields json, tenant_id)`.
1845 ///
1846 /// Fully generic: the column list comes from the validated
1847 /// [`IndexDefinition`], so no per-model code is generated anywhere. The
1848 /// values are returned as one JSON object rather than N columns, which is
1849 /// what lets a single `QueryableByName` struct serve every model.
1850 ///
1851 /// The key column comes from [`IndexDefinition::key_column`], which
1852 /// `#[model]` fills in from the `#[id]` field — so a model keyed on
1853 /// `article_id` backfills as readily as one keyed on `id`. `validate()`
1854 /// has already checked it is a bare identifier; it is quoted here anyway,
1855 /// so a column named after a reserved word still works.
1856 async fn load_documents(
1857 &self,
1858 definition: &IndexDefinition,
1859 selection: Selection<'_>,
1860 ) -> SearchResult<Vec<SearchDocument>> {
1861 // ONE connection for both statements: acquiring a second while holding
1862 // the first deadlocks a small pool.
1863 let mut conn = self.conn().await?;
1864 let columns = self.optional_columns(definition, &mut conn).await?;
1865 let key = format!("\"{}\"", definition.key_column);
1866
1867 let mut binds: Vec<Bound> = Vec::new();
1868 let mut param = 1_usize;
1869 let mut predicates: Vec<String> = Vec::new();
1870 let mut limit_clause = String::new();
1871
1872 match selection {
1873 Selection::Ids(ids) => {
1874 predicates.push(format!("{key} = ANY(${param})"));
1875 binds.push(Bound::Ids(ids.to_vec()));
1876 }
1877 Selection::After { after, limit } => {
1878 if let Some(after) = after {
1879 predicates.push(format!("{key} > ${param}"));
1880 binds.push(Bound::BigInt(after));
1881 param = param.saturating_add(1);
1882 }
1883 limit_clause = format!(" LIMIT ${param}");
1884 binds.push(Bound::BigInt(i64::try_from(limit).unwrap_or(i64::MAX)));
1885 }
1886 }
1887 // Both conditions: the column has to exist, and the model's repository has to
1888 // actually be `soft_delete`.
1889 //
1890 // Column presence alone is not the question. A `deleted_at` that is audit
1891 // history — a supported shape, whose finders return those rows — would
1892 // otherwise be read as a tombstone here: reindex would see the row as absent
1893 // and delete its document, and a purging backfill would drop it entirely. The
1894 // index would hide records the app still shows.
1895 //
1896 // When the repository is `soft_delete`, the filter is required for the
1897 // mirror-image reason: `after_delete_commit` removes the document, and a later
1898 // backfill would put it straight back — searchable while `find` hides it.
1899 if columns.deleted_at && definition.soft_delete {
1900 predicates.push("deleted_at IS NULL".to_owned());
1901 }
1902 let where_clause = if predicates.is_empty() {
1903 String::new()
1904 } else {
1905 format!("WHERE {}", predicates.join(" AND "))
1906 };
1907
1908 let tenant = if columns.tenant_id {
1909 "tenant_id::text"
1910 } else {
1911 "NULL::text"
1912 };
1913
1914 // `::bigint`: the row is decoded as `BigInt`, so an `integer`/`serial`
1915 // key column would be a runtime deserialization error the compiler
1916 // cannot see. Aliased to `id` because that is what `SourceRow` names.
1917 let rows = bind_all(
1918 diesel::sql_query(format!(
1919 "SELECT {key}::bigint AS id, {} AS fields, {tenant} AS tenant_id \
1920 FROM \"{}\" {where_clause} ORDER BY {key} ASC{limit_clause}",
1921 fields_projection(definition),
1922 definition.name,
1923 ))
1924 .into_boxed::<autumn_web::RuntimeBackend>(),
1925 binds,
1926 )
1927 .load::<SourceRow>(&mut conn)
1928 .await
1929 .map_err(SearchError::backend)?;
1930
1931 let mut documents = Vec::with_capacity(rows.len());
1932 for row in rows {
1933 let values: std::collections::BTreeMap<String, String> =
1934 serde_json::from_str(&row.fields).map_err(SearchError::backend)?;
1935 let mut document = SearchDocument::new(definition.name, row.id);
1936 for field in definition.fields.iter() {
1937 let value = values.get(field.name).cloned().unwrap_or_default();
1938 document = document.with_field(field.name, field.weight, value);
1939 }
1940 if let Some(embed) = definition.embed_field
1941 && let Some(text) = values.get(embed)
1942 && !text.is_empty()
1943 {
1944 document.embed_text = Some(text.clone());
1945 }
1946 document.tenant_id = row.tenant_id;
1947 documents.push(document);
1948 }
1949 Ok(documents)
1950 }
1951}
1952
1953/// The `jsonb_build_object(...)` projection for `definition`'s fields.
1954///
1955/// Chunked and concatenated with `||`: `jsonb_build_object` is variadic-"any"
1956/// and Postgres caps a function call at **100 arguments**, so a model with
1957/// more than 50 searchable fields would otherwise be a hard
1958/// `cannot pass more than 100 arguments to a function` error.
1959fn fields_projection(definition: &IndexDefinition) -> String {
1960 /// 40 pairs = 80 arguments, comfortably under the cap.
1961 const PAIRS_PER_CHUNK: usize = 40;
1962
1963 let pairs: Vec<String> = definition
1964 .fields
1965 .iter()
1966 .map(|field| format!("'{0}', COALESCE(\"{0}\"::text, '')", field.name))
1967 .collect();
1968 if pairs.is_empty() {
1969 return "'{}'::jsonb::text".to_owned();
1970 }
1971 let merged = pairs
1972 .chunks(PAIRS_PER_CHUNK)
1973 .map(|chunk| format!("jsonb_build_object({})", chunk.join(", ")))
1974 .collect::<Vec<_>>()
1975 .join(" || ");
1976 // Parenthesized before the cast: `::` binds TIGHTER than `||`, so
1977 // `a || b::text` casts only the final chunk and then asks Postgres for
1978 // `jsonb || text` — which is not a jsonb merge. The result is either an
1979 // operator error or two objects concatenated into invalid JSON that
1980 // `load_documents` then fails to deserialize. Only reachable past the
1981 // chunk boundary, which is exactly the case the chunking exists to serve.
1982 format!("({merged})::text")
1983}
1984
1985/// The `fields` JSON one document is stored with.
1986fn fields_json(document: &IndexedDocument) -> SearchResult<String> {
1987 serde_json::to_string(
1988 &document
1989 .document
1990 .fields
1991 .iter()
1992 .map(|f| (f.name, f.value.clone()))
1993 .collect::<std::collections::BTreeMap<_, _>>(),
1994 )
1995 .map_err(SearchError::backend)
1996}
1997
1998/// DDL for the framework-owned index, applied idempotently on every boot.
1999///
2000/// Not a `diesel_migrations` set: the table is plugin-owned infrastructure,
2001/// not application schema, and every statement is `IF NOT EXISTS`, so running
2002/// it on each boot is both safe and self-repairing.
2003const SCHEMA_STATEMENTS: &[&str] = &[
2004 // Durable evidence that a record was deleted, and WHEN.
2005 //
2006 // A hard delete leaves no trace, so a backfill batch scanned before the
2007 // delete would insert the record straight back — the `ON CONFLICT` guard
2008 // cannot help, because with the row gone there is no conflict to guard.
2009 // The ledger is what a conditional insert consults instead. Rows are
2010 // pruned once they are older than any backfill could plausibly be.
2011 "CREATE TABLE IF NOT EXISTS autumn_search_deletes (
2012 index_name TEXT NOT NULL,
2013 record_id BIGINT NOT NULL,
2014 deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2015 PRIMARY KEY (index_name, record_id)
2016 )",
2017 "CREATE INDEX IF NOT EXISTS autumn_search_deletes_pruning_idx
2018 ON autumn_search_deletes (deleted_at)",
2019 "CREATE TABLE IF NOT EXISTS autumn_search_documents (
2020 index_name TEXT NOT NULL,
2021 record_id BIGINT NOT NULL,
2022 tenant_id TEXT,
2023 language TEXT NOT NULL DEFAULT 'simple',
2024 fields JSONB NOT NULL DEFAULT '{}'::jsonb,
2025 content TEXT NOT NULL DEFAULT '',
2026 search_vector TSVECTOR NOT NULL DEFAULT to_tsvector('simple', ''),
2027 embedding DOUBLE PRECISION[],
2028 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2029 PRIMARY KEY (index_name, record_id)
2030 )",
2031 "CREATE INDEX IF NOT EXISTS autumn_search_documents_vector_idx
2032 ON autumn_search_documents USING GIN (search_vector)",
2033 "CREATE INDEX IF NOT EXISTS autumn_search_documents_tenant_idx
2034 ON autumn_search_documents (index_name, tenant_id)",
2035 // Portable cosine similarity for the non-pgvector path. `unnest(a, b)`
2036 // walks both arrays in lockstep; a zero-norm vector yields 0 rather than a
2037 // division by zero.
2038 "CREATE OR REPLACE FUNCTION autumn_search_cosine(a DOUBLE PRECISION[], b DOUBLE PRECISION[])
2039 RETURNS DOUBLE PRECISION LANGUAGE sql IMMUTABLE AS $$
2040 SELECT COALESCE(
2041 SUM(t.x * t.y) / NULLIF(SQRT(SUM(t.x * t.x)) * SQRT(SUM(t.y * t.y)), 0),
2042 0
2043 )
2044 FROM unnest(a, b) AS t(x, y)
2045 $$",
2046];
2047
2048#[cfg(test)]
2049mod tests {
2050 use autumn_web::search::SearchIndexField;
2051
2052 use super::*;
2053
2054 const FIELDS: &[SearchIndexField] = &[
2055 SearchIndexField::new("title", 'A'),
2056 SearchIndexField::new("body", 'B'),
2057 ];
2058
2059 // An app can use the store without the plugin. On a SQLite build, every
2060 // query is refused before it reaches the pool (#2539 §5).
2061 #[test]
2062 fn the_store_needs_the_postgres_backend() {
2063 let result = require_postgres_runtime();
2064 autumn_web::backend_select! {
2065 pg => {
2066 assert!(result.is_ok(), "{result:?}");
2067 },
2068 sqlite => {
2069 let message = result.expect_err("refused on SQLite").to_string();
2070 assert!(message.contains("SQLite"), "{message}");
2071 },
2072 }
2073 }
2074
2075 fn definition() -> IndexDefinition {
2076 IndexDefinition::new("articles", "english", FIELDS, Some("body"), false)
2077 }
2078
2079 // A short-circuit (an empty id list here) must not hide the backend
2080 // mismatch on SQLite (#2539 review).
2081 #[tokio::test]
2082 async fn an_early_return_does_not_skip_the_backend_check() {
2083 let store = PostgresSearchStore::new(None);
2084 let result = store.delete(&definition(), &[]).await;
2085 autumn_web::backend_select! {
2086 pg => {
2087 assert!(result.is_ok(), "{result:?}");
2088 },
2089 sqlite => {
2090 let message = result.expect_err("refused on SQLite").to_string();
2091 assert!(message.contains("SQLite"), "{message}");
2092 },
2093 }
2094 }
2095
2096 fn doc(id: i64) -> IndexedDocument {
2097 IndexedDocument::new(autumn_web::search::SearchDocument::new("articles", id))
2098 }
2099
2100 #[test]
2101 fn unconditional_dedup_keeps_the_last_occurrence() {
2102 // The unconditional (`index`) form ran N sequential UPSERTs with no
2103 // guard, so a later occurrence's write is what actually persists —
2104 // keep the LAST, to match.
2105 let documents = vec![doc(1), doc(2), doc(1), doc(3), doc(2)];
2106 let kept: Vec<i64> = dedupe_by_id(&documents, false)
2107 .into_iter()
2108 .map(IndexedDocument::id)
2109 .collect();
2110 // documents[2] (id=1) and documents[4] (id=2) are the LAST
2111 // occurrences of their ids; documents[3] (id=3) is unique.
2112 // Ascending original-index order: 1, 3, 2.
2113 assert_eq!(kept, vec![1, 3, 2], "{kept:?}");
2114 }
2115
2116 #[test]
2117 fn watermark_guarded_dedup_keeps_the_first_occurrence() {
2118 // The old per-document loop's FIRST occurrence sets
2119 // `updated_at = NOW()`, which is newer than `watermark` — so every
2120 // LATER occurrence's own `WHERE updated_at <= watermark` guard fails
2121 // against that just-written row and silently no-ops. Keep the
2122 // FIRST, to match.
2123 let documents = vec![doc(1), doc(2), doc(1), doc(3), doc(2)];
2124 let kept: Vec<i64> = dedupe_by_id(&documents, true)
2125 .into_iter()
2126 .map(IndexedDocument::id)
2127 .collect();
2128 // documents[0] (id=1) and documents[1] (id=2) are the FIRST
2129 // occurrences of their ids; documents[3] (id=3) is unique.
2130 // Ascending original-index order: 1, 2, 3.
2131 assert_eq!(kept, vec![1, 2, 3], "{kept:?}");
2132 }
2133
2134 #[test]
2135 fn no_duplicates_keeps_every_index_in_order_either_direction() {
2136 let documents = vec![doc(10), doc(20), doc(30)];
2137 for keep_first in [true, false] {
2138 let kept: Vec<i64> = dedupe_by_id(&documents, keep_first)
2139 .into_iter()
2140 .map(IndexedDocument::id)
2141 .collect();
2142 assert_eq!(kept, vec![10, 20, 30], "keep_first={keep_first}");
2143 }
2144 }
2145
2146 #[test]
2147 fn deduplicated_documents_sort_by_record_id_regardless_of_input_order() {
2148 // The mechanism write_documents relies on to prevent a cross-batch
2149 // deadlock: two callers indexing the same ids in different orders
2150 // (e.g. `[1, 2]` and `[2, 1]`) must both end up locking conflict
2151 // rows in the SAME order, or they can lock-and-wait on each other.
2152 // A canonical (ascending) order across every caller is what makes
2153 // that impossible — verified here for both dedup directions and
2154 // several input orderings.
2155 for keep_first in [true, false] {
2156 for documents in [
2157 vec![doc(2), doc(1), doc(3)],
2158 vec![doc(3), doc(1), doc(2)],
2159 vec![doc(1), doc(3), doc(2)],
2160 ] {
2161 let mut kept: Vec<&IndexedDocument> = dedupe_by_id(&documents, keep_first);
2162 kept.sort_unstable_by_key(|document| document.id());
2163 let ids: Vec<i64> = kept.into_iter().map(IndexedDocument::id).collect();
2164 assert_eq!(
2165 ids,
2166 vec![1, 2, 3],
2167 "keep_first={keep_first} input={documents:?}"
2168 );
2169 }
2170 }
2171 }
2172
2173 fn doc_with_embedding(id: i64, width: usize) -> IndexedDocument {
2174 doc(id).with_embedding(vec![0.0; width])
2175 }
2176
2177 const PGVECTOR_MODE: Option<VectorMode> = Some(VectorMode::PgVector { dimensions: 4 });
2178
2179 fn assert_dimension_mismatch(error: &SearchError, expected: usize, actual: usize) {
2180 assert!(
2181 matches!(
2182 error,
2183 SearchError::DimensionMismatch {
2184 expected: exp,
2185 actual: act
2186 } if *exp == expected && *act == actual
2187 ),
2188 "{error:?}"
2189 );
2190 }
2191
2192 #[test]
2193 fn embedding_widths_are_validated_before_unconditional_dedup_can_hide_a_bad_duplicate() {
2194 // #2311: the issue's exact scenario. Unconditional (`index`) dedup
2195 // keeps the LAST occurrence, so the malformed first entry never
2196 // reaches the row loop's width check — without pre-dedup validation
2197 // this batch succeeds and the malformed embedder output is masked.
2198 let documents = vec![
2199 doc_with_embedding(1, 3), // bad width, loses the coin flip
2200 doc_with_embedding(1, 4), // valid, survives dedup
2201 ];
2202 let error = validate_embedding_widths(&documents, Some(4), PGVECTOR_MODE)
2203 .expect_err("a wrong-width embedding must be rejected");
2204 assert_dimension_mismatch(&error, 4, 3);
2205 }
2206
2207 #[test]
2208 fn embedding_widths_are_validated_before_guarded_dedup_can_hide_a_bad_duplicate() {
2209 // Watermark-guarded (`index_unless_newer`) dedup keeps the FIRST
2210 // occurrence, so a malformed LATER duplicate is the one that would
2211 // be silently discarded.
2212 let documents = vec![
2213 doc_with_embedding(1, 4), // valid, survives dedup
2214 doc_with_embedding(1, 3), // bad width, loses the coin flip
2215 ];
2216 let error = validate_embedding_widths(&documents, Some(4), PGVECTOR_MODE)
2217 .expect_err("a wrong-width embedding must be rejected");
2218 assert_dimension_mismatch(&error, 4, 3);
2219 }
2220
2221 #[test]
2222 fn embedding_width_validation_accepts_a_clean_batch() {
2223 let documents = vec![
2224 doc_with_embedding(1, 4),
2225 doc_with_embedding(2, 4),
2226 doc(3), // no embedding at all is fine
2227 ];
2228 validate_embedding_widths(&documents, Some(4), PGVECTOR_MODE)
2229 .expect("a clean batch must validate");
2230 }
2231
2232 #[test]
2233 fn embedding_width_validation_only_rejects_in_pgvector_mode() {
2234 // The portable/`Array` mode does not reject a width mismatch: the
2235 // row loop repairs a stale `embedding_vec` copy from a previous
2236 // width by NULLing it, so pre-dedup validation must not change that
2237 // behavior.
2238 let documents = vec![doc_with_embedding(1, 3)];
2239 validate_embedding_widths(&documents, Some(4), Some(VectorMode::Array))
2240 .expect("non-pgvector mode must not reject a width mismatch");
2241 }
2242
2243 #[test]
2244 fn embedding_width_validation_skips_when_there_is_no_physical_column() {
2245 // `None` width means the `embedding_vec` column is absent and must
2246 // stay out of the write entirely — nothing to validate against.
2247 let documents = vec![doc_with_embedding(1, 3)];
2248 validate_embedding_widths(&documents, None, PGVECTOR_MODE)
2249 .expect("no physical column means no validation");
2250 }
2251
2252 #[test]
2253 fn greedy_chunking_never_lets_a_statement_approach_the_bind_limit_even_with_repeated_fields() {
2254 // Mirrors write_documents' own greedy accounting: `SearchDocument::fields`
2255 // is a public, uncapped `Vec` — a hand-built document can carry the
2256 // SAME declared field many times over, each contributing its own
2257 // bind. A per-document estimate based on `definition.fields.len()`
2258 // (one declared field, one possible value) would undercount this;
2259 // the real code counts each document's ACTUAL matching-field
2260 // entries instead. Reproduces the reported case: many documents
2261 // each carrying far more copies of one field than the index
2262 // declares.
2263 const PARAM_BUDGET: usize = 60_000;
2264 let weight_of = |name: &str| if name == "title" { Some('A') } else { None };
2265
2266 // 800 documents, each with the SAME declared field repeated 90
2267 // times: 5 fixed + 90 = 95 binds/document, 800 * 95 = 76,000 total —
2268 // over PARAM_BUDGET, so this MUST split into more than one chunk.
2269 let mut documents = Vec::new();
2270 for id in 0..800i64 {
2271 let mut document = autumn_web::search::SearchDocument::new("articles", id);
2272 for _ in 0..90 {
2273 document = document.with_field("title", 'A', "x");
2274 }
2275 documents.push(IndexedDocument::new(document));
2276 }
2277
2278 let mut chunks: Vec<usize> = Vec::new();
2279 let mut next_param = 3usize; // $1 index_name, $2 language (no watermark)
2280 let mut chunk_len = 0usize;
2281 for document in &documents {
2282 let doc_param_count = 5usize.saturating_add(
2283 document
2284 .document
2285 .fields
2286 .iter()
2287 .filter(|field| weight_of(field.name).is_some())
2288 .count(),
2289 );
2290 if chunk_len > 0 && next_param.saturating_add(doc_param_count) > PARAM_BUDGET {
2291 chunks.push(next_param);
2292 next_param = 3;
2293 chunk_len = 0;
2294 }
2295 next_param = next_param.saturating_add(doc_param_count);
2296 chunk_len = chunk_len.saturating_add(1);
2297 }
2298 chunks.push(next_param);
2299
2300 assert!(
2301 chunks.len() > 1,
2302 "the fixture must force more than one chunk: {chunks:?}"
2303 );
2304 for &total in &chunks {
2305 assert!(
2306 total < 65_535,
2307 "a chunk's total bind count {total} must stay under Postgres' bind limit: \
2308 {chunks:?}"
2309 );
2310 }
2311 }
2312
2313 #[test]
2314 fn a_row_count_cap_bounds_chunk_size_even_when_the_param_budget_never_would() {
2315 // A document with NO weighted fields binds only 5 params — the
2316 // param budget alone would admit 60_000 / 5 = 12,000 rows into one
2317 // chunk. The watermark-guarded shape joins rows with `UNION ALL`,
2318 // and a chain that deep risks Postgres' `max_stack_depth` during
2319 // parsing even while comfortably under the bind limit — so a
2320 // SEPARATE row-count cap has to apply regardless of how cheap each
2321 // row's params are.
2322 const PARAM_BUDGET: usize = 60_000;
2323 const MAX_ROWS_PER_CHUNK: usize = 1_000;
2324 let doc_param_count = 5usize; // no weighted fields on any document
2325
2326 let mut chunks: Vec<usize> = Vec::new();
2327 let mut next_param = 3usize;
2328 let mut chunk_len = 0usize;
2329 for _ in 0..5_000 {
2330 if chunk_len > 0
2331 && (next_param.saturating_add(doc_param_count) > PARAM_BUDGET
2332 || chunk_len >= MAX_ROWS_PER_CHUNK)
2333 {
2334 chunks.push(chunk_len);
2335 next_param = 3;
2336 chunk_len = 0;
2337 }
2338 next_param = next_param.saturating_add(doc_param_count);
2339 chunk_len = chunk_len.saturating_add(1);
2340 }
2341 chunks.push(chunk_len);
2342
2343 assert!(
2344 chunks.len() > 1,
2345 "5,000 minimal-field documents must still split into more than one chunk: {chunks:?}"
2346 );
2347 for &len in &chunks {
2348 assert!(
2349 len <= MAX_ROWS_PER_CHUNK,
2350 "a chunk of {len} rows must never exceed MAX_ROWS_PER_CHUNK: {chunks:?}"
2351 );
2352 }
2353 }
2354
2355 #[test]
2356 fn a_filtered_vector_query_does_not_let_ivfflat_decide_what_is_visible() {
2357 // Unfiltered: order by DISTANCE so ivfflat can serve it. That form is
2358 // the whole reason the fast path is worth having.
2359 let open = pgvector_order_by(false);
2360 assert!(open.contains("<=>"), "{open}");
2361 assert!(open.contains("ASC, record_id ASC"), "{open}");
2362
2363 // Filtered: order by the DERIVED similarity, which the planner cannot
2364 // serve from ivfflat, so the scan is exact. ivfflat probes lists
2365 // before the WHERE clause runs, so a selective authorization filter
2366 // can otherwise return short or empty while authorized neighbours sit
2367 // in unprobed lists.
2368 let scoped = pgvector_order_by(true);
2369 assert!(
2370 scoped.starts_with("ORDER BY (1 - "),
2371 "a filtered query must not be served by the approximate index: {scoped}"
2372 );
2373 assert_ne!(open, scoped);
2374 }
2375
2376 #[test]
2377 fn the_pgvector_predicate_excludes_nan_scores_before_limit() {
2378 // Zero-norm stored vectors make pgvector's `<=>` return NaN, which
2379 // sorts FIRST under `DESC` — on a filtered query they would consume
2380 // `LIMIT` slots ahead of valid neighbours, and the Rust-side
2381 // `is_finite` filter runs too late to give those slots back (#2313).
2382 // NaN is not equal to itself, so the predicate's self-equality test
2383 // on the distance drops exactly the NaN rows, before ordering and
2384 // limiting.
2385 let predicate = pgvector_embedding_predicate();
2386 assert!(
2387 predicate.contains("embedding_vec IS NOT NULL"),
2388 "{predicate}"
2389 );
2390 assert!(
2391 predicate.contains("(embedding_vec <=> $2::vector) = (embedding_vec <=> $2::vector)"),
2392 "NaN must be excluded by self-inequality: {predicate}"
2393 );
2394 }
2395
2396 #[test]
2397 fn an_unconditional_upsert_clears_the_tombstone_in_the_same_statement() {
2398 // Two statements let a delete interleave: the upsert commits, the
2399 // delete commits (document removed, tombstone written), and the
2400 // trailing clear then erases that NEWER tombstone while the document
2401 // stays absent — leaving a backfill able to see neither and resurrect
2402 // the record. One statement admits only the two consistent
2403 // serializations.
2404 let row = || UpsertRow {
2405 columns: "$1, $4, $5, $2, $6::jsonb, $7, to_tsvector('simple', ''), \
2406 $8::double precision[]"
2407 .to_owned(),
2408 record_id_param: "$4".to_owned(),
2409 };
2410 let sql = upsert_sql(&[row()], "", "", 9, None);
2411 assert!(
2412 sql.starts_with("WITH upserted AS ("),
2413 "the upsert and the tombstone clear must be one statement: {sql}"
2414 );
2415 assert!(
2416 sql.contains(&format!("DELETE FROM {DELETES_TABLE}")),
2417 "{sql}"
2418 );
2419 assert!(sql.contains("record_id = ANY($9)"), "{sql}");
2420
2421 // The tombstone clear must lock the ledger rows in ascending
2422 // `record_id` order — ORDER BY + FOR UPDATE is the only thing that
2423 // controls Postgres's lock acquisition order — matching `delete()`'s
2424 // `INSERT ... unnest($2)` order. A bare DELETE ... ANY($n) locks in
2425 // scan order and deadlocks against an oppositely-ordered delete
2426 // racing over the same tombstones (#2310).
2427 let lock_block = sql
2428 .find(&format!("DELETE FROM {DELETES_TABLE}"))
2429 .map(|at| &sql[..at])
2430 .expect("the tombstone-clear DELETE exists");
2431 assert!(lock_block.contains("ORDER BY record_id"), "{sql}");
2432 assert!(lock_block.contains("FOR UPDATE"), "{sql}");
2433
2434 // A watermarked (backfill) write leaves the ledger alone — its batch is
2435 // older than any tombstone by construction. Clearing it there would
2436 // undo the very delete the guard is protecting.
2437 let guarded = upsert_sql(&[row()], "", "", 9, Some(3));
2438 assert!(
2439 !guarded.contains(&format!("DELETE FROM {DELETES_TABLE}")),
2440 "a backfill write must never clear a tombstone: {guarded}"
2441 );
2442 assert!(
2443 guarded.contains(&format!("SELECT 1 FROM {DELETES_TABLE}")),
2444 "but it must still be blocked by one: {guarded}"
2445 );
2446
2447 // Two rows, guarded: joined by UNION ALL, each with its own
2448 // `record_id` in its own tombstone check, sharing the SAME watermark
2449 // slot rather than binding it twice.
2450 let second = UpsertRow {
2451 columns: "$1, $9, $10, $2, $11::jsonb, $12, to_tsvector('simple', ''), \
2452 $13::double precision[]"
2453 .to_owned(),
2454 record_id_param: "$9".to_owned(),
2455 };
2456 let multi = upsert_sql(&[row(), second], "", "", 99, Some(3));
2457 assert_eq!(multi.matches(" UNION ALL ").count(), 1, "{multi}");
2458 assert_eq!(multi.matches("$3::timestamptz").count(), 3, "{multi}");
2459 assert!(multi.contains("record_id = $4"), "{multi}");
2460 assert!(multi.contains("record_id = $9"), "{multi}");
2461 }
2462
2463 #[test]
2464 fn delete_binds_its_ledger_ids_in_ascending_order() {
2465 // `delete()`'s `INSERT ... unnest($2)` acquires tombstone rows in
2466 // array order, so the bound array must be ascending to match the
2467 // unconditional batch's `ORDER BY record_id FOR UPDATE` tombstone
2468 // clear — opposite orders on the two paths deadlock (#2310).
2469 assert_eq!(ascending_ids(&[3, 1, 2]), vec![1, 2, 3]);
2470 assert_eq!(ascending_ids(&[1, 2, 3]), vec![1, 2, 3]);
2471 assert_eq!(ascending_ids(&[5, 5, 1]), vec![1, 5, 5]);
2472 assert_eq!(ascending_ids(&[]), Vec::<i64>::new());
2473 }
2474
2475 #[test]
2476 fn a_min_score_is_bound_so_the_statement_cache_stays_bounded() {
2477 // The property that matters is not "the value is correct" but "the SQL
2478 // TEXT does not vary with the value" — diesel's statement cache is
2479 // keyed on that text and never evicts, so a request-controlled
2480 // threshold would otherwise leak one permanent prepared statement per
2481 // distinct value.
2482 let expr = "(1 - (embedding_vec <=> $2::vector))::double precision";
2483 let mut a = 5;
2484 let mut b = 5;
2485 let first = score_threshold_sql(expr, Some(0.25), &mut a);
2486 let second = score_threshold_sql(expr, Some(0.999_999), &mut b);
2487 assert_eq!(
2488 first, second,
2489 "two thresholds must produce ONE statement, not two"
2490 );
2491 assert_eq!(a, b, "and consume the same parameter slot");
2492 assert!(first.contains("$5"), "{first}");
2493 assert!(
2494 !first.contains("0.25"),
2495 "the value must not reach the SQL text: {first}"
2496 );
2497
2498 // No bound: no fragment, no parameter consumed.
2499 let mut param = 5;
2500 assert!(score_threshold_sql(expr, None, &mut param).is_empty());
2501 assert_eq!(param, 5);
2502
2503 // Non-finite: dropped rather than bound. `>= NaN` matches nothing.
2504 let mut param = 5;
2505 assert!(score_threshold_sql(expr, Some(f32::NAN), &mut param).is_empty());
2506 assert!(score_threshold_sql(expr, Some(f32::INFINITY), &mut param).is_empty());
2507 assert_eq!(param, 5);
2508
2509 // The fragment and its bind must agree on whether a slot was used —
2510 // a disagreement would shift every later parameter by one.
2511 for candidate in [None, Some(0.5), Some(f32::NAN), Some(f32::INFINITY)] {
2512 let mut param = 5;
2513 let emitted = !score_threshold_sql(expr, candidate, &mut param).is_empty();
2514 assert_eq!(
2515 emitted,
2516 score_threshold_bind(candidate).is_some(),
2517 "fragment/bind disagreement for {candidate:?}"
2518 );
2519 assert_eq!(param, if emitted { 6 } else { 5 });
2520 }
2521 }
2522
2523 #[test]
2524 fn ts_rank_weights_track_the_framework_factors() {
2525 // Postgres' own default is `{0.1, 0.2, 0.4, 1.0}`; the framework's
2526 // contract normalizes to `{0.1, 0.2, 0.5, 1.0}`. `B` is the one that
2527 // differs, which is precisely the weight `#[searchable(weight = "B")]`
2528 // gives a body field — so leaving it defaulted makes Postgres rank
2529 // differently from `MemorySearchBackend` on the most common model
2530 // shape there is.
2531 use autumn_web::search::weight_factor;
2532
2533 // Compared numerically, not as text: a string comparison would depend
2534 // on float formatting (`1.0` renders as `1`) and a fixed-precision
2535 // format would hide a genuine drift in a small ratio.
2536 let declared = parse_vector(
2537 TS_RANK_WEIGHTS
2538 .trim_end_matches("::float4[]")
2539 .trim_matches('\''),
2540 );
2541 let top = weight_factor('A');
2542 let expected: Vec<f32> = ['D', 'C', 'B', 'A']
2543 .into_iter()
2544 .map(|weight| weight_factor(weight) / top)
2545 .collect();
2546 assert_eq!(
2547 declared.len(),
2548 expected.len(),
2549 "ts_rank_cd takes exactly {{D, C, B, A}}: {TS_RANK_WEIGHTS}"
2550 );
2551 for (index, (got, want)) in declared.iter().zip(&expected).enumerate() {
2552 assert!(
2553 (got - want).abs() < 1e-6,
2554 "weight {} is {got}, expected {want} — the SQL array must stay in step with \
2555 `weight_factor`",
2556 ['D', 'C', 'B', 'A'][index]
2557 );
2558 }
2559 }
2560
2561 #[test]
2562 fn array_literals_round_trip() {
2563 let vector = vec![1.0_f32, -0.5, 0.25];
2564 assert_eq!(parse_vector(&array_literal(&vector)), vector);
2565 assert_eq!(parse_vector(&vector_literal(&vector)), vector);
2566 }
2567
2568 #[test]
2569 fn non_finite_values_never_produce_invalid_sql() {
2570 let literal = array_literal(&[f32::NAN, f32::INFINITY, 1.0]);
2571 assert_eq!(literal, "{0,0,1}");
2572 assert!(!literal.contains("NaN"));
2573 assert!(!literal.contains("inf"));
2574 }
2575
2576 #[test]
2577 fn parsing_tolerates_empty_and_malformed_elements() {
2578 assert!(parse_vector("{}").is_empty());
2579 assert!(parse_vector("[]").is_empty());
2580 assert_eq!(parse_vector("{1, ,2}"), vec![1.0, 2.0]);
2581 assert_eq!(parse_vector("{1,oops,2}"), vec![1.0, 2.0]);
2582 }
2583
2584 #[test]
2585 fn an_empty_filter_adds_no_predicate() {
2586 let mut param = 2;
2587 let mut binds = Vec::new();
2588 assert_eq!(
2589 filter_sql(
2590 &definition(),
2591 &SearchFilter::default(),
2592 &mut param,
2593 &mut binds
2594 ),
2595 ""
2596 );
2597 assert_eq!(param, 2);
2598 assert!(binds.is_empty());
2599 }
2600
2601 #[test]
2602 fn a_tenant_filter_binds_its_value_rather_than_interpolating_it() {
2603 let mut param = 2;
2604 let mut binds = Vec::new();
2605 let sql = filter_sql(
2606 &definition(),
2607 &SearchFilter::default().tenant("acme'; DROP TABLE users; --"),
2608 &mut param,
2609 &mut binds,
2610 );
2611 assert_eq!(sql, " AND tenant_id = $2");
2612 assert_eq!(
2613 binds,
2614 vec![Bound::Text("acme'; DROP TABLE users; --".to_owned())]
2615 );
2616 assert_eq!(param, 3);
2617 }
2618
2619 #[test]
2620 fn an_empty_allowlist_renders_a_fail_closed_predicate() {
2621 let mut param = 2;
2622 let mut binds = Vec::new();
2623 let sql = filter_sql(
2624 &definition(),
2625 &SearchFilter::default().allow_ids(Vec::<i64>::new()),
2626 &mut param,
2627 &mut binds,
2628 );
2629 assert!(sql.contains("AND FALSE"), "{sql}");
2630 }
2631
2632 #[test]
2633 fn id_lists_are_bound_as_arrays_never_interpolated() {
2634 let mut param = 2;
2635 let mut binds = Vec::new();
2636 let sql = filter_sql(
2637 &definition(),
2638 &SearchFilter::default()
2639 .allow_ids([1_i64, 2])
2640 .exclude_ids([3_i64]),
2641 &mut param,
2642 &mut binds,
2643 );
2644 // Bound arrays, not interpolated `IN (…)` lists: the literal form
2645 // would mint a distinct (never-evicted) prepared statement for every
2646 // distinct id set a caller happens to filter on.
2647 assert!(sql.contains("record_id = ANY($2)"), "{sql}");
2648 assert!(sql.contains("NOT (record_id = ANY($3))"), "{sql}");
2649 assert!(
2650 !sql.contains("IN ("),
2651 "ids must never be interpolated: {sql}"
2652 );
2653 assert_eq!(binds, vec![Bound::Ids(vec![1, 2]), Bound::Ids(vec![3])]);
2654 }
2655
2656 #[test]
2657 fn equals_predicates_bind_values_and_number_parameters_in_order() {
2658 let mut param = 4;
2659 let mut binds = Vec::new();
2660 let sql = filter_sql(
2661 &definition(),
2662 &SearchFilter::default()
2663 .equals("title", "Hello")
2664 .equals("body", "World"),
2665 &mut param,
2666 &mut binds,
2667 );
2668 // BTreeMap iterates in key order: body, then title.
2669 assert!(sql.contains("fields ->> 'body' = $4"), "{sql}");
2670 assert!(sql.contains("fields ->> 'title' = $5"), "{sql}");
2671 assert_eq!(
2672 binds,
2673 vec![
2674 Bound::Text("World".to_owned()),
2675 Bound::Text("Hello".to_owned())
2676 ]
2677 );
2678 assert_eq!(param, 6);
2679 }
2680
2681 #[test]
2682 fn an_undeclared_equals_key_never_reaches_sql() {
2683 // A field name is an IDENTIFIER position. Left unchecked, a
2684 // request-supplied facet key like `title' = 'x' OR '1'='1` would close
2685 // the quote and OR away the tenant and allowlist predicates that
2686 // precede it — widening past the visibility filter, not merely
2687 // injecting. It must fail closed instead.
2688 let mut param = 4;
2689 let mut binds = Vec::new();
2690 let sql = filter_sql(
2691 &definition(),
2692 &SearchFilter::default().equals("title' = 'x' OR '1'='1", "boom"),
2693 &mut param,
2694 &mut binds,
2695 );
2696
2697 assert_eq!(sql, " AND FALSE", "{sql}");
2698 assert!(!sql.contains("OR"), "{sql}");
2699 assert!(binds.is_empty(), "nothing may be bound for a dropped key");
2700 assert_eq!(param, 4, "the parameter counter must not advance");
2701 }
2702
2703 #[test]
2704 fn an_undeclared_key_does_not_disturb_the_numbering_of_declared_ones() {
2705 let mut param = 4;
2706 let mut binds = Vec::new();
2707 let sql = filter_sql(
2708 &definition(),
2709 &SearchFilter::default()
2710 .equals("aaa_not_a_field", "x")
2711 .equals("title", "Hello"),
2712 &mut param,
2713 &mut binds,
2714 );
2715
2716 // BTreeMap order puts the bogus key first; the real one must still
2717 // bind at $4.
2718 assert!(sql.contains("fields ->> 'title' = $4"), "{sql}");
2719 assert!(sql.contains("AND FALSE"), "{sql}");
2720 assert_eq!(binds, vec![Bound::Text("Hello".to_owned())]);
2721 assert_eq!(param, 5);
2722 }
2723
2724 #[test]
2725 fn the_reserved_tenant_key_is_still_honoured() {
2726 let mut param = 2;
2727 let mut binds = Vec::new();
2728 let sql = filter_sql(
2729 &definition(),
2730 &SearchFilter::default().equals(crate::backend::TENANT_FILTER_KEY, "acme"),
2731 &mut param,
2732 &mut binds,
2733 );
2734 assert_eq!(sql, " AND tenant_id = $2");
2735 assert_eq!(binds, vec![Bound::Text("acme".to_owned())]);
2736 }
2737
2738 #[test]
2739 fn the_fields_projection_casts_the_whole_merge_not_just_the_last_chunk() {
2740 // `::` binds tighter than `||`, so an unparenthesized
2741 // `a || b::text` would cast only `b` and stop being a jsonb merge.
2742 const PAIRS_PER_CHUNK: usize = 40;
2743
2744 // Under the chunk boundary: one call, still parenthesized.
2745 let single = fields_projection(&definition());
2746 assert!(single.starts_with("(jsonb_build_object("), "{single}");
2747 assert!(single.ends_with(")::text"), "{single}");
2748 assert_eq!(single.matches("jsonb_build_object(").count(), 1);
2749
2750 // Past it: several calls merged with `||`, and the cast must apply to
2751 // the whole expression.
2752 let fields: Vec<SearchIndexField> = (0..=(PAIRS_PER_CHUNK * 2))
2753 .map(|i| SearchIndexField::new(format!("field_{i}").leak(), 'D'))
2754 .collect();
2755 let mut wide = definition();
2756 wide.fields = std::borrow::Cow::Owned(fields);
2757 wide.embed_field = None;
2758
2759 let projection = fields_projection(&wide);
2760 assert_eq!(
2761 projection.matches("jsonb_build_object(").count(),
2762 3,
2763 "81 pairs at 40/chunk is 3 calls: {projection}"
2764 );
2765 assert!(projection.contains(" || "), "{projection}");
2766 assert!(projection.starts_with('('), "{projection}");
2767 assert!(projection.ends_with(")::text"), "{projection}");
2768 // The cast must not sit directly on a chunk.
2769 assert!(
2770 !projection.contains(")::text ||"),
2771 "the cast must wrap the merge, not a chunk: {projection}"
2772 );
2773 }
2774
2775 #[test]
2776 fn an_empty_field_list_still_yields_valid_json() {
2777 let mut empty = definition();
2778 empty.fields = std::borrow::Cow::Borrowed(&[]);
2779 assert_eq!(fields_projection(&empty), "'{}'::jsonb::text");
2780 }
2781
2782 #[test]
2783 fn an_invalid_definition_is_rejected_before_any_sql_is_built() {
2784 let mut bad = definition();
2785 bad.name = "articles\"; DROP TABLE users; --";
2786 assert!(matches!(checked(&bad), Err(SearchError::InvalidIndex(_))));
2787 assert!(checked(&definition()).is_ok());
2788 }
2789
2790 #[test]
2791 fn the_schema_is_idempotent_by_construction() {
2792 for statement in SCHEMA_STATEMENTS {
2793 let statement = statement.trim_start();
2794 assert!(
2795 statement.contains("IF NOT EXISTS") || statement.starts_with("CREATE OR REPLACE"),
2796 "not idempotent: {statement}"
2797 );
2798 }
2799 }
2800
2801 #[test]
2802 fn a_store_without_a_pool_reports_the_missing_configuration() {
2803 let store = PostgresSearchStore::new(None);
2804 let Err(error) = store.pool() else {
2805 panic!("a store with no installed pool must not report one");
2806 };
2807 // On SQLite, the backend refusal comes first (#2539 §5).
2808 let expected = autumn_web::backend_select! {
2809 pg => { "database.primary_url" },
2810 sqlite => { "SQLite" },
2811 };
2812 assert!(error.to_string().contains(expected), "{error}");
2813 assert!(store.vector_mode().is_none());
2814 }
2815
2816 #[test]
2817 fn vector_mode_reports_whether_pgvector_is_in_use() {
2818 assert!(VectorMode::PgVector { dimensions: 4 }.is_pgvector());
2819 assert!(!VectorMode::Array.is_pgvector());
2820 }
2821}