Skip to main content

questdb/
db.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//! QWP ingestion connection pool.
26//!
27//! `QuestDb` is a thread-safe pool of store-and-forward producer handles to a
28//! single QuestDB QWP/WebSocket endpoint. By default (Java parity) `connect`
29//! is eager: it pre-opens the warm minimums (`sender_pool_min`,
30//! `query_pool_min`), honoring `initial_connect_retry` for the ingest
31//! senders (readers always connect fail-fast), so a down server fails the
32//! constructor fast. With `lazy_connect=true` the pool tolerates a down
33//! server at startup: `connect` performs no blocking network I/O,
34//! `query_pool_min` defaults to 0, and borrowing
35//! [`QuestDb::borrow_sender`] creates a local store-and-forward producer
36//! immediately whose background runner connects later, so callers can buffer
37//! while the server is absent. In disk-backed store-and-forward mode either
38//! variant may pre-open parked recovery senders whose initial connect and
39//! replay run in the background. Direct ingestion senders open their
40//! transport on first borrow.
41//! The pools auto-grow up to their configured caps (`sender_pool_max` /
42//! `query_pool_max`) on demand and (under `pool_reap=auto`)
43//! run a background thread that closes above-minimum idle entries after
44//! `idle_timeout_ms`.
45//!
46//! Each pool slot is handed out as a [`BorrowedSender`] which returns
47//! itself to the pool on `Drop`. Slots whose underlying connection has
48//! latched terminal state are dropped on return instead of being
49//! recycled.
50
51use std::fmt::{self, Debug, Formatter};
52use std::marker::PhantomData;
53#[cfg(feature = "_egress")]
54use std::ops::{Deref, DerefMut};
55use std::path::{Path, PathBuf};
56use std::rc::Rc;
57use std::sync::atomic::{AtomicBool, Ordering};
58use std::sync::{Arc, Condvar, Mutex};
59use std::thread::{self, JoinHandle};
60use std::time::{Duration, Instant};
61
62#[cfg(feature = "_egress")]
63use crate::egress::Reader;
64use crate::ingress::conn_events;
65use crate::ingress::rejection_events;
66use crate::ingress::sender::is_candidate_orphan;
67use crate::ingress::sender::qwp_ws::QwpWsHostHealthTracker;
68use crate::ingress::{Buffer, SenderBuilder};
69use crate::ingress::{
70    QwpWsConnector, QwpWsManagedSlotExclusion, RawQwpWsRoundStream, ReconnectReason,
71};
72// The reconnect backoff helpers are only consumed by the retry-capable borrow
73// paths: Polars `reborrow_with_retry` and the FFI owned
74// `*_with_retry` entry points. Keep the import unconditional (so the shared
75// re-export chain that feeds it stays live) but quiet the unused-import lint in
76// the plain library build that compiles neither retry path.
77#[cfg_attr(
78    not(any(
79        feature = "polars-ingress",
80        feature = "polars-egress",
81        feature = "ffi-support"
82    )),
83    allow(unused_imports)
84)]
85use crate::ingress::{reconnect_backoff_step, reconnect_error_is_terminal};
86use crate::{Result, error};
87
88/// Connect-string parsing for the [`QuestDb`] pool. Shared by every borrow
89/// kind (store-and-forward ingestion, direct ingestion, reader), so it lives
90/// with the pool rather than under a payload encoder.
91mod conf;
92
93use crate::ingress::AckLevel;
94use crate::ingress::column_sender::conn::ColumnConn;
95use crate::ingress::column_sender::{DirectSenderCore, PooledSenderCore};
96use conf::PoolReap;
97
98/// FFI escape-hatch surface: owned (lifetime-free) pool handles and the entry
99/// points that mint them, for the `questdb-rs-ffi` C-ABI crate. Hidden,
100/// feature-gated, and not part of the public Rust API — normal Rust users
101/// borrow lifetime-bound handles via [`QuestDb::borrow_sender`] (and, with
102/// egress, `QuestDb::borrow_reader`).
103/// Only `questdb-rs-ffi` enables the `ffi-support` feature.
104#[cfg(feature = "ffi-support")]
105#[doc(hidden)]
106pub mod ffi_support;
107
108/// Lower bound on the reaper's wake interval.
109const REAPER_MIN_TICK: Duration = Duration::from_secs(5);
110
111/// Poison-tolerant lock helper. The pool must survive a panic in another
112/// thread's locked region: under `panic=abort` (FFI consumers) poisoning
113/// can never be observed, but `questdb-rs` library consumers run with
114/// `panic=unwind` and a single panicking thread would otherwise turn
115/// every subsequent borrow/return into a panic via `.expect("poisoned")`.
116fn lock_state<S>(m: &Mutex<PoolState<S>>) -> std::sync::MutexGuard<'_, PoolState<S>> {
117    m.lock().unwrap_or_else(|e| e.into_inner())
118}
119
120fn lock_health(
121    m: &Mutex<QwpWsHostHealthTracker>,
122) -> std::sync::MutexGuard<'_, QwpWsHostHealthTracker> {
123    m.lock().unwrap_or_else(|e| e.into_inner())
124}
125
126#[cfg(feature = "_egress")]
127fn lock_reader_state(m: &Mutex<ReaderPoolState>) -> std::sync::MutexGuard<'_, ReaderPoolState> {
128    m.lock().unwrap_or_else(|e| e.into_inner())
129}
130
131/// RAII guard that increments `state.in_use` on construction and
132/// decrements it on drop unless [`InUseSlot::commit`] is called first.
133/// Closes the leak window between `state.in_use += 1` and the connect
134/// round: a panic in the connect path (allocator OOM,
135/// TLS handshake panic) would otherwise skip the matching decrement
136/// and permanently strand a pool slot.
137struct InUseSlot<'a, S> {
138    state: &'a Mutex<PoolState<S>>,
139    cv: &'a Condvar,
140    slot_index: Option<usize>,
141    armed: bool,
142}
143
144impl<S> InUseSlot<'_, S> {
145    fn commit(mut self) {
146        self.armed = false;
147    }
148}
149
150impl<S> Drop for InUseSlot<'_, S> {
151    fn drop(&mut self) {
152        if self.armed {
153            let mut state = lock_state(self.state);
154            state.in_use = state.in_use.saturating_sub(1);
155            state.free_slot_index(self.slot_index);
156            self.cv.notify_all();
157        }
158    }
159}
160
161#[cfg(feature = "_egress")]
162struct ReaderInUseSlot<'a> {
163    inner: &'a DbInner,
164    armed: bool,
165}
166
167#[cfg(feature = "_egress")]
168impl ReaderInUseSlot<'_> {
169    fn commit(mut self) {
170        self.armed = false;
171    }
172}
173
174#[cfg(feature = "_egress")]
175impl Drop for ReaderInUseSlot<'_> {
176    fn drop(&mut self) {
177        if self.armed {
178            {
179                let mut state = lock_reader_state(&self.inner.reader_state);
180                state.in_use = state.in_use.saturating_sub(1);
181            }
182            self.inner.reader_cv.notify_all();
183        }
184    }
185}
186
187struct SenderSlotRelease<'a> {
188    inner: &'a DbInner,
189    slot_index: Option<usize>,
190    decrement_in_use: bool,
191    decrement_closing: bool,
192}
193
194impl Drop for SenderSlotRelease<'_> {
195    fn drop(&mut self) {
196        if self.slot_index.is_none() && !self.decrement_in_use && !self.decrement_closing {
197            return;
198        }
199        let mut state = lock_state(&self.inner.state);
200        if self.decrement_in_use {
201            state.in_use = state.in_use.saturating_sub(1);
202        }
203        if self.decrement_closing {
204            state.closing = state.closing.saturating_sub(1);
205        }
206        state.free_slot_index(self.slot_index);
207        self.inner.cv.notify_all();
208    }
209}
210
211/// Connection pool for QWP/WebSocket ingestion and egress.
212///
213/// Construct with [`QuestDb::connect`]. Share the pool across threads — its
214/// internal state is `Mutex`-guarded so [`QuestDb::borrow_sender`] /
215/// [`QuestDb::reap_idle`] / Drop-driven returns are safe to interleave.
216///
217/// Each borrow ([`BorrowedSender`] / the internal direct sender) is **not**
218/// `Send` — it belongs to the thread that borrowed it. To ingest in parallel,
219/// borrow one sender per worker thread from the same `QuestDb`.
220/// Optional per-pool event handlers for [`QuestDb::connect_with_handlers`].
221#[derive(Default)]
222#[non_exhaustive]
223pub struct ConnectHandlers {
224    /// Connection lifecycle listener; see [`QuestDb::connect_with_listener`].
225    pub connection_listener: Option<crate::ingress::ConnectionListener>,
226    /// Listener inbox capacity; `0` selects the default (64).
227    pub connection_event_inbox_capacity: usize,
228    /// Server-rejection handler; without one every rejection is logged.
229    pub error_handler: Option<crate::ingress::QwpWsErrorHandler>,
230    /// Handler inbox capacity; `0` selects the default (64).
231    pub error_inbox_capacity: usize,
232}
233
234pub struct QuestDb {
235    inner: Arc<DbInner>,
236    reaper: Option<JoinHandle<()>>,
237}
238
239struct DbInner {
240    /// Original connect string. Kept verbatim so the reader pool
241    /// (`Reader::from_conf`) can spin up a new connection with the same
242    /// settings. The sender pools connect through pre-parsed builders so they
243    /// can override only the managed disk-SF slot id.
244    #[cfg(feature = "_egress")]
245    conf: String,
246    /// Resolved, reusable QWP/WebSocket connect ingredients (endpoint list,
247    /// TLS, auth, config). Every sender connection — first-borrow open,
248    /// auto-grow, and failover re-borrow — opens through this connector so it rotates
249    /// across the configured endpoints. A single-endpoint pool behaves
250    /// exactly as before (one endpoint, no rotation).
251    connector: QwpWsConnector,
252    /// Buffer-factory configuration retained directly on the pool root so a
253    /// caller can create a QWP/WebSocket Buffer without borrowing a sender.
254    buffer_max_name_len: usize,
255    /// One health tracker shared by every connect attempt. A connect failure
256    /// or a mid-stream transport death marks the offending endpoint unhealthy
257    /// so subsequent borrows skip it until it re-probes healthy; role rejects
258    /// rotate to the writable primary. Pool-level (not per-conn) so the pool
259    /// stops handing out connections to a dead peer rather than rediscovering
260    /// it one connection at a time.
261    health: Mutex<QwpWsHostHealthTracker>,
262    /// Warm minimum the reaper preserves in the store-and-forward
263    /// ingestion pool.
264    sender_pool_min: usize,
265    /// Hard cap on the store-and-forward ingestion pool and on the direct
266    /// column-sender pool (both are ingestion-side connections).
267    sender_pool_max: usize,
268    /// Warm minimum the reaper preserves in the reader pool.
269    #[cfg(feature = "_egress")]
270    query_pool_min: usize,
271    /// Hard cap on the reader pool.
272    #[cfg(feature = "_egress")]
273    query_pool_max: usize,
274    /// How long an at-cap borrow waits for a connection to be returned
275    /// before failing. Zero disables waiting (fail-fast).
276    acquire_timeout: Duration,
277    /// `sf_dir` set: store-and-forward senders use pool-minted disk slots.
278    sf_disk: bool,
279    /// Configured `sender_id` kept as the slot base. Disk-backed pool slots are
280    /// minted as `<base>-ingest-<index>`.
281    slot_base_id: String,
282    /// Managed ingestion slot range excluded from orphan scans so sibling
283    /// senders do not adopt each other's live pool slots.
284    managed_slot_exclusion: Option<QwpWsManagedSlotExclusion>,
285    /// Same-base managed slots left outside this pool's live index range by a
286    /// larger previous run. Snapshotted once before the pool is published and
287    /// reused by every sender build, so borrow-triggered growth never rescans
288    /// `sf_dir`. The pool namespace is exclusive: a same-base slot created
289    /// after connect is recovered by the next pool instance.
290    out_of_range_recovery_candidates: Vec<PathBuf>,
291    idle_timeout: Duration,
292    /// Pool-wide connection lifecycle event source (dispatcher + attempt
293    /// counter + success-classification state). Fixed at connect — with a
294    /// listener via [`QuestDb::connect_with_listener`], disabled otherwise —
295    /// before any recovery sender is pre-opened, so every direct or
296    /// store-and-forward emitter reports through it from its first connect.
297    conn_events: Arc<conn_events::ConnectionEventSource>,
298    state: Mutex<PoolState<PooledSenderCore>>,
299    /// Always-direct column-sender pool, independent of `sf_dir`. Backs
300    /// [`QuestDb::borrow_direct_column_sender`] (DataFrame ingestion). Lazy-init
301    /// like the reader pool: starts empty, opens a direct
302    /// connection on demand, recycles through its own free list and the shared
303    /// `sender_pool_max` cap. Kept separate from `state` so DataFrame ingest always
304    /// gets a plain pipelined connection even when `state` is in
305    /// store-and-forward mode.
306    direct_state: Mutex<PoolState<DirectSenderCore>>,
307    /// Reader pool. Lazy-init: starts empty, populated on first
308    /// `borrow_reader_owned` call. Sized by `query_pool_min` /
309    /// `query_pool_max` with the shared `idle_timeout`, but
310    /// tracks and caps them on an independent free list, so heavy ingest
311    /// can't starve queries. The caps are enforced separately, so the
312    /// combined live connection count across the store-and-forward ingress,
313    /// direct ingestion, and reader pools can reach up to
314    /// `2 * sender_pool_max + query_pool_max`.
315    #[cfg(feature = "_egress")]
316    reader_state: Mutex<ReaderPoolState>,
317    /// Wakes the reaper thread on `shutdown` and lets a disk-SF borrow wait
318    /// briefly for an in-flight slot close to release its flock.
319    cv: Condvar,
320    /// Wakes at-cap direct-pool borrows when a direct sender is returned
321    /// or a reservation is rolled back. Paired with `direct_state`.
322    direct_cv: Condvar,
323    /// Wakes at-cap reader borrows when a reader is returned or a
324    /// reservation is rolled back. Paired with `reader_state`.
325    #[cfg(feature = "_egress")]
326    reader_cv: Condvar,
327    /// Pool-wide server-rejection event source. Every rejection a
328    /// store-and-forward runner records is published through it: to the
329    /// user handler on a dedicated dispatcher thread when one was
330    /// registered, otherwise to the log (warn for retriable policies,
331    /// error for terminal), so silence is never the default.
332    rejections: Arc<rejection_events::RejectionEventSource>,
333    shutdown: AtomicBool,
334}
335
336#[derive(Default)]
337struct SlotReservations(Option<Vec<bool>>);
338
339impl SlotReservations {
340    fn with_disk_slots(pool_max: usize) -> Self {
341        Self(Some(vec![false; pool_max]))
342    }
343
344    fn reserved_total(&self, fallback_total: usize) -> usize {
345        match &self.0 {
346            Some(slots) => slots.iter().filter(|in_use| **in_use).count(),
347            None => fallback_total,
348        }
349    }
350
351    fn allocate(&mut self) -> Option<usize> {
352        let slots = self.0.as_mut()?;
353        let index = slots.iter().position(|in_use| !*in_use)?;
354        slots[index] = true;
355        Some(index)
356    }
357
358    fn reserve(&mut self, index: usize) -> bool {
359        let Some(slots) = self.0.as_mut() else {
360            return false;
361        };
362        let Some(slot) = slots.get_mut(index) else {
363            return false;
364        };
365        if *slot {
366            return false;
367        }
368        *slot = true;
369        true
370    }
371
372    fn free(&mut self, slot_index: Option<usize>) {
373        if let (Some(slots), Some(index)) = (&mut self.0, slot_index)
374            && let Some(slot) = slots.get_mut(index)
375        {
376            *slot = false;
377        }
378    }
379}
380
381struct PoolState<S> {
382    /// Idle connections. Borrow/return is LIFO on the back (push/pop);
383    /// the reaper drains the oldest entries from the front. Keeps hot
384    /// connections warm in the common case while the reaper still
385    /// retires entries in age order.
386    free: Vec<PoolEntry<S>>,
387    /// Sum of currently-borrowed senders + in-flight grow operations.
388    in_use: usize,
389    /// Reserved disk slots whose sender has started close/drop but has not yet
390    /// released the slot flock. Borrowers at cap may wait for this to complete.
391    closing: usize,
392    /// Disk-backed store-and-forward slot reservations. Empty for in-memory
393    /// SF and direct senders; populated for pool-minted disk slot indices.
394    slots: SlotReservations,
395}
396
397impl<S> Default for PoolState<S> {
398    fn default() -> Self {
399        Self {
400            free: Vec::new(),
401            in_use: 0,
402            closing: 0,
403            slots: SlotReservations::default(),
404        }
405    }
406}
407
408impl<S> PoolState<S> {
409    fn total(&self) -> usize {
410        self.free.len() + self.in_use
411    }
412
413    fn with_disk_slots(pool_max: usize) -> Self {
414        Self {
415            free: Vec::new(),
416            in_use: 0,
417            closing: 0,
418            slots: SlotReservations::with_disk_slots(pool_max),
419        }
420    }
421
422    fn reserved_total(&self) -> usize {
423        self.slots.reserved_total(self.total())
424    }
425
426    fn allocate_slot_index(&mut self) -> Option<usize> {
427        self.slots.allocate()
428    }
429
430    fn reserve_slot_index(&mut self, index: usize) -> bool {
431        self.slots.reserve(index)
432    }
433
434    fn free_slot_index(&mut self, slot_index: Option<usize>) {
435        self.slots.free(slot_index);
436    }
437}
438
439struct PoolEntry<S> {
440    sender: S,
441    slot_index: Option<usize>,
442    last_idle_at: Instant,
443}
444
445struct PooledSender<S> {
446    sender: S,
447    slot_index: Option<usize>,
448}
449
450#[cfg(feature = "_egress")]
451#[derive(Default)]
452struct ReaderPoolState {
453    /// Idle readers, oldest at front, newest at back (push on return /
454    /// pop on borrow). Same FIFO/LIFO discipline as the sender free list.
455    free: Vec<ReaderPoolEntry>,
456    /// Currently-borrowed readers + in-flight grow operations.
457    in_use: usize,
458}
459
460#[cfg(feature = "_egress")]
461impl ReaderPoolState {
462    fn total(&self) -> usize {
463        self.free.len() + self.in_use
464    }
465}
466
467#[cfg(feature = "_egress")]
468struct ReaderPoolEntry {
469    /// The reader carries its own per-connection state (symbol dict,
470    /// schema registry, request-id sequence) inside itself, so unlike
471    /// the sender pool we don't need to track them as separate fields.
472    reader: Reader,
473    last_idle_at: Instant,
474}
475
476/// Connection counts for a single pool inside a [`QuestDb`], part of the
477/// unstable diagnostics snapshot returned by [`QuestDb::dbg_pool_counts`].
478///
479/// **Not semver-stable.** `#[doc(hidden)]` and `#[non_exhaustive]`; exists for
480/// soak / leak harnesses to assert the pool drains back to a steady baseline
481/// after load and failover episodes.
482#[doc(hidden)]
483#[non_exhaustive]
484#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
485pub struct DbgPoolCount {
486    /// Idle connections parked on the free list.
487    pub free: usize,
488    /// Borrowed connections plus in-flight grow operations.
489    pub in_use: usize,
490    /// Disk store-and-forward slots that have begun close/drop but have not
491    /// yet released their slot flock. Always 0 for the direct and reader
492    /// pools (they hold no disk slots).
493    pub closing: usize,
494}
495
496/// Per-pool connection-count snapshot for a [`QuestDb`], for soak / leak
497/// diagnostics. **Not semver-stable** (`#[doc(hidden)]`, `#[non_exhaustive]`).
498///
499/// The ingestion and direct pools are each capped at `sender_pool_max` and
500/// the reader pool at `query_pool_max`, so `free + in_use` summed across all
501/// three fields can reach `2 * sender_pool_max + query_pool_max` when egress
502/// is enabled.
503#[doc(hidden)]
504#[non_exhaustive]
505#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
506pub struct DbgPoolCounts {
507    /// Store-and-forward ingestion pool (the pool behind `borrow_sender`).
508    pub ingress: DbgPoolCount,
509    /// Always-direct column-sender pool (DataFrame ingest, the pool behind
510    /// `borrow_direct_column_sender`).
511    pub column_direct: DbgPoolCount,
512    /// Reader (egress) pool. Always zero when the crate is built without an
513    /// egress feature.
514    pub reader: DbgPoolCount,
515}
516
517struct ManagedSlotRecoveryCandidate {
518    index: usize,
519    path: PathBuf,
520}
521
522#[derive(Default)]
523struct ManagedSlotRecoveryScan {
524    in_range: Vec<ManagedSlotRecoveryCandidate>,
525    out_of_range: Vec<PathBuf>,
526}
527
528fn managed_slot_exclusion(base: &str, pool_max: usize) -> QwpWsManagedSlotExclusion {
529    QwpWsManagedSlotExclusion::new(managed_slot_prefix(base), pool_max)
530}
531
532fn managed_slot_id(base: &str, index: usize) -> String {
533    managed_slot_exclusion(base, usize::MAX).slot_name(index)
534}
535
536fn managed_slot_prefix(base: &str) -> String {
537    format!("{base}-ingest-")
538}
539
540fn parse_managed_slot_id(base: &str, name: &str) -> Option<usize> {
541    managed_slot_exclusion(base, usize::MAX).parse_index(name)
542}
543
544fn managed_slot_recovery_scan_from(
545    sf_dir: &Path,
546    base: &str,
547    pool_max: usize,
548) -> ManagedSlotRecoveryScan {
549    let Ok(entries) = std::fs::read_dir(sf_dir) else {
550        return ManagedSlotRecoveryScan::default();
551    };
552    let mut scan = ManagedSlotRecoveryScan::default();
553    for entry in entries.flatten() {
554        let slot_path = entry.path();
555        if !slot_path.is_dir() {
556            continue;
557        }
558        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
559            continue;
560        };
561        let Some(index) = parse_managed_slot_id(base, &name) else {
562            continue;
563        };
564        if !is_candidate_orphan(&slot_path) {
565            continue;
566        }
567        // In-range managed slots are owned by this live pool even when they
568        // are not currently borrowed. They are pre-opened by connect-time
569        // recovery, so a sibling drainer must not take their flock.
570        if index < pool_max {
571            scan.in_range.push(ManagedSlotRecoveryCandidate {
572                index,
573                path: slot_path,
574            });
575        } else {
576            // The pool-lifetime snapshot emits this warning once per candidate
577            // instead of repeating it on every borrow-triggered sender build.
578            log::warn!(
579                "adopting out-of-range store-and-forward slot `{}`; \
580                 `<sender_id>-ingest-*` directories under \
581                 sf_dir belong to the QuestDb pool namespace, so use a unique \
582                 sender_id for pools sharing an sf_dir",
583                slot_path.display()
584            );
585            scan.out_of_range.push(slot_path);
586        }
587    }
588    scan
589}
590
591/// Pre-open dirty in-range disk-SF slots at connect so a restart at lower
592/// concurrency still replays all recoverable queued frames without waiting for
593/// the exact high index to be borrowed again.
594///
595/// Recovery senders count toward the ingestion pool total like ordinary parked
596/// senders. They are reaped only after their queues are delivered and idle past
597/// the timeout, and drain on pool close via `drain_sfa_senders_bounded`. Each
598/// pre-opened sender also enrolls the pool's snapshotted out-of-range managed
599/// slots in its orphan-drainer set, so those slots may begin replay at connect
600/// as well.
601fn preopen_recovery_senders(
602    inner: &Arc<DbInner>,
603    in_range_candidates: &[ManagedSlotRecoveryCandidate],
604) {
605    for candidate in in_range_candidates {
606        preopen_recovery_sender(
607            inner,
608            candidate.index,
609            &candidate.path,
610            &inner.out_of_range_recovery_candidates,
611        );
612    }
613}
614
615fn preopen_recovery_sender(
616    inner: &Arc<DbInner>,
617    index: usize,
618    slot_path: &Path,
619    recovery_candidates: &[PathBuf],
620) {
621    let slot = {
622        let mut state = lock_state(&inner.state);
623        if !state.reserve_slot_index(index) {
624            return;
625        }
626        state.in_use += 1;
627        InUseSlot {
628            state: &inner.state,
629            cv: &inner.cv,
630            slot_index: Some(index),
631            armed: true,
632        }
633    };
634
635    match connect_sfa_pool_with_recovery_candidates(inner, Some(index), recovery_candidates, true) {
636        Ok(sender) => {
637            let slot_index = slot.slot_index;
638            {
639                let mut state = lock_state(&inner.state);
640                state.in_use = state.in_use.saturating_sub(1);
641                state.free.push(PoolEntry {
642                    sender,
643                    slot_index,
644                    last_idle_at: Instant::now(),
645                });
646            }
647            slot.commit();
648            inner.cv.notify_all();
649        }
650        Err(err) => {
651            log::warn!(
652                "skipping parked store-and-forward ingestion slot `{}` during recovery: {}",
653                slot_path.display(),
654                err
655            );
656        }
657    }
658}
659
660impl QuestDb {
661    /// Open a pool against `conf`.
662    ///
663    /// The connect string must use a QWP/WebSocket schema (`ws::` /
664    /// `wss::` / `ws::` / `wss::`). Pool-specific keys are recognised:
665    ///
666    /// | Key                  | Default | Meaning                                                          |
667    /// |----------------------|---------|------------------------------------------------------------------|
668    /// | `sender_pool_min`    | 1       | Warm minimum of the ingestion pool, pre-opened at connect unless `lazy_connect=true`. |
669    /// | `sender_pool_max`    | 4       | Hard cap on the ingestion pool; the direct column-sender pool used by DataFrame ingestion is capped separately at the same value. |
670    /// | `query_pool_min`     | 1 (0 when lazy) | Warm minimum of the reader pool, pre-opened at connect unless `lazy_connect=true`. |
671    /// | `query_pool_max`     | 4       | Hard cap on the reader pool. |
672    /// | `acquire_timeout_ms` | 5000    | How long an at-cap borrow waits for a return before failing; `0` fails immediately. |
673    /// | `idle_timeout_ms`    | 60000   | Above-minimum idle connections are closed after this long. |
674    /// | `pool_reap`          | `auto`  | `auto` runs a background reaper; `manual` requires `reap_idle`. |
675    /// | `lazy_connect`       | `false` | Tolerate a down server at startup: connect opens nothing, senders buffer and connect in the background, readers connect on first borrow. |
676    ///
677    /// Key names and defaults match the Java client's `QuestDBBuilder`; the
678    /// Java-only lifecycle keys (`max_lifetime_ms`, `housekeeper_interval_ms`,
679    /// `query_close_timeout_ms`) have no counterpart here — the reaper tick
680    /// and `close_flush_timeout` own those responsibilities.
681    ///
682    /// [`Self::borrow_sender`] is always store-and-forward (in-memory when no
683    /// `sf_dir`, disk-backed when set). Setting `sf_dir` gives every pooled
684    /// sender its own slot directory, minted from the configured `sender_id`
685    /// base as `<base>-ingest-<index>`. Those `<sender_id>-ingest-*`
686    /// directories are reserved for this pool namespace under `sf_dir`; use a
687    /// unique `sender_id` for each pool that shares an `sf_dir`.
688    /// `sender_pool_min` / `sender_pool_max` apply to this unified ingestion pool. At
689    /// cap, borrows return `InvalidApiCall` except disk-backed
690    /// ingestion borrows can wait up to `close_flush_timeout` (default 5s)
691    /// while an in-flight slot close releases its lock. For a plain pipelined
692    /// (non-SF) connection — used by DataFrame ingestion — see
693    /// [`Self::borrow_direct_column_sender`].
694    ///
695    /// Startup matches the Java client. By default `connect` is **eager**:
696    /// it pre-opens `sender_pool_min` ingest senders — honoring only an
697    /// explicitly set `initial_connect_retry`: `off` (the default) fails
698    /// fast, `sync` retries within the reconnect budget, `async` connects in
699    /// the background — and `query_pool_min` readers, which have no retry
700    /// mode and always connect synchronously, failing fast; `sync` governs
701    /// only the ingest side. Reconnect-to-sync promotion applies only to
702    /// standalone [`SenderBuilder::build`]; pools honor only an explicitly
703    /// set mode. Bare `initial_connect_retry=async` is likewise not a
704    /// non-blocking startup while `query_pool_min > 0`; `lazy_connect=true`
705    /// is. Growth borrows beyond the minimum honor the same rules.
706    ///
707    /// With `lazy_connect=true` the pool tolerates a down server at startup:
708    /// `connect` performs no blocking network I/O, `query_pool_min` defaults
709    /// to 0 (readers connect lazily on first use), and every ingest borrow
710    /// creates its local store-and-forward producer immediately and connects
711    /// in the background, so the borrower can buffer while the server is
712    /// absent. An explicit blocking `initial_connect_retry` alongside
713    /// `lazy_connect=true` is rejected as a configuration conflict. In
714    /// disk-backed store-and-forward mode, either variant may pre-open parked
715    /// recovery senders whose initial connect and replay run in the
716    /// background. Direct senders open their transport on first borrow.
717    /// `sender_pool_min` / `query_pool_min` are the warm minimums the reaper
718    /// keeps.
719    ///
720    /// # Store-and-forward durability
721    ///
722    /// Disk store-and-forward (`sf_dir`) writes queued frames and their symbol
723    /// dictionary to disk but does **not** `fsync` — the data is *page-cache
724    /// durable*, matching the standalone QWP/WebSocket sender. That survives a **process / JVM
725    /// crash** (unacked frames replay on the next borrow / recovery), but **not**
726    /// a **host / power crash**, which can lose or tear unflushed pages. A
727    /// recovery that finds a torn symbol dictionary (or a frame whose dictionary
728    /// cannot be re-registered on the fresh server) fails loudly with a
729    /// **terminal, resend-required** error —
730    /// [`StoreResendRequired`](crate::ErrorCode::StoreResendRequired), a code
731    /// *distinct from* the transient [`SocketError`](crate::ErrorCode::SocketError)
732    /// you would retry, so callers can branch on it directly. The sender's own
733    /// reconnect/failover loops treat it as terminal (they stop) rather than
734    /// retrying it to their deadline. Those rows must be re-ingested from their
735    /// source, not retried in place.
736    /// In-memory store-and-forward (no `sf_dir`) has no cross-restart durability.
737    ///
738    pub fn connect(conf: &str) -> Result<Self> {
739        Self::connect_with_handlers(conf, ConnectHandlers::default())
740    }
741
742    /// [`Self::connect`] with a connection lifecycle listener. Events (see
743    /// [`ConnectionEventKind`](crate::ingress::ConnectionEventKind)) are
744    /// delivered on a dedicated dispatcher thread through a bounded
745    /// inbox — a slow listener can never stall connect, publish, or
746    /// reconnect paths; on overflow the oldest undelivered event is
747    /// dropped (counted by [`Self::connection_events_dropped`]).
748    ///
749    /// All direct and store-and-forward senders share this one source and
750    /// inbox. Concurrent emitters are serialized into the inbox in emission
751    /// order. `inbox_capacity == 0` selects the default (64).
752    ///
753    /// The listener is registered before the pool opens anything, so it
754    /// observes every transition — including the initial
755    /// [`Connected`](crate::ingress::ConnectionEventKind::Connected) of disk
756    /// recovery senders pre-opened by connect itself. This is the only way to
757    /// attach a listener to a pool: registration after connect would race
758    /// those recovery connects and could miss them.
759    pub fn connect_with_listener(
760        conf: &str,
761        listener: crate::ingress::ConnectionListener,
762        inbox_capacity: usize,
763    ) -> Result<Self> {
764        Self::connect_with_handlers(
765            conf,
766            ConnectHandlers {
767                connection_listener: Some(listener),
768                connection_event_inbox_capacity: inbox_capacity,
769                ..ConnectHandlers::default()
770            },
771        )
772    }
773
774    /// [`Self::connect`] with any combination of a connection lifecycle
775    /// listener (see [`Self::connect_with_listener`]) and a server-rejection
776    /// handler.
777    ///
778    /// The rejection handler receives every server rejection any of the
779    /// pool's store-and-forward connections records — including rejections
780    /// for frames whose lease was already returned — on a dedicated
781    /// dispatcher thread through a bounded inbox (overflow drops the oldest
782    /// event, counted by [`Self::rejection_events_dropped`]). Without a
783    /// handler every rejection is logged instead: warn for retriable
784    /// policies (the frames are replayed, not lost), error for terminal
785    /// ones. Use the handler for dead-lettering, alerting, and metrics;
786    /// producer-side abort logic belongs with the terminal error raised by
787    /// the sender calls themselves.
788    pub fn connect_with_handlers(conf: &str, handlers: ConnectHandlers) -> Result<Self> {
789        let conn_events = match handlers.connection_listener {
790            Some(listener) => conn_events::ConnectionEventSource::new(
791                listener,
792                handlers.connection_event_inbox_capacity,
793            ),
794            None => conn_events::ConnectionEventSource::disabled(),
795        };
796        let rejections = match handlers.error_handler {
797            Some(handler) => rejection_events::RejectionEventSource::with_handler(
798                handler,
799                handlers.error_inbox_capacity,
800            ),
801            None => rejection_events::RejectionEventSource::logging_default(),
802        };
803        Self::connect_impl(conf, conn_events, rejections)
804    }
805
806    fn connect_impl(
807        conf: &str,
808        conn_events: conn_events::ConnectionEventSource,
809        rejections: rejection_events::RejectionEventSource,
810    ) -> Result<Self> {
811        let parsed = conf::parse(conf)?;
812        // The public ingestion pool is always store-and-forward: in-memory
813        // queues when no `sf_dir`, disk-backed pool-minted slots when set.
814        let sf_disk = parsed.sf_disk;
815        let pool_cfg = parsed.pool;
816
817        let mut builder = SenderBuilder::from_conf(conf)?;
818        if pool_cfg.lazy_connect {
819            // Java's lazy_connect injects an async initial connect into the
820            // ingest config once; every pooled sender then inherits it.
821            builder.force_async_initial_connect();
822        }
823        let buffer_max_name_len = builder.configured_max_name_len();
824        let connector = builder.build_qwp_ws_connector()?;
825        let health = QwpWsHostHealthTracker::new(connector.endpoint_count());
826        let slot_base_id = connector.sender_id().to_owned();
827        let managed_slot_exclusion = if sf_disk {
828            Some(managed_slot_exclusion(
829                &slot_base_id,
830                pool_cfg.sender_pool_max,
831            ))
832        } else {
833            None
834        };
835        // Snapshot managed recovery before any sender is built. In-range
836        // entries are retained locally for connect-time pre-open; out-of-range
837        // entries live on DbInner and are reused by prewarm and later growth.
838        // This matches Java SenderPool's cached out-of-range worklist and keeps
839        // the borrow path free of top-level sf_dir scans.
840        let recovery_scan = if sf_disk {
841            connector
842                .sf_dir()
843                .map(|sf_dir| {
844                    managed_slot_recovery_scan_from(sf_dir, &slot_base_id, pool_cfg.sender_pool_max)
845                })
846                .unwrap_or_default()
847        } else {
848            ManagedSlotRecoveryScan::default()
849        };
850        let ManagedSlotRecoveryScan {
851            in_range: in_range_recovery_candidates,
852            out_of_range: out_of_range_recovery_candidates,
853        } = recovery_scan;
854
855        // Start empty; connect-time recovery may pre-open dirty disk-SF slots
856        // after `inner` exists, otherwise the pools open on first borrow.
857        let free = Vec::new();
858
859        let inner = Arc::new(DbInner {
860            #[cfg(feature = "_egress")]
861            conf: conf.to_owned(),
862            connector,
863            buffer_max_name_len,
864            health: Mutex::new(health),
865            sender_pool_min: pool_cfg.sender_pool_min,
866            sender_pool_max: pool_cfg.sender_pool_max,
867            #[cfg(feature = "_egress")]
868            query_pool_min: pool_cfg.query_pool_min,
869            #[cfg(feature = "_egress")]
870            query_pool_max: pool_cfg.query_pool_max,
871            acquire_timeout: pool_cfg.acquire_timeout,
872            sf_disk,
873            slot_base_id,
874            managed_slot_exclusion,
875            out_of_range_recovery_candidates,
876            idle_timeout: pool_cfg.idle_timeout,
877            state: Mutex::new(if sf_disk {
878                PoolState::with_disk_slots(pool_cfg.sender_pool_max)
879            } else {
880                PoolState {
881                    free,
882                    ..PoolState::default()
883                }
884            }),
885            direct_state: Mutex::new(PoolState::default()),
886            #[cfg(feature = "_egress")]
887            reader_state: Mutex::new(ReaderPoolState::default()),
888            cv: Condvar::new(),
889            direct_cv: Condvar::new(),
890            #[cfg(feature = "_egress")]
891            reader_cv: Condvar::new(),
892            rejections: Arc::new(rejections),
893            shutdown: AtomicBool::new(false),
894            conn_events: Arc::new(conn_events),
895        });
896
897        let reaper = match pool_cfg.pool_reap {
898            PoolReap::Auto => Some(spawn_reaper(Arc::clone(&inner)).map_err(|err| {
899                inner.shutdown.store(true, Ordering::SeqCst);
900                crate::Error::new(
901                    crate::ErrorCode::SocketError,
902                    format!("Failed to spawn pool reaper thread: {err}"),
903                )
904            })?),
905            PoolReap::Manual => None,
906        };
907
908        let db = Self { inner, reaper };
909        // Prewarm BEFORE recovery pre-open, matching the Java client's order.
910        // Prewarm adopts each dirty in-range slot through its deterministic
911        // managed id and enrolls the snapshotted out-of-range candidates, so
912        // its foreground connect genuinely probes the server; recovery must
913        // not run first or its background-connecting (forced-async) senders
914        // would sit in the free list and satisfy the warm minimum without any
915        // connect, silently voiding the eager fail-fast contract whenever a
916        // previous run left queued data behind. On error the drop of `db`
917        // closes whatever was opened.
918        if !pool_cfg.lazy_connect {
919            prewarm_min_connections(&db)?;
920        }
921        // Recovery pre-open then re-adopts any dirty slots prewarm did not
922        // claim; their initial connect and replay run in the background.
923        preopen_recovery_senders(&db.inner, &in_range_recovery_candidates);
924        Ok(db)
925    }
926
927    /// Create a caller-owned QWP/WebSocket row buffer using this pool's
928    /// configured table/column name limit. The buffer is independent of any
929    /// particular sender borrow and may be filled or moved before it is
930    /// published by a store-and-forward sender from this pool.
931    pub fn new_buffer(&self) -> Buffer {
932        Buffer::qwp_ws_with_max_name_len(self.inner.buffer_max_name_len)
933    }
934
935    /// Configured name limit used by [`Self::new_buffer`]. Exposed for the C++
936    /// wrapper so a moved-from buffer can lazily recreate the same kind of
937    /// buffer without retaining a pool reference.
938    #[doc(hidden)]
939    pub fn buffer_max_name_len(&self) -> usize {
940        self.inner.buffer_max_name_len
941    }
942
943    /// Borrow a sender.
944    ///
945    /// Selection: pop the most-recently-returned slot from the free list;
946    /// failing that, open a new connection if we are below `sender_pool_max`;
947    /// failing that, in disk-backed store-and-forward mode only, wait up to
948    /// `close_flush_timeout` (default 5s) while an in-flight slot close
949    /// releases its lock; failing that, wait up to `acquire_timeout_ms` for a
950    /// return (`acquire_timeout_ms=0` fails fast); failing that, return
951    /// `InvalidApiCall`.
952    ///
953    /// A borrow that opens a new connection honors `initial_connect_retry`:
954    /// `off` (the default) connects synchronously and fails fast, `sync`
955    /// retries within the reconnect budget before returning. Under
956    /// `lazy_connect=true` the connection starts in the background instead,
957    /// so the borrow succeeds even while the server is away; see
958    /// [`Self::connect`].
959    pub fn borrow_sender(&self) -> Result<BorrowedSender<'_>> {
960        let cs = self.pick_sender()?;
961        Ok(BorrowedSender(SenderHandle::new(self, cs)))
962    }
963
964    /// Borrow a **direct** (non-store-and-forward) column sender from the
965    /// always-direct pool, independent of `sf_dir`.
966    ///
967    /// Not part of the public API: the direct sender is the transport behind
968    /// [`Self::flush_arrow_batch`] / [`Self::flush_polars_dataframe`], which own
969    /// their own commit + replay. Hidden from the docs; callers ingest through
970    /// those entry points rather than handling a sender.
971    #[doc(hidden)]
972    pub fn borrow_direct_column_sender(&self) -> Result<BorrowedDirectColumnSender<'_>> {
973        let cs = pick_direct_sender(&self.inner)?;
974        Ok(BorrowedDirectColumnSender(DirectSenderHandle::new(
975            self, cs,
976        )))
977    }
978
979    /// Flush a single Arrow [`RecordBatch`](arrow::array::RecordBatch) to
980    /// `table` in one call.
981    ///
982    /// This is the recommended entry point for one-off Arrow ingestion: it
983    /// borrows a direct column sender from the pool, publishes the batch as a
984    /// commit boundary, waits for the server `Ok` ack, and returns the sender
985    /// to the pool — callers never handle a sender.
986    ///
987    /// `timestamp_column` selects where each row's designated timestamp comes
988    /// from:
989    /// * `Some(col)` — source it from the named `Timestamp(_)` column of
990    ///   `batch` (mirrors the old `flush_arrow_batch_at_column`).
991    /// * `None` — let the server stamp each row on arrival (mirrors the old
992    ///   `flush_arrow_batch_at_now`).
993    ///
994    /// `overrides` carries per-column wire-type hints (e.g. promote a UTF-8
995    /// column to SYMBOL, or a UInt32 to IPv4); pass `&[]` when the Arrow schema
996    /// is self-describing.
997    ///
998    /// `ack_level` chooses how far the call blocks before returning:
999    /// * `None` — wait for the connect string's default, i.e. the same level
1000    ///   the store-and-forward senders use: [`AckLevel::Durable`] when the
1001    ///   Enterprise-only durable mode is enabled with
1002    ///   `request_durable_ack=on`, otherwise [`AckLevel::Ok`].
1003    /// * `Some(level)` — wait for exactly `level`. [`AckLevel::Durable`]
1004    ///   requires QuestDB Enterprise and `request_durable_ack=on`; otherwise
1005    ///   the call is rejected with [`ErrorCode::InvalidApiCall`].
1006    ///
1007    /// The call publishes the batch as a commit boundary and blocks until the
1008    /// resolved acknowledgement level is reached. An `Ok` acknowledgement
1009    /// confirms server acceptance; only the Enterprise durable level confirms
1010    /// durable coverage. On a transient [`ErrorCode::FailoverRetry`] it
1011    /// surfaces the error rather than replaying (the batch is fully owned by
1012    /// the caller, so retrying is a plain re-call); the DataFrame path
1013    /// ([`Self::flush_polars_dataframe`]) re-drives automatically instead.
1014    ///
1015    /// [`ErrorCode::FailoverRetry`]: crate::ErrorCode::FailoverRetry
1016    /// [`ErrorCode::InvalidApiCall`]: crate::ErrorCode::InvalidApiCall
1017    #[cfg(feature = "arrow-ingress")]
1018    pub fn flush_arrow_batch<'t, T>(
1019        &self,
1020        table: T,
1021        batch: &arrow::array::RecordBatch,
1022        timestamp_column: Option<crate::ingress::ColumnName<'_>>,
1023        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
1024        ack_level: Option<AckLevel>,
1025    ) -> Result<()>
1026    where
1027        T: TryInto<crate::ingress::TableName<'t>>,
1028        crate::Error: From<T::Error>,
1029    {
1030        let ack = ack_level.unwrap_or_else(|| self.default_ack_level());
1031        let mut sender = self.borrow_direct_column_sender()?;
1032        // `table` is moved into exactly one arm, so the generic `T` flows
1033        // straight through to the chosen `_and_wait` method unchanged.
1034        match timestamp_column {
1035            Some(ts) => {
1036                sender.flush_arrow_batch_at_column_and_wait(table, batch, ts, overrides, ack)
1037            }
1038            None => sender.flush_arrow_batch_at_now_and_wait(table, batch, overrides, ack),
1039        }
1040    }
1041
1042    /// The ack level these convenience flushes wait for when the caller does
1043    /// not name one: [`AckLevel::Durable`] when the connect string enabled the
1044    /// Enterprise-only durable mode with `request_durable_ack=on`, otherwise
1045    /// [`AckLevel::Ok`]. Mirrors the level the store-and-forward senders use
1046    /// for the same pool.
1047    #[cfg(feature = "arrow-ingress")]
1048    pub(crate) fn default_ack_level(&self) -> AckLevel {
1049        if self.inner.connector.request_durable_ack() {
1050            AckLevel::Durable
1051        } else {
1052            AckLevel::Ok
1053        }
1054    }
1055
1056    /// FFI escape hatch: like [`Self::borrow_sender`] but the returned
1057    /// handle is not lifetime-bound to `&self`. Carries an `Arc<DbInner>`
1058    /// internally so it can outlive the user-facing `QuestDb` pointer
1059    /// (the pool's return path stays alive as long as any borrow is
1060    /// outstanding; after pool close, returned handles are dropped instead of
1061    /// recycled).
1062    ///
1063    /// Hidden from the Rust API because Rust callers should prefer the
1064    /// lifetime-bound `borrow_sender`, which catches use-after-close at
1065    /// compile time. C callers reach this through `questdb_db_borrow_sender`.
1066    #[cfg(feature = "ffi-support")]
1067    pub(crate) fn borrow_sender_owned(&self) -> Result<OwnedSender> {
1068        let cs = self.pick_sender()?;
1069        Ok(OwnedSender::new(Arc::clone(&self.inner), cs))
1070    }
1071
1072    /// Like [`borrow_sender_owned`] but retries the connect within `budget`
1073    /// using the pool's reconnect backoff (the cluster may be electing a
1074    /// primary). Backs the C ABI's `questdb_db_borrow_sender_with_retry`.
1075    #[cfg(feature = "ffi-support")]
1076    pub(crate) fn borrow_sender_owned_with_retry(&self, budget: Duration) -> Result<OwnedSender> {
1077        let deadline = Instant::now().checked_add(budget);
1078        let cs = reconnect_pick(&self.inner, deadline, pick_sfa_sender)?;
1079        Ok(OwnedSender::new(Arc::clone(&self.inner), cs))
1080    }
1081
1082    /// FFI escape hatch: like [`Self::borrow_direct_column_sender`] but the
1083    /// returned handle is not lifetime-bound to `&self` (carries an
1084    /// `Arc<DbInner>` so it can outlive the user-facing `QuestDb` pointer).
1085    /// Backs the C ABI's `questdb_db_borrow_direct_sender`. Hidden from
1086    /// the Rust API; Rust callers should prefer the lifetime-bound
1087    /// [`Self::borrow_direct_column_sender`].
1088    #[cfg(feature = "ffi-support")]
1089    pub(crate) fn borrow_direct_column_sender_owned(&self) -> Result<OwnedDirectColumnSender> {
1090        let cs = pick_direct_sender(&self.inner)?;
1091        Ok(OwnedDirectColumnSender::new(Arc::clone(&self.inner), cs))
1092    }
1093
1094    /// Like [`borrow_direct_column_sender_owned`] but retries the connect
1095    /// within `budget` using the reconnect backoff. Backs the C ABI's
1096    /// `questdb_db_borrow_direct_sender_with_retry`.
1097    #[cfg(feature = "ffi-support")]
1098    pub(crate) fn borrow_direct_column_sender_owned_with_retry(
1099        &self,
1100        budget: Duration,
1101    ) -> Result<OwnedDirectColumnSender> {
1102        let deadline = Instant::now().checked_add(budget);
1103        let cs = reconnect_pick(&self.inner, deadline, pick_direct_sender)?;
1104        Ok(OwnedDirectColumnSender::new(Arc::clone(&self.inner), cs))
1105    }
1106
1107    fn pick_sender(&self) -> Result<PooledSender<PooledSenderCore>> {
1108        pick_sfa_sender(&self.inner)
1109    }
1110
1111    fn pick_replacement_sender(&self) -> Result<PooledSender<DirectSenderCore>> {
1112        if self.inner.shutdown.load(Ordering::SeqCst) {
1113            return Err(error::fmt!(
1114                InvalidApiCall,
1115                "QuestDb pool is closed; cannot replace sender"
1116            ));
1117        }
1118        // Same-handle replacement: the borrowed direct sender already owns one
1119        // logical in-use slot, so this must not reserve another one or
1120        // sender_sender_pool_max=1 would reject replacing a dead direct connection.
1121        if let Some(entry) = lock_state(&self.inner.direct_state).free.pop() {
1122            return Ok(PooledSender {
1123                sender: entry.sender,
1124                slot_index: entry.slot_index,
1125            });
1126        }
1127
1128        let conn = connect_conn_pool(&self.inner)?;
1129        Ok(PooledSender {
1130            sender: DirectSenderCore::new(
1131                conn,
1132                crate::ingress::SymbolGlobalDict::new(),
1133                crate::ingress::column_sender::encoder::EncodeScratch::new(),
1134                false,
1135            ),
1136            slot_index: None,
1137        })
1138    }
1139
1140    /// Manually reap idle connections.
1141    ///
1142    /// Closes free-list entries that have been idle longer than
1143    /// `idle_timeout_ms`, never shrinking the sender pools below
1144    /// `sender_pool_min` or the reader pool below `query_pool_min`. Returns
1145    /// the number of connections closed.
1146    ///
1147    /// Under the default `pool_reap=auto`, a background thread invokes this
1148    /// logic periodically and this call is harmless. Under
1149    /// `pool_reap=manual`, callers that want shrinking must invoke this on
1150    /// their own cadence.
1151    pub fn reap_idle(&self) -> usize {
1152        reap_idle_inner(&self.inner)
1153    }
1154
1155    /// Total connection events discarded by the listener inbox's
1156    /// drop-oldest policy. `0` when no listener is registered.
1157    pub fn connection_events_dropped(&self) -> u64 {
1158        self.inner.conn_events.dropped()
1159    }
1160
1161    /// Total connection events delivered to the listener. `0` when no
1162    /// listener is registered.
1163    pub fn connection_events_delivered(&self) -> u64 {
1164        self.inner.conn_events.delivered()
1165    }
1166
1167    /// Total server rejections delivered to the rejection handler (or to
1168    /// the default log handler when none was registered).
1169    pub fn rejection_events_delivered(&self) -> u64 {
1170        self.inner.rejections.delivered()
1171    }
1172
1173    /// Total server rejections discarded by the rejection handler inbox's
1174    /// drop-oldest policy. Always `0` without a registered handler: the
1175    /// default log handler has no inbox.
1176    pub fn rejection_events_dropped(&self) -> u64 {
1177        self.inner.rejections.dropped()
1178    }
1179
1180    /// Snapshot per-pool connection counts for diagnostics.
1181    ///
1182    /// Soak / leak harnesses read this on a cadence and assert every pool
1183    /// returns to a steady baseline after load and failover episodes (an FD /
1184    /// connection leak shows up as `in_use` or `free` failing to fall back).
1185    ///
1186    /// Each pool's lock is taken in turn (never two at once), so every field
1187    /// is internally consistent but the three are not a single atomic instant —
1188    /// fine for a monitoring snapshot. **Not semver-stable** (`#[doc(hidden)]`,
1189    /// `#[non_exhaustive]` result); mirrors the `questdb_db_dbg_reader_*_count`
1190    /// FFI diagnostics precedent.
1191    #[doc(hidden)]
1192    pub fn dbg_pool_counts(&self) -> DbgPoolCounts {
1193        let ingress = {
1194            let s = lock_state(&self.inner.state);
1195            DbgPoolCount {
1196                free: s.free.len(),
1197                in_use: s.in_use,
1198                closing: s.closing,
1199            }
1200        };
1201        let column_direct = {
1202            let s = lock_state(&self.inner.direct_state);
1203            DbgPoolCount {
1204                free: s.free.len(),
1205                in_use: s.in_use,
1206                closing: s.closing,
1207            }
1208        };
1209        #[cfg(feature = "_egress")]
1210        let reader = {
1211            let s = lock_reader_state(&self.inner.reader_state);
1212            DbgPoolCount {
1213                free: s.free.len(),
1214                in_use: s.in_use,
1215                closing: 0,
1216            }
1217        };
1218        #[cfg(not(feature = "_egress"))]
1219        let reader = DbgPoolCount::default();
1220        DbgPoolCounts {
1221            ingress,
1222            column_direct,
1223            reader,
1224        }
1225    }
1226
1227    /// Close the pool: stop the reaper (if any), reject future borrows, drop
1228    /// all idle connections, and consume `self`.
1229    ///
1230    /// FFI-owned outstanding handles remain return/drop-safe through their
1231    /// internal pool reference, but return after close drops the connection
1232    /// instead of recycling it.
1233    ///
1234    /// Drop has the same effect; `close` exists for parity with the C ABI
1235    /// (where `Drop` is not available) and to give callers a place to handle
1236    /// any reaper-join errors explicitly in the future.
1237    pub fn close(self) {
1238        drop(self);
1239    }
1240
1241    /// The pool's reconnect backoff budget, parsed from the connect string's
1242    /// `reconnect_*` keys.
1243    #[cfg(any(feature = "polars-ingress", feature = "polars-egress"))]
1244    pub(crate) fn reconnect_policy(&self) -> crate::ingress::ReconnectPolicy {
1245        self.inner.connector.reconnect_policy()
1246    }
1247
1248    /// The pool's failover budget (`reconnect_max_duration`, default 300s).
1249    /// Exposed so the C ABI can let callers bound an overall failover deadline.
1250    #[cfg(feature = "ffi-support")]
1251    pub(crate) fn reconnect_max_duration(&self) -> Duration {
1252        self.inner.connector.reconnect_policy().max_duration()
1253    }
1254
1255    /// Snapshot the number of idle (free) connections currently in the pool.
1256    #[cfg(test)]
1257    pub(crate) fn free_count(&self) -> usize {
1258        lock_state(&self.inner.state).free.len()
1259    }
1260
1261    /// Snapshot the number of currently-borrowed (or in-flight-being-built)
1262    /// connections.
1263    #[cfg(test)]
1264    pub(crate) fn in_use_count(&self) -> usize {
1265        lock_state(&self.inner.state).in_use
1266    }
1267
1268    /// Snapshot the number of disk store-and-forward column slots currently
1269    /// waiting for their close/drop path to release the slot flock.
1270    #[cfg(all(test, feature = "ffi-support"))]
1271    pub(crate) fn closing_count(&self) -> usize {
1272        lock_state(&self.inner.state).closing
1273    }
1274
1275    /// Snapshot the number of idle (free) senders in the always-direct pool.
1276    #[cfg(test)]
1277    pub(crate) fn direct_free_count(&self) -> usize {
1278        lock_state(&self.inner.direct_state).free.len()
1279    }
1280
1281    /// Snapshot the number of currently-borrowed senders in the always-direct
1282    /// pool.
1283    #[cfg(test)]
1284    pub(crate) fn direct_in_use_count(&self) -> usize {
1285        lock_state(&self.inner.direct_state).in_use
1286    }
1287
1288    /// Borrow a query [`Reader`] from the egress pool.
1289    ///
1290    /// Egress companion to [`Self::borrow_sender`]: pulls a [`Reader`]
1291    /// from the pool's reader free list, lazily opening a fresh connection
1292    /// (via `Reader::from_conf` on the original connect string) when the
1293    /// free list is empty and the pool is below `query_pool_max`. The reader
1294    /// pool is lazily grown and capped **independently** of the two ingestion
1295    /// pools, so heavy ingest can't starve queries and vice versa (the
1296    /// combined live-connection ceiling across all three pools is
1297    /// `2 * sender_pool_max + query_pool_max`).
1298    ///
1299    /// Borrow at the cap waits up to `acquire_timeout_ms` for a return
1300    /// (`acquire_timeout_ms=0` fails fast), then returns
1301    /// [`InvalidApiCall`](crate::ErrorCode::InvalidApiCall).
1302    ///
1303    /// The returned [`BorrowedReader`] derefs to `Reader`, so the usual
1304    /// `prepare` / `execute` cursor flow works unchanged, and returns the
1305    /// reader to the pool on `Drop` — unless its transport has been torn
1306    /// down (or [`BorrowedReader::drop_on_return`] was called), in which
1307    /// case it is dropped and the next borrow opens a fresh one.
1308    ///
1309    /// Like [`BorrowedSender`], [`BorrowedReader`] is **not** `Send` or
1310    /// `Sync`: borrow one reader per worker thread from the same `QuestDb`.
1311    #[cfg(feature = "_egress")]
1312    pub fn borrow_reader(&self) -> crate::error::Result<BorrowedReader<'_>> {
1313        let reader = self.pick_reader()?;
1314        Ok(BorrowedReader::new(self, reader))
1315    }
1316
1317    /// FFI escape hatch: borrow a reader from the egress pool.
1318    ///
1319    /// Same shape as [`Self::borrow_sender_owned`] but pulls a
1320    /// [`Reader`] from the reader free list (lazily opens one if the
1321    /// free list is empty and total < `query_pool_max`). Returned via
1322    /// [`OwnedReader`]'s Drop: see the sender variant for the same
1323    /// pattern.
1324    #[cfg(all(feature = "_egress", feature = "ffi-support"))]
1325    pub(crate) fn borrow_reader_owned(&self) -> crate::error::Result<OwnedReader> {
1326        let reader = self.pick_reader()?;
1327        Ok(OwnedReader {
1328            inner: Arc::clone(&self.inner),
1329            reader: Some(reader),
1330            must_close: false,
1331        })
1332    }
1333
1334    /// Construct an opaque pool reference that downstream code (the
1335    /// FFI's `reader` wrapper, in particular) can hold to return
1336    /// readers without having to expose [`DbInner`].
1337    #[cfg(all(feature = "_egress", feature = "ffi-support"))]
1338    pub(crate) fn reader_pool_handle(&self) -> ReaderPoolHandle {
1339        ReaderPoolHandle {
1340            inner: Arc::clone(&self.inner),
1341        }
1342    }
1343
1344    #[cfg(feature = "_egress")]
1345    fn pick_reader(&self) -> crate::error::Result<Reader> {
1346        use crate::{Error, ErrorCode};
1347        let slot = {
1348            let mut state = lock_reader_state(&self.inner.reader_state);
1349            let mut acquire_deadline = None;
1350            loop {
1351                if self.inner.shutdown.load(Ordering::SeqCst) {
1352                    return Err(Error::new(
1353                        ErrorCode::InvalidApiCall,
1354                        "QuestDb pool is closed; cannot borrow reader",
1355                    ));
1356                }
1357                if let Some(entry) = state.free.pop() {
1358                    state.in_use += 1;
1359                    drop(state);
1360                    return Ok(entry.reader);
1361                }
1362                if state.total() < self.inner.query_pool_max {
1363                    break;
1364                }
1365                if let Some(wait_for) =
1366                    remaining_wait(&mut acquire_deadline, self.inner.acquire_timeout)
1367                {
1368                    let (next_state, _) = match self.inner.reader_cv.wait_timeout(state, wait_for) {
1369                        Ok((guard, result)) => (guard, result),
1370                        Err(poisoned) => poisoned.into_inner(),
1371                    };
1372                    state = next_state;
1373                    continue;
1374                }
1375                return Err(Error::new(
1376                    ErrorCode::InvalidApiCall,
1377                    format!(
1378                        "Reader pool exhausted: {} readers are currently borrowed at \
1379                         the `query_pool_max` cap of {} after waiting \
1380                         acquire_timeout_ms={}. Release a reader, or raise \
1381                         `query_pool_max` / `acquire_timeout_ms`.",
1382                        state.in_use,
1383                        self.inner.query_pool_max,
1384                        self.inner.acquire_timeout.as_millis()
1385                    ),
1386                ));
1387            }
1388            state.in_use += 1;
1389            ReaderInUseSlot {
1390                inner: &self.inner,
1391                armed: true,
1392            }
1393        };
1394        let reader = Reader::from_conf(&self.inner.conf)?;
1395        slot.commit();
1396        Ok(reader)
1397    }
1398
1399    /// Snapshot the number of idle (free) readers currently in the pool.
1400    #[cfg(all(feature = "_egress", any(test, feature = "ffi-support")))]
1401    pub(crate) fn reader_free_count(&self) -> usize {
1402        lock_reader_state(&self.inner.reader_state).free.len()
1403    }
1404
1405    /// Snapshot the number of currently-borrowed readers.
1406    #[cfg(all(feature = "_egress", any(test, feature = "ffi-support")))]
1407    pub(crate) fn reader_in_use_count(&self) -> usize {
1408        lock_reader_state(&self.inner.reader_state).in_use
1409    }
1410}
1411
1412impl Debug for QuestDb {
1413    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1414        let state = lock_state(&self.inner.state);
1415        let mut s = f.debug_struct("QuestDb");
1416        s.field("sender_pool_min", &self.inner.sender_pool_min)
1417            .field("sender_pool_max", &self.inner.sender_pool_max);
1418        #[cfg(feature = "_egress")]
1419        s.field("query_pool_min", &self.inner.query_pool_min)
1420            .field("query_pool_max", &self.inner.query_pool_max);
1421        s.field("acquire_timeout", &self.inner.acquire_timeout)
1422            .field("free", &state.free.len())
1423            .field("in_use", &state.in_use)
1424            .finish()
1425    }
1426}
1427
1428impl Drop for QuestDb {
1429    fn drop(&mut self) {
1430        // Wake the reaper and any at-cap borrow waits, and let them
1431        // observe shutdown.
1432        self.inner.shutdown.store(true, Ordering::SeqCst);
1433        // Notifying under the mutex avoids the lost-wakeup race where the
1434        // waiter has just released the lock and is about to wait.
1435        {
1436            let _g = lock_state(&self.inner.state);
1437            self.inner.cv.notify_all();
1438        }
1439        {
1440            let _g = lock_state(&self.inner.direct_state);
1441            self.inner.direct_cv.notify_all();
1442        }
1443        #[cfg(feature = "_egress")]
1444        {
1445            let _g = lock_reader_state(&self.inner.reader_state);
1446            self.inner.reader_cv.notify_all();
1447        }
1448        if let Some(handle) = self.reaper.take() {
1449            let _ = handle.join();
1450        }
1451        // Close idle resources now. Outstanding borrows hold their own Arc and
1452        // will be dropped instead of recycled when they return after shutdown.
1453        drain_idle_inner(&self.inner);
1454        // FFI-owned senders may outlive the public pool handle. Detach and join
1455        // the dispatchers after idle emitters are gone; any outstanding sender
1456        // still holds the sources but can no longer reach the user callbacks.
1457        // Returning from pool close is therefore a callback/user_data fence.
1458        self.inner.conn_events.close();
1459        self.inner.rejections.close();
1460    }
1461}
1462
1463struct SenderHandle<'a> {
1464    db: &'a QuestDb,
1465    sender: Option<PooledSenderCore>,
1466    slot_index: Option<usize>,
1467    _not_send: PhantomData<Rc<()>>,
1468}
1469
1470impl<'a> SenderHandle<'a> {
1471    fn new(db: &'a QuestDb, sender: PooledSender<PooledSenderCore>) -> Self {
1472        Self {
1473            db,
1474            sender: Some(sender.sender),
1475            slot_index: sender.slot_index,
1476            _not_send: PhantomData,
1477        }
1478    }
1479
1480    fn inner_mut(&mut self) -> &mut PooledSenderCore {
1481        self.sender
1482            .as_mut()
1483            .expect("borrowed sender already returned")
1484    }
1485
1486    fn inner_ref(&self) -> &PooledSenderCore {
1487        self.sender
1488            .as_ref()
1489            .expect("borrowed sender already returned")
1490    }
1491}
1492
1493struct DirectSenderHandle<'a> {
1494    db: &'a QuestDb,
1495    sender: Option<DirectSenderCore>,
1496    _not_send: PhantomData<Rc<()>>,
1497}
1498
1499impl<'a> DirectSenderHandle<'a> {
1500    fn new(db: &'a QuestDb, sender: PooledSender<DirectSenderCore>) -> Self {
1501        debug_assert!(sender.slot_index.is_none());
1502        Self {
1503            db,
1504            sender: Some(sender.sender),
1505            _not_send: PhantomData,
1506        }
1507    }
1508
1509    fn inner_mut(&mut self) -> &mut DirectSenderCore {
1510        self.sender
1511            .as_mut()
1512            .expect("borrowed direct sender already returned")
1513    }
1514
1515    #[cfg(test)]
1516    fn inner_ref(&self) -> &DirectSenderCore {
1517        self.sender
1518            .as_ref()
1519            .expect("borrowed direct sender already returned")
1520    }
1521
1522    #[cfg(any(feature = "polars-ingress", feature = "polars-egress"))]
1523    pub(crate) fn reconnect_policy(&self) -> crate::ingress::ReconnectPolicy {
1524        self.db.reconnect_policy()
1525    }
1526
1527    /// Drop the current connection (and its paired connection-scoped
1528    /// `SymbolGlobalDict`) back to the pool and obtain a fresh one **behind
1529    /// the same handle**, so the caller's borrowed direct sender stays valid.
1530    ///
1531    /// This is the direct sender's failover primitive: after a transient
1532    /// (`ErrorCode::FailoverRetry`) flush/sync failure, call this to swap onto
1533    /// a live connection — the pool's connect path rotates across endpoints,
1534    /// skips the dead one, and follows the writable primary. The dropped
1535    /// connection's dict is discarded with it; the fresh connection brings its
1536    /// own dict, consistent with the server it talks to, so the unchanged
1537    /// delta-dict encoder re-drives correctly on the re-iterated source.
1538    ///
1539    /// The current connection stays behind this handle until a replacement has
1540    /// been opened. If replacement connect fails, the handle remains populated
1541    /// (possibly with a terminal connection) so later safe calls report errors
1542    /// instead of panicking. Once replacement succeeds, a failed connection is
1543    /// dropped (not recycled); a clean connection with un-sync'd in-flight
1544    /// frames is also dropped, mirroring [`Drop`], so the next borrower never
1545    /// commits this caller's data.
1546    pub fn reborrow_from_pool(&mut self) -> Result<()> {
1547        if let Some(sender) = self.sender.as_mut() {
1548            // reborrow is a failover path, not a forced rotate. A healthy,
1549            // fully-sync'd connection needs no replacement; replacing it would
1550            // open a fresh connection and recycle this one, growing the pool.
1551            if sender.in_flight() == 0 && !sender.must_close() && !sender.transport_dead() {
1552                return Ok(());
1553            }
1554            if sender.in_flight() > 0 {
1555                log::warn!(
1556                    "direct sender failover dropped a connection with un-sync'd \
1557                     deferred frame(s); their data is discarded. Re-drive the source \
1558                     from the last successful sync(), not from the failing chunk."
1559                );
1560                sender.mark_must_close();
1561            }
1562            record_sender_transport_failure(&self.db.inner, sender);
1563        }
1564        let fresh = self.db.pick_replacement_sender()?;
1565        debug_assert!(fresh.slot_index.is_none());
1566        if let Some(old) = self.sender.replace(fresh.sender) {
1567            finish_replaced_sender(&self.db.inner, old);
1568        }
1569        Ok(())
1570    }
1571
1572    /// Retry [`reborrow_from_pool`] within `deadline` using the row API's
1573    /// reconnect backoff (centered-jittered, role-reject reset; `AuthError` /
1574    /// `ProtocolVersionError` terminal). On terminal failure or budget
1575    /// exhaustion the handle stays populated (per [`reborrow_from_pool`]), so a
1576    /// later call reports a typed error rather than panicking.
1577    #[cfg(any(feature = "polars-ingress", feature = "polars-egress"))]
1578    pub(crate) fn reborrow_with_retry(&mut self, deadline: Option<Instant>) -> Result<()> {
1579        let policy = self.reconnect_policy();
1580        let mut backoff = policy.initial_backoff();
1581        loop {
1582            match self.reborrow_from_pool() {
1583                Ok(()) => return Ok(()),
1584                Err(e)
1585                    if reconnect_error_is_terminal(&e) || reconnect_deadline_expired(deadline) =>
1586                {
1587                    return Err(e);
1588                }
1589                Err(e) => {
1590                    let (sleep_for, next) = reconnect_backoff_step(
1591                        &e,
1592                        policy.initial_backoff(),
1593                        policy.max_backoff(),
1594                        backoff,
1595                    );
1596                    sleep_until_deadline(sleep_for, deadline);
1597                    backoff = next;
1598                }
1599            }
1600        }
1601    }
1602}
1603
1604impl Debug for SenderHandle<'_> {
1605    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1606        f.debug_struct("SenderHandle")
1607            .field("sender", &self.sender)
1608            .finish()
1609    }
1610}
1611
1612impl Debug for DirectSenderHandle<'_> {
1613    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1614        f.debug_struct("DirectSenderHandle")
1615            .field("sender", &self.sender)
1616            .finish()
1617    }
1618}
1619
1620/// Store-and-forward QWP sender borrowed from a [`QuestDb`] pool — the
1621/// handle returned by [`QuestDb::borrow_sender`].
1622///
1623/// [`Self::flush`] appends a frame to the connection's store-and-forward queue
1624/// and returns as soon as it is accepted locally (no server round-trip); the
1625/// connection's background runner delivers it asynchronously. While the handle
1626/// is borrowed or parked in the pool the runner keeps delivering, so returning
1627/// or dropping the handle does not by itself lose accepted frames.
1628///
1629/// Delivery is completed best-effort when the pool is closed or the connection
1630/// is retired, bounded by `close_flush_timeout` (default 5s): an in-memory
1631/// queue whose server stays unreachable past that window drops its undelivered
1632/// tail, logging a warning. For a hard guarantee, call [`Self::wait`] before
1633/// closing the pool — it blocks until the frames published so far reach the
1634/// requested [`AckLevel`], i.e. confirms delivery — or configure `sf_dir` for
1635/// crash-durable on-disk persistence with replay. [`Self::flush_and_wait`]
1636/// combines the two ("publish this batch and return once it is delivered");
1637/// its wait is bounded by the pool-wide `request_timeout` setting, so compose
1638/// [`Self::flush`] then [`Self::wait`] if you want to pass an explicit
1639/// timeout instead.
1640/// Use FSNs only for non-blocking progress tracking while this borrowed sender
1641/// is still held: they are stream watermarks, not portable receipts to check
1642/// through an arbitrary later pool borrow.
1643///
1644/// Not `Send` or `Sync`.
1645///
1646/// The lease cannot outlive its pool:
1647///
1648/// ```compile_fail
1649/// use questdb::{BorrowedSender, QuestDb};
1650///
1651/// fn escape() -> BorrowedSender<'static> {
1652///     let db = QuestDb::connect("ws::addr=localhost:9000;").unwrap();
1653///     db.borrow_sender().unwrap()
1654/// }
1655/// ```
1656///
1657/// It cannot be moved to another thread:
1658///
1659/// ```compile_fail
1660/// use questdb::QuestDb;
1661///
1662/// let db = QuestDb::connect("ws::addr=localhost:9000;").unwrap();
1663/// let sender = db.borrow_sender().unwrap();
1664/// std::thread::scope(|scope| {
1665///     scope.spawn(move || drop(sender));
1666/// });
1667/// ```
1668///
1669/// Nor can a shared reference be sent to another thread:
1670///
1671/// ```compile_fail
1672/// use questdb::QuestDb;
1673///
1674/// let db = QuestDb::connect("ws::addr=localhost:9000;").unwrap();
1675/// let sender = db.borrow_sender().unwrap();
1676/// std::thread::scope(|scope| {
1677///     scope.spawn(|| std::hint::black_box(&sender));
1678/// });
1679/// ```
1680pub struct BorrowedSender<'a>(SenderHandle<'a>);
1681
1682impl<'a> BorrowedSender<'a> {
1683    #[cfg(test)]
1684    pub(crate) fn effective_frame_cap_for_test(&self) -> (usize, bool) {
1685        self.0.inner_ref().effective_frame_cap()
1686    }
1687
1688    /// Create a caller-owned QWP/WebSocket [`Buffer`] using the pool's
1689    /// configured name limit. The buffer is not tied to this lease and may be
1690    /// flushed by another sender borrowed from the same pool.
1691    pub fn new_buffer(&self) -> Buffer {
1692        self.0.db.new_buffer()
1693    }
1694
1695    /// Encode and publish `chunk` into the store-and-forward queue, returning
1696    /// as soon as the frame is accepted locally (no server round-trip). On
1697    /// success `chunk` is cleared; on a delivery-uncertain failure the error
1698    /// is tagged [`in_doubt`](crate::Error::in_doubt).
1699    pub fn flush(&mut self, chunk: &mut crate::ingress::column_sender::Chunk<'_>) -> Result<()> {
1700        self.0.inner_mut().flush(chunk)
1701    }
1702
1703    /// Publish a caller-owned QWP/WebSocket [`Buffer`] into this sender's local
1704    /// store-and-forward queue and clear it after local acceptance.
1705    pub fn flush_buffer(&mut self, buffer: &mut Buffer) -> Result<()> {
1706        self.0.inner_mut().flush_buffer(buffer)
1707    }
1708
1709    /// Publish a caller-owned QWP/WebSocket [`Buffer`] without clearing it.
1710    pub fn flush_buffer_and_keep(&mut self, buffer: &Buffer) -> Result<()> {
1711        self.0.inner_mut().flush_buffer_and_keep(buffer)
1712    }
1713
1714    /// Publish and clear a QWP/WebSocket [`Buffer`], returning its local frame
1715    /// sequence number. Empty buffers publish no frame and return `None`.
1716    pub fn flush_buffer_and_get_fsn(&mut self, buffer: &mut Buffer) -> Result<Option<u64>> {
1717        self.0.inner_mut().flush_buffer_and_get_fsn(buffer)
1718    }
1719
1720    /// Publish a QWP/WebSocket [`Buffer`] without clearing it and return its
1721    /// local frame sequence number. Empty buffers return `None`.
1722    pub fn flush_buffer_and_keep_and_get_fsn(&mut self, buffer: &Buffer) -> Result<Option<u64>> {
1723        self.0.inner_mut().flush_buffer_and_keep_and_get_fsn(buffer)
1724    }
1725
1726    /// Publish and clear a QWP/WebSocket [`Buffer`], then wait for the requested
1727    /// ACK boundary using the pool's configured request timeout.
1728    pub fn flush_buffer_and_wait(
1729        &mut self,
1730        buffer: &mut Buffer,
1731        ack_level: AckLevel,
1732    ) -> Result<()> {
1733        self.0.inner_mut().flush_buffer_and_wait(buffer, ack_level)
1734    }
1735
1736    /// Publish `chunk` into the store-and-forward queue as a completion
1737    /// boundary, then wait until every frame published on this handle so far
1738    /// reaches `ack_level` — [`Self::flush`] followed by [`Self::wait`] in one
1739    /// call. Unlike [`Self::wait`], which takes an explicit timeout argument,
1740    /// this call's wait is bounded by the pool-wide `request_timeout` setting
1741    /// (the no-progress timeout fires when the ack watermark stops advancing
1742    /// for that long); compose the two calls yourself to choose the timeout
1743    /// per call.
1744    ///
1745    /// `AckLevel::Durable` requires QuestDB Enterprise and a pool opened with
1746    /// `request_durable_ack=on`; otherwise the call is rejected up front
1747    /// (`InvalidApiCall`) before `chunk` is touched.
1748    ///
1749    /// Failure contract: if local publication fails, `chunk` is untouched and
1750    /// retryable. Once the frame is accepted into the queue `chunk` is cleared
1751    /// even if the wait then fails. On the no-progress timeout
1752    /// ([`ErrorCode::FailoverRetry`](crate::ErrorCode)) the frames remain
1753    /// queued and the background runner keeps delivering them — recover by
1754    /// calling [`Self::wait`] until it returns `Ok`, not by re-flushing
1755    /// (which would deliver the same rows twice). A terminal server rejection
1756    /// or transport failure instead ends delivery on this sender: drop the
1757    /// borrow and recover per the rejection policy.
1758    pub fn flush_and_wait(
1759        &mut self,
1760        chunk: &mut crate::ingress::column_sender::Chunk<'_>,
1761        ack_level: AckLevel,
1762    ) -> Result<()> {
1763        self.0.inner_mut().flush_and_wait(chunk, ack_level)
1764    }
1765
1766    /// Encode and publish `chunk` into the store-and-forward queue and return
1767    /// the highest published frame sequence number.
1768    ///
1769    /// This is the non-blocking progress-tracking form of [`Self::flush`]:
1770    /// success means the frame was accepted locally, not that the server has
1771    /// ACKed it. If the chunk is split into multiple frames, the returned FSN
1772    /// is the final frame boundary; cumulative ACK coverage of that boundary
1773    /// covers the whole chunk. Use [`Self::wait`] when you only need a simple
1774    /// blocking barrier for everything published so far. Treat the returned
1775    /// FSN as meaningful only with this sender stream while this borrow is
1776    /// held.
1777    pub fn flush_and_get_fsn(
1778        &mut self,
1779        chunk: &mut crate::ingress::column_sender::Chunk<'_>,
1780    ) -> Result<Option<u64>> {
1781        self.0.inner_mut().flush_and_get_fsn(chunk)
1782    }
1783
1784    /// Return the highest frame sequence number published locally by this
1785    /// sender, or `None` if no frame has been published.
1786    ///
1787    /// This is a stream watermark for the currently borrowed sender, not a
1788    /// portable receipt to check through an arbitrary later pool borrow.
1789    pub fn published_fsn(&self) -> Result<Option<u64>> {
1790        self.0.inner_ref().published_fsn()
1791    }
1792
1793    /// Return the highest frame sequence number completed by server ACK or
1794    /// server-side reject-and-continue, or `None` if no frame has completed.
1795    ///
1796    /// In Enterprise durable-ACK mode this watermark advances after durable
1797    /// ACK coverage; use [`Self::wait`] when you need an explicit
1798    /// [`AckLevel::Ok`] or [`AckLevel::Durable`] barrier. Compare it only with
1799    /// FSNs produced by this same sender stream.
1800    pub fn acked_fsn(&self) -> Result<Option<u64>> {
1801        self.0.inner_ref().acked_fsn()
1802    }
1803
1804    /// Wait up to `timeout` for every frame published through this lease so
1805    /// far to reach `ack_level`. Short-circuits when the lease published
1806    /// nothing or the watermark already covers its latest frame. The barrier
1807    /// is a watermark check plus a terminal-latch check: only a terminal
1808    /// connection failure fails it. Server rejections are delivered to the
1809    /// pool's rejection handler (default: logged; see
1810    /// [`QuestDb::connect_with_handlers`]) rather than raised here;
1811    /// retriable ones are replayed by the queue. `AckLevel::Durable` requires
1812    /// QuestDB Enterprise and a pool opened with `request_durable_ack=on`.
1813    ///
1814    /// `timeout` is a no-progress deadline (it fires only if the ack watermark
1815    /// fails to advance for that long); `Duration::ZERO` waits indefinitely.
1816    /// On expiry it returns an [`ErrorCode::FailoverRetry`](crate::ErrorCode)
1817    /// error; the frames remain queued and the background runner keeps
1818    /// delivering them, so recover by calling `wait()` again until it returns
1819    /// `Ok` — not by re-flushing, which would deliver the same rows twice.
1820    pub fn wait(&mut self, ack_level: AckLevel, timeout: Duration) -> Result<()> {
1821        self.0.inner_mut().wait(ack_level, timeout)
1822    }
1823
1824    /// Force this borrowed connection to be dropped (not recycled) on return.
1825    ///
1826    /// Use normal `Drop` for healthy connections: the return path already
1827    /// retires connections that latched terminal state, or whose pool has been
1828    /// closed. Call this after abandoning work or handling an error where the
1829    /// next borrower must not inherit this backend. If queued
1830    /// store-and-forward frames must not be lost, call [`Self::wait`] first or
1831    /// configure `sf_dir` for replay.
1832    pub fn drop_on_return(&mut self) {
1833        self.0.inner_mut().mark_must_close()
1834    }
1835
1836    #[cfg(test)]
1837    pub(crate) fn must_close_for_test(&self) -> bool {
1838        self.0.inner_ref().must_close()
1839    }
1840
1841    /// Always `true` for an SF handle (it wraps a store-and-forward backend).
1842    /// Retained for symmetry with [`BorrowedDirectColumnSender`] and test assertions.
1843    #[cfg(test)]
1844    pub(crate) fn is_store_and_forward(&self) -> bool {
1845        true
1846    }
1847
1848    /// In-flight (published-but-unacked) frame count. Always 0 for the SF
1849    /// backend, whose queue tracks delivery internally.
1850    #[cfg(test)]
1851    pub(crate) fn in_flight(&self) -> u32 {
1852        0
1853    }
1854
1855    /// Encode and publish an Arrow [`RecordBatch`](arrow::array::RecordBatch)
1856    /// into the queue, letting the server stamp each row's designated
1857    /// timestamp on arrival. Publish-only; call [`Self::wait`] for an ack.
1858    #[cfg(feature = "arrow-ingress")]
1859    pub fn flush_arrow_batch_at_now<'t, T>(
1860        &mut self,
1861        table: T,
1862        batch: &arrow::array::RecordBatch,
1863        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
1864    ) -> Result<()>
1865    where
1866        T: TryInto<crate::ingress::TableName<'t>>,
1867        crate::Error: From<T::Error>,
1868    {
1869        self.0
1870            .inner_mut()
1871            .flush_arrow_batch_at_now(table, batch, overrides)
1872    }
1873
1874    /// ACKing counterpart of [`Self::flush_arrow_batch_at_now`]: publish the
1875    /// batch as a completion boundary, then wait for `ack_level`. The same
1876    /// contract as [`Self::flush_and_wait`] applies.
1877    #[cfg(feature = "arrow-ingress")]
1878    pub fn flush_arrow_batch_at_now_and_wait<'t, T>(
1879        &mut self,
1880        table: T,
1881        batch: &arrow::array::RecordBatch,
1882        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
1883        ack_level: AckLevel,
1884    ) -> Result<()>
1885    where
1886        T: TryInto<crate::ingress::TableName<'t>>,
1887        crate::Error: From<T::Error>,
1888    {
1889        self.0
1890            .inner_mut()
1891            .flush_arrow_batch_at_now_and_wait(table, batch, overrides, ack_level)
1892    }
1893
1894    /// Arrow counterpart of [`Self::flush_and_get_fsn`], letting the server
1895    /// stamp each row's designated timestamp on arrival.
1896    #[cfg(feature = "arrow-ingress")]
1897    pub fn flush_arrow_batch_at_now_and_get_fsn<'t, T>(
1898        &mut self,
1899        table: T,
1900        batch: &arrow::array::RecordBatch,
1901        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
1902    ) -> Result<Option<u64>>
1903    where
1904        T: TryInto<crate::ingress::TableName<'t>>,
1905        crate::Error: From<T::Error>,
1906    {
1907        self.0
1908            .inner_mut()
1909            .flush_arrow_batch_at_now_and_get_fsn(table, batch, overrides)
1910    }
1911
1912    /// Encode and publish an Arrow [`RecordBatch`](arrow::array::RecordBatch)
1913    /// into the queue, sourcing the designated timestamp from the named
1914    /// column. Publish-only; call [`Self::wait`] for an ack.
1915    #[cfg(feature = "arrow-ingress")]
1916    pub fn flush_arrow_batch_at_column<'t, T>(
1917        &mut self,
1918        table: T,
1919        batch: &arrow::array::RecordBatch,
1920        ts_column: crate::ingress::ColumnName<'_>,
1921        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
1922    ) -> Result<()>
1923    where
1924        T: TryInto<crate::ingress::TableName<'t>>,
1925        crate::Error: From<T::Error>,
1926    {
1927        self.0
1928            .inner_mut()
1929            .flush_arrow_batch_at_column(table, batch, ts_column, overrides)
1930    }
1931
1932    /// ACKing counterpart of [`Self::flush_arrow_batch_at_column`]: publish
1933    /// the batch as a completion boundary, then wait for `ack_level`. The same
1934    /// contract as [`Self::flush_and_wait`] applies.
1935    #[cfg(feature = "arrow-ingress")]
1936    pub fn flush_arrow_batch_at_column_and_wait<'t, T>(
1937        &mut self,
1938        table: T,
1939        batch: &arrow::array::RecordBatch,
1940        ts_column: crate::ingress::ColumnName<'_>,
1941        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
1942        ack_level: AckLevel,
1943    ) -> Result<()>
1944    where
1945        T: TryInto<crate::ingress::TableName<'t>>,
1946        crate::Error: From<T::Error>,
1947    {
1948        self.0
1949            .inner_mut()
1950            .flush_arrow_batch_at_column_and_wait(table, batch, ts_column, overrides, ack_level)
1951    }
1952
1953    /// Arrow counterpart of [`Self::flush_and_get_fsn`], sourcing the
1954    /// designated timestamp from the named column.
1955    #[cfg(feature = "arrow-ingress")]
1956    pub fn flush_arrow_batch_at_column_and_get_fsn<'t, T>(
1957        &mut self,
1958        table: T,
1959        batch: &arrow::array::RecordBatch,
1960        ts_column: crate::ingress::ColumnName<'_>,
1961        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
1962    ) -> Result<Option<u64>>
1963    where
1964        T: TryInto<crate::ingress::TableName<'t>>,
1965        crate::Error: From<T::Error>,
1966    {
1967        self.0
1968            .inner_mut()
1969            .flush_arrow_batch_at_column_and_get_fsn(table, batch, ts_column, overrides)
1970    }
1971}
1972
1973impl Debug for BorrowedSender<'_> {
1974    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1975        f.debug_tuple("BorrowedSender").field(&self.0).finish()
1976    }
1977}
1978
1979/// Direct (pipelined, non-store-and-forward) column sender borrowed from a
1980/// [`QuestDb`] pool — the handle returned by
1981/// [`QuestDb::borrow_direct_column_sender`], used by DataFrame ingestion.
1982///
1983/// [`Self::flush`] pipelines a deferred frame; [`Self::commit`] (or
1984/// [`Self::flush_and_wait`] on the final chunk) sends the commit boundary and
1985/// waits for `ack_level`. Normal `Drop` makes a best-effort commit of
1986/// uncommitted deferred frames at the pool's default ack level. If that commit
1987/// fails, or if [`Self::drop_on_return`] was requested, those frames are
1988/// discarded; for deterministic error handling, call [`Self::commit`] or
1989/// [`Self::flush_and_wait`] yourself and re-drive from the last successful
1990/// commit after failure.
1991///
1992/// Not `Send` or `Sync`.
1993pub struct BorrowedDirectColumnSender<'a>(DirectSenderHandle<'a>);
1994
1995impl<'a> BorrowedDirectColumnSender<'a> {
1996    /// Encode and pipeline `chunk` as a deferred frame without waiting. The
1997    /// frame is not committed until [`Self::commit`] / [`Self::flush_and_wait`].
1998    pub fn flush(&mut self, chunk: &mut crate::ingress::column_sender::Chunk<'_>) -> Result<()> {
1999        self.0.inner_mut().flush(chunk)
2000    }
2001
2002    /// Publish `chunk` as a non-deferred commit boundary and block until it
2003    /// (and all prior pipelined frames) reach `ack_level`.
2004    pub fn flush_and_wait(
2005        &mut self,
2006        chunk: &mut crate::ingress::column_sender::Chunk<'_>,
2007        ack_level: AckLevel,
2008    ) -> Result<()> {
2009        self.0.inner_mut().flush_and_wait(chunk, ack_level)
2010    }
2011
2012    /// Send the commit boundary for all pipelined frames and block until they
2013    /// reach `ack_level`. This is the direct sender's explicit durability
2014    /// checkpoint; normal `Drop` attempts the same kind of commit best-effort,
2015    /// but callers that need deterministic error handling should call
2016    /// `commit()` themselves.
2017    pub fn commit(&mut self, ack_level: AckLevel) -> Result<()> {
2018        self.0.inner_mut().sync(ack_level)
2019    }
2020
2021    /// Failover primitive: swap onto a fresh connection from the pool behind
2022    /// the same handle after a transient flush failure. No-op on a healthy,
2023    /// fully-committed connection.
2024    pub fn reborrow_from_pool(&mut self) -> Result<()> {
2025        self.0.reborrow_from_pool()
2026    }
2027
2028    #[cfg(any(feature = "polars-ingress", feature = "polars-egress"))]
2029    pub(crate) fn reborrow_with_retry(&mut self, deadline: Option<Instant>) -> Result<()> {
2030        self.0.reborrow_with_retry(deadline)
2031    }
2032
2033    #[cfg(any(feature = "polars-ingress", feature = "polars-egress"))]
2034    pub(crate) fn reconnect_policy(&self) -> crate::ingress::ReconnectPolicy {
2035        self.0.reconnect_policy()
2036    }
2037
2038    /// The pool's default ack level (see [`QuestDb::default_ack_level`]),
2039    /// reached through the handle's owning `QuestDb`.
2040    #[cfg(feature = "polars-ingress")]
2041    pub(crate) fn default_ack_level(&self) -> AckLevel {
2042        self.0.db.default_ack_level()
2043    }
2044
2045    /// Force this borrowed connection to be dropped (not recycled) on return.
2046    ///
2047    /// Use normal `Drop` for healthy connections: the return path already
2048    /// retires connections that latched terminal state, or whose pool has been
2049    /// closed. Call this after abandoning deferred frames or handling an error
2050    /// where the next borrower must not inherit this backend. Call this only
2051    /// after you are done using the handle. To preserve deferred frames, commit
2052    /// them successfully with [`Self::commit`] or [`Self::flush_and_wait`]
2053    /// before calling `drop_on_return()`; after this call the connection is
2054    /// terminal and later commit/flush attempts may fail.
2055    pub fn drop_on_return(&mut self) {
2056        self.0.inner_mut().mark_must_close()
2057    }
2058
2059    #[cfg(test)]
2060    pub(crate) fn must_close_for_test(&self) -> bool {
2061        self.0.inner_ref().must_close()
2062    }
2063
2064    /// Always `false` for a direct handle. Retained for symmetry with
2065    /// [`BorrowedSender`] and test assertions.
2066    #[cfg(test)]
2067    pub(crate) fn is_store_and_forward(&self) -> bool {
2068        false
2069    }
2070
2071    /// In-flight (published-but-unacked) deferred frame count.
2072    #[cfg(test)]
2073    pub(crate) fn in_flight(&self) -> u32 {
2074        self.0.inner_ref().in_flight()
2075    }
2076
2077    /// Publish-only Arrow flush (server-stamped). Pair with [`Self::commit`].
2078    /// Only the DataFrame checkpoint loop pipelines publish-only frames, so this
2079    /// is gated on `polars-ingress` (a plain `arrow-ingress` build reaches the
2080    /// server only through the ACKing `flush_arrow_batch`).
2081    #[cfg(feature = "polars-ingress")]
2082    pub(crate) fn flush_arrow_batch_at_now<'t, T>(
2083        &mut self,
2084        table: T,
2085        batch: &arrow::array::RecordBatch,
2086        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
2087    ) -> Result<()>
2088    where
2089        T: TryInto<crate::ingress::TableName<'t>>,
2090        crate::Error: From<T::Error>,
2091    {
2092        self.0
2093            .inner_mut()
2094            .flush_arrow_batch_at_now(table, batch, overrides)
2095    }
2096
2097    /// Publish-only Arrow flush (column-stamped). Pair with [`Self::commit`].
2098    /// `polars-ingress`-gated for the same reason as
2099    /// [`Self::flush_arrow_batch_at_now`].
2100    #[cfg(feature = "polars-ingress")]
2101    pub(crate) fn flush_arrow_batch_at_column<'t, T>(
2102        &mut self,
2103        table: T,
2104        batch: &arrow::array::RecordBatch,
2105        ts_column: crate::ingress::ColumnName<'_>,
2106        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
2107    ) -> Result<()>
2108    where
2109        T: TryInto<crate::ingress::TableName<'t>>,
2110        crate::Error: From<T::Error>,
2111    {
2112        self.0
2113            .inner_mut()
2114            .flush_arrow_batch_at_column(table, batch, ts_column, overrides)
2115    }
2116
2117    /// ACKing Arrow flush (server-stamped): publish as a commit boundary and
2118    /// wait for `ack_level`.
2119    #[cfg(feature = "arrow-ingress")]
2120    pub(crate) fn flush_arrow_batch_at_now_and_wait<'t, T>(
2121        &mut self,
2122        table: T,
2123        batch: &arrow::array::RecordBatch,
2124        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
2125        ack_level: AckLevel,
2126    ) -> Result<()>
2127    where
2128        T: TryInto<crate::ingress::TableName<'t>>,
2129        crate::Error: From<T::Error>,
2130    {
2131        self.0
2132            .inner_mut()
2133            .flush_arrow_batch_at_now_and_wait(table, batch, overrides, ack_level)
2134    }
2135
2136    /// ACKing Arrow flush (column-stamped): publish as a commit boundary and
2137    /// wait for `ack_level`.
2138    #[cfg(feature = "arrow-ingress")]
2139    pub(crate) fn flush_arrow_batch_at_column_and_wait<'t, T>(
2140        &mut self,
2141        table: T,
2142        batch: &arrow::array::RecordBatch,
2143        ts_column: crate::ingress::ColumnName<'_>,
2144        overrides: &[crate::ingress::column_sender::ArrowColumnOverride<'_>],
2145        ack_level: AckLevel,
2146    ) -> Result<()>
2147    where
2148        T: TryInto<crate::ingress::TableName<'t>>,
2149        crate::Error: From<T::Error>,
2150    {
2151        self.0
2152            .inner_mut()
2153            .flush_arrow_batch_at_column_and_wait(table, batch, ts_column, overrides, ack_level)
2154    }
2155}
2156
2157impl Debug for BorrowedDirectColumnSender<'_> {
2158    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2159        f.debug_tuple("BorrowedDirectColumnSender")
2160            .field(&self.0)
2161            .finish()
2162    }
2163}
2164
2165impl Drop for SenderHandle<'_> {
2166    fn drop(&mut self) {
2167        let Some(sender) = self.sender.take() else {
2168            return;
2169        };
2170        return_sfa_to_pool(&self.db.inner, sender, self.slot_index);
2171    }
2172}
2173
2174impl Drop for DirectSenderHandle<'_> {
2175    fn drop(&mut self) {
2176        let Some(mut sender) = self.sender.take() else {
2177            return;
2178        };
2179        commit_in_flight_on_drop(self.db.inner.connector.request_durable_ack(), &mut sender);
2180        return_direct_to_pool(&self.db.inner, sender);
2181    }
2182}
2183
2184/// Owned (lifetime-free) variant of a borrowed sender used by the C FFI.
2185///
2186/// Holds an `Arc<DbInner>` so the pool's return path outlives the
2187/// user-facing `QuestDb` pointer — the C ABI can free its `questdb_db*`
2188/// before dropping outstanding `qwp_sender*` or `qwp_direct_sender*`
2189/// handles. After pool close, returned handles are dropped instead of recycled.
2190#[cfg(feature = "ffi-support")]
2191pub struct OwnedSender {
2192    inner: Arc<DbInner>,
2193    sender: Option<PooledSenderCore>,
2194    slot_index: Option<usize>,
2195}
2196
2197#[cfg(feature = "ffi-support")]
2198impl OwnedSender {
2199    fn new(inner: Arc<DbInner>, sender: PooledSender<PooledSenderCore>) -> Self {
2200        Self {
2201            inner,
2202            sender: Some(sender.sender),
2203            slot_index: sender.slot_index,
2204        }
2205    }
2206
2207    /// Borrow the underlying [`PooledSenderCore`] mutably. Always returns a
2208    /// live reference until `Drop` runs.
2209    pub fn get_mut(&mut self) -> &mut PooledSenderCore {
2210        self.sender
2211            .as_mut()
2212            .expect("OwnedSender already returned to the pool")
2213    }
2214
2215    /// Inspect the wrapped sender without taking ownership.
2216    pub fn get(&self) -> &PooledSenderCore {
2217        self.sender
2218            .as_ref()
2219            .expect("OwnedSender already returned to the pool")
2220    }
2221
2222    /// `true` after the originating pool has been closed. FFI callers use
2223    /// this to reject new work on checked-out handles while still allowing
2224    /// return/drop to clean up safely.
2225    pub fn pool_closed(&self) -> bool {
2226        self.inner.shutdown.load(Ordering::SeqCst)
2227    }
2228
2229    /// Force this sender to be dropped instead of recycled when the owned FFI
2230    /// handle is released.
2231    pub fn mark_must_close(&mut self) {
2232        self.get_mut().mark_must_close();
2233    }
2234
2235    /// `true` when this sender cannot be returned to the pool, either because
2236    /// the sender is terminal or because its originating pool has closed.
2237    pub fn must_close(&self) -> bool {
2238        self.pool_closed() || self.get().must_close()
2239    }
2240}
2241
2242#[cfg(feature = "ffi-support")]
2243impl Drop for OwnedSender {
2244    fn drop(&mut self) {
2245        if let Some(sender) = self.sender.take() {
2246            return_sfa_to_pool(&self.inner, sender, self.slot_index);
2247        }
2248    }
2249}
2250
2251/// Backing of an [`OwnedDirectColumnSender`]: either a slot returned to a
2252/// pool, or a poolless connection owned outright.
2253#[cfg(feature = "ffi-support")]
2254enum DirectBacking {
2255    Pool(Arc<DbInner>),
2256    Standalone { request_durable_ack: bool },
2257}
2258
2259/// Owned variant of the hidden direct sender used by the C FFI. Either
2260/// borrowed from a [`QuestDb`] pool or built standalone from a config string.
2261#[cfg(feature = "ffi-support")]
2262pub struct OwnedDirectColumnSender {
2263    backing: DirectBacking,
2264    sender: Option<DirectSenderCore>,
2265}
2266
2267#[cfg(feature = "ffi-support")]
2268impl OwnedDirectColumnSender {
2269    fn new(inner: Arc<DbInner>, sender: PooledSender<DirectSenderCore>) -> Self {
2270        debug_assert!(sender.slot_index.is_none());
2271        Self {
2272            backing: DirectBacking::Pool(inner),
2273            sender: Some(sender.sender),
2274        }
2275    }
2276
2277    /// Build a direct column sender from a QWP/WebSocket config string,
2278    /// opening its own connection and owning it outright — no pool.
2279    pub fn from_conf(conf: &str) -> Result<Self> {
2280        Self::from_builder(&SenderBuilder::from_conf(conf)?)
2281    }
2282
2283    /// Build a direct column sender from an already-configured
2284    /// [`SenderBuilder`] (which carries auth/TLS applied programmatically,
2285    /// not just what a config string encodes), owning its own connection
2286    /// with no pool. The builder is only borrowed.
2287    pub fn from_builder(builder: &SenderBuilder) -> Result<Self> {
2288        let connector = builder.build_qwp_ws_connector()?;
2289        let health = Mutex::new(QwpWsHostHealthTracker::new(connector.endpoint_count()));
2290        let raw = connector.connect_round_pooled(&health, None)?;
2291        let conn = ColumnConn::from_round_stream(raw)?;
2292        let sender = DirectSenderCore::new(
2293            conn,
2294            crate::ingress::SymbolGlobalDict::new(),
2295            crate::ingress::column_sender::encoder::EncodeScratch::new(),
2296            false,
2297        );
2298        Ok(Self {
2299            backing: DirectBacking::Standalone {
2300                request_durable_ack: connector.request_durable_ack(),
2301            },
2302            sender: Some(sender),
2303        })
2304    }
2305
2306    pub fn get_mut(&mut self) -> &mut DirectSenderCore {
2307        self.sender
2308            .as_mut()
2309            .expect("OwnedDirectColumnSender already released")
2310    }
2311
2312    pub fn get(&self) -> &DirectSenderCore {
2313        self.sender
2314            .as_ref()
2315            .expect("OwnedDirectColumnSender already released")
2316    }
2317
2318    pub fn pool_closed(&self) -> bool {
2319        match &self.backing {
2320            DirectBacking::Pool(inner) => inner.shutdown.load(Ordering::SeqCst),
2321            DirectBacking::Standalone { .. } => false,
2322        }
2323    }
2324
2325    pub fn mark_must_close(&mut self) {
2326        self.get_mut().mark_must_close();
2327    }
2328
2329    pub fn must_close(&self) -> bool {
2330        self.pool_closed() || self.get().must_close()
2331    }
2332}
2333
2334#[cfg(feature = "ffi-support")]
2335impl Drop for OwnedDirectColumnSender {
2336    fn drop(&mut self) {
2337        let Some(mut sender) = self.sender.take() else {
2338            return;
2339        };
2340        match &self.backing {
2341            DirectBacking::Pool(inner) => {
2342                commit_in_flight_on_drop(inner.connector.request_durable_ack(), &mut sender);
2343                return_direct_to_pool(inner, sender);
2344            }
2345            DirectBacking::Standalone {
2346                request_durable_ack,
2347            } => {
2348                commit_in_flight_on_drop(*request_durable_ack, &mut sender);
2349            }
2350        }
2351    }
2352}
2353
2354/// A query [`Reader`] borrowed from a [`QuestDb`] pool.
2355///
2356/// Egress companion to [`BorrowedSender`]. Derefs to `Reader`, so the usual
2357/// `prepare` / `execute` cursor flow works unchanged. On `Drop` the reader
2358/// is returned to the reader pool, unless its transport has been torn down
2359/// (or [`Self::drop_on_return`] was called), in which case it is dropped
2360/// and the next borrow opens a fresh one.
2361///
2362/// `BorrowedReader` is **not** `Send` or `Sync`: the borrowed connection
2363/// belongs to the borrowing thread for the duration of the borrow.
2364#[cfg(feature = "_egress")]
2365pub struct BorrowedReader<'a> {
2366    db: &'a QuestDb,
2367    reader: Option<Reader>,
2368    must_close: bool,
2369    /// !Send / !Sync marker, mirroring [`BorrowedSender`].
2370    _not_send: PhantomData<Rc<()>>,
2371}
2372
2373#[cfg(feature = "_egress")]
2374impl<'a> BorrowedReader<'a> {
2375    fn new(db: &'a QuestDb, reader: Reader) -> Self {
2376        Self {
2377            db,
2378            reader: Some(reader),
2379            must_close: false,
2380            _not_send: PhantomData,
2381        }
2382    }
2383
2384    /// Force this borrowed reader to be dropped (not recycled) when the borrow
2385    /// ends.
2386    ///
2387    /// Use normal `Drop` for healthy readers: the return path already retires
2388    /// readers whose transport was torn down, or whose pool has been closed.
2389    /// Call this after abandoning work or handling an error where the next
2390    /// borrower must not inherit this connection.
2391    pub fn drop_on_return(&mut self) {
2392        self.must_close = true;
2393    }
2394}
2395
2396#[cfg(feature = "_egress")]
2397impl Debug for BorrowedReader<'_> {
2398    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
2399        // `Reader` is not `Debug`; surface only the handle state.
2400        f.debug_struct("BorrowedReader")
2401            .field("borrowed", &self.reader.is_some())
2402            .field("must_close", &self.must_close)
2403            .finish()
2404    }
2405}
2406
2407#[cfg(feature = "_egress")]
2408impl Deref for BorrowedReader<'_> {
2409    type Target = Reader;
2410
2411    fn deref(&self) -> &Self::Target {
2412        self.reader
2413            .as_ref()
2414            .expect("borrowed reader already returned")
2415    }
2416}
2417
2418#[cfg(feature = "_egress")]
2419impl DerefMut for BorrowedReader<'_> {
2420    fn deref_mut(&mut self) -> &mut Self::Target {
2421        self.reader
2422            .as_mut()
2423            .expect("borrowed reader already returned")
2424    }
2425}
2426
2427#[cfg(feature = "_egress")]
2428impl Drop for BorrowedReader<'_> {
2429    fn drop(&mut self) {
2430        if let Some(reader) = self.reader.take() {
2431            return_reader_to_pool(&self.db.inner, reader, self.must_close);
2432        }
2433    }
2434}
2435
2436/// Owned (lifetime-free) variant of a borrowed reader used by the C FFI.
2437///
2438/// Holds an `Arc<DbInner>` for the same reason [`OwnedSender`] does: the
2439/// C ABI can free its `questdb_db*` pointer before dropping outstanding
2440/// reader handles. After pool close, returned readers are dropped instead of
2441/// recycled.
2442///
2443/// `must_close` short-circuits the return path: when set, the reader is
2444/// dropped instead of being returned to the pool. Pool shutdown has the same
2445/// effect. The egress-side
2446/// cursor lifecycle uses this to force-close readers whose underlying
2447/// transport has been torn down by a mid-stream cursor drop.
2448#[cfg(all(feature = "_egress", feature = "ffi-support"))]
2449pub struct OwnedReader {
2450    inner: Arc<DbInner>,
2451    reader: Option<Reader>,
2452    must_close: bool,
2453}
2454
2455#[cfg(all(feature = "_egress", feature = "ffi-support"))]
2456impl OwnedReader {
2457    /// Inspect the wrapped reader without taking ownership.
2458    pub fn get(&self) -> &Reader {
2459        self.reader
2460            .as_ref()
2461            .expect("OwnedReader already returned to the pool")
2462    }
2463
2464    /// Borrow the underlying reader mutably.
2465    pub fn get_mut(&mut self) -> &mut Reader {
2466        self.reader
2467            .as_mut()
2468            .expect("OwnedReader already returned to the pool")
2469    }
2470
2471    /// Mark this reader for must-close: it will be dropped on Drop
2472    /// instead of returned to the pool.
2473    pub fn mark_must_close(&mut self) {
2474        self.must_close = true;
2475    }
2476
2477    /// Take the inner reader, leaving the wrapper inert. Used by the
2478    /// FFI to expose the raw `Reader` to other call sites that don't
2479    /// know about the pool (e.g. monitoring stat getters).
2480    ///
2481    /// After this call, `Drop` no longer decrements the pool's
2482    /// `in_use` counter — the caller has assumed responsibility for
2483    /// either dropping the returned `Reader` into oblivion (e.g.
2484    /// `qwp_reader_close`'s leak-on-active branch) or routing it
2485    /// back to the pool via [`ReaderPoolHandle::return_reader`].
2486    /// Forgetting both permanently burns one pool slot.
2487    pub fn take(mut self) -> Option<Reader> {
2488        self.reader.take()
2489    }
2490}
2491
2492#[cfg(all(feature = "_egress", feature = "ffi-support"))]
2493impl Drop for OwnedReader {
2494    fn drop(&mut self) {
2495        if let Some(reader) = self.reader.take() {
2496            return_reader_to_pool(&self.inner, reader, self.must_close);
2497        }
2498    }
2499}
2500
2501/// Opaque handle to a [`QuestDb`] pool, used by the FFI's
2502/// `reader` wrapper to return readers without exposing
2503/// `DbInner`. Cheap to clone (just bumps the inner `Arc`).
2504#[cfg(all(feature = "_egress", feature = "ffi-support"))]
2505#[derive(Clone)]
2506pub struct ReaderPoolHandle {
2507    inner: Arc<DbInner>,
2508}
2509
2510#[cfg(all(feature = "_egress", feature = "ffi-support"))]
2511impl ReaderPoolHandle {
2512    /// Return a [`Reader`] to the pool it came from. If `must_close`
2513    /// is set the reader is dropped instead of recycled — matching
2514    /// the [`OwnedReader::mark_must_close`] semantics.
2515    pub fn return_reader(&self, reader: Reader, must_close: bool) {
2516        return_reader_to_pool(&self.inner, reader, must_close);
2517    }
2518
2519    /// `true` after the originating pool has been closed.
2520    pub fn pool_closed(&self) -> bool {
2521        self.inner.shutdown.load(Ordering::SeqCst)
2522    }
2523
2524    /// Release the `in_use` slot that was reserved when this reader
2525    /// was borrowed, without returning the `Reader` itself. Used by
2526    /// the FFI leak-on-active path: when a `qwp_reader_close` arrives
2527    /// with a cursor still live, the underlying `Reader` cannot be
2528    /// extracted (UnsafeCell aliasing with the in-flight `&mut Reader`),
2529    /// so it leaks — but the pool's borrow accounting must still drop
2530    /// the slot or a `query_pool_max` slot is permanently burned.
2531    pub fn release_leaked_slot(&self) {
2532        let mut state = lock_reader_state(&self.inner.reader_state);
2533        state.in_use = state.in_use.saturating_sub(1);
2534    }
2535}
2536
2537#[cfg(feature = "_egress")]
2538fn return_reader_to_pool(inner: &Arc<DbInner>, reader: Reader, must_close: bool) {
2539    let must_close = must_close || reader.transport_torn_down();
2540    let mut state = lock_reader_state(&inner.reader_state);
2541    state.in_use = state.in_use.saturating_sub(1);
2542    if !must_close && !inner.shutdown.load(Ordering::SeqCst) {
2543        state.free.push(ReaderPoolEntry {
2544            reader,
2545            last_idle_at: Instant::now(),
2546        });
2547    }
2548    drop(state);
2549    inner.reader_cv.notify_all();
2550}
2551
2552/// Pop a free connection or open a fresh one within `sender_pool_max`; at cap,
2553/// wait up to `acquire_timeout_ms` for a return. Reserves the pool slot under
2554/// one lock so a concurrent return can't race past the cap.
2555/// Recyclability hooks shared by the store-and-forward and direct sender
2556/// pools so `pick_sender_inner` can retire free-list entries that latched
2557/// terminal state while parked instead of lending them out.
2558trait PoolableSender {
2559    fn is_stale(&self) -> bool;
2560    fn drain_for_retire(&mut self, inner: &DbInner);
2561}
2562
2563impl PoolableSender for PooledSenderCore {
2564    fn is_stale(&self) -> bool {
2565        self.must_close()
2566    }
2567
2568    fn drain_for_retire(&mut self, inner: &DbInner) {
2569        drain_sfa_before_drop(inner, self);
2570    }
2571}
2572
2573impl PoolableSender for DirectSenderCore {
2574    fn is_stale(&self) -> bool {
2575        self.must_close()
2576    }
2577
2578    fn drain_for_retire(&mut self, _inner: &DbInner) {}
2579}
2580
2581fn retire_stale_entry<S: PoolableSender>(inner: &Arc<DbInner>, entry: PoolEntry<S>) {
2582    let _release = entry.slot_index.is_some().then_some(SenderSlotRelease {
2583        inner: inner.as_ref(),
2584        slot_index: entry.slot_index,
2585        decrement_in_use: false,
2586        decrement_closing: true,
2587    });
2588    let mut sender = entry.sender;
2589    sender.drain_for_retire(inner);
2590    drop(sender);
2591}
2592
2593fn pick_sender_inner<S: PoolableSender>(
2594    inner: &Arc<DbInner>,
2595    pool: &Mutex<PoolState<S>>,
2596    cv: &Condvar,
2597    sfa: bool,
2598    connect: impl FnOnce(Option<usize>) -> Result<S>,
2599) -> Result<PooledSender<S>> {
2600    let slot = {
2601        let mut state = lock_state(pool);
2602        let mut close_wait_deadline = None;
2603        let mut acquire_deadline = None;
2604        loop {
2605            if inner.shutdown.load(Ordering::SeqCst) {
2606                return Err(error::fmt!(
2607                    InvalidApiCall,
2608                    "QuestDb pool is closed; cannot borrow sender"
2609                ));
2610            }
2611            if let Some(entry) = state.free.pop() {
2612                if entry.sender.is_stale() {
2613                    if entry.slot_index.is_some() {
2614                        state.closing += 1;
2615                    }
2616                    drop(state);
2617                    retire_stale_entry(inner, entry);
2618                    state = lock_state(pool);
2619                    continue;
2620                }
2621                state.in_use += 1;
2622                drop(state);
2623                return Ok(PooledSender {
2624                    sender: entry.sender,
2625                    slot_index: entry.slot_index,
2626                });
2627            }
2628            if state.reserved_total() < inner.sender_pool_max {
2629                break;
2630            }
2631            let wait_timeout = inner.connector.close_flush_timeout();
2632            if sfa
2633                && inner.sf_disk
2634                && state.closing > 0
2635                && let Some(wait_for) = remaining_wait(&mut close_wait_deadline, wait_timeout)
2636            {
2637                let (next_state, _) = match cv.wait_timeout(state, wait_for) {
2638                    Ok((guard, result)) => (guard, result),
2639                    Err(poisoned) => poisoned.into_inner(),
2640                };
2641                state = next_state;
2642                continue;
2643            }
2644            if let Some(wait_for) = remaining_wait(&mut acquire_deadline, inner.acquire_timeout) {
2645                let (next_state, _) = match cv.wait_timeout(state, wait_for) {
2646                    Ok((guard, result)) => (guard, result),
2647                    Err(poisoned) => poisoned.into_inner(),
2648                };
2649                state = next_state;
2650                continue;
2651            }
2652            return Err(error::fmt!(
2653                InvalidApiCall,
2654                "Connection pool exhausted: {} sender(s) in use at the \
2655                 sender_pool_max cap of {} after waiting acquire_timeout_ms={}. \
2656                 Drop a borrowed sender, or raise sender_pool_max / \
2657                 acquire_timeout_ms.",
2658                state.in_use,
2659                inner.sender_pool_max,
2660                inner.acquire_timeout.as_millis()
2661            ));
2662        }
2663        let slot_index = state.allocate_slot_index();
2664        debug_assert_eq!(slot_index.is_some(), sfa && inner.sf_disk);
2665        state.in_use += 1;
2666        InUseSlot {
2667            state: pool,
2668            cv,
2669            slot_index,
2670            armed: true,
2671        }
2672    };
2673    let sender = connect(slot.slot_index)?;
2674    let slot_index = slot.slot_index;
2675    slot.commit();
2676    Ok(PooledSender { sender, slot_index })
2677}
2678
2679fn pick_sfa_sender(inner: &Arc<DbInner>) -> Result<PooledSender<PooledSenderCore>> {
2680    let mut picked = pick_sender_inner(inner, &inner.state, &inner.cv, true, |slot_index| {
2681        connect_sfa_pool(inner, slot_index)
2682    })?;
2683    picked.sender.rebase_lease_observation();
2684    Ok(picked)
2685}
2686
2687fn pick_direct_sender(inner: &Arc<DbInner>) -> Result<PooledSender<DirectSenderCore>> {
2688    pick_sender_inner(
2689        inner,
2690        &inner.direct_state,
2691        &inner.direct_cv,
2692        false,
2693        |_slot_index| {
2694            let conn = connect_conn_pool(inner)?;
2695            Ok(DirectSenderCore::new(
2696                conn,
2697                crate::ingress::SymbolGlobalDict::new(),
2698                crate::ingress::column_sender::encoder::EncodeScratch::new(),
2699                false,
2700            ))
2701        },
2702    )
2703}
2704
2705/// Java-parity eager startup: open ingest senders until the pool holds
2706/// `sender_pool_min` and readers until it holds `query_pool_min`, honoring
2707/// an explicitly set `initial_connect_retry`. Runs BEFORE recovery pre-open,
2708/// so every warm sender performs a real foreground connect — adopting dirty
2709/// disk slots (and replaying them) along the way via the borrow path's
2710/// recovery candidates. All warm borrows are held at once so each opens a
2711/// distinct connection, then returned to the free lists; on the first
2712/// failure the already-opened connections are returned and the error
2713/// propagates to `connect()`.
2714fn prewarm_min_connections(db: &QuestDb) -> Result<()> {
2715    let inner = &db.inner;
2716    let mut warm = Vec::new();
2717    let mut outcome: Result<()> = Ok(());
2718    for _ in 0..inner.sender_pool_min {
2719        match pick_sfa_sender(inner) {
2720            Ok(sender) => warm.push(sender),
2721            Err(err) => {
2722                outcome = Err(err);
2723                break;
2724            }
2725        }
2726    }
2727    for picked in warm {
2728        return_sfa_to_pool(inner, picked.sender, picked.slot_index);
2729    }
2730    outcome?;
2731    #[cfg(feature = "_egress")]
2732    {
2733        let mut warm = Vec::new();
2734        let mut outcome: Result<()> = Ok(());
2735        for _ in 0..inner.query_pool_min {
2736            match db.pick_reader() {
2737                Ok(reader) => warm.push(reader),
2738                Err(err) => {
2739                    outcome = Err(err);
2740                    break;
2741                }
2742            }
2743        }
2744        for reader in warm {
2745            return_reader_to_pool(inner, reader, false);
2746        }
2747        outcome?;
2748    }
2749    Ok(())
2750}
2751
2752fn connect_sfa_pool(inner: &Arc<DbInner>, slot_index: Option<usize>) -> Result<PooledSenderCore> {
2753    // The connector already carries the pool's resolved initial-connect mode.
2754    connect_sfa_pool_with_recovery_candidates(
2755        inner,
2756        slot_index,
2757        &inner.out_of_range_recovery_candidates,
2758        false,
2759    )
2760}
2761
2762fn connect_sfa_pool_with_recovery_candidates(
2763    inner: &Arc<DbInner>,
2764    slot_index: Option<usize>,
2765    recovery_candidates: &[PathBuf],
2766    force_async_initial_connect: bool,
2767) -> Result<PooledSenderCore> {
2768    let sender_id = slot_index.map(|index| managed_slot_id(&inner.slot_base_id, index));
2769    let state = inner
2770        .connector
2771        .connect_sfa_background_with_pool_slot(
2772            sender_id.as_deref(),
2773            inner.managed_slot_exclusion.as_slice(),
2774            recovery_candidates,
2775            Arc::clone(&inner.conn_events),
2776            Arc::clone(&inner.rejections),
2777            force_async_initial_connect,
2778        )
2779        .map_err(|err| {
2780            crate::Error::new(
2781                err.code(),
2782                format!("Failed to open store-and-forward sender: {}", err.msg()),
2783            )
2784        })?;
2785    PooledSenderCore::new_store_and_forward(
2786        state,
2787        inner.connector.max_buf_size(),
2788        inner.connector.request_durable_ack(),
2789        inner.connector.request_timeout(),
2790    )
2791}
2792
2793/// Re-acquire a live connection within `deadline`, retrying with the pool's
2794/// reconnect backoff: a failed pick (every endpoint role-rejecting while the
2795/// cluster elects a primary, or a transient transport error) backs off and
2796/// retries; `AuthError` / `ProtocolVersionError` and deadline exhaustion are
2797/// terminal.
2798#[cfg(feature = "ffi-support")]
2799fn reconnect_pick<S>(
2800    inner: &Arc<DbInner>,
2801    deadline: Option<Instant>,
2802    mut pick: impl FnMut(&Arc<DbInner>) -> Result<PooledSender<S>>,
2803) -> Result<PooledSender<S>> {
2804    let policy = inner.connector.reconnect_policy();
2805    let mut backoff = policy.initial_backoff();
2806    loop {
2807        match pick(inner) {
2808            Ok(cs) => return Ok(cs),
2809            Err(e) if reconnect_error_is_terminal(&e) || reconnect_deadline_expired(deadline) => {
2810                return Err(e);
2811            }
2812            Err(e) => {
2813                let (sleep_for, next) = reconnect_backoff_step(
2814                    &e,
2815                    policy.initial_backoff(),
2816                    policy.max_backoff(),
2817                    backoff,
2818                );
2819                sleep_until_deadline(sleep_for, deadline);
2820                backoff = next;
2821            }
2822        }
2823    }
2824}
2825
2826#[cfg(any(
2827    feature = "polars-ingress",
2828    feature = "polars-egress",
2829    feature = "ffi-support"
2830))]
2831pub(crate) fn reconnect_deadline_expired(deadline: Option<Instant>) -> bool {
2832    deadline.is_some_and(|d| Instant::now() >= d)
2833}
2834
2835fn remaining_wait(deadline: &mut Option<Instant>, timeout: Duration) -> Option<Duration> {
2836    if timeout.is_zero() {
2837        return None;
2838    }
2839    let now = Instant::now();
2840    let deadline = deadline.get_or_insert_with(|| now.checked_add(timeout).unwrap_or(now));
2841    let remaining = deadline.saturating_duration_since(now);
2842    if remaining.is_zero() {
2843        None
2844    } else {
2845        Some(remaining)
2846    }
2847}
2848
2849#[cfg(any(
2850    feature = "polars-ingress",
2851    feature = "polars-egress",
2852    feature = "ffi-support"
2853))]
2854fn sleep_until_deadline(sleep_for: Duration, deadline: Option<Instant>) {
2855    let d = match deadline {
2856        Some(dl) => sleep_for.min(dl.saturating_duration_since(Instant::now())),
2857        None => sleep_for,
2858    };
2859    if !d.is_zero() {
2860        thread::sleep(d);
2861    }
2862}
2863
2864/// Open one direct connection through the live pool's `connector`. The shared
2865/// health tracker is locked only per tracker operation (pick/claim/record),
2866/// never across the
2867/// blocking TCP+TLS+WS-upgrade handshake — so concurrent cold-start borrows do
2868/// not serialize end-to-end, and dead-sender returns that also grab
2869/// `inner.health` (via [`record_sender_transport_failure`]) are not stalled
2870/// behind one slow / black-holed connect.
2871fn connect_conn_pool(inner: &Arc<DbInner>) -> Result<ColumnConn> {
2872    let raw: RawQwpWsRoundStream = inner
2873        .connector
2874        .connect_round_pooled(&inner.health, Some(inner.conn_events.as_ref()))?;
2875    ColumnConn::from_round_stream(raw)
2876}
2877
2878/// Best-effort commit of un-sync'd deferred frames on drop, so the natural
2879/// `flush()`-loop-then-drop path doesn't silently lose data. Commits at the
2880/// pool's default ack level so a `request_durable_ack=on` pool still waits for
2881/// the durability ACK instead of silently downgrading to `Ok`. On failure the
2882/// connection is latched `must_close` so the next borrower can't commit these
2883/// frames under a foreign table.
2884///
2885/// Gated on [`can_drain_in_flight`](DirectSenderCore::can_drain_in_flight), not
2886/// `!must_close()`: a connection retired for a **full symbol dictionary**
2887/// (`SymbolDictFull`) is `spent` but its transport is healthy, so its deferred
2888/// tail — frames the caller already flushed, referencing already-interned
2889/// symbols — is committed here rather than discarded. A symbol-less commit
2890/// interns nothing, so the full dictionary does not block it. Only a hard latch
2891/// (transport death, or a prior failed commit) skips the attempt.
2892fn commit_in_flight_on_drop(request_durable_ack: bool, sender: &mut DirectSenderCore) {
2893    if sender.in_flight() == 0 {
2894        return;
2895    }
2896    let ack = if request_durable_ack {
2897        AckLevel::Durable
2898    } else {
2899        AckLevel::Ok
2900    };
2901    let committed = sender.can_drain_in_flight() && sender.sync(ack).is_ok();
2902    if !committed {
2903        log::warn!(
2904            "direct sender dropped with un-sync'd deferred frame(s) that could \
2905             not be committed; their data is discarded. Call sync() (or \
2906             flush_and_wait() on the final chunk) before the handle is dropped."
2907        );
2908        sender.mark_must_close();
2909    }
2910}
2911
2912/// Best-effort delivery of a store-and-forward connection's queued frames just
2913/// before it is dropped (not recycled) — on pool shutdown or a `must_close`
2914/// return. While a connection is parked in the free list its background runner
2915/// keeps delivering, but dropping it stops the runner, so we give the queue a
2916/// bounded window (the configured `close_flush_timeout`) to finish. On timeout
2917/// or a terminal transport the undelivered frames are discarded with a warning,
2918/// mirroring [`commit_in_flight_on_drop`] for the direct backend.
2919fn drain_sfa_before_drop(inner: &DbInner, sender: &mut PooledSenderCore) {
2920    let timeout = inner.connector.close_flush_timeout();
2921    if timeout.is_zero() {
2922        return;
2923    }
2924    let durable = inner.connector.request_durable_ack();
2925    if sender.sfa_fully_delivered(durable) {
2926        return;
2927    }
2928    sender.begin_close();
2929    if let Err(err) = sender.drain_to_deadline(Instant::now().checked_add(timeout)) {
2930        log::warn!(
2931            "store-and-forward sender dropped with frame(s) that could \
2932             not be delivered within close_flush_timeout; their data is \
2933             discarded. Call wait() before closing the pool, or set sf_dir for \
2934             crash-durable persistence. Cause: {err}"
2935        );
2936    }
2937}
2938
2939/// Batched [`drain_sfa_before_drop`] for the connections retired together when
2940/// the pool is closed. Every runner is signalled first (non-blocking) so their
2941/// deliveries overlap, then each is awaited under a *single* shared deadline —
2942/// so total close time is roughly one `close_flush_timeout` no matter how many
2943/// connections are draining, instead of the sum.
2944fn drain_sfa_senders_bounded(inner: &DbInner, senders: &mut [PooledSenderCore]) {
2945    let timeout = inner.connector.close_flush_timeout();
2946    if timeout.is_zero() || senders.is_empty() {
2947        return;
2948    }
2949    let durable = inner.connector.request_durable_ack();
2950    for sender in senders.iter() {
2951        sender.begin_close();
2952    }
2953    let deadline = Instant::now().checked_add(timeout);
2954    for sender in senders.iter_mut() {
2955        if sender.sfa_fully_delivered(durable) {
2956            continue;
2957        }
2958        if let Err(err) = sender.drain_to_deadline(deadline) {
2959            log::warn!(
2960                "store-and-forward sender dropped on pool close with \
2961                 frame(s) that could not be delivered within close_flush_timeout; \
2962                 their data is discarded. Call wait() before close, or set \
2963                 sf_dir for crash-durable persistence. Cause: {err}"
2964            );
2965        }
2966    }
2967}
2968
2969fn return_sfa_to_pool(
2970    inner: &Arc<DbInner>,
2971    mut sender: PooledSenderCore,
2972    slot_index: Option<usize>,
2973) {
2974    let must_close = sender.must_close();
2975    let release_slot;
2976    {
2977        let mut state = lock_state(&inner.state);
2978        if !must_close && !inner.shutdown.load(Ordering::SeqCst) {
2979            state.in_use = state.in_use.saturating_sub(1);
2980            state.free.push(PoolEntry {
2981                sender,
2982                slot_index,
2983                last_idle_at: Instant::now(),
2984            });
2985            inner.cv.notify_all();
2986            return;
2987        }
2988        release_slot = slot_index.is_some();
2989        if release_slot {
2990            state.closing += 1;
2991        } else {
2992            state.in_use = state.in_use.saturating_sub(1);
2993        }
2994    }
2995    let _release = release_slot.then_some(SenderSlotRelease {
2996        inner: inner.as_ref(),
2997        slot_index,
2998        decrement_in_use: true,
2999        decrement_closing: true,
3000    });
3001    // Not recycling: this connection and its background runner are about to be
3002    // dropped, so drain its queue first (bounded, outside the pool lock).
3003    drain_sfa_before_drop(inner, &mut sender);
3004    drop(sender);
3005}
3006
3007fn return_direct_to_pool(inner: &Arc<DbInner>, sender: DirectSenderCore) {
3008    let must_close = sender.must_close();
3009    record_sender_transport_failure(inner, &sender);
3010    {
3011        let mut state = lock_state(&inner.direct_state);
3012        state.in_use = state.in_use.saturating_sub(1);
3013        if !must_close && !inner.shutdown.load(Ordering::SeqCst) {
3014            state.free.push(PoolEntry {
3015                sender,
3016                slot_index: None,
3017                last_idle_at: Instant::now(),
3018            });
3019        }
3020    }
3021    inner.direct_cv.notify_all();
3022}
3023
3024fn finish_replaced_sender(inner: &Arc<DbInner>, sender: DirectSenderCore) {
3025    let must_close = sender.must_close();
3026    record_sender_transport_failure(inner, &sender);
3027    {
3028        let mut state = lock_state(&inner.direct_state);
3029        if !must_close
3030            && !inner.shutdown.load(Ordering::SeqCst)
3031            && state.total() < inner.sender_pool_max
3032        {
3033            state.free.push(PoolEntry {
3034                sender,
3035                slot_index: None,
3036                last_idle_at: Instant::now(),
3037            });
3038        }
3039    }
3040    inner.direct_cv.notify_all();
3041}
3042
3043fn record_sender_transport_failure(inner: &Arc<DbInner>, sender: &DirectSenderCore) {
3044    if sender.transport_dead() {
3045        let idx = sender.endpoint_idx();
3046        lock_health(&inner.health)
3047            .record_mid_stream_failure(idx, Some(ReconnectReason::RetryableFailure));
3048        if let Some(endpoint) = inner.connector.endpoint(idx) {
3049            inner
3050                .conn_events
3051                .disconnected(&endpoint.host, &endpoint.port);
3052        }
3053    }
3054}
3055
3056fn spawn_reaper(inner: Arc<DbInner>) -> std::io::Result<JoinHandle<()>> {
3057    let tick = reaper_tick(inner.idle_timeout);
3058    thread::Builder::new()
3059        .name("questdb-ingress-pool-reaper".to_string())
3060        .spawn(move || reaper_loop(inner, tick))
3061}
3062
3063fn reaper_tick(idle_timeout: Duration) -> Duration {
3064    let twelfth = idle_timeout / 12;
3065    if twelfth > REAPER_MIN_TICK {
3066        twelfth
3067    } else {
3068        REAPER_MIN_TICK
3069    }
3070}
3071
3072fn reaper_loop(inner: Arc<DbInner>, tick: Duration) {
3073    loop {
3074        // Check shutdown WHILE holding the lock so a concurrent Drop's
3075        // notify-under-lock is never lost: Drop sets `shutdown` then
3076        // acquires the same lock to notify, so either we observe
3077        // `shutdown=true` before sleeping or we are sleeping when the
3078        // notify arrives.
3079        let state = lock_state(&inner.state);
3080        if inner.shutdown.load(Ordering::SeqCst) {
3081            break;
3082        }
3083        let (state, _) = inner
3084            .cv
3085            .wait_timeout(state, tick)
3086            .unwrap_or_else(|e| e.into_inner());
3087        if inner.shutdown.load(Ordering::SeqCst) {
3088            break;
3089        }
3090        drop(state);
3091        reap_idle_inner(&inner);
3092    }
3093}
3094
3095fn reap_idle_inner(inner: &DbInner) -> usize {
3096    let mut dropped = reap_idle_senders(inner);
3097    dropped += reap_idle_direct_senders(inner);
3098    #[cfg(feature = "_egress")]
3099    {
3100        dropped += reap_idle_readers(inner);
3101    }
3102    dropped
3103}
3104
3105fn drain_idle_inner(inner: &DbInner) -> usize {
3106    let mut dropped = drain_idle_senders(inner);
3107    dropped += drain_idle_direct_senders(inner);
3108    #[cfg(feature = "_egress")]
3109    {
3110        dropped += drain_idle_readers(inner);
3111    }
3112    dropped
3113}
3114
3115fn drain_idle_senders(inner: &DbInner) -> usize {
3116    let mut to_drop: Vec<PooledSenderCore> = {
3117        let mut state = lock_state(&inner.state);
3118        state.free.drain(..).map(|entry| entry.sender).collect()
3119    };
3120    let dropped = to_drop.len();
3121    // The Main pool is store-and-forward: deliver each connection's queued
3122    // frames (bounded by close_flush_timeout, shared across all of them) before
3123    // the runners are stopped on drop. Done outside the pool lock.
3124    drain_sfa_senders_bounded(inner, &mut to_drop);
3125    drop(to_drop);
3126    dropped
3127}
3128
3129fn drain_idle_direct_senders(inner: &DbInner) -> usize {
3130    let to_drop: Vec<DirectSenderCore> = {
3131        let mut state = lock_state(&inner.direct_state);
3132        state.free.drain(..).map(|entry| entry.sender).collect()
3133    };
3134    let dropped = to_drop.len();
3135    drop(to_drop);
3136    dropped
3137}
3138
3139#[cfg(feature = "_egress")]
3140fn drain_idle_readers(inner: &DbInner) -> usize {
3141    let to_drop: Vec<Reader> = {
3142        let mut state = lock_reader_state(&inner.reader_state);
3143        state.free.drain(..).map(|entry| entry.reader).collect()
3144    };
3145    let dropped = to_drop.len();
3146    drop(to_drop);
3147    dropped
3148}
3149
3150fn reap_idle_senders(inner: &DbInner) -> usize {
3151    let durable = inner.connector.request_durable_ack();
3152    let mut dropped = 0;
3153    while let Some((sender, slot_index)) = take_reapable_column_sender(inner, durable) {
3154        let _release = slot_index.is_some().then_some(SenderSlotRelease {
3155            inner,
3156            slot_index,
3157            decrement_in_use: false,
3158            decrement_closing: true,
3159        });
3160        drop(sender);
3161        dropped += 1;
3162    }
3163    dropped
3164}
3165
3166fn take_reapable_column_sender(
3167    inner: &DbInner,
3168    durable: bool,
3169) -> Option<(PooledSenderCore, Option<usize>)> {
3170    let mut state = lock_state(&inner.state);
3171    let now = Instant::now();
3172    // Free-list is oldest at front, newest at back (push on return /
3173    // pop on borrow). We must protect `total() >= sender_pool_min` after the
3174    // drop, so we only remove an entry if total stays above the floor.
3175    let mut i = 0;
3176    while i < state.free.len() {
3177        if state.total() <= inner.sender_pool_min {
3178            return None;
3179        }
3180        let idle_for = now.saturating_duration_since(state.free[i].last_idle_at);
3181        // Never evict a connection whose store-and-forward queue still holds
3182        // undelivered frames: its background runner is still delivering, and
3183        // dropping it now would lose that data. It becomes reapable once the
3184        // runner drains it (or the transport goes terminal). `sfa_fully_delivered`
3185        // is a lock-free progress read in the healthy case.
3186        if idle_for > inner.idle_timeout && state.free[i].sender.sfa_fully_delivered(durable) {
3187            let entry = state.free.remove(i);
3188            if entry.slot_index.is_some() {
3189                state.closing += 1;
3190            }
3191            return Some((entry.sender, entry.slot_index));
3192        }
3193        i += 1;
3194    }
3195    None
3196}
3197
3198fn reap_idle_direct_senders(inner: &DbInner) -> usize {
3199    // Direct pool is lazy-init (no pre-population at connect), so there is no
3200    // warm-min floor to preserve — reap any sender parked longer than the idle
3201    // timeout. The direct pool has no configured warm minimum.
3202    let to_drop: Vec<DirectSenderCore> = {
3203        let mut state = lock_state(&inner.direct_state);
3204        let mut to_drop = Vec::new();
3205        let now = Instant::now();
3206        let mut i = 0;
3207        while i < state.free.len() {
3208            let idle_for = now.saturating_duration_since(state.free[i].last_idle_at);
3209            if idle_for > inner.idle_timeout {
3210                let entry = state.free.remove(i);
3211                to_drop.push(entry.sender);
3212            } else {
3213                i += 1;
3214            }
3215        }
3216        to_drop
3217    };
3218    let dropped = to_drop.len();
3219    drop(to_drop);
3220    dropped
3221}
3222
3223#[cfg(feature = "_egress")]
3224fn reap_idle_readers(inner: &DbInner) -> usize {
3225    // `query_pool_min` readers are pre-opened at connect by default
3226    // (none under lazy_connect, where the pool fills on first borrow);
3227    // either way `query_pool_min` is the reaper's floor here.
3228    let to_drop: Vec<Reader> = {
3229        let mut state = lock_reader_state(&inner.reader_state);
3230        let mut to_drop = Vec::new();
3231        let now = Instant::now();
3232        let mut i = 0;
3233        while i < state.free.len() {
3234            if state.total() <= inner.query_pool_min {
3235                break;
3236            }
3237            let idle_for = now.saturating_duration_since(state.free[i].last_idle_at);
3238            if idle_for > inner.idle_timeout {
3239                let entry = state.free.remove(i);
3240                to_drop.push(entry.reader);
3241            } else {
3242                i += 1;
3243            }
3244        }
3245        to_drop
3246    };
3247    let dropped = to_drop.len();
3248    drop(to_drop);
3249    dropped
3250}
3251
3252const _: fn() = || {
3253    fn assert_send_sync<T: Send + Sync>() {}
3254    assert_send_sync::<QuestDb>();
3255    #[cfg(feature = "ffi-support")]
3256    {
3257        fn assert_send<T: Send>() {}
3258        assert_send::<OwnedSender>();
3259        assert_send::<OwnedDirectColumnSender>();
3260    }
3261};
3262
3263const _: fn() = || {
3264    trait AmbiguousIfSend<A> {
3265        fn _disambiguate() {}
3266    }
3267    impl<T: ?Sized> AmbiguousIfSend<()> for T {}
3268    impl<T: ?Sized + Send> AmbiguousIfSend<u8> for T {}
3269    fn assert_not_send<T: ?Sized>() {
3270        let _: fn() = <T as AmbiguousIfSend<_>>::_disambiguate;
3271    }
3272    assert_not_send::<BorrowedSender<'_>>();
3273    assert_not_send::<BorrowedDirectColumnSender<'_>>();
3274    #[cfg(feature = "_egress")]
3275    assert_not_send::<BorrowedReader<'_>>();
3276    assert_not_send::<crate::ingress::column_sender::Chunk<'_>>();
3277};
3278
3279const _: fn() = || {
3280    trait AmbiguousIfSync<A> {
3281        fn _disambiguate() {}
3282    }
3283    impl<T: ?Sized> AmbiguousIfSync<()> for T {}
3284    impl<T: ?Sized + Sync> AmbiguousIfSync<u8> for T {}
3285    fn assert_not_sync<T: ?Sized>() {
3286        let _: fn() = <T as AmbiguousIfSync<_>>::_disambiguate;
3287    }
3288    assert_not_sync::<BorrowedSender<'_>>();
3289    assert_not_sync::<BorrowedDirectColumnSender<'_>>();
3290    #[cfg(feature = "_egress")]
3291    assert_not_sync::<BorrowedReader<'_>>();
3292    assert_not_sync::<crate::ingress::column_sender::Chunk<'_>>();
3293};
3294
3295#[cfg(test)]
3296mod tests {
3297    use std::fs;
3298
3299    use tempfile::TempDir;
3300
3301    use super::{SlotReservations, managed_slot_recovery_scan_from};
3302
3303    fn dirty_slot(root: &std::path::Path, name: &str) {
3304        let slot = root.join(name);
3305        fs::create_dir(&slot).unwrap();
3306        fs::write(slot.join("sf-0.sfa"), b"queued").unwrap();
3307    }
3308
3309    #[test]
3310    fn managed_slot_recovery_candidates_exclude_live_pool_range() {
3311        let temp = TempDir::new().unwrap();
3312        dirty_slot(temp.path(), "default-ingest-0");
3313        dirty_slot(temp.path(), "default-ingest-1");
3314        dirty_slot(temp.path(), "default-ingest-2");
3315        dirty_slot(temp.path(), &format!("default-{}-2", "col"));
3316        dirty_slot(temp.path(), &format!("default-{}-2", "row"));
3317
3318        let mut actual = managed_slot_recovery_scan_from(temp.path(), "default", 2).out_of_range;
3319        actual.sort();
3320
3321        assert_eq!(actual, vec![temp.path().join("default-ingest-2")]);
3322    }
3323
3324    #[test]
3325    fn slot_reservations_reserve_specific_index() {
3326        let mut disk = SlotReservations::with_disk_slots(2);
3327        assert!(disk.reserve(1));
3328        assert!(!disk.reserve(1), "double-reserve must fail");
3329        assert!(!disk.reserve(2), "out-of-range reserve must fail");
3330        disk.free(Some(1));
3331        assert!(disk.reserve(1), "freed slot can be reserved again");
3332
3333        let mut in_memory = SlotReservations::default();
3334        assert!(!in_memory.reserve(0));
3335    }
3336}