Skip to main content

micromegas_ingestion/
sql_migration.rs

1use crate::sql_telemetry_db::create_tables;
2use anyhow::{Context, Result};
3use micromegas_tracing::prelude::*;
4use sqlx::Executor;
5use sqlx::Row;
6
7/// The latest schema version for the data lake.
8pub const LATEST_DATA_LAKE_SCHEMA_VERSION: i32 = 11;
9
10/// Reads the current schema version from the database.
11pub async fn read_data_lake_schema_version(tr: &mut sqlx::Transaction<'_, sqlx::Postgres>) -> i32 {
12    match sqlx::query(
13        "SELECT version
14         FROM migration;",
15    )
16    .fetch_one(&mut **tr)
17    .await
18    {
19        Ok(row) => row.get("version"),
20        Err(e) => {
21            info!(
22                "Error reading data lake schema version, assuming version 0: {}",
23                e
24            );
25            0
26        }
27    }
28}
29
30/// Warns when the connected database is behind [`LATEST_DATA_LAKE_SCHEMA_VERSION`].
31///
32/// `execute_migration` only runs from `telemetry-ingestion-srv`/`micromegas-monolith`; a
33/// `flight-sql-srv`/`analytics-web-srv` process reads the same database without ever migrating
34/// it. On a schema that already has the `audience_grants` table (v7+) but predates the v9 seed
35/// rows, `AudienceReadPolicy` resolves every audience to an empty grant set rather than erroring,
36/// so queries silently return zero rows -- this is the only signal an operator gets otherwise.
37pub async fn warn_if_data_lake_schema_stale(pool: &sqlx::Pool<sqlx::Postgres>) {
38    let version = match pool.begin().await {
39        Ok(mut tr) => read_data_lake_schema_version(&mut tr).await,
40        Err(e) => {
41            warn!("could not check data lake schema version: {e}");
42            return;
43        }
44    };
45    if version < LATEST_DATA_LAKE_SCHEMA_VERSION {
46        warn!(
47            "data lake schema is v{version}, behind the latest v{LATEST_DATA_LAKE_SCHEMA_VERSION}; \
48             the seeded 'public' audience_grants rows have not migrated in yet, so every query \
49             will resolve to an empty read scope and return zero rows until the schema is upgraded; \
50             on a schema older than v10 the `groups`/`group_members` tables do not exist yet, so \
51             every request fails with a retryable 503 until the migration runs, because the group \
52             store cannot load"
53        );
54    }
55}
56
57/// Upgrades the data lake schema to version 2.
58pub async fn upgrade_data_lake_schema_v2(
59    tr: &mut sqlx::Transaction<'_, sqlx::Postgres>,
60) -> Result<()> {
61    tr.execute("ALTER TABLE blocks ADD insert_time TIMESTAMPTZ;")
62        .await
63        .with_context(|| "adding column insert_time to blocks table")?;
64    tr.execute("UPDATE blocks SET insert_time=end_time WHERE insert_time is NULL;")
65        .await
66        .with_context(|| "use end_time as insert_time to backfill missing data")?;
67    tr.execute("CREATE INDEX block_begin_time on blocks(begin_time);")
68        .await
69        .with_context(|| "adding index block_begin_time")?;
70    tr.execute("CREATE INDEX block_end_time on blocks(end_time);")
71        .await
72        .with_context(|| "adding index block_end_time")?;
73    tr.execute("CREATE INDEX block_insert_time on blocks(insert_time);")
74        .await
75        .with_context(|| "adding index block_insert_time")?;
76    tr.execute("CREATE INDEX process_insert_time on processes(insert_time);")
77        .await
78        .with_context(|| "adding index process_insert_time")?;
79    tr.execute("UPDATE migration SET version=2;")
80        .await
81        .with_context(|| "Updating data lake schema version to 2")?;
82    Ok(())
83}
84
85/// Upgrades the data lake schema to version 3.
86/// Drops old non-unique indexes (superseded by the unique indexes created before this transaction).
87pub async fn upgrade_data_lake_schema_v3(
88    tr: &mut sqlx::Transaction<'_, sqlx::Postgres>,
89) -> Result<()> {
90    tr.execute("DROP INDEX IF EXISTS process_id;")
91        .await
92        .with_context(|| "dropping old non-unique index process_id")?;
93    tr.execute("DROP INDEX IF EXISTS stream_id;")
94        .await
95        .with_context(|| "dropping old non-unique index stream_id")?;
96    tr.execute("DROP INDEX IF EXISTS block_id;")
97        .await
98        .with_context(|| "dropping old non-unique index block_id")?;
99    tr.execute("UPDATE migration SET version=3;")
100        .await
101        .with_context(|| "updating data lake schema version to 3")?;
102    Ok(())
103}
104
105/// Upgrades the data lake schema to version 4.
106/// Adds the `format` column to `streams` so OTLP and native blocks can be distinguished.
107pub async fn upgrade_data_lake_schema_v4(
108    tr: &mut sqlx::Transaction<'_, sqlx::Postgres>,
109) -> Result<()> {
110    tr.execute("ALTER TABLE streams ADD COLUMN format TEXT NOT NULL DEFAULT 'micromegas-transit';")
111        .await
112        .with_context(|| "adding column format to streams table")?;
113    tr.execute("UPDATE migration SET version=4;")
114        .await
115        .with_context(|| "updating data lake schema version to 4")?;
116    Ok(())
117}
118
119/// Upgrades the data lake schema to version 5.
120/// Adds `ingestion_api_keys` and `analytics_api_keys`, the DB-backed API key store:
121/// a `key_id` UUID primary key (a non-secret handle for `DELETE`/`GET`,
122/// distinct from the `key_hash` lookup value), a SHA-256 `key_hash` with a unique
123/// index for O(1) validation lookups, and a `created_at`/`created_by`/`last_used_at`/
124/// `revoked_at`/`revoked_by` audit trail. The two tables are identical in shape but
125/// deliberately separate (never a shared `scopes` column) so an ingestion (write)
126/// key can never double as an analytics (read) credential.
127pub async fn upgrade_data_lake_schema_v5(
128    tr: &mut sqlx::Transaction<'_, sqlx::Postgres>,
129) -> Result<()> {
130    tr.execute(
131        "CREATE TABLE ingestion_api_keys (
132           key_id       UUID PRIMARY KEY,
133           key_hash     BYTEA NOT NULL,
134           name         VARCHAR(255) NOT NULL,
135           created_at   TIMESTAMPTZ NOT NULL,
136           created_by   VARCHAR(255) NOT NULL,
137           last_used_at TIMESTAMPTZ,
138           revoked_at   TIMESTAMPTZ,
139           revoked_by   VARCHAR(255)
140         );",
141    )
142    .await
143    .with_context(|| "creating table ingestion_api_keys")?;
144    tr.execute("CREATE UNIQUE INDEX ingestion_api_keys_key_hash ON ingestion_api_keys(key_hash);")
145        .await
146        .with_context(|| "creating unique index ingestion_api_keys_key_hash")?;
147    tr.execute(
148        "CREATE TABLE analytics_api_keys (
149           key_id       UUID PRIMARY KEY,
150           key_hash     BYTEA NOT NULL,
151           name         VARCHAR(255) NOT NULL,
152           created_at   TIMESTAMPTZ NOT NULL,
153           created_by   VARCHAR(255) NOT NULL,
154           last_used_at TIMESTAMPTZ,
155           revoked_at   TIMESTAMPTZ,
156           revoked_by   VARCHAR(255)
157         );",
158    )
159    .await
160    .with_context(|| "creating table analytics_api_keys")?;
161    tr.execute("CREATE UNIQUE INDEX analytics_api_keys_key_hash ON analytics_api_keys(key_hash);")
162        .await
163        .with_context(|| "creating unique index analytics_api_keys_key_hash")?;
164    tr.execute("UPDATE migration SET version=5;")
165        .await
166        .with_context(|| "updating data lake schema version to 5")?;
167    Ok(())
168}
169
170/// Upgrades the data lake schema to version 6.
171/// Adds the `audience` column to `ingestion_api_keys`: the write audience a key is
172/// immutably bound to. Backfilled to `'public'` before `SET NOT NULL` -- every pre-existing
173/// row predates audience stamping, and `public` is the accurate description of its current,
174/// unstamped-and-visible-to-everyone state, not a new grant. No `DEFAULT` on the column:
175/// that would let a not-yet-upgraded `analytics-web-srv` keep inserting rows that silently
176/// take `public`, defeating the column's fail-closed property. `analytics_api_keys` is
177/// untouched -- its read-side mirror is `read_audiences`, a set-valued grant in the opposite
178/// direction.
179pub async fn upgrade_data_lake_schema_v6(
180    tr: &mut sqlx::Transaction<'_, sqlx::Postgres>,
181) -> Result<()> {
182    tr.execute("ALTER TABLE ingestion_api_keys ADD COLUMN audience VARCHAR(255);")
183        .await
184        .with_context(|| "adding column audience to ingestion_api_keys table")?;
185    tr.execute("UPDATE ingestion_api_keys SET audience = 'public' WHERE audience IS NULL;")
186        .await
187        .with_context(|| "backfilling audience to 'public' on ingestion_api_keys")?;
188    tr.execute("ALTER TABLE ingestion_api_keys ALTER COLUMN audience SET NOT NULL;")
189        .await
190        .with_context(|| "setting audience NOT NULL on ingestion_api_keys")?;
191    tr.execute(
192        "ALTER TABLE ingestion_api_keys ADD CONSTRAINT ingestion_api_keys_audience_name \
193         CHECK (audience ~ '^[A-Za-z0-9_-]+$');",
194    )
195    .await
196    .with_context(|| "adding audience-name CHECK constraint on ingestion_api_keys")?;
197    tr.execute("UPDATE migration SET version=6;")
198        .await
199        .with_context(|| "updating data lake schema version to 6")?;
200    Ok(())
201}
202
203/// Upgrades the data lake schema to version 7.
204/// Adds `audience_grants`: the DB-backed audience grant store, a 1:1
205/// stand-in for the long-term model's `group_read_grants`/`group_mint_grants` tables. One table
206/// with an `axis` column (`'read'`/`'mint'`) rather than two, mirroring today's single env-var
207/// grant map (`{prefix}_AUDIENCE_GRANTS`); `(audience, axis, selector)` is the row's own natural
208/// key, so there is no surrogate `grant_id` the way `ingestion_api_keys.key_id` has one (a grant
209/// carries no secret to keep unlinkable from its own identity). No `revoked_at`/`revoked_by`
210/// either -- grants are hard-`DELETE`d, since a removed grant has no ongoing artifact whose
211/// provenance a caller might later need. The two `CHECK` constraints mirror the same validation
212/// `AudienceGrants::from_rows` (`rust/auth/src/policy.rs`) re-runs independently in Rust, so a row
213/// inserted by any means other than the admin API (a manual `psql` fix, a future migration) still
214/// can't produce an unparseable or unreadable grant silently.
215pub async fn upgrade_data_lake_schema_v7(
216    tr: &mut sqlx::Transaction<'_, sqlx::Postgres>,
217) -> Result<()> {
218    tr.execute(
219        "CREATE TABLE audience_grants (
220           audience   VARCHAR(255) NOT NULL,
221           axis       VARCHAR(4) NOT NULL CHECK (axis IN ('read', 'mint')),
222           selector   VARCHAR(255) NOT NULL,
223           created_at TIMESTAMPTZ NOT NULL,
224           created_by VARCHAR(255) NOT NULL,
225           PRIMARY KEY (audience, axis, selector),
226           CONSTRAINT audience_grants_audience_name CHECK (audience ~ '^[A-Za-z0-9_-]+$'),
227           CONSTRAINT audience_grants_selector_shape
228               CHECK (selector = '*' OR selector ~ '^(user|group):.+$')
229         );",
230    )
231    .await
232    .with_context(|| "creating table audience_grants")?;
233    tr.execute("UPDATE migration SET version=7;")
234        .await
235        .with_context(|| "updating data lake schema version to 7")?;
236    Ok(())
237}
238
239/// Upgrades the data lake schema to version 8.
240///
241/// Adds a nullable `audience` column to `processes`, `streams`, and `blocks`: the write audience
242/// the row was stamped with at insert time. No `DEFAULT` and no backfill -- a NULL column means
243/// the row predates this stage and resolves to the deployment's `MICROMEGAS_DEFAULT_AUDIENCE` at
244/// read time, exactly as an unstamped row does today.
245///
246/// The `CHECK` constraints are added `NOT VALID` so `ALTER TABLE` does not scan and lock every
247/// existing row on `blocks`, the largest table in the lake; the constraint only applies to rows
248/// written from this point on (`WriteAudience::new` already validates the charset in Rust).
249///
250/// No index: nothing queries Postgres by audience, so one would be pure write cost.
251///
252/// `sql_telemetry_db.rs`'s `create_tables` (the v1 shape) is not touched -- a fresh database
253/// walks every upgrade in turn, so adding the column there too would double-apply it.
254pub async fn upgrade_data_lake_schema_v8(
255    tr: &mut sqlx::Transaction<'_, sqlx::Postgres>,
256) -> Result<()> {
257    tr.execute("ALTER TABLE processes ADD COLUMN audience VARCHAR(255);")
258        .await
259        .with_context(|| "adding column audience to processes table")?;
260    tr.execute("ALTER TABLE streams ADD COLUMN audience VARCHAR(255);")
261        .await
262        .with_context(|| "adding column audience to streams table")?;
263    tr.execute("ALTER TABLE blocks ADD COLUMN audience VARCHAR(255);")
264        .await
265        .with_context(|| "adding column audience to blocks table")?;
266    tr.execute(
267        "ALTER TABLE processes ADD CONSTRAINT processes_audience_name \
268         CHECK (audience ~ '^[A-Za-z0-9_-]+$') NOT VALID;",
269    )
270    .await
271    .with_context(|| "adding audience-name CHECK constraint on processes")?;
272    tr.execute(
273        "ALTER TABLE streams ADD CONSTRAINT streams_audience_name \
274         CHECK (audience ~ '^[A-Za-z0-9_-]+$') NOT VALID;",
275    )
276    .await
277    .with_context(|| "adding audience-name CHECK constraint on streams")?;
278    tr.execute(
279        "ALTER TABLE blocks ADD CONSTRAINT blocks_audience_name \
280         CHECK (audience ~ '^[A-Za-z0-9_-]+$') NOT VALID;",
281    )
282    .await
283    .with_context(|| "adding audience-name CHECK constraint on blocks")?;
284    tr.execute("UPDATE migration SET version=8;")
285        .await
286        .with_context(|| "updating data lake schema version to 8")?;
287    Ok(())
288}
289
290/// Upgrades the data lake schema to version 9.
291///
292/// Seeds `('public', 'read', '*')` and `('public', 'mint', '*')` into `audience_grants`. The
293/// read row replaces the built-in `PUBLIC_AUDIENCE` arm removed from `AudienceReadPolicy::resolve`;
294/// the mint row is what lets a non-admin mint a key bound to `public` without an operator having
295/// to discover and create the row by hand. `ON CONFLICT DO NOTHING` because an operator may
296/// already have created either row before upgrading -- this must not fail the migration.
297pub async fn upgrade_data_lake_schema_v9(
298    tr: &mut sqlx::Transaction<'_, sqlx::Postgres>,
299) -> Result<()> {
300    tr.execute(
301        "INSERT INTO audience_grants (audience, axis, selector, created_at, created_by)
302         VALUES ('public', 'read', '*', now(), 'default'),
303                ('public', 'mint', '*', now(), 'default')
304         ON CONFLICT DO NOTHING;",
305    )
306    .await
307    .with_context(|| "seeding public read/mint grants")?;
308    tr.execute("UPDATE migration SET version=9;")
309        .await
310        .with_context(|| "updating data lake schema version to 9")?;
311    Ok(())
312}
313
314/// `true` if `name` matches the `groups.name`/`group_members.group_name` charset:
315/// `[A-Za-z0-9_-]{1,255}`. A local copy rather than a dependency on `micromegas-auth` (which this
316/// crate does not otherwise depend on).
317fn is_valid_group_name(name: &str) -> bool {
318    !name.is_empty()
319        && name.len() <= 255
320        && name
321            .bytes()
322            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
323}
324
325/// Upgrades the data lake schema to version 10.
326///
327/// Adds `groups` and `group_members`, the local-group-membership store: a `group_members.member`
328/// row is a selector in exactly the vocabulary `audience_grants.selector` uses (`*`,
329/// `user:<email>`, `group:<name>`), so nesting is the `group:` arm of the same predicate rather
330/// than a special case. Seeds the reserved `admins` group with a single wildcard member
331/// (`('admins', '*')`) on every upgrade, fresh install or not; the operator is expected to add
332/// `user:<their email>` to `admins` and then remove the wildcard row afterward, via the Groups
333/// page or `micromegas-groups`. Also backfills an empty group for every distinct `group:X`
334/// selector already present in `audience_grants` so a legacy claim-derived grant is not left
335/// dangling at an unknown name.
336pub async fn upgrade_data_lake_schema_v10(
337    tr: &mut sqlx::Transaction<'_, sqlx::Postgres>,
338) -> Result<()> {
339    tr.execute(
340        "CREATE TABLE groups (
341           name        VARCHAR(255) PRIMARY KEY,
342           description TEXT,
343           created_at  TIMESTAMPTZ NOT NULL,
344           created_by  VARCHAR(255) NOT NULL,
345           CONSTRAINT groups_name CHECK (name ~ '^[A-Za-z0-9_-]+$')
346         );",
347    )
348    .await
349    .with_context(|| "creating table groups")?;
350    tr.execute(
351        "CREATE TABLE group_members (
352           group_name  VARCHAR(255) NOT NULL REFERENCES groups(name) ON DELETE CASCADE,
353           member      VARCHAR(255) NOT NULL,
354           created_at  TIMESTAMPTZ NOT NULL,
355           created_by  VARCHAR(255) NOT NULL,
356           PRIMARY KEY (group_name, member),
357           CONSTRAINT group_members_selector_shape CHECK (member = '*' OR member ~ '^(user|group):.+$')
358         );",
359    )
360    .await
361    .with_context(|| "creating table group_members")?;
362
363    tr.execute(
364        "INSERT INTO groups (name, description, created_at, created_by)
365         VALUES ('admins', 'Deployment administrators', now(), 'default')
366         ON CONFLICT (name) DO NOTHING;",
367    )
368    .await
369    .with_context(|| "seeding the admins group")?;
370
371    tr.execute(
372        "INSERT INTO group_members (group_name, member, created_at, created_by)
373         VALUES ('admins', '*', now(), 'default')
374         ON CONFLICT (group_name, member) DO NOTHING;",
375    )
376    .await
377    .with_context(|| "seeding the admins group with the wildcard member")?;
378
379    let rows =
380        sqlx::query("SELECT DISTINCT selector FROM audience_grants WHERE selector LIKE 'group:%';")
381            .fetch_all(&mut **tr)
382            .await
383            .with_context(|| "querying distinct group: selectors from audience_grants")?;
384    for row in rows {
385        let selector: String = row.try_get("selector").context("reading selector")?;
386        let name = selector
387            .strip_prefix("group:")
388            .expect("selector matched the LIKE 'group:%' filter");
389        if !is_valid_group_name(name) {
390            warn!(
391                "legacy audience_grants selector group:{name:?} does not match the group name \
392                 charset [A-Za-z0-9_-]{{1,255}}; left as an inert grant row, not backfilled into \
393                 a group"
394            );
395            continue;
396        }
397        sqlx::query(
398            "INSERT INTO groups (name, description, created_at, created_by)
399             VALUES ($1, NULL, now(), 'migration')
400             ON CONFLICT (name) DO NOTHING;",
401        )
402        .bind(name)
403        .execute(&mut **tr)
404        .await
405        .with_context(|| format!("backfilling empty group {name:?} from a legacy grant"))?;
406        info!("backfilled empty group {name:?} from a legacy audience_grants selector");
407    }
408
409    tr.execute("UPDATE migration SET version=10;")
410        .await
411        .with_context(|| "updating data lake schema version to 10")?;
412    Ok(())
413}
414
415/// Upgrades the data lake schema to version 11.
416/// Adds a nullable `allowed_cidrs TEXT[]` column to both `ingestion_api_keys` and
417/// `analytics_api_keys`: the CIDR ranges/bare IPs a key may be presented from. No backfill
418/// needed (unlike `audience`, which had to become `NOT NULL`): `NULL`/absent means "no
419/// restriction", which is exactly the correct value for every pre-existing row.
420pub async fn upgrade_data_lake_schema_v11(
421    tr: &mut sqlx::Transaction<'_, sqlx::Postgres>,
422) -> Result<()> {
423    tr.execute("ALTER TABLE ingestion_api_keys ADD COLUMN allowed_cidrs TEXT[];")
424        .await
425        .with_context(|| "adding column allowed_cidrs to ingestion_api_keys table")?;
426    tr.execute("ALTER TABLE analytics_api_keys ADD COLUMN allowed_cidrs TEXT[];")
427        .await
428        .with_context(|| "adding column allowed_cidrs to analytics_api_keys table")?;
429    tr.execute("UPDATE migration SET version=11;")
430        .await
431        .with_context(|| "updating data lake schema version to 11")?;
432    Ok(())
433}
434
435/// Checks whether a specific index is valid in `pg_index`.
436/// If the index is invalid, drops it and returns `Ok(false)`.
437/// If valid, returns `Ok(true)`.
438/// Returns an error if the index does not exist.
439async fn check_index_is_valid(pool: &sqlx::Pool<sqlx::Postgres>, index_name: &str) -> Result<bool> {
440    let row = sqlx::query(
441        "SELECT i.indisvalid
442         FROM pg_class c
443         JOIN pg_index i ON i.indexrelid = c.oid
444         WHERE c.relname = $1;",
445    )
446    .bind(index_name)
447    .fetch_optional(pool)
448    .await
449    .with_context(|| format!("querying pg_index for {index_name}"))?;
450
451    let row = row.with_context(|| format!("index {index_name} not found in pg_class"))?;
452    let is_valid: bool = row.get("indisvalid");
453
454    if !is_valid {
455        info!("index {index_name} is INVALID, dropping it");
456        sqlx::query(&format!("DROP INDEX IF EXISTS {index_name}"))
457            .execute(pool)
458            .await
459            .with_context(|| format!("dropping invalid index {index_name}"))?;
460        return Ok(false);
461    }
462
463    Ok(true)
464}
465
466/// Validates that all three unique indexes created during the v2→v3 migration are valid.
467/// Drops any invalid indexes and returns an error so the migration can be retried.
468async fn validate_unique_indexes(pool: &sqlx::Pool<sqlx::Postgres>) -> Result<()> {
469    let index_names = [
470        "processes_process_id_unique",
471        "streams_stream_id_unique",
472        "blocks_block_id_unique",
473    ];
474
475    let mut invalid_indexes = Vec::new();
476    for name in &index_names {
477        if !check_index_is_valid(pool, name).await? {
478            invalid_indexes.push(*name);
479        }
480    }
481
482    if !invalid_indexes.is_empty() {
483        anyhow::bail!(
484            "invalid indexes detected and dropped: {}. The migration will be retried on next startup.",
485            invalid_indexes.join(", ")
486        );
487    }
488
489    Ok(())
490}
491
492/// Executes the database migration.
493pub async fn execute_migration(pool: sqlx::Pool<sqlx::Postgres>) -> Result<()> {
494    let mut current_version = read_data_lake_schema_version(&mut pool.begin().await?).await;
495    if 0 == current_version {
496        info!("creating v1 data_lake_schema");
497        let mut tr = pool.begin().await?;
498        create_tables(&mut tr).await?;
499        current_version = read_data_lake_schema_version(&mut tr).await;
500        tr.commit().await?;
501    }
502    if 1 == current_version {
503        info!("upgrading data_lake_schema to v2");
504        let mut tr = pool.begin().await?;
505        upgrade_data_lake_schema_v2(&mut tr).await?;
506        current_version = read_data_lake_schema_version(&mut tr).await;
507        tr.commit().await?;
508    }
509    if 2 == current_version {
510        info!("upgrading data_lake_schema to v3");
511        // CREATE UNIQUE INDEX CONCURRENTLY cannot run inside a transaction.
512        // Run these outside any transaction, then do the rest in a transaction.
513        // IF NOT EXISTS makes this idempotent and safe for retries.
514        sqlx::query("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS processes_process_id_unique ON processes(process_id);")
515            .execute(&pool)
516            .await
517            .with_context(|| "creating unique index on processes(process_id)")?;
518        sqlx::query("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS streams_stream_id_unique ON streams(stream_id);")
519            .execute(&pool)
520            .await
521            .with_context(|| "creating unique index on streams(stream_id)")?;
522        sqlx::query("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS blocks_block_id_unique ON blocks(block_id);")
523            .execute(&pool)
524            .await
525            .with_context(|| "creating unique index on blocks(block_id)")?;
526
527        validate_unique_indexes(&pool).await?;
528
529        let mut tr = pool.begin().await?;
530        upgrade_data_lake_schema_v3(&mut tr).await?;
531        current_version = read_data_lake_schema_version(&mut tr).await;
532        tr.commit().await?;
533    }
534    if 3 == current_version {
535        info!("upgrading data_lake_schema to v4");
536        let mut tr = pool.begin().await?;
537        upgrade_data_lake_schema_v4(&mut tr).await?;
538        current_version = read_data_lake_schema_version(&mut tr).await;
539        tr.commit().await?;
540    }
541    if 4 == current_version {
542        info!("upgrading data_lake_schema to v5");
543        let mut tr = pool.begin().await?;
544        upgrade_data_lake_schema_v5(&mut tr).await?;
545        current_version = read_data_lake_schema_version(&mut tr).await;
546        tr.commit().await?;
547    }
548    if 5 == current_version {
549        info!("upgrading data_lake_schema to v6");
550        let mut tr = pool.begin().await?;
551        upgrade_data_lake_schema_v6(&mut tr).await?;
552        current_version = read_data_lake_schema_version(&mut tr).await;
553        tr.commit().await?;
554    }
555    if 6 == current_version {
556        info!("upgrading data_lake_schema to v7");
557        let mut tr = pool.begin().await?;
558        upgrade_data_lake_schema_v7(&mut tr).await?;
559        current_version = read_data_lake_schema_version(&mut tr).await;
560        tr.commit().await?;
561    }
562    if 7 == current_version {
563        info!("upgrading data_lake_schema to v8");
564        let mut tr = pool.begin().await?;
565        upgrade_data_lake_schema_v8(&mut tr).await?;
566        current_version = read_data_lake_schema_version(&mut tr).await;
567        tr.commit().await?;
568    }
569    if 8 == current_version {
570        info!("upgrading data_lake_schema to v9");
571        let mut tr = pool.begin().await?;
572        upgrade_data_lake_schema_v9(&mut tr).await?;
573        current_version = read_data_lake_schema_version(&mut tr).await;
574        tr.commit().await?;
575    }
576    if 9 == current_version {
577        info!("upgrading data_lake_schema to v10");
578        let mut tr = pool.begin().await?;
579        upgrade_data_lake_schema_v10(&mut tr).await?;
580        current_version = read_data_lake_schema_version(&mut tr).await;
581        tr.commit().await?;
582    }
583    if 10 == current_version {
584        info!("upgrading data_lake_schema to v11");
585        let mut tr = pool.begin().await?;
586        upgrade_data_lake_schema_v11(&mut tr).await?;
587        current_version = read_data_lake_schema_version(&mut tr).await;
588        tr.commit().await?;
589    }
590    assert_eq!(current_version, LATEST_DATA_LAKE_SCHEMA_VERSION);
591    Ok(())
592}