Skip to content

Commit 09c7276

Browse files
authored
Merge pull request #8 from semiotic-ai/test/bounds-memo-wiring
test(blocks): cover ChainBoundsMemo wiring through block_range_for_timestamps
2 parents 20ee457 + 9cde27d commit 09c7276

1 file changed

Lines changed: 266 additions & 48 deletions

File tree

src/blocks/window.rs

Lines changed: 266 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -548,58 +548,20 @@ impl<P: Provider> BlockWindowCalculator<P> {
548548
let span = spans::block_range_for_timestamps(start_ts.as_u64(), end_ts.as_u64());
549549
let _guard = span.enter();
550550

551-
if start_ts > end_ts {
552-
return Err(BlockWindowError::invalid_timestamp_range(start_ts, end_ts));
553-
}
554-
555-
let genesis_ts = self
556-
.bounds_memo
557-
.get_or_fetch_genesis(|| self.get_block_timestamp(0))
558-
.await?;
559-
560-
let (latest_block, latest_ts) = self
561-
.bounds_memo
562-
.get_or_fetch_head(|| async {
563-
let latest_block = self
564-
.provider
565-
.get_block_number()
566-
.await
567-
.map_err(RpcError::get_block_number_failed)?;
568-
let latest_ts = if latest_block == 0 {
569-
genesis_ts
570-
} else {
571-
self.get_block_timestamp(latest_block).await?
572-
};
573-
Ok((latest_block, latest_ts))
574-
})
575-
.await?;
576-
577-
info!(
578-
start_ts = %start_ts,
579-
end_ts = %end_ts,
580-
latest_block,
581-
"Resolving timestamp range to block range"
582-
);
583-
584-
let (start_block, end_block) = compute_block_range_given_bounds(
551+
block_range_for_timestamps_with(
552+
&self.bounds_memo,
585553
start_ts,
586554
end_ts,
587-
latest_block,
588-
genesis_ts,
589-
latest_ts,
590555
|n| self.get_block_timestamp(n),
556+
|| async {
557+
self.provider
558+
.get_block_number()
559+
.await
560+
.map_err(RpcError::get_block_number_failed)
561+
.map_err(BlockWindowError::from)
562+
},
591563
)
592-
.await?;
593-
594-
info!(
595-
start_ts = %start_ts,
596-
end_ts = %end_ts,
597-
start_block,
598-
end_block,
599-
"Resolved timestamp range to block range"
600-
);
601-
602-
Ok((start_block, end_block))
564+
.await
603565
}
604566

605567
/// Gets (or computes and caches) the daily block window for a specific chain and date
@@ -867,6 +829,75 @@ where
867829
Ok((start_block, end_block))
868830
}
869831

