Skip to main content

hotshot_query_service/
migration.rs

1use std::{collections::HashSet, env, time::Duration};
2
3use async_trait::async_trait;
4
5use crate::data_source::{
6    Transaction as _, VersionedDataSource,
7    storage::sql::{Backfill, SqlStorage, Transaction},
8};
9
10const RETRY_INTERVAL: Duration = Duration::from_secs(300);
11
12/// A background migration that copies or transforms data in batches.
13#[async_trait]
14pub trait DataBackfill: Send + Sync + 'static {
15    fn name(&self) -> &'static str;
16
17    /// Names of other [`DataBackfill`] migrations that must complete before this one starts.
18    fn requires(&self) -> &'static [&'static str] {
19        &[]
20    }
21
22    /// Number of rows to process per batch.
23    fn batch_size(&self) -> usize {
24        1_000
25    }
26
27    /// Number of batches between progress log lines.
28    fn log_interval(&self) -> usize {
29        10
30    }
31
32    /// How long to sleep between batches to avoid saturating the database.
33    fn batch_delay(&self) -> Duration {
34        Duration::from_millis(50)
35    }
36
37    /// Process one batch starting at `offset`.
38    ///
39    /// Returns `Some(next_offset)` to continue, or `None` when all rows have been processed.
40    ///
41    /// The transaction runs at READ COMMITTED on Postgres (see [`Backfill`]), so batches must be
42    /// idempotent — typically by writing with `ON CONFLICT` and advancing a monotonic cursor.
43    async fn run_batch(
44        &self,
45        tx: &mut Transaction<Backfill>,
46        offset: u64,
47    ) -> anyhow::Result<Option<u64>>;
48}
49
50/// An ordered registry of [`DataBackfill`] migrations.
51pub struct MigrationRegistry {
52    migrations: Vec<Box<dyn DataBackfill>>,
53}
54
55impl MigrationRegistry {
56    /// Create an empty registry.
57    pub fn new() -> Self {
58        Self {
59            migrations: Vec::new(),
60        }
61    }
62
63    /// Append a migration. Migrations run in the order they are added.
64    pub fn backfill(mut self, m: impl DataBackfill) -> Self {
65        self.migrations.push(Box::new(m));
66        self
67    }
68
69    /// Validate the registry.
70    ///
71    /// Checks that:
72    /// - All migration names are unique.
73    /// - Every name listed in `requires()` refers to a migration that appears earlier in the list.
74    pub fn validate(&self) -> anyhow::Result<()> {
75        let mut seen: HashSet<&'static str> = HashSet::new();
76        for m in &self.migrations {
77            let name = m.name();
78            anyhow::ensure!(seen.insert(name), "duplicate migration name: {name}");
79            for dep in m.requires() {
80                anyhow::ensure!(
81                    seen.contains(dep),
82                    "migration {name} requires {dep} which either does not exist or appears later \
83                     in the registry",
84                );
85            }
86        }
87        Ok(())
88    }
89
90    /// Iterate over the registered migrations in order.
91    pub fn iter(&self) -> impl Iterator<Item = &dyn DataBackfill> {
92        self.migrations.iter().map(|b| b.as_ref())
93    }
94
95    /// Drive all registered migrations to completion.
96    ///
97    /// Iterates over all migrations on each pass. Migrations whose prerequisites are not yet
98    /// complete are skipped with a warning and retried after [`RETRY_INTERVAL`]. The loop exits
99    /// once every migration has been marked complete.
100    pub async fn run_all_migrations(self, db: SqlStorage) {
101        if let Err(e) = self.validate() {
102            tracing::error!(
103                "deferred migration registry is invalid, skipping all backfills: {e:#}"
104            );
105            return;
106        }
107
108        loop {
109            let mut pending = 0usize;
110
111            for m in &self.migrations {
112                let name = m.name();
113
114                // Skip migrations that have already been marked complete.
115                match Self::is_complete(&db, name).await {
116                    Ok(true) => continue,
117                    Ok(false) => {},
118                    Err(e) => {
119                        tracing::error!(name, "failed to check migration status: {e:#}");
120                        pending += 1;
121                        continue;
122                    },
123                }
124
125                // Defer if any prerequisite is not yet complete.
126                let mut deps_ready = true;
127                for dep in m.requires() {
128                    match Self::is_complete(&db, dep).await {
129                        Ok(true) => {},
130                        Ok(false) => {
131                            tracing::warn!(
132                                name,
133                                dep,
134                                "prerequisite not yet complete, will retry after \
135                                 {RETRY_INTERVAL:?}"
136                            );
137                            deps_ready = false;
138                        },
139                        Err(e) => {
140                            tracing::error!(name, dep, "failed to check prerequisite: {e:#}");
141                            deps_ready = false;
142                        },
143                    }
144                }
145                if !deps_ready {
146                    pending += 1;
147                    continue;
148                }
149
150                // Run all batches for this migration.
151                if let Err(e) = Self::run_migration(&db, m.as_ref()).await {
152                    tracing::error!(name, "deferred migration failed: {e:#}");
153                    pending += 1;
154                }
155            }
156
157            if pending == 0 {
158                tracing::warn!("all deferred migrations complete");
159                break;
160            }
161
162            tokio::time::sleep(RETRY_INTERVAL).await;
163        }
164    }
165
166    /// Run all batches for a single migration to completion.
167    async fn run_migration(db: &SqlStorage, m: &dyn DataBackfill) -> anyhow::Result<()> {
168        use anyhow::Context as _;
169
170        let name = m.name();
171
172        let mut offset = Self::init_and_get_offset(db, name)
173            .await
174            .context("failed to initialize migration")?;
175
176        let delay = env::var("ESPRESSO_NODE_BACKFILL_BATCH_DELAY_MS")
177            .ok()
178            .and_then(|v| v.parse::<u64>().ok())
179            .map(Duration::from_millis)
180            .unwrap_or_else(|| m.batch_delay());
181
182        tracing::warn!(name, offset, ?delay, "starting deferred migration");
183
184        let mut batch_count: usize = 0;
185
186        loop {
187            let mut tx = db.backfill().await.with_context(|| {
188                format!("failed to open backfill transaction at offset {offset}")
189            })?;
190
191            let next = m
192                .run_batch(&mut tx, offset)
193                .await
194                .with_context(|| format!("migration batch failed at offset {offset}"))?;
195
196            sqlx::query(
197                "UPDATE deferred_migrations SET last_offset = $1, completed_at = CASE WHEN $2 \
198                 THEN CURRENT_TIMESTAMP ELSE completed_at END WHERE name = $3",
199            )
200            .bind(next.unwrap_or(offset) as i64)
201            .bind(next.is_none())
202            .bind(name)
203            .execute(tx.as_mut())
204            .await
205            .context("failed to persist migration progress")?;
206
207            tx.commit()
208                .await
209                .context("failed to commit migration batch")?;
210
211            batch_count += 1;
212
213            let Some(next_offset) = next else {
214                tracing::warn!(name, batches = batch_count, "deferred migration complete");
215                return Ok(());
216            };
217            offset = next_offset;
218
219            if !delay.is_zero() {
220                tokio::time::sleep(delay).await;
221            }
222
223            if batch_count.is_multiple_of(m.log_interval()) {
224                tracing::warn!(
225                    name,
226                    offset,
227                    batches = batch_count,
228                    "deferred migration progress"
229                );
230            }
231        }
232    }
233
234    /// Returns `true` if the named migration has been marked complete in the database.
235    /// Returns `false` if the row does not exist or `completed_at` is null.
236    async fn is_complete(db: &SqlStorage, name: &str) -> anyhow::Result<bool> {
237        let mut tx = db.read().await?;
238        let row: Option<(bool,)> = sqlx::query_as(
239            "SELECT completed_at IS NOT NULL FROM deferred_migrations WHERE name = $1",
240        )
241        .bind(name)
242        .fetch_optional(tx.as_mut())
243        .await?;
244        Ok(row.map(|(b,)| b).unwrap_or(false))
245    }
246
247    /// Insert a tracking row for `name` if one does not yet exist, then return the stored offset
248    /// to resume from.
249    async fn init_and_get_offset(db: &SqlStorage, name: &str) -> anyhow::Result<u64> {
250        let mut tx = db.write().await?;
251
252        sqlx::query(
253            "INSERT INTO deferred_migrations (name, started_at, last_offset) VALUES ($1, \
254             CURRENT_TIMESTAMP, 0) ON CONFLICT (name) DO NOTHING",
255        )
256        .bind(name)
257        .execute(tx.as_mut())
258        .await?;
259
260        let (last_offset,): (i64,) = sqlx::query_as(
261            "SELECT COALESCE(last_offset, 0) FROM deferred_migrations WHERE name = $1",
262        )
263        .bind(name)
264        .fetch_one(tx.as_mut())
265        .await?;
266
267        tx.commit().await?;
268
269        Ok(last_offset as u64)
270    }
271}
272
273impl Default for MigrationRegistry {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    // A minimal DataBackfill that runs `total` batches (one unit of work each) then stops.
284    struct CountBatches {
285        name: &'static str,
286        total: u64,
287        deps: &'static [&'static str],
288    }
289
290    impl CountBatches {
291        fn new(name: &'static str, total: u64) -> Self {
292            Self {
293                name,
294                total,
295                deps: &[],
296            }
297        }
298
299        fn with_deps(name: &'static str, total: u64, deps: &'static [&'static str]) -> Self {
300            Self { name, total, deps }
301        }
302    }
303
304    #[async_trait]
305    impl DataBackfill for CountBatches {
306        fn name(&self) -> &'static str {
307            self.name
308        }
309
310        fn requires(&self) -> &'static [&'static str] {
311            self.deps
312        }
313
314        fn batch_size(&self) -> usize {
315            1
316        }
317
318        async fn run_batch(
319            &self,
320            _tx: &mut Transaction<Backfill>,
321            offset: u64,
322        ) -> anyhow::Result<Option<u64>> {
323            Ok((offset < self.total).then_some(offset + 1))
324        }
325    }
326
327    // --- validate() unit tests (no database required) ---
328
329    #[test]
330    fn validate_empty() {
331        MigrationRegistry::new().validate().unwrap();
332    }
333
334    #[test]
335    fn validate_single() {
336        MigrationRegistry::new()
337            .backfill(CountBatches::new("a", 1))
338            .validate()
339            .unwrap();
340    }
341
342    #[test]
343    fn validate_duplicate_name() {
344        let err = MigrationRegistry::new()
345            .backfill(CountBatches::new("foo", 1))
346            .backfill(CountBatches::new("foo", 1))
347            .validate()
348            .unwrap_err();
349        assert!(err.to_string().contains("duplicate"));
350    }
351
352    #[test]
353    fn validate_unknown_dep() {
354        let err = MigrationRegistry::new()
355            .backfill(CountBatches::with_deps("bar", 1, &["nonexistent"]))
356            .validate()
357            .unwrap_err();
358        assert!(err.to_string().contains("nonexistent"));
359    }
360
361    #[test]
362    fn validate_dep_must_precede() {
363        // "a" depends on "b" but "b" comes after "a" in the registry.
364        let err = MigrationRegistry::new()
365            .backfill(CountBatches::with_deps("a", 1, &["b"]))
366            .backfill(CountBatches::new("b", 1))
367            .validate()
368            .unwrap_err();
369        assert!(err.to_string().contains("b"));
370    }
371
372    #[test]
373    fn validate_valid_dependency_chain() {
374        MigrationRegistry::new()
375            .backfill(CountBatches::new("a", 1))
376            .backfill(CountBatches::with_deps("b", 1, &["a"]))
377            .backfill(CountBatches::with_deps("c", 1, &["a", "b"]))
378            .validate()
379            .unwrap();
380    }
381
382    // --- Integration tests (require a real postgres database) ---
383
384    #[cfg(not(feature = "embedded-db"))]
385    mod db_tests {
386        use super::*;
387        use crate::data_source::storage::sql::{
388            Migration, SqlStorage, StorageConnectionType, testing::TmpDb,
389        };
390
391        // The deferred_migrations table lives in espresso-node's migrations; for tests within
392        // this crate we inject it as an extra migration.
393        const CREATE_DEFERRED_MIGRATIONS: &str = "
394            CREATE TABLE IF NOT EXISTS deferred_migrations (
395                name         TEXT        PRIMARY KEY,
396                started_at   TIMESTAMPTZ NOT NULL,
397                completed_at TIMESTAMPTZ,
398                last_offset  BIGINT
399            );
400        ";
401
402        async fn setup() -> (TmpDb, SqlStorage) {
403            let db = TmpDb::init().await;
404            let storage = SqlStorage::connect(
405                db.config().migrations(vec![
406                    Migration::unapplied(
407                        "V9990__deferred_migrations.sql",
408                        CREATE_DEFERRED_MIGRATIONS,
409                    )
410                    .unwrap(),
411                ]),
412                StorageConnectionType::Query,
413            )
414            .await
415            .unwrap();
416            (db, storage)
417        }
418
419        async fn is_complete(storage: &SqlStorage, name: &str) -> bool {
420            let mut tx = storage.read().await.unwrap();
421            let row: Option<(bool,)> = sqlx::query_as(
422                "SELECT completed_at IS NOT NULL FROM deferred_migrations WHERE name = $1",
423            )
424            .bind(name)
425            .fetch_optional(tx.as_mut())
426            .await
427            .unwrap();
428            row.map(|(b,)| b).unwrap_or(false)
429        }
430
431        async fn last_offset(storage: &SqlStorage, name: &str) -> Option<i64> {
432            let mut tx = storage.read().await.unwrap();
433            let row: Option<(i64,)> =
434                sqlx::query_as("SELECT last_offset FROM deferred_migrations WHERE name = $1")
435                    .bind(name)
436                    .fetch_optional(tx.as_mut())
437                    .await
438                    .unwrap();
439            row.map(|(o,)| o)
440        }
441
442        #[test_log::test(tokio::test(flavor = "multi_thread"))]
443        async fn migration_runs_to_completion() {
444            let (_db, storage) = setup().await;
445
446            MigrationRegistry::new()
447                .backfill(CountBatches::new("m", 5))
448                .run_all_migrations(storage.clone())
449                .await;
450
451            assert!(is_complete(&storage, "m").await);
452            assert_eq!(last_offset(&storage, "m").await, Some(5));
453        }
454
455        #[test_log::test(tokio::test(flavor = "multi_thread"))]
456        async fn migration_resumes_from_checkpoint() {
457            let (_db, storage) = setup().await;
458
459            // Seed partial progress: already at offset 3 of 5.
460            let mut tx = storage.write().await.unwrap();
461            sqlx::query(
462                "INSERT INTO deferred_migrations (name, started_at, last_offset) VALUES ($1, \
463                 CURRENT_TIMESTAMP, $2)",
464            )
465            .bind("m")
466            .bind(3i64)
467            .execute(tx.as_mut())
468            .await
469            .unwrap();
470            tx.commit().await.unwrap();
471
472            MigrationRegistry::new()
473                .backfill(CountBatches::new("m", 5))
474                .run_all_migrations(storage.clone())
475                .await;
476
477            assert!(is_complete(&storage, "m").await);
478            assert_eq!(last_offset(&storage, "m").await, Some(5));
479        }
480
481        #[test_log::test(tokio::test(flavor = "multi_thread"))]
482        async fn completed_migration_is_skipped() {
483            let (_db, storage) = setup().await;
484
485            // Pre-mark the migration as complete at offset 99.
486            let mut tx = storage.write().await.unwrap();
487            sqlx::query(
488                "INSERT INTO deferred_migrations (name, started_at, completed_at, last_offset) \
489                 VALUES ($1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, $2)",
490            )
491            .bind("m")
492            .bind(99i64)
493            .execute(tx.as_mut())
494            .await
495            .unwrap();
496            tx.commit().await.unwrap();
497
498            // Run a migration with total=0 — if it ran, it would reset the offset.
499            MigrationRegistry::new()
500                .backfill(CountBatches::new("m", 0))
501                .run_all_migrations(storage.clone())
502                .await;
503
504            // Offset must not have changed.
505            assert_eq!(last_offset(&storage, "m").await, Some(99));
506        }
507
508        #[test_log::test(tokio::test(flavor = "multi_thread"))]
509        async fn dependency_ordering_respected() {
510            let (_db, storage) = setup().await;
511
512            MigrationRegistry::new()
513                .backfill(CountBatches::new("first", 3))
514                .backfill(CountBatches::with_deps("second", 3, &["first"]))
515                .run_all_migrations(storage.clone())
516                .await;
517
518            assert!(is_complete(&storage, "first").await);
519            assert!(is_complete(&storage, "second").await);
520        }
521    }
522}