Skip to main content

espresso_node/persistence/
migrations.rs

1#![cfg(not(feature = "embedded-db"))]
2
3use async_trait::async_trait;
4use hotshot_query_service::{
5    data_source::storage::sql::{Backfill, Transaction},
6    migration::{DataBackfill, MigrationRegistry},
7};
8
9pub struct BackfillHash;
10
11#[async_trait]
12impl DataBackfill for BackfillHash {
13    fn name(&self) -> &'static str {
14        "hash_bigint_backfill_hash"
15    }
16
17    async fn run_batch(
18        &self,
19        tx: &mut Transaction<Backfill>,
20        // Reused as last-seen id (keyset cursor), not a row count.
21        // Initial value 0 is safe because auto-increment ids start at 1.
22        offset: u64,
23    ) -> anyhow::Result<Option<u64>> {
24        let rows: Vec<(i32, Vec<u8>)> =
25            sqlx::query_as("SELECT id, value FROM hash WHERE id > $1 ORDER BY id LIMIT $2")
26                .bind(offset as i64)
27                .bind(self.batch_size() as i64)
28                .fetch_all(tx.as_mut())
29                .await?;
30
31        if rows.is_empty() {
32            return Ok(None);
33        }
34        let n = rows.len();
35        let last_id = rows.last().expect("non-empty").0 as u64;
36
37        let (ids, values): (Vec<i64>, Vec<Vec<u8>>) = rows
38            .into_iter()
39            .map(|(id, value)| (id as i64, value))
40            .unzip();
41
42        sqlx::query(
43            "INSERT INTO hash_bigint (id, value)
44             SELECT * FROM UNNEST($1::bigint[], $2::bytea[])
45             ON CONFLICT DO NOTHING",
46        )
47        .bind(&ids)
48        .bind(&values)
49        .execute(tx.as_mut())
50        .await?;
51
52        if n < self.batch_size() {
53            Ok(None)
54        } else {
55            Ok(Some(last_id))
56        }
57    }
58}
59
60macro_rules! merkle_tree_backfill {
61    ($struct_name:ident, $migration_name:literal, $legacy_table:literal, $new_table:literal) => {
62        pub struct $struct_name;
63
64        #[async_trait]
65        impl DataBackfill for $struct_name {
66            fn name(&self) -> &'static str {
67                $migration_name
68            }
69
70            fn requires(&self) -> &'static [&'static str] {
71                &["hash_bigint_backfill_hash"]
72            }
73
74            async fn run_batch(
75                &self,
76                tx: &mut Transaction<Backfill>,
77                // Cursor: the start of the `created` (block height) range for this batch.
78                // Each batch covers [offset, offset + batch_size), using the index on `created`.
79                offset: u64,
80            ) -> anyhow::Result<Option<u64>> {
81                let batch_size = self.batch_size() as i64;
82
83                // Check if any rows remain at or beyond the current offset. Checking only the
84                // current window would cause early termination if block heights have gaps larger
85                // than batch_size; checking the open-ended tail means gaps are just a few fast
86                // no-op iterations.
87                let any: Option<(i64,)> = sqlx::query_as(concat!(
88                    "SELECT created FROM ",
89                    $legacy_table,
90                    " WHERE created >= $1 LIMIT 1"
91                ))
92                .bind(offset as i64)
93                .fetch_optional(tx.as_mut())
94                .await?;
95                if any.is_none() {
96                    return Ok(None);
97                }
98
99                // Move rows from legacy into the new _bigint table, translating both
100                // `hash_id` and every element of `children` from legacy hash ids into
101                // hash_bigint ids by joining on the hash `value`. This removes any
102                // dependency on `BackfillHash` having preserved the original ids.
103                sqlx::query(concat!(
104                    "INSERT INTO ",
105                    $new_table,
106                    " (path, created, hash_id, children, children_bitvec, idx, entry)
107                     SELECT
108                         legacy_merkle_node.path,
109                         legacy_merkle_node.created,
110                         new_hash.id,
111                         CASE WHEN legacy_merkle_node.children IS NULL THEN NULL
112                              ELSE COALESCE((
113                                  SELECT jsonb_agg(child_new_hash.id ORDER BY \
114                     child_position)
115                                  FROM \
116                     jsonb_array_elements_text(legacy_merkle_node.children)
117                                       WITH ORDINALITY AS child_elem(legacy_hash_id, \
118                     child_position)
119                                  JOIN hash AS child_legacy_hash
120                                    ON child_legacy_hash.id = \
121                     child_elem.legacy_hash_id::INT
122                                  JOIN hash_bigint AS child_new_hash
123                                    ON child_new_hash.value = child_legacy_hash.value
124                              ), '[]'::jsonb)
125                         END,
126                         legacy_merkle_node.children_bitvec,
127                         legacy_merkle_node.idx,
128                         legacy_merkle_node.entry
129                     FROM ",
130                    $legacy_table,
131                    " AS legacy_merkle_node
132                     JOIN hash AS legacy_hash ON legacy_hash.id = \
133                     legacy_merkle_node.hash_id
134                     JOIN hash_bigint AS new_hash ON new_hash.value = legacy_hash.value
135                     WHERE legacy_merkle_node.created >= $1 AND legacy_merkle_node.created \
136                     < $2
137                     ON CONFLICT (path, created) DO NOTHING"
138                ))
139                .bind(offset as i64)
140                .bind(offset as i64 + batch_size)
141                .execute(tx.as_mut())
142                .await?;
143
144                // Delete moved rows from the legacy table in the same transaction so that
145                // storage stays roughly flat during the migration (move, not copy).
146                sqlx::query(concat!(
147                    "DELETE FROM ",
148                    $legacy_table,
149                    " WHERE created >= $1 AND created < $2"
150                ))
151                .bind(offset as i64)
152                .bind(offset as i64 + batch_size)
153                .execute(tx.as_mut())
154                .await?;
155
156                Ok(Some(offset + self.batch_size() as u64))
157            }
158        }
159    };
160}
161
162merkle_tree_backfill!(
163    BackfillFeeMerkleTree,
164    "hash_bigint_backfill_fee_merkle_tree",
165    "fee_merkle_tree",
166    "fee_merkle_tree_bigint"
167);
168merkle_tree_backfill!(
169    BackfillBlockMerkleTree,
170    "hash_bigint_backfill_block_merkle_tree",
171    "block_merkle_tree",
172    "block_merkle_tree_bigint"
173);
174
175pub struct CleanupLegacyHashTable;
176
177#[async_trait]
178impl DataBackfill for CleanupLegacyHashTable {
179    fn name(&self) -> &'static str {
180        "hash_bigint_cleanup_legacy_hash"
181    }
182
183    fn requires(&self) -> &'static [&'static str] {
184        &[
185            "hash_bigint_backfill_fee_merkle_tree",
186            "hash_bigint_backfill_block_merkle_tree",
187        ]
188    }
189
190    async fn run_batch(
191        &self,
192        tx: &mut Transaction<Backfill>,
193        _offset: u64,
194    ) -> anyhow::Result<Option<u64>> {
195        sqlx::query("TRUNCATE TABLE hash, fee_merkle_tree, block_merkle_tree")
196            .execute(tx.as_mut())
197            .await?;
198        Ok(None)
199    }
200}
201
202pub fn hash_bigint_migrations() -> MigrationRegistry {
203    MigrationRegistry::new()
204        .backfill(BackfillHash)
205        .backfill(BackfillFeeMerkleTree)
206        .backfill(BackfillBlockMerkleTree)
207        .backfill(CleanupLegacyHashTable)
208}
209
210#[cfg(test)]
211mod tests {
212    use alloy::primitives::Address;
213    use espresso_types::{FEE_MERKLE_TREE_HEIGHT, FeeAccount, FeeAmount, FeeMerkleTree, SeqTypes};
214    use hotshot_query_service::{
215        data_source::{
216            Transaction as _, VersionedDataSource,
217            sql::Config,
218            storage::{
219                MerklizedStateStorage,
220                sql::{
221                    SqlStorage, StorageConnectionType, Transaction as SqlTransaction, Write,
222                    testing::TmpDb,
223                },
224            },
225        },
226        merklized_state::{Snapshot, UpdateStateData},
227    };
228    use jf_merkle_tree_compat::{
229        LookupResult, MerkleTreeScheme, ToTraversalPath, UniversalMerkleTreeScheme,
230    };
231
232    use super::*;
233    use crate::api::sql::impl_testable_data_source::tmp_options;
234
235    async fn run_to_completion(
236        backfill: &dyn DataBackfill,
237        storage: &SqlStorage,
238    ) -> anyhow::Result<()> {
239        let mut offset = 0u64;
240        loop {
241            let mut tx = storage.backfill().await?;
242            let next = backfill.run_batch(&mut tx, offset).await?;
243            tx.commit().await?;
244            match next {
245                Some(o) => offset = o,
246                None => return Ok(()),
247            }
248        }
249    }
250
251    async fn write_fee_merkle_proofs(
252        tx: &mut SqlTransaction<Write>,
253        tree: &FeeMerkleTree,
254        accounts: &[FeeAccount],
255        block_height: u64,
256    ) {
257        let proofs: Vec<_> = accounts
258            .iter()
259            .map(|a| {
260                let proof = match tree.universal_lookup(a) {
261                    LookupResult::Ok(_, p) => p,
262                    LookupResult::NotFound(p) => p,
263                    LookupResult::NotInMemory => panic!("account not in memory"),
264                };
265                let path =
266                    <FeeAccount as ToTraversalPath<{ FeeMerkleTree::ARITY }>>::to_traversal_path(
267                        a,
268                        tree.height(),
269                    );
270                (proof, path)
271            })
272            .collect();
273        UpdateStateData::<SeqTypes, FeeMerkleTree, { FeeMerkleTree::ARITY }>::insert_merkle_nodes_batch(
274            tx,
275            proofs,
276            block_height,
277        )
278        .await
279        .expect("insert_merkle_nodes_batch");
280    }
281
282    fn membership_proof(
283        tree: &FeeMerkleTree,
284        account: &FeeAccount,
285    ) -> <FeeMerkleTree as MerkleTreeScheme>::MembershipProof {
286        match tree.universal_lookup(account) {
287            LookupResult::Ok(_, p) => p,
288            _ => panic!("account not in tree"),
289        }
290    }
291
292    /// Seed a `header` row carrying `commit` as its `fee_merkle_tree_root`.
293    ///
294    /// The root is read back by `snapshot_info` to verify the reconstructed proof.
295    async fn insert_fee_header(
296        tx: &mut SqlTransaction<Write>,
297        height: i64,
298        commit: <FeeMerkleTree as MerkleTreeScheme>::Commitment,
299    ) {
300        let data = serde_json::json!({
301            "fee_merkle_tree_root": serde_json::to_value(commit).unwrap(),
302            // Non-null filler for the other generated root column.
303            "block_merkle_tree_root": "0",
304        });
305        sqlx::query(
306            "INSERT INTO header (height, hash, payload_hash, timestamp, ns_table, data) VALUES \
307             ($1, $2, $3, $4, $5, $6)",
308        )
309        .bind(height)
310        .bind(format!("hash{height}"))
311        .bind("payload")
312        .bind(0i64)
313        .bind("ns")
314        .bind(data)
315        .execute(tx.as_mut())
316        .await
317        .expect("insert header");
318    }
319
320    /// Regression test: reads during the backfill window must return the latest
321    /// version of a node, not a stale one.
322    ///
323    /// Each Merkle node row is keyed `(path, created)` where `created` is the block
324    /// height at which the node changed, so a single path accumulates one row per
325    /// such height. The backfill moves rows by ascending `created`, so mid-migration
326    /// a path's history is split: an older `created` lives in `*_bigint` while a
327    /// newer `created` still lives in the legacy table.
328    ///
329    /// `get_path`'s legacy fallback keys on whether the path exists in `*_bigint` at
330    /// all, not on `created`. With any older version present in `*_bigint` the path
331    /// counts as "found", legacy is never consulted, and the snapshot read returns
332    /// the stale older version. The reconstructed commitment then fails to match the
333    /// header root and `get_path` errors (or, absent that check, returns wrong data).
334    #[test_log::test(tokio::test(flavor = "multi_thread"))]
335    async fn read_during_backfill_returns_latest_version() {
336        let db = TmpDb::init().await;
337        let opt = tmp_options(&db);
338        let cfg = Config::try_from(&opt).expect("config");
339        let storage = SqlStorage::connect(cfg, StorageConnectionType::Query)
340            .await
341            .expect("connect");
342
343        let account = FeeAccount::from(Address::repeat_byte(0x42));
344        let mut tree = FeeMerkleTree::new(FEE_MERKLE_TREE_HEIGHT);
345
346        // Height 1: account = 100. Live write goes to the *_bigint tables.
347        tree.update(account, FeeAmount::from(100u64)).unwrap();
348        let proof_v1 = membership_proof(&tree, &account);
349        let commit_v1 = tree.commitment();
350        let mut tx = storage.write().await.unwrap();
351        write_fee_merkle_proofs(&mut tx, &tree, &[account], 1).await;
352        tx.commit().await.unwrap();
353
354        // Height 2: account = 200. Also written to the *_bigint tables.
355        tree.update(account, FeeAmount::from(200u64)).unwrap();
356        let proof_v2 = membership_proof(&tree, &account);
357        let commit_v2 = tree.commitment();
358        let mut tx = storage.write().await.unwrap();
359        write_fee_merkle_proofs(&mut tx, &tree, &[account], 2).await;
360        tx.commit().await.unwrap();
361
362        // End-state of the mid-migration split, constructed in reverse: both
363        // writes went to *_bigint above, now move the height-2 row across to
364        // legacy. The natural flow is legacy → *_bigint by ascending `created`,
365        // but the resulting (older in *_bigint, newer in legacy) shape is the
366        // same. Also seed legacy `hash` so the legacy table's hash_id FK resolves.
367        let mut tx = storage.write().await.unwrap();
368        sqlx::query(
369            "INSERT INTO hash (id, value) SELECT id::INT, value FROM hash_bigint ON CONFLICT DO \
370             NOTHING",
371        )
372        .execute(tx.as_mut())
373        .await
374        .unwrap();
375        sqlx::query(
376            "INSERT INTO fee_merkle_tree (path, created, hash_id, children, children_bitvec, idx, \
377             entry) SELECT path, created, hash_id::INT, children, children_bitvec::BIT(256), idx, \
378             entry FROM fee_merkle_tree_bigint WHERE created = 2",
379        )
380        .execute(tx.as_mut())
381        .await
382        .unwrap();
383        sqlx::query("DELETE FROM fee_merkle_tree_bigint WHERE created = 2")
384            .execute(tx.as_mut())
385            .await
386            .unwrap();
387        tx.commit().await.unwrap();
388
389        // Seed headers carrying each height's fee root, and mark height 2 decided.
390        let mut tx = storage.write().await.unwrap();
391        insert_fee_header(&mut tx, 1, commit_v1).await;
392        insert_fee_header(&mut tx, 2, commit_v2).await;
393        UpdateStateData::<SeqTypes, FeeMerkleTree, { FeeMerkleTree::ARITY }>::set_last_state_height(
394            &mut tx, 2,
395        )
396        .await
397        .unwrap();
398        tx.commit().await.unwrap();
399
400        // Control: height 1 is served from the *_bigint row and is correct.
401        let mut tx = storage.read().await.unwrap();
402        let got_v1 = tx
403            .get_path(
404                Snapshot::<SeqTypes, FeeMerkleTree, { FeeMerkleTree::ARITY }>::Index(1),
405                account,
406            )
407            .await
408            .expect("get_path at height 1");
409        assert_eq!(got_v1, proof_v1, "height 1 should return the V1 proof");
410
411        // Bug: height 2's latest node lives in legacy, but *_bigint still holds the
412        // height-1 version of the same path, so the fallback returns it stale.
413        let mut tx = storage.read().await.unwrap();
414        let got_v2 = tx
415            .get_path(
416                Snapshot::<SeqTypes, FeeMerkleTree, { FeeMerkleTree::ARITY }>::Index(2),
417                account,
418            )
419            .await
420            .expect("get_path at height 2 (latest version is in the legacy table)");
421        assert_eq!(
422            got_v2, proof_v2,
423            "height 2 must return the latest (V2) proof, not the stale V1 row from *_bigint"
424        );
425    }
426
427    /// Regression test for the FK race between `BackfillHash` and live writes
428    /// to `hash_bigint`.
429    ///
430    /// V1501 seeds the `hash_bigint(id)` sequence above `MAX(hash.id)` so new
431    /// auto-ids cannot collide with legacy ids — but nothing protects the
432    /// `value` UNIQUE constraint. Whenever a post-migration write inserts a
433    /// value that also lives in legacy `hash` (the common case: empty-subtree
434    /// hashes and unchanged branch hashes are byte-identical across blocks),
435    /// the live row claims a new id, and the backfill's `INSERT (old_id, value)
436    /// ON CONFLICT DO NOTHING` is silently dropped. The Merkle tree backfill
437    /// then copies the legacy `hash_id` verbatim and the FK to `hash_bigint(id)`
438    /// fires because that id was never inserted.
439    ///
440    /// This test exercises the real `UpdateStateData::insert_merkle_nodes_batch`
441    /// path so the shared-hash overlap arises from realistic Merkle proofs.
442    #[test_log::test(tokio::test(flavor = "multi_thread"))]
443    async fn backfill_preserves_fk_when_live_write_shares_hash_value() {
444        let db = TmpDb::init().await;
445        let opt = tmp_options(&db);
446        let cfg = Config::try_from(&opt).expect("config");
447        let storage = SqlStorage::connect(cfg, StorageConnectionType::Query)
448            .await
449            .expect("connect");
450
451        let mut tree = FeeMerkleTree::new(FEE_MERKLE_TREE_HEIGHT);
452        let account = FeeAccount::from(Address::repeat_byte(0x42));
453        tree.update(account, FeeAmount::from(100_u64)).unwrap();
454
455        // Write a real Merkle proof for `account` into the *new* _bigint tables.
456        let mut tx = storage.write().await.unwrap();
457        write_fee_merkle_proofs(&mut tx, &tree, &[account], 1).await;
458        tx.commit().await.unwrap();
459
460        // Move every row from the _bigint tables back into the legacy tables to
461        // simulate a database that was populated before V1501 ran. Then reset
462        // the hash_bigint sequence the way V1501 itself does.
463        let mut tx = storage.write().await.unwrap();
464        sqlx::query("INSERT INTO hash (id, value) SELECT id::INT, value FROM hash_bigint")
465            .execute(tx.as_mut())
466            .await
467            .unwrap();
468        sqlx::query(
469            "INSERT INTO fee_merkle_tree (path, created, hash_id, children, children_bitvec, idx, \
470             entry) SELECT path, created, hash_id::INT, children, children_bitvec::BIT(256), idx, \
471             entry FROM fee_merkle_tree_bigint",
472        )
473        .execute(tx.as_mut())
474        .await
475        .unwrap();
476        sqlx::query("TRUNCATE fee_merkle_tree_bigint, block_merkle_tree_bigint, hash_bigint")
477            .execute(tx.as_mut())
478            .await
479            .unwrap();
480        sqlx::query(
481            "SELECT setval(pg_get_serial_sequence('hash_bigint', 'id'), GREATEST(COALESCE((SELECT \
482             MAX(id) FROM hash), 1), 1))",
483        )
484        .execute(tx.as_mut())
485        .await
486        .unwrap();
487        tx.commit().await.unwrap();
488
489        // Live post-V1501 write at a new block height. The proof for the same
490        // account shares almost every hash value with the legacy proof above,
491        // so the live `batch_insert_hashes` calls collide on `value` with every
492        // row BackfillHash is about to copy.
493        let mut tx = storage.write().await.unwrap();
494        write_fee_merkle_proofs(&mut tx, &tree, &[account], 2).await;
495        tx.commit().await.unwrap();
496
497        // Drive backfills directly so a failure surfaces immediately rather
498        // than entering the registry's 5-minute retry loop.
499        run_to_completion(&BackfillHash, &storage)
500            .await
501            .expect("BackfillHash failed");
502        run_to_completion(&BackfillFeeMerkleTree, &storage)
503            .await
504            .expect("BackfillFeeMerkleTree failed (FK violation from dropped hash row)");
505
506        let mut tx = storage.read().await.unwrap();
507        let (n_legacy,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM fee_merkle_tree")
508            .fetch_one(tx.as_mut())
509            .await
510            .unwrap();
511        assert_eq!(n_legacy, 0, "legacy fee_merkle_tree rows were not migrated");
512
513        let (n_heights,): (i64,) =
514            sqlx::query_as("SELECT COUNT(DISTINCT created) FROM fee_merkle_tree_bigint")
515                .fetch_one(tx.as_mut())
516                .await
517                .unwrap();
518        assert_eq!(n_heights, 2, "expected rows at both heights");
519
520        let (n_orphans,): (i64,) = sqlx::query_as(
521            "SELECT COUNT(*) FROM fee_merkle_tree_bigint fmt LEFT JOIN hash_bigint hb ON hb.id = \
522             fmt.hash_id WHERE hb.id IS NULL",
523        )
524        .fetch_one(tx.as_mut())
525        .await
526        .unwrap();
527        assert_eq!(n_orphans, 0, "fee_merkle_tree_bigint has dangling hash_id");
528
529        let (n_orphan_children,): (i64,) = sqlx::query_as(
530            "SELECT COUNT(*) FROM ( SELECT child_id FROM fee_merkle_tree_bigint fmt, \
531             jsonb_array_elements_text(fmt.children) AS arr(child_id) WHERE fmt.children IS NOT \
532             NULL ) c LEFT JOIN hash_bigint hb ON hb.id = c.child_id::BIGINT WHERE hb.id IS NULL",
533        )
534        .fetch_one(tx.as_mut())
535        .await
536        .unwrap();
537        assert_eq!(
538            n_orphan_children, 0,
539            "fee_merkle_tree_bigint.children has dangling hash_id"
540        );
541    }
542}