Skip to main content

hypersync_client/
config.rs

1use anyhow::Result;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use url::Url;
5
6use crate::ColumnMapping;
7
8/// Configuration for the hypersync client.
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct ClientConfig {
11    /// HyperSync server URL.
12    #[serde(default)]
13    pub url: String,
14    /// HyperSync server api token.
15    #[serde(default)]
16    pub api_token: String,
17    /// Milliseconds to wait for a response before timing out.
18    #[serde(default = "ClientConfig::default_http_req_timeout_millis")]
19    pub http_req_timeout_millis: u64,
20    /// Number of retries to attempt before returning error.
21    #[serde(default = "ClientConfig::default_max_num_retries")]
22    pub max_num_retries: usize,
23    /// Milliseconds that would be used for retry backoff increasing.
24    #[serde(default = "ClientConfig::default_retry_backoff_ms")]
25    pub retry_backoff_ms: u64,
26    /// Initial wait time for request backoff.
27    #[serde(default = "ClientConfig::default_retry_base_ms")]
28    pub retry_base_ms: u64,
29    /// Ceiling time for request backoff.
30    #[serde(default = "ClientConfig::default_retry_ceiling_ms")]
31    pub retry_ceiling_ms: u64,
32    /// Query serialization format to use for HTTP requests.
33    #[serde(default)]
34    pub serialization_format: SerializationFormat,
35    /// Whether to proactively sleep when the rate limit is exhausted instead of
36    /// sending requests that will be rejected with 429.
37    ///
38    /// Enabled by default. Set to `false` to opt out and handle rate limits yourself.
39    #[serde(default = "ClientConfig::default_proactive_rate_limit_sleep")]
40    pub proactive_rate_limit_sleep: bool,
41}
42
43impl Default for ClientConfig {
44    fn default() -> Self {
45        Self {
46            url: String::default(),
47            api_token: String::default(),
48            http_req_timeout_millis: Self::default_http_req_timeout_millis(),
49            max_num_retries: Self::default_max_num_retries(),
50            retry_backoff_ms: Self::default_retry_backoff_ms(),
51            retry_base_ms: Self::default_retry_base_ms(),
52            retry_ceiling_ms: Self::default_retry_ceiling_ms(),
53            serialization_format: SerializationFormat::default(),
54            proactive_rate_limit_sleep: Self::default_proactive_rate_limit_sleep(),
55        }
56    }
57}
58
59impl ClientConfig {
60    /// Default HTTP request timeout in milliseconds
61    pub const fn default_http_req_timeout_millis() -> u64 {
62        30_000
63    }
64
65    /// Default maximum number of retries
66    pub const fn default_max_num_retries() -> usize {
67        12
68    }
69
70    /// Default retry backoff in milliseconds
71    pub const fn default_retry_backoff_ms() -> u64 {
72        500
73    }
74
75    /// Default retry base time in milliseconds
76    pub const fn default_retry_base_ms() -> u64 {
77        200
78    }
79
80    /// Default retry ceiling time in milliseconds
81    pub const fn default_retry_ceiling_ms() -> u64 {
82        5_000
83    }
84
85    /// Default proactive rate limit sleep setting
86    pub const fn default_proactive_rate_limit_sleep() -> bool {
87        true
88    }
89    /// Validates the config
90    pub fn validate(&self) -> Result<()> {
91        if self.url.is_empty() {
92            anyhow::bail!("url is required");
93        }
94
95        // validate that url is a valid url
96        if Url::parse(&self.url).is_err() {
97            anyhow::bail!("url is malformed");
98        }
99
100        if self.api_token.is_empty() {
101            anyhow::bail!("api_token is required - get one from https://envio.dev/app/api-tokens");
102        }
103        // validate that api token is a uuid
104        if uuid::Uuid::parse_str(self.api_token.as_str()).is_err() {
105            anyhow::bail!("api_token is malformed - make sure its a token from https://envio.dev/app/api-tokens");
106        }
107
108        if self.http_req_timeout_millis == 0 {
109            anyhow::bail!("http_req_timeout_millis must be greater than 0");
110        }
111
112        Ok(())
113    }
114}
115
116/// Determines query serialization format for HTTP requests.
117#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
118pub enum SerializationFormat {
119    /// Use JSON serialization
120    Json,
121    /// Use Cap'n Proto binary serialization (default, with query caching enabled)
122    CapnProto {
123        /// Whether to use query caching
124        should_cache_queries: bool,
125    },
126}
127
128impl Default for SerializationFormat {
129    fn default() -> Self {
130        Self::CapnProto {
131            should_cache_queries: true,
132        }
133    }
134}
135
136/// Config for hypersync event streaming.
137///
138/// `column_mapping`, `event_signature` and `hex_output` only affect the Arrow output path
139/// (`collect_arrow`, `collect_parquet`, `stream_arrow`). The simple-type functions (`collect`,
140/// `collect_events`, `stream`, `stream_events`) always work on raw binary columns: they reject
141/// `column_mapping`, `event_signature` and any `hex_output` other than `NoEncode`.
142#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
143pub struct StreamConfig {
144    /// Column mapping for stream function output.
145    /// It lets you map columns you want into the DataTypes you want.
146    ///
147    /// Arrow output path only. The simple-type functions return an error if this is set.
148    pub column_mapping: Option<ColumnMapping>,
149    /// Event signature used to populate decode logs. Decode logs would be empty if set to None.
150    ///
151    /// Arrow output path only. The simple-type functions return an error if this is set.
152    pub event_signature: Option<String>,
153    /// Determines formatting of binary columns numbers into utf8 hex.
154    ///
155    /// Arrow output path only. The simple-type functions return an error unless this is
156    /// `NoEncode`, since their output types hold raw bytes and render hex themselves.
157    #[serde(default)]
158    pub hex_output: HexOutput,
159    /// Initial, deliberately-overestimated batch size, used for the first wave of
160    /// requests and as a fallback before any response density has been measured.
161    #[serde(default = "StreamConfig::default_batch_size")]
162    pub batch_size: u64,
163    /// Optional hard upper cap on the number of blocks fetched in a single
164    /// request. `None` (the default) means no cap: an over-large request that the
165    /// server truncates simply leaves a gap that is backfilled, so overshoot is
166    /// self-correcting. Set it to bound blocks-per-chunk explicitly.
167    #[serde(default)]
168    pub max_batch_size: Option<u64>,
169    /// Hard lower clamp on the projected block count, to avoid tiny ranges.
170    #[serde(default = "StreamConfig::default_min_batch_size")]
171    pub min_batch_size: u64,
172    /// Number of async threads that would be spawned to execute different block ranges of queries.
173    /// `0` is an error, `1` streams sequentially, `>= 2` uses the projecting scheduler.
174    #[serde(default = "StreamConfig::default_concurrency")]
175    pub concurrency: usize,
176    /// Max number of blocks to fetch in a single request.
177    #[serde(default)]
178    pub max_num_blocks: Option<usize>,
179    /// Max number of transactions to fetch in a single request.
180    #[serde(default)]
181    pub max_num_transactions: Option<usize>,
182    /// Max number of logs to fetch in a single request.
183    #[serde(default)]
184    pub max_num_logs: Option<usize>,
185    /// Max number of traces to fetch in a single request.
186    #[serde(default)]
187    pub max_num_traces: Option<usize>,
188    /// Target response size in bytes. Each request's block span is projected from
189    /// the most recently observed byte-density to aim each response at this size.
190    #[serde(default = "StreamConfig::default_response_bytes_target")]
191    pub response_bytes_target: u64,
192    /// Optional cap on the bytes of fetched-but-undelivered chunks held in the
193    /// reorder buffer (consumer backpressure). `None` (the default) is
194    /// **adaptive**: it starts at `2 * concurrency * response_bytes_target` and
195    /// grows to `2 * concurrency * max(response_bytes_target, largest_response)`
196    /// so the pipeline stays full even for byte-heavy queries whose responses far
197    /// exceed the target (otherwise a single response could exceed the cap and
198    /// throttle look-ahead to near-sequential). Set an explicit value to bound
199    /// memory; an explicit cap is honoured verbatim and never grown. `Some(0)` is
200    /// valid and means "no look-ahead buffer": only the chunk delivery is
201    /// currently waiting on is fetched, so the stream runs effectively
202    /// sequentially with minimal memory (it still completes — the watermark chunk
203    /// is always allowed through).
204    #[serde(default)]
205    pub max_buffered_bytes: Option<u64>,
206    /// Stream data in reverse order
207    #[serde(default = "StreamConfig::default_reverse")]
208    pub reverse: bool,
209}
210
211/// Determines format of Binary column in Arrow output.
212///
213/// Only used by `collect_arrow`, `collect_parquet` and `stream_arrow`. The simple-type functions
214/// reject any value other than `NoEncode`.
215#[derive(Default, Clone, Copy, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
216pub enum HexOutput {
217    /// Binary column won't be formatted as hex
218    #[default]
219    NoEncode,
220    /// Binary column would be formatted as prefixed hex i.e. 0xdeadbeef
221    Prefixed,
222    /// Binary column would be formatted as non prefixed hex i.e. deadbeef
223    NonPrefixed,
224}
225
226impl Default for StreamConfig {
227    fn default() -> Self {
228        Self {
229            column_mapping: None,
230            event_signature: None,
231            hex_output: HexOutput::default(),
232            batch_size: Self::default_batch_size(),
233            max_batch_size: None,
234            min_batch_size: Self::default_min_batch_size(),
235            concurrency: Self::default_concurrency(),
236            max_num_blocks: None,
237            max_num_transactions: None,
238            max_num_logs: None,
239            max_num_traces: None,
240            response_bytes_target: Self::default_response_bytes_target(),
241            max_buffered_bytes: None,
242            reverse: Self::default_reverse(),
243        }
244    }
245}
246
247impl StreamConfig {
248    /// Default concurrency for stream processing
249    pub const fn default_concurrency() -> usize {
250        10
251    }
252
253    /// Default initial batch size
254    pub const fn default_batch_size() -> u64 {
255        1000
256    }
257
258    /// Default minimum batch size
259    pub const fn default_min_batch_size() -> u64 {
260        200
261    }
262
263    /// Default target response size in bytes that projection aims each response at
264    pub const fn default_response_bytes_target() -> u64 {
265        400_000
266    }
267
268    /// Default reverse streaming setting
269    pub const fn default_reverse() -> bool {
270        false
271    }
272
273    /// Preset for **dense** workloads: queries that match a lot of data per block
274    /// (busy contracts, all-logs, popular ERC-20 transfers).
275    ///
276    /// Such streams are throughput-bound and scale well with parallelism, so this
277    /// raises `concurrency` above the default. The default `response_bytes_target`
278    /// (400 KB) is already a good fit — benchmarking showed dense responses
279    /// plateau near that size, and pushing the target higher mostly adds
280    /// truncation/backfill rather than bigger responses.
281    ///
282    /// `max_buffered_bytes` is left unset so the adaptive default applies. If you
283    /// have plenty of rate-limit headroom you can push `concurrency` higher still.
284    pub fn dense() -> Self {
285        Self {
286            concurrency: 20,
287            response_bytes_target: Self::default_response_bytes_target(),
288            ..Self::default()
289        }
290    }
291
292    /// Preset for **sparse** workloads: selective queries over wide block ranges
293    /// (rare events, low-volume contracts) where most blocks match nothing.
294    ///
295    /// Here latency, not bytes, dominates, and benchmarking showed that *high*
296    /// concurrency actually hurts: extra workers just fragment a large empty
297    /// region into more (smaller) requests. So this keeps concurrency moderate and
298    /// raises `batch_size` so the first wave covers a lot of ground before
299    /// per-request projection kicks in — an over-estimate that self-corrects via
300    /// backfill if it hits a dense patch.
301    pub fn sparse() -> Self {
302        Self {
303            concurrency: Self::default_concurrency(),
304            batch_size: 20_000,
305            ..Self::default()
306        }
307    }
308
309    /// Preset for **archival / byte-heavy** workloads: full block + transaction
310    /// pulls (e.g. `include_all_blocks` with wide field selection) where each
311    /// response is many megabytes.
312    ///
313    /// These streams are bounded by the reorder buffer, not concurrency: a single
314    /// response can dwarf `response_bytes_target`, so the adaptive
315    /// `max_buffered_bytes` default (left unset here) is what keeps the pipeline
316    /// full — in benchmarks it roughly doubled throughput versus a buffer sized to
317    /// the target. Concurrency past ~10–15 gives little extra here.
318    pub fn archival() -> Self {
319        Self {
320            concurrency: 12,
321            ..Self::default()
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_validate() {
332        let valid_cfg = ClientConfig {
333            url: "https://hypersync.xyz".into(),
334            api_token: "00000000-0000-0000-0000-000000000000".to_string(),
335            ..Default::default()
336        };
337
338        assert!(valid_cfg.validate().is_ok(), "valid config");
339
340        let cfg = ClientConfig {
341            url: "https://hypersync.xyz".to_string(),
342            api_token: "not a uuid".to_string(),
343            ..Default::default()
344        };
345
346        assert!(cfg.validate().is_err(), "invalid uuid");
347
348        let cfg = ClientConfig {
349            url: "https://hypersync.xyz".to_string(),
350            ..Default::default()
351        };
352
353        assert!(cfg.validate().is_err(), "missing api token");
354
355        let cfg = ClientConfig {
356            api_token: "00000000-0000-0000-0000-000000000000".to_string(),
357            ..Default::default()
358        };
359
360        assert!(cfg.validate().is_err(), "missing url");
361        let cfg = ClientConfig {
362            http_req_timeout_millis: 0,
363            ..valid_cfg
364        };
365        assert!(
366            cfg.validate().is_err(),
367            "http_req_timeout_millis must be greater than 0"
368        );
369    }
370
371    #[test]
372    fn test_stream_config_defaults() {
373        let default_config = StreamConfig::default();
374
375        // Check that all defaults are applied correctly
376        assert_eq!(default_config.concurrency, 10);
377        assert_eq!(default_config.batch_size, 1000);
378        assert_eq!(default_config.max_batch_size, None);
379        assert_eq!(default_config.min_batch_size, 200);
380        assert_eq!(default_config.response_bytes_target, 400_000);
381        assert_eq!(default_config.max_buffered_bytes, None);
382        assert!(!default_config.reverse);
383        assert_eq!(default_config.hex_output, HexOutput::NoEncode);
384        assert!(default_config.column_mapping.is_none());
385        assert!(default_config.event_signature.is_none());
386        assert!(default_config.max_num_blocks.is_none());
387        assert!(default_config.max_num_transactions.is_none());
388        assert!(default_config.max_num_logs.is_none());
389        assert!(default_config.max_num_traces.is_none());
390    }
391
392    #[test]
393    fn test_stream_config_serde() {
394        // Test serialization of default config
395        let default_config = StreamConfig::default();
396        let json = serde_json::to_string(&default_config).unwrap();
397        let deserialized: StreamConfig = serde_json::from_str(&json).unwrap();
398
399        // Verify round-trip works
400        assert_eq!(deserialized.concurrency, default_config.concurrency);
401        assert_eq!(deserialized.batch_size, default_config.batch_size);
402        assert_eq!(deserialized.reverse, default_config.reverse);
403
404        // Test partial JSON (missing some fields should use defaults)
405        let partial_json = r#"{"reverse": true, "batch_size": 500}"#;
406        let partial_config: StreamConfig = serde_json::from_str(partial_json).unwrap();
407
408        assert!(partial_config.reverse);
409        assert_eq!(partial_config.batch_size, 500);
410        assert_eq!(partial_config.concurrency, 10); // should use default
411        assert_eq!(partial_config.max_batch_size, None); // should use default
412        assert_eq!(partial_config.response_bytes_target, 400_000); // should use default
413        assert_eq!(partial_config.max_buffered_bytes, None); // should use default
414
415        // Explicitly setting the new optional caps round-trips.
416        let explicit_json = r#"{"max_batch_size": 50000, "response_bytes_target": 800000, "max_buffered_bytes": 1048576}"#;
417        let explicit_config: StreamConfig = serde_json::from_str(explicit_json).unwrap();
418        assert_eq!(explicit_config.max_batch_size, Some(50_000));
419        assert_eq!(explicit_config.response_bytes_target, 800_000);
420        assert_eq!(explicit_config.max_buffered_bytes, Some(1_048_576));
421    }
422
423    #[test]
424    fn test_stream_config_presets() {
425        // Dense: more parallelism, default target, adaptive buffer.
426        let dense = StreamConfig::dense();
427        assert_eq!(dense.concurrency, 20);
428        assert_eq!(dense.response_bytes_target, 400_000);
429        assert_eq!(dense.max_buffered_bytes, None);
430
431        // Sparse: moderate concurrency, big first wave.
432        let sparse = StreamConfig::sparse();
433        assert_eq!(sparse.concurrency, 10);
434        assert_eq!(sparse.batch_size, 20_000);
435        assert_eq!(sparse.max_buffered_bytes, None);
436
437        // Archival: modest concurrency, relies on adaptive buffer.
438        let archival = StreamConfig::archival();
439        assert_eq!(archival.concurrency, 12);
440        assert_eq!(archival.max_buffered_bytes, None);
441
442        // Presets keep the rest of the defaults.
443        assert_eq!(dense.min_batch_size, StreamConfig::default_min_batch_size());
444        assert!(!sparse.reverse);
445    }
446}