From f0ebcc91f4e39c057c847360bf5025b1b04c3b0e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 11:24:12 +0200 Subject: [PATCH 1/3] fix(flatfiles): emit strikes in dollars and bound the wire connection The CSV and JSONL writers emitted the raw scaled wire strike (tenths of a cent) while the Arrow and typed-row paths emitted dollars, so the same request produced two different strike units depending on output format. Strikes are dollars on every client-facing surface; the scaled integer wire form must never reach a caller. A single shared conversion now feeds CSV, JSONL, Arrow, and the typed row so all four agree on the exact value, and a cross-surface test pins the invariant. The flat-file wire path also had no connect or read timeout, so a host that accepted the socket but never finished the TLS handshake, or a server that stalled mid-stream, would block a download forever. The session now bounds the combined connect plus auth handshake per host and bounds the wait for each response frame, classifying both as transient so the existing retry ladder reconnects. The bounds default to the same connect budget as the historical channel, with a generous inter-frame ceiling that never cuts off a slow-but-progressing transfer. Co-Authored-By: Claude Opus 4.8 --- crates/thetadatadx/src/config/flatfiles.rs | 30 ++++ .../thetadatadx/src/flatfiles/decoded_row.rs | 7 +- crates/thetadatadx/src/flatfiles/index.rs | 25 +++ crates/thetadatadx/src/flatfiles/request.rs | 27 ++- crates/thetadatadx/src/flatfiles/session.rs | 91 ++++++++-- crates/thetadatadx/src/flatfiles/writer.rs | 165 +++++++++++++++++- 6 files changed, 323 insertions(+), 22 deletions(-) diff --git a/crates/thetadatadx/src/config/flatfiles.rs b/crates/thetadatadx/src/config/flatfiles.rs index 7996b3479..f93a624cc 100644 --- a/crates/thetadatadx/src/config/flatfiles.rs +++ b/crates/thetadatadx/src/config/flatfiles.rs @@ -41,6 +41,27 @@ pub struct FlatFilesConfig { /// once, and an un-jittered ladder lands those retries in /// lockstep. Disable only for tests that assert exact timings. pub jitter: bool, + + /// TCP + TLS connect timeout for one legacy-host attempt, in seconds. + /// + /// Bounds the time spent establishing the connection and running the + /// auth handshake against a single host before the attempt is + /// abandoned and the next host (or the retry ladder) takes over. + /// Without it a host that accepts the TCP connection but never + /// completes the TLS handshake would hang the request indefinitely. + /// Mirrors the historical (gRPC) channel's `connect_timeout_secs`. + pub connect_timeout_secs: u64, + + /// Read timeout for a single FLAT_FILE response frame, in seconds. + /// + /// Bounds the wait for the next chunk once streaming has begun. A + /// server that stalls mid-stream — accepting the request but never + /// sending another frame or the FLAT_FILE_END terminator — would + /// otherwise block the download forever. On expiry the attempt fails + /// as a transient stall so the retry ladder reconnects on a fresh + /// session. Sized generously so a slow-but-progressing bulk transfer + /// is never cut off mid-chunk. + pub read_timeout_secs: u64, } impl FlatFilesConfig { @@ -57,6 +78,15 @@ impl FlatFilesConfig { initial_backoff: Duration::from_secs(1), max_backoff: Duration::from_secs(30), jitter: true, + // Matches the historical (gRPC) channel connect bound; long + // enough for a TLS handshake behind NAT / VPN, short enough + // that a black-holed host fails over fast. + connect_timeout_secs: 10, + // A FLAT_FILE stream sends frequent chunks while it is making + // progress; 60 s between frames is far beyond any healthy + // inter-chunk gap, so a longer silence means the server has + // stalled and the attempt should fail over rather than hang. + read_timeout_secs: 60, } } diff --git a/crates/thetadatadx/src/flatfiles/decoded_row.rs b/crates/thetadatadx/src/flatfiles/decoded_row.rs index 67dc840f4..bbf30c17b 100644 --- a/crates/thetadatadx/src/flatfiles/decoded_row.rs +++ b/crates/thetadatadx/src/flatfiles/decoded_row.rs @@ -87,9 +87,10 @@ impl FlatFileRow { Ok(Self { symbol: symbol.to_string(), expiration, - // Vendor fixed-point (1/1000 dollar) -> dollars at the - // typed boundary. - strike: strike.map(|s| f64::from(s) / 1000.0), + // Strikes are dollars on every client-facing surface; the + // shared conversion keeps CSV / JSONL / Arrow / typed-row in + // lockstep on the exact value. + strike: crate::flatfiles::index::strike_dollars(strike), right, fields, }) diff --git a/crates/thetadatadx/src/flatfiles/index.rs b/crates/thetadatadx/src/flatfiles/index.rs index 854435b91..89dc57a6e 100644 --- a/crates/thetadatadx/src/flatfiles/index.rs +++ b/crates/thetadatadx/src/flatfiles/index.rs @@ -104,6 +104,23 @@ pub(crate) fn parse_header(blob: &[u8]) -> Result { }) } +/// Divisor that converts the vendor wire strike (tenths of a cent) into +/// dollars. `210000` wire units ≡ `$210.00`. Invariant: strikes are +/// always dollars on every client-facing surface (CSV, JSONL, Arrow, and +/// the typed [`crate::flatfiles::decoded_row::FlatFileRow`]); the scaled +/// integer wire form must never reach a caller. Centralised here so the +/// scale lives in exactly one place across all output paths. +pub(crate) const STRIKE_WIRE_PER_DOLLAR: f64 = 1000.0; + +/// Convert a wire strike (tenths of a cent) to dollars. +/// +/// Returns `None` for stock entries, which carry no strike. Shared by +/// every output path so CSV, JSONL, Arrow, and the typed row agree on the +/// exact value for the same input. +pub(crate) fn strike_dollars(strike: Option) -> Option { + strike.map(|s| f64::from(s) / STRIKE_WIRE_PER_DOLLAR) +} + /// One INDEX entry's contract key + DATA-section pointer. #[derive(Debug, Clone)] pub(crate) struct IndexEntry { @@ -121,6 +138,14 @@ pub(crate) struct IndexEntry { pub(crate) block_end: u64, } +impl IndexEntry { + /// This entry's strike in dollars, or `None` for stock entries. See + /// [`strike_dollars`] for the shared conversion every output path uses. + pub(crate) fn strike_dollars(&self) -> Option { + strike_dollars(self.strike) + } +} + /// Iterator over INDEX entries. /// /// Holds a borrowed slice of just the INDEX section bytes (i.e. diff --git a/crates/thetadatadx/src/flatfiles/request.rs b/crates/thetadatadx/src/flatfiles/request.rs index 9be01c356..edf42da12 100644 --- a/crates/thetadatadx/src/flatfiles/request.rs +++ b/crates/thetadatadx/src/flatfiles/request.rs @@ -193,7 +193,7 @@ pub async fn flatfile_request_raw_with_config( run_retry_loop(config, move |_attempt| { let creds = creds.clone(); let path = output_for_attempt.clone(); - async move { run_one_attempt(&creds, sec, req, date, &path).await } + async move { run_one_attempt(&creds, sec, req, date, &path, config).await } }) .await } @@ -257,6 +257,7 @@ async fn run_one_attempt( req: ReqType, date: &str, output_path: &Path, + config: &FlatFilesConfig, ) -> Result { // Build the host candidate list — try every (host, port) in priority // order, matching the vendor terminal's `MDDS_NJ_HOSTS` config. @@ -268,7 +269,9 @@ async fn run_one_attempt( } } - let mut session = connect_and_login(&hosts, creds).await?; + let connect_timeout = std::time::Duration::from_secs(config.connect_timeout_secs.max(1)); + let read_timeout = std::time::Duration::from_secs(config.read_timeout_secs.max(1)); + let mut session = connect_and_login(&hosts, creds, connect_timeout).await?; tracing::debug!(target: "flatfiles", "authed against MDDS legacy: bundle={}", session.bundle); let request_id = next_id(); @@ -292,9 +295,22 @@ async fn run_one_attempt( // clean end-of-stream, no bookkeeping flag needed. loop { - let frame = match read_frame(&mut session.stream).await { - Ok(f) => f, - Err(e) => { + // Bound the wait for each frame. A server that stalls mid-stream + // — never sending the next chunk nor FLAT_FILE_END — would + // otherwise hang the download forever. On expiry, surface a + // transient stall so the retry ladder reconnects on a fresh + // session rather than blocking indefinitely. + let frame = match tokio::time::timeout(read_timeout, read_frame(&mut session.stream)).await + { + Err(_) => { + return Err(Error::FlatFilesUnavailable( + FlatFilesUnavailableReason::StreamTruncated { + bytes_received: total, + }, + )); + } + Ok(Ok(f)) => f, + Ok(Err(e)) => { // Differentiate between EOF-after-some-data (truncation) // and an outright protocol error. if total > 0 @@ -442,6 +458,7 @@ mod tests { initial_backoff: std::time::Duration::from_millis(1), max_backoff: std::time::Duration::from_millis(4), jitter: false, + ..FlatFilesConfig::production_defaults() } } diff --git a/crates/thetadatadx/src/flatfiles/session.rs b/crates/thetadatadx/src/flatfiles/session.rs index cdfba5d3c..4dce6b4c4 100644 --- a/crates/thetadatadx/src/flatfiles/session.rs +++ b/crates/thetadatadx/src/flatfiles/session.rs @@ -13,6 +13,7 @@ //! from the METADATA payload. use std::sync::Arc; +use std::time::Duration; use rustls::pki_types::ServerName; use rustls::ClientConfig; @@ -172,24 +173,47 @@ pub(crate) async fn login( /// short-circuited: replaying it across every MDDS host is pointless, /// risks rate-limiting the account, and the original error already /// describes what the server objected to. +/// +/// `connect_timeout` bounds the combined TCP + TLS handshake **and** auth +/// exchange for a single host. A host that accepts the socket but never +/// finishes the TLS handshake, or never returns the auth frames, would +/// otherwise block the whole request forever; on expiry the attempt +/// moves on to the next host (or surfaces a transient timeout the retry +/// ladder reconnects on). pub(crate) async fn connect_and_login<'a>( hosts: &[MddsHost<'a>], creds: &Credentials, + connect_timeout: Duration, ) -> Result { let mut last_err: Option = None; for host in hosts { - match connect_tls(MddsHost { - host: host.host, - port: host.port, - }) - .await - { - Ok(mut stream) => match login(&mut stream, creds).await { - Ok(bundle) => return Ok(AuthedSession { stream, bundle }), - Err(e) if is_terminal_login_error(&e) => return Err(e), - Err(e) => last_err = Some(e), - }, - Err(e) => last_err = Some(e), + let attempt = async { + let mut stream = connect_tls(MddsHost { + host: host.host, + port: host.port, + }) + .await?; + let bundle = login(&mut stream, creds).await?; + Ok::(AuthedSession { stream, bundle }) + }; + match tokio::time::timeout(connect_timeout, attempt).await { + Ok(Ok(session)) => return Ok(session), + Ok(Err(e)) if is_terminal_login_error(&e) => return Err(e), + Ok(Err(e)) => last_err = Some(e), + Err(_) => { + // Treated as a transient I/O timeout so the retry ladder + // reconnects; a stuck handshake on one host should not + // fail the whole request without a reconnect attempt. + last_err = Some(Error::Io(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "MDDS connect/login to {}:{} timed out after {}s", + host.host, + host.port, + connect_timeout.as_secs() + ), + ))); + } } } Err(last_err.unwrap_or_else(|| Error::config_missing("mdds.hosts"))) @@ -224,4 +248,47 @@ mod tests { // first character of the JSON body must be '{'. assert_eq!(p[4], b'{'); } + + #[tokio::test] + async fn connect_and_login_times_out_on_unreachable_host() { + use crate::auth::Credentials; + use std::time::Instant; + + // 192.0.2.1 is TEST-NET-1 (RFC 5737): guaranteed non-routable, so + // the TCP connect stalls until our timeout fires rather than + // failing fast. Without the connect bound this call would hang. + let hosts = [MddsHost { + host: "192.0.2.1", + port: 12_000, + }]; + let creds = Credentials::new("user@example.com", "pw"); + let budget = Duration::from_millis(150); + let started = Instant::now(); + let result = connect_and_login(&hosts, &creds, budget).await; + let elapsed = started.elapsed(); + + // `AuthedSession` is not `Debug`; match the variants by hand so a + // success path fails the test without needing a `Debug` bound. + let err = match result { + Ok(_) => panic!("connect to a black-hole host must fail, not succeed"), + Err(e) => e, + }; + // The bound must actually fire — a hung host cannot block forever. + assert!( + elapsed < Duration::from_secs(5), + "connect must abandon the host near its timeout, took {elapsed:?}" + ); + // Classified transient so the retry ladder reconnects. + assert!( + error_is_transient_for_test(&err), + "connect timeout must be transient, got {err:?}" + ); + } + + /// Mirror of the request-layer transient classifier for the single + /// case this test exercises: a timed-out connect surfaces as + /// `Error::Io`, which the retry loop treats as transient. + fn error_is_transient_for_test(err: &Error) -> bool { + matches!(err, Error::Io(_)) + } } diff --git a/crates/thetadatadx/src/flatfiles/writer.rs b/crates/thetadatadx/src/flatfiles/writer.rs index 29ab154f2..826171d1a 100644 --- a/crates/thetadatadx/src/flatfiles/writer.rs +++ b/crates/thetadatadx/src/flatfiles/writer.rs @@ -132,7 +132,11 @@ fn append_csv_prefix(buf: &mut String, entry: &IndexEntry, sec: SecType) { buf.push(','); let _ = write!(buf, "{}", entry.expiration.unwrap_or(0)); buf.push(','); - let _ = write!(buf, "{}", entry.strike.unwrap_or(0)); + // Strikes are dollars on every client-facing surface — emit + // the same dollar value the Arrow and typed-row paths do, via + // the shared conversion. `f64` Display preserves sub-dollar + // strikes without trailing-zero noise. + let _ = write!(buf, "{}", entry.strike_dollars().unwrap_or(0.0)); buf.push(','); buf.push(entry.right.unwrap_or('?')); buf.push(','); @@ -292,9 +296,17 @@ impl RowSink for JsonlSink { "expiration".into(), serde_json::Value::Number(row.entry.expiration.unwrap_or(0).into()), ); + // Strikes are dollars on every client-facing surface; + // emit the same dollar value as the Arrow and typed-row + // paths via the shared conversion. A non-finite f64 cannot + // arise from an i32/1000 quotient, but `from_f64` is the + // only fallible step, so fall back to JSON null defensively. + let strike_dollars = row.entry.strike_dollars().unwrap_or(0.0); obj.insert( "strike".into(), - serde_json::Value::Number(row.entry.strike.unwrap_or(0).into()), + serde_json::Number::from_f64(strike_dollars) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), ); obj.insert( "right".into(), @@ -510,6 +522,155 @@ mod tests { assert!(res.is_err()); } + /// Read back the strike value the CSV sink wrote for `entry`. CSV + /// strike is the 3rd field: `symbol,expiration,strike,right,...`. + fn csv_strike_for(entry: &IndexEntry, fmt: &[DataType], row_data: &[i32]) -> f64 { + let path = scratch_path(); + let mut csv = + CsvSink::new(&path, SecType::Option, fmt.to_vec(), Some(2)).expect("CsvSink::new"); + csv.write_header().expect("csv header"); + csv.write_row(RowView { + entry, + data: row_data, + }) + .expect("csv row"); + Box::new(csv).finish().expect("csv finish"); + let text = std::fs::read_to_string(&path).expect("read csv"); + text.lines() + .nth(1) + .expect("csv data row") + .split(',') + .nth(2) + .expect("csv strike field") + .parse() + .expect("csv strike parses as f64") + } + + /// Read back the strike value the JSONL sink wrote for `entry`. + fn jsonl_strike_for(entry: &IndexEntry, fmt: &[DataType], row_data: &[i32]) -> f64 { + let path = scratch_path(); + let mut jsonl = + JsonlSink::new(&path, SecType::Option, fmt.to_vec(), Some(2)).expect("JsonlSink::new"); + jsonl.write_header().expect("jsonl header"); + jsonl + .write_row(RowView { + entry, + data: row_data, + }) + .expect("jsonl row"); + Box::new(jsonl).finish().expect("jsonl finish"); + let text = std::fs::read_to_string(&path).expect("read jsonl"); + let parsed: serde_json::Value = + serde_json::from_str(text.lines().next().expect("jsonl line")).expect("jsonl parses"); + parsed["strike"].as_f64().expect("jsonl strike is a number") + } + + #[test] + fn strike_is_dollars_and_identical_across_csv_and_jsonl() { + let fmt = vec![ + DataType::MsOfDay, + DataType::Bid, + DataType::PriceType, + DataType::Date, + ]; + let row_data = [34_200_000_i32, 15_025, 8, 20_250_428]; + // Whole-dollar and sub-dollar wire strikes (tenths of a cent). + // 580000 -> $580.00, 1500 -> $1.50. + for (wire_strike, expected_dollars) in [(580_000_i32, 580.0_f64), (1_500_i32, 1.5_f64)] { + let entry = IndexEntry { + symbol: "SPX".into(), + expiration: Some(20_260_516), + strike: Some(wire_strike), + right: Some('C'), + block_start: 0, + block_end: 0, + }; + let csv_strike = csv_strike_for(&entry, &fmt, &row_data); + let jsonl_strike = jsonl_strike_for(&entry, &fmt, &row_data); + assert!( + (csv_strike - expected_dollars).abs() < 1e-9, + "CSV strike must be dollars: got {csv_strike}, want {expected_dollars}" + ); + assert!( + (jsonl_strike - expected_dollars).abs() < 1e-9, + "JSONL strike must be dollars: got {jsonl_strike}, want {expected_dollars}" + ); + assert_eq!( + csv_strike, jsonl_strike, + "CSV and JSONL strike must be identical" + ); + } + } + + #[cfg(feature = "arrow")] + #[test] + fn strike_is_dollars_and_identical_across_csv_jsonl_arrow() { + use crate::flatfiles::arrow::rows_to_arrow; + use crate::flatfiles::decoded_row::FlatFileRow; + use arrow_array::cast::AsArray; + use arrow_array::types::Float64Type; + + // Schema with a PRICE_TYPE column so every surface decodes prices + // the same way; the strike assertion is independent of price. + let fmt = vec![ + DataType::MsOfDay, + DataType::Bid, + DataType::PriceType, + DataType::Date, + ]; + let data_idx = data_indices(&fmt, Some(2)); + let row_data = [34_200_000_i32, 15_025, 8, 20_250_428]; + + // Whole-dollar and sub-dollar wire strikes (tenths of a cent). + // 580000 -> $580.00, 1500 -> $1.50. + for (wire_strike, expected_dollars) in [(580_000_i32, 580.0_f64), (1_500_i32, 1.5_f64)] { + let entry = IndexEntry { + symbol: "SPX".into(), + expiration: Some(20_260_516), + strike: Some(wire_strike), + right: Some('C'), + block_start: 0, + block_end: 0, + }; + + let csv_strike = csv_strike_for(&entry, &fmt, &row_data); + let jsonl_strike = jsonl_strike_for(&entry, &fmt, &row_data); + + // Arrow: strike column is Float64 dollars. + let typed_row = FlatFileRow::from_decoded( + &entry.symbol, + entry.expiration, + entry.strike, + entry.right, + &fmt, + &row_data, + &data_idx, + Some(row_data[2]), + ) + .expect("from_decoded"); + let batch = rows_to_arrow(std::slice::from_ref(&typed_row)).expect("rows_to_arrow"); + let strike_col = batch + .column_by_name("strike") + .expect("strike column") + .as_primitive::(); + let arrow_strike = strike_col.value(0); + assert!( + (arrow_strike - expected_dollars).abs() < 1e-9, + "Arrow strike must be dollars: got {arrow_strike}, want {expected_dollars}" + ); + + // The load-bearing invariant: all three surfaces agree exactly. + assert_eq!( + csv_strike, jsonl_strike, + "CSV and JSONL strike must be identical" + ); + assert_eq!( + csv_strike, arrow_strike, + "CSV and Arrow strike must be identical" + ); + } + } + #[test] fn csv_sink_write_row_smoke_with_in_range_price_type() { let tmp = scratch_path(); From 85c52002aa8871d88cc1b03f1bc60a85f116835d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 11:56:28 +0200 Subject: [PATCH 2/3] feat(config): expose flatfile connect and read timeouts on every binding Bind FlatFilesConfig.connect_timeout_secs and read_timeout_secs as u64-second config knobs across the C ABI, Python, TypeScript, and C++ surfaces, mirroring the existing flatfile backoff setters so the per-host connect/auth bound and the per-frame read bound are tunable from every language. Each field carries a setter, a getter, a documented production default, a parity row, and a round-trip binding test. Co-Authored-By: Claude Opus 4.8 --- ffi/src/auth.rs | 146 ++++++++++++++++++ scripts/check_doc_defaults.py | 36 +++++ sdks/cpp/include/thetadatadx.h | 35 +++++ sdks/cpp/include/thetadatadx.hpp | 24 +++ sdks/cpp/tests/flatfiles_config.cpp | 33 +++- sdks/parity.toml | 16 +- sdks/python/python/thetadatadx/__init__.pyi | 4 + sdks/python/src/lib.rs | 34 ++++ sdks/python/tests/test_flatfiles_config.py | 39 ++++- .../__tests__/flatfiles_config.test.mjs | 51 +++++- sdks/typescript/index.d.ts | 24 +++ sdks/typescript/src/config_class.rs | 76 +++++++++ 12 files changed, 511 insertions(+), 7 deletions(-) diff --git a/ffi/src/auth.rs b/ffi/src/auth.rs index 72ce7adfa..6ab4a8c72 100644 --- a/ffi/src/auth.rs +++ b/ffi/src/auth.rs @@ -2146,6 +2146,82 @@ pub unsafe extern "C" fn thetadatadx_config_get_flatfiles_max_backoff_secs( }) } +/// Set the TCP + TLS connect timeout (seconds) for one flatfile-host +/// attempt. Bounds the connect/auth handshake before the attempt is +/// abandoned and the next host (or the retry ladder) takes over. +/// Default `10`. +#[no_mangle] +pub unsafe extern "C" fn thetadatadx_config_set_flatfiles_connect_timeout_secs( + config: *mut ThetaDataDxConfig, + secs: u64, +) { + ffi_boundary!((), { + let config = require_config_mut!(config); + config.inner.flatfiles.connect_timeout_secs = secs; + }) +} + +/// Read the current `flatfiles.connect_timeout_secs` setting (seconds). +/// Returns `0` on success, `-1` if either pointer is null. +#[no_mangle] +pub unsafe extern "C" fn thetadatadx_config_get_flatfiles_connect_timeout_secs( + config: *const ThetaDataDxConfig, + out_secs: *mut u64, +) -> i32 { + ffi_boundary!(-1, { + if config.is_null() || out_secs.is_null() { + set_error("config or out-parameter pointer is null"); + return -1; + } + // SAFETY: config is a non-null `*const ThetaDataDxConfig` returned by `thetadatadx_config_*` and not yet freed; `&*` produces a shared reference valid for the call duration. + let config = unsafe { &*config }; + // SAFETY: out_secs null-checked above. The field is a `u64`, so + // the write is layout-compatible with the caller-pinned storage. + unsafe { + *out_secs = config.inner.flatfiles.connect_timeout_secs; + } + 0 + }) +} + +/// Set the read timeout (seconds) for a single flatfile response frame. +/// Bounds the wait for the next chunk once streaming has begun so a +/// mid-stream stall fails over instead of blocking forever. Default +/// `60`. +#[no_mangle] +pub unsafe extern "C" fn thetadatadx_config_set_flatfiles_read_timeout_secs( + config: *mut ThetaDataDxConfig, + secs: u64, +) { + ffi_boundary!((), { + let config = require_config_mut!(config); + config.inner.flatfiles.read_timeout_secs = secs; + }) +} + +/// Read the current `flatfiles.read_timeout_secs` setting (seconds). +/// Returns `0` on success, `-1` if either pointer is null. +#[no_mangle] +pub unsafe extern "C" fn thetadatadx_config_get_flatfiles_read_timeout_secs( + config: *const ThetaDataDxConfig, + out_secs: *mut u64, +) -> i32 { + ffi_boundary!(-1, { + if config.is_null() || out_secs.is_null() { + set_error("config or out-parameter pointer is null"); + return -1; + } + // SAFETY: config is a non-null `*const ThetaDataDxConfig` returned by `thetadatadx_config_*` and not yet freed; `&*` produces a shared reference valid for the call duration. + let config = unsafe { &*config }; + // SAFETY: out_secs null-checked above. The field is a `u64`, so + // the write is layout-compatible with the caller-pinned storage. + unsafe { + *out_secs = config.inner.flatfiles.read_timeout_secs; + } + 0 + }) +} + // ── AuthConfig ───────────────────────────────────────────────────── // // Per-field setters/getters on `DirectConfig.auth`. Both fields are @@ -3350,6 +3426,56 @@ mod flatfiles_setter_tests { } } + #[test] + fn flatfiles_connect_timeout_secs_round_trips() { + let cfg = super::thetadatadx_config_production(); + // SAFETY: handle just returned by thetadatadx_config_production. + unsafe { + let mut got: u64 = 0; + // Default seeded from FlatFilesConfig::production_defaults(). + assert_eq!( + super::thetadatadx_config_get_flatfiles_connect_timeout_secs(cfg, &mut got), + 0 + ); + assert_eq!(got, 10); + for secs in [0u64, 1, 4, 10, 60, 3600] { + super::thetadatadx_config_set_flatfiles_connect_timeout_secs(cfg, secs); + assert_eq!((*cfg).inner.flatfiles.connect_timeout_secs, secs); + assert_eq!( + super::thetadatadx_config_get_flatfiles_connect_timeout_secs(cfg, &mut got), + 0 + ); + assert_eq!(got, secs); + } + super::thetadatadx_config_free(cfg); + } + } + + #[test] + fn flatfiles_read_timeout_secs_round_trips() { + let cfg = super::thetadatadx_config_production(); + // SAFETY: handle just returned by thetadatadx_config_production. + unsafe { + let mut got: u64 = 0; + // Default seeded from FlatFilesConfig::production_defaults(). + assert_eq!( + super::thetadatadx_config_get_flatfiles_read_timeout_secs(cfg, &mut got), + 0 + ); + assert_eq!(got, 60); + for secs in [0u64, 1, 4, 10, 60, 3600, 86_400] { + super::thetadatadx_config_set_flatfiles_read_timeout_secs(cfg, secs); + assert_eq!((*cfg).inner.flatfiles.read_timeout_secs, secs); + assert_eq!( + super::thetadatadx_config_get_flatfiles_read_timeout_secs(cfg, &mut got), + 0 + ); + assert_eq!(got, secs); + } + super::thetadatadx_config_free(cfg); + } + } + #[test] fn flatfiles_setters_null_handle_returns_minus_one_or_noop() { // SAFETY: passing null to thetadatadx_config_* is the documented FFI @@ -3358,6 +3484,8 @@ mod flatfiles_setter_tests { super::thetadatadx_config_set_flatfiles_max_attempts(std::ptr::null_mut(), 3); super::thetadatadx_config_set_flatfiles_initial_backoff_secs(std::ptr::null_mut(), 1); super::thetadatadx_config_set_flatfiles_max_backoff_secs(std::ptr::null_mut(), 4); + super::thetadatadx_config_set_flatfiles_connect_timeout_secs(std::ptr::null_mut(), 10); + super::thetadatadx_config_set_flatfiles_read_timeout_secs(std::ptr::null_mut(), 60); let mut got_n: u32 = 0; let mut got_secs: u64 = 0; assert_eq!( @@ -3378,6 +3506,20 @@ mod flatfiles_setter_tests { ), -1 ); + assert_eq!( + super::thetadatadx_config_get_flatfiles_connect_timeout_secs( + std::ptr::null(), + &mut got_secs + ), + -1 + ); + assert_eq!( + super::thetadatadx_config_get_flatfiles_read_timeout_secs( + std::ptr::null(), + &mut got_secs + ), + -1 + ); } } @@ -3392,10 +3534,14 @@ mod flatfiles_setter_tests { super::thetadatadx_config_set_flatfiles_max_attempts(cfg, 5); super::thetadatadx_config_set_flatfiles_initial_backoff_secs(cfg, 2); super::thetadatadx_config_set_flatfiles_max_backoff_secs(cfg, 30); + super::thetadatadx_config_set_flatfiles_connect_timeout_secs(cfg, 15); + super::thetadatadx_config_set_flatfiles_read_timeout_secs(cfg, 90); let ff = &(*cfg).inner.flatfiles; assert_eq!(ff.max_attempts, 5); assert_eq!(ff.initial_backoff, std::time::Duration::from_secs(2)); assert_eq!(ff.max_backoff, std::time::Duration::from_secs(30)); + assert_eq!(ff.connect_timeout_secs, 15); + assert_eq!(ff.read_timeout_secs, 90); super::thetadatadx_config_free(cfg); } } diff --git a/scripts/check_doc_defaults.py b/scripts/check_doc_defaults.py index 628683aab..290ba8d53 100644 --- a/scripts/check_doc_defaults.py +++ b/scripts/check_doc_defaults.py @@ -178,6 +178,8 @@ def load_canonical(root: pathlib.Path = REPO_ROOT) -> dict[str, int]: canon["flatfiles.max_attempts"] = _norm_int(ff["max_attempts"]) canon["flatfiles.initial_backoff_secs"] = _duration_secs(ff["initial_backoff"]) canon["flatfiles.max_backoff_secs"] = _duration_secs(ff["max_backoff"]) + canon["flatfiles.connect_timeout_secs"] = _norm_int(ff["connect_timeout_secs"]) + canon["flatfiles.read_timeout_secs"] = _norm_int(ff["read_timeout_secs"]) # FlatFilesConfig bounds — `MAX_ATTEMPTS: ... = 1..=100`. ff_src = _read(CONFIG_DIR / "flatfiles.rs", root) @@ -543,6 +545,14 @@ def build_surfaces() -> list[Surface]: SurfaceField( "flatfiles.max_backoff_secs", _re(r"set_flatfiles_max_backoff_secs\b") ), + SurfaceField( + "flatfiles.connect_timeout_secs", + _re(r"set_flatfiles_connect_timeout_secs\b"), + ), + SurfaceField( + "flatfiles.read_timeout_secs", + _re(r"set_flatfiles_read_timeout_secs\b"), + ), ] surfaces.append(ffi) @@ -607,6 +617,14 @@ def build_surfaces() -> list[Surface]: SurfaceField( "flatfiles.max_backoff_secs", _re(r"set_flatfiles_max_backoff_secs\b") ), + SurfaceField( + "flatfiles.connect_timeout_secs", + _re(r"set_flatfiles_connect_timeout_secs\b"), + ), + SurfaceField( + "flatfiles.read_timeout_secs", + _re(r"set_flatfiles_read_timeout_secs\b"), + ), ] surfaces.append(cpp_h) @@ -654,6 +672,14 @@ def build_surfaces() -> list[Surface]: SurfaceField( "flatfiles.max_backoff_secs", _re(r"setFlatfilesMaxBackoffSecs\b") ), + SurfaceField( + "flatfiles.connect_timeout_secs", + _re(r"setFlatfilesConnectTimeoutSecs\b"), + ), + SurfaceField( + "flatfiles.read_timeout_secs", + _re(r"setFlatfilesReadTimeoutSecs\b"), + ), ] surfaces.append(ts) @@ -701,6 +727,14 @@ def build_surfaces() -> list[Surface]: SurfaceField( "flatfiles.max_backoff_secs", _re(r"^\s*flatfiles_max_backoff_secs:") ), + SurfaceField( + "flatfiles.connect_timeout_secs", + _re(r"^\s*flatfiles_connect_timeout_secs:"), + ), + SurfaceField( + "flatfiles.read_timeout_secs", + _re(r"^\s*flatfiles_read_timeout_secs:"), + ), SurfaceField("streaming.timeout_ms", _re(r"^\s*streaming_timeout_ms:")), SurfaceField("streaming.data_watchdog_ms", _re(r"^\s*streaming_data_watchdog_ms:")), SurfaceField( @@ -805,6 +839,8 @@ def _selftest() -> int: initial_backoff: Duration::from_secs(1), max_backoff: Duration::from_secs(30), jitter: true, + connect_timeout_secs: 10, + read_timeout_secs: 60, } } } diff --git a/sdks/cpp/include/thetadatadx.h b/sdks/cpp/include/thetadatadx.h index 249f4f2ce..3c41a40e1 100644 --- a/sdks/cpp/include/thetadatadx.h +++ b/sdks/cpp/include/thetadatadx.h @@ -1626,6 +1626,41 @@ void thetadatadx_config_set_flatfiles_max_backoff_secs(ThetaDataDxConfig* config */ int32_t thetadatadx_config_get_flatfiles_max_backoff_secs(const ThetaDataDxConfig* config, uint64_t* out_secs); +/** + * Set the TCP + TLS connect timeout (seconds) for one flatfile-host + * attempt. Bounds the connect/auth handshake before the attempt is + * abandoned and the next host (or the retry ladder) takes over. + * Default 10. + * @param config Config handle to mutate; no-op when NULL. + * @param secs Connect timeout in seconds. + */ +void thetadatadx_config_set_flatfiles_connect_timeout_secs(ThetaDataDxConfig* config, uint64_t secs); + +/** + * Read the flatfile connect-timeout setting (seconds). + * @param config Config handle to read. + * @param out_secs Receives the connect timeout in seconds on success. + * @return 0 on success, -1 if either pointer is null. + */ +int32_t thetadatadx_config_get_flatfiles_connect_timeout_secs(const ThetaDataDxConfig* config, uint64_t* out_secs); + +/** + * Set the read timeout (seconds) for a single flatfile response frame. + * Bounds the wait for the next chunk once streaming has begun so a + * mid-stream stall fails over instead of blocking forever. Default 60. + * @param config Config handle to mutate; no-op when NULL. + * @param secs Read timeout in seconds. + */ +void thetadatadx_config_set_flatfiles_read_timeout_secs(ThetaDataDxConfig* config, uint64_t secs); + +/** + * Read the flatfile read-timeout setting (seconds). + * @param config Config handle to read. + * @param out_secs Receives the read timeout in seconds on success. + * @return 0 on success, -1 if either pointer is null. + */ +int32_t thetadatadx_config_get_flatfiles_read_timeout_secs(const ThetaDataDxConfig* config, uint64_t* out_secs); + /* ── AuthConfig field setters/getters ── */ /** diff --git a/sdks/cpp/include/thetadatadx.hpp b/sdks/cpp/include/thetadatadx.hpp index befc1e3bc..24a83f1de 100644 --- a/sdks/cpp/include/thetadatadx.hpp +++ b/sdks/cpp/include/thetadatadx.hpp @@ -1088,6 +1088,30 @@ class Config { return out; } + /** TCP + TLS connect timeout (seconds) for one flatfile-host attempt. + * Bounds the connect/auth handshake before the next host (or the + * retry ladder) takes over. Default 10. */ + void set_flatfiles_connect_timeout_secs(uint64_t secs) { + thetadatadx_config_set_flatfiles_connect_timeout_secs(handle_.get(), secs); + } + uint64_t get_flatfiles_connect_timeout_secs() const { + uint64_t out{}; + thetadatadx_config_get_flatfiles_connect_timeout_secs(handle_.get(), &out); + return out; + } + + /** Read timeout (seconds) for a single flatfile response frame. Bounds + * the wait for the next chunk so a mid-stream stall fails over + * instead of blocking forever. Default 60. */ + void set_flatfiles_read_timeout_secs(uint64_t secs) { + thetadatadx_config_set_flatfiles_read_timeout_secs(handle_.get(), secs); + } + uint64_t get_flatfiles_read_timeout_secs() const { + uint64_t out{}; + thetadatadx_config_get_flatfiles_read_timeout_secs(handle_.get(), &out); + return out; + } + // ── AuthConfig field setters/getters ── /** diff --git a/sdks/cpp/tests/flatfiles_config.cpp b/sdks/cpp/tests/flatfiles_config.cpp index cb0ce6a01..ac642ee32 100644 --- a/sdks/cpp/tests/flatfiles_config.cpp +++ b/sdks/cpp/tests/flatfiles_config.cpp @@ -2,8 +2,9 @@ // with Python / TypeScript / FFI. // // Pins the C++ surface contract for `set_flatfiles_max_attempts`, -// `set_flatfiles_initial_backoff_secs`, and -// `set_flatfiles_max_backoff_secs`. The Rust core enforces the +// `set_flatfiles_initial_backoff_secs`, +// `set_flatfiles_max_backoff_secs`, `set_flatfiles_connect_timeout_secs`, +// and `set_flatfiles_read_timeout_secs`. The Rust core enforces the // `[1, 10]` range on `max_attempts` and the // `max_backoff >= initial_backoff` invariant at // `DirectConfig::validate` time, not at the C ABI setter; this file @@ -24,6 +25,8 @@ TEST_CASE("Config exposes FlatFilesConfig production defaults", REQUIRE(cfg.get_flatfiles_max_attempts() == 10u); REQUIRE(cfg.get_flatfiles_initial_backoff_secs() == 1u); REQUIRE(cfg.get_flatfiles_max_backoff_secs() == 30u); + REQUIRE(cfg.get_flatfiles_connect_timeout_secs() == 10u); + REQUIRE(cfg.get_flatfiles_read_timeout_secs() == 60u); } TEST_CASE("Config::set_flatfiles_max_attempts round-trips via getter", @@ -58,6 +61,28 @@ TEST_CASE("Config::set_flatfiles_max_backoff_secs round-trips via getter", } } +TEST_CASE("Config::set_flatfiles_connect_timeout_secs round-trips via getter", + "[config][flatfiles][offline]") { + auto cfg = thetadatadx::Config::production(); + for (std::uint64_t secs : {std::uint64_t{0}, std::uint64_t{1}, + std::uint64_t{4}, std::uint64_t{10}, + std::uint64_t{60}, std::uint64_t{3600}}) { + REQUIRE_NOTHROW(cfg.set_flatfiles_connect_timeout_secs(secs)); + REQUIRE(cfg.get_flatfiles_connect_timeout_secs() == secs); + } +} + +TEST_CASE("Config::set_flatfiles_read_timeout_secs round-trips via getter", + "[config][flatfiles][offline]") { + auto cfg = thetadatadx::Config::production(); + for (std::uint64_t secs : {std::uint64_t{0}, std::uint64_t{1}, + std::uint64_t{4}, std::uint64_t{60}, + std::uint64_t{3600}, std::uint64_t{86'400}}) { + REQUIRE_NOTHROW(cfg.set_flatfiles_read_timeout_secs(secs)); + REQUIRE(cfg.get_flatfiles_read_timeout_secs() == secs); + } +} + TEST_CASE("FlatFiles setters compose with pool-sizing setters", "[config][flatfiles][offline]") { // Interleaved flatfiles setter and pool-sizing setter calls on @@ -66,9 +91,13 @@ TEST_CASE("FlatFiles setters compose with pool-sizing setters", REQUIRE_NOTHROW(cfg.set_flatfiles_max_attempts(7)); REQUIRE_NOTHROW(cfg.set_flatfiles_initial_backoff_secs(3)); REQUIRE_NOTHROW(cfg.set_flatfiles_max_backoff_secs(12)); + REQUIRE_NOTHROW(cfg.set_flatfiles_connect_timeout_secs(20)); + REQUIRE_NOTHROW(cfg.set_flatfiles_read_timeout_secs(45)); REQUIRE_NOTHROW(cfg.set_concurrent_requests(4)); REQUIRE(cfg.get_flatfiles_max_attempts() == 7u); REQUIRE(cfg.get_flatfiles_initial_backoff_secs() == 3u); REQUIRE(cfg.get_flatfiles_max_backoff_secs() == 12u); + REQUIRE(cfg.get_flatfiles_connect_timeout_secs() == 20u); + REQUIRE(cfg.get_flatfiles_read_timeout_secs() == 45u); } diff --git a/sdks/parity.toml b/sdks/parity.toml index 61b2cae81..097865f45 100644 --- a/sdks/parity.toml +++ b/sdks/parity.toml @@ -227,7 +227,9 @@ cpp = true # `thetadatadx::Config::set_flatfiles_*` (which forwards to the FFI # `thetadatadx_config_set_flatfiles_*` symbols). `max_attempts` is `u32`, the # two `Duration` fields cross the binding boundary as `u64` seconds -# (`initial_backoff_secs` / `max_backoff_secs`). +# (`initial_backoff_secs` / `max_backoff_secs`), and the two timeout +# fields (`connect_timeout_secs` / `read_timeout_secs`) are `u64` seconds +# directly. [[class]] name = "FlatFilesConfig.max_attempts" @@ -253,6 +255,18 @@ python = true typescript = true cpp = true +[[class]] +name = "FlatFilesConfig.connect_timeout_secs" +python = true +typescript = true +cpp = true + +[[class]] +name = "FlatFilesConfig.read_timeout_secs" +python = true +typescript = true +cpp = true + # ─── HistoricalConfig cross-binding setters ──────────────────────────── # # Tuning knobs on the historical sub-config exposed across every binding. diff --git a/sdks/python/python/thetadatadx/__init__.pyi b/sdks/python/python/thetadatadx/__init__.pyi index b1502d655..ea89bb083 100644 --- a/sdks/python/python/thetadatadx/__init__.pyi +++ b/sdks/python/python/thetadatadx/__init__.pyi @@ -155,6 +155,10 @@ class Config: """Ceiling back-off, in seconds, of the flat-file driver retry loop (default 30).""" flatfiles_jitter: bool """Whether jitter is applied to flat-file driver retry delays (default ``True``).""" + flatfiles_connect_timeout_secs: int + """TCP + TLS connect timeout, in seconds, for one flat-file host attempt (default 10).""" + flatfiles_read_timeout_secs: int + """Read timeout, in seconds, for a single flat-file response frame (default 60).""" nexus_url: str """Authentication endpoint URL (defaults to the production endpoint).""" client_type: str diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index 73558ce5d..80b83b7ca 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -958,6 +958,40 @@ impl Config { guard.flatfiles.max_backoff.as_secs() } + /// Set the TCP + TLS connect timeout (seconds) for one flatfile-host + /// attempt. Bounds the connect/auth handshake before the attempt is + /// abandoned and the next host (or the retry ladder) takes over. + /// Default ``10``. + #[setter] + fn set_flatfiles_connect_timeout_secs(&self, secs: u64) { + let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.flatfiles.connect_timeout_secs = secs; + } + + /// Current ``flatfiles.connect_timeout_secs`` value in seconds. + #[getter] + fn get_flatfiles_connect_timeout_secs(&self) -> u64 { + let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.flatfiles.connect_timeout_secs + } + + /// Set the read timeout (seconds) for a single flatfile response + /// frame. Bounds the wait for the next chunk once streaming has begun + /// so a mid-stream stall fails over instead of blocking forever. + /// Default ``60``. + #[setter] + fn set_flatfiles_read_timeout_secs(&self, secs: u64) { + let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.flatfiles.read_timeout_secs = secs; + } + + /// Current ``flatfiles.read_timeout_secs`` value in seconds. + #[getter] + fn get_flatfiles_read_timeout_secs(&self) -> u64 { + let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.flatfiles.read_timeout_secs + } + // ── AuthConfig field setters/getters ────────────────────────────── // // Per-field access on ``DirectConfig.auth``. Both fields are diff --git a/sdks/python/tests/test_flatfiles_config.py b/sdks/python/tests/test_flatfiles_config.py index 30fd5cba8..aa1b1f67c 100644 --- a/sdks/python/tests/test_flatfiles_config.py +++ b/sdks/python/tests/test_flatfiles_config.py @@ -2,7 +2,8 @@ TypeScript / C++ / FFI. Pins the Python surface contract for ``flatfiles_max_attempts``, -``flatfiles_initial_backoff_secs``, and ``flatfiles_max_backoff_secs``. +``flatfiles_initial_backoff_secs``, ``flatfiles_max_backoff_secs``, +``flatfiles_connect_timeout_secs``, and ``flatfiles_read_timeout_secs``. The Rust core enforces the ``[1, 10]`` range on ``max_attempts`` and the ``max_backoff >= initial_backoff`` invariant at ``DirectConfig::validate`` time; this file pins only that the Python @@ -34,6 +35,8 @@ def test_flatfiles_defaults_mirror_production_defaults() -> None: assert cfg.flatfiles_max_attempts == 10 assert cfg.flatfiles_initial_backoff_secs == 1 assert cfg.flatfiles_max_backoff_secs == 30 + assert cfg.flatfiles_connect_timeout_secs == 10 + assert cfg.flatfiles_read_timeout_secs == 60 # ─── Round-trip ───────────────────────────────────────────────────── @@ -71,6 +74,24 @@ def test_flatfiles_max_backoff_secs_round_trips() -> None: assert cfg.flatfiles_max_backoff_secs == secs +def test_flatfiles_connect_timeout_secs_round_trips() -> None: + """Setter / getter pair round-trips across the documented u64 range.""" + mod = _import_module() + cfg = mod.Config.production() + for secs in (0, 1, 4, 10, 60, 3_600): + cfg.flatfiles_connect_timeout_secs = secs + assert cfg.flatfiles_connect_timeout_secs == secs + + +def test_flatfiles_read_timeout_secs_round_trips() -> None: + """Setter / getter pair round-trips across the documented u64 range.""" + mod = _import_module() + cfg = mod.Config.production() + for secs in (0, 1, 4, 10, 60, 3_600, 86_400): + cfg.flatfiles_read_timeout_secs = secs + assert cfg.flatfiles_read_timeout_secs == secs + + # ─── Boundary cases ───────────────────────────────────────────────── @@ -92,6 +113,10 @@ def test_flatfiles_backoff_secs_rejects_negative() -> None: cfg.flatfiles_initial_backoff_secs = -1 with pytest.raises(OverflowError): cfg.flatfiles_max_backoff_secs = -1 + with pytest.raises(OverflowError): + cfg.flatfiles_connect_timeout_secs = -1 + with pytest.raises(OverflowError): + cfg.flatfiles_read_timeout_secs = -1 def test_flatfiles_backoff_secs_rejects_above_u64() -> None: @@ -102,6 +127,10 @@ def test_flatfiles_backoff_secs_rejects_above_u64() -> None: cfg.flatfiles_initial_backoff_secs = 1 << 65 with pytest.raises(OverflowError): cfg.flatfiles_max_backoff_secs = 1 << 65 + with pytest.raises(OverflowError): + cfg.flatfiles_connect_timeout_secs = 1 << 65 + with pytest.raises(OverflowError): + cfg.flatfiles_read_timeout_secs = 1 << 65 # ─── Composed state ───────────────────────────────────────────────── @@ -117,9 +146,13 @@ def test_flatfiles_field_setters_compose_into_consistent_config() -> None: cfg.flatfiles_max_attempts = 5 cfg.flatfiles_initial_backoff_secs = 2 cfg.flatfiles_max_backoff_secs = 30 + cfg.flatfiles_connect_timeout_secs = 15 + cfg.flatfiles_read_timeout_secs = 90 assert cfg.flatfiles_max_attempts == 5 assert cfg.flatfiles_initial_backoff_secs == 2 assert cfg.flatfiles_max_backoff_secs == 30 + assert cfg.flatfiles_connect_timeout_secs == 15 + assert cfg.flatfiles_read_timeout_secs == 90 def test_flatfiles_setter_state_survives_interleaved_calls() -> None: @@ -131,8 +164,12 @@ def test_flatfiles_setter_state_survives_interleaved_calls() -> None: cfg.flatfiles_max_attempts = 7 cfg.flatfiles_initial_backoff_secs = 3 cfg.flatfiles_max_backoff_secs = 12 + cfg.flatfiles_connect_timeout_secs = 20 + cfg.flatfiles_read_timeout_secs = 45 cfg.concurrent_requests = 4 assert cfg.concurrent_requests == 4 assert cfg.flatfiles_max_attempts == 7 assert cfg.flatfiles_initial_backoff_secs == 3 assert cfg.flatfiles_max_backoff_secs == 12 + assert cfg.flatfiles_connect_timeout_secs == 20 + assert cfg.flatfiles_read_timeout_secs == 45 diff --git a/sdks/typescript/__tests__/flatfiles_config.test.mjs b/sdks/typescript/__tests__/flatfiles_config.test.mjs index c07415b88..7309f164b 100644 --- a/sdks/typescript/__tests__/flatfiles_config.test.mjs +++ b/sdks/typescript/__tests__/flatfiles_config.test.mjs @@ -1,7 +1,8 @@ // FlatFilesConfig setters on `Config` — TypeScript binding parity // with Python / C++ / FFI. Pins the JS surface contract for -// `setFlatfilesMaxAttempts`, `setFlatfilesInitialBackoffSecs`, and -// `setFlatfilesMaxBackoffSecs`. +// `setFlatfilesMaxAttempts`, `setFlatfilesInitialBackoffSecs`, +// `setFlatfilesMaxBackoffSecs`, `setFlatfilesConnectTimeoutSecs`, and +// `setFlatfilesReadTimeoutSecs`. // // The Rust core enforces the `[1, 10]` range on `max_attempts` and // the `max_backoff >= initial_backoff` invariant at @@ -22,11 +23,13 @@ try { const { Config } = mod; describe('Config.flatFiles* — defaults mirror FlatFilesConfig::production_defaults', () => { - it('expose the three FlatFilesConfig field defaults', () => { + it('expose the FlatFilesConfig field defaults', () => { const cfg = Config.production(); assert.equal(cfg.flatfilesMaxAttempts, 10); assert.equal(cfg.flatfilesInitialBackoffSecs, 1n); assert.equal(cfg.flatfilesMaxBackoffSecs, 30n); + assert.equal(cfg.flatfilesConnectTimeoutSecs, 10n); + assert.equal(cfg.flatfilesReadTimeoutSecs, 60n); }); }); @@ -78,16 +81,58 @@ describe('Config.setFlatfilesMaxBackoffSecs', () => { }); }); +describe('Config.setFlatfilesConnectTimeoutSecs', () => { + it('round-trips through the setter across the documented u64 range', () => { + const cfg = Config.production(); + for (const secs of [0n, 1n, 4n, 10n, 60n, 3_600n]) { + cfg.setFlatfilesConnectTimeoutSecs(secs); + assert.equal(cfg.flatfilesConnectTimeoutSecs, secs); + } + }); + + it('rejects BigInt magnitudes above u64::MAX', () => { + const cfg = Config.production(); + assert.throws( + () => cfg.setFlatfilesConnectTimeoutSecs(1n << 65n), + /setFlatfilesConnectTimeoutSecs/, + 'magnitude above u64::MAX must be rejected at the boundary', + ); + }); +}); + +describe('Config.setFlatfilesReadTimeoutSecs', () => { + it('round-trips through the setter across the documented u64 range', () => { + const cfg = Config.production(); + for (const secs of [0n, 1n, 4n, 10n, 60n, 3_600n, 86_400n]) { + cfg.setFlatfilesReadTimeoutSecs(secs); + assert.equal(cfg.flatfilesReadTimeoutSecs, secs); + } + }); + + it('rejects BigInt magnitudes above u64::MAX', () => { + const cfg = Config.production(); + assert.throws( + () => cfg.setFlatfilesReadTimeoutSecs(1n << 65n), + /setFlatfilesReadTimeoutSecs/, + 'magnitude above u64::MAX must be rejected at the boundary', + ); + }); +}); + describe('FlatFiles setter state survives interleaved pool-sizing calls', () => { it('FlatFiles setter mutations land independently of pool-sizing mutations', () => { const cfg = Config.production(); cfg.setFlatfilesMaxAttempts(7); cfg.setFlatfilesInitialBackoffSecs(3n); cfg.setFlatfilesMaxBackoffSecs(12n); + cfg.setFlatfilesConnectTimeoutSecs(20n); + cfg.setFlatfilesReadTimeoutSecs(45n); cfg.setConcurrentRequests(4); assert.equal(cfg.flatfilesMaxAttempts, 7); assert.equal(cfg.flatfilesInitialBackoffSecs, 3n); assert.equal(cfg.flatfilesMaxBackoffSecs, 12n); + assert.equal(cfg.flatfilesConnectTimeoutSecs, 20n); + assert.equal(cfg.flatfilesReadTimeoutSecs, 45n); assert.equal(cfg.concurrentRequests, 4); }); }); diff --git a/sdks/typescript/index.d.ts b/sdks/typescript/index.d.ts index a46ae8ad3..8b87c1378 100644 --- a/sdks/typescript/index.d.ts +++ b/sdks/typescript/index.d.ts @@ -421,6 +421,30 @@ export declare class Config { setFlatfilesMaxBackoffSecs(secs: bigint): void /** Current `flatfiles.max_backoff` value (seconds, returned as BigInt). */ get flatfilesMaxBackoffSecs(): bigint + /** + * Set the TCP + TLS connect timeout (seconds) for one flatfile-host + * attempt. Bounds the connect/auth handshake before the attempt is + * abandoned and the next host (or the retry ladder) takes over. + * Default `10n`. + * + * Accepts a `bigint` for parity with the other bindings, which + * use a 64-bit unsigned integer. + */ + setFlatfilesConnectTimeoutSecs(secs: bigint): void + /** Current `flatfiles.connect_timeout_secs` value (seconds, returned as BigInt). */ + get flatfilesConnectTimeoutSecs(): bigint + /** + * Set the read timeout (seconds) for a single flatfile response + * frame. Bounds the wait for the next chunk once streaming has begun + * so a mid-stream stall fails over instead of blocking forever. + * Default `60n`. + * + * Accepts a `bigint` for parity with the other bindings, which + * use a 64-bit unsigned integer. + */ + setFlatfilesReadTimeoutSecs(secs: bigint): void + /** Current `flatfiles.read_timeout_secs` value (seconds, returned as BigInt). */ + get flatfilesReadTimeoutSecs(): bigint /** * Set the Nexus auth URL. Default matches the upstream * production endpoint; override to redirect at a staging diff --git a/sdks/typescript/src/config_class.rs b/sdks/typescript/src/config_class.rs index c3d71648e..f89c85e3b 100644 --- a/sdks/typescript/src/config_class.rs +++ b/sdks/typescript/src/config_class.rs @@ -1282,6 +1282,82 @@ impl Config { )) } + /// Set the TCP + TLS connect timeout (seconds) for one flatfile-host + /// attempt. Bounds the connect/auth handshake before the attempt is + /// abandoned and the next host (or the retry ladder) takes over. + /// Default `10n`. + /// + /// Accepts a `bigint` for parity with the other bindings, which + /// use a 64-bit unsigned integer. + #[napi(js_name = "setFlatfilesConnectTimeoutSecs")] + pub fn set_flatfiles_connect_timeout_secs( + &self, + secs: napi::bindgen_prelude::BigInt, + ) -> napi::Result<()> { + let (_signed, value, lossless) = secs.get_u64(); + if !lossless { + return Err(napi::Error::from_reason( + "setFlatfilesConnectTimeoutSecs: BigInt magnitude must fit in u64", + )); + } + let mut guard = self + .inner + .lock() + .map_err(|_| napi::Error::from_reason("Config mutex poisoned"))?; + guard.flatfiles.connect_timeout_secs = value; + Ok(()) + } + + /// Current `flatfiles.connect_timeout_secs` value (seconds, returned as BigInt). + #[napi(getter, js_name = "flatfilesConnectTimeoutSecs")] + pub fn flatfiles_connect_timeout_secs(&self) -> napi::Result { + let guard = self + .inner + .lock() + .map_err(|_| napi::Error::from_reason("Config mutex poisoned"))?; + Ok(napi::bindgen_prelude::BigInt::from( + guard.flatfiles.connect_timeout_secs, + )) + } + + /// Set the read timeout (seconds) for a single flatfile response + /// frame. Bounds the wait for the next chunk once streaming has begun + /// so a mid-stream stall fails over instead of blocking forever. + /// Default `60n`. + /// + /// Accepts a `bigint` for parity with the other bindings, which + /// use a 64-bit unsigned integer. + #[napi(js_name = "setFlatfilesReadTimeoutSecs")] + pub fn set_flatfiles_read_timeout_secs( + &self, + secs: napi::bindgen_prelude::BigInt, + ) -> napi::Result<()> { + let (_signed, value, lossless) = secs.get_u64(); + if !lossless { + return Err(napi::Error::from_reason( + "setFlatfilesReadTimeoutSecs: BigInt magnitude must fit in u64", + )); + } + let mut guard = self + .inner + .lock() + .map_err(|_| napi::Error::from_reason("Config mutex poisoned"))?; + guard.flatfiles.read_timeout_secs = value; + Ok(()) + } + + /// Current `flatfiles.read_timeout_secs` value (seconds, returned as BigInt). + #[napi(getter, js_name = "flatfilesReadTimeoutSecs")] + pub fn flatfiles_read_timeout_secs(&self) -> napi::Result { + let guard = self + .inner + .lock() + .map_err(|_| napi::Error::from_reason("Config mutex poisoned"))?; + Ok(napi::bindgen_prelude::BigInt::from( + guard.flatfiles.read_timeout_secs, + )) + } + // ── AuthConfig field setters/getters ────────────────────────── /// Set the Nexus auth URL. Default matches the upstream From 35e3e4352bccf2f5dfed52c8c51394f586e5885c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 13:11:25 +0200 Subject: [PATCH 3/3] test(flatfiles): pin the synthetic golden CSV to the dollar strike The decode-only golden fixed the strike at the raw wire integer; the CSV writer now emits dollars like every other output surface, so the golden expectation moves to 580 to match. --- crates/thetadatadx/tests/flatfiles_synthetic_golden.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs b/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs index 2d66f49d3..46e65b5d7 100644 --- a/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs +++ b/crates/thetadatadx/tests/flatfiles_synthetic_golden.rs @@ -122,16 +122,18 @@ fn synthetic_option_blob() -> Vec { /// Manual derivation: /// - header columns (price_type column suppressed by the writer): /// `symbol,expiration,strike,right,ms_of_day,bid,date`. -/// - row 1: contract prefix `SPY,20240315,580000,C` plus -/// `34200000,123.45,20240315`. Bid=12345 with price_type=8 → +/// - row 1: contract prefix `SPY,20240315,580,C` plus +/// `34200000,123.45,20240315`. The wire strike of 580000 thousandths +/// renders as 580 dollars, the same unit every other output surface +/// emits. Bid=12345 with price_type=8 → /// `12345 / 10^(10-8) = 12345 / 100 = 123.45`. /// - row 2: contract prefix is the same; ms_of_day delta +100 → /// 34200100; bid delta +5 → 12350 → 123.5 (Rust f64 Display drops /// the trailing zero); date carried forward → 20240315. const EXPECTED_CSV: &str = "\ symbol,expiration,strike,right,ms_of_day,bid,date -SPY,20240315,580000,C,34200000,123.45,20240315 -SPY,20240315,580000,C,34200100,123.5,20240315 +SPY,20240315,580,C,34200000,123.45,20240315 +SPY,20240315,580,C,34200100,123.5,20240315 "; #[test]