Skip to main content

questdb/
ingress.rs

1/*******************************************************************************
2 *     ___                  _   ____  ____
3 *    / _ \ _   _  ___  ___| |_|  _ \| __ )
4 *   | | | | | | |/ _ \/ __| __| | | |  _ \
5 *   | |_| | |_| |  __/\__ \ |_| |_| | |_) |
6 *    \__\_\\__,_|\___||___/\__|____/|____/
7 *
8 *  Copyright (c) 2014-2019 Appsicle
9 *  Copyright (c) 2019-2025 QuestDB
10 *
11 *  Licensed under the Apache License, Version 2.0 (the "License");
12 *  you may not use this file except in compliance with the License.
13 *  You may obtain a copy of the License at
14 *
15 *  http://www.apache.org/licenses/LICENSE-2.0
16 *
17 *  Unless required by applicable law or agreed to in writing, software
18 *  distributed under the License is distributed on an "AS IS" BASIS,
19 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 *  See the License for the specific language governing permissions and
21 *  limitations under the License.
22 *
23 ******************************************************************************/
24
25#![cfg_attr(feature = "_sync-sender", doc = include_str!("ingress/mod.md"))]
26#![cfg_attr(
27    not(feature = "_sync-sender"),
28    doc = "Shared data types used by the egress reader. Enable a `sync-sender-*` \
29           feature to expose the sender APIs and their full module documentation."
30)]
31
32#[cfg(feature = "_sender-qwp-ws")]
33pub(crate) use self::conf::QwpWsManagedSlotExclusion;
34pub use self::ndarr::{ArrayElement, NdArrayView};
35pub use self::timestamp::*;
36use crate::error::Result;
37#[cfg(feature = "_sync-sender")]
38use crate::error::{self, fmt};
39#[cfg(feature = "_sync-sender")]
40use crate::ingress::conf::ConfigSetting;
41#[cfg(feature = "_sync-sender")]
42use core::time::Duration;
43#[cfg(feature = "_sync-sender")]
44use std::collections::HashMap;
45#[cfg(feature = "_sender-qwp-ws")]
46use std::collections::HashSet;
47#[cfg(feature = "_sync-sender")]
48use std::fmt::Write;
49use std::fmt::{Debug, Display, Formatter};
50
51#[cfg(feature = "_sync-sender")]
52use std::ops::Deref;
53#[cfg(feature = "_sender-qwp-ws")]
54use std::path::Path;
55#[cfg(feature = "_sync-sender")]
56use std::path::PathBuf;
57#[cfg(feature = "_sync-sender")]
58use std::str::FromStr;
59
60#[cfg(feature = "_sync-sender")]
61mod tls;
62
63#[cfg(all(feature = "_sender-tcp", feature = "aws-lc-crypto"))]
64use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair};
65
66#[cfg(all(feature = "_sender-tcp", feature = "ring-crypto"))]
67use ring::{
68    rand::SystemRandom,
69    signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair},
70};
71
72#[cfg(feature = "_sync-sender")]
73mod conf;
74
75#[cfg(feature = "_sender-qwp-ws")]
76pub mod conn_events;
77#[cfg(feature = "_sender-qwp-ws")]
78pub use conn_events::{
79    ConnectionEvent, ConnectionEventDispatcher, ConnectionEventKind, ConnectionListener,
80};
81
82#[cfg(feature = "_sender-qwp-ws")]
83pub(crate) mod rejection_events;
84
85pub(crate) mod ndarr;
86
87mod timestamp;
88
89mod buffer;
90pub use buffer::*;
91
92#[cfg(feature = "_sync-sender")]
93pub(crate) mod sender;
94#[cfg(feature = "_sender-qwp-ws")]
95pub(crate) use sender::QwpWsRoleReject;
96#[cfg(feature = "polars-ingress")]
97pub(crate) use sender::ReconnectPolicy;
98#[cfg(feature = "sync-sender-qwp-ws")]
99pub(crate) use sender::ReconnectReason;
100#[cfg(feature = "_sync-sender")]
101pub use sender::*;
102#[cfg(feature = "sync-sender-qwp-ws")]
103pub(crate) use sender::{reconnect_backoff_step, reconnect_error_is_terminal};
104
105mod decimal;
106pub use decimal::DecimalView;
107
108#[cfg(feature = "sync-sender-qwp-ws")]
109pub mod column_sender;
110
111/// Acknowledgement level shared by the column-major and row-major QWP/WebSocket
112/// senders' `wait` / `sync` APIs.
113#[cfg(feature = "sync-sender-qwp-ws")]
114#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum AckLevel {
117    /// Wait for the server to accept every published frame.
118    #[default]
119    Ok,
120    /// Wait for durable-ACK coverage. This level requires QuestDB Enterprise
121    /// and the `request_durable_ack=on` connection-string setting.
122    Durable,
123}
124
125/// Precision of a timestamp column, selecting the QWP wire type used by
126/// [`Chunk::column_ts`](crate::ingress::column_sender::Chunk::column_ts):
127/// [`TimestampUnit::Micros`] maps to `TIMESTAMP` and [`TimestampUnit::Nanos`]
128/// to `TIMESTAMP_NANOS`. Column values are Unix-epoch integers in the chosen
129/// unit.
130#[cfg(feature = "sync-sender-qwp-ws")]
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum TimestampUnit {
133    /// Microseconds since the Unix epoch (QWP wire type `TIMESTAMP`).
134    Micros,
135    /// Nanoseconds since the Unix epoch (QWP wire type `TIMESTAMP_NANOS`).
136    Nanos,
137}
138
139#[cfg(feature = "polars-ingress")]
140pub mod polars;
141
142const MAX_NAME_LEN_DEFAULT: usize = 127;
143
144/// The maximum allowed dimensions for arrays.
145pub const MAX_ARRAY_DIMS: usize = 32;
146pub const MAX_ARRAY_BUFFER_SIZE: usize = 512 * 1024 * 1024; // 512MiB
147pub const MAX_ARRAY_DIM_LEN: usize = 0x0FFF_FFFF; // 1 << 28 - 1
148
149/// Maximum element count of a single ndarray row payload (`prod(shape)`).
150/// Bounds the per-row reservation (`leaf_count * 8` bytes) well below
151/// `isize::MAX` so allocator-OOM cannot abort the host under
152/// `panic = "abort"`. Enforced on both the FFI and pure-Rust entry
153/// points to keep the contract uniform across API surfaces.
154pub const MAX_NDARRAY_LEAF_ELEMS: usize = 1 << 24;
155
156pub(crate) const ARRAY_BINARY_FORMAT_TYPE: u8 = 14;
157pub(crate) const DOUBLE_BINARY_FORMAT_TYPE: u8 = 16;
158pub const DECIMAL_BINARY_FORMAT_TYPE: u8 = 23;
159
160/// Transport-scoped protocol version identifier used by the ingestion APIs.
161///
162/// Interpret this value together with the transport protocol.
163/// The same version number may correspond to different wire formats or feature
164/// sets on different transports.
165#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
166pub enum ProtocolVersion {
167    /// Version 1.
168    V1 = 1,
169
170    /// Version 2.
171    V2 = 2,
172
173    /// Version 3.
174    V3 = 3,
175}
176
177/// List of supported ILP protocol versions, in order of preference (highest to lowest).
178#[cfg(feature = "_sender-http")]
179const SUPPORTED_PROTOCOL_VERSIONS: [ProtocolVersion; 3] = [
180    ProtocolVersion::V3,
181    ProtocolVersion::V2,
182    ProtocolVersion::V1,
183];
184
185impl Display for ProtocolVersion {
186    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
187        match self {
188            ProtocolVersion::V1 => write!(f, "v1"),
189            ProtocolVersion::V2 => write!(f, "v2"),
190            ProtocolVersion::V3 => write!(f, "v3"),
191        }
192    }
193}
194
195#[cfg(feature = "_sender-tcp")]
196fn map_io_to_socket_err(prefix: &str, io_err: std::io::Error) -> error::Error {
197    fmt!(SocketError, "{}{}", prefix, io_err)
198}
199
200/// Possible sources of the root certificates used to validate the server's TLS
201/// certificate.
202#[derive(PartialEq, Debug, Clone, Copy)]
203pub enum CertificateAuthority {
204    /// Use the root certificates provided by the
205    /// [`webpki-roots`](https://crates.io/crates/webpki-roots) crate.
206    #[cfg(feature = "tls-webpki-certs")]
207    WebpkiRoots,
208
209    /// Use the root certificates provided by the OS
210    #[cfg(feature = "tls-native-certs")]
211    OsRoots,
212
213    /// Combine the root certificates provided by the OS and the `webpki-roots` crate.
214    #[cfg(all(feature = "tls-webpki-certs", feature = "tls-native-certs"))]
215    WebpkiAndOsRoots,
216
217    /// Use the root certificates provided in a PEM-encoded file.
218    PemFile,
219}
220
221/// A `u16` port number or `String` port service name as is registered with
222/// `/etc/services` or equivalent.
223///
224/// ```
225/// use questdb::ingress::Port;
226/// use std::convert::Into;
227///
228/// let service: Port = 9009.into();
229/// ```
230///
231/// or
232///
233/// ```
234/// use questdb::ingress::Port;
235/// use std::convert::Into;
236///
237/// // Assuming the service name is registered.
238/// let service: Port = "qdb_ilp".into();  // or with a String too.
239/// ```
240#[cfg(feature = "_sync-sender")]
241pub struct Port(String);
242
243#[cfg(feature = "_sync-sender")]
244impl From<String> for Port {
245    fn from(s: String) -> Self {
246        Port(s)
247    }
248}
249
250#[cfg(feature = "_sync-sender")]
251impl From<&str> for Port {
252    fn from(s: &str) -> Self {
253        Port(s.to_owned())
254    }
255}
256
257#[cfg(feature = "_sync-sender")]
258impl From<u16> for Port {
259    fn from(p: u16) -> Self {
260        Port(p.to_string())
261    }
262}
263
264#[cfg(feature = "_sync-sender")]
265fn validate_auto_flush_params(params: &HashMap<String, String>) -> Result<()> {
266    if let Some(auto_flush) = params.get("auto_flush")
267        && auto_flush.as_str() != "off"
268    {
269        return Err(error::fmt!(
270            ConfigError,
271            "Invalid auto_flush value '{auto_flush}'. This client does not \
272            support auto-flush, so the only accepted value is 'off'"
273        ));
274    }
275
276    for &param in ["auto_flush_rows", "auto_flush_bytes", "auto_flush_interval"].iter() {
277        if params.contains_key(param) {
278            return Err(error::fmt!(
279                ConfigError,
280                "Invalid configuration parameter {:?}. This client does not support auto-flush",
281                param
282            ));
283        }
284    }
285    Ok(())
286}
287
288/// Protocol used to communicate with the QuestDB server.
289///
290/// `#[non_exhaustive]` so new wire protocols can be added without breaking
291/// exhaustive matches in downstream code (the surface already covers ILP/TCP,
292/// ILP/HTTP, QWP/UDP, and QWP/WS, and is expected to grow).
293#[derive(PartialEq, Debug, Clone, Copy)]
294#[non_exhaustive]
295#[cfg(feature = "_sync-sender")]
296pub enum Protocol {
297    #[cfg(feature = "_sender-tcp")]
298    /// ILP over TCP (streaming).
299    Tcp,
300
301    #[cfg(feature = "_sender-tcp")]
302    /// TCP + TLS
303    Tcps,
304
305    #[cfg(feature = "_sender-http")]
306    /// ILP over HTTP (request-response)
307    /// Version 1 is compatible with the InfluxDB Line Protocol.
308    Http,
309
310    #[cfg(feature = "_sender-http")]
311    /// HTTP + TLS
312    Https,
313
314    #[cfg(feature = "_sender-qwp-udp")]
315    /// Quest Wire Protocol over UDP datagrams (IPv4-only).
316    Udp,
317
318    #[cfg(feature = "_sender-qwp-ws")]
319    /// Quest Wire Protocol over WebSocket (RFC 6455).
320    Ws,
321
322    #[cfg(feature = "_sender-qwp-ws")]
323    /// Quest Wire Protocol over WebSocket Secure (TLS).
324    Wss,
325}
326
327#[cfg(feature = "_sync-sender")]
328impl Display for Protocol {
329    fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
330        f.write_str(self.schema())
331    }
332}
333
334#[cfg(feature = "_sync-sender")]
335impl Protocol {
336    fn default_port(&self) -> &str {
337        match *self {
338            #[cfg(feature = "_sender-tcp")]
339            Protocol::Tcp | Protocol::Tcps => "9009",
340            #[cfg(feature = "_sender-http")]
341            Protocol::Http | Protocol::Https => "9000",
342            #[cfg(feature = "_sender-qwp-udp")]
343            Protocol::Udp => "9007",
344            #[cfg(feature = "_sender-qwp-ws")]
345            Protocol::Ws | Protocol::Wss => "9000",
346        }
347    }
348
349    fn tls_enabled(&self) -> bool {
350        match *self {
351            #[cfg(feature = "_sender-tcp")]
352            Protocol::Tcp => false,
353            #[cfg(feature = "_sender-tcp")]
354            Protocol::Tcps => true,
355            #[cfg(feature = "_sender-http")]
356            Protocol::Http => false,
357            #[cfg(feature = "_sender-http")]
358            Protocol::Https => true,
359            #[cfg(feature = "_sender-qwp-udp")]
360            Protocol::Udp => false,
361            #[cfg(feature = "_sender-qwp-ws")]
362            Protocol::Ws => false,
363            #[cfg(feature = "_sender-qwp-ws")]
364            Protocol::Wss => true,
365        }
366    }
367
368    #[cfg(feature = "_sender-tcp")]
369    fn is_tcpx(&self) -> bool {
370        match self {
371            Protocol::Tcp | Protocol::Tcps => true,
372            #[cfg(feature = "_sender-http")]
373            Protocol::Http | Protocol::Https => false,
374            #[cfg(feature = "_sender-qwp-udp")]
375            Protocol::Udp => false,
376            #[cfg(feature = "_sender-qwp-ws")]
377            Protocol::Ws | Protocol::Wss => false,
378        }
379    }
380
381    #[cfg(feature = "_sender-http")]
382    fn is_httpx(&self) -> bool {
383        match self {
384            #[cfg(feature = "_sender-tcp")]
385            Protocol::Tcp | Protocol::Tcps => false,
386            Protocol::Http | Protocol::Https => true,
387            #[cfg(feature = "_sender-qwp-udp")]
388            Protocol::Udp => false,
389            #[cfg(feature = "_sender-qwp-ws")]
390            Protocol::Ws | Protocol::Wss => false,
391        }
392    }
393
394    #[cfg(feature = "_sender-qwp-udp")]
395    fn is_qwp_udp(&self) -> bool {
396        matches!(self, Protocol::Udp)
397    }
398
399    #[cfg(feature = "_sender-qwp-ws")]
400    fn is_qwp_ws(&self) -> bool {
401        matches!(self, Protocol::Ws | Protocol::Wss)
402    }
403
404    /// True if the protocol authenticates via HTTP-style headers
405    /// (basic / bearer-token), i.e. ILP/HTTP or QWP/WebSocket.
406    #[cfg(any(feature = "_sender-http", feature = "_sender-qwp-ws"))]
407    fn accepts_http_auth(&self) -> bool {
408        let mut accepts = false;
409        #[cfg(feature = "_sender-http")]
410        if self.is_httpx() {
411            accepts = true;
412        }
413        #[cfg(feature = "_sender-qwp-ws")]
414        if self.is_qwp_ws() {
415            accepts = true;
416        }
417        accepts
418    }
419
420    fn schema(&self) -> &str {
421        match *self {
422            #[cfg(feature = "_sender-tcp")]
423            Protocol::Tcp => "tcp",
424            #[cfg(feature = "_sender-tcp")]
425            Protocol::Tcps => "tcps",
426            #[cfg(feature = "_sender-http")]
427            Protocol::Http => "http",
428            #[cfg(feature = "_sender-http")]
429            Protocol::Https => "https",
430            #[cfg(feature = "_sender-qwp-udp")]
431            Protocol::Udp => "udp",
432            #[cfg(feature = "_sender-qwp-ws")]
433            Protocol::Ws => "ws",
434            #[cfg(feature = "_sender-qwp-ws")]
435            Protocol::Wss => "wss",
436        }
437    }
438
439    fn from_schema(schema: &str) -> Result<Self> {
440        #[cfg(feature = "_sender-tcp")]
441        if schema.eq_ignore_ascii_case("tcp") {
442            return Ok(Protocol::Tcp);
443        }
444        #[cfg(feature = "_sender-tcp")]
445        if schema.eq_ignore_ascii_case("tcps") {
446            return Ok(Protocol::Tcps);
447        }
448        #[cfg(feature = "_sender-http")]
449        if schema.eq_ignore_ascii_case("http") {
450            return Ok(Protocol::Http);
451        }
452        #[cfg(feature = "_sender-http")]
453        if schema.eq_ignore_ascii_case("https") {
454            return Ok(Protocol::Https);
455        }
456        #[cfg(feature = "_sender-qwp-udp")]
457        if schema.eq_ignore_ascii_case("udp") {
458            return Ok(Protocol::Udp);
459        }
460        #[cfg(feature = "_sender-qwp-udp")]
461        if schema.eq_ignore_ascii_case("udps") {
462            return Err(error::fmt!(ConfigError, "TLS is not supported for UDP."));
463        }
464        #[cfg(feature = "_sender-qwp-ws")]
465        if schema.eq_ignore_ascii_case("ws") {
466            return Ok(Protocol::Ws);
467        }
468        #[cfg(feature = "_sender-qwp-ws")]
469        if schema.eq_ignore_ascii_case("wss") {
470            return Ok(Protocol::Wss);
471        }
472        Err(error::fmt!(ConfigError, "Unsupported protocol: {}", schema))
473    }
474}
475
476#[cfg(any(feature = "_sender-qwp-ws", feature = "_egress"))]
477pub(crate) struct QwpWsAddrScan {
478    pub(crate) addr_values: Vec<String>,
479    pub(crate) sanitized_conf: String,
480}
481
482/// Resolved, reusable ingredients for opening QWP/WebSocket connections to a
483/// rotating set of endpoints. Built once by
484/// [`SenderBuilder::build_qwp_ws_connector`] and held by the ingestion pool,
485/// which drives it through a
486/// shared [`sender::qwp_ws::QwpWsHostHealthTracker`] so every borrow lands on
487/// a live, writable endpoint (skipping unhealthy / role-rejecting ones)
488/// without re-parsing the connect string.
489#[cfg(feature = "sync-sender-qwp-ws")]
490pub(crate) struct QwpWsConnector {
491    host: String,
492    port: String,
493    endpoints: std::sync::Arc<[conf::QwpWsEndpoint]>,
494    use_tls: bool,
495    tls_settings: Option<tls::TlsSettings>,
496    qwp_ws: conf::QwpWsConfig,
497    auth_header: Option<String>,
498    max_buf_size: usize,
499}
500
501#[cfg(feature = "sync-sender-qwp-ws")]
502impl QwpWsConnector {
503    /// Number of configured endpoints — the pool sizes its shared health
504    /// tracker to this.
505    pub(crate) fn endpoint_count(&self) -> usize {
506        self.endpoints.len()
507    }
508
509    /// Endpoint at `idx`, for event narration. `None` when out of range.
510    pub(crate) fn endpoint(&self, idx: usize) -> Option<&conf::QwpWsEndpoint> {
511        self.endpoints.get(idx)
512    }
513
514    pub(crate) fn max_buf_size(&self) -> usize {
515        self.max_buf_size
516    }
517
518    pub(crate) fn request_durable_ack(&self) -> bool {
519        *self.qwp_ws.request_durable_ack
520    }
521
522    pub(crate) fn sender_id(&self) -> &str {
523        self.qwp_ws.sender_id.as_str()
524    }
525
526    pub(crate) fn sf_dir(&self) -> Option<&Path> {
527        self.qwp_ws.sf_dir.as_deref()
528    }
529
530    /// Per-call request timeout parsed from the connect string. The direct
531    /// column backend arms this as the socket read/write timeout; the
532    /// store-and-forward backend uses it as the no-progress deadline in its
533    /// `sync` poll loop so a silent-but-alive peer cannot block the caller
534    /// forever.
535    pub(crate) fn request_timeout(&self) -> Duration {
536        *self.qwp_ws.request_timeout
537    }
538
539    /// Bound on how long the store-and-forward ingestion pool waits for a
540    /// connection's background runner to deliver its queued frames before the
541    /// connection is dropped (pool close / shutdown return). `Duration::ZERO`
542    /// disables the wait. Parsed from `close_flush_timeout_millis`.
543    pub(crate) fn close_flush_timeout(&self) -> Duration {
544        *self.qwp_ws.close_flush_timeout
545    }
546
547    /// Reconnect backoff budget parsed from the connect string's
548    /// `reconnect_*` keys. Only the retry-capable borrow paths consume it
549    /// (the polars `reborrow_with_retry` and the FFI owned `*_with_retry`
550    /// entry points); keep it compiled (so the `ReconnectPolicy` re-export it
551    /// returns stays live) but quiet the dead-code lint when neither is built.
552    #[cfg(feature = "sync-sender-qwp-ws")]
553    #[cfg_attr(
554        not(any(
555            feature = "polars-ingress",
556            feature = "polars-egress",
557            feature = "ffi-support"
558        )),
559        allow(dead_code)
560    )]
561    pub(crate) fn reconnect_policy(&self) -> sender::ReconnectPolicy {
562        sender::ReconnectPolicy::bounded(
563            *self.qwp_ws.reconnect_max_duration,
564            *self.qwp_ws.reconnect_initial_backoff,
565            *self.qwp_ws.reconnect_max_backoff,
566        )
567    }
568
569    /// Pool path: drive the connect round against the *shared* health tracker
570    /// behind `health`, locking it only per tracker operation. The health lock
571    /// is **never** held across
572    /// the blocking TCP/TLS/WS-upgrade handshake, so concurrent cold-start
573    /// borrows no longer serialize end-to-end and dead-sender returns that
574    /// need the same lock are not stalled behind one slow / black-holed
575    /// connect.
576    pub(crate) fn connect_round_pooled(
577        &self,
578        health: &std::sync::Mutex<sender::qwp_ws::QwpWsHostHealthTracker>,
579        events: Option<&conn_events::ConnectionEventSource>,
580    ) -> Result<RawQwpWsRoundStream> {
581        self.connect_round_with(sender::qwp_ws::LockedQwpWsHealth::new(health), events)
582    }
583
584    fn connect_round_with<A: sender::qwp_ws::QwpWsHealthAccess>(
585        &self,
586        health: A,
587        events: Option<&conn_events::ConnectionEventSource>,
588    ) -> Result<RawQwpWsRoundStream> {
589        let mut previous_idx = None;
590        let connected = sender::qwp_ws::connect_qwp_ws_endpoint_round(
591            &self.endpoints,
592            health,
593            &mut previous_idx,
594            None,
595            self.use_tls,
596            self.tls_settings.clone(),
597            sender::qwp_ws::QwpWsConnectKind::Foreground,
598            &self.qwp_ws,
599            self.auth_header.as_deref(),
600            events,
601            None,
602        )?;
603        // The per-frame cap is the negotiated one: the configured
604        // max_buf_size clamped to the server's advertised
605        // X-QWP-Max-Batch-Size (0 = not advertised), matching the row
606        // sender's effective_qwp_ws_max_buf_size.
607        let max_buf_size = if connected.server_max_batch_size > 0 {
608            self.max_buf_size.min(connected.server_max_batch_size)
609        } else {
610            self.max_buf_size
611        };
612        let raw = RawQwpWsRoundStream {
613            endpoint_idx: connected.endpoint_idx,
614            stream: connected.stream,
615            leftover: connected.leftover,
616            max_buf_size,
617            request_timeout: *self.qwp_ws.request_timeout,
618            durable_ack_opt_in: *self.qwp_ws.request_durable_ack,
619        };
620        if let Some(events) = events
621            && let Some(endpoint) = self.endpoints.get(raw.endpoint_idx)
622        {
623            // Publish success only after the negotiated stream state,
624            // including the server frame cap, has been committed locally.
625            events.connect_succeeded(&endpoint.host, &endpoint.port);
626        }
627        Ok(raw)
628    }
629
630    pub(crate) fn connect_sfa_background_with_pool_slot(
631        &self,
632        sender_id: Option<&str>,
633        managed_exclusions: &[conf::QwpWsManagedSlotExclusion],
634        extra_orphan_slots: &[PathBuf],
635        conn_events: std::sync::Arc<conn_events::ConnectionEventSource>,
636        rejection_sink: std::sync::Arc<rejection_events::RejectionEventSource>,
637        force_async_initial_connect: bool,
638    ) -> Result<sender::qwp_ws::SyncQwpWsHandlerState> {
639        let mut qwp_ws = self.qwp_ws.clone();
640        // Reconnect-to-sync promotion applies only to standalone
641        // `SenderBuilder::build()`; pools honor only an explicitly set mode.
642        // Recovery pre-opens still override that mode for this one connect.
643        if force_async_initial_connect {
644            qwp_ws.force_async_initial_connect();
645        }
646        // The pool's shared sources exist (handlers already attached, or
647        // permanently defaulted) before connect-time recovery senders are
648        // pre-opened, so every runner narrates through them from its first
649        // connect.
650        qwp_ws.conn_events = Some(conn_events);
651        qwp_ws.rejection_sink = Some(rejection_sink);
652        configure_qwp_ws_pool_slot(
653            &mut qwp_ws,
654            sender_id,
655            managed_exclusions,
656            extra_orphan_slots,
657        )?;
658        sender::qwp_ws::connect_qwp_ws_background_state(
659            self.host.as_str(),
660            self.port.as_str(),
661            self.use_tls,
662            self.tls_settings.clone(),
663            &qwp_ws,
664            self.auth_header.clone(),
665        )
666    }
667}
668
669#[cfg(feature = "sync-sender-qwp-ws")]
670fn configure_qwp_ws_pool_slot(
671    qwp_ws: &mut conf::QwpWsConfig,
672    sender_id: Option<&str>,
673    managed_exclusions: &[conf::QwpWsManagedSlotExclusion],
674    extra_orphan_slots: &[PathBuf],
675) -> Result<()> {
676    if let Some(sender_id) = sender_id {
677        if !conf::is_valid_qwp_ws_sender_id(sender_id) {
678            return Err(error::fmt!(
679                ConfigError,
680                "invalid pool-managed sender_id [value={sender_id}, allowed-chars=[A-Za-z0-9_-]]"
681            ));
682        }
683        qwp_ws.sender_id = ConfigSetting::new_specified(sender_id.to_owned());
684    }
685    qwp_ws.orphan_exclude_managed_slots = managed_exclusions.to_vec();
686    qwp_ws.orphan_extra_slots = extra_orphan_slots.to_vec();
687    qwp_ws.pool_managed_slot = sender_id.is_some();
688    Ok(())
689}
690
691/// One connection opened by `QwpWsConnector::connect_round_pooled`, tagged with the
692/// endpoint index it landed on so the pool can mark that endpoint unhealthy if
693/// the connection later dies.
694#[cfg(feature = "sync-sender-qwp-ws")]
695pub(crate) struct RawQwpWsRoundStream {
696    pub(crate) endpoint_idx: usize,
697    pub(crate) stream: sender::qwp_ws::WsStream,
698    pub(crate) leftover: Vec<u8>,
699    pub(crate) max_buf_size: usize,
700    pub(crate) request_timeout: Duration,
701    pub(crate) durable_ack_opt_in: bool,
702}
703
704/// Pre-scan a raw connect string for repeated `addr=...` params. Returns the
705/// full list of addr values and a sanitized conf with duplicate `addr=` params
706/// removed (the first one is kept so the downstream `questdb_confstr` parser
707/// still sees a value).
708///
709/// Triggered when the schema is one of `ws` or `wss`; for
710/// any other schema (or a malformed conf), returns `None` and the caller
711/// should fall back to the standard `params.get("addr")` flow.
712#[cfg(any(feature = "_sender-qwp-ws", feature = "_egress"))]
713pub(crate) fn scan_qwp_ws_addr_params(conf: &str) -> Result<Option<QwpWsAddrScan>> {
714    let Some((service, params)) = conf.split_once("::") else {
715        return Ok(None);
716    };
717    if !service.eq_ignore_ascii_case("ws") && !service.eq_ignore_ascii_case("wss") {
718        return Ok(None);
719    }
720
721    let mut addr_values = Vec::new();
722    let mut sanitized_conf = String::with_capacity(conf.len());
723    sanitized_conf.push_str(service);
724    sanitized_conf.push_str("::");
725
726    let params_offset = service.len() + 2;
727    let mut pos = 0usize;
728    while pos < params.len() {
729        let param_start = pos;
730        let Some(eq_rel) = params[pos..].find('=') else {
731            return Ok(None);
732        };
733        let key_start = pos;
734        let key_end = pos + eq_rel;
735        let key = &params[key_start..key_end];
736        pos = key_end + 1;
737
738        let mut value = String::new();
739        while pos < params.len() {
740            let rest = &params[pos..];
741            let mut chars = rest.char_indices();
742            let (_, ch) = chars.next().expect("pos is within params");
743            if ch == ';' {
744                let next_pos = pos + ch.len_utf8();
745                if params[next_pos..].starts_with(';') {
746                    value.push(';');
747                    pos = next_pos + 1;
748                    continue;
749                }
750                pos = next_pos;
751                break;
752            }
753            value.push(ch);
754            pos += ch.len_utf8();
755        }
756
757        let param_end = pos;
758        if key.eq_ignore_ascii_case("addr") {
759            if addr_values.is_empty() {
760                sanitized_conf
761                    .push_str(&conf[params_offset + param_start..params_offset + param_end]);
762            }
763            addr_values.push(value);
764        } else {
765            sanitized_conf.push_str(&conf[params_offset + param_start..params_offset + param_end]);
766        }
767    }
768
769    Ok(Some(QwpWsAddrScan {
770        addr_values,
771        sanitized_conf,
772    }))
773}
774
775#[cfg(feature = "_sender-qwp-ws")]
776fn parse_qwp_ws_endpoints(
777    addr_values: &[String],
778    default_port: &str,
779) -> Result<Vec<conf::QwpWsEndpoint>> {
780    let mut endpoints = Vec::new();
781    let mut seen = HashSet::new();
782    for addr in addr_values {
783        for raw_entry in addr.split(',') {
784            let entry = raw_entry.trim();
785            if entry.is_empty() {
786                return Err(error::fmt!(
787                    ConfigError,
788                    "invalid QWP/WebSocket addr list: empty entry"
789                ));
790            }
791            let (host, port) = if let Some(rest) = entry.strip_prefix('[') {
792                let (host, after) = rest.split_once(']').ok_or_else(|| {
793                    error::fmt!(
794                        ConfigError,
795                        "invalid QWP/WebSocket addr entry {:?}: missing ']'",
796                        entry
797                    )
798                })?;
799                let port = match after.strip_prefix(':') {
800                    Some(port) => port.trim(),
801                    None if after.is_empty() => default_port,
802                    None => {
803                        return Err(error::fmt!(
804                            ConfigError,
805                            "invalid QWP/WebSocket addr entry {:?}: \
806                             expected ':port' after ']'",
807                            entry
808                        ));
809                    }
810                };
811                (host.trim(), port)
812            } else if entry.matches(':').count() > 1 {
813                return Err(error::fmt!(
814                    ConfigError,
815                    "invalid QWP/WebSocket addr entry {:?}: bracket IPv6 \
816                     addresses, e.g. [::1]:9000",
817                    entry
818                ));
819            } else {
820                match entry.split_once(':') {
821                    Some((host, port)) => (host.trim(), port.trim()),
822                    None => (entry, default_port),
823                }
824            };
825            if host.is_empty() {
826                return Err(error::fmt!(
827                    ConfigError,
828                    "invalid QWP/WebSocket addr entry {:?}: empty host",
829                    entry
830                ));
831            }
832            if port.is_empty() {
833                return Err(error::fmt!(
834                    ConfigError,
835                    "invalid QWP/WebSocket addr entry {:?}: empty port",
836                    entry
837                ));
838            }
839            let parsed_port = port.parse::<u16>().map_err(|_| {
840                error::fmt!(
841                    ConfigError,
842                    "invalid QWP/WebSocket addr entry {:?}: invalid port {:?}",
843                    entry,
844                    port
845                )
846            })?;
847            if parsed_port == 0 {
848                return Err(error::fmt!(
849                    ConfigError,
850                    "invalid QWP/WebSocket addr entry {:?}: invalid port {:?}",
851                    entry,
852                    port
853                ));
854            }
855            let normalized_port = parsed_port.to_string();
856            let key = (host.to_string(), normalized_port.clone());
857            if !seen.insert(key.clone()) {
858                return Err(error::fmt!(
859                    ConfigError,
860                    "duplicate QWP/WebSocket addr endpoint {}:{}",
861                    host,
862                    normalized_port
863                ));
864            }
865            endpoints.push(conf::QwpWsEndpoint::new(key.0, key.1));
866        }
867    }
868    if endpoints.is_empty() {
869        return Err(error::fmt!(
870            ConfigError,
871            "Missing \"addr\" parameter in config string"
872        ));
873    }
874    Ok(endpoints)
875}
876
877/// Accumulates parameters for a new `Sender` instance.
878///
879/// You can also create the builder from a config string.
880///
881/// ```no_run
882/// # use questdb::Result;
883/// use questdb::ingress::SenderBuilder;
884///
885/// # fn main() -> Result<()> {
886/// let mut sender = SenderBuilder::from_conf("https::addr=localhost:9000;")?.build()?;
887/// # Ok(())
888/// # }
889/// ```
890///
891/// Or create it from the `QDB_CLIENT_CONF` environment variable.
892///
893/// ```no_run
894/// # use questdb::Result;
895/// use questdb::ingress::SenderBuilder;
896///
897/// # fn main() -> Result<()> {
898/// // export QDB_CLIENT_CONF="https::addr=localhost:9000;"
899/// let mut sender = SenderBuilder::from_env()?.build()?;
900/// # Ok(())
901/// # }
902/// ```
903///
904/// The `SenderBuilder` can also be built programmatically.
905/// The minimum required parameters are the protocol, host, and port.
906///
907/// ```no_run
908/// # use questdb::Result;
909/// use questdb::ingress::SenderBuilder;
910/// use questdb::ingress::Protocol;
911///
912/// # fn main() -> Result<()> {
913/// # #[cfg(feature = "sync-sender-http")] {
914/// let mut sender = SenderBuilder::new(Protocol::Http, "localhost", 9000).build()?;
915/// # }
916/// # #[cfg(all(not(feature = "sync-sender-http"), feature = "sync-sender-tcp"))] {
917/// let mut sender = SenderBuilder::new(Protocol::Tcp, "localhost", 9009).build()?;
918/// # }
919/// # Ok(())
920/// # }
921/// ```
922#[derive(Debug, Clone)]
923#[cfg(feature = "_sync-sender")]
924pub struct SenderBuilder {
925    protocol: Protocol,
926    host: ConfigSetting<String>,
927    port: ConfigSetting<String>,
928    net_interface: ConfigSetting<Option<String>>,
929    init_buf_size: ConfigSetting<usize>,
930    max_buf_size: ConfigSetting<usize>,
931    max_name_len: ConfigSetting<usize>,
932    auth_timeout: ConfigSetting<Duration>,
933    username: ConfigSetting<Option<String>>,
934    password: ConfigSetting<Option<String>>,
935    token: ConfigSetting<Option<String>>,
936
937    #[cfg(feature = "_sender-tcp")]
938    token_x: ConfigSetting<Option<String>>,
939
940    #[cfg(feature = "_sender-tcp")]
941    token_y: ConfigSetting<Option<String>>,
942
943    protocol_version: ConfigSetting<Option<ProtocolVersion>>,
944
945    #[cfg(feature = "insecure-skip-verify")]
946    tls_verify: ConfigSetting<bool>,
947
948    tls_ca: ConfigSetting<CertificateAuthority>,
949    tls_roots: ConfigSetting<Option<PathBuf>>,
950
951    /// Password unlocking a JKS / PKCS#12 keystore named by
952    /// `tls_roots`. QWP/WebSocket only — other transports keep PEM
953    /// as the sole `tls_roots` format.
954    #[cfg(feature = "_sender-qwp-ws")]
955    tls_roots_password: ConfigSetting<Option<String>>,
956
957    #[cfg(feature = "_sender-http")]
958    http: Option<conf::HttpConfig>,
959
960    #[cfg(feature = "_sender-qwp-udp")]
961    qwp_udp: Option<conf::QwpUdpConfig>,
962
963    #[cfg(feature = "_sender-qwp-ws")]
964    qwp_ws: Option<conf::QwpWsConfig>,
965
966    #[cfg(feature = "_sender-qwp-ws")]
967    qwp_ws_error_handler: QwpWsErrorHandler,
968}
969
970#[cfg(feature = "_sync-sender")]
971impl SenderBuilder {
972    /// Create a new `SenderBuilder` instance from the configuration string.
973    ///
974    /// The format of the string is: `"http::addr=host:port;key=value;...;"`.
975    ///
976    /// Instead of `"http"`, you can also specify `"https"`, `"tcp"`, `"tcps"`,
977    /// `"udp"`, and the QWP/WebSocket schemes `"ws"` / `"wss"` when the
978    /// corresponding sender features are enabled.
979    ///
980    /// We recommend HTTP for most cases because it provides more features, like
981    /// reporting errors to the client and supporting transaction control. TCP can
982    /// sometimes be faster in higher-latency networks, but misses a number of
983    /// features.
984    ///
985    /// Many accepted keys match one-for-one with the methods on `SenderBuilder`.
986    /// For example, this is a valid configuration string:
987    ///
988    /// "https::addr=host:port;username=alice;password=secret;"
989    ///
990    /// and there are matching methods [SenderBuilder::username] and
991    /// [SenderBuilder::password]. The value of `addr=` is supplied directly to the
992    /// `SenderBuilder` constructor, so there's no matching method for that.
993    ///
994    /// Some QWP/WebSocket configuration keys are accepted only through the
995    /// configuration string, primarily for compatibility with Java-style
996    /// configuration names and settings without a public Rust builder method.
997    /// These include `sf_dir`, `sender_id`, `sf_max_segment_bytes`,
998    /// `sf_max_total_bytes`, `sf_durability`, `sf_sync_interval_millis`,
999    /// `sf_append_deadline_millis`, `auth_timeout_ms`, `close_flush_timeout_millis`,
1000    /// `request_durable_ack`,
1001    /// `durable_ack_keepalive_interval_millis`, `drain_orphans`,
1002    /// `max_background_drainers`, and `error_inbox_capacity`.
1003    ///
1004    /// `sf_max_segment_bytes` defaults to 4 MiB. Smaller disk-backed segments
1005    /// release acknowledged space more granularly, but rotate more often and
1006    /// therefore increase crash-consistency synchronization and file-operation
1007    /// overhead.
1008    ///
1009    /// You can also load the configuration from an environment variable. See
1010    /// [`SenderBuilder::from_env`].
1011    ///
1012    /// Once you have a `SenderBuilder` instance, you can further customize it
1013    /// before calling [`SenderBuilder::build`], but you can't change any settings
1014    /// that are already set in the config string.
1015    pub fn from_conf<T: AsRef<str>>(conf: T) -> Result<Self> {
1016        let conf = conf.as_ref();
1017        #[cfg(feature = "_sender-qwp-ws")]
1018        let qwp_ws_addr_scan = scan_qwp_ws_addr_params(conf)?;
1019        #[cfg(feature = "_sender-qwp-ws")]
1020        let conf_to_parse = qwp_ws_addr_scan
1021            .as_ref()
1022            .map(|scan| scan.sanitized_conf.as_str())
1023            .unwrap_or(conf);
1024        #[cfg(not(feature = "_sender-qwp-ws"))]
1025        let conf_to_parse = conf;
1026
1027        let conf = questdb_confstr::parse_conf_str(conf_to_parse)
1028            .map_err(|e| error::fmt!(ConfigError, "Config parse error: {}", e))?;
1029        let service = conf.service();
1030        let params = conf.params();
1031
1032        let protocol = Protocol::from_schema(service)?;
1033        #[cfg(feature = "_sender-qwp-ws")]
1034        let conf_is_qwp_ws = protocol.is_qwp_ws();
1035
1036        let Some(addr) = params.get("addr") else {
1037            return Err(error::fmt!(
1038                ConfigError,
1039                "Missing \"addr\" parameter in config string"
1040            ));
1041        };
1042        #[cfg(feature = "_sender-qwp-ws")]
1043        let qwp_ws_endpoints = if protocol.is_qwp_ws() {
1044            let addr_values = qwp_ws_addr_scan
1045                .as_ref()
1046                .map(|scan| scan.addr_values.as_slice())
1047                .unwrap_or_else(|| std::slice::from_ref(addr));
1048            Some(parse_qwp_ws_endpoints(
1049                addr_values,
1050                protocol.default_port(),
1051            )?)
1052        } else {
1053            None
1054        };
1055        let (host, port) = {
1056            #[cfg(feature = "_sender-qwp-ws")]
1057            if let Some(endpoints) = qwp_ws_endpoints.as_ref() {
1058                let first = endpoints.first().ok_or_else(|| {
1059                    error::fmt!(ConfigError, "Missing \"addr\" parameter in config string")
1060                })?;
1061                (first.host.as_str(), first.port.as_str())
1062            } else {
1063                match addr.split_once(':') {
1064                    Some((h, p)) => (h, p),
1065                    None => (addr.as_str(), protocol.default_port()),
1066                }
1067            }
1068
1069            #[cfg(not(feature = "_sender-qwp-ws"))]
1070            {
1071                match addr.split_once(':') {
1072                    Some((h, p)) => (h, p),
1073                    None => (addr.as_str(), protocol.default_port()),
1074                }
1075            }
1076        };
1077        let mut builder = SenderBuilder::new(protocol, host, port);
1078        #[cfg(feature = "_sender-qwp-ws")]
1079        if let Some(endpoints) = qwp_ws_endpoints {
1080            builder = builder.qwp_ws_endpoints(endpoints)?;
1081        }
1082
1083        validate_auto_flush_params(params)?;
1084
1085        // Connect-string keys valid on a `ws::` / `wss::` string that are not
1086        // matched by an arm below: `addr` (consumed before this loop), the
1087        // auto-flush keys (validated by `validate_auto_flush_params`), the
1088        // egress query-client keys (a single connect string drives both the
1089        // sender and the `QwpQueryClient`), and the ingestion-pool keys
1090        // (`pool_*`, consumed by `QuestDb::connect` before it opens each
1091        // per-slot `SenderBuilder::from_conf` connection). Any other key on a
1092        // QWP/WebSocket connect string is rejected as unknown. Keys added to
1093        // any of these directions MUST be reflected here or a shared connect
1094        // string breaks.
1095        #[cfg(feature = "_sender-qwp-ws")]
1096        const QWP_WS_PORTABLE_CONFIG_KEYS: &[&str] = &[
1097            "addr",
1098            "auth",
1099            "auto_flush",
1100            "auto_flush_bytes",
1101            "auto_flush_interval",
1102            "auto_flush_rows",
1103            "buffer_pool_size",
1104            "client_id",
1105            "compression",
1106            "compression_level",
1107            "failover",
1108            "failover_backoff_initial_ms",
1109            "failover_backoff_max_ms",
1110            "failover_max_attempts",
1111            "failover_max_duration_ms",
1112            "max_batch_rows",
1113            "max_version",
1114            "on_internal_error",
1115            "on_parse_error",
1116            "on_schema_error",
1117            "on_security_error",
1118            "on_server_error",
1119            "on_write_error",
1120            "path",
1121            "acquire_timeout_ms",
1122            "idle_timeout_ms",
1123            "lazy_connect",
1124            "pool_reap",
1125            "query_pool_max",
1126            "query_pool_min",
1127            "sender_pool_max",
1128            "sender_pool_min",
1129            "target",
1130            "zone",
1131        ];
1132
1133        for (key, val) in params.iter().map(|(k, v)| (k.as_str(), v.as_str())) {
1134            builder = match key {
1135                "username" => builder.username(val)?,
1136                "password" => builder.password(val)?,
1137                "token" => builder.token(val)?,
1138                "token_x" => builder.token_x(val)?,
1139                "token_y" => builder.token_y(val)?,
1140                "bind_interface" => builder.bind_interface(val)?,
1141                #[cfg(feature = "_sender-qwp-udp")]
1142                "max_datagram_size" => builder.max_datagram_size(parse_conf_value(key, val)?)?,
1143                #[cfg(feature = "_sender-qwp-udp")]
1144                "multicast_ttl" => builder.multicast_ttl(parse_conf_value(key, val)?)?,
1145                #[cfg(feature = "_sender-qwp-ws")]
1146                "qwp_ws_progress" => builder.qwp_ws_progress(parse_qwp_ws_progress_value(val)?)?,
1147                #[cfg(feature = "_sender-qwp-ws")]
1148                "sf_dir" => builder.store_and_forward_dir(PathBuf::from(val))?,
1149                #[cfg(feature = "_sender-qwp-ws")]
1150                "sender_id" => builder.sender_id(val)?,
1151                #[cfg(feature = "_sender-qwp-ws")]
1152                "sf_max_segment_bytes" => {
1153                    builder.store_and_forward_max_bytes(parse_size_conf_value(key, val)?)?
1154                }
1155                #[cfg(feature = "_sender-qwp-ws")]
1156                "sf_max_total_bytes" => {
1157                    builder.store_and_forward_max_total_bytes(parse_size_conf_value(key, val)?)?
1158                }
1159                #[cfg(feature = "_sender-qwp-ws")]
1160                "sf_durability" => {
1161                    builder.store_and_forward_durability(parse_sf_durability_value(val)?)?
1162                }
1163                #[cfg(feature = "_sender-qwp-ws")]
1164                "sf_sync_interval_millis" => builder.store_and_forward_sync_interval_millis(val)?,
1165                #[cfg(feature = "_sender-qwp-ws")]
1166                "sf_append_deadline_millis" => builder.store_and_forward_append_deadline(
1167                    Duration::from_millis(parse_conf_value(key, val)?),
1168                )?,
1169                #[cfg(feature = "_sender-qwp-ws")]
1170                "reconnect_max_duration_millis" => builder
1171                    .reconnect_max_duration(Duration::from_millis(parse_conf_value(key, val)?))?,
1172                #[cfg(feature = "_sender-qwp-ws")]
1173                "reconnect_initial_backoff_millis" => builder.reconnect_initial_backoff(
1174                    Duration::from_millis(parse_conf_value(key, val)?),
1175                )?,
1176                #[cfg(feature = "_sender-qwp-ws")]
1177                "reconnect_max_backoff_millis" => builder
1178                    .reconnect_max_backoff(Duration::from_millis(parse_conf_value(key, val)?))?,
1179                #[cfg(feature = "_sender-qwp-ws")]
1180                "initial_connect_retry" => {
1181                    builder.qwp_ws_initial_connect_mode(parse_initial_connect_retry_value(val)?)?
1182                }
1183                #[cfg(feature = "_sender-qwp-ws")]
1184                "auth_timeout_ms" => builder.qwp_ws_auth_timeout_millis(val)?,
1185                #[cfg(feature = "_sender-qwp-ws")]
1186                "connect_timeout" => builder.qwp_ws_connect_timeout_millis(val)?,
1187                #[cfg(feature = "_sender-qwp-ws")]
1188                "close_flush_timeout_millis" => builder.close_flush_timeout_millis(val)?,
1189                #[cfg(feature = "_sender-qwp-ws")]
1190                "request_durable_ack" => builder.request_durable_ack(val)?,
1191                #[cfg(feature = "_sender-qwp-ws")]
1192                "durable_ack_keepalive_interval_millis" => {
1193                    builder.durable_ack_keepalive_interval_millis(val)?
1194                }
1195                #[cfg(feature = "_sender-qwp-ws")]
1196                "drain_orphans" => builder.drain_orphans(val)?,
1197                #[cfg(feature = "_sender-qwp-ws")]
1198                "max_background_drainers" => builder.max_background_drainers(val)?,
1199                #[cfg(feature = "_sender-qwp-ws")]
1200                "error_inbox_capacity" => builder.error_inbox_capacity(val)?,
1201                #[cfg(feature = "_sender-qwp-ws")]
1202                "max_frame_rejections" => {
1203                    builder.max_frame_rejections(parse_conf_value(key, val)?)?
1204                }
1205                #[cfg(feature = "_sender-qwp-ws")]
1206                "poison_min_escalation_window_millis" => builder.poison_min_escalation_window(
1207                    Duration::from_millis(parse_conf_value(key, val)?),
1208                )?,
1209                "protocol_version" => match val {
1210                    "1" => builder.protocol_version(ProtocolVersion::V1)?,
1211                    "2" => builder.protocol_version(ProtocolVersion::V2)?,
1212                    "3" => builder.protocol_version(ProtocolVersion::V3)?,
1213                    "auto" => builder,
1214                    invalid => {
1215                        return Err(error::fmt!(
1216                            ConfigError,
1217                            "invalid \"protocol_version\" [value={invalid}, allowed-values=[auto, 1, 2, 3]]"
1218                        ));
1219                    }
1220                },
1221                "max_name_len" => builder.max_name_len(parse_conf_value(key, val)?)?,
1222
1223                "init_buf_size" => builder.init_buf_size(parse_conf_value(key, val)?)?,
1224
1225                "max_buf_size" => builder.max_buf_size(parse_conf_value(key, val)?)?,
1226
1227                "auth_timeout" => {
1228                    builder.auth_timeout(Duration::from_millis(parse_conf_value(key, val)?))?
1229                }
1230
1231                "tls_verify" => {
1232                    let verify = match val {
1233                        "on" => true,
1234                        "unsafe_off" => false,
1235                        _ => {
1236                            return Err(fmt!(
1237                                ConfigError,
1238                                r##"Config parameter "tls_verify" must be either "on" or "unsafe_off".'"##,
1239                            ));
1240                        }
1241                    };
1242
1243                    #[cfg(not(feature = "insecure-skip-verify"))]
1244                    {
1245                        if !verify {
1246                            return Err(fmt!(
1247                                ConfigError,
1248                                r##"The "insecure-skip-verify" feature is not enabled, so "tls_verify=unsafe_off" is not supported"##,
1249                            ));
1250                        }
1251                        builder
1252                    }
1253
1254                    #[cfg(feature = "insecure-skip-verify")]
1255                    builder.tls_verify(verify)?
1256                }
1257
1258                "tls_ca" => {
1259                    #[allow(unreachable_code, unused_variables)]
1260                    {
1261                        let ca = match val {
1262                            #[cfg(feature = "tls-webpki-certs")]
1263                            "webpki_roots" => CertificateAuthority::WebpkiRoots,
1264
1265                            #[cfg(not(feature = "tls-webpki-certs"))]
1266                            "webpki_roots" => {
1267                                return Err(error::fmt!(
1268                                    ConfigError,
1269                                    "Config parameter \"tls_ca=webpki_roots\" requires the \"tls-webpki-certs\" feature"
1270                                ));
1271                            }
1272
1273                            #[cfg(feature = "tls-native-certs")]
1274                            "os_roots" => CertificateAuthority::OsRoots,
1275
1276                            #[cfg(not(feature = "tls-native-certs"))]
1277                            "os_roots" => {
1278                                return Err(error::fmt!(
1279                                    ConfigError,
1280                                    "Config parameter \"tls_ca=os_roots\" requires the \"tls-native-certs\" feature"
1281                                ));
1282                            }
1283
1284                            #[cfg(all(feature = "tls-webpki-certs", feature = "tls-native-certs"))]
1285                            "webpki_and_os_roots" => CertificateAuthority::WebpkiAndOsRoots,
1286
1287                            #[cfg(not(all(
1288                                feature = "tls-webpki-certs",
1289                                feature = "tls-native-certs"
1290                            )))]
1291                            "webpki_and_os_roots" => {
1292                                return Err(error::fmt!(
1293                                    ConfigError,
1294                                    "Config parameter \"tls_ca=webpki_and_os_roots\" requires both the \"tls-webpki-certs\" and \"tls-native-certs\" features"
1295                                ));
1296                            }
1297
1298                            _ => {
1299                                return Err(error::fmt!(
1300                                    ConfigError,
1301                                    "Invalid value {val:?} for \"tls_ca\""
1302                                ));
1303                            }
1304                        };
1305                        builder.tls_ca(ca)?
1306                    }
1307                }
1308
1309                "tls_roots" => {
1310                    let path = PathBuf::from_str(val).map_err(|e| {
1311                        error::fmt!(
1312                            ConfigError,
1313                            "Invalid path {:?} for \"tls_roots\": {}",
1314                            val,
1315                            e
1316                        )
1317                    })?;
1318                    builder.tls_roots(path)?
1319                }
1320
1321                "tls_roots_password" => {
1322                    #[cfg(feature = "_sender-qwp-ws")]
1323                    {
1324                        builder.tls_roots_password(val.to_string())?
1325                    }
1326                    #[cfg(not(feature = "_sender-qwp-ws"))]
1327                    {
1328                        return Err(error::fmt!(
1329                            ConfigError,
1330                            "\"tls_roots_password\" is only supported for QWP/WebSocket \
1331                             (ws / wss). ILP/TCP and ILP/HTTP transports read \
1332                             unencrypted PEM via rustls."
1333                        ));
1334                    }
1335                }
1336
1337                #[cfg(feature = "sync-sender-http")]
1338                "request_min_throughput" => {
1339                    builder.request_min_throughput(parse_conf_value(key, val)?)?
1340                }
1341
1342                #[cfg(feature = "sync-sender-http")]
1343                "request_timeout" => {
1344                    builder.request_timeout(Duration::from_millis(parse_conf_value(key, val)?))?
1345                }
1346
1347                #[cfg(feature = "sync-sender-http")]
1348                "retry_timeout" => {
1349                    builder.retry_timeout(Duration::from_millis(parse_conf_value(key, val)?))?
1350                }
1351                #[cfg(feature = "sync-sender-http")]
1352                "retry_max_backoff_millis" => {
1353                    builder.retry_max_backoff(Duration::from_millis(parse_conf_value(key, val)?))?
1354                }
1355
1356                // QWP/WebSocket follows the connect-string spec: a key that is
1357                // neither matched above nor portable (QWP_WS_PORTABLE_CONFIG_KEYS)
1358                // is a typo or unsupported option and is rejected. Legacy ILP
1359                // transports keep ignoring unknown keys -- a parameter added to
1360                // one ILP client must not force a lock-step release of the others.
1361                #[cfg(feature = "_sender-qwp-ws")]
1362                other if conf_is_qwp_ws && !QWP_WS_PORTABLE_CONFIG_KEYS.contains(&other) => {
1363                    return Err(error::fmt!(ConfigError, "Unknown config key \"{}\"", other));
1364                }
1365                _ => builder,
1366            };
1367        }
1368
1369        Ok(builder)
1370    }
1371
1372    /// Create a new `SenderBuilder` instance from the configuration from the
1373    /// configuration stored in the `QDB_CLIENT_CONF` environment variable.
1374    ///
1375    /// The format of the string is the same as for [`SenderBuilder::from_conf`].
1376    pub fn from_env() -> Result<Self> {
1377        let conf = std::env::var("QDB_CLIENT_CONF").map_err(|_| {
1378            error::fmt!(ConfigError, "Environment variable QDB_CLIENT_CONF not set.")
1379        })?;
1380        Self::from_conf(conf)
1381    }
1382
1383    /// Create a new `SenderBuilder` instance with the provided QuestDB
1384    /// server and port, using ILP over the specified protocol.
1385    ///
1386    /// ```no_run
1387    /// # use questdb::Result;
1388    /// use questdb::ingress::{Protocol, SenderBuilder};
1389    ///
1390    /// # fn main() -> Result<()> {
1391    /// # #[cfg(feature = "sync-sender-tcp")] {
1392    /// let mut sender = SenderBuilder::new(
1393    ///     Protocol::Tcp, "localhost", 9009).build()?;
1394    /// # }
1395    /// # #[cfg(all(not(feature = "sync-sender-tcp"), feature = "sync-sender-http"))] {
1396    /// let mut sender = SenderBuilder::new(
1397    ///     Protocol::Http, "localhost", 9000).build()?;
1398    /// # }
1399    /// # Ok(())
1400    /// # }
1401    /// ```
1402    pub fn new<H: Into<String>, P: Into<Port>>(protocol: Protocol, host: H, port: P) -> Self {
1403        let host = host.into();
1404        let port: Port = port.into();
1405        let port = port.0;
1406
1407        #[cfg(feature = "tls-webpki-certs")]
1408        let tls_ca = CertificateAuthority::WebpkiRoots;
1409
1410        #[cfg(all(not(feature = "tls-webpki-certs"), feature = "tls-native-certs"))]
1411        let tls_ca = CertificateAuthority::OsRoots;
1412
1413        #[cfg(not(any(feature = "tls-webpki-certs", feature = "tls-native-certs")))]
1414        let tls_ca = CertificateAuthority::PemFile;
1415
1416        Self {
1417            protocol,
1418            host: ConfigSetting::new_specified(host),
1419            port: ConfigSetting::new_specified(port),
1420            net_interface: ConfigSetting::new_default(None),
1421            init_buf_size: ConfigSetting::new_default(64 * 1024),
1422            max_buf_size: ConfigSetting::new_default(100 * 1024 * 1024),
1423            max_name_len: ConfigSetting::new_default(MAX_NAME_LEN_DEFAULT),
1424            auth_timeout: ConfigSetting::new_default(Duration::from_secs(15)),
1425            username: ConfigSetting::new_default(None),
1426            password: ConfigSetting::new_default(None),
1427            token: ConfigSetting::new_default(None),
1428
1429            #[cfg(feature = "_sender-tcp")]
1430            token_x: ConfigSetting::new_default(None),
1431
1432            #[cfg(feature = "_sender-tcp")]
1433            token_y: ConfigSetting::new_default(None),
1434
1435            protocol_version: ConfigSetting::new_default(None),
1436
1437            #[cfg(feature = "insecure-skip-verify")]
1438            tls_verify: ConfigSetting::new_default(true),
1439
1440            tls_ca: ConfigSetting::new_default(tls_ca),
1441            tls_roots: ConfigSetting::new_default(None),
1442
1443            #[cfg(feature = "_sender-qwp-ws")]
1444            tls_roots_password: ConfigSetting::new_default(None),
1445
1446            #[cfg(feature = "sync-sender-http")]
1447            http: if protocol.is_httpx() {
1448                Some(conf::HttpConfig::default())
1449            } else {
1450                None
1451            },
1452
1453            #[cfg(feature = "_sender-qwp-udp")]
1454            qwp_udp: if protocol.is_qwp_udp() {
1455                Some(conf::QwpUdpConfig::default())
1456            } else {
1457                None
1458            },
1459
1460            #[cfg(feature = "_sender-qwp-ws")]
1461            qwp_ws: if protocol.is_qwp_ws() {
1462                Some(conf::QwpWsConfig::default())
1463            } else {
1464                None
1465            },
1466
1467            #[cfg(feature = "_sender-qwp-ws")]
1468            qwp_ws_error_handler: QwpWsErrorHandler::log_default(),
1469        }
1470    }
1471
1472    /// Install a producer-thread handler for structured QWP/WebSocket server
1473    /// diagnostics.
1474    ///
1475    /// The handler runs synchronously from sender API calls such as `flush`.
1476    /// It must not call methods on the same sender.
1477    #[cfg(feature = "_sender-qwp-ws")]
1478    pub fn qwp_ws_error_handler<F>(mut self, handler: F) -> Result<Self>
1479    where
1480        F: Fn(&QwpWsSenderError) + Send + Sync + 'static,
1481    {
1482        self.qwp_ws_error_handler = QwpWsErrorHandler::new(handler);
1483        Ok(self)
1484    }
1485
1486    /// Select local outbound interface.
1487    ///
1488    /// This may be relevant if your machine has multiple network interfaces.
1489    ///
1490    /// The default is `"0.0.0.0"`.
1491    pub fn bind_interface<I: Into<String>>(self, addr: I) -> Result<Self> {
1492        #[cfg(any(feature = "_sender-tcp", feature = "_sender-qwp-udp"))]
1493        {
1494            let mut builder = self;
1495            builder.ensure_supports_bind_interface("bind_interface")?;
1496            builder
1497                .net_interface
1498                .set_specified("bind_interface", Some(validate_value(addr.into())?))?;
1499            Ok(builder)
1500        }
1501
1502        #[cfg(not(any(feature = "_sender-tcp", feature = "_sender-qwp-udp")))]
1503        {
1504            let _ = addr;
1505            Err(error::fmt!(
1506                ConfigError,
1507                "The \"bind_interface\" setting can only be used with the TCP protocol."
1508            ))
1509        }
1510    }
1511
1512    /// Set the username for authentication.
1513    ///
1514    /// For TCP, this is the `kid` part of the ECDSA key set.
1515    /// The other fields are [`token`](SenderBuilder::token), [`token_x`](SenderBuilder::token_x),
1516    /// and [`token_y`](SenderBuilder::token_y).
1517    ///
1518    /// For HTTP, this is a part of basic authentication.
1519    /// See also: [`password`](SenderBuilder::password).
1520    pub fn username(mut self, username: &str) -> Result<Self> {
1521        #[cfg(feature = "_sender-qwp-udp")]
1522        self.reject_if_qwp_udp("username")?;
1523        self.username
1524            .set_specified("username", Some(validate_value(username.to_string())?))?;
1525        Ok(self)
1526    }
1527
1528    /// Set the password for basic HTTP authentication.
1529    /// See also: [`username`](SenderBuilder::username).
1530    pub fn password(mut self, password: &str) -> Result<Self> {
1531        #[cfg(feature = "_sender-qwp-udp")]
1532        self.reject_if_qwp_udp("password")?;
1533        self.password
1534            .set_specified("password", Some(validate_value(password.to_string())?))?;
1535        Ok(self)
1536    }
1537
1538    /// Set the bearer-token authentication parameter for HTTP or
1539    /// QWP/WebSocket, which requires QuestDB Enterprise, or set the ECDSA
1540    /// private key for TCP authentication.
1541    pub fn token(mut self, token: &str) -> Result<Self> {
1542        #[cfg(feature = "_sender-qwp-udp")]
1543        self.reject_if_qwp_udp("token")?;
1544        self.token
1545            .set_specified("token", Some(validate_value(token.to_string())?))?;
1546        Ok(self)
1547    }
1548
1549    /// Set the ECDSA public key X for TCP authentication.
1550    pub fn token_x(self, token_x: &str) -> Result<Self> {
1551        #[cfg(feature = "_sender-qwp-udp")]
1552        self.reject_if_qwp_udp("token_x")?;
1553        #[cfg(feature = "_sender-tcp")]
1554        {
1555            let mut builder = self;
1556            builder
1557                .token_x
1558                .set_specified("token_x", Some(validate_value(token_x.to_string())?))?;
1559            Ok(builder)
1560        }
1561
1562        #[cfg(not(feature = "_sender-tcp"))]
1563        {
1564            let _ = token_x;
1565            Err(error::fmt!(
1566                ConfigError,
1567                "cannot specify \"token_x\": ECDSA authentication is only available with ILP/TCP and not available with ILP/HTTP."
1568            ))
1569        }
1570    }
1571
1572    /// Set the ECDSA public key Y for TCP authentication.
1573    pub fn token_y(self, token_y: &str) -> Result<Self> {
1574        #[cfg(feature = "_sender-qwp-udp")]
1575        self.reject_if_qwp_udp("token_y")?;
1576        #[cfg(feature = "_sender-tcp")]
1577        {
1578            let mut builder = self;
1579            builder
1580                .token_y
1581                .set_specified("token_y", Some(validate_value(token_y.to_string())?))?;
1582            Ok(builder)
1583        }
1584
1585        #[cfg(not(feature = "_sender-tcp"))]
1586        {
1587            let _ = token_y;
1588            Err(error::fmt!(
1589                ConfigError,
1590                "cannot specify \"token_y\": ECDSA authentication is only available with ILP/TCP and not available with ILP/HTTP."
1591            ))
1592        }
1593    }
1594
1595    /// Sets the protocol version for ILP transports.
1596    /// - HTTP transport automatically negotiates the protocol version by default(unset, **Strong Recommended**).
1597    ///   You can explicitly configure the protocol version to avoid the slight latency cost at connection time.
1598    /// - TCP transport does not negotiate the protocol version and uses [`ProtocolVersion::V1`] by
1599    ///   default. You must explicitly set [`ProtocolVersion::V2`] in order to ingest
1600    ///   arrays.
1601    /// - QWP/UDP does not support explicit `protocol_version` configuration.
1602    ///
1603    /// **Note**: QuestDB server version 9.0.0 or later is required for [`ProtocolVersion::V2`] support.
1604    pub fn protocol_version(mut self, protocol_version: ProtocolVersion) -> Result<Self> {
1605        #[cfg(feature = "_sender-qwp-udp")]
1606        self.reject_if_qwp_udp("protocol_version")?;
1607        self.protocol_version
1608            .set_specified("protocol_version", Some(protocol_version))?;
1609        Ok(self)
1610    }
1611
1612    #[cfg(feature = "_sender-qwp-udp")]
1613    /// Set the maximum datagram size in bytes for QWP/UDP transport.
1614    ///
1615    /// `value` must be between 1 and 65,507 bytes, inclusive. The upper bound
1616    /// is the UDP/IPv4 payload limit, not a recommended operating size. The
1617    /// default is 1,400 bytes, leaving room for IPv4 and UDP headers under a
1618    /// common 1,500-byte Ethernet MTU. If you raise this value, keep it within
1619    /// the effective UDP payload budget for the path MTU. Oversized IPv4
1620    /// packets may be fragmented when fragmentation is allowed, or dropped when
1621    /// it is not; fragmented UDP is fragile because losing any fragment loses
1622    /// the whole datagram.
1623    pub fn max_datagram_size(mut self, value: usize) -> Result<Self> {
1624        if value == 0 {
1625            return Err(error::fmt!(
1626                ConfigError,
1627                "\"max_datagram_size\" must be greater than 0."
1628            ));
1629        }
1630        if value > 65507 {
1631            return Err(error::fmt!(
1632                ConfigError,
1633                "\"max_datagram_size\" must not exceed 65507 (UDP/IPv4 limit)."
1634            ));
1635        }
1636        let Some(qwp_udp) = &mut self.qwp_udp else {
1637            return Err(error::fmt!(
1638                ConfigError,
1639                "The \"max_datagram_size\" setting is only supported for QWP/UDP."
1640            ));
1641        };
1642        qwp_udp
1643            .max_datagram_size
1644            .set_specified("max_datagram_size", value)?;
1645        Ok(self)
1646    }
1647
1648    #[cfg(feature = "_sender-qwp-udp")]
1649    /// Set the multicast TTL for QWP/UDP transport. The default is 1.
1650    ///
1651    /// Use a value greater than 0 when sending to a multicast address. A value
1652    /// of 0 prevents multicast datagrams from leaving the local host.
1653    pub fn multicast_ttl(mut self, value: u32) -> Result<Self> {
1654        if value > 255 {
1655            return Err(error::fmt!(
1656                ConfigError,
1657                "\"multicast_ttl\" must be between 0 and 255."
1658            ));
1659        }
1660        let Some(qwp_udp) = &mut self.qwp_udp else {
1661            return Err(error::fmt!(
1662                ConfigError,
1663                "The \"multicast_ttl\" setting is only supported for QWP/UDP."
1664            ));
1665        };
1666        qwp_udp
1667            .multicast_ttl
1668            .set_specified("multicast_ttl", value)?;
1669        Ok(self)
1670    }
1671
1672    #[cfg(feature = "_sender-qwp-ws")]
1673    /// Register a connection lifecycle listener: one
1674    /// [`ConnectionEvent`] per
1675    /// connection-state transition of this sender's QWP/WebSocket
1676    /// connection (initial connect, per-endpoint attempt failures,
1677    /// disconnect, reconnect/failover, terminal auth rejection).
1678    /// Delivered on a dedicated dispatcher thread through a bounded inbox
1679    /// (`inbox_capacity`; `0` selects the default of 64) with a
1680    /// drop-oldest overflow policy. At most one listener per sender.
1681    pub fn connection_listener(
1682        mut self,
1683        listener: crate::ingress::ConnectionListener,
1684        inbox_capacity: usize,
1685    ) -> Result<Self> {
1686        let Some(qwp_ws) = &mut self.qwp_ws else {
1687            return Err(error::fmt!(
1688                ConfigError,
1689                "The \"connection_listener\" setting is only supported for QWP/WebSocket."
1690            ));
1691        };
1692        if qwp_ws.conn_events.is_some() {
1693            return Err(error::fmt!(
1694                ConfigError,
1695                "A connection listener is already registered on this builder."
1696            ));
1697        }
1698        qwp_ws.conn_events = Some(std::sync::Arc::new(
1699            conn_events::ConnectionEventSource::new(listener, inbox_capacity),
1700        ));
1701        Ok(self)
1702    }
1703
1704    #[cfg(feature = "_sender-qwp-ws")]
1705    /// Control whether QWP/WebSocket progress is driven by a background thread
1706    /// or manually by the caller. The default is [`QwpWsProgress::Background`],
1707    /// matching the Java sender.
1708    pub fn qwp_ws_progress(mut self, progress: QwpWsProgress) -> Result<Self> {
1709        let Some(qwp_ws) = &mut self.qwp_ws else {
1710            return Err(error::fmt!(
1711                ConfigError,
1712                "The \"qwp_ws_progress\" setting is only supported for QWP/WebSocket."
1713            ));
1714        };
1715        qwp_ws.progress.set_specified("qwp_ws_progress", progress)?;
1716        Ok(self)
1717    }
1718
1719    #[cfg(feature = "_sender-qwp-ws")]
1720    fn store_and_forward_dir(mut self, dir: PathBuf) -> Result<Self> {
1721        if dir.as_os_str().is_empty() {
1722            return Err(error::fmt!(ConfigError, "\"sf_dir\" cannot be empty."));
1723        }
1724        let Some(qwp_ws) = &mut self.qwp_ws else {
1725            return Err(error::fmt!(
1726                ConfigError,
1727                "The \"sf_dir\" setting is only supported for QWP/WebSocket."
1728            ));
1729        };
1730        qwp_ws.sf_dir.set_specified("sf_dir", Some(dir))?;
1731        Ok(self)
1732    }
1733
1734    #[cfg(feature = "_sender-qwp-ws")]
1735    fn sender_id(mut self, sender_id: &str) -> Result<Self> {
1736        let sender_id = validate_value(sender_id)?;
1737        if !conf::is_valid_qwp_ws_sender_id(sender_id) {
1738            return Err(error::fmt!(
1739                ConfigError,
1740                "invalid sender_id [value={sender_id}, allowed-chars=[A-Za-z0-9_-]]"
1741            ));
1742        }
1743        let Some(qwp_ws) = &mut self.qwp_ws else {
1744            return Err(error::fmt!(
1745                ConfigError,
1746                "The \"sender_id\" setting is only supported for QWP/WebSocket."
1747            ));
1748        };
1749        qwp_ws
1750            .sender_id
1751            .set_specified("sender_id", sender_id.to_owned())?;
1752        Ok(self)
1753    }
1754
1755    #[cfg(feature = "_sender-qwp-ws")]
1756    fn store_and_forward_max_bytes(mut self, value: u64) -> Result<Self> {
1757        if value == 0 {
1758            return Err(error::fmt!(
1759                ConfigError,
1760                "\"sf_max_segment_bytes\" must be greater than 0."
1761            ));
1762        }
1763        let Some(qwp_ws) = &mut self.qwp_ws else {
1764            return Err(error::fmt!(
1765                ConfigError,
1766                "The \"sf_max_segment_bytes\" setting is only supported for QWP/WebSocket."
1767            ));
1768        };
1769        qwp_ws
1770            .sf_max_segment_bytes
1771            .set_specified("sf_max_segment_bytes", value)?;
1772        Ok(self)
1773    }
1774
1775    #[cfg(feature = "_sender-qwp-ws")]
1776    fn store_and_forward_max_total_bytes(mut self, value: u64) -> Result<Self> {
1777        if value == 0 {
1778            return Err(error::fmt!(
1779                ConfigError,
1780                "\"sf_max_total_bytes\" must be greater than 0."
1781            ));
1782        }
1783        let Some(qwp_ws) = &mut self.qwp_ws else {
1784            return Err(error::fmt!(
1785                ConfigError,
1786                "The \"sf_max_total_bytes\" setting is only supported for QWP/WebSocket."
1787            ));
1788        };
1789        qwp_ws
1790            .sf_max_total_bytes
1791            .set_specified("sf_max_total_bytes", Some(value))?;
1792        Ok(self)
1793    }
1794
1795    #[cfg(feature = "_sender-qwp-ws")]
1796    fn store_and_forward_durability(mut self, durability: conf::SfDurability) -> Result<Self> {
1797        let Some(qwp_ws) = &mut self.qwp_ws else {
1798            return Err(error::fmt!(
1799                ConfigError,
1800                "The \"sf_durability\" setting is only supported for QWP/WebSocket."
1801            ));
1802        };
1803        qwp_ws
1804            .sf_durability
1805            .set_specified("sf_durability", durability)?;
1806        Ok(self)
1807    }
1808
1809    #[cfg(feature = "_sender-qwp-ws")]
1810    fn store_and_forward_sync_interval_millis(mut self, value: &str) -> Result<Self> {
1811        const MAX_MILLIS: i64 = i64::MAX / 1_000_000;
1812
1813        let Some(qwp_ws) = &mut self.qwp_ws else {
1814            return Err(error::fmt!(
1815                ConfigError,
1816                "The \"sf_sync_interval_millis\" setting is only supported for QWP/WebSocket."
1817            ));
1818        };
1819        let millis: i64 = parse_conf_value("sf_sync_interval_millis", value)?;
1820        if millis <= 0 {
1821            return Err(error::fmt!(
1822                ConfigError,
1823                "\"sf_sync_interval_millis\" must be greater than 0."
1824            ));
1825        }
1826        if millis > MAX_MILLIS {
1827            return Err(error::fmt!(
1828                ConfigError,
1829                "\"sf_sync_interval_millis\" must be at most {MAX_MILLIS}."
1830            ));
1831        }
1832        qwp_ws.sf_sync_interval.set_specified(
1833            "sf_sync_interval_millis",
1834            Some(Duration::from_millis(millis as u64)),
1835        )?;
1836        Ok(self)
1837    }
1838
1839    #[cfg(feature = "_sender-qwp-ws")]
1840    fn store_and_forward_append_deadline(mut self, value: Duration) -> Result<Self> {
1841        if value.is_zero() {
1842            return Err(error::fmt!(
1843                ConfigError,
1844                "\"sf_append_deadline_millis\" must be greater than 0."
1845            ));
1846        }
1847        let Some(qwp_ws) = &mut self.qwp_ws else {
1848            return Err(error::fmt!(
1849                ConfigError,
1850                "The \"sf_append_deadline_millis\" setting is only supported for QWP/WebSocket."
1851            ));
1852        };
1853        qwp_ws
1854            .sf_append_deadline
1855            .set_specified("sf_append_deadline_millis", value)?;
1856        Ok(self)
1857    }
1858
1859    #[cfg(feature = "_sender-qwp-ws")]
1860    /// Per-outage reconnect retry budget. Default 300s.
1861    pub fn reconnect_max_duration(mut self, value: Duration) -> Result<Self> {
1862        let Some(qwp_ws) = &mut self.qwp_ws else {
1863            return Err(error::fmt!(
1864                ConfigError,
1865                "The \"reconnect_max_duration_millis\" setting is only supported for QWP/WebSocket."
1866            ));
1867        };
1868        qwp_ws
1869            .reconnect_max_duration
1870            .set_specified("reconnect_max_duration_millis", value)?;
1871        Ok(self)
1872    }
1873
1874    #[cfg(feature = "_sender-qwp-ws")]
1875    /// Maximum repeated same-head-FSN rejects or server close frames tolerated
1876    /// without ACK progress before the sender treats the frame as poison.
1877    /// Default 4, matching the Java QWP/WebSocket sender.
1878    pub fn max_frame_rejections(mut self, value: usize) -> Result<Self> {
1879        if value == 0 {
1880            return Err(error::fmt!(
1881                ConfigError,
1882                "\"max_frame_rejections\" must be greater than 0."
1883            ));
1884        }
1885        let Some(qwp_ws) = &mut self.qwp_ws else {
1886            return Err(error::fmt!(
1887                ConfigError,
1888                "The \"max_frame_rejections\" setting is only supported for QWP/WebSocket."
1889            ));
1890        };
1891        qwp_ws
1892            .max_frame_rejections
1893            .set_specified("max_frame_rejections", value)?;
1894        Ok(self)
1895    }
1896
1897    #[cfg(feature = "_sender-qwp-ws")]
1898    /// Minimum dwell before repeated same-head-FSN rejects or server close
1899    /// frames can escalate to a poison-frame protocol violation. Default 5s.
1900    /// Set to zero to escalate immediately at `max_frame_rejections`.
1901    pub fn poison_min_escalation_window(mut self, value: Duration) -> Result<Self> {
1902        let Some(qwp_ws) = &mut self.qwp_ws else {
1903            return Err(error::fmt!(
1904                ConfigError,
1905                "The \"poison_min_escalation_window_millis\" setting is only supported for QWP/WebSocket."
1906            ));
1907        };
1908        qwp_ws
1909            .poison_min_escalation_window
1910            .set_specified("poison_min_escalation_window_millis", value)?;
1911        Ok(self)
1912    }
1913
1914    #[cfg(feature = "_sender-qwp-ws")]
1915    /// Initial reconnect backoff. Default 100ms.
1916    pub fn reconnect_initial_backoff(mut self, value: Duration) -> Result<Self> {
1917        if value.is_zero() {
1918            return Err(error::fmt!(
1919                ConfigError,
1920                "\"reconnect_initial_backoff_millis\" must be greater than 0."
1921            ));
1922        }
1923        let Some(qwp_ws) = &mut self.qwp_ws else {
1924            return Err(error::fmt!(
1925                ConfigError,
1926                "The \"reconnect_initial_backoff_millis\" setting is only supported for QWP/WebSocket."
1927            ));
1928        };
1929        qwp_ws
1930            .reconnect_initial_backoff
1931            .set_specified("reconnect_initial_backoff_millis", value)?;
1932        Ok(self)
1933    }
1934
1935    #[cfg(feature = "_sender-qwp-ws")]
1936    /// Cap on the reconnect backoff the retry loop doubles toward; the actual
1937    /// per-attempt delay is this value jittered to ~[half, 1.5x]. Default 5s.
1938    pub fn reconnect_max_backoff(mut self, value: Duration) -> Result<Self> {
1939        if value.is_zero() {
1940            return Err(error::fmt!(
1941                ConfigError,
1942                "\"reconnect_max_backoff_millis\" must be greater than 0."
1943            ));
1944        }
1945        let Some(qwp_ws) = &mut self.qwp_ws else {
1946            return Err(error::fmt!(
1947                ConfigError,
1948                "The \"reconnect_max_backoff_millis\" setting is only supported for QWP/WebSocket."
1949            ));
1950        };
1951        qwp_ws
1952            .reconnect_max_backoff
1953            .set_specified("reconnect_max_backoff_millis", value)?;
1954        Ok(self)
1955    }
1956
1957    #[cfg(feature = "_sender-qwp-ws")]
1958    fn qwp_ws_endpoints(mut self, endpoints: Vec<conf::QwpWsEndpoint>) -> Result<Self> {
1959        let Some(qwp_ws) = &mut self.qwp_ws else {
1960            return Err(error::fmt!(
1961                ConfigError,
1962                "QWP/WebSocket endpoint lists are only supported for QWP/WebSocket."
1963            ));
1964        };
1965        qwp_ws.endpoints.set_specified("addr", endpoints)?;
1966        Ok(self)
1967    }
1968
1969    #[cfg(feature = "_sender-qwp-ws")]
1970    fn qwp_ws_initial_connect_mode(mut self, mode: conf::QwpWsInitialConnectMode) -> Result<Self> {
1971        let Some(qwp_ws) = &mut self.qwp_ws else {
1972            return Err(error::fmt!(
1973                ConfigError,
1974                "The \"initial_connect_retry\" setting is only supported for QWP/WebSocket."
1975            ));
1976        };
1977        qwp_ws
1978            .initial_connect_retry
1979            .set_specified("initial_connect_retry", mode)?;
1980        Ok(self)
1981    }
1982
1983    #[cfg(feature = "_sender-qwp-ws")]
1984    /// Retry the initial connection using the reconnect policy. Default false.
1985    ///
1986    /// The mode also governs the pool ([`crate::QuestDb::connect`]): the
1987    /// eager warm-minimum pre-open and every pool borrow that opens a new
1988    /// connection honor it, defaulting to fail-fast `off`. A `lazy_connect`
1989    /// pool always connects in the background and rejects an explicit
1990    /// blocking mode. Reconnect-to-sync promotion applies only to standalone
1991    /// [`SenderBuilder::build`]; pools honor only an explicitly set mode.
1992    pub fn initial_connect_retry(mut self, value: bool) -> Result<Self> {
1993        let Some(qwp_ws) = &mut self.qwp_ws else {
1994            return Err(error::fmt!(
1995                ConfigError,
1996                "The \"initial_connect_retry\" setting is only supported for QWP/WebSocket."
1997            ));
1998        };
1999        qwp_ws.initial_connect_retry.set_specified(
2000            "initial_connect_retry",
2001            if value {
2002                conf::QwpWsInitialConnectMode::Sync
2003            } else {
2004                conf::QwpWsInitialConnectMode::Off
2005            },
2006        )?;
2007        Ok(self)
2008    }
2009
2010    #[cfg(feature = "_sender-qwp-ws")]
2011    fn qwp_ws_auth_timeout_millis(mut self, value: &str) -> Result<Self> {
2012        let Some(qwp_ws) = &mut self.qwp_ws else {
2013            return Err(error::fmt!(
2014                ConfigError,
2015                "The \"auth_timeout_ms\" setting is only supported for QWP/WebSocket."
2016            ));
2017        };
2018        let millis: i64 = parse_conf_value("auth_timeout_ms", value)?;
2019        if millis <= 0 {
2020            return Err(error::fmt!(
2021                ConfigError,
2022                "auth_timeout_ms must be > 0: {}",
2023                millis
2024            ));
2025        }
2026        qwp_ws
2027            .auth_timeout
2028            .set_specified("auth_timeout_ms", Duration::from_millis(millis as u64))?;
2029        Ok(self)
2030    }
2031
2032    #[cfg(feature = "_sender-qwp-ws")]
2033    fn qwp_ws_connect_timeout_millis(mut self, value: &str) -> Result<Self> {
2034        let Some(qwp_ws) = &mut self.qwp_ws else {
2035            return Err(error::fmt!(
2036                ConfigError,
2037                "The \"connect_timeout\" setting is only supported for QWP/WebSocket."
2038            ));
2039        };
2040        let millis: i64 = parse_conf_value("connect_timeout", value)?;
2041        if millis <= 0 {
2042            return Err(error::fmt!(
2043                ConfigError,
2044                "connect_timeout must be > 0: {}",
2045                millis
2046            ));
2047        }
2048        qwp_ws.connect_timeout.set_specified(
2049            "connect_timeout",
2050            Some(Duration::from_millis(millis as u64)),
2051        )?;
2052        Ok(self)
2053    }
2054
2055    #[cfg(feature = "_sender-qwp-ws")]
2056    fn close_flush_timeout_millis(mut self, value: &str) -> Result<Self> {
2057        let Some(qwp_ws) = &mut self.qwp_ws else {
2058            return Err(error::fmt!(
2059                ConfigError,
2060                "The \"close_flush_timeout_millis\" setting is only supported for QWP/WebSocket."
2061            ));
2062        };
2063        let millis: i64 = parse_conf_value("close_flush_timeout_millis", value)?;
2064        let timeout = if millis <= 0 {
2065            Duration::ZERO
2066        } else {
2067            Duration::from_millis(millis as u64)
2068        };
2069        qwp_ws
2070            .close_flush_timeout
2071            .set_specified("close_flush_timeout_millis", timeout)?;
2072        Ok(self)
2073    }
2074
2075    #[cfg(feature = "_sender-qwp-ws")]
2076    fn request_durable_ack(mut self, value: &str) -> Result<Self> {
2077        let Some(qwp_ws) = &mut self.qwp_ws else {
2078            return Err(error::fmt!(
2079                ConfigError,
2080                "The \"request_durable_ack\" setting is only supported for QWP/WebSocket."
2081            ));
2082        };
2083        if value.eq_ignore_ascii_case("off") {
2084            qwp_ws
2085                .request_durable_ack
2086                .set_specified("request_durable_ack", false)?;
2087            return Ok(self);
2088        }
2089        if value.eq_ignore_ascii_case("on") {
2090            qwp_ws
2091                .request_durable_ack
2092                .set_specified("request_durable_ack", true)?;
2093            return Ok(self);
2094        }
2095
2096        Err(error::fmt!(
2097            ConfigError,
2098            "invalid request_durable_ack [value={value}, allowed-values=[on, off]]"
2099        ))
2100    }
2101
2102    #[cfg(feature = "_sender-qwp-ws")]
2103    fn drain_orphans(mut self, value: &str) -> Result<Self> {
2104        let Some(qwp_ws) = &mut self.qwp_ws else {
2105            return Err(error::fmt!(
2106                ConfigError,
2107                "The \"drain_orphans\" setting is only supported for QWP/WebSocket."
2108            ));
2109        };
2110        if value.eq_ignore_ascii_case("off") || value.eq_ignore_ascii_case("false") {
2111            qwp_ws.drain_orphans.set_specified("drain_orphans", false)?;
2112            return Ok(self);
2113        }
2114        if value.eq_ignore_ascii_case("on") || value.eq_ignore_ascii_case("true") {
2115            qwp_ws.drain_orphans.set_specified("drain_orphans", true)?;
2116            return Ok(self);
2117        }
2118
2119        Err(error::fmt!(
2120            ConfigError,
2121            "invalid drain_orphans [value={value}, allowed-values=[on, off, true, false]]"
2122        ))
2123    }
2124
2125    #[cfg(feature = "_sender-qwp-ws")]
2126    fn durable_ack_keepalive_interval_millis(mut self, value: &str) -> Result<Self> {
2127        let Some(qwp_ws) = &mut self.qwp_ws else {
2128            return Err(error::fmt!(
2129                ConfigError,
2130                "The \"durable_ack_keepalive_interval_millis\" setting is only supported for QWP/WebSocket."
2131            ));
2132        };
2133        let millis: i64 = parse_conf_value("durable_ack_keepalive_interval_millis", value)?;
2134        let interval = if millis <= 0 {
2135            Duration::ZERO
2136        } else {
2137            Duration::from_millis(millis as u64)
2138        };
2139        qwp_ws
2140            .durable_ack_keepalive_interval
2141            .set_specified("durable_ack_keepalive_interval_millis", interval)?;
2142        Ok(self)
2143    }
2144
2145    #[cfg(feature = "_sender-qwp-ws")]
2146    fn max_background_drainers(mut self, value: &str) -> Result<Self> {
2147        let Some(qwp_ws) = &mut self.qwp_ws else {
2148            return Err(error::fmt!(
2149                ConfigError,
2150                "The \"max_background_drainers\" setting is only supported for QWP/WebSocket."
2151            ));
2152        };
2153        let value: i32 = parse_conf_value("max_background_drainers", value)?;
2154        if value < 0 {
2155            return Err(error::fmt!(
2156                ConfigError,
2157                "max_background_drainers must be >= 0: {value}"
2158            ));
2159        }
2160        qwp_ws
2161            .max_background_drainers
2162            .set_specified("max_background_drainers", value as usize)?;
2163        Ok(self)
2164    }
2165
2166    #[cfg(feature = "_sender-qwp-ws")]
2167    fn error_inbox_capacity(mut self, value: &str) -> Result<Self> {
2168        let Some(qwp_ws) = &mut self.qwp_ws else {
2169            return Err(error::fmt!(
2170                ConfigError,
2171                "The \"error_inbox_capacity\" setting is only supported for QWP/WebSocket."
2172            ));
2173        };
2174        let value: usize = parse_conf_value("error_inbox_capacity", value)?;
2175        if value < conf::QWP_WS_MIN_ERROR_INBOX_CAPACITY {
2176            return Err(error::fmt!(
2177                ConfigError,
2178                "error_inbox_capacity must be >= {}: {value}",
2179                conf::QWP_WS_MIN_ERROR_INBOX_CAPACITY
2180            ));
2181        }
2182        qwp_ws
2183            .error_inbox_capacity
2184            .set_specified("error_inbox_capacity", value)?;
2185        Ok(self)
2186    }
2187
2188    /// Configure how long to wait for messages from the QuestDB server during
2189    /// the TLS handshake and authentication process. For QWP/WebSocket this
2190    /// bounds only the HTTP upgrade response read. The default is 15 seconds.
2191    pub fn auth_timeout(mut self, value: Duration) -> Result<Self> {
2192        #[cfg(feature = "_sender-qwp-udp")]
2193        self.reject_if_qwp_udp("auth_timeout")?;
2194        #[cfg(feature = "_sender-qwp-ws")]
2195        if let Some(qwp_ws) = &mut self.qwp_ws {
2196            if value.is_zero() {
2197                return Err(error::fmt!(
2198                    ConfigError,
2199                    "\"auth_timeout\" must be greater than 0."
2200                ));
2201            }
2202            qwp_ws.auth_timeout.set_specified("auth_timeout", value)?;
2203            return Ok(self);
2204        }
2205        self.auth_timeout.set_specified("auth_timeout", value)?;
2206        Ok(self)
2207    }
2208
2209    #[cfg(feature = "_sender-qwp-udp")]
2210    fn reject_if_qwp_udp(&self, setting: &str) -> Result<()> {
2211        if self.protocol.is_qwp_udp() {
2212            return Err(error::fmt!(
2213                ConfigError,
2214                "The \"{setting}\" setting is not supported for QWP/UDP."
2215            ));
2216        }
2217        Ok(())
2218    }
2219
2220    /// Ensure that TLS is enabled for the protocol.
2221    pub fn ensure_tls_enabled(&self, property: &str) -> Result<()> {
2222        if !self.protocol.tls_enabled() {
2223            return Err(error::fmt!(
2224                ConfigError,
2225                "Cannot set {property:?}: TLS is not supported for protocol {}",
2226                self.protocol
2227            ));
2228        }
2229        Ok(())
2230    }
2231
2232    /// Set to `false` to disable TLS certificate verification.
2233    /// This should only be used for debugging purposes as it reduces security.
2234    ///
2235    /// For testing, consider specifying a path to a `.pem` file instead via
2236    /// the [`tls_roots`](SenderBuilder::tls_roots) method.
2237    #[cfg(feature = "insecure-skip-verify")]
2238    pub fn tls_verify(mut self, verify: bool) -> Result<Self> {
2239        self.ensure_tls_enabled("tls_verify")?;
2240        self.tls_verify.set_specified("tls_verify", verify)?;
2241        Ok(self)
2242    }
2243
2244    /// Specify where to find the root certificate used to validate the
2245    /// server's TLS certificate.
2246    pub fn tls_ca(mut self, ca: CertificateAuthority) -> Result<Self> {
2247        self.ensure_tls_enabled("tls_ca")?;
2248        self.tls_ca.set_specified("tls_ca", ca)?;
2249        Ok(self)
2250    }
2251
2252    /// Set the path to a custom root certificate `.pem` file.
2253    /// This is used to validate the server's certificate during the TLS handshake.
2254    ///
2255    /// On QWP/WebSocket (`ws::` / `wss::`) the same path key
2256    /// also accepts a JKS or PKCS#12 keystore — see
2257    /// [`tls_roots_password`](SenderBuilder::tls_roots_password) for
2258    /// the unlock password.
2259    ///
2260    /// See notes on how to test with [self-signed
2261    /// certificates](https://github.com/questdb/c-questdb-client/tree/main/tls_certs).
2262    pub fn tls_roots<P: Into<PathBuf>>(self, path: P) -> Result<Self> {
2263        let mut builder = self.tls_ca(CertificateAuthority::PemFile)?;
2264        let path = path.into();
2265        // Attempt to read the file here to catch any issues early.
2266        let _file = std::fs::File::open(&path).map_err(|io_err| {
2267            error::fmt!(
2268                ConfigError,
2269                "Could not open root certificate file from path {:?}: {}",
2270                path,
2271                io_err
2272            )
2273        })?;
2274        builder.tls_roots.set_specified("tls_roots", Some(path))?;
2275        Ok(builder)
2276    }
2277
2278    /// Set the password unlocking the JKS / PKCS#12 keystore named by
2279    /// [`tls_roots`](SenderBuilder::tls_roots). QWP/WebSocket only —
2280    /// other transports keep PEM as the sole `tls_roots` format.
2281    ///
2282    /// With this set, the `tls_roots` file is read as a Java
2283    /// KeyStore (auto-detected: JKS magic `0xFEEDFEED`, or PKCS#12
2284    /// ASN.1 SEQUENCE) and trusted-certificate entries become the
2285    /// rustls root store. Mirrors the Java reference client's
2286    /// `tls_roots_password` connect-string key.
2287    #[cfg(feature = "_sender-qwp-ws")]
2288    pub fn tls_roots_password<S: Into<String>>(mut self, password: S) -> Result<Self> {
2289        if !self.protocol.is_qwp_ws() {
2290            return Err(error::fmt!(
2291                ConfigError,
2292                "\"tls_roots_password\" is only supported for QWP/WebSocket \
2293                 (ws / wss). ILP/TCP and ILP/HTTP transports read \
2294                 unencrypted PEM via rustls."
2295            ));
2296        }
2297        self.ensure_tls_enabled("tls_roots_password")?;
2298        self.tls_roots_password
2299            .set_specified("tls_roots_password", Some(password.into()))?;
2300        Ok(self)
2301    }
2302
2303    /// The initial buffered size that the client will pre-allocate for new
2304    /// [`Buffer`] instances returned by [`Sender::new_buffer`].
2305    /// The default is 64 KiB.
2306    ///
2307    /// For ILP / HTTP this pre-allocates the underlying byte vector to this
2308    /// size; the buffer then grows up to [`Self::max_buf_size`].
2309    /// For QWP/WebSocket the value is accepted and cross-validated against
2310    /// `max_buf_size`, but no flat byte buffer exists to pre-allocate
2311    /// — the columnar buffer allocates per-table on first row.
2312    /// For QWP/UDP the value is accepted but has no effect: datagrams are
2313    /// bounded by `max_datagram_size`.
2314    pub fn init_buf_size(mut self, value: usize) -> Result<Self> {
2315        let min = 1024;
2316        if value < min {
2317            return Err(error::fmt!(
2318                ConfigError,
2319                "\"init_buf_size\" must be at least {min} bytes."
2320            ));
2321        }
2322        self.init_buf_size.set_specified("init_buf_size", value)?;
2323        Ok(self)
2324    }
2325
2326    /// The maximum buffered size that the client will flush to the server.
2327    /// The default is 100 MiB.
2328    ///
2329    /// For ILP this applies to the exact pending byte length.
2330    /// For QWP/UDP this applies to the buffer size hint exposed by [`Buffer::len`].
2331    /// For QWP/WebSocket this applies to the encoded replay message size.
2332    pub fn max_buf_size(mut self, value: usize) -> Result<Self> {
2333        let min = 1024;
2334        if value < min {
2335            return Err(error::fmt!(
2336                ConfigError,
2337                "max_buf_size\" must be at least {min} bytes."
2338            ));
2339        }
2340        self.max_buf_size.set_specified("max_buf_size", value)?;
2341        Ok(self)
2342    }
2343
2344    /// The maximum length of a table or column name in bytes.
2345    /// Matches the `cairo.max.file.name.length` setting in the server.
2346    /// The default is 127 bytes.
2347    /// If running over HTTP and protocol version 2 is auto-negotiated, this
2348    /// value is picked up from the server.
2349    pub fn max_name_len(mut self, value: usize) -> Result<Self> {
2350        if value < 16 {
2351            return Err(error::fmt!(
2352                ConfigError,
2353                "max_name_len must be at least 16 bytes."
2354            ));
2355        }
2356        self.max_name_len.set_specified("max_name_len", value)?;
2357        Ok(self)
2358    }
2359
2360    // Only consumed by the QWP/WebSocket-gated pool builder in `db.rs`.
2361    #[cfg(feature = "sync-sender-qwp-ws")]
2362    pub(crate) fn configured_max_name_len(&self) -> usize {
2363        *self.max_name_len
2364    }
2365
2366    #[cfg(feature = "sync-sender-http")]
2367    /// Set the cumulative duration spent in retries.
2368    /// The value is in milliseconds, and the default is 10 seconds.
2369    pub fn retry_timeout(mut self, value: Duration) -> Result<Self> {
2370        if let Some(http) = &mut self.http {
2371            http.retry_timeout.set_specified("retry_timeout", value)?;
2372        } else {
2373            return Err(error::fmt!(
2374                ConfigError,
2375                "retry_timeout is supported only in ILP over HTTP."
2376            ));
2377        }
2378        Ok(self)
2379    }
2380
2381    #[cfg(feature = "sync-sender-http")]
2382    /// Cap on per-attempt backoff in the HTTP retry loop.
2383    ///
2384    /// The retry loop starts at 10 ms, doubles each attempt with ±5 ms
2385    /// jitter, and is bounded by this value (default: 1 second; minimum
2386    /// 10 ms — a cap below the initial interval is incoherent). Total
2387    /// retry budget is independently capped by
2388    /// [`SenderBuilder::retry_timeout`]; this knob shapes how aggressively
2389    /// the loop hits the server while waiting out a transient failure.
2390    ///
2391    /// Mirrors Java's `LineSenderBuilder.maxBackoffMillis(int)`.
2392    pub fn retry_max_backoff(mut self, value: Duration) -> Result<Self> {
2393        if value < Duration::from_millis(10) {
2394            return Err(error::fmt!(
2395                ConfigError,
2396                "\"retry_max_backoff_millis\" must be at least 10."
2397            ));
2398        }
2399        if let Some(http) = &mut self.http {
2400            http.retry_max_backoff
2401                .set_specified("retry_max_backoff_millis", value)?;
2402        } else {
2403            return Err(error::fmt!(
2404                ConfigError,
2405                "retry_max_backoff_millis is supported only in ILP over HTTP."
2406            ));
2407        }
2408        Ok(self)
2409    }
2410
2411    #[cfg(feature = "sync-sender-http")]
2412    /// Set the minimum acceptable throughput while sending a buffer to the server.
2413    /// The sender will divide the payload size by this number to determine for how
2414    /// long to keep sending the payload before timing out.
2415    /// The value is in bytes per second, and the default is 100 KiB/s.
2416    /// The timeout calculated from minimum throughput is adedd to the value of
2417    /// [`request_timeout`](SenderBuilder::request_timeout) to get the total timeout
2418    /// value.
2419    /// A value of 0 disables this feature, so it's similar to setting "infinite"
2420    /// minimum throughput. The total timeout will then be equal to `request_timeout`.
2421    pub fn request_min_throughput(mut self, value: u64) -> Result<Self> {
2422        if let Some(http) = &mut self.http {
2423            http.request_min_throughput
2424                .set_specified("request_min_throughput", value)?;
2425        } else {
2426            return Err(error::fmt!(
2427                ConfigError,
2428                "\"request_min_throughput\" is supported only in ILP over HTTP."
2429            ));
2430        }
2431        Ok(self)
2432    }
2433
2434    #[cfg(feature = "sync-sender-http")]
2435    /// Additional time to wait on top of that calculated from the minimum throughput.
2436    /// This accounts for the fixed latency of the HTTP request-response roundtrip.
2437    /// The default is 10 seconds.
2438    /// See also: [`request_min_throughput`](SenderBuilder::request_min_throughput).
2439    pub fn request_timeout(mut self, value: Duration) -> Result<Self> {
2440        if let Some(http) = &mut self.http {
2441            if value.is_zero() {
2442                return Err(error::fmt!(
2443                    ConfigError,
2444                    "\"request_timeout\" must be greater than 0."
2445                ));
2446            }
2447            http.request_timeout
2448                .set_specified("request_timeout", value)?;
2449        } else {
2450            return Err(error::fmt!(
2451                ConfigError,
2452                "\"request_timeout\" is supported only in ILP over HTTP."
2453            ));
2454        }
2455        Ok(self)
2456    }
2457
2458    #[cfg(feature = "sync-sender-http")]
2459    /// Internal API, do not use.
2460    /// This is exposed exclusively for the Python client.
2461    /// We (QuestDB) use this to help us debug which client is being used if we encounter issues.
2462    #[doc(hidden)]
2463    pub fn user_agent(mut self, value: &str) -> Result<Self> {
2464        let value = validate_value(value)?;
2465        if let Some(http) = &mut self.http {
2466            http.user_agent = value.to_string();
2467        }
2468        Ok(self)
2469    }
2470
2471    fn build_auth(&self) -> Result<Option<conf::AuthParams>> {
2472        match (
2473            self.protocol,
2474            self.username.deref(),
2475            self.password.deref(),
2476            self.token.deref(),
2477            #[cfg(feature = "_sender-tcp")]
2478            self.token_x.deref(),
2479            #[cfg(not(feature = "_sender-tcp"))]
2480            None::<String>,
2481            #[cfg(feature = "_sender-tcp")]
2482            self.token_y.deref(),
2483            #[cfg(not(feature = "_sender-tcp"))]
2484            None::<String>,
2485        ) {
2486            (_, None, None, None, None, None) => Ok(None),
2487
2488            #[cfg(feature = "_sender-tcp")]
2489            (protocol, Some(username), None, Some(token), Some(token_x), Some(token_y))
2490                if protocol.is_tcpx() =>
2491            {
2492                Ok(Some(conf::AuthParams::Ecdsa(conf::EcdsaAuthParams {
2493                    key_id: username.to_string(),
2494                    priv_key: token.to_string(),
2495                    pub_key_x: token_x.to_string(),
2496                    pub_key_y: token_y.to_string(),
2497                })))
2498            }
2499
2500            #[cfg(feature = "_sender-tcp")]
2501            (protocol, Some(_username), Some(_password), None, None, None)
2502                if protocol.is_tcpx() =>
2503            {
2504                Err(error::fmt!(
2505                    ConfigError,
2506                    r##"The "basic_auth" setting can only be used with the ILP/HTTP protocol."##,
2507                ))
2508            }
2509
2510            #[cfg(feature = "_sender-tcp")]
2511            (protocol, None, None, Some(_token), None, None) if protocol.is_tcpx() => {
2512                Err(error::fmt!(
2513                    ConfigError,
2514                    "Token authentication only be used with the ILP/HTTP protocol."
2515                ))
2516            }
2517
2518            #[cfg(feature = "_sender-tcp")]
2519            (protocol, _username, None, _token, _token_x, _token_y) if protocol.is_tcpx() => {
2520                Err(error::fmt!(
2521                    ConfigError,
2522                    r##"Incomplete ECDSA authentication parameters. Specify either all or none of: "username", "token", "token_x", "token_y"."##,
2523                ))
2524            }
2525            #[cfg(any(feature = "_sender-http", feature = "_sender-qwp-ws"))]
2526            (protocol, Some(username), Some(password), None, None, None)
2527                if protocol.accepts_http_auth() =>
2528            {
2529                Ok(Some(conf::AuthParams::Basic(conf::BasicAuthParams {
2530                    username: username.to_string(),
2531                    password: password.to_string(),
2532                })))
2533            }
2534            #[cfg(any(feature = "_sender-http", feature = "_sender-qwp-ws"))]
2535            (protocol, Some(_username), None, None, None, None) if protocol.accepts_http_auth() => {
2536                Err(error::fmt!(
2537                    ConfigError,
2538                    r##"Basic authentication parameter "username" is present, but "password" is missing."##,
2539                ))
2540            }
2541            #[cfg(any(feature = "_sender-http", feature = "_sender-qwp-ws"))]
2542            (protocol, None, Some(_password), None, None, None) if protocol.accepts_http_auth() => {
2543                Err(error::fmt!(
2544                    ConfigError,
2545                    r##"Basic authentication parameter "password" is present, but "username" is missing."##,
2546                ))
2547            }
2548            #[cfg(any(feature = "_sender-http", feature = "_sender-qwp-ws"))]
2549            (protocol, None, None, Some(token), None, None) if protocol.accepts_http_auth() => {
2550                Ok(Some(conf::AuthParams::Token(conf::TokenAuthParams {
2551                    token: token.to_string(),
2552                })))
2553            }
2554            #[cfg(feature = "_sender-http")]
2555            (protocol, Some(_username), None, Some(_token), Some(_token_x), Some(_token_y))
2556                if protocol.is_httpx() =>
2557            {
2558                Err(error::fmt!(
2559                    ConfigError,
2560                    "ECDSA authentication is only available with ILP/TCP and not available with ILP/HTTP."
2561                ))
2562            }
2563            #[cfg(feature = "_sender-http")]
2564            (protocol, _username, _password, _token, None, None) if protocol.is_httpx() => {
2565                Err(error::fmt!(
2566                    ConfigError,
2567                    r##"Inconsistent HTTP authentication parameters. Specify either "username" and "password", or just "token"."##,
2568                ))
2569            }
2570            _ => Err(error::fmt!(
2571                ConfigError,
2572                r##"Incomplete authentication parameters. Check "username", "password", "token", "token_x" and "token_y" parameters are set correctly."##,
2573            )),
2574        }
2575    }
2576
2577    #[cfg(feature = "_sync-sender")]
2578    /// Build the sender.
2579    ///
2580    /// In the case of TCP, this synchronously establishes the TCP connection, and
2581    /// returns once the connection is fully established. If the connection
2582    /// requires authentication or TLS, these will also be completed before
2583    /// returning.
2584    pub fn build(&self) -> Result<Sender> {
2585        // Fail fast on misconfigured buffer sizes before opening any sockets.
2586        // Only enforce the init-vs-max relationship when the user explicitly
2587        // set init_buf_size; a defaulted init_buf_size silently clamps to
2588        // max_buf_size below.
2589        if self.init_buf_size.is_specified() && *self.init_buf_size > *self.max_buf_size {
2590            return Err(error::fmt!(
2591                ConfigError,
2592                "init_buf_size ({}) cannot exceed max_buf_size ({})",
2593                *self.init_buf_size,
2594                *self.max_buf_size
2595            ));
2596        }
2597
2598        let mut descr = format!("Sender[host={:?},port={:?},", self.host, self.port);
2599
2600        if self.protocol.tls_enabled() {
2601            write!(descr, "tls=enabled,").unwrap();
2602        } else {
2603            write!(descr, "tls=disabled,").unwrap();
2604        }
2605
2606        #[cfg(feature = "insecure-skip-verify")]
2607        let tls_verify = *self.tls_verify;
2608
2609        #[cfg(feature = "_sender-qwp-ws")]
2610        let tls_roots_password = self.tls_roots_password.deref().as_deref();
2611        #[cfg(not(feature = "_sender-qwp-ws"))]
2612        let tls_roots_password: Option<&str> = None;
2613
2614        // Pair validation: the password unlocks the keystore at
2615        // `tls_roots`. Without `tls_roots`, the password names no
2616        // file, so the trust source falls back to the default — not
2617        // what the caller asked for. Java enforces the same pairing.
2618        if tls_roots_password.is_some() && self.tls_roots.deref().is_none() {
2619            return Err(error::fmt!(
2620                ConfigError,
2621                "\"tls_roots_password\" requires \"tls_roots\" \
2622                 (the password unlocks the keystore at that path)"
2623            ));
2624        }
2625
2626        #[allow(unused_variables)]
2627        let tls_settings = tls::TlsSettings::build(
2628            self.protocol.tls_enabled(),
2629            #[cfg(feature = "insecure-skip-verify")]
2630            tls_verify,
2631            *self.tls_ca,
2632            self.tls_roots.deref().as_deref(),
2633            tls_roots_password,
2634        )?;
2635
2636        let auth = self.build_auth()?;
2637
2638        let handler = match self.protocol {
2639            #[cfg(feature = "sync-sender-tcp")]
2640            Protocol::Tcp | Protocol::Tcps => connect_tcp(
2641                self.host.as_str(),
2642                self.port.as_str(),
2643                self.net_interface.deref().as_deref(),
2644                *self.auth_timeout,
2645                tls_settings,
2646                &auth,
2647            )?,
2648            #[cfg(feature = "sync-sender-http")]
2649            Protocol::Http | Protocol::Https => {
2650                use ureq::unversioned::transport::Connector;
2651                use ureq::unversioned::transport::TcpConnector;
2652                if self.net_interface.is_some() {
2653                    // See: https://github.com/algesten/ureq/issues/692
2654                    return Err(error::fmt!(
2655                        InvalidApiCall,
2656                        "net_interface is not supported for ILP over HTTP."
2657                    ));
2658                }
2659
2660                let http_config = self.http.as_ref().unwrap();
2661                let user_agent = http_config.user_agent.as_str();
2662                let connector = TcpConnector::default();
2663
2664                let agent_builder = ureq::Agent::config_builder()
2665                    .user_agent(user_agent)
2666                    .no_delay(true);
2667
2668                let tls_config = match tls_settings {
2669                    Some(tls_settings) => Some(tls::configure_tls(tls_settings)?),
2670                    None => None,
2671                };
2672
2673                let connector = connector.chain(TlsConnector::new(tls_config));
2674
2675                let auth = match auth {
2676                    Some(conf::AuthParams::Basic(ref auth)) => Some(auth.to_header_string()),
2677                    Some(conf::AuthParams::Token(ref auth)) => Some(auth.to_header_string()?),
2678
2679                    #[cfg(feature = "sync-sender-tcp")]
2680                    Some(conf::AuthParams::Ecdsa(_)) => {
2681                        return Err(fmt!(
2682                            AuthError,
2683                            "ECDSA authentication is not supported for ILP over HTTP. \
2684                            Please use basic or token authentication instead."
2685                        ));
2686                    }
2687                    None => None,
2688                };
2689                let agent_builder = agent_builder
2690                    .timeout_connect(Some(*http_config.request_timeout.deref()))
2691                    .http_status_as_error(false);
2692                let agent = ureq::Agent::with_parts(
2693                    agent_builder.build(),
2694                    connector,
2695                    ureq::unversioned::resolver::DefaultResolver::default(),
2696                );
2697                let proto = self.protocol.schema();
2698                let url = format!(
2699                    "{}://{}:{}/write",
2700                    proto,
2701                    self.host.deref(),
2702                    self.port.deref()
2703                );
2704                SyncProtocolHandler::SyncHttp(SyncHttpHandlerState {
2705                    agent,
2706                    url,
2707                    auth,
2708                    config: self.http.as_ref().unwrap().clone(),
2709                })
2710            }
2711            #[cfg(feature = "sync-sender-qwp-udp")]
2712            Protocol::Udp => {
2713                let Some(qwp_udp) = self.qwp_udp.as_ref() else {
2714                    return Err(error::fmt!(
2715                        ConfigError,
2716                        "QWP/UDP configuration is missing."
2717                    ));
2718                };
2719                connect_qwp_udp(
2720                    self.host.as_str(),
2721                    self.port.as_str(),
2722                    self.net_interface.deref().as_deref(),
2723                    qwp_udp,
2724                )?
2725            }
2726            #[cfg(feature = "sync-sender-qwp-ws")]
2727            Protocol::Ws | Protocol::Wss => {
2728                if self.net_interface.is_some() {
2729                    return Err(error::fmt!(
2730                        InvalidApiCall,
2731                        "net_interface is not supported for QWP over WebSocket."
2732                    ));
2733                }
2734                let Some(qwp_ws) = self.qwp_ws.as_ref() else {
2735                    return Err(error::fmt!(
2736                        ConfigError,
2737                        "QWP/WebSocket configuration is missing."
2738                    ));
2739                };
2740                // Resolve reconnect-implies-initial-retry only for this
2741                // standalone build. The builder retains the user's explicit
2742                // choice (or lack of one), so pool connector builds never see
2743                // this effective mode.
2744                let actual_initial_connect_retry = qwp_ws.resolve_initial_connect_retry();
2745                let mut qwp_ws = qwp_ws.clone();
2746                qwp_ws.initial_connect_retry =
2747                    ConfigSetting::Specified(actual_initial_connect_retry);
2748                let qwp_ws = &qwp_ws;
2749                reject_unsupported_qwp_ws_sf_config(qwp_ws)?;
2750                let basic_auth = qwp_ws_auth_header(&auth)?;
2751                if *qwp_ws.progress == QwpWsProgress::Manual {
2752                    if *qwp_ws.initial_connect_retry == conf::QwpWsInitialConnectMode::Async {
2753                        return Err(error::fmt!(
2754                            ConfigError,
2755                            "initial_connect_retry=async requires QWP/WebSocket background progress; use qwp_ws_progress=background or initial_connect_retry=sync"
2756                        ));
2757                    }
2758                    SyncProtocolHandler::ManualQwpWs(Box::new(open_manual_qwp_ws(
2759                        self.host.as_str(),
2760                        self.port.as_str(),
2761                        matches!(self.protocol, Protocol::Wss),
2762                        tls_settings,
2763                        qwp_ws,
2764                        basic_auth,
2765                    )?))
2766                } else {
2767                    connect_qwp_ws(
2768                        self.host.as_str(),
2769                        self.port.as_str(),
2770                        matches!(self.protocol, Protocol::Wss),
2771                        tls_settings,
2772                        qwp_ws,
2773                        basic_auth,
2774                    )?
2775                }
2776            }
2777        };
2778
2779        #[allow(unused_mut)]
2780        let mut max_name_len = *self.max_name_len;
2781
2782        let protocol_version = match self.protocol_version.deref() {
2783            Some(v) => *v,
2784            None => match self.protocol {
2785                #[cfg(feature = "sync-sender-tcp")]
2786                Protocol::Tcp | Protocol::Tcps => ProtocolVersion::V1,
2787                #[cfg(feature = "sync-sender-http")]
2788                Protocol::Http | Protocol::Https => {
2789                    #[allow(irrefutable_let_patterns)]
2790                    if let SyncProtocolHandler::SyncHttp(http_state) = &handler {
2791                        let settings_url = &format!(
2792                            "{}://{}:{}/settings",
2793                            self.protocol.schema(),
2794                            self.host.deref(),
2795                            self.port.deref()
2796                        );
2797                        let (protocol_versions, server_max_name_len) =
2798                            read_server_settings(http_state, settings_url, max_name_len)?;
2799                        max_name_len = server_max_name_len;
2800                        SUPPORTED_PROTOCOL_VERSIONS
2801                            .iter()
2802                            .find(|version| protocol_versions.contains(version))
2803                            .copied()
2804                            .ok_or_else(|| {
2805                                fmt!(
2806                                    ProtocolVersionError,
2807                                    "Server does not support any of the client protocol versions: {:?}",
2808                                    SUPPORTED_PROTOCOL_VERSIONS
2809                                )
2810                            })?
2811                    } else {
2812                        unreachable!("HTTP handler should be used for HTTP protocol");
2813                    }
2814                }
2815                #[cfg(feature = "sync-sender-qwp-udp")]
2816                Protocol::Udp => ProtocolVersion::V1,
2817                #[cfg(feature = "sync-sender-qwp-ws")]
2818                Protocol::Ws | Protocol::Wss => ProtocolVersion::V1,
2819            },
2820        };
2821
2822        if auth.is_some() {
2823            descr.push_str("auth=on]");
2824        } else {
2825            descr.push_str("auth=off]");
2826        }
2827
2828        // Defaulted init_buf_size clamps to max_buf_size when the cap is
2829        // smaller. The explicit-init-too-big check fires at the top of
2830        // build(); reaching here means init_buf_size is in range.
2831        let effective_init_buf_size = (*self.init_buf_size).min(*self.max_buf_size);
2832
2833        let sender = Sender::new(
2834            descr,
2835            handler,
2836            effective_init_buf_size,
2837            *self.max_buf_size,
2838            self.protocol,
2839            protocol_version,
2840            max_name_len,
2841            #[cfg(feature = "_sender-qwp-ws")]
2842            self.qwp_ws_error_handler.clone(),
2843            #[cfg(feature = "_sender-qwp-ws")]
2844            self.qwp_ws
2845                .as_ref()
2846                .and_then(|qwp_ws| qwp_ws.conn_events.clone()),
2847        );
2848
2849        Ok(sender)
2850    }
2851
2852    /// Resolve the QWP/WebSocket connect ingredients used by
2853    /// [`Self::build_qwp_ws_connector`]: validate the protocol / buffer / TLS
2854    /// settings, build the TLS config and auth header, and clone the
2855    /// SF-vetted `QwpWsConfig`.
2856    #[cfg(feature = "sync-sender-qwp-ws")]
2857    fn resolve_qwp_ws_ingredients(
2858        &self,
2859    ) -> Result<(
2860        bool,
2861        Option<tls::TlsSettings>,
2862        conf::QwpWsConfig,
2863        Option<String>,
2864    )> {
2865        if self.init_buf_size.is_specified() && *self.init_buf_size > *self.max_buf_size {
2866            return Err(error::fmt!(
2867                ConfigError,
2868                "init_buf_size ({}) cannot exceed max_buf_size ({})",
2869                *self.init_buf_size,
2870                *self.max_buf_size
2871            ));
2872        }
2873
2874        if !matches!(self.protocol, Protocol::Ws | Protocol::Wss) {
2875            return Err(error::fmt!(
2876                ConfigError,
2877                "Column-sender requires a QWP/WebSocket connect string \
2878                 (got protocol {:?})",
2879                self.protocol
2880            ));
2881        }
2882        if self.net_interface.is_some() {
2883            return Err(error::fmt!(
2884                InvalidApiCall,
2885                "net_interface is not supported for QWP over WebSocket."
2886            ));
2887        }
2888        let Some(qwp_ws) = self.qwp_ws.as_ref() else {
2889            return Err(error::fmt!(
2890                ConfigError,
2891                "QWP/WebSocket configuration is missing."
2892            ));
2893        };
2894
2895        #[cfg(feature = "insecure-skip-verify")]
2896        let tls_verify = *self.tls_verify;
2897        let tls_roots_password = self.tls_roots_password.deref().as_deref();
2898
2899        if tls_roots_password.is_some() && self.tls_roots.deref().is_none() {
2900            return Err(error::fmt!(
2901                ConfigError,
2902                "\"tls_roots_password\" requires \"tls_roots\" \
2903                 (the password unlocks the keystore at that path)"
2904            ));
2905        }
2906
2907        let tls_settings = tls::TlsSettings::build(
2908            self.protocol.tls_enabled(),
2909            #[cfg(feature = "insecure-skip-verify")]
2910            tls_verify,
2911            *self.tls_ca,
2912            self.tls_roots.deref().as_deref(),
2913            tls_roots_password,
2914        )?;
2915
2916        let auth = self.build_auth()?;
2917        let auth_header = qwp_ws_auth_header(&auth)?;
2918        let qwp_ws = qwp_ws.clone();
2919        reject_unsupported_qwp_ws_sf_config(&qwp_ws)?;
2920        if *qwp_ws.progress == QwpWsProgress::Manual
2921            && *qwp_ws.initial_connect_retry == conf::QwpWsInitialConnectMode::Async
2922        {
2923            return Err(error::fmt!(
2924                ConfigError,
2925                "initial_connect_retry=async requires QWP/WebSocket background progress; use qwp_ws_progress=background or initial_connect_retry=sync"
2926            ));
2927        }
2928
2929        let use_tls = matches!(self.protocol, Protocol::Wss);
2930        Ok((use_tls, tls_settings, qwp_ws, auth_header))
2931    }
2932
2933    /// Force the pool connector's baked-in initial connect mode to background.
2934    #[cfg(feature = "sync-sender-qwp-ws")]
2935    pub(crate) fn force_async_initial_connect(&mut self) {
2936        if let Some(qwp_ws) = self.qwp_ws.as_mut() {
2937            qwp_ws.force_async_initial_connect();
2938        }
2939    }
2940
2941    /// Build a reusable [`QwpWsConnector`] capturing the full configured
2942    /// endpoint list — the pooled QWP ingress path's entry point into the
2943    /// network. The pool drives it through a shared health tracker so each
2944    /// connect rotates across endpoints, skips unhealthy ones, and follows
2945    /// the writable primary on a role reject; the resulting `WsStream` does
2946    /// its own synchronous frame I/O and does not use the standalone
2947    /// [`Sender`]'s replay encoder or transaction ownership.
2948    #[cfg(feature = "sync-sender-qwp-ws")]
2949    pub(crate) fn build_qwp_ws_connector(&self) -> Result<QwpWsConnector> {
2950        let (use_tls, tls_settings, qwp_ws, auth_header) = self.resolve_qwp_ws_ingredients()?;
2951        let endpoints = sender::qwp_ws::qwp_ws_configured_endpoints(
2952            self.host.as_str(),
2953            self.port.as_str(),
2954            &qwp_ws,
2955        );
2956        Ok(QwpWsConnector {
2957            host: self.host.to_string(),
2958            port: self.port.to_string(),
2959            endpoints,
2960            use_tls,
2961            tls_settings,
2962            qwp_ws,
2963            auth_header,
2964            max_buf_size: *self.max_buf_size,
2965        })
2966    }
2967
2968    #[cfg(any(feature = "_sender-tcp", feature = "_sender-qwp-udp"))]
2969    fn ensure_supports_bind_interface(&self, param_name: &str) -> Result<()> {
2970        #[cfg(feature = "_sender-tcp")]
2971        if self.protocol.is_tcpx() {
2972            return Ok(());
2973        }
2974
2975        #[cfg(feature = "_sender-qwp-udp")]
2976        if self.protocol.is_qwp_udp() {
2977            return Ok(());
2978        }
2979
2980        #[cfg(feature = "_sender-qwp-udp")]
2981        let supported = "TCP or QWP/UDP";
2982        #[cfg(not(feature = "_sender-qwp-udp"))]
2983        let supported = "TCP";
2984
2985        Err(fmt!(
2986            ConfigError,
2987            "The {param_name:?} setting can only be used with the {supported} protocol."
2988        ))
2989    }
2990}
2991
2992/// When parsing from config, we exclude certain characters.
2993/// Here we repeat the same validation logic for consistency.
2994#[cfg(feature = "_sync-sender")]
2995fn validate_value<T: AsRef<str>>(value: T) -> Result<T> {
2996    let str_ref = value.as_ref();
2997    for (p, c) in str_ref.chars().enumerate() {
2998        if matches!(c, '\u{0}'..='\u{1f}' | '\u{7f}'..='\u{9f}') {
2999            return Err(error::fmt!(
3000                ConfigError,
3001                "Invalid character {c:?} at position {p}"
3002            ));
3003        }
3004    }
3005    Ok(value)
3006}
3007
3008#[cfg(feature = "_sync-sender")]
3009fn parse_conf_value<T>(param_name: &str, str_value: &str) -> Result<T>
3010where
3011    T: FromStr,
3012    T::Err: std::fmt::Debug,
3013{
3014    str_value.parse().map_err(|e| {
3015        fmt!(
3016            ConfigError,
3017            "Could not parse {param_name:?} to number: {e:?}"
3018        )
3019    })
3020}
3021
3022/// `true` when the ingress parser recognizes `str_value` as an
3023/// `initial_connect_retry` mode that blocks or fails fast at startup.
3024/// The pool's `lazy_connect` conflict check derives from the parser so the
3025/// two cannot drift when a mode is added.
3026#[cfg(feature = "_sender-qwp-ws")]
3027pub(crate) fn initial_connect_retry_value_is_blocking(str_value: &str) -> bool {
3028    matches!(
3029        parse_initial_connect_retry_value(str_value),
3030        Ok(mode) if mode != conf::QwpWsInitialConnectMode::Async
3031    )
3032}
3033
3034#[cfg(feature = "_sender-qwp-ws")]
3035fn parse_initial_connect_retry_value(str_value: &str) -> Result<conf::QwpWsInitialConnectMode> {
3036    if str_value.eq_ignore_ascii_case("on") || str_value.eq_ignore_ascii_case("true") {
3037        return Ok(conf::QwpWsInitialConnectMode::Sync);
3038    }
3039    if str_value.eq_ignore_ascii_case("sync") {
3040        return Ok(conf::QwpWsInitialConnectMode::Sync);
3041    }
3042    if str_value.eq_ignore_ascii_case("off") || str_value.eq_ignore_ascii_case("false") {
3043        return Ok(conf::QwpWsInitialConnectMode::Off);
3044    }
3045    if str_value.eq_ignore_ascii_case("async") {
3046        return Ok(conf::QwpWsInitialConnectMode::Async);
3047    }
3048    Err(error::fmt!(
3049        ConfigError,
3050        "invalid initial_connect_retry [value={str_value}, allowed-values=[on, off, true, false, sync, async]]"
3051    ))
3052}
3053
3054#[cfg(feature = "_sender-qwp-ws")]
3055fn parse_size_conf_value(param_name: &str, str_value: &str) -> Result<u64> {
3056    let mut end = str_value.len();
3057    if end == 0 {
3058        return Err(error::fmt!(
3059            ConfigError,
3060            "invalid {param_name} [value={str_value}]"
3061        ));
3062    }
3063
3064    let bytes = str_value.as_bytes();
3065    if matches!(bytes[end - 1], b'b' | b'B') {
3066        end -= 1;
3067    }
3068
3069    let multiplier = if end > 0 {
3070        match bytes[end - 1] {
3071            b'k' | b'K' => {
3072                end -= 1;
3073                1024
3074            }
3075            b'm' | b'M' => {
3076                end -= 1;
3077                1024 * 1024
3078            }
3079            b'g' | b'G' => {
3080                end -= 1;
3081                1024 * 1024 * 1024
3082            }
3083            b't' | b'T' => {
3084                end -= 1;
3085                1024_u64 * 1024 * 1024 * 1024
3086            }
3087            _ => 1,
3088        }
3089    } else {
3090        1
3091    };
3092
3093    if end == 0 {
3094        return Err(error::fmt!(
3095            ConfigError,
3096            "invalid {param_name} [value={str_value}]"
3097        ));
3098    }
3099
3100    let digits = &str_value[..end];
3101    let value = digits
3102        .parse::<u64>()
3103        .map_err(|_| error::fmt!(ConfigError, "invalid {param_name} [value={str_value}]"))?;
3104    value.checked_mul(multiplier).ok_or_else(|| {
3105        error::fmt!(
3106            ConfigError,
3107            "{param_name} overflows u64 [value={str_value}]"
3108        )
3109    })
3110}
3111
3112#[cfg(feature = "_sender-qwp-ws")]
3113fn parse_sf_durability_value(str_value: &str) -> Result<conf::SfDurability> {
3114    if str_value.eq_ignore_ascii_case("memory") {
3115        return Ok(conf::SfDurability::Memory);
3116    }
3117    if str_value.eq_ignore_ascii_case("periodic") {
3118        return Ok(conf::SfDurability::Periodic);
3119    }
3120    if str_value.eq_ignore_ascii_case("flush") {
3121        return Ok(conf::SfDurability::Flush);
3122    }
3123    if str_value.eq_ignore_ascii_case("append") {
3124        return Ok(conf::SfDurability::Append);
3125    }
3126    Err(error::fmt!(
3127        ConfigError,
3128        "invalid sf_durability [value={str_value}, allowed-values=[memory, periodic, flush, append]]"
3129    ))
3130}
3131
3132#[cfg(feature = "_sender-qwp-ws")]
3133fn parse_qwp_ws_progress_value(str_value: &str) -> Result<QwpWsProgress> {
3134    if str_value.eq_ignore_ascii_case("background") {
3135        return Ok(QwpWsProgress::Background);
3136    }
3137    if str_value.eq_ignore_ascii_case("manual") {
3138        return Ok(QwpWsProgress::Manual);
3139    }
3140    Err(error::fmt!(
3141        ConfigError,
3142        "invalid qwp_ws_progress [value={str_value}, allowed-values=[background, manual]]"
3143    ))
3144}
3145
3146#[cfg(feature = "_sender-qwp-ws")]
3147fn reject_unsupported_qwp_ws_sf_config(qwp_ws: &conf::QwpWsConfig) -> Result<()> {
3148    if matches!(
3149        *qwp_ws.sf_durability,
3150        conf::SfDurability::Flush | conf::SfDurability::Append
3151    ) {
3152        let durability = qwp_ws.sf_durability.as_conf_value();
3153        return Err(error::fmt!(
3154            ConfigError,
3155            "sf_durability={durability} is not yet supported (use sf_durability=memory or periodic)"
3156        ));
3157    }
3158    if *qwp_ws.sf_durability == conf::SfDurability::Periodic && qwp_ws.sf_dir.is_none() {
3159        return Err(error::fmt!(
3160            ConfigError,
3161            "sf_durability=periodic requires sf_dir"
3162        ));
3163    }
3164    if qwp_ws.sf_sync_interval.is_specified()
3165        && *qwp_ws.sf_durability != conf::SfDurability::Periodic
3166    {
3167        return Err(error::fmt!(
3168            ConfigError,
3169            "sf_sync_interval_millis requires sf_durability=periodic"
3170        ));
3171    }
3172
3173    Ok(())
3174}
3175
3176#[cfg(feature = "sync-sender-qwp-ws")]
3177fn qwp_ws_auth_header(auth: &Option<conf::AuthParams>) -> Result<Option<String>> {
3178    match auth {
3179        Some(conf::AuthParams::Basic(b)) => Ok(Some(b.to_header_string())),
3180        Some(conf::AuthParams::Token(t)) => Ok(Some(t.to_header_string()?)),
3181        #[cfg(feature = "_sender-tcp")]
3182        Some(conf::AuthParams::Ecdsa(_)) => Err(error::fmt!(
3183            AuthError,
3184            "ECDSA authentication is not supported for QWP/WebSocket. \
3185             Use basic or token authentication instead."
3186        )),
3187        None => Ok(None),
3188    }
3189}
3190
3191#[cfg(feature = "_sender-tcp")]
3192fn b64_decode(descr: &'static str, buf: &str) -> Result<Vec<u8>> {
3193    use base64ct::{Base64UrlUnpadded, Encoding};
3194    Base64UrlUnpadded::decode_vec(buf).map_err(|b64_err| {
3195        fmt!(
3196            AuthError,
3197            "Misconfigured ILP authentication keys. Could not decode {}: {}. \
3198            Hint: Check the keys for a possible typo.",
3199            descr,
3200            b64_err
3201        )
3202    })
3203}
3204
3205#[cfg(feature = "_sender-tcp")]
3206fn parse_public_key(pub_key_x: &str, pub_key_y: &str) -> Result<Vec<u8>> {
3207    let mut pub_key_x = b64_decode("public key x", pub_key_x)?;
3208    let mut pub_key_y = b64_decode("public key y", pub_key_y)?;
3209
3210    // SEC 1 Uncompressed Octet-String-to-Elliptic-Curve-Point Encoding
3211    let mut encoded = Vec::new();
3212    encoded.push(4u8); // 0x04 magic byte that identifies this as uncompressed.
3213    let pub_key_x_ken = pub_key_x.len();
3214    if pub_key_x_ken > 32 {
3215        return Err(fmt!(
3216            AuthError,
3217            "Misconfigured ILP authentication keys. Public key x is too long. \
3218            Hint: Check the keys for a possible typo."
3219        ));
3220    }
3221    let pub_key_y_len = pub_key_y.len();
3222    if pub_key_y_len > 32 {
3223        return Err(fmt!(
3224            AuthError,
3225            "Misconfigured ILP authentication keys. Public key y is too long. \
3226            Hint: Check the keys for a possible typo."
3227        ));
3228    }
3229    encoded.resize((32 - pub_key_x_ken) + 1, 0u8);
3230    encoded.append(&mut pub_key_x);
3231    encoded.resize((32 - pub_key_y_len) + 1 + 32, 0u8);
3232    encoded.append(&mut pub_key_y);
3233    Ok(encoded)
3234}
3235
3236#[cfg(feature = "_sender-tcp")]
3237fn parse_key_pair(auth: &conf::EcdsaAuthParams) -> Result<EcdsaKeyPair> {
3238    let private_key = b64_decode("private authentication key", auth.priv_key.as_str())?;
3239    let public_key = parse_public_key(auth.pub_key_x.as_str(), auth.pub_key_y.as_str())?;
3240
3241    #[cfg(feature = "aws-lc-crypto")]
3242    let res = EcdsaKeyPair::from_private_key_and_public_key(
3243        &ECDSA_P256_SHA256_FIXED_SIGNING,
3244        &private_key[..],
3245        &public_key[..],
3246    );
3247
3248    #[cfg(feature = "ring-crypto")]
3249    let res = {
3250        let system_random = SystemRandom::new();
3251        EcdsaKeyPair::from_private_key_and_public_key(
3252            &ECDSA_P256_SHA256_FIXED_SIGNING,
3253            &private_key[..],
3254            &public_key[..],
3255            &system_random,
3256        )
3257    };
3258
3259    res.map_err(|key_rejected| {
3260        fmt!(
3261            AuthError,
3262            "Misconfigured ILP authentication keys: {}. Hint: Check the keys for a possible typo.",
3263            key_rejected
3264        )
3265    })
3266}
3267
3268struct DebugBytes<'a>(pub &'a [u8]);
3269
3270impl Debug for DebugBytes<'_> {
3271    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3272        write!(f, "b\"")?;
3273
3274        for &byte in self.0 {
3275            match byte {
3276                // Printable ASCII characters (except backslash and quote)
3277                0x20..=0x21 | 0x23..=0x5B | 0x5D..=0x7E => {
3278                    write!(f, "{}", byte as char)?;
3279                }
3280                // Common escape sequences
3281                b'\n' => write!(f, "\\n")?,
3282                b'\r' => write!(f, "\\r")?,
3283                b'\t' => write!(f, "\\t")?,
3284                b'\\' => write!(f, "\\\\")?,
3285                b'"' => write!(f, "\\\"")?,
3286                b'\0' => write!(f, "\\0")?,
3287                // Non-printable bytes as hex escapes
3288                _ => write!(f, "\\x{byte:02x}")?,
3289            }
3290        }
3291
3292        write!(f, "\"")
3293    }
3294}
3295
3296#[cfg(all(test, feature = "_sync-sender"))]
3297mod tests;