Skip to main content

haste_fhir_search/pg_search/
migration.rs

1use std::fmt::Write as _;
2
3use haste_fhir_model::r4::generated::terminology::IssueType;
4use haste_fhir_operation_error::OperationOutcomeError;
5use sqlx::{AssertSqlSafe, Pool, Postgres};
6
7use super::schema::{
8    ColumnDef, IndexKind, ResourceTypeSchema, SHARED_TABLES, SchemaRegistry, SharedTable,
9    resource_table_name, shared_table_name, shared_value_columns, sql_type, string_prefix_expr,
10};
11
12/// Creates the anchor, the shared tables and one table per resource type.
13/// Idempotent: new parameters are added with `ADD COLUMN IF NOT EXISTS`, and
14/// existing rows read NULL until reindexed.
15///
16/// # Errors
17///
18/// Returns an error if the migration lock cannot be taken or any DDL fails.
19pub async fn run_migration(
20    pool: &Pool<Postgres>,
21    registry: &SchemaRegistry,
22) -> Result<(), OperationOutcomeError> {
23    // Concurrent `CREATE TABLE IF NOT EXISTS` can fail on
24    // `pg_type_typname_nsp_index` when two servers start together, so the
25    // whole migration runs under an advisory lock.
26    let mut lock = pool.acquire().await.map_err(|e| {
27        wrap(
28            "Failed to acquire a connection for the PG search migration",
29            &e,
30        )
31    })?;
32
33    sqlx::query("SELECT pg_advisory_lock($1)")
34        .bind(MIGRATION_LOCK_KEY)
35        .execute(&mut *lock)
36        .await
37        .map_err(|e| wrap("Failed to take the PG search migration lock", &e))?;
38
39    // DDL runs on the pool; other servers are still blocked on the lock above.
40    let result = run_migration_locked(pool, registry).await;
41
42    // Best-effort: the lock is session-scoped and dies with the connection.
43    let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
44        .bind(MIGRATION_LOCK_KEY)
45        .execute(&mut *lock)
46        .await;
47
48    result
49}
50
51/// Arbitrary but stable. No other advisory lock in this database may use it.
52const MIGRATION_LOCK_KEY: i64 = 0x0F47_5EA4_C401;
53
54async fn run_migration_locked(
55    pool: &Pool<Postgres>,
56    registry: &SchemaRegistry,
57) -> Result<(), OperationOutcomeError> {
58    execute_ddl(
59        pool,
60        &base_migration_sql(registry),
61        "Failed to run PG search base migration",
62    )
63    .await?;
64
65    // A fixed order keeps the per-table ACCESS EXCLUSIVE locks deadlock-free
66    // even without the advisory lock.
67    let mut schemas: Vec<_> = registry.schemas.values().collect();
68    schemas.sort_by(|a, b| a.table_name.cmp(&b.table_name));
69
70    for schema in schemas {
71        execute_ddl(
72            pool,
73            &resource_type_table_sql(schema),
74            &format!("Failed to migrate PG search table '{}'", schema.table_name),
75        )
76        .await?;
77    }
78
79    tracing::info!(
80        "PG search index tables created/verified successfully ({} resource type tables).",
81        registry.schemas.len()
82    );
83    Ok(())
84}
85
86async fn execute_ddl(
87    pool: &Pool<Postgres>,
88    sql: &str,
89    context: &str,
90) -> Result<(), OperationOutcomeError> {
91    sqlx::raw_sql(AssertSqlSafe(sql))
92        .execute(pool)
93        .await
94        .map(|_| ())
95        .map_err(|e| wrap(context, &e))
96}
97
98/// The anchor and the shared tables: everything independent of which
99/// parameters exist.
100fn base_migration_sql(registry: &SchemaRegistry) -> String {
101    let resource_table = resource_table_name(&registry.version);
102
103    let create_anchor = format!(
104        "-- One row per indexed resource. Other tables reference it by `res_key`,
105-- which is never reused.
106--
107-- `sequence` is the repository write sequence of the indexed version; the
108-- upsert uses it to reject replays of older versions.
109CREATE TABLE IF NOT EXISTS {resource_table} (
110    res_key       BIGINT GENERATED ALWAYS AS IDENTITY UNIQUE,
111    tenant        TEXT NOT NULL,
112    project       TEXT NOT NULL,
113    resource_type TEXT NOT NULL,
114    resource_id   TEXT NOT NULL,
115    version_id    TEXT NOT NULL,
116    sequence      BIGINT NOT NULL,
117    PRIMARY KEY (tenant, project, resource_type, resource_id)
118);
119
120-- `:contains` is an unanchored LIKE: trigrams serve it, and `btree_gin` lets
121-- the scope columns lead that index.
122CREATE EXTENSION IF NOT EXISTS pg_trgm;
123CREATE EXTENSION IF NOT EXISTS btree_gin;
124"
125    );
126
127    // Resource-level parameters are anchor columns, so untyped searches can
128    // read them.
129    let anchor_columns = add_columns_sql(&resource_table, &registry.anchor.columns);
130    let anchor_indexes = indexes_sql(&resource_table, &registry.anchor.columns, ANCHOR_SCOPE);
131    let shared_tables: String = SHARED_TABLES
132        .into_iter()
133        .map(|table| shared_table_sql(registry, table))
134        .collect();
135
136    create_anchor + &anchor_columns + &anchor_indexes + &shared_tables
137}
138
139/// One shared table. Rows are keyed by `(res_key, param_identity)`, sixteen
140/// bytes instead of the text they hash, and value columns are scalar so the
141/// same B-trees work as on the type tables.
142fn shared_table_sql(registry: &SchemaRegistry, table: SharedTable) -> String {
143    let name = shared_table_name(&registry.version, table);
144
145    let value_columns = shared_value_columns(table)
146        .iter()
147        .map(|column| {
148            let null = if column.nullable { "" } else { " NOT NULL" };
149            format!("    {} {}{null}", column.name, column.sql_type)
150        })
151        .collect::<Vec<_>>()
152        .join(",\n");
153
154    let create_table = format!(
155        "CREATE TABLE IF NOT EXISTS {name} (
156    res_key        BIGINT NOT NULL,
157    param_identity BIGINT NOT NULL,
158{value_columns}
159);
160
161-- Correlates rows back to their resource, for clauses and for clearing.
162CREATE INDEX IF NOT EXISTS idx_{name}_resource
163    ON {name} (res_key, param_identity);
164"
165    );
166
167    // Lookup index: the identity (already one project), then the compared
168    // value. String searches compare the truncated, lowered value.
169    let is_string = matches!(table, SharedTable::String);
170    let value_key = match table {
171        SharedTable::String => format!("{} text_pattern_ops", string_prefix_expr("value")),
172        SharedTable::Uri | SharedTable::Number => "value".into(),
173        SharedTable::Token => "code".into(),
174        SharedTable::Date => "start_ms, end_ms".into(),
175        SharedTable::Quantity => "start_value, end_value".into(),
176        SharedTable::Reference => "target_id".into(),
177    };
178    // The string index and statistics are named for what they cover; they
179    // replaced ones over the whole value, which a long value could not enter,
180    // and a database migrated before that still has those to drop.
181    let value_index_name = if is_string { "prefix" } else { "value" };
182    let stats_name = if is_string {
183        "prefix_stats"
184    } else {
185        "identity_stats"
186    };
187    let legacy = if is_string {
188        format!(
189            "DROP INDEX IF EXISTS idx_{name}_value;
190DROP STATISTICS IF EXISTS {name}_identity_stats;
191"
192        )
193    } else {
194        String::new()
195    };
196    let value_index = format!(
197        "{legacy}CREATE INDEX IF NOT EXISTS idx_{name}_{value_index_name}
198    ON {name} (param_identity, {value_key});
199"
200    );
201
202    // All parameters share the table, so a value's overall frequency says
203    // little about its frequency under one parameter. Without these stats the
204    // planner multiplies selectivities, badly underestimates values common to
205    // one parameter (e.g. a LOINC code under `combo-code`), and picks the wrong
206    // driving index.
207    let stats_key = match table {
208        SharedTable::String => Some(format!("({})", string_prefix_expr("value"))),
209        SharedTable::Uri => Some("value".into()),
210        SharedTable::Token => Some("code".into()),
211        SharedTable::Reference => Some("target_id".into()),
212        SharedTable::Number | SharedTable::Date | SharedTable::Quantity => None,
213    };
214    let statistics = stats_key.map_or_else(String::new, |stats_key| {
215        format!(
216            "CREATE STATISTICS IF NOT EXISTS {name}_{stats_name} (mcv, ndistinct, dependencies)
217    ON param_identity, {stats_key} FROM {name};
218ALTER STATISTICS {name}_{stats_name} SET STATISTICS 1000;
219"
220        )
221    });
222
223    let contains_index = if matches!(table, SharedTable::String) {
224        format!(
225            "CREATE INDEX IF NOT EXISTS idx_{name}_contains
226    ON {name} USING GIN (param_identity, LOWER(value) gin_trgm_ops);
227"
228        )
229    } else {
230        String::new()
231    };
232
233    create_table + &value_index + &statistics + &contains_index
234}
235
236/// Creates or extends one resource type table. `scope` hashes tenant and
237/// project so indexes stay within one project without storing their text.
238fn resource_type_table_sql(schema: &ResourceTypeSchema) -> String {
239    let table = &schema.table_name;
240
241    format!(
242        "CREATE TABLE IF NOT EXISTS {table} (
243    res_key BIGINT PRIMARY KEY,
244    scope   BIGINT NOT NULL
245);
246"
247    ) + &add_columns_sql(table, &schema.columns)
248        + &indexes_sql(table, &schema.columns, TYPE_TABLE_SCOPE)
249}
250
251fn add_columns_sql(table: &str, columns: &[ColumnDef]) -> String {
252    columns.iter().fold(String::new(), |mut sql, column| {
253        let _ = writeln!(
254            sql,
255            "ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {} {};",
256            quote_ident(&column.name),
257            sql_type(column.column_type)
258        );
259        sql
260    })
261}
262
263fn indexes_sql(table: &str, columns: &[ColumnDef], scope: &[&str]) -> String {
264    columns
265        .iter()
266        .map(|column| index_sql(table, column, scope))
267        .collect()
268}
269
270/// Leads every type table index, so a query never scans other tenants' rows.
271const TYPE_TABLE_SCOPE: &[&str] = &["scope"];
272
273/// Leads every anchor index. Typed searches (nearly all) pin `resource_type`
274/// too; untyped ones still use the first two.
275const ANCHOR_SCOPE: &[&str] = &["tenant", "project", "resource_type"];
276
277/// The index for one column, led by the equality-constrained `scope` columns.
278fn index_sql(table: &str, column: &ColumnDef, scope: &[&str]) -> String {
279    let name = truncate_identifier(&format!("idx_{table}_{}", column.name));
280    let ident = quote_ident(&column.name);
281    let lead = scope.join(", ");
282
283    match column.index {
284        IndexKind::None => String::new(),
285        IndexKind::BTree => {
286            format!("CREATE INDEX IF NOT EXISTS {name} ON {table} ({lead}, {ident});\n")
287        }
288        IndexKind::BTreeDescending => format!(
289            "CREATE INDEX IF NOT EXISTS {name} ON {table} ({lead}, {ident} DESC NULLS LAST);\n"
290        ),
291        // B-tree for anchored `LIKE 'abc%'`, trigram GIN for `LIKE '%abc%'`.
292        // The B-tree replaced `{name}`, over the whole value, which a long
293        // value could not enter; a database migrated before that drops it.
294        IndexKind::LoweredPrefix => format!(
295            "DROP INDEX IF EXISTS {name};\n\
296             CREATE INDEX IF NOT EXISTS {name}_prefix \
297             ON {table} ({lead}, {} text_pattern_ops);\n\
298             CREATE INDEX IF NOT EXISTS {name}_ct \
299             ON {table} USING GIN ({lead}, LOWER({ident}) gin_trgm_ops);\n",
300            string_prefix_expr(&ident)
301        ),
302    }
303}
304
305/// Postgres truncates identifiers past `NAMEDATALEN - 1` silently.
306const MAX_IDENTIFIER_LEN: usize = 63;
307
308fn truncate_identifier(name: &str) -> String {
309    name.chars().take(MAX_IDENTIFIER_LEN).collect()
310}
311
312fn quote_ident(name: &str) -> String {
313    format!("\"{}\"", name.replace('"', "\"\""))
314}
315
316fn wrap(context: &str, error: &sqlx::Error) -> OperationOutcomeError {
317    OperationOutcomeError::fatal(IssueType::exception(), format!("{context}: {error}"))
318}
319
320/// A shared table's `CREATE TABLE`, so indexing tests can check their inserts
321/// against it. Its shape doesn't depend on the registered parameters.
322#[cfg(test)]
323pub(crate) fn shared_table_sql_for_test(
324    version: haste_repository::types::SupportedFHIRVersions,
325    table: SharedTable,
326) -> String {
327    shared_table_sql(&super::schema::generate_schemas(version, &[]), table)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::pg_search::schema::ColumnType;
334
335    fn column(name: &str, index: IndexKind) -> ColumnDef {
336        ColumnDef {
337            name: name.to_string(),
338            column_type: ColumnType::Text,
339            index,
340        }
341    }
342
343    /// An index not led by the scope scans every tenant's rows.
344    #[test]
345    fn every_index_leads_with_the_tenant_scope() {
346        for (scope, lead) in [
347            (TYPE_TABLE_SCOPE, "(scope, "),
348            (ANCHOR_SCOPE, "(tenant, project, resource_type, "),
349        ] {
350            for index in [
351                IndexKind::BTree,
352                IndexKind::BTreeDescending,
353                IndexKind::LoweredPrefix,
354            ] {
355                let sql = index_sql("t", &column("code", index), scope);
356                for statement in sql.lines().filter(|line| line.contains("CREATE INDEX")) {
357                    assert!(
358                        statement.contains(lead),
359                        "index does not lead with {lead}: {statement}"
360                    );
361                }
362            }
363        }
364    }
365}