1use reqwest::Client;
2use ripget::{WindowedDownload, WindowedDownloadOptions, download_url_windowed};
3use rseek::Seekable;
4use serde::Deserialize;
5use std::{fmt, io, pin::Pin};
6use tokio::io::{AsyncRead, AsyncSeek, BufReader, ReadBuf, SeekFrom};
7
8use crate::archive;
9use crate::node_reader::Len;
10
11#[cfg(feature = "s3-backend")]
12use {
13 crate::archive::S3Location,
14 aws_sdk_s3::Client as S3Client,
15 std::{future::Future, sync::Arc},
16};
17
18pub const BASE_URL: &str = "https://files.old-faithful.net";
20
21#[inline(always)]
22pub const fn epoch_to_slot_range(epoch: u64) -> (u64, u64) {
26 let first = epoch * 432000;
27 (first, first + 431999)
28}
29
30#[inline(always)]
31pub const fn slot_to_epoch(slot: u64) -> u64 {
33 slot / 432000
34}
35
36trait EpochReader: AsyncRead + AsyncSeek + Send + Unpin {}
37impl<T> EpochReader for T where T: AsyncRead + AsyncSeek + Send + Unpin {}
38type DynEpochReader = dyn EpochReader;
39
40pub struct EpochStream {
42 inner: Pin<Box<DynEpochReader>>,
43 total_len: u64,
44}
45
46impl EpochStream {
47 fn new<R>(reader: R) -> Self
48 where
49 R: AsyncRead + AsyncSeek + Len + Send + Unpin + 'static,
50 {
51 let len = reader.len();
52 Self {
53 inner: Box::pin(reader),
54 total_len: len,
55 }
56 }
57}
58
59impl AsyncRead for EpochStream {
60 fn poll_read(
61 self: Pin<&mut Self>,
62 cx: &mut std::task::Context<'_>,
63 buf: &mut ReadBuf<'_>,
64 ) -> std::task::Poll<io::Result<()>> {
65 let this = unsafe { self.get_unchecked_mut() };
66 this.inner.as_mut().poll_read(cx, buf)
67 }
68}
69
70impl AsyncSeek for EpochStream {
71 fn start_seek(self: Pin<&mut Self>, position: SeekFrom) -> io::Result<()> {
72 let this = unsafe { self.get_unchecked_mut() };
73 this.inner.as_mut().start_seek(position)
74 }
75
76 fn poll_complete(
77 self: Pin<&mut Self>,
78 cx: &mut std::task::Context<'_>,
79 ) -> std::task::Poll<io::Result<u64>> {
80 let this = unsafe { self.get_unchecked_mut() };
81 this.inner.as_mut().poll_complete(cx)
82 }
83}
84
85impl Len for EpochStream {
86 fn len(&self) -> u64 {
87 self.total_len
88 }
89}
90
91impl Unpin for EpochStream {}
92
93impl<T: Len + AsyncRead> Len for BufReader<T> {
97 #[inline]
98 fn len(&self) -> u64 {
99 self.get_ref().len()
100 }
101}
102
103#[derive(Clone, Debug)]
105pub struct FetchEpochStreamOptions {
106 pub sequential: bool,
108 pub ripget_threads: usize,
110 pub buffer_window_bytes: u64,
112 pub ripget_client: Option<ripget::Client>,
114}
115
116impl FetchEpochStreamOptions {
117 pub fn parallel_default() -> Self {
119 Self {
120 sequential: false,
121 ripget_threads: 1,
122 buffer_window_bytes: 2,
123 ripget_client: None,
124 }
125 }
126}
127
128struct RipgetEpochReader {
129 inner: WindowedDownload,
130 len: u64,
131 position: u64,
132}
133
134impl RipgetEpochReader {
135 async fn new(
136 url: impl AsRef<str>,
137 threads: usize,
138 buffer_window_bytes: u64,
139 ripget_client: Option<ripget::Client>,
140 ) -> Result<Self, ripget::RipgetError> {
141 let mut options = WindowedDownloadOptions::new(buffer_window_bytes.max(2))
142 .threads(std::cmp::max(1, threads))
143 .user_agent(format!(
144 "jetstreamer-firehose/{}",
145 env!("CARGO_PKG_VERSION")
146 ));
147 if let Some(c) = ripget_client {
148 options = options.client(c);
149 }
150 let inner = download_url_windowed(url.as_ref(), options).await?;
151 let len = inner.expected_len();
152 Ok(Self {
153 inner,
154 len,
155 position: 0,
156 })
157 }
158}
159
160impl AsyncRead for RipgetEpochReader {
161 fn poll_read(
162 self: Pin<&mut Self>,
163 cx: &mut std::task::Context<'_>,
164 buf: &mut ReadBuf<'_>,
165 ) -> std::task::Poll<io::Result<()>> {
166 let this = self.get_mut();
167 let before = buf.filled().len();
168 let result = Pin::new(&mut this.inner).poll_read(cx, buf);
169 if let std::task::Poll::Ready(Ok(())) = &result {
170 let after = buf.filled().len();
171 let delta = after.saturating_sub(before) as u64;
172 this.position = this.position.saturating_add(delta);
173 }
174 result
175 }
176}
177
178impl AsyncSeek for RipgetEpochReader {
179 fn start_seek(self: Pin<&mut Self>, position: SeekFrom) -> io::Result<()> {
180 if matches!(position, SeekFrom::Current(0)) {
181 return Ok(());
182 }
183 Err(io::Error::new(
184 io::ErrorKind::Unsupported,
185 "seek is not supported for ripget windowed streams",
186 ))
187 }
188
189 fn poll_complete(
190 self: Pin<&mut Self>,
191 _cx: &mut std::task::Context<'_>,
192 ) -> std::task::Poll<io::Result<u64>> {
193 let this = self.get_mut();
194 std::task::Poll::Ready(Ok(this.position))
195 }
196}
197
198impl Len for RipgetEpochReader {
199 fn len(&self) -> u64 {
200 self.len
201 }
202}
203
204pub async fn epoch_exists(epoch: u64, client: &Client) -> bool {
206 epoch_exists_at(epoch, client, archive::car_location()).await
207}
208
209async fn epoch_exists_at(epoch: u64, client: &Client, location: &archive::Location) -> bool {
210 let path = format!("{epoch}/epoch-{epoch}.car");
211
212 if location.is_http() {
213 let url = location
214 .url()
215 .join(&path)
216 .unwrap_or_else(|err| panic!("invalid CAR URL for epoch {epoch}: {err}"));
217 let response = client.head(url).send().await;
218 return response
219 .map(|res| res.status().is_success())
220 .unwrap_or(false);
221 }
222
223 #[cfg(feature = "s3-backend")]
224 if let Some(cfg) = location.as_s3() {
225 let key = cfg.key_for(&path);
226 return cfg
227 .client
228 .head_object()
229 .bucket(cfg.bucket.as_ref())
230 .key(key)
231 .send()
232 .await
233 .is_ok();
234 }
235
236 panic!(
237 "unsupported archive backend for CAR location {}",
238 location.url()
239 );
240}
241
242pub async fn fetch_epoch_stream(epoch: u64, client: &Client) -> EpochStream {
244 fetch_epoch_stream_with_options(epoch, client, None).await
245}
246
247pub async fn fetch_epoch_stream_with_options(
252 epoch: u64,
253 client: &Client,
254 options: Option<FetchEpochStreamOptions>,
255) -> EpochStream {
256 fetch_epoch_stream_at(epoch, client, options, archive::car_location()).await
257}
258
259async fn fetch_epoch_stream_at(
260 epoch: u64,
261 client: &Client,
262 options: Option<FetchEpochStreamOptions>,
263 location: &archive::Location,
264) -> EpochStream {
265 let options = options.unwrap_or_else(FetchEpochStreamOptions::parallel_default);
266 let path = format!("{epoch}/epoch-{epoch}.car");
267
268 if location.is_http() {
269 let url = location
270 .url()
271 .join(&path)
272 .unwrap_or_else(|err| panic!("invalid CAR URL for epoch {epoch}: {err}"));
273 let request_url = url.to_string();
274 if options.sequential {
275 match RipgetEpochReader::new(
276 request_url.clone(),
277 options.ripget_threads,
278 options.buffer_window_bytes,
279 options.ripget_client,
280 )
281 .await
282 {
283 Ok(reader) => {
284 return EpochStream::new(BufReader::with_capacity(8 * 1024 * 1024, reader));
285 }
286 Err(err) => {
287 log::warn!(
288 target: crate::LOG_MODULE,
289 "ripget windowed stream failed to initialize for epoch {} ({}), falling back to seekable stream",
290 epoch,
291 err
292 );
293 }
294 }
295 }
296 let http_client = client.clone();
297 let seekable = Seekable::new(move || http_client.get(request_url.clone())).await;
298 let reader = BufReader::with_capacity(8 * 1024 * 1024, seekable);
299 return EpochStream::new(reader);
300 }
301
302 #[cfg(feature = "s3-backend")]
303 if let Some(cfg) = location.as_s3() {
304 let s3_reader = S3SeekableReader::new(cfg, path)
305 .await
306 .unwrap_or_else(|err| panic!("failed to open epoch {epoch} CAR via S3: {err}"));
307 let reader = BufReader::with_capacity(8 * 1024 * 1024, s3_reader);
308 return EpochStream::new(reader);
309 }
310
311 panic!(
312 "unsupported archive backend for CAR location {}",
313 location.url()
314 );
315}
316
317#[cfg(feature = "s3-backend")]
318type ReaderFuture =
319 Pin<Box<dyn Future<Output = io::Result<Pin<Box<dyn AsyncRead + Send>>>> + Send>>;
320
321#[cfg(feature = "s3-backend")]
322struct S3SeekableReader {
323 client: Arc<S3Client>,
324 bucket: Arc<str>,
325 key: String,
326 len: u64,
327 position: u64,
328 reader: Option<Pin<Box<dyn AsyncRead + Send>>>,
329 init_fetch: Option<ReaderFuture>,
330}
331
332#[cfg(feature = "s3-backend")]
333impl S3SeekableReader {
334 async fn new(location: Arc<S3Location>, path: String) -> io::Result<Self> {
335 let key = location.key_for(&path);
336 let head = location
337 .client
338 .head_object()
339 .bucket(location.bucket.as_ref())
340 .key(&key)
341 .send()
342 .await
343 .map_err(|err| io::Error::other(err.to_string()))?;
344 let len = head
345 .content_length()
346 .ok_or_else(|| io::Error::other("S3 object missing length"))? as u64;
347
348 let mut reader = Self {
349 client: Arc::clone(&location.client),
350 bucket: Arc::clone(&location.bucket),
351 key,
352 len,
353 position: 0,
354 reader: None,
355 init_fetch: None,
356 };
357 reader.schedule_fetch(0);
358 Ok(reader)
359 }
360
361 fn schedule_fetch(&mut self, start: u64) {
362 let client = Arc::clone(&self.client);
363 let bucket = Arc::clone(&self.bucket);
364 let key = self.key.clone();
365 let len = self.len;
366 self.reader = None;
367 self.init_fetch = Some(Box::pin(async move {
368 let end = len.saturating_sub(1);
369 let range = if start >= len {
370 format!("bytes={end}-{end}")
371 } else {
372 format!("bytes={start}-{end}")
373 };
374 let resp = client
375 .get_object()
376 .bucket(bucket.as_ref())
377 .key(&key)
378 .range(range)
379 .send()
380 .await
381 .map_err(|err| io::Error::other(err.to_string()))?;
382 let reader = tokio::io::BufReader::new(resp.body.into_async_read());
383 Ok(Box::pin(reader) as Pin<Box<dyn AsyncRead + Send>>)
384 }));
385 }
386}
387
388#[cfg(feature = "s3-backend")]
389impl AsyncRead for S3SeekableReader {
390 fn poll_read(
391 self: Pin<&mut Self>,
392 cx: &mut std::task::Context<'_>,
393 buf: &mut ReadBuf<'_>,
394 ) -> std::task::Poll<io::Result<()>> {
395 let this = unsafe { self.get_unchecked_mut() };
396
397 if this.position >= this.len {
398 return std::task::Poll::Ready(Ok(()));
399 }
400
401 loop {
402 if let Some(reader) = this.reader.as_mut() {
403 let before = buf.filled().len();
404 match reader.as_mut().poll_read(cx, buf) {
405 std::task::Poll::Ready(Ok(())) => {
406 let after = buf.filled().len();
407 let delta = after.saturating_sub(before);
408 this.position = this.position.saturating_add(delta as u64);
409 return std::task::Poll::Ready(Ok(()));
410 }
411 other => return other,
412 }
413 }
414
415 if let Some(fut) = this.init_fetch.as_mut() {
416 match fut.as_mut().poll(cx) {
417 std::task::Poll::Ready(Ok(reader)) => {
418 this.reader = Some(reader);
419 this.init_fetch = None;
420 continue;
421 }
422 std::task::Poll::Ready(Err(err)) => {
423 this.init_fetch = None;
424 return std::task::Poll::Ready(Err(err));
425 }
426 std::task::Poll::Pending => return std::task::Poll::Pending,
427 }
428 } else {
429 this.schedule_fetch(this.position);
430 }
431 }
432 }
433}
434
435#[cfg(feature = "s3-backend")]
436impl AsyncSeek for S3SeekableReader {
437 fn start_seek(self: Pin<&mut Self>, position: SeekFrom) -> io::Result<()> {
438 let this = unsafe { self.get_unchecked_mut() };
439 let target = match position {
440 SeekFrom::Start(offset) => offset,
441 SeekFrom::Current(delta) => {
442 let tmp = this.position as i64 + delta;
443 if tmp < 0 {
444 return Err(io::Error::new(io::ErrorKind::InvalidInput, "negative seek"));
445 }
446 tmp as u64
447 }
448 SeekFrom::End(delta) => {
449 let tmp = this.len as i64 + delta;
450 if tmp < 0 {
451 return Err(io::Error::new(io::ErrorKind::InvalidInput, "negative seek"));
452 }
453 tmp as u64
454 }
455 };
456 this.position = target.min(this.len);
457 this.reader = None;
458 this.init_fetch = None;
459 this.schedule_fetch(this.position);
460 Ok(())
461 }
462
463 fn poll_complete(
464 self: Pin<&mut Self>,
465 _cx: &mut std::task::Context<'_>,
466 ) -> std::task::Poll<io::Result<u64>> {
467 let this = unsafe { self.get_unchecked_mut() };
468 std::task::Poll::Ready(Ok(this.position))
469 }
470}
471
472#[cfg(feature = "s3-backend")]
473impl Len for S3SeekableReader {
474 fn len(&self) -> u64 {
475 self.len
476 }
477}
478
479#[cfg(feature = "s3-backend")]
480impl Unpin for S3SeekableReader {}
481
482#[derive(Debug)]
484pub enum SlotTimestampError {
485 Transport(reqwest::Error),
487 Decode(serde_json::Error),
489 Rpc(Option<serde_json::Value>),
491 NoBlockTime,
493}
494impl fmt::Display for SlotTimestampError {
495 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
496 match self {
497 SlotTimestampError::Transport(e) => write!(f, "RPC transport error: {e}"),
498 SlotTimestampError::Decode(e) => write!(f, "RPC decode error: {e}"),
499 SlotTimestampError::Rpc(e) => write!(f, "RPC error: {:?}", e),
500 SlotTimestampError::NoBlockTime => write!(f, "No blockTime found in getBlock result"),
501 }
502 }
503}
504impl std::error::Error for SlotTimestampError {}
505
506pub async fn get_slot_timestamp(
509 slot: u64,
510 rpc_url: &str,
511 client: &Client,
512) -> Result<u64, SlotTimestampError> {
513 #[derive(Deserialize)]
514 struct BlockResult {
515 #[serde(rename = "blockTime")]
516 block_time: Option<u64>,
517 }
518 #[derive(Deserialize)]
519 struct RpcResponse {
520 result: Option<BlockResult>,
521 error: Option<serde_json::Value>,
522 }
523
524 let req = serde_json::json!({
525 "jsonrpc": "2.0",
526 "id": 1,
527 "method": "getBlock",
528 "params": [slot, { "maxSupportedTransactionVersion": 0 }],
529 });
530
531 let resp = client
532 .post(rpc_url)
533 .json(&req)
534 .send()
535 .await
536 .map_err(SlotTimestampError::Transport)?;
537
538 let text = resp.text().await.map_err(SlotTimestampError::Transport)?;
539 let resp_val: RpcResponse = serde_json::from_str(&text).map_err(SlotTimestampError::Decode)?;
540
541 if resp_val.error.is_some() {
542 return Err(SlotTimestampError::Rpc(resp_val.error));
543 }
544 resp_val
545 .result
546 .and_then(|r| r.block_time)
547 .ok_or(SlotTimestampError::NoBlockTime)
548}
549
550#[cfg(test)]
552mod tests;
553
554#[test]
555fn test_epoch_to_slot() {
556 assert_eq!(epoch_to_slot_range(0), (0, 431999));
557 assert_eq!(epoch_to_slot_range(770), (332640000, 333071999));
558}
559
560#[test]
561fn test_slot_to_epoch() {
562 assert_eq!(slot_to_epoch(0), 0);
563 assert_eq!(slot_to_epoch(431999), 0);
564 assert_eq!(slot_to_epoch(432000), 1);
565 assert_eq!(slot_to_epoch(332640000), 770);
566 assert_eq!(slot_to_epoch(333071999), 770);
567}
568
569#[test]
570fn test_epoch_to_slot_range() {
571 assert_eq!(epoch_to_slot_range(0), (0, 431999));
572 assert_eq!(epoch_to_slot_range(1), (432000, 863999));
573 assert_eq!(epoch_to_slot_range(2), (864000, 1295999));
574 assert_eq!(epoch_to_slot_range(3), (1296000, 1727999));
575}