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}