832+
/// Memo-aware resolution of an inclusive timestamp range to its inclusive
833+
/// block range. Pulled out of [`BlockWindowCalculator::block_range_for_timestamps`]
834+
/// so the full path — input validation, genesis/head memoization via
835+
/// [`ChainBoundsMemo`], non-monotonic detection, and binary search — can be
836+
/// exercised in tests without a live RPC.
837+
///
838+
/// `fetch_ts(0)` supplies the genesis timestamp, `fetch_latest_block_number()`
839+
/// supplies the chain head's block number, and `fetch_ts(latest_block)`
840+
/// supplies the head's timestamp. The production path wires
841+
/// `self.get_block_timestamp` and `self.provider.get_block_number` through.
842+
async fn block_range_for_timestamps_with<F, FtFut, G, GnFut>(
843+
bounds_memo: &ChainBoundsMemo,
844+
start_ts: UnixTimestamp,
845+
end_ts: UnixTimestamp,
846+
mut fetch_ts: F,
847+
fetch_latest_block_number: G,
848+
) -> Result<(BlockNumber, BlockNumber), BlockWindowError>
849+
where
850+
F: FnMut(BlockNumber) -> FtFut,
851+
FtFut: std::future::Future<Output = Result<UnixTimestamp, BlockWindowError>>,
852+
G: FnOnce() -> GnFut,
853+
GnFut: std::future::Future<Output = Result<BlockNumber, BlockWindowError>>,
854+
{
855+
if start_ts > end_ts {
856+
return Err(BlockWindowError::invalid_timestamp_range(start_ts, end_ts));
857+
}
858+
859+
let genesis_ts = bounds_memo.get_or_fetch_genesis(|| fetch_ts(0)).await?;
860+
861+
let (latest_block, latest_ts) = bounds_memo
862+
.get_or_fetch_head(|| async {
863+
let latest_block = fetch_latest_block_number().await?;
864+
let latest_ts = if latest_block == 0 {
865+
genesis_ts
866+
} else {
867+
fetch_ts(latest_block).await?
868+
};
869+
Ok((latest_block, latest_ts))
870+
})
871+
.await?;
872+
873+
info!(
874+
start_ts = %start_ts,
875+
end_ts = %end_ts,
876+
latest_block,
877+
"Resolving timestamp range to block range"
878+
);
879+
880+
let (start_block, end_block) = compute_block_range_given_bounds(
881+
start_ts,
882+
end_ts,
883+
latest_block,
884+
genesis_ts,
885+
latest_ts,
886+
fetch_ts,
887+
)
888+
.await?;
889+
890+
info!(
891+
start_ts = %start_ts,
892+
end_ts = %end_ts,
893+
start_block,
894+
end_block,
895+
"Resolved timestamp range to block range"
896+
);
897+
898+
Ok((start_block, end_block))
899+
}
900+
870901
#[cfg(test)]
871902
mod tests {
872903
use super::*;
@@ -1510,4 +1541,191 @@ mod tests {
15101541
);
15111542
}
15121543
}
1544+
1545+
/// End-to-end tests for the memo-aware wiring in
1546+
/// [`block_range_for_timestamps_with`] — the closures inside
1547+
/// [`BlockWindowCalculator::block_range_for_timestamps`] are reachable
1548+
/// today only by running against a live RPC. These tests drive that
1549+
/// path via the closure-injected helper using an in-memory fixture so
1550+
/// the wiring (genesis fetch → head fetch → bounds-aware binary search)
1551+
/// is exercised under `cargo test`.
1552+
mod wiring {
1553+
use super::*;
1554+
use std::sync::atomic::{AtomicUsize, Ordering};
1555+
use std::sync::{Arc, Mutex as StdMutex};
1556+
1557+
type BoxedTsFut = std::pin::Pin<
1558+
Box<dyn std::future::Future<Output = Result<UnixTimestamp, BlockWindowError>>>,
1559+
>;
1560+
type BoxedHeadFut = std::pin::Pin<
1561+
Box<dyn std::future::Future<Output = Result<BlockNumber, BlockWindowError>>>,
1562+
>;
1563+
1564+
/// Records each block requested in `log`, then returns
1565+
/// `timestamps[block]`. Lets tests count how many times the
1566+
/// underlying timestamp fetcher was invoked for any given block.
1567+
fn counting_fetch_ts(
1568+
timestamps: Vec<i64>,
1569+
log: Arc<StdMutex<Vec<BlockNumber>>>,
1570+
) -> impl FnMut(BlockNumber) -> BoxedTsFut {
1571+
move |n: BlockNumber| {
1572+
let ts = timestamps[n as usize];
1573+
let log = log.clone();
1574+
Box::pin(async move {
1575+
log.lock().expect("test log mutex poisoned").push(n);
1576+
Ok(UnixTimestamp(ts))
1577+
})
1578+
}
1579+
}
1580+
1581+
/// Mirrors `self.provider.get_block_number()` and increments
1582+
/// `counter` on each invocation.
1583+
fn counting_fetch_head(
1584+
latest: BlockNumber,
1585+
counter: Arc<AtomicUsize>,
1586+
) -> impl FnOnce() -> BoxedHeadFut {
1587+
move || {
1588+
Box::pin(async move {
1589+
counter.fetch_add(1, Ordering::SeqCst);
1590+
Ok(latest)
1591+
})
1592+
}
1593+
}
1594+
1595+
#[tokio::test]
1596+
async fn bounds_memoized_across_two_calls_within_ttl() {
1597+
// Five-block monotonic chain. Two back-to-back resolutions
1598+
// within the default TTL should collapse the genesis+head
1599+
// probes from 2·2 into a single pair.
1600+
//
1601+
// Both windows are chosen so that the bound-overlap short-circuits
1602+
// in `compute_block_range_given_bounds` fire instead of the
1603+
// binary search — that isolates the memo-bootstrap fetches
1604+
// (block 0 + block `latest`) from the binary-search probes,
1605+
// which are not memoized and would otherwise re-probe both
1606+
// bookends during their bisection.
1607+
let timestamps = vec![1000, 1100, 1200, 1300, 1400];
1608+
let latest: BlockNumber = 4;
1609+
let bounds_memo = ChainBoundsMemo::new(DEFAULT_HEAD_TTL);
1610+
1611+
let log = Arc::new(StdMutex::new(Vec::<BlockNumber>::new()));
1612+
let head_counter = Arc::new(AtomicUsize::new(0));
1613+
1614+
// First call covers the full chain — both bounds clamp to the
1615+
// chain extremes without running the binary search. Only the
1616+
// memo bootstrap (genesis + head) hits `fetch_ts`.
1617+
let (s1, e1) = block_range_for_timestamps_with(
1618+
&bounds_memo,
1619+
UnixTimestamp(500),
1620+
UnixTimestamp(9999),
1621+
counting_fetch_ts(timestamps.clone(), log.clone()),
1622+
counting_fetch_head(latest, head_counter.clone()),
1623+
)
1624+
.await
1625+
.unwrap();
1626+
assert_eq!((s1, e1), (0, latest));
1627+
1628+
// Second call lies strictly past chain head: the `start_ts >
1629+
// latest_ts` short-circuit fires using the memoized `latest_ts`
1630+
// (1400). The returned (latest, latest) proves the cached head
1631+
// was read back, not just written.
1632+
let (s2, e2) = block_range_for_timestamps_with(
1633+
&bounds_memo,
1634+
UnixTimestamp(2000),
1635+
UnixTimestamp(3000),
1636+
counting_fetch_ts(timestamps, log.clone()),
1637+
counting_fetch_head(latest, head_counter.clone()),
1638+
)
1639+
.await
1640+
.unwrap();
1641+
assert_eq!((s2, e2), (latest, latest));
1642+
1643+
// The memo collapses the four genesis+head probes that the
1644+
// naive implementation would issue (2 per call) into a single
1645+
// pair, in the order genesis-then-head.
1646+
let calls = log.lock().unwrap();
1647+
assert_eq!(
1648+
calls.as_slice(),
1649+
&[0u64, latest],
1650+
"memo must collapse genesis+head fetches across calls within TTL (got: {calls:?})"
1651+
);
1652+
assert_eq!(
1653+
head_counter.load(Ordering::SeqCst),
1654+
1,
1655+
"head block number must be fetched once within TTL"
1656+
);
1657+
}
1658+
1659+
#[tokio::test]
1660+
async fn single_block_chain_reuses_genesis_for_head() {
1661+
// Single-block chain (latest_block == 0). The head closure
1662+
// must reuse the memoized genesis timestamp instead of
1663+
// refetching block 0; a regression that drops the
1664+
// `latest_block == 0` short-circuit shows up here as a second
1665+
// fetch of block 0.
1666+
let timestamps = vec![1500];
1667+
let latest: BlockNumber = 0;
1668+
let bounds_memo = ChainBoundsMemo::new(DEFAULT_HEAD_TTL);
1669+
1670+
let log = Arc::new(StdMutex::new(Vec::<BlockNumber>::new()));
1671+
let head_counter = Arc::new(AtomicUsize::new(0));
1672+
1673+
let (s, e) = block_range_for_timestamps_with(
1674+
&bounds_memo,
1675+
UnixTimestamp(1000),
1676+
UnixTimestamp(2000),
1677+
counting_fetch_ts(timestamps, log.clone()),
1678+
counting_fetch_head(latest, head_counter.clone()),
1679+
)
1680+
.await
1681+
.unwrap();
1682+
assert_eq!((s, e), (0, 0));
1683+
1684+
let calls = log.lock().unwrap();
1685+
assert_eq!(
1686+
calls.as_slice(),
1687+
&[0u64],
1688+
"block 0 must be fetched exactly once for a single-block chain (calls: {calls:?})"
1689+
);
1690+
assert_eq!(head_counter.load(Ordering::SeqCst), 1);
1691+
}
1692+
1693+
#[tokio::test]
1694+
async fn interior_window_threads_memoized_latest_ts_through_binary_search() {
1695+
// Window strictly inside chain history: both bounds drive the
1696+
// binary search, so the returned `(start, end)` depends on
1697+
// `latest_ts` being honestly threaded from the memo into
1698+
// `compute_block_range_given_bounds`. A regression that swaps
1699+
// `genesis_ts` and `latest_ts` at the call site, or zeroes
1700+
// out `latest_block` after the head closure runs, changes
1701+
// the answer (the `end_ts >= latest_ts` short-circuit fires
1702+
// under the wrong `latest_ts`, clamping `end_block` to
1703+
// `latest` instead of the correct interior block).
1704+
//
1705+
// The earlier tests prove the memo is consulted; this one
1706+
// proves the memoized values are actually fed to the binary
1707+
// search.
1708+
let timestamps = vec![1000, 1100, 1200, 1300, 1400];
1709+
let latest: BlockNumber = 4;
1710+
let bounds_memo = ChainBoundsMemo::new(DEFAULT_HEAD_TTL);
1711+
1712+
let log = Arc::new(StdMutex::new(Vec::<BlockNumber>::new()));
1713+
let head_counter = Arc::new(AtomicUsize::new(0));
1714+
1715+
let (s, e) = block_range_for_timestamps_with(
1716+
&bounds_memo,
1717+
UnixTimestamp(1150),
1718+
UnixTimestamp(1250),
1719+
counting_fetch_ts(timestamps, log.clone()),
1720+
counting_fetch_head(latest, head_counter.clone()),
1721+
)
1722+
.await
1723+
.unwrap();
1724+
1725+
// First block with ts >= 1150 is block 2 (ts=1200).
1726+
// Last block with ts <= 1250 is also block 2 (ts=1200).
1727+
assert_eq!((s, e), (2, 2));
1728+
assert_eq!(head_counter.load(Ordering::SeqCst), 1);
1729+
}
1730+
}
15131731
}

0 commit comments

Comments
 (0)