Skip to main content

light_client/
storage.rs

1#[cfg(feature = "client")]
2use std::{
3    cmp::min,
4    collections::HashMap,
5    path::PathBuf,
6    str::FromStr,
7    sync::atomic::{AtomicI64, Ordering},
8    time::Duration,
9};
10#[cfg(all(unix, feature = "client"))]
11use std::{fs::Permissions, os::unix::fs::PermissionsExt};
12use std::{future::Future, sync::Arc};
13
14#[cfg(feature = "client")]
15use alloy::primitives::Address;
16#[cfg(feature = "client")]
17use anyhow::Context;
18use anyhow::Result;
19#[cfg(feature = "client")]
20use derivative::Derivative;
21use derive_more::{Display, From};
22#[cfg(feature = "client")]
23use espresso_types::{BackoffParams, PubKey, Ratio, v0_3::RegisteredValidator};
24use espresso_types::{SeqTypes, StakeTableState};
25#[cfg(feature = "client")]
26use futures::TryStreamExt;
27#[cfg(feature = "client")]
28use hotshot_query_service::data_source::storage::sql::release_only_idle;
29#[cfg(feature = "client")]
30use hotshot_query_service_types::HeightIndexed;
31use hotshot_query_service_types::availability::{BlockId, LeafId, LeafQueryData};
32use hotshot_types::data::EpochNumber;
33#[cfg(feature = "client")]
34use hotshot_types::{light_client::StateVerKey, x25519};
35#[cfg(feature = "client")]
36use serde::{Deserialize, Serialize};
37#[cfg(feature = "client")]
38use serde_json::Value;
39#[cfg(feature = "client")]
40use sqlx::{
41    ConnectOptions, Executor, PgPool, QueryBuilder, SqlitePool,
42    postgres::{PgConnectOptions, PgPoolOptions},
43    query, query_as,
44    sqlite::SqlitePoolOptions,
45};
46#[cfg(feature = "client")]
47use tempfile::{Builder, TempDir};
48#[cfg(feature = "client")]
49use tokio::runtime::Handle;
50use vbs::version::Version;
51
52/// Different ways to ask the database for a leaf.
53#[derive(Clone, Copy, Debug, Display, From)]
54pub enum LeafRequest {
55    /// Ask for a leaf with a given ID.
56    #[display("leaf {_0}")]
57    Leaf(LeafId<SeqTypes>),
58
59    /// Ask for the leaf containing a header with a given ID.
60    #[display("header {_0}")]
61    Header(BlockId<SeqTypes>),
62}
63
64/// Maximum number of retries for a failed write before propagating the error.
65#[cfg(feature = "client")]
66const WRITE_RETRY_MAX: u32 = 5;
67
68/// Backoff for retrying failed writes. Staggered so concurrent writers don't lock-step.
69#[cfg(feature = "client")]
70const WRITE_BACKOFF: BackoffParams = BackoffParams::new(
71    Duration::from_millis(50),
72    Duration::from_millis(1_000),
73    2,
74    Ratio {
75        numerator: 5,
76        denominator: 10,
77    },
78);
79
80#[cfg(feature = "client")]
81#[derive(Clone, Debug)]
82enum DbPool {
83    Sqlite(SqlitePool),
84    Postgres(PgPool),
85}
86
87/// Expands `$body` once per backend, so each copy type-checks against a concrete sqlx database.
88///
89/// The closure syntax only keeps rustfmt formatting the body. It is not a closure, so `?` and
90/// `return` leave the enclosing function. sqlx's `Any` driver would avoid the expansion but cannot
91/// bind JSON.
92#[cfg(feature = "client")]
93macro_rules! with_pool {
94    ($pool:expr, |$p:ident| $body:expr) => {
95        match $pool {
96            DbPool::Sqlite($p) => $body,
97            DbPool::Postgres($p) => $body,
98        }
99    };
100}
101
102/// In-memory LRU recency tracker shared across all clones of a [`SqlStorage`].
103///
104/// `touch` is called on the read path (pure, no DB write). `drain` is called inside
105/// `insert_leaf` to flush pending recency updates as part of the existing write transaction.
106/// Held in an `Arc`, so its `Drop` runs exactly once, when the last `SqlStorage` clone is
107/// dropped, flushing any touches that no `insert_leaf` persisted (graceful shutdown).
108#[cfg(feature = "client")]
109#[derive(Debug)]
110struct Recency {
111    /// Monotonically increasing tick counter. Persisted maximum is seeded at `connect` time so
112    /// ticks always exceed any value already stored in the DB after a restart.
113    next_tick: AtomicI64,
114    /// height -> latest tick; flushed to DB by the next `insert_leaf` or by `Drop`.
115    dirty: std::sync::Mutex<HashMap<i64, i64>>,
116    /// Pool handle kept alive for the on-drop flush.
117    pool: DbPool,
118}
119
120#[cfg(feature = "client")]
121impl Recency {
122    fn touch(&self, height: i64) {
123        let t = self.next_tick.fetch_add(1, Ordering::Relaxed);
124        self.dirty.lock().unwrap().insert(height, t);
125    }
126
127    fn drain(&self) -> Vec<(i64, i64)> {
128        self.dirty.lock().unwrap().drain().collect()
129    }
130
131    /// Persist drained touches to `last_used`. Best-effort: a failure only degrades GC ranking,
132    /// it never corrupts data.
133    async fn flush(pool: DbPool, pending: Vec<(i64, i64)>) {
134        let res: sqlx::Result<()> = async {
135            with_pool!(&pool, |pool| {
136                let mut tx = pool.begin().await?;
137                for (h, tick) in &pending {
138                    query("UPDATE leaf SET last_used = $1 WHERE height = $2")
139                        .bind(tick)
140                        .bind(h)
141                        .execute(tx.as_mut())
142                        .await?;
143                }
144                tx.commit().await
145            })
146        }
147        .await;
148        if let Err(err) = res {
149            tracing::warn!(%err, "failed to flush LRU recency");
150        }
151    }
152}
153
154#[cfg(feature = "client")]
155impl Drop for Recency {
156    /// Flush pending read-path touches to the DB on shutdown, best-effort.
157    ///
158    /// Touches are otherwise only persisted by the next `insert_leaf`; without this, GC after a
159    /// restart would rank recently-read leaves by a stale `last_used` and could evict them.
160    /// Inside a tokio runtime the flush is spawned, not driven synchronously: blocking the
161    /// current thread on the pool's acquire path deadlocks a current-thread runtime, because
162    /// acquire depends on tokio timers and tasks that cannot run while the thread is blocked.
163    /// A spawned flush may be cancelled by runtime shutdown, losing the pending touches, which
164    /// only degrades GC ranking. Outside a runtime the flush is driven to completion here.
165    fn drop(&mut self) {
166        let pending = self.drain();
167        if pending.is_empty() {
168            return;
169        }
170        let flush = Self::flush(self.pool.clone(), pending);
171        match Handle::try_current() {
172            Ok(handle) => {
173                handle.spawn(flush);
174            },
175            Err(_) => futures::executor::block_on(flush),
176        }
177    }
178}
179
180/// Client-side database for a [`LightClient`].
181pub trait Storage: Sized + Send + Sync + 'static {
182    /// Create a default, empty instance of the state.
183    ///
184    /// This is an async, fallible version of [`Default::default`]. If `Self: Default`, this is
185    /// equivalent to `ready(Ok(<Self as Default>::default()))`.
186    fn default() -> impl Send + Future<Output = Result<Self>>;
187
188    /// Get the number of blocks known to be in the chain.
189    ///
190    /// This is equivalent to one more than the block number of the latest known block.
191    ///
192    /// Because the database is not constantly being updated, this may be an underestimate of the
193    /// true number of blocks that exist.
194    fn block_height(&self) -> impl Send + Future<Output = Result<u64>>;
195
196    /// Get the earliest available leaf which is later than or equal to the requested leaf.
197    ///
198    /// This will either be the leaf requested, or can be used as the known-finalized endpoint in a
199    /// leaf chain proving that requested leaf is finalized (after the requested leaf is fetched
200    /// from elsewhere).
201    ///
202    /// If there is no known leaf later than the requested leaf, the result is [`None`].
203    fn leaf_upper_bound(
204        &self,
205        leaf: impl Into<LeafRequest> + Send,
206    ) -> impl Send + Future<Output = Result<Option<LeafQueryData<SeqTypes>>>>;
207
208    /// Get all leaves in the range [start, end)
209    fn get_leaves_in_range(
210        &self,
211        start: u32,
212        end: u32,
213    ) -> impl Send + Future<Output = Result<Vec<LeafQueryData<SeqTypes>>>>;
214
215    /// Add a leaf to the cache.
216    ///
217    /// This may result in an older leaf being removed.
218    fn insert_leaf(&self, leaf: LeafQueryData<SeqTypes>)
219    -> impl Send + Future<Output = Result<()>>;
220
221    /// Get the stake table for the latest epoch which is not later than `epoch`.
222    ///
223    /// If such a stake table is available in the database, returns the loaded epoch number, the
224    /// stake table state, the protocol version of the epoch root header under whose rules that
225    /// stake table's active set was selected, and the protocol version of the epoch root header
226    /// in the next epoch (used to seed iter 1 of a catchup that resumes from this row).
227    fn stake_table_lower_bound(
228        &self,
229        epoch: EpochNumber,
230    ) -> impl Send + Future<Output = Result<Option<(EpochNumber, StakeTableState, Version, Version)>>>;
231
232    /// Add a stake table to the cache.
233    ///
234    /// `epoch_root_protocol_version` is the protocol version of the epoch root header in epoch
235    /// `e-2` (the snapshot point), used so that future cache hits can apply the same active-set
236    /// selection rules without re-fetching the root.
237    ///
238    /// `next_epoch_root_protocol_version` is the protocol version of the epoch root header in
239    /// epoch `e-1` (the snapshot point for `e+1`), used so that a future catchup resuming from
240    /// this row can seed iter 1's filter version without re-fetching that root.
241    fn insert_stake_table(
242        &self,
243        epoch: EpochNumber,
244        stake_table: &StakeTableState,
245        epoch_root_protocol_version: Version,
246        next_epoch_root_protocol_version: Version,
247    ) -> impl Send + Future<Output = Result<()>>;
248}
249
250impl<T: Storage> Storage for Arc<T> {
251    async fn default() -> Result<Self> {
252        Ok(Arc::new(T::default().await?))
253    }
254
255    async fn block_height(&self) -> Result<u64> {
256        (**self).block_height().await
257    }
258
259    async fn leaf_upper_bound(
260        &self,
261        leaf: impl Into<LeafRequest> + Send,
262    ) -> Result<Option<LeafQueryData<SeqTypes>>> {
263        (**self).leaf_upper_bound(leaf).await
264    }
265
266    async fn get_leaves_in_range(
267        &self,
268        start: u32,
269        end: u32,
270    ) -> Result<Vec<LeafQueryData<SeqTypes>>> {
271        (**self).get_leaves_in_range(start, end).await
272    }
273
274    async fn insert_leaf(&self, leaf: LeafQueryData<SeqTypes>) -> Result<()> {
275        (**self).insert_leaf(leaf).await
276    }
277
278    async fn stake_table_lower_bound(
279        &self,
280        epoch: EpochNumber,
281    ) -> Result<Option<(EpochNumber, StakeTableState, Version, Version)>> {
282        (**self).stake_table_lower_bound(epoch).await
283    }
284
285    async fn insert_stake_table(
286        &self,
287        epoch: EpochNumber,
288        stake_table: &StakeTableState,
289        epoch_root_protocol_version: Version,
290        next_epoch_root_protocol_version: Version,
291    ) -> Result<()> {
292        (**self)
293            .insert_stake_table(
294                epoch,
295                stake_table,
296                epoch_root_protocol_version,
297                next_epoch_root_protocol_version,
298            )
299            .await
300    }
301}
302
303#[cfg(feature = "client")]
304#[derive(Clone, Debug, Serialize, Deserialize)]
305#[cfg_attr(feature = "clap", derive(clap::Parser))]
306pub struct LightClientSqliteOptions {
307    /// Maximum number of simultaneous DB connections to allow.
308    #[cfg_attr(
309        feature = "clap",
310        clap(
311            long = "light-client-db-num-connections",
312            env = "LIGHT_CLIENT_DB_NUM_CONNECTIONS",
313            default_value = "5",
314        )
315    )]
316    pub num_connections: u32,
317
318    /// Maximum number of leaves to cache in the local DB.
319    #[cfg_attr(
320        feature = "clap",
321        clap(
322            long = "light-client-db-num-leaves",
323            env = "LIGHT_CLIENT_DB_NUM_LEAVES",
324            default_value = "100",
325        )
326    )]
327    pub num_leaves: u32,
328
329    /// Path at which the light client database is persisted.
330    ///
331    /// If not present, the database is created in a temporary directory that is removed when the
332    /// storage is dropped. Set this in production so cached leaves and stake tables survive
333    /// restarts. Postgres-backed query nodes ignore it and keep the light client in the node
334    /// database's `light_client` schema.
335    #[cfg_attr(
336        feature = "clap",
337        clap(long = "light-client-db-path", env = "LIGHT_CLIENT_DB_PATH")
338    )]
339    pub lc_path: Option<PathBuf>,
340}
341
342#[cfg(feature = "client")]
343impl Default for LightClientSqliteOptions {
344    fn default() -> Self {
345        Self {
346            num_connections: 5,
347            num_leaves: 100,
348            lc_path: None,
349        }
350    }
351}
352
353#[cfg(feature = "client")]
354impl LightClientSqliteOptions {
355    /// Create or connect to a database with the given options.
356    pub async fn connect(self) -> Result<SqlStorage> {
357        let (path, tmp) = match self.lc_path {
358            Some(p) => {
359                if let Some(parent) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
360                    std::fs::create_dir_all(parent)
361                        .with_context(|| format!("creating parent directory {parent:?}"))?;
362                }
363                (p, None)
364            },
365            None => {
366                let mut builder = Builder::new();
367                builder.prefix("espresso-lc-");
368                #[cfg(unix)]
369                builder.permissions(Permissions::from_mode(0o700));
370                let dir = builder.tempdir().context(
371                    "creating temporary directory for light client database; set \
372                     LIGHT_CLIENT_DB_PATH to use a persistent location",
373                )?;
374                let path = dir.path().join("lc.db");
375                tracing::warn!(
376                    path = %path.display(),
377                    "LIGHT_CLIENT_DB_PATH is not set, so the light client database is in a \
378                     temporary directory and its leaves and stake tables are lost on restart. Set \
379                     LIGHT_CLIENT_DB_PATH to a persistent location"
380                );
381                (path, Some(Arc::new(dir)))
382            },
383        };
384
385        let opt = hotshot_query_service::sqlite_options::sqlite_options().filename(&path);
386        let pool = SqlitePoolOptions::default()
387            .max_connections(self.num_connections)
388            .connect_with(opt)
389            .await?;
390        sqlx::migrate!("./migrations").run(&pool).await?;
391        SqlStorage::new(DbPool::Sqlite(pool), self.num_leaves, tmp).await
392    }
393}
394
395/// Postgres connection for a light client that shares its node's database.
396///
397/// The light client's tables live in the `light_client` schema, apart from the query service's
398/// own tables. The pool opens up to `num_connections` connections of its own.
399#[cfg(feature = "client")]
400#[derive(Clone, Derivative)]
401#[derivative(Debug)]
402pub struct LightClientPostgresOptions {
403    // Carries the database password.
404    #[derivative(Debug = "ignore")]
405    pub connect: PgConnectOptions,
406    pub num_connections: u32,
407    pub num_leaves: u32,
408}
409
410#[cfg(feature = "client")]
411impl LightClientPostgresOptions {
412    /// Connect, creating the `light_client` schema and its tables if they do not exist.
413    pub async fn connect(self) -> Result<SqlStorage> {
414        let pool = release_only_idle(PgPoolOptions::new().max_connections(self.num_connections))
415            .after_connect(|conn, _| {
416                Box::pin(async move {
417                    conn.execute("SET search_path TO light_client").await?;
418                    Ok(())
419                })
420            })
421            .connect_with(self.connect)
422            .await
423            .context("connecting to Postgres for the light client")?;
424        query("CREATE SCHEMA IF NOT EXISTS light_client")
425            .execute(&pool)
426            .await?;
427        sqlx::migrate!("./migrations-postgres").run(&pool).await?;
428        SqlStorage::new(DbPool::Postgres(pool), self.num_leaves, None).await
429    }
430
431    /// Drop the `light_client` schema and everything cached in it.
432    ///
433    /// Run it whenever the node's own database is reset, since the cache is only valid for the
434    /// chain it was built from.
435    pub async fn reset(connect: &PgConnectOptions) -> Result<()> {
436        let mut conn = connect
437            .connect()
438            .await
439            .context("connecting to Postgres to reset the light client")?;
440        query("DROP SCHEMA IF EXISTS light_client CASCADE")
441            .execute(&mut conn)
442            .await?;
443        Ok(())
444    }
445}
446
447/// Where a node keeps its light client database.
448#[cfg(feature = "client")]
449#[derive(Clone, Debug)]
450pub enum LightClientDbOptions {
451    Sqlite(LightClientSqliteOptions),
452    Postgres(Box<LightClientPostgresOptions>),
453}
454
455#[cfg(feature = "client")]
456impl Default for LightClientDbOptions {
457    fn default() -> Self {
458        Self::Sqlite(Default::default())
459    }
460}
461
462#[cfg(feature = "client")]
463impl LightClientDbOptions {
464    pub async fn connect(self) -> Result<SqlStorage> {
465        match self {
466            Self::Sqlite(opt) => opt.connect().await,
467            Self::Postgres(opt) => opt.connect().await,
468        }
469    }
470}
471
472/// [`Storage`] backed by SQLite or Postgres.
473#[cfg(feature = "client")]
474#[derive(Clone, Debug)]
475pub struct SqlStorage {
476    pool: DbPool,
477    num_leaves: u32,
478    /// Shared across all clones; all map operations are sync and never held across `.await`.
479    recency: Arc<Recency>,
480    _tmp: Option<Arc<TempDir>>,
481}
482
483#[cfg(feature = "client")]
484impl SqlStorage {
485    async fn new(pool: DbPool, num_leaves: u32, tmp: Option<Arc<TempDir>>) -> Result<Self> {
486        // Seed the tick counter so new ticks always exceed any value persisted in the DB.
487        let max_used = with_pool!(&pool, |p| {
488            let (max_used,): (i64,) = query_as("SELECT COALESCE(MAX(last_used), 0) FROM leaf")
489                .fetch_one(p)
490                .await?;
491            max_used
492        });
493        let recency = Arc::new(Recency {
494            next_tick: AtomicI64::new(max_used + 1),
495            dirty: Default::default(),
496            pool: pool.clone(),
497        });
498        Ok(Self {
499            pool,
500            num_leaves,
501            recency,
502            _tmp: tmp,
503        })
504    }
505}
506
507#[cfg(feature = "client")]
508impl Storage for SqlStorage {
509    async fn default() -> Result<Self> {
510        LightClientSqliteOptions::default().connect().await
511    }
512
513    async fn block_height(&self) -> Result<u64> {
514        with_pool!(&self.pool, |pool| {
515            let mut tx = pool.begin().await?;
516            let (height,): (i64,) = query_as("SELECT COALESCE(max(height) + 1, 0) FROM leaf")
517                .fetch_one(tx.as_mut())
518                .await?;
519            Ok(u64::try_from(height)?)
520        })
521    }
522
523    async fn leaf_upper_bound(
524        &self,
525        id: impl Into<LeafRequest> + Send,
526    ) -> Result<Option<LeafQueryData<SeqTypes>>> {
527        let id = id.into();
528        let row = with_pool!(&self.pool, |pool| {
529            let mut q = QueryBuilder::new("SELECT data FROM leaf WHERE ");
530            match id {
531                LeafRequest::Leaf(LeafId::Number(n)) | LeafRequest::Header(BlockId::Number(n)) => {
532                    q.push("height >= ")
533                        .push_bind(n as i64)
534                        .push(" ORDER BY height");
535                },
536                LeafRequest::Leaf(LeafId::Hash(h)) => {
537                    q.push("hash = ").push_bind(h.to_string());
538                },
539                LeafRequest::Header(BlockId::Hash(h)) => {
540                    q.push("block_hash = ").push_bind(h.to_string());
541                },
542                LeafRequest::Header(BlockId::PayloadHash(h)) => {
543                    q.push("payload_hash = ")
544                        .push_bind(h.to_string())
545                        .push(" ORDER BY height");
546                },
547            }
548            q.push(" LIMIT 1");
549
550            q.build_query_as::<(serde_json::Value,)>()
551                .fetch_optional(pool)
552                .await?
553        });
554        let Some((data,)) = row else {
555            return Ok(None);
556        };
557
558        let leaf: LeafQueryData<SeqTypes> = serde_json::from_value(data)?;
559        self.recency.touch(leaf.height() as i64);
560        Ok(Some(leaf))
561    }
562
563    async fn get_leaves_in_range(
564        &self,
565        start_height: u32,
566        end_height: u32,
567    ) -> Result<Vec<LeafQueryData<SeqTypes>>> {
568        with_pool!(&self.pool, |pool| {
569            query_as::<_, (i64, serde_json::Value)>(
570                "SELECT height, data FROM leaf WHERE height >= $1 AND height < $2 ORDER BY height",
571            )
572            .bind(start_height as i64)
573            .bind(end_height as i64)
574            .fetch_all(pool)
575            .await?
576            .into_iter()
577            .map(|(height, data)| {
578                let leaf = serde_json::from_value(data)?;
579                self.recency.touch(height);
580                Ok(leaf)
581            })
582            .collect::<Result<Vec<_>, serde_json::Error>>()
583            .map_err(anyhow::Error::new)
584        })
585    }
586
587    async fn insert_leaf(&self, leaf: LeafQueryData<SeqTypes>) -> Result<()> {
588        let height = leaf.height() as i64;
589        let hash = leaf.hash().to_string();
590        let block_hash = leaf.block_hash().to_string();
591        let payload_hash = leaf.payload_hash().to_string();
592        let data = serde_json::to_value(&leaf)?;
593
594        // Compute both values before the retry loop so retries reuse them (idempotent).
595        let pending = self.recency.drain();
596        let insert_tick = self.recency.next_tick.fetch_add(1, Ordering::Relaxed);
597
598        let result = WRITE_BACKOFF
599            .retry_if(
600                WRITE_RETRY_MAX,
601                |_| true,
602                || async {
603                    with_pool!(&self.pool, |pool| {
604                        let mut tx = pool.begin().await?;
605
606                        tracing::debug!(height, hash, "inserting leaf");
607                        query(
608                            "INSERT INTO leaf (height, hash, block_hash, payload_hash, data, \
609                             last_used) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (height) DO \
610                             UPDATE SET data = excluded.data, last_used = excluded.last_used",
611                        )
612                        .bind(height)
613                        .bind(&hash)
614                        .bind(&block_hash)
615                        .bind(&payload_hash)
616                        .bind(&data)
617                        .bind(insert_tick)
618                        .execute(tx.as_mut())
619                        .await
620                        .context("inserting new leaf")?;
621                        tracing::debug!(height, hash, "inserted leaf");
622
623                        // Flush pending recency touches accumulated since the last insert.
624                        for (h, tick) in &pending {
625                            query("UPDATE leaf SET last_used = $1 WHERE height = $2")
626                                .bind(tick)
627                                .bind(h)
628                                .execute(tx.as_mut())
629                                .await
630                                .context("flushing recency touch")?;
631                        }
632
633                        // GC: evict least-recently-used leaves until count <= num_leaves.
634                        let (num_leaves,): (i64,) = query_as("SELECT count(*) FROM leaf")
635                            .fetch_one(tx.as_mut())
636                            .await
637                            .context("counting leaves")?;
638                        let to_delete = num_leaves.saturating_sub(i64::from(self.num_leaves));
639                        if to_delete > 0 {
640                            tracing::debug!("garbage collecting {to_delete} leaves");
641                            let res = query(
642                                "DELETE FROM leaf WHERE height IN (SELECT height FROM leaf ORDER \
643                                 BY last_used ASC, height ASC LIMIT $1)",
644                            )
645                            .bind(to_delete)
646                            .execute(tx.as_mut())
647                            .await
648                            .context("deleting old leaves")?;
649                            tracing::debug!("deleted {} leaves", res.rows_affected());
650                        }
651
652                        tx.commit().await?;
653                        Ok(())
654                    })
655                },
656            )
657            .await;
658
659        if result.is_err() {
660            // Restore drained touches so a recently-read leaf is not wrongly evicted later.
661            // Use `or_insert` so any newer touch already recorded for that height wins.
662            let mut dirty = self.recency.dirty.lock().unwrap();
663            for (h, tick) in pending {
664                dirty.entry(h).or_insert(tick);
665            }
666        }
667
668        result
669    }
670
671    async fn stake_table_lower_bound(
672        &self,
673        epoch: EpochNumber,
674    ) -> Result<Option<(EpochNumber, StakeTableState, Version, Version)>> {
675        with_pool!(&self.pool, |pool| {
676            let mut tx = pool.begin().await?;
677
678            // The largest epoch number the database can represent is `i64::MAX`. Since we are only
679            // looking for a lower bound, if requested for an epoch number that can overflow, we can
680            // simply return the lower bound of `i64::MAX`.
681            let epoch = min(*epoch, i64::MAX as u64) as i64;
682
683            let Some((epoch, epoch_root_protocol_version, next_epoch_root_protocol_version)) =
684                query_as::<_, (i64, String, String)>(
685                    "SELECT epoch, epoch_root_protocol_version, next_epoch_root_protocol_version \
686                     FROM stake_table_epoch WHERE epoch <= $1 ORDER BY epoch DESC LIMIT 1",
687                )
688                .bind(epoch)
689                .fetch_optional(tx.as_mut())
690                .await
691                .context("loading epoch lower bound")?
692            else {
693                return Ok(None);
694            };
695            let epoch_root_protocol_version = versions::parse_version(&epoch_root_protocol_version)
696                .with_context(|| {
697                    format!(
698                        "parsing stored epoch root protocol version \
699                         {epoch_root_protocol_version:?} for epoch {epoch}"
700                    )
701                })?;
702            let next_epoch_root_protocol_version =
703                versions::parse_version(&next_epoch_root_protocol_version).with_context(|| {
704                    format!(
705                        "parsing stored next epoch root protocol version \
706                         {next_epoch_root_protocol_version:?} for epoch {epoch}"
707                    )
708                })?;
709
710            let validators = query_as::<_, (Value,)>(
711                "SELECT data FROM stake_table_validator WHERE epoch = $1 ORDER BY idx",
712            )
713            .bind(epoch)
714            .fetch(tx.as_mut())
715            .map_err(anyhow::Error::new)
716            .and_then(|(json,)| async move {
717                let validator: RegisteredValidator<PubKey> = serde_json::from_value(json)?;
718                Ok((validator.account, validator))
719            })
720            .try_collect()
721            .await
722            .context(format!("loading stake table for epoch {epoch}"))?;
723
724            let validator_exits =
725                query_as::<_, (String,)>("SELECT address FROM stake_table_exit WHERE epoch <= $1")
726                    .bind(epoch)
727                    .fetch(tx.as_mut())
728                    .map_err(anyhow::Error::new)
729                    .and_then(|(s,)| async move { Ok(Address::from_str(&s)?) })
730                    .try_collect()
731                    .await
732                    .context(format!("loading validator exits for epoch {epoch}"))?;
733
734            let used_bls_keys =
735                query_as::<_, (String,)>("SELECT key FROM stake_table_bls_key WHERE epoch <= $1")
736                    .bind(epoch)
737                    .fetch(tx.as_mut())
738                    .map_err(anyhow::Error::new)
739                    .and_then(|(s,)| async move { Ok(PubKey::from_str(&s)?) })
740                    .try_collect()
741                    .await
742                    .context(format!("loading BLS keys for epoch {epoch}"))?;
743
744            let used_schnorr_keys = query_as::<_, (String,)>(
745                "SELECT key FROM stake_table_schnorr_key WHERE epoch <= $1",
746            )
747            .bind(epoch)
748            .fetch(tx.as_mut())
749            .map_err(anyhow::Error::new)
750            .and_then(|(s,)| async move { Ok(StateVerKey::from_str(&s)?) })
751            .try_collect()
752            .await
753            .context(format!("loading Schnorr keys for epoch {epoch}"))?;
754
755            let used_x25519_keys = query_as::<_, (String,)>(
756                "SELECT key FROM stake_table_x25519_key WHERE epoch <= $1",
757            )
758            .bind(epoch)
759            .fetch(tx.as_mut())
760            .map_err(anyhow::Error::new)
761            .and_then(|(s,)| async move { Ok(x25519::PublicKey::from_str(&s)?) })
762            .try_collect()
763            .await
764            .context(format!("loading x25519 keys for epoch {epoch}"))?;
765
766            Ok(Some((
767                EpochNumber::new(epoch as u64),
768                StakeTableState::new(
769                    validators,
770                    validator_exits,
771                    used_bls_keys,
772                    used_schnorr_keys,
773                    used_x25519_keys,
774                ),
775                epoch_root_protocol_version,
776                next_epoch_root_protocol_version,
777            )))
778        })
779    }
780
781    async fn insert_stake_table(
782        &self,
783        epoch: EpochNumber,
784        stake_table: &StakeTableState,
785        epoch_root_protocol_version: Version,
786        next_epoch_root_protocol_version: Version,
787    ) -> Result<()> {
788        let epoch = i64::try_from(*epoch).context("epoch overflow")?;
789        let epoch_root_protocol_version_str = epoch_root_protocol_version.to_string();
790        let next_epoch_root_protocol_version_str = next_epoch_root_protocol_version.to_string();
791        let validators = stake_table
792            .validators()
793            .values()
794            .cloned()
795            .map(serde_json::to_value)
796            .collect::<Result<Vec<_>, _>>()?;
797
798        WRITE_BACKOFF
799            .retry_if(
800                WRITE_RETRY_MAX,
801                |_| true,
802                || async {
803                    with_pool!(&self.pool, |pool| {
804                        let mut tx = pool.begin().await?;
805
806                        // Record that the stake table for this epoch is available, along with the
807                        // versions of the epoch root headers in epochs `e-2` and `e-1` (snapshot points
808                        // for `e` and `e+1`). The stake table for an epoch is immutable, so if the row
809                        // already exists (e.g. a concurrent or repeated catchup) the whole operation is
810                        // a no-op: skip the remaining inserts to avoid spurious UNIQUE-constraint
811                        // retries.
812                        let inserted = query(
813                            "INSERT INTO stake_table_epoch (epoch, epoch_root_protocol_version, \
814                             next_epoch_root_protocol_version) VALUES ($1, $2, $3) ON CONFLICT \
815                             (epoch) DO NOTHING",
816                        )
817                        .bind(epoch)
818                        .bind(&epoch_root_protocol_version_str)
819                        .bind(&next_epoch_root_protocol_version_str)
820                        .execute(tx.as_mut())
821                        .await
822                        .context(format!(
823                            "recording stake table availability for epoch {epoch}"
824                        ))?;
825                        if inserted.rows_affected() == 0 {
826                            return Ok(());
827                        }
828
829                        QueryBuilder::new("INSERT INTO stake_table_validator (epoch, idx, data) ")
830                            .push_values(validators.iter().enumerate(), |mut q, (i, data)| {
831                                q.push_bind(epoch).push_bind(i as i64).push_bind(data);
832                            })
833                            .build()
834                            .execute(tx.as_mut())
835                            .await
836                            .context(format!("inserting validators for epoch {epoch}"))?;
837
838                        // Insert only newly used BLS keys.
839                        QueryBuilder::new("INSERT INTO stake_table_bls_key (epoch, key) ")
840                            .push_values(stake_table.used_bls_keys(), |mut q, key| {
841                                q.push_bind(epoch).push_bind(key.to_string());
842                            })
843                            // If we insert keys out of order, make sure `epoch` reflects the earliest time
844                            // when this key was added to the state.
845                            .push(
846                                " ON CONFLICT (key) DO UPDATE SET epoch = excluded.epoch WHERE \
847                                 excluded.epoch < stake_table_bls_key.epoch",
848                            )
849                            .build()
850                            .execute(tx.as_mut())
851                            .await
852                            .context(format!("inserting newly used BLS keys for epoch {epoch}"))?;
853
854                        // Insert only newly used Schnorr keys.
855                        QueryBuilder::new("INSERT INTO stake_table_schnorr_key (epoch, key) ")
856                            .push_values(stake_table.used_schnorr_keys(), |mut q, key| {
857                                q.push_bind(epoch).push_bind(key.to_string());
858                            })
859                            // If we insert keys out of order, make sure `epoch` reflects the earliest time
860                            // when this key was added to the state.
861                            .push(
862                                " ON CONFLICT (key) DO UPDATE SET epoch = excluded.epoch WHERE \
863                                 excluded.epoch < stake_table_schnorr_key.epoch",
864                            )
865                            .build()
866                            .execute(tx.as_mut())
867                            .await
868                            .context(format!(
869                                "inserting newly used Schnorr keys for epoch {epoch}"
870                            ))?;
871
872                        // Insert only newly used x25519 keys.
873                        if !stake_table.used_x25519_keys().is_empty() {
874                            QueryBuilder::new("INSERT INTO stake_table_x25519_key (epoch, key) ")
875                                .push_values(stake_table.used_x25519_keys(), |mut q, key| {
876                                    q.push_bind(epoch).push_bind(key.to_string());
877                                })
878                                // If we insert keys out of order, make sure `epoch` reflects the earliest
879                                // time when this key was added to the state.
880                                .push(
881                                    " ON CONFLICT (key) DO UPDATE SET epoch = excluded.epoch \
882                                     WHERE excluded.epoch < stake_table_x25519_key.epoch",
883                                )
884                                .build()
885                                .execute(tx.as_mut())
886                                .await
887                                .context(format!(
888                                    "inserting newly used x25519 keys for epoch {epoch}"
889                                ))?;
890                        }
891
892                        // Insert only the new validator exits.
893                        if !stake_table.validator_exits().is_empty() {
894                            QueryBuilder::new("INSERT INTO stake_table_exit (epoch, address) ")
895                                .push_values(stake_table.validator_exits(), |mut q, address| {
896                                    q.push_bind(epoch).push_bind(address.to_string());
897                                })
898                                // If we insert exits out of order, make sure `epoch` reflects the earliest
899                                // time when this exit was added to the state.
900                                .push(
901                                    " ON CONFLICT (address) DO UPDATE SET epoch = excluded.epoch \
902                                     WHERE excluded.epoch < stake_table_exit.epoch",
903                                )
904                                .build()
905                                .execute(tx.as_mut())
906                                .await
907                                .context(format!(
908                                    "inserting new validator exits for epoch {epoch}"
909                                ))?;
910                        }
911
912                        tx.commit().await?;
913                        Ok(())
914                    })
915                },
916            )
917            .await
918    }
919}
920
921#[cfg(test)]
922mod test {
923    #[cfg(unix)]
924    use std::os::unix::fs::PermissionsExt;
925
926    use hotshot_query_service::data_source::storage::sql::testing::TmpPostgres;
927    use pretty_assertions::assert_eq;
928    use sqlx::sqlite::SqliteConnectOptions;
929    use tempfile::tempdir;
930    use versions::{EPOCH_VERSION, NEW_PROTOCOL_VERSION};
931
932    use super::*;
933    use crate::testing::{leaf_chain, random_validator};
934
935    #[derive(Clone, Copy, Debug)]
936    enum Backend {
937        Sqlite,
938        Postgres,
939    }
940
941    impl Backend {
942        async fn temp_db(self) -> TestDb {
943            match self {
944                Self::Sqlite => TestDb::Sqlite(tempdir().unwrap()),
945                Self::Postgres => TestDb::Postgres(TmpPostgres::init().await),
946            }
947        }
948
949        /// Hold the returned [`TestDb`] for the whole test, since dropping it deletes the
950        /// database.
951        async fn open(self, num_leaves: u32) -> (SqlStorage, TestDb) {
952            let tmp = self.temp_db().await;
953            (tmp.connect(num_leaves).await, tmp)
954        }
955    }
956
957    /// A database that outlives the [`SqlStorage`] handles connected to it, so a test can drop a
958    /// handle and reconnect as a restarted node would.
959    enum TestDb {
960        Sqlite(tempfile::TempDir),
961        Postgres(TmpPostgres),
962    }
963
964    impl TestDb {
965        async fn connect(&self, num_leaves: u32) -> SqlStorage {
966            match self {
967                Self::Sqlite(dir) => LightClientSqliteOptions {
968                    lc_path: Some(dir.path().join("lc.db")),
969                    num_leaves,
970                    ..Default::default()
971                }
972                .connect()
973                .await
974                .unwrap(),
975                Self::Postgres(tmp) => LightClientPostgresOptions {
976                    connect: PgConnectOptions::new()
977                        .host(&tmp.host())
978                        .port(tmp.port())
979                        .username("postgres")
980                        .password("password"),
981                    num_connections: 5,
982                    num_leaves,
983                }
984                .connect()
985                .await
986                .unwrap(),
987            }
988        }
989    }
990
991    #[rstest_reuse::template]
992    #[rstest::rstest]
993    #[case::sqlite(Backend::Sqlite)]
994    #[case::postgres(Backend::Postgres)]
995    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
996    async fn backends(#[case] backend: Backend) {}
997
998    #[test_log::test(rstest_reuse::apply(backends))]
999    async fn test_survives_reconnect(#[case] backend: Backend) {
1000        let tmp = backend.temp_db().await;
1001        let leaf = leaf_chain(0..1, EPOCH_VERSION).await.remove(0);
1002        let state = random_stake_table();
1003        {
1004            let db = tmp.connect(10).await;
1005            db.insert_leaf(leaf.clone()).await.unwrap();
1006            db.insert_stake_table(EpochNumber::new(3), &state, EPOCH_VERSION, EPOCH_VERSION)
1007                .await
1008                .unwrap();
1009        }
1010
1011        let db = tmp.connect(10).await;
1012        assert_eq!(
1013            db.leaf_upper_bound(LeafId::Number(0)).await.unwrap(),
1014            Some(leaf)
1015        );
1016        assert_eq!(
1017            db.stake_table_lower_bound(EpochNumber::new(5))
1018                .await
1019                .unwrap()
1020                .unwrap(),
1021            (EpochNumber::new(3), state, EPOCH_VERSION, EPOCH_VERSION)
1022        );
1023    }
1024
1025    #[tokio::test]
1026    #[test_log::test]
1027    async fn test_default_storage_survives_connection_churn() {
1028        let db = SqlStorage::default().await.unwrap();
1029
1030        {
1031            let DbPool::Sqlite(pool) = &db.pool else {
1032                unreachable!("default storage is SQLite");
1033            };
1034            let conn = pool.acquire().await.unwrap();
1035            drop(conn);
1036        }
1037
1038        let leaf = leaf_chain(0..1, EPOCH_VERSION).await.remove(0);
1039        db.insert_leaf(leaf).await.unwrap();
1040        assert_eq!(db.block_height().await.unwrap(), 1);
1041    }
1042
1043    #[tokio::test]
1044    #[test_log::test]
1045    async fn test_default_storage_survives_idle_reap() {
1046        use std::time::Duration;
1047
1048        let db = SqlStorage::default().await.unwrap();
1049        let path = db
1050            ._tmp
1051            .as_ref()
1052            .expect("default storage must own a tempdir")
1053            .path()
1054            .join("lc.db");
1055
1056        let opt = SqliteConnectOptions::new()
1057            .filename(&path)
1058            .create_if_missing(false);
1059        let pool = SqlitePoolOptions::default()
1060            .max_connections(5)
1061            .min_connections(0)
1062            .idle_timeout(Some(Duration::from_millis(50)))
1063            .connect_with(opt)
1064            .await
1065            .unwrap();
1066
1067        let (height,): (i64,) = sqlx::query_as("SELECT COALESCE(max(height) + 1, 0) FROM leaf")
1068            .fetch_one(&pool)
1069            .await
1070            .unwrap();
1071        assert_eq!(height, 0);
1072
1073        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1074        while pool.size() > 0 && tokio::time::Instant::now() < deadline {
1075            tokio::time::sleep(Duration::from_millis(50)).await;
1076        }
1077        assert_eq!(pool.size(), 0, "pool did not reap idle connections in time");
1078
1079        let (height,): (i64,) = sqlx::query_as("SELECT COALESCE(max(height) + 1, 0) FROM leaf")
1080            .fetch_one(&pool)
1081            .await
1082            .expect("schema must survive the pool reaping idle connections");
1083        assert_eq!(height, 0);
1084
1085        drop(db);
1086    }
1087
1088    // Regression: `leaf_upper_bound` previously did a SELECT then an UPDATE
1089    // (LRU recency bump) inside one write transaction, so every read took the
1090    // WAL write lock and contended with concurrent inserts under a full-history
1091    // backfill, producing SQLITE_BUSY. The read path is now a pure SELECT.
1092    // The cache is smaller than the chain, so on Postgres concurrent inserts also
1093    // race their recency updates against each other's eviction, which can deadlock
1094    // and must be absorbed by the write retries.
1095    #[test_log::test(rstest_reuse::apply(backends))]
1096    async fn test_concurrent_reads_do_not_lock(#[case] backend: Backend) {
1097        let (db, _tmp) = backend.open(8).await;
1098        let db = Arc::new(db);
1099        let leaves = leaf_chain(0..40, EPOCH_VERSION).await;
1100
1101        let mut tasks = Vec::new();
1102        for leaf in leaves {
1103            let db = db.clone();
1104            tasks.push(tokio::spawn(async move {
1105                db.insert_leaf(leaf).await.map(|_| ())
1106            }));
1107        }
1108        for h in 0..40usize {
1109            let read_db = db.clone();
1110            tasks.push(tokio::spawn(async move {
1111                read_db
1112                    .leaf_upper_bound(LeafId::Number(h))
1113                    .await
1114                    .map(|_| ())
1115            }));
1116            let height_db = db.clone();
1117            tasks.push(tokio::spawn(async move {
1118                height_db.block_height().await.map(|_| ())
1119            }));
1120        }
1121
1122        for task in tasks {
1123            task.await
1124                .unwrap()
1125                .expect("concurrent op must not fail with a database lock error");
1126        }
1127    }
1128
1129    #[cfg(unix)]
1130    #[tokio::test]
1131    #[test_log::test]
1132    async fn test_default_storage_tempdir_is_owner_only() {
1133        let db = SqlStorage::default().await.unwrap();
1134        let dir = db
1135            ._tmp
1136            .as_ref()
1137            .expect("default storage must own a tempdir");
1138        let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
1139        assert_eq!(mode, 0o700, "tempdir must be owner-only, got {mode:o}");
1140    }
1141
1142    #[tokio::test]
1143    #[test_log::test]
1144    async fn test_file_backed_creates_parent_dir() {
1145        let dir = tempdir().unwrap();
1146        let path = dir.path().join("nested").join("subdir").join("lc.db");
1147        let db = LightClientSqliteOptions {
1148            lc_path: Some(path.clone()),
1149            ..Default::default()
1150        }
1151        .connect()
1152        .await
1153        .unwrap();
1154        assert_eq!(db.block_height().await.unwrap(), 0);
1155        assert!(path.exists(), "sqlite file should have been created");
1156    }
1157
1158    #[test_log::test(rstest_reuse::apply(backends))]
1159    async fn test_block_height(#[case] backend: Backend) {
1160        let (db, _guard) = backend.open(100).await;
1161
1162        // Test with empty db.
1163        assert_eq!(db.block_height().await.unwrap(), 0);
1164
1165        // Test with nonconsecutive leaves.
1166        let leaf = leaf_chain(100..101, EPOCH_VERSION).await.remove(0);
1167        db.insert_leaf(leaf).await.unwrap();
1168        assert_eq!(db.block_height().await.unwrap(), 101);
1169    }
1170
1171    #[test_log::test(rstest_reuse::apply(backends))]
1172    async fn test_leaf_upper_bound_exact(#[case] backend: Backend) {
1173        let (db, _guard) = backend.open(100).await;
1174
1175        let leaf = leaf_chain(0..1, EPOCH_VERSION).await.remove(0);
1176        db.insert_leaf(leaf.clone()).await.unwrap();
1177        assert_eq!(
1178            db.leaf_upper_bound(LeafId::Number(0))
1179                .await
1180                .unwrap()
1181                .unwrap(),
1182            leaf
1183        );
1184        assert_eq!(
1185            db.leaf_upper_bound(LeafId::Hash(leaf.hash()))
1186                .await
1187                .unwrap()
1188                .unwrap(),
1189            leaf
1190        );
1191        assert_eq!(
1192            db.leaf_upper_bound(BlockId::Number(0))
1193                .await
1194                .unwrap()
1195                .unwrap(),
1196            leaf
1197        );
1198        assert_eq!(
1199            db.leaf_upper_bound(BlockId::Hash(leaf.block_hash()))
1200                .await
1201                .unwrap()
1202                .unwrap(),
1203            leaf
1204        );
1205        assert_eq!(
1206            db.leaf_upper_bound(BlockId::PayloadHash(leaf.payload_hash()))
1207                .await
1208                .unwrap()
1209                .unwrap()
1210                .payload_hash(),
1211            leaf.payload_hash()
1212        );
1213    }
1214
1215    #[test_log::test(rstest_reuse::apply(backends))]
1216    async fn test_leaf_upper_bound_loose(#[case] backend: Backend) {
1217        let (db, _guard) = backend.open(100).await;
1218
1219        let leaves = leaf_chain(0..=1, EPOCH_VERSION).await;
1220        db.insert_leaf(leaves[1].clone()).await.unwrap();
1221        assert_eq!(
1222            db.leaf_upper_bound(LeafId::Number(0))
1223                .await
1224                .unwrap()
1225                .unwrap(),
1226            leaves[1]
1227        );
1228        // Searching by hash either gives an exact match or fails, there is no way of "upper
1229        // bounding" a hash.
1230        assert_eq!(
1231            db.leaf_upper_bound(LeafId::Hash(leaves[0].hash()))
1232                .await
1233                .unwrap(),
1234            None
1235        );
1236        assert_eq!(
1237            db.leaf_upper_bound(BlockId::Hash(leaves[0].block_hash()))
1238                .await
1239                .unwrap(),
1240            None
1241        );
1242    }
1243
1244    #[test_log::test(rstest_reuse::apply(backends))]
1245    async fn test_leaf_upper_bound_least_upper_bound(#[case] backend: Backend) {
1246        let (db, _guard) = backend.open(100).await;
1247
1248        let leaves = leaf_chain(0..=2, EPOCH_VERSION).await;
1249        db.insert_leaf(leaves[2].clone()).await.unwrap();
1250        db.insert_leaf(leaves[1].clone()).await.unwrap();
1251        assert_eq!(
1252            db.leaf_upper_bound(LeafId::Number(0))
1253                .await
1254                .unwrap()
1255                .unwrap(),
1256            leaves[1]
1257        );
1258    }
1259
1260    #[test_log::test(rstest_reuse::apply(backends))]
1261    async fn test_leaf_upper_bound_not_found(#[case] backend: Backend) {
1262        let (db, _guard) = backend.open(100).await;
1263
1264        let leaves = leaf_chain(0..=1, EPOCH_VERSION).await;
1265        db.insert_leaf(leaves[0].clone()).await.unwrap();
1266        assert_eq!(db.leaf_upper_bound(LeafId::Number(1)).await.unwrap(), None);
1267        assert_eq!(
1268            db.leaf_upper_bound(LeafId::Hash(leaves[1].hash()))
1269                .await
1270                .unwrap(),
1271            None
1272        );
1273    }
1274
1275    #[test_log::test(rstest_reuse::apply(backends))]
1276    async fn test_gc_last_inserted(#[case] backend: Backend) {
1277        let (db, _guard) = backend.open(1).await;
1278
1279        let leaves = leaf_chain(0..=1, EPOCH_VERSION).await;
1280        db.insert_leaf(leaves[1].clone()).await.unwrap();
1281        db.insert_leaf(leaves[0].clone()).await.unwrap();
1282
1283        assert_eq!(
1284            db.leaf_upper_bound(LeafId::Number(0))
1285                .await
1286                .unwrap()
1287                .unwrap(),
1288            leaves[0]
1289        );
1290        assert_eq!(db.leaf_upper_bound(LeafId::Number(1)).await.unwrap(), None);
1291    }
1292
1293    // LRU GC: leaf touched by a read is kept; untouched leaf is evicted.
1294    #[test_log::test(rstest_reuse::apply(backends))]
1295    async fn test_gc_evicts_least_recently_used(#[case] backend: Backend) {
1296        let (db, _guard) = backend.open(2).await;
1297
1298        let leaves = leaf_chain(0..=2, EPOCH_VERSION).await;
1299        db.insert_leaf(leaves[0].clone()).await.unwrap();
1300        db.insert_leaf(leaves[1].clone()).await.unwrap();
1301
1302        // Touch leaf 0 via a read, making leaf 1 the least-recently-used.
1303        db.leaf_upper_bound(LeafId::Number(0)).await.unwrap();
1304
1305        // Insert leaf 2; GC evicts 1 (LRU) to bring count back to 2.
1306        db.insert_leaf(leaves[2].clone()).await.unwrap();
1307
1308        // Check exact presence/absence via hash lookups (the Number variant is an
1309        // upper bound, so it would return a higher leaf instead of None).
1310        assert_eq!(
1311            db.leaf_upper_bound(LeafId::Hash(leaves[0].hash()))
1312                .await
1313                .unwrap(),
1314            Some(leaves[0].clone()),
1315            "leaf 0 was recently read and must be kept"
1316        );
1317        assert_eq!(
1318            db.leaf_upper_bound(LeafId::Hash(leaves[1].hash()))
1319                .await
1320                .unwrap(),
1321            None,
1322            "leaf 1 was least-recently-used and must be evicted"
1323        );
1324        assert_eq!(
1325            db.leaf_upper_bound(LeafId::Hash(leaves[2].hash()))
1326                .await
1327                .unwrap(),
1328            Some(leaves[2].clone()),
1329            "leaf 2 was just inserted and must be kept"
1330        );
1331    }
1332
1333    // Recency (last_used) must survive a process restart so GC after reopen
1334    // still honours access history. Specifically: `next_tick` must be seeded
1335    // above the persisted MAX(last_used) so that a touch recorded after reopen
1336    // outranks all pre-restart last_used values.
1337    #[test_log::test(rstest_reuse::apply(backends))]
1338    async fn test_recency_survives_restart(#[case] backend: Backend) {
1339        let tmp = backend.temp_db().await;
1340        let leaves = leaf_chain(0..=3, EPOCH_VERSION).await;
1341
1342        {
1343            let db = tmp.connect(2).await;
1344            db.insert_leaf(leaves[0].clone()).await.unwrap();
1345            db.insert_leaf(leaves[1].clone()).await.unwrap();
1346
1347            // Touch leaf 0 so it has a higher last_used than leaf 1.
1348            db.leaf_upper_bound(LeafId::Number(0)).await.unwrap();
1349
1350            // Insert leaf 2; GC evicts leaf 1 (LRU); flushes the touch for leaf 0.
1351            // After GC: {leaf 0, leaf 2} remain.
1352            db.insert_leaf(leaves[2].clone()).await.unwrap();
1353        }
1354
1355        // Reopen. next_tick seeds from MAX(last_used) + 1, so any post-reopen
1356        // tick is strictly greater than all persisted last_used values.
1357        {
1358            let db = tmp.connect(2).await;
1359
1360            // Touch leaf 2 after reopen; its tick now exceeds all pre-restart last_used.
1361            db.leaf_upper_bound(LeafId::Hash(leaves[2].hash()))
1362                .await
1363                .unwrap();
1364
1365            // Insert leaf 3; GC evicts the leaf with the lowest last_used (leaf 0,
1366            // whose pre-restart tick is lower than leaf 2's post-reopen tick).
1367            // After GC: {leaf 2, leaf 3} remain.
1368            db.insert_leaf(leaves[3].clone()).await.unwrap();
1369
1370            assert_eq!(
1371                db.leaf_upper_bound(LeafId::Hash(leaves[0].hash()))
1372                    .await
1373                    .unwrap(),
1374                None,
1375                "leaf 0 had the lowest persisted last_used and must be evicted"
1376            );
1377            assert_eq!(
1378                db.leaf_upper_bound(LeafId::Hash(leaves[2].hash()))
1379                    .await
1380                    .unwrap(),
1381                Some(leaves[2].clone()),
1382                "leaf 2 was touched after reopen and must survive"
1383            );
1384            assert_eq!(
1385                db.leaf_upper_bound(LeafId::Hash(leaves[3].hash()))
1386                    .await
1387                    .unwrap(),
1388                Some(leaves[3].clone()),
1389                "leaf 3 was just inserted and must survive"
1390            );
1391        }
1392    }
1393
1394    // The shutdown flush must persist read-path touches when no insert follows the
1395    // final reads, so GC after restart honours them. `Drop` spawns the flush as a
1396    // detached task inside a runtime, so the test awaits the same flush directly to
1397    // stay deterministic; the subsequent drop is then a no-op.
1398    #[test_log::test(rstest_reuse::apply(backends))]
1399    async fn test_recency_flushed_on_shutdown_without_insert(#[case] backend: Backend) {
1400        let tmp = backend.temp_db().await;
1401        let leaves = leaf_chain(0..=2, EPOCH_VERSION).await;
1402
1403        {
1404            let db = tmp.connect(2).await;
1405            db.insert_leaf(leaves[0].clone()).await.unwrap();
1406            db.insert_leaf(leaves[1].clone()).await.unwrap();
1407
1408            // Touch leaf 0 via a read; recorded only in memory.
1409            db.leaf_upper_bound(LeafId::Number(0)).await.unwrap();
1410
1411            // No insert follows; run the shutdown flush explicitly and await it.
1412            Recency::flush(db.pool.clone(), db.recency.drain()).await;
1413        }
1414
1415        // Reopen and insert leaf 2; GC evicts the lowest last_used. With the touch
1416        // flushed, leaf 0 outranks leaf 1, so leaf 1 is evicted.
1417        {
1418            let db = tmp.connect(2).await;
1419            db.insert_leaf(leaves[2].clone()).await.unwrap();
1420
1421            assert_eq!(
1422                db.leaf_upper_bound(LeafId::Hash(leaves[0].hash()))
1423                    .await
1424                    .unwrap(),
1425                Some(leaves[0].clone()),
1426                "leaf 0 was touched before the shutdown flush and must survive"
1427            );
1428            assert_eq!(
1429                db.leaf_upper_bound(LeafId::Hash(leaves[1].hash()))
1430                    .await
1431                    .unwrap(),
1432                None,
1433                "leaf 1 was least-recently-used and must be evicted"
1434            );
1435        }
1436    }
1437
1438    #[test_log::test(rstest_reuse::apply(backends))]
1439    async fn test_get_leaves_in_range(#[case] backend: Backend) {
1440        let (db, _guard) = backend.open(100).await;
1441
1442        let leaves = leaf_chain(0..5, EPOCH_VERSION).await;
1443        for leaf in &leaves {
1444            db.insert_leaf(leaf.clone()).await.unwrap();
1445        }
1446
1447        let fetched = db.get_leaves_in_range(1, 4).await.unwrap();
1448        assert_eq!(fetched, leaves[1..4]);
1449    }
1450
1451    #[test_log::test(rstest_reuse::apply(backends))]
1452    async fn test_get_leaves_in_range_not_found(#[case] backend: Backend) {
1453        let (db, _guard) = backend.open(100).await;
1454
1455        let leaves = leaf_chain(0..3, EPOCH_VERSION).await;
1456        for leaf in &leaves {
1457            db.insert_leaf(leaf.clone()).await.unwrap();
1458        }
1459
1460        let fetched = db.get_leaves_in_range(3, 5).await.unwrap();
1461        assert!(fetched.is_empty());
1462    }
1463
1464    #[test_log::test(rstest_reuse::apply(backends))]
1465    async fn test_insert_stake_table_idempotent(#[case] backend: Backend) {
1466        let (db, _guard) = backend.open(100).await;
1467
1468        let epoch = EpochNumber::new(1);
1469        let state = random_stake_table();
1470        db.insert_stake_table(epoch, &state, EPOCH_VERSION, EPOCH_VERSION)
1471            .await
1472            .unwrap();
1473        // Re-recording the same epoch is a no-op rather than a UNIQUE-constraint failure.
1474        db.insert_stake_table(epoch, &state, EPOCH_VERSION, EPOCH_VERSION)
1475            .await
1476            .unwrap();
1477        assert_eq!(
1478            db.stake_table_lower_bound(epoch).await.unwrap().unwrap(),
1479            (epoch, state, EPOCH_VERSION, EPOCH_VERSION)
1480        );
1481    }
1482
1483    #[test_log::test(rstest_reuse::apply(backends))]
1484    async fn test_stake_table_lower_bound_exact(#[case] backend: Backend) {
1485        let (db, _guard) = backend.open(100).await;
1486
1487        let epoch = EpochNumber::new(1);
1488        let state = random_stake_table();
1489        db.insert_stake_table(epoch, &state, EPOCH_VERSION, EPOCH_VERSION)
1490            .await
1491            .unwrap();
1492        assert_eq!(
1493            db.stake_table_lower_bound(epoch).await.unwrap().unwrap(),
1494            (epoch, state, EPOCH_VERSION, EPOCH_VERSION)
1495        );
1496    }
1497
1498    #[test_log::test(rstest_reuse::apply(backends))]
1499    async fn test_stake_table_lower_bound_loose(#[case] backend: Backend) {
1500        let (db, _guard) = backend.open(100).await;
1501
1502        let epoch = EpochNumber::new(1);
1503        let state = random_stake_table();
1504        db.insert_stake_table(epoch, &state, EPOCH_VERSION, EPOCH_VERSION)
1505            .await
1506            .unwrap();
1507        assert_eq!(
1508            db.stake_table_lower_bound(epoch + 1)
1509                .await
1510                .unwrap()
1511                .unwrap(),
1512            (epoch, state, EPOCH_VERSION, EPOCH_VERSION)
1513        );
1514    }
1515
1516    #[test_log::test(rstest_reuse::apply(backends))]
1517    async fn test_stake_table_lower_bound_greatest_lower_bound(#[case] backend: Backend) {
1518        let (db, _guard) = backend.open(100).await;
1519
1520        let state1 = random_stake_table();
1521        let state2 = chain_stake_table(&state1);
1522        db.insert_stake_table(EpochNumber::new(1), &state1, EPOCH_VERSION, EPOCH_VERSION)
1523            .await
1524            .unwrap();
1525        db.insert_stake_table(EpochNumber::new(2), &state2, EPOCH_VERSION, EPOCH_VERSION)
1526            .await
1527            .unwrap();
1528
1529        assert_eq!(
1530            db.stake_table_lower_bound(EpochNumber::new(3))
1531                .await
1532                .unwrap()
1533                .unwrap(),
1534            (EpochNumber::new(2), state2, EPOCH_VERSION, EPOCH_VERSION)
1535        );
1536    }
1537
1538    #[test_log::test(rstest_reuse::apply(backends))]
1539    async fn test_stake_table_lower_bound_not_found(#[case] backend: Backend) {
1540        let (db, _guard) = backend.open(100).await;
1541        db.insert_stake_table(
1542            EpochNumber::new(2),
1543            &random_stake_table(),
1544            EPOCH_VERSION,
1545            EPOCH_VERSION,
1546        )
1547        .await
1548        .unwrap();
1549        assert_eq!(
1550            db.stake_table_lower_bound(EpochNumber::new(1))
1551                .await
1552                .unwrap(),
1553            None
1554        );
1555    }
1556
1557    #[test_log::test(rstest_reuse::apply(backends))]
1558    async fn test_stake_table_keeps_every_epoch(#[case] backend: Backend) {
1559        let (db, _guard) = backend.open(100).await;
1560
1561        let mut states = Vec::from([random_stake_table()]);
1562        for _ in 1..150 {
1563            states.push(chain_stake_table(states.last().unwrap()));
1564        }
1565        for (epoch, state) in (1..).zip(&states) {
1566            db.insert_stake_table(EpochNumber::new(epoch), state, EPOCH_VERSION, EPOCH_VERSION)
1567                .await
1568                .unwrap();
1569        }
1570
1571        for (epoch, state) in (1..).zip(states) {
1572            assert_eq!(
1573                db.stake_table_lower_bound(EpochNumber::new(epoch))
1574                    .await
1575                    .unwrap()
1576                    .unwrap(),
1577                (EpochNumber::new(epoch), state, EPOCH_VERSION, EPOCH_VERSION)
1578            );
1579        }
1580    }
1581
1582    #[test_log::test(rstest_reuse::apply(backends))]
1583    async fn test_stake_table_insert_out_of_order(#[case] backend: Backend) {
1584        let (db, _guard) = backend.open(100).await;
1585
1586        let state1 = random_stake_table();
1587        let state2 = chain_stake_table(&state1);
1588        db.insert_stake_table(EpochNumber::new(2), &state2, EPOCH_VERSION, EPOCH_VERSION)
1589            .await
1590            .unwrap();
1591        db.insert_stake_table(EpochNumber::new(1), &state1, EPOCH_VERSION, EPOCH_VERSION)
1592            .await
1593            .unwrap();
1594
1595        assert_eq!(
1596            db.stake_table_lower_bound(EpochNumber::new(1))
1597                .await
1598                .unwrap()
1599                .unwrap(),
1600            (EpochNumber::new(1), state1, EPOCH_VERSION, EPOCH_VERSION)
1601        );
1602        assert_eq!(
1603            db.stake_table_lower_bound(EpochNumber::new(2))
1604                .await
1605                .unwrap()
1606                .unwrap(),
1607            (EpochNumber::new(2), state2, EPOCH_VERSION, EPOCH_VERSION)
1608        );
1609    }
1610
1611    #[test_log::test(rstest_reuse::apply(backends))]
1612    async fn test_stake_table_epoch_root_protocol_version_roundtrip(#[case] backend: Backend) {
1613        let (db, _guard) = backend.open(100).await;
1614
1615        let epoch = EpochNumber::new(1);
1616        let state = random_stake_table();
1617        db.insert_stake_table(epoch, &state, NEW_PROTOCOL_VERSION, EPOCH_VERSION)
1618            .await
1619            .unwrap();
1620        let (loaded_epoch, loaded_state, loaded_version, loaded_next_version) =
1621            db.stake_table_lower_bound(epoch).await.unwrap().unwrap();
1622        assert_eq!(loaded_epoch, epoch);
1623        assert_eq!(loaded_state, state);
1624        assert_eq!(loaded_version, NEW_PROTOCOL_VERSION);
1625        assert_eq!(loaded_next_version, EPOCH_VERSION);
1626    }
1627
1628    /// Regression: storage previously dropped `used_x25519_keys` on round trip,
1629    /// so a reloaded `StakeTableState::commit()` diverged from the proposer's
1630    /// hash once any V3 events had been applied.
1631    #[test_log::test(rstest_reuse::apply(backends))]
1632    async fn test_stake_table_x25519_keys_round_trip(#[case] backend: Backend) {
1633        use committable::Committable;
1634
1635        let (db, _guard) = backend.open(100).await;
1636        let epoch = EpochNumber::new(1);
1637        let state = random_stake_table();
1638        assert!(
1639            !state.used_x25519_keys().is_empty(),
1640            "random_stake_table must populate used_x25519_keys for this test to be meaningful"
1641        );
1642
1643        db.insert_stake_table(epoch, &state, NEW_PROTOCOL_VERSION, NEW_PROTOCOL_VERSION)
1644            .await
1645            .unwrap();
1646        let (_, loaded, ..) = db.stake_table_lower_bound(epoch).await.unwrap().unwrap();
1647
1648        assert_eq!(loaded.used_x25519_keys(), state.used_x25519_keys());
1649        assert_eq!(loaded.commit(), state.commit());
1650    }
1651
1652    /// Make a stake table state with all fields populated.
1653    fn random_stake_table() -> StakeTableState {
1654        let validator = random_validator();
1655        let candidate: RegisteredValidator<PubKey> = validator.clone().into();
1656        let x25519_key =
1657            x25519::PublicKey::try_from(rand::random::<[u8; 32]>().as_slice()).unwrap();
1658        let candidate_bls = candidate
1659            .stake_table_key
1660            .expect("random_validator returns authenticated validator");
1661        StakeTableState::new(
1662            [(candidate.account, candidate.clone())]
1663                .into_iter()
1664                .collect(),
1665            [Address::random()].into_iter().collect(),
1666            [candidate_bls].into_iter().collect(),
1667            [candidate
1668                .state_ver_key
1669                .expect("random_validator has valid schnorr key")]
1670            .into_iter()
1671            .collect(),
1672            [x25519_key].into_iter().collect(),
1673        )
1674    }
1675
1676    /// Create a new stake table state which is a possible successor to the given state.
1677    fn chain_stake_table(state: &StakeTableState) -> StakeTableState {
1678        let new_validator = random_validator();
1679        let new_candidate: RegisteredValidator<PubKey> = new_validator.clone().into();
1680        let new_candidate_bls = new_candidate
1681            .stake_table_key
1682            .expect("random_validator returns authenticated validator");
1683        let new_exit = Address::random();
1684        let new_x25519 =
1685            x25519::PublicKey::try_from(rand::random::<[u8; 32]>().as_slice()).unwrap();
1686        StakeTableState::new(
1687            state
1688                .validators()
1689                .values()
1690                .chain([&new_candidate])
1691                .map(|v| (v.account, v.clone()))
1692                .collect(),
1693            state
1694                .validator_exits()
1695                .iter()
1696                .chain([&new_exit])
1697                .cloned()
1698                .collect(),
1699            state
1700                .used_bls_keys()
1701                .iter()
1702                .chain([&new_candidate_bls])
1703                .cloned()
1704                .collect(),
1705            state
1706                .used_schnorr_keys()
1707                .iter()
1708                .chain([new_candidate
1709                    .state_ver_key
1710                    .as_ref()
1711                    .expect("random_validator has valid schnorr key")])
1712                .cloned()
1713                .collect(),
1714            state
1715                .used_x25519_keys()
1716                .iter()
1717                .chain([&new_x25519])
1718                .cloned()
1719                .collect(),
1720        )
1721    }
1722}