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
9pub async fn run_migration(
23 pool: &Pool<Postgres>,
24 registry: &SchemaRegistry,
25) -> Result<(), OperationOutcomeError> {
26 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 let result = run_migration_locked(pool, registry).await;
50
51 let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
54 .bind(MIGRATION_LOCK_KEY)
55 .execute(&mut *lock)
56 .await;
57
58 result
59}
60
61const 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 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
95async 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 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
146fn 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
180fn 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
196fn 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 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
223const 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 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
241fn 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
253static 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";