Skip to main content

questdb/ingress/sender/
qwp_ws_ownership.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/WebSocket progress ownership.
26
27use std::fmt;
28use std::sync::Arc;
29
30/// Controls whether a QWP/WebSocket [`crate::ingress::Sender`] starts its
31/// background progress runner or requires the caller to drive progress.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum QwpWsProgress {
34    /// Start a background runner that sends frames, receives ACKs, reconnects,
35    /// and replays as needed. This is the default and matches Java's sender.
36    Background,
37    /// Do not start a background runner. The caller must call
38    /// [`crate::ingress::Sender::drive_once`] or
39    /// [`crate::ingress::Sender::wait`] to advance WebSocket progress.
40    Manual,
41}
42
43/// Structured server-side error observed by a QWP/WebSocket sender.
44///
45/// This mirrors Java's `SenderError` shape, but Rust exposes it through
46/// polling on [`crate::ingress::Sender`] instead of a callback dispatcher.
47#[non_exhaustive]
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct QwpWsSenderError {
50    /// Server-distinguishable error category.
51    pub category: QwpWsErrorCategory,
52    /// Policy the client applied after observing the error.
53    pub applied_policy: QwpWsErrorPolicy,
54    /// Raw QWP status byte. `None` for WebSocket protocol violations, which do
55    /// not carry a QWP status byte.
56    pub status: Option<u8>,
57    /// Human-readable message provided by the server or derived from the
58    /// WebSocket close reason.
59    pub message: Option<String>,
60    /// Server's per-frame QWP message sequence. `None` for WebSocket protocol
61    /// violations, which do not carry a QWP message sequence.
62    pub message_sequence: Option<u64>,
63    /// Inclusive lower bound of the affected frame sequence number span.
64    pub from_fsn: u64,
65    /// Inclusive upper bound of the affected frame sequence number span.
66    pub to_fsn: u64,
67}
68
69/// Producer-thread callback invoked for structured QWP/WebSocket server
70/// diagnostics.
71///
72/// The callback runs synchronously from sender API calls such as
73/// [`crate::ingress::Sender::flush`]. It must not call back into the same
74/// sender. For a terminal diagnostic, the sender's terminal state and
75/// pollable diagnostic are committed before the callback is invoked.
76#[derive(Clone)]
77pub struct QwpWsErrorHandler {
78    handler: Arc<dyn Fn(&QwpWsSenderError) + Send + Sync>,
79}
80
81impl QwpWsErrorHandler {
82    /// Create a handler from a closure.
83    pub fn new<F>(handler: F) -> Self
84    where
85        F: Fn(&QwpWsSenderError) + Send + Sync + 'static,
86    {
87        Self {
88            handler: Arc::new(handler),
89        }
90    }
91
92    pub(crate) fn log_default() -> Self {
93        Self::new(default_qwp_ws_error_handler)
94    }
95
96    pub(crate) fn handle(&self, error: &QwpWsSenderError) {
97        (self.handler)(error);
98    }
99}
100
101impl fmt::Debug for QwpWsErrorHandler {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        f.write_str("QwpWsErrorHandler { .. }")
104    }
105}
106
107fn default_qwp_ws_error_handler(error: &QwpWsSenderError) {
108    let status = error
109        .status
110        .map(|status| format!("0x{status:02x}"))
111        .unwrap_or_else(|| "none".to_string());
112    let sequence = error
113        .message_sequence
114        .map(|sequence| sequence.to_string())
115        .unwrap_or_else(|| "none".to_string());
116    let message = error.message.as_deref().unwrap_or("");
117    if error.applied_policy == QwpWsErrorPolicy::Terminal {
118        log::error!(
119            target: "questdb::ingress",
120            "QWP/WebSocket server rejected batch [category={:?}, policy={:?}, status={}, fsn=[{},{}], seq={}, msg={}]",
121            error.category,
122            error.applied_policy,
123            status,
124            error.from_fsn,
125            error.to_fsn,
126            sequence,
127            message
128        );
129    } else {
130        log::warn!(
131            target: "questdb::ingress",
132            "QWP/WebSocket server rejected batch [category={:?}, policy={:?}, status={}, fsn=[{},{}], seq={}, msg={}]",
133            error.category,
134            error.applied_policy,
135            status,
136            error.from_fsn,
137            error.to_fsn,
138            sequence,
139            message
140        );
141    }
142}
143
144/// Lifetime totals reported by a QWP/WebSocket [`crate::ingress::Sender`].
145///
146/// Mirrors the `getTotal*` counters on Java's `QwpWebSocketSender` so the
147/// QuestDB Enterprise e2e harness (questdb-ent/e2e) can compare the same
148/// signal across language bindings. All counts are cumulative from the
149/// moment the sender was constructed: they never reset, and they survive
150/// reconnects.
151#[non_exhaustive]
152#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
153pub struct QwpWsTotals {
154    /// Frames handed to the transport for writing, regardless of whether the
155    /// server has acknowledged them.
156    pub frames_sent: u64,
157    /// Frames handed to the transport while catching up through the
158    /// publication boundary captured at a successful reconnect. Initial
159    /// publication, including recovered store-and-forward frames sent on the
160    /// first connection, is not counted as replay.
161    pub frames_replayed: u64,
162    /// Server responses interpreted as ACKs: ordinary OK, DurableOk, and
163    /// stand-alone DurableAck position notifications.
164    pub acks: u64,
165    /// Reconnect attempts initiated, including ones that returned
166    /// immediately because the retry budget was exhausted.
167    pub reconnect_attempts: u64,
168    /// Reconnect cycles that completed successfully and resumed publication.
169    pub reconnects_succeeded: u64,
170    /// Server-sent Reject responses (any policy: retriable, terminal, durable,
171    /// presend).
172    pub server_errors: u64,
173}
174
175/// Server-distinguishable QWP/WebSocket error category.
176#[non_exhaustive]
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum QwpWsErrorCategory {
179    /// Server-side schema mismatch, such as a missing column, type clash, or
180    /// NOT NULL violation.
181    SchemaMismatch,
182    /// Malformed QWP payload.
183    ParseError,
184    /// Server-side internal failure.
185    InternalError,
186    /// Authentication or authorization failure.
187    SecurityError,
188    /// Non-critical server write failure.
189    WriteError,
190    /// Server reports that the endpoint is temporarily not writable.
191    NotWritable,
192    /// WebSocket protocol violation, including poison-frame escalation.
193    ProtocolViolation,
194    /// Unknown QWP status byte.
195    Unknown,
196}
197
198/// Policy applied by the sender after observing a QWP/WebSocket server error.
199#[non_exhaustive]
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum QwpWsErrorPolicy {
202    /// Tear down the connection, reconnect, and replay from the unresolved
203    /// store-and-forward watermark.
204    Retriable,
205    /// Tear down and replay, but classify separately for diagnostics because
206    /// another endpoint/role may be required.
207    RetriableOther,
208    /// Latch the error as terminal. The bytes remain in store-and-forward
209    /// storage for inspection or a later compatible client/server.
210    Terminal,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub(crate) struct QwpWsRoleReject {
215    pub(crate) role: String,
216    pub(crate) zone: Option<String>,
217}
218
219impl QwpWsRoleReject {
220    pub(crate) fn new(role: &str, zone: Option<&str>) -> Self {
221        Self {
222            role: role.to_string(),
223            zone: zone.map(str::to_string),
224        }
225    }
226
227    pub(crate) fn is_transient(&self) -> bool {
228        self.role.eq_ignore_ascii_case("PRIMARY_CATCHUP")
229    }
230}