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::{Pool, Postgres};
6
7use super::schema::{ColumnDef, ResourceTypeSchema, SchemaRegistry};
8
9/// Creates the shared tables, then a per-resource-type table for every schema
10/// in `registry`. Idempotent — safe to re-run on an existing database.
11///
12/// New search parameters added by a later release show up as new columns on
13/// the existing tables via `ADD COLUMN IF NOT EXISTS`, so an upgrade never
14/// requires a reindex to *add* a parameter (existing rows keep NULL until the
15/// resource is next indexed).
16///
17/// # Errors
18///
19/// Returns an error if the migration lock cannot be taken, or if any of the
20/// DDL fails — a connection drop, or a table an earlier release left in a
21/// shape this one cannot reconcile.
22pub async fn run_migration(
23    pool: &Pool<Postgres>,
24    registry: &SchemaRegistry,
25) -> Result<(), OperationOutcomeError> {
26    // `CREATE TABLE IF NOT EXISTS` is not atomic against a concurrent create:
27    // two servers starting together both see the table missing, both create
28    // it, and one fails on `pg_type_typname_nsp_index`. Across ~145 tables
29    // that is close to certain. An advisory lock serializes the whole
30    // migration instead, so the second server waits and then finds everything
31    // already in place.
32    let mut lock = pool.acquire().await.map_err(|e| {
33        wrap(
34            "Failed to acquire a connection for the PG search migration",
35            &e,
36        )
37    })?;
38
39    sqlx::query("SELECT pg_advisory_lock($1)")
40        .bind(MIGRATION_LOCK_KEY)
41        .execute(&mut *lock)
42        .await
43        .map_err(|e| wrap("Failed to take the PG search migration lock", &e))?;
44
45    // The DDL itself runs on the pool rather than on `lock`. The mutual
46    // exclusion still holds: any other server blocks on `pg_advisory_lock`
47    // above until this one releases it below, whichever connections the work
48    // in between happens to use.
49    let result = run_migration_locked(pool, registry).await;
50
51    // Releasing is best-effort: the lock is session-scoped, so dropping the
52    // connection frees it anyway.
53    let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
54        .bind(MIGRATION_LOCK_KEY)
55        .execute(&mut *lock)
56        .await;
57
58    result
59}
60
61/// An arbitrary but stable key — any other advisory lock in this database has
62/// to avoid it.
63const MIGRATION_LOCK_KEY: i64 = 0x0F47_5EA4_C401;
64
65async fn run_migration_locked(
66    pool: &Pool<Postgres>,
67    registry: &SchemaRegistry,
68) -> Result<(), OperationOutcomeError> {
69    execute_ddl(
70        pool,
71        BASE_MIGRATION_SQL,
72        "Failed to run PG search base migration",
73    )
74    .await?;
75
76    // Sorted, not in registry order: `SchemaRegistry` is backed by a HashMap,
77    // so every process iterates it differently, and each table's DDL takes an
78    // ACCESS EXCLUSIVE lock. The advisory lock above already serializes this,
79    // but a single order costs nothing and keeps the DDL from deadlocking if
80    // it is ever run outside that lock.
81    let mut schemas: Vec<_> = registry.iter().collect();
82    schemas.sort_by(|a, b| a.table_name.cmp(&b.table_name));
83
84    for schema in schemas {
85        migrate_resource_type_table(pool, schema).await?;
86    }
87
88    tracing::info!(
89        "PG search index tables created/verified successfully ({} resource type tables).",
90        registry.len()
91    );
92    Ok(())
93}
94
95/// Runs one DDL script, tagging any failure with what it was doing.
96async fn execute_ddl(
97    pool: &Pool<Postgres>,
98    sql: &str,
99    context: &str,
100) -> Result<(), OperationOutcomeError> {
101    sqlx::raw_sql(sql)
102        .execute(pool)
103        .await
104        .map_err(|e| wrap(context, &e))?;
105    Ok(())
106}
107
108async fn migrate_resource_type_table(
109    pool: &Pool<Postgres>,
110    schema: &ResourceTypeSchema,
111) -> Result<(), OperationOutcomeError> {
112    let table = &schema.table_name;
113
114    execute_ddl(
115        pool,
116        &create_table_sql(schema),
117        &format!("Failed to create table '{table}'"),
118    )
119    .await?;
120
121    // A table created by an earlier release may be missing columns for search
122    // parameters added since; add them rather than recreating the table.
123    let add_columns = add_columns_sql(schema);
124    if !add_columns.is_empty() {
125        execute_ddl(
126            pool,
127            &add_columns,
128            &format!("Failed to add columns to '{table}'"),
129        )
130        .await?;
131    }
132
133    let indexes = create_indexes_sql(schema);
134    if !indexes.is_empty() {
135        execute_ddl(
136            pool,
137            &indexes,
138            &format!("Failed to create indexes on '{table}'"),
139        )
140        .await?;
141    }
142
143    Ok(())
144}
145
146/// The `CREATE TABLE IF NOT EXISTS` statement for one resource type.
147///
148/// `resource_type` is a constant on this table — it exists only so the
149/// foreign key can match `search_resource`'s composite primary key — so it
150/// defaults to the table's resource type and is pinned there by a CHECK. The
151/// table's own primary key is just `(tenant, project, resource_id)`.
152fn create_table_sql(schema: &ResourceTypeSchema) -> String {
153    let mut sql = format!(
154        "CREATE TABLE IF NOT EXISTS {table} (\n    \
155         tenant        TEXT NOT NULL,\n    \
156         project       TEXT NOT NULL,\n    \
157         resource_id   TEXT NOT NULL,\n    \
158         version_id    TEXT NOT NULL,\n    \
159         resource_type TEXT NOT NULL DEFAULT '{resource_type}'\n        \
160         CONSTRAINT {constraint} CHECK (resource_type = '{resource_type}')",
161        table = schema.table_name,
162        resource_type = schema.resource_type,
163        constraint = truncate_identifier(&format!("chk_{}_resource_type", schema.table_name)),
164    );
165
166    for column in &schema.columns {
167        let _ = write!(
168            sql,
169            ",\n    {} {}",
170            quote_ident(&column.name),
171            column.column_type.sql_type()
172        );
173    }
174
175    sql.push_str(",\n    PRIMARY KEY (tenant, project, resource_id)\n);");
176
177    sql
178}
179
180/// `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for every value column, so an
181/// existing table picks up parameters added after it was created.
182fn add_columns_sql(schema: &ResourceTypeSchema) -> String {
183    let mut sql = String::new();
184    for column in &schema.columns {
185        let _ = writeln!(
186            sql,
187            "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {};",
188            schema.table_name,
189            quote_ident(&column.name),
190            column.column_type.sql_type()
191        );
192    }
193    sql
194}
195
196/// GIN indexes for the selective value columns, plus the tenant/project
197/// lookup index used by every query's join.
198fn create_indexes_sql(schema: &ResourceTypeSchema) -> String {
199    let table = &schema.table_name;
200    let mut sql =
201        format!("CREATE INDEX IF NOT EXISTS idx_{table}_lookup ON {table} (tenant, project);\n");
202
203    for column in &schema.columns {
204        if !column.indexed {
205            continue;
206        }
207        sql.push_str(&index_sql(table, column));
208    }
209
210    sql
211}
212
213fn index_sql(table: &str, column: &ColumnDef) -> String {
214    // Index names are capped at 63 bytes by PostgreSQL and silently truncated
215    // past that, which would make two long parameter names collide.
216    let index_name = truncate_identifier(&format!("idx_{table}_{}", column.name));
217    format!(
218        "CREATE INDEX IF NOT EXISTS {index_name} ON {table} USING GIN ({});\n",
219        quote_ident(&column.name)
220    )
221}
222
223/// PostgreSQL's identifier limit (`NAMEDATALEN - 1`).
224const MAX_IDENTIFIER_LEN: usize = 63;
225
226fn truncate_identifier(name: &str) -> String {
227    if name.len() <= MAX_IDENTIFIER_LEN {
228        return name.to_string();
229    }
230
231    // Keep the prefix readable but append a hash of the full name so two
232    // truncated-to-identical names stay distinct.
233    let hash = name.bytes().fold(0u64, |acc, b| {
234        acc.wrapping_mul(31).wrapping_add(u64::from(b))
235    });
236    let suffix = format!("_{hash:x}");
237    let keep = MAX_IDENTIFIER_LEN - suffix.len();
238    format!("{}{suffix}", &name[..keep])
239}
240
241/// Double-quotes an identifier so a generated column name is never parsed as a
242/// keyword. Column names come from `code_to_column_base`, which already
243/// restricts them to `[a-z0-9_]`, so escaping embedded quotes is unnecessary —
244/// but the quoting keeps codes like `_source` unambiguous.
245fn quote_ident(name: &str) -> String {
246    format!("\"{name}\"")
247}
248
249fn wrap(context: &str, error: &sqlx::Error) -> OperationOutcomeError {
250    OperationOutcomeError::fatal(IssueType::exception(), format!("{context}: {error}"))
251}
252
253/// Shared tables: the resource anchor plus the EAV tables backing
254/// project-level (dynamic) search parameters.
255///
256/// The `search_dynamic_*` names replace the earlier `search_*` EAV tables,
257/// which held *all* parameters before system-level ones moved to dedicated
258/// per-resource-type columns. The rename is done first, so a database created
259/// by the earlier schema carries its rows forward instead of silently starting
260/// over with empty tables.
261static BASE_MIGRATION_SQL: &str = r"
262-- Migrate pre-hybrid EAV tables to their new names. `search_resource` is
263-- unchanged, so a renamed table keeps its foreign key intact.
264DO $$
265DECLARE
266    legacy TEXT;
267BEGIN
268    FOREACH legacy IN ARRAY ARRAY['string', 'token', 'date', 'number', 'uri', 'reference', 'quantity']
269    LOOP
270        IF to_regclass('search_' || legacy) IS NOT NULL
271           AND to_regclass('search_dynamic_' || legacy) IS NULL THEN
272            EXECUTE format('ALTER TABLE %I RENAME TO %I', 'search_' || legacy, 'search_dynamic_' || legacy);
273        END IF;
274    END LOOP;
275END $$;
276
277-- Core resource identity table (one row per indexed resource)
278CREATE TABLE IF NOT EXISTS search_resource (
279    tenant        TEXT NOT NULL,
280    project       TEXT NOT NULL,
281    resource_type TEXT NOT NULL,
282    resource_id   TEXT NOT NULL,
283    version_id    TEXT NOT NULL,
284    PRIMARY KEY (tenant, project, resource_type, resource_id)
285);
286
287CREATE INDEX IF NOT EXISTS idx_search_resource_lookup
288    ON search_resource (tenant, project, resource_type);
289
290-- Every search table used to carry an ON DELETE CASCADE foreign key to
291-- `search_resource`. With one table per resource type that grew to ~145
292-- constraints on a single parent, and Postgres fires a referential-integrity
293-- trigger for *every one of them* on *every* anchor row deleted — ~145,000
294-- trigger invocations to delete a 1000-resource batch, which measured at
295-- 800ms before any real work happened. Indexing now deletes the child rows
296-- explicitly and targets only the tables a batch actually touches, so the
297-- constraints are dropped here. This is a derived index rebuildable from the
298-- repository, and `indexing.rs` is the only writer.
299DO $$
300DECLARE
301    constraint_row record;
302BEGIN
303    FOR constraint_row IN
304        SELECT conrelid::regclass AS child_table, conname
305        FROM pg_constraint
306        WHERE confrelid = 'search_resource'::regclass AND contype = 'f'
307        -- Dropping a constraint takes an ACCESS EXCLUSIVE lock on its table.
308        -- Two servers migrating at once would take ~145 of those in whatever
309        -- order the catalog scan returned, and deadlock; a deterministic order
310        -- makes them queue instead.
311        ORDER BY conrelid::regclass::text, conname
312    LOOP
313        -- The other session may have dropped it in between: both took their
314        -- snapshot of the catalog before either started.
315        EXECUTE format(
316            'ALTER TABLE %s DROP CONSTRAINT IF EXISTS %I',
317            constraint_row.child_table,
318            constraint_row.conname
319        );
320    END LOOP;
321END $$;
322
323-- String values (name, address, etc.)
324CREATE TABLE IF NOT EXISTS search_dynamic_string (
325    tenant        TEXT NOT NULL,
326    project       TEXT NOT NULL,
327    resource_type TEXT NOT NULL,
328    resource_id   TEXT NOT NULL,
329    param_url     TEXT NOT NULL,
330    value         TEXT NOT NULL
331);
332
333CREATE INDEX IF NOT EXISTS idx_search_dynamic_string_prefix
334    ON search_dynamic_string (tenant, project, resource_type, param_url, value text_pattern_ops);
335
336-- Token values (code, system|code pairs)
337CREATE TABLE IF NOT EXISTS search_dynamic_token (
338    tenant        TEXT NOT NULL,
339    project       TEXT NOT NULL,
340    resource_type TEXT NOT NULL,
341    resource_id   TEXT NOT NULL,
342    param_url     TEXT NOT NULL,
343    system        TEXT,
344    code          TEXT
345);
346
347CREATE INDEX IF NOT EXISTS idx_search_dynamic_token_code
348    ON search_dynamic_token (tenant, project, resource_type, param_url, code);
349CREATE INDEX IF NOT EXISTS idx_search_dynamic_token_system_code
350    ON search_dynamic_token (tenant, project, resource_type, param_url, system, code);
351
352-- Date values (ranges stored as milliseconds-since-epoch)
353CREATE TABLE IF NOT EXISTS search_dynamic_date (
354    tenant        TEXT NOT NULL,
355    project       TEXT NOT NULL,
356    resource_type TEXT NOT NULL,
357    resource_id   TEXT NOT NULL,
358    param_url     TEXT NOT NULL,
359    start_ms      BIGINT NOT NULL,
360    end_ms        BIGINT NOT NULL
361);
362
363CREATE INDEX IF NOT EXISTS idx_search_dynamic_date_range
364    ON search_dynamic_date (tenant, project, resource_type, param_url, start_ms, end_ms);
365
366-- Number values
367CREATE TABLE IF NOT EXISTS search_dynamic_number (
368    tenant        TEXT NOT NULL,
369    project       TEXT NOT NULL,
370    resource_type TEXT NOT NULL,
371    resource_id   TEXT NOT NULL,
372    param_url     TEXT NOT NULL,
373    value         DOUBLE PRECISION NOT NULL
374);
375
376CREATE INDEX IF NOT EXISTS idx_search_dynamic_number_value
377    ON search_dynamic_number (tenant, project, resource_type, param_url, value);
378
379-- URI values
380CREATE TABLE IF NOT EXISTS search_dynamic_uri (
381    tenant        TEXT NOT NULL,
382    project       TEXT NOT NULL,
383    resource_type TEXT NOT NULL,
384    resource_id   TEXT NOT NULL,
385    param_url     TEXT NOT NULL,
386    value         TEXT NOT NULL
387);
388
389CREATE INDEX IF NOT EXISTS idx_search_dynamic_uri_value
390    ON search_dynamic_uri (tenant, project, resource_type, param_url, value);
391
392-- Reference values. Also backs reverse-reference lookups for system-level
393-- parameters, which is why every reference is written here in addition to the
394-- per-resource-type columns.
395CREATE TABLE IF NOT EXISTS search_dynamic_reference (
396    tenant               TEXT NOT NULL,
397    project              TEXT NOT NULL,
398    resource_type        TEXT NOT NULL,
399    resource_id          TEXT NOT NULL,
400    param_url            TEXT NOT NULL,
401    target_resource_type TEXT,
402    target_id            TEXT,
403    target_uri           TEXT
404);
405
406CREATE INDEX IF NOT EXISTS idx_search_dynamic_reference_target
407    ON search_dynamic_reference (tenant, project, resource_type, param_url, target_resource_type, target_id);
408CREATE INDEX IF NOT EXISTS idx_search_dynamic_reference_reverse
409    ON search_dynamic_reference (tenant, project, target_resource_type, target_id);
410
411-- Quantity values (ranges with unit info)
412CREATE TABLE IF NOT EXISTS search_dynamic_quantity (
413    tenant        TEXT NOT NULL,
414    project       TEXT NOT NULL,
415    resource_type TEXT NOT NULL,
416    resource_id   TEXT NOT NULL,
417    param_url     TEXT NOT NULL,
418    start_value   DOUBLE PRECISION NOT NULL,
419    start_system  TEXT,
420    start_code    TEXT,
421    end_value     DOUBLE PRECISION NOT NULL,
422    end_system    TEXT,
423    end_code      TEXT
424);
425
426CREATE INDEX IF NOT EXISTS idx_search_dynamic_quantity_value
427    ON search_dynamic_quantity (tenant, project, resource_type, param_url, start_value, end_value);
428
429-- Re-indexing a resource clears its old rows from every `search_dynamic_*`
430-- table by (tenant, project, resource_type, resource_id). The value indexes
431-- above all carry `param_url` in position 4, so none of them can serve that
432-- lookup — without these the delete degrades to a sequential scan of the whole
433-- table, on every single create and update.
434
435CREATE INDEX IF NOT EXISTS idx_search_dynamic_string_resource
436    ON search_dynamic_string (tenant, project, resource_type, resource_id);
437CREATE INDEX IF NOT EXISTS idx_search_dynamic_token_resource
438    ON search_dynamic_token (tenant, project, resource_type, resource_id);
439CREATE INDEX IF NOT EXISTS idx_search_dynamic_date_resource
440    ON search_dynamic_date (tenant, project, resource_type, resource_id);
441CREATE INDEX IF NOT EXISTS idx_search_dynamic_number_resource
442    ON search_dynamic_number (tenant, project, resource_type, resource_id);
443CREATE INDEX IF NOT EXISTS idx_search_dynamic_uri_resource
444    ON search_dynamic_uri (tenant, project, resource_type, resource_id);
445CREATE INDEX IF NOT EXISTS idx_search_dynamic_reference_resource
446    ON search_dynamic_reference (tenant, project, resource_type, resource_id);
447CREATE INDEX IF NOT EXISTS idx_search_dynamic_quantity_resource
448    ON search_dynamic_quantity (tenant, project, resource_type, resource_id);
449";