quack-ruby 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/LICENSE-DuckDB +7 -0
- data/LICENSE-QuackProtocol +21 -0
- data/LICENSE-Rust-dependencies.json +2057 -0
- data/README.md +229 -0
- data/ext/quack/Cargo.lock +1803 -0
- data/ext/quack/Cargo.toml +17 -0
- data/ext/quack/extconf.rb +4 -0
- data/ext/quack/src/lib.rs +647 -0
- data/ext/quack/vendor/quack_protocol/Cargo.lock +1907 -0
- data/ext/quack/vendor/quack_protocol/Cargo.toml +41 -0
- data/ext/quack/vendor/quack_protocol/LICENSE +21 -0
- data/ext/quack/vendor/quack_protocol/PATCHES.md +16 -0
- data/ext/quack/vendor/quack_protocol/README.md +272 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/columns.rs +399 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/mod.rs +134 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/schema.rs +103 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/tests.rs +851 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/values.rs +150 -0
- data/ext/quack/vendor/quack_protocol/src/binary.rs +504 -0
- data/ext/quack/vendor/quack_protocol/src/builders.rs +217 -0
- data/ext/quack/vendor/quack_protocol/src/client.rs +1486 -0
- data/ext/quack/vendor/quack_protocol/src/constants.rs +11 -0
- data/ext/quack/vendor/quack_protocol/src/errors.rs +103 -0
- data/ext/quack/vendor/quack_protocol/src/inspection.rs +250 -0
- data/ext/quack/vendor/quack_protocol/src/json.rs +225 -0
- data/ext/quack/vendor/quack_protocol/src/lib.rs +54 -0
- data/ext/quack/vendor/quack_protocol/src/logical_types.rs +977 -0
- data/ext/quack/vendor/quack_protocol/src/messages.rs +843 -0
- data/ext/quack/vendor/quack_protocol/src/pool.rs +741 -0
- data/ext/quack/vendor/quack_protocol/src/protocol_tests.rs +759 -0
- data/ext/quack/vendor/quack_protocol/src/proxy_error.rs +77 -0
- data/ext/quack/vendor/quack_protocol/src/sql.rs +359 -0
- data/ext/quack/vendor/quack_protocol/src/tls.rs +354 -0
- data/ext/quack/vendor/quack_protocol/src/values.rs +77 -0
- data/ext/quack/vendor/quack_protocol/src/vector.rs +1364 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/key.der +0 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/spki.der +0 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/v1-cert.der +0 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/v3-cert.der +0 -0
- data/lib/quack/codec.rb +263 -0
- data/lib/quack/connection.rb +131 -0
- data/lib/quack/logical_types.rb +72 -0
- data/lib/quack/result.rb +64 -0
- data/lib/quack/sql.rb +183 -0
- data/lib/quack/types.rb +40 -0
- data/lib/quack/variant.rb +298 -0
- data/lib/quack.rb +34 -0
- metadata +136 -0
|
@@ -0,0 +1,1486 @@
|
|
|
1
|
+
use std::iter::zip;
|
|
2
|
+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
3
|
+
use std::sync::{Arc, Mutex as StdMutex};
|
|
4
|
+
use std::time::{Duration, Instant};
|
|
5
|
+
|
|
6
|
+
use async_stream::try_stream;
|
|
7
|
+
use futures_util::stream::{self, BoxStream};
|
|
8
|
+
use futures_util::{StreamExt, TryStreamExt};
|
|
9
|
+
use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
|
|
10
|
+
use tokio::sync::{Mutex, OwnedMutexGuard};
|
|
11
|
+
|
|
12
|
+
use crate::binary::HugeIntParts;
|
|
13
|
+
use crate::builders::{ColumnDefinition, data_chunk_from_rows};
|
|
14
|
+
use crate::constants::{
|
|
15
|
+
DEFAULT_HEARTBEAT_TIMEOUT_SECS, DEFAULT_QUACK_PORT, DUCKDB_MIME_TYPE,
|
|
16
|
+
MAX_HEARTBEAT_TIMEOUT_SECS, MAX_QUACK_VERSION, MIN_QUACK_VERSION, QUACK_ENDPOINT, QUACK_V1,
|
|
17
|
+
QUACK_V3,
|
|
18
|
+
};
|
|
19
|
+
use crate::errors::{QuackError, Result};
|
|
20
|
+
use crate::messages::{
|
|
21
|
+
MessageHeader, MessageType, QuackMessage, decode_message_for_version,
|
|
22
|
+
encode_message_for_version,
|
|
23
|
+
};
|
|
24
|
+
use crate::sql::{QuerySql, SqlParameters, format_sql};
|
|
25
|
+
use crate::tls::{CertificatePin, pinned_client_config};
|
|
26
|
+
use crate::vector::{DataChunk, Row, Value, rows_from_chunk_with_names};
|
|
27
|
+
|
|
28
|
+
const DEFAULT_QUACK_REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
|
|
29
|
+
const DEFAULT_QUACK_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
30
|
+
// A DISCONNECT sent from `Drop` runs detached, with no caller left to observe
|
|
31
|
+
// it, so it is not given the full request timeout to hang around for.
|
|
32
|
+
const DISCONNECT_ON_DROP_TIMEOUT: Duration = Duration::from_secs(10);
|
|
33
|
+
|
|
34
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
35
|
+
pub(crate) struct ParsedQuackUri {
|
|
36
|
+
pub(crate) base_url: String,
|
|
37
|
+
pub(crate) host: String,
|
|
38
|
+
pub(crate) port: u16,
|
|
39
|
+
pub(crate) ssl: bool,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[derive(Clone)]
|
|
43
|
+
pub struct QuackClientOptions {
|
|
44
|
+
/// Sent to the server on connect; redacted in `Debug` output.
|
|
45
|
+
pub auth_token: Option<String>,
|
|
46
|
+
pub client_duckdb_version: Option<String>,
|
|
47
|
+
pub client_platform: Option<String>,
|
|
48
|
+
pub min_supported_quack_version: Option<u64>,
|
|
49
|
+
pub max_supported_quack_version: Option<u64>,
|
|
50
|
+
pub client_id: Option<String>,
|
|
51
|
+
pub heartbeat_timeout: Option<Duration>,
|
|
52
|
+
pub ssl: Option<bool>,
|
|
53
|
+
/// SHA-256 fingerprint of the server's TLS certificate, as DuckDB's
|
|
54
|
+
/// `ssl_fingerprint` takes it (hex, optionally colon-separated, optional
|
|
55
|
+
/// `sha256:` prefix). When set, the client trusts exactly that certificate
|
|
56
|
+
/// instead of verifying it against a CA, which is how a server's
|
|
57
|
+
/// self-signed certificate is authenticated. A pin implies HTTPS.
|
|
58
|
+
pub ssl_fingerprint: Option<String>,
|
|
59
|
+
pub timeout: Option<Duration>,
|
|
60
|
+
/// Extra HTTP headers sent with every request, as name/value pairs.
|
|
61
|
+
/// Invalid names or values are reported when the client connects.
|
|
62
|
+
pub headers: Vec<(String, String)>,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
impl std::fmt::Debug for QuackClientOptions {
|
|
66
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
67
|
+
f.debug_struct("QuackClientOptions")
|
|
68
|
+
.field(
|
|
69
|
+
"auth_token",
|
|
70
|
+
&self.auth_token.as_ref().map(|_| "<redacted>"),
|
|
71
|
+
)
|
|
72
|
+
.field("client_duckdb_version", &self.client_duckdb_version)
|
|
73
|
+
.field("client_platform", &self.client_platform)
|
|
74
|
+
.field(
|
|
75
|
+
"min_supported_quack_version",
|
|
76
|
+
&self.min_supported_quack_version,
|
|
77
|
+
)
|
|
78
|
+
.field(
|
|
79
|
+
"max_supported_quack_version",
|
|
80
|
+
&self.max_supported_quack_version,
|
|
81
|
+
)
|
|
82
|
+
.field("client_id", &self.client_id)
|
|
83
|
+
.field("heartbeat_timeout", &self.heartbeat_timeout)
|
|
84
|
+
.field("ssl", &self.ssl)
|
|
85
|
+
.field("ssl_fingerprint", &self.ssl_fingerprint)
|
|
86
|
+
.field("timeout", &self.timeout)
|
|
87
|
+
.field("headers", &"[redacted]")
|
|
88
|
+
.finish()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
impl Default for QuackClientOptions {
|
|
93
|
+
fn default() -> Self {
|
|
94
|
+
Self {
|
|
95
|
+
auth_token: None,
|
|
96
|
+
client_duckdb_version: None,
|
|
97
|
+
client_platform: None,
|
|
98
|
+
min_supported_quack_version: None,
|
|
99
|
+
max_supported_quack_version: None,
|
|
100
|
+
client_id: None,
|
|
101
|
+
heartbeat_timeout: Some(Duration::from_secs(DEFAULT_HEARTBEAT_TIMEOUT_SECS)),
|
|
102
|
+
ssl: None,
|
|
103
|
+
ssl_fingerprint: None,
|
|
104
|
+
timeout: Some(DEFAULT_QUACK_REQUEST_TIMEOUT),
|
|
105
|
+
headers: Vec::new(),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
111
|
+
pub struct QuackConnectionInfo {
|
|
112
|
+
pub server_duckdb_version: Option<String>,
|
|
113
|
+
pub server_platform: Option<String>,
|
|
114
|
+
pub quack_version: Option<u64>,
|
|
115
|
+
pub heartbeat_timeout: Option<Duration>,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
pub struct QueryMetadata {
|
|
119
|
+
pub query_id: Option<String>,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#[must_use = "query results are dropped unless the stream is consumed"]
|
|
123
|
+
pub struct QuackResultStream {
|
|
124
|
+
columns: Vec<ColumnDefinition>,
|
|
125
|
+
inner: BoxStream<'static, Result<DataChunk>>,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
impl QuackResultStream {
|
|
129
|
+
pub(crate) fn new(
|
|
130
|
+
columns: Vec<ColumnDefinition>,
|
|
131
|
+
chunks: BoxStream<'static, Result<DataChunk>>,
|
|
132
|
+
) -> Self {
|
|
133
|
+
Self {
|
|
134
|
+
columns,
|
|
135
|
+
inner: chunks,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pub fn into_chunks(self) -> (Vec<ColumnDefinition>, BoxStream<'static, Result<DataChunk>>) {
|
|
140
|
+
(self.columns, self.inner)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pub fn into_rows(self) -> (Vec<ColumnDefinition>, BoxStream<'static, Result<Row>>) {
|
|
144
|
+
let col_names: Vec<String> = self.columns.iter().map(|col| col.name.to_owned()).collect();
|
|
145
|
+
let rows = self
|
|
146
|
+
.inner
|
|
147
|
+
.flat_map(move |chunk| {
|
|
148
|
+
stream::iter(
|
|
149
|
+
match chunk.and_then(|chunk| rows_from_chunk_with_names(&chunk, &col_names)) {
|
|
150
|
+
Ok(rows) => rows.into_iter().map(Ok).collect(),
|
|
151
|
+
Err(err) => vec![Err(err)],
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
.boxed();
|
|
156
|
+
(self.columns, rows)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Consuming helpers shared by `QuackClient` and `QuackPool`; all of them
|
|
160
|
+
// drain the stream, which releases the connection.
|
|
161
|
+
pub(crate) async fn drain(self) -> Result<()> {
|
|
162
|
+
let (_, mut chunks) = self.into_chunks();
|
|
163
|
+
while chunks.try_next().await?.is_some() {}
|
|
164
|
+
Ok(())
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
pub(crate) async fn first_row(self) -> Result<Option<Row>> {
|
|
168
|
+
let (_, rows) = self.into_rows();
|
|
169
|
+
let rows: Vec<_> = rows.try_collect().await?;
|
|
170
|
+
Ok(rows.into_iter().next())
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
pub(crate) async fn one_row(self) -> Result<Row> {
|
|
174
|
+
let (_, rows) = self.into_rows();
|
|
175
|
+
let rows: Vec<_> = rows.try_collect().await?;
|
|
176
|
+
if rows.len() != 1 {
|
|
177
|
+
return Err(QuackError::protocol(format!(
|
|
178
|
+
"expected exactly one row, got {}",
|
|
179
|
+
rows.len()
|
|
180
|
+
)));
|
|
181
|
+
}
|
|
182
|
+
Ok(rows.into_iter().next().expect("one row"))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
pub(crate) async fn first_column(self) -> Result<Vec<Value>> {
|
|
186
|
+
let (columns, rows) = self.into_rows();
|
|
187
|
+
let rows: Vec<_> = rows.try_collect().await?;
|
|
188
|
+
let first_name = match columns.first() {
|
|
189
|
+
Some(col) => &col.name,
|
|
190
|
+
None => return Ok(Vec::new()),
|
|
191
|
+
};
|
|
192
|
+
Ok(rows
|
|
193
|
+
.into_iter()
|
|
194
|
+
.map(|mut row| row.shift_remove(first_name).unwrap_or(Value::Null))
|
|
195
|
+
.collect())
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
struct FetchState {
|
|
200
|
+
connection: OwnedMutexGuard<Connection>,
|
|
201
|
+
sql: QuerySql,
|
|
202
|
+
result_uuid: HugeIntParts,
|
|
203
|
+
needs_more_fetch: bool,
|
|
204
|
+
query_started: Instant,
|
|
205
|
+
rows_delivered: usize,
|
|
206
|
+
next_batch_index: u64,
|
|
207
|
+
ack_index: u64,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// One session on a Quack server.
|
|
211
|
+
///
|
|
212
|
+
/// A `QuackClient` maps to exactly one server-side connection id for its whole
|
|
213
|
+
/// lifetime, clones included - they share one `Arc`. That has two
|
|
214
|
+
/// consequences:
|
|
215
|
+
///
|
|
216
|
+
/// - **Queries are serialized.** The server keeps one resumable result cursor
|
|
217
|
+
/// per connection id, and a PREPARE resets it, which would invalidate a FETCH
|
|
218
|
+
/// still in flight. Queries therefore take a mutex, and a result stream holds
|
|
219
|
+
/// it until the stream is drained or dropped. Cloning the client does not
|
|
220
|
+
/// change this: concurrent queries need [`QuackPool`](crate::QuackPool),
|
|
221
|
+
/// which spreads them over several sessions.
|
|
222
|
+
/// - **Session state persists.** Temporary tables, `SET`, transactions, and
|
|
223
|
+
/// attached databases live on the connection, so consecutive calls on one
|
|
224
|
+
/// client see each other's effects.
|
|
225
|
+
///
|
|
226
|
+
/// Dropping the last clone closes the server-side session on a background
|
|
227
|
+
/// task, best-effort; [`disconnect`](Self::disconnect) closes it
|
|
228
|
+
/// deterministically and reports whether that worked.
|
|
229
|
+
#[derive(Clone, Debug)]
|
|
230
|
+
pub struct QuackClient {
|
|
231
|
+
connection: Arc<Mutex<Connection>>,
|
|
232
|
+
// Also held by the `Connection` itself, so status stays readable while a
|
|
233
|
+
// result stream holds the mutex.
|
|
234
|
+
state: Arc<ConnectionState>,
|
|
235
|
+
// Protocol v3 leases expire without heartbeats; the guard's task stops when
|
|
236
|
+
// the last clone of this client is dropped.
|
|
237
|
+
_heartbeat: Option<Arc<HeartbeatGuard>>,
|
|
238
|
+
pub info: Option<QuackConnectionInfo>,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
impl QuackClient {
|
|
242
|
+
pub async fn connect(uri: &str, options: QuackClientOptions) -> Result<Self> {
|
|
243
|
+
let pin = options
|
|
244
|
+
.ssl_fingerprint
|
|
245
|
+
.as_deref()
|
|
246
|
+
.map(CertificatePin::parse)
|
|
247
|
+
.transpose()?;
|
|
248
|
+
if pin.is_some() && options.ssl == Some(false) {
|
|
249
|
+
return Err(QuackError::protocol(
|
|
250
|
+
"ssl_fingerprint requires HTTPS, but ssl is set to false",
|
|
251
|
+
));
|
|
252
|
+
}
|
|
253
|
+
let ssl = if pin.is_some() {
|
|
254
|
+
Some(true)
|
|
255
|
+
} else {
|
|
256
|
+
options.ssl
|
|
257
|
+
};
|
|
258
|
+
let parsed = parse_quack_uri(uri, ssl)?;
|
|
259
|
+
if pin.is_some() && !parsed.ssl {
|
|
260
|
+
return Err(QuackError::protocol(format!(
|
|
261
|
+
"ssl_fingerprint requires HTTPS, but {uri} is an http URL"
|
|
262
|
+
)));
|
|
263
|
+
}
|
|
264
|
+
let timeout = options.timeout.unwrap_or(DEFAULT_QUACK_REQUEST_TIMEOUT);
|
|
265
|
+
let http = http_client(parsed.ssl, pin, timeout)?;
|
|
266
|
+
let base_url = parsed.base_url.trim_end_matches('/').to_string();
|
|
267
|
+
let (connection, info) = Connection::connect(base_url, http, timeout, options).await?;
|
|
268
|
+
let state = Arc::clone(&connection.state);
|
|
269
|
+
let heartbeat = info
|
|
270
|
+
.heartbeat_timeout
|
|
271
|
+
.map(|timeout| Arc::new(HeartbeatGuard::start(&connection, timeout)));
|
|
272
|
+
|
|
273
|
+
Ok(Self {
|
|
274
|
+
connection: Arc::new(Mutex::new(connection)),
|
|
275
|
+
state,
|
|
276
|
+
_heartbeat: heartbeat,
|
|
277
|
+
info: Some(info),
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
pub fn is_connected(&self) -> bool {
|
|
282
|
+
!self.state.is_closed()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Whether a pool may hand this connection to the next caller: still open,
|
|
286
|
+
// and no wire failure has been seen on it.
|
|
287
|
+
pub(crate) fn is_reusable(&self) -> bool {
|
|
288
|
+
self.state.is_reusable()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// Run a statement and discard its result.
|
|
292
|
+
pub async fn execute(&self, sql: &str, metadata: Option<&QueryMetadata>) -> Result<()> {
|
|
293
|
+
self.query_inner(sql, None, metadata).await?.drain().await
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
pub async fn query(
|
|
297
|
+
&self,
|
|
298
|
+
sql: &str,
|
|
299
|
+
metadata: Option<&QueryMetadata>,
|
|
300
|
+
) -> Result<QuackResultStream> {
|
|
301
|
+
self.query_inner(sql, None, metadata).await
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
pub async fn query_with_params(
|
|
305
|
+
&self,
|
|
306
|
+
sql: &str,
|
|
307
|
+
params: Option<&SqlParameters>,
|
|
308
|
+
) -> Result<QuackResultStream> {
|
|
309
|
+
self.query_inner(sql, params, None).await
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Execute a SQL query on Quack server and stream results via repeated
|
|
313
|
+
// FETCH calls to server.
|
|
314
|
+
pub(crate) async fn query_inner(
|
|
315
|
+
&self,
|
|
316
|
+
sql: &str,
|
|
317
|
+
params: Option<&SqlParameters>,
|
|
318
|
+
metadata: Option<&QueryMetadata>,
|
|
319
|
+
) -> Result<QuackResultStream> {
|
|
320
|
+
let query_id = metadata
|
|
321
|
+
.and_then(|metadata| metadata.query_id.as_deref())
|
|
322
|
+
.unwrap_or("-")
|
|
323
|
+
.to_string();
|
|
324
|
+
let sql = QuerySql::new(format_sql(sql, params)?);
|
|
325
|
+
|
|
326
|
+
let (columns, chunks, fetch_state) = self.prepare(sql, query_id.clone()).await?;
|
|
327
|
+
let fetch_stream = self.fetch(fetch_state, &columns, query_id);
|
|
328
|
+
|
|
329
|
+
Ok(QuackResultStream::new(
|
|
330
|
+
columns,
|
|
331
|
+
stream::iter(chunks).map(Ok).chain(fetch_stream).boxed(),
|
|
332
|
+
))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async fn prepare(
|
|
336
|
+
&self,
|
|
337
|
+
sql: QuerySql,
|
|
338
|
+
query_id: String,
|
|
339
|
+
) -> Result<(Vec<ColumnDefinition>, Vec<DataChunk>, FetchState)> {
|
|
340
|
+
// Acquires the connection lock here and carries it forward via
|
|
341
|
+
// `FetchState` so the same lock stays held through every FETCH - see
|
|
342
|
+
// `client.connection` field docs.
|
|
343
|
+
let connection = Arc::clone(&self.connection).lock_owned().await;
|
|
344
|
+
let query_started = Instant::now();
|
|
345
|
+
let (result_types, result_names, needs_more_fetch, mut chunks, result_uuid) =
|
|
346
|
+
match connection.prepare(sql.as_str()).await? {
|
|
347
|
+
QuackMessage::PrepareResponse {
|
|
348
|
+
result_types,
|
|
349
|
+
result_names,
|
|
350
|
+
needs_more_fetch,
|
|
351
|
+
results,
|
|
352
|
+
result_uuid,
|
|
353
|
+
..
|
|
354
|
+
} => (
|
|
355
|
+
result_types,
|
|
356
|
+
result_names,
|
|
357
|
+
needs_more_fetch,
|
|
358
|
+
results,
|
|
359
|
+
result_uuid,
|
|
360
|
+
),
|
|
361
|
+
other => {
|
|
362
|
+
return Err(connection.protocol_failure(format!(
|
|
363
|
+
"expected PREPARE_RESPONSE, got {:?}",
|
|
364
|
+
other.message_type()
|
|
365
|
+
)));
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
let rows: usize = chunks.iter().map(|chunk| chunk.row_count).sum();
|
|
370
|
+
tracing::debug!(
|
|
371
|
+
query_id,
|
|
372
|
+
%sql,
|
|
373
|
+
%result_uuid,
|
|
374
|
+
rows,
|
|
375
|
+
elapsed_ms = query_started.elapsed().as_millis() as u64,
|
|
376
|
+
"quack PREPARE completed"
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
attach_column_names(&mut chunks, &result_names);
|
|
380
|
+
let columns: Vec<ColumnDefinition> = zip(result_names, result_types)
|
|
381
|
+
.map(|(name, logical_type)| ColumnDefinition { name, logical_type })
|
|
382
|
+
.collect();
|
|
383
|
+
|
|
384
|
+
let fetch_state = FetchState {
|
|
385
|
+
connection,
|
|
386
|
+
sql,
|
|
387
|
+
result_uuid,
|
|
388
|
+
needs_more_fetch,
|
|
389
|
+
query_started,
|
|
390
|
+
rows_delivered: rows,
|
|
391
|
+
next_batch_index: 1,
|
|
392
|
+
ack_index: 0,
|
|
393
|
+
};
|
|
394
|
+
Ok((columns, chunks, fetch_state))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
fn fetch(
|
|
398
|
+
&self,
|
|
399
|
+
state: FetchState,
|
|
400
|
+
columns: &[ColumnDefinition],
|
|
401
|
+
query_id: String,
|
|
402
|
+
) -> BoxStream<'static, Result<DataChunk>> {
|
|
403
|
+
let FetchState {
|
|
404
|
+
connection,
|
|
405
|
+
sql,
|
|
406
|
+
result_uuid,
|
|
407
|
+
mut needs_more_fetch,
|
|
408
|
+
query_started,
|
|
409
|
+
mut rows_delivered,
|
|
410
|
+
mut next_batch_index,
|
|
411
|
+
mut ack_index,
|
|
412
|
+
} = state;
|
|
413
|
+
let column_names = columns
|
|
414
|
+
.iter()
|
|
415
|
+
.map(|col| col.name.to_owned())
|
|
416
|
+
.collect::<Vec<_>>();
|
|
417
|
+
let quack_version = connection.quack_version;
|
|
418
|
+
|
|
419
|
+
Box::pin(try_stream! {
|
|
420
|
+
while needs_more_fetch {
|
|
421
|
+
let fetch_started = Instant::now();
|
|
422
|
+
let (mut results, total_batches, batch_index) = match connection
|
|
423
|
+
.fetch(result_uuid, next_batch_index, ack_index)
|
|
424
|
+
.await?
|
|
425
|
+
{
|
|
426
|
+
QuackMessage::FetchResponse {
|
|
427
|
+
results,
|
|
428
|
+
total_batches,
|
|
429
|
+
batch_index,
|
|
430
|
+
..
|
|
431
|
+
} => (results, total_batches, batch_index),
|
|
432
|
+
other => Err(connection.protocol_failure(format!(
|
|
433
|
+
"expected FETCH_RESPONSE, got {:?}",
|
|
434
|
+
other.message_type()
|
|
435
|
+
)))?,
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
if quack_version == QUACK_V3 {
|
|
439
|
+
match batch_index {
|
|
440
|
+
Some(batch_index) if batch_index == next_batch_index => {
|
|
441
|
+
if results.is_empty() {
|
|
442
|
+
Err(connection.protocol_failure(
|
|
443
|
+
"FETCH_RESPONSE batch did not include any chunks",
|
|
444
|
+
))?;
|
|
445
|
+
}
|
|
446
|
+
ack_index = batch_index;
|
|
447
|
+
next_batch_index = next_batch_index.checked_add(1).ok_or_else(|| {
|
|
448
|
+
connection.protocol_failure("FETCH_RESPONSE batch index overflow")
|
|
449
|
+
})?;
|
|
450
|
+
}
|
|
451
|
+
// A v3 FETCH_REQUEST asks for one exact batch. The server may produce
|
|
452
|
+
// batches out of order internally, but it must answer with the requested
|
|
453
|
+
// index; accepting another index here would silently reorder the result.
|
|
454
|
+
Some(batch_index) => Err(connection.protocol_failure(format!(
|
|
455
|
+
"expected FETCH_RESPONSE batch {next_batch_index}, got {batch_index}"
|
|
456
|
+
)))?,
|
|
457
|
+
None if results.is_empty() => {
|
|
458
|
+
if let Some(total_batches) = total_batches {
|
|
459
|
+
if total_batches != ack_index {
|
|
460
|
+
Err(connection.protocol_failure(format!(
|
|
461
|
+
"FETCH_RESPONSE ended after {ack_index} batches but announced {total_batches}"
|
|
462
|
+
)))?;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
None => Err(connection.protocol_failure(
|
|
467
|
+
"FETCH_RESPONSE with chunks did not include a batch index",
|
|
468
|
+
))?,
|
|
469
|
+
}
|
|
470
|
+
needs_more_fetch = batch_index.is_some();
|
|
471
|
+
} else {
|
|
472
|
+
needs_more_fetch = !results.is_empty();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let rows: usize = results.iter().map(|chunk| chunk.row_count).sum();
|
|
476
|
+
rows_delivered += rows;
|
|
477
|
+
tracing::debug!(
|
|
478
|
+
query_id,
|
|
479
|
+
%sql,
|
|
480
|
+
%result_uuid,
|
|
481
|
+
rows,
|
|
482
|
+
elapsed_ms = fetch_started.elapsed().as_millis() as u64,
|
|
483
|
+
"quack FETCH completed"
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
attach_column_names(&mut results, &column_names);
|
|
487
|
+
for chunk in results {
|
|
488
|
+
yield chunk;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
tracing::debug!(
|
|
493
|
+
query_id,
|
|
494
|
+
%sql,
|
|
495
|
+
%result_uuid,
|
|
496
|
+
rows = rows_delivered,
|
|
497
|
+
elapsed_ms = query_started.elapsed().as_millis() as u64,
|
|
498
|
+
"quack query completed"
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// `connection` drops here and the lock is released for the next queued
|
|
502
|
+
// operation - the session on the Quack server stays open until
|
|
503
|
+
// `disconnect`/`close` is called
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
pub async fn first(&self, sql: &str) -> Result<Option<Row>> {
|
|
508
|
+
self.query(sql, None).await?.first_row().await
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
pub async fn one(&self, sql: &str) -> Result<Row> {
|
|
512
|
+
self.query(sql, None).await?.one_row().await
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
pub async fn values(&self, sql: &str) -> Result<Vec<Value>> {
|
|
516
|
+
self.query(sql, None).await?.first_column().await
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
pub async fn append(
|
|
520
|
+
&self,
|
|
521
|
+
table_name: impl Into<String>,
|
|
522
|
+
schema_name: Option<String>,
|
|
523
|
+
chunk: DataChunk,
|
|
524
|
+
) -> Result<()> {
|
|
525
|
+
self.connection
|
|
526
|
+
.lock()
|
|
527
|
+
.await
|
|
528
|
+
.append(table_name.into(), schema_name, chunk)
|
|
529
|
+
.await
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
pub async fn append_rows(
|
|
533
|
+
&self,
|
|
534
|
+
table_name: impl Into<String>,
|
|
535
|
+
schema_name: Option<String>,
|
|
536
|
+
rows: &[Row],
|
|
537
|
+
columns: Option<Vec<ColumnDefinition>>,
|
|
538
|
+
batch_size: Option<usize>,
|
|
539
|
+
) -> Result<()> {
|
|
540
|
+
let table_name = table_name.into();
|
|
541
|
+
if rows.is_empty() {
|
|
542
|
+
let chunk = data_chunk_from_rows(rows, columns)?;
|
|
543
|
+
return self.append(table_name, schema_name, chunk).await;
|
|
544
|
+
}
|
|
545
|
+
let batch_size = batch_size.unwrap_or(rows.len());
|
|
546
|
+
if batch_size == 0 {
|
|
547
|
+
return Err(QuackError::protocol(
|
|
548
|
+
"append_rows batch_size must be at least 1",
|
|
549
|
+
));
|
|
550
|
+
}
|
|
551
|
+
for batch in rows.chunks(batch_size) {
|
|
552
|
+
let chunk = data_chunk_from_rows(batch, columns.clone())?;
|
|
553
|
+
self.append(table_name.clone(), schema_name.clone(), chunk)
|
|
554
|
+
.await?;
|
|
555
|
+
}
|
|
556
|
+
Ok(())
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/// Close the server-side session, releasing its cursor and session state.
|
|
560
|
+
///
|
|
561
|
+
/// Waits for any in-flight query to finish, since it takes the same lock.
|
|
562
|
+
/// Dropping the client does the same thing on a detached task, but a
|
|
563
|
+
/// detached task only runs while the Tokio runtime is alive; call this when
|
|
564
|
+
/// the session must be closed before the program moves on, and to see
|
|
565
|
+
/// whether closing succeeded.
|
|
566
|
+
pub async fn disconnect(&self) -> Result<()> {
|
|
567
|
+
self.connection.lock().await.disconnect().await
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
pub async fn close(&self) -> Result<()> {
|
|
571
|
+
self.disconnect().await
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#[derive(Debug)]
|
|
576
|
+
struct Connection {
|
|
577
|
+
transport: Transport,
|
|
578
|
+
connection_id: String,
|
|
579
|
+
quack_version: u64,
|
|
580
|
+
query_counter: AtomicU64,
|
|
581
|
+
query_uuid_counter: AtomicU64,
|
|
582
|
+
state: Arc<ConnectionState>,
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
impl Connection {
|
|
586
|
+
async fn connect(
|
|
587
|
+
base_url: String,
|
|
588
|
+
http: reqwest::Client,
|
|
589
|
+
timeout: Duration,
|
|
590
|
+
options: QuackClientOptions,
|
|
591
|
+
) -> Result<(Self, QuackConnectionInfo)> {
|
|
592
|
+
let min_version = options
|
|
593
|
+
.min_supported_quack_version
|
|
594
|
+
.unwrap_or(MIN_QUACK_VERSION);
|
|
595
|
+
let max_version = options
|
|
596
|
+
.max_supported_quack_version
|
|
597
|
+
.unwrap_or(MAX_QUACK_VERSION);
|
|
598
|
+
if min_version > max_version {
|
|
599
|
+
return Err(QuackError::protocol(format!(
|
|
600
|
+
"minimum Quack protocol version {min_version} exceeds maximum {max_version}"
|
|
601
|
+
)));
|
|
602
|
+
}
|
|
603
|
+
let versions = [QUACK_V3, QUACK_V1]
|
|
604
|
+
.into_iter()
|
|
605
|
+
.filter(|version| (min_version..=max_version).contains(version))
|
|
606
|
+
.collect::<Vec<_>>();
|
|
607
|
+
if versions.is_empty() {
|
|
608
|
+
return Err(QuackError::protocol(format!(
|
|
609
|
+
"supported Quack protocol versions are {QUACK_V1} and {QUACK_V3}, requested range was {min_version}..={max_version}"
|
|
610
|
+
)));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let heartbeat_timeout_seconds = options
|
|
614
|
+
.heartbeat_timeout
|
|
615
|
+
.unwrap_or_else(|| Duration::from_secs(DEFAULT_HEARTBEAT_TIMEOUT_SECS))
|
|
616
|
+
.as_secs();
|
|
617
|
+
if versions.contains(&QUACK_V3) {
|
|
618
|
+
validate_heartbeat_timeout(heartbeat_timeout_seconds)?;
|
|
619
|
+
}
|
|
620
|
+
let transport = Transport {
|
|
621
|
+
base_url,
|
|
622
|
+
http,
|
|
623
|
+
headers: header_map(&options.headers)?,
|
|
624
|
+
timeout,
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
let mut previous_error = None;
|
|
628
|
+
for (index, quack_version) in versions.iter().copied().enumerate() {
|
|
629
|
+
let response = transport
|
|
630
|
+
.send(
|
|
631
|
+
&QuackMessage::ConnectionRequest {
|
|
632
|
+
header: MessageHeader::new(MessageType::ConnectionRequest),
|
|
633
|
+
auth_string: options.auth_token.clone(),
|
|
634
|
+
client_duckdb_version: options.client_duckdb_version.clone(),
|
|
635
|
+
client_platform: Some(
|
|
636
|
+
options
|
|
637
|
+
.client_platform
|
|
638
|
+
.clone()
|
|
639
|
+
.unwrap_or_else(|| "quack-rust".to_string()),
|
|
640
|
+
),
|
|
641
|
+
min_supported_quack_version: quack_version,
|
|
642
|
+
max_supported_quack_version: quack_version,
|
|
643
|
+
client_id: options.client_id.clone(),
|
|
644
|
+
heartbeat_timeout_seconds,
|
|
645
|
+
},
|
|
646
|
+
quack_version,
|
|
647
|
+
)
|
|
648
|
+
.await;
|
|
649
|
+
|
|
650
|
+
let response = match response {
|
|
651
|
+
Ok(response) => response,
|
|
652
|
+
Err(error)
|
|
653
|
+
if index + 1 < versions.len()
|
|
654
|
+
&& quack_version == QUACK_V3
|
|
655
|
+
&& is_version_negotiation_error(&error) =>
|
|
656
|
+
{
|
|
657
|
+
previous_error = Some(error);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
Err(error) if previous_error.is_some() && is_version_negotiation_error(&error) => {
|
|
661
|
+
return Err(previous_error.expect("checked above"));
|
|
662
|
+
}
|
|
663
|
+
Err(error) => return Err(error),
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
match response {
|
|
667
|
+
QuackMessage::ConnectionResponse {
|
|
668
|
+
header,
|
|
669
|
+
server_duckdb_version,
|
|
670
|
+
server_platform,
|
|
671
|
+
quack_version: selected_version,
|
|
672
|
+
heartbeat_timeout_seconds,
|
|
673
|
+
} => {
|
|
674
|
+
let connection_id = header.connection_id.ok_or_else(|| {
|
|
675
|
+
QuackError::protocol("CONNECTION_RESPONSE did not include a connection id")
|
|
676
|
+
})?;
|
|
677
|
+
if selected_version != Some(quack_version) {
|
|
678
|
+
return Err(QuackError::protocol(format!(
|
|
679
|
+
"server selected Quack protocol version {selected_version:?}, expected {quack_version}"
|
|
680
|
+
)));
|
|
681
|
+
}
|
|
682
|
+
let heartbeat_timeout = if quack_version == QUACK_V3 {
|
|
683
|
+
let seconds = heartbeat_timeout_seconds.ok_or_else(|| {
|
|
684
|
+
QuackError::protocol(
|
|
685
|
+
"protocol v3 CONNECTION_RESPONSE did not include a heartbeat timeout",
|
|
686
|
+
)
|
|
687
|
+
})?;
|
|
688
|
+
validate_heartbeat_timeout(seconds)?;
|
|
689
|
+
Some(Duration::from_secs(seconds))
|
|
690
|
+
} else {
|
|
691
|
+
None
|
|
692
|
+
};
|
|
693
|
+
let info = QuackConnectionInfo {
|
|
694
|
+
server_duckdb_version,
|
|
695
|
+
server_platform,
|
|
696
|
+
quack_version: Some(quack_version),
|
|
697
|
+
heartbeat_timeout,
|
|
698
|
+
};
|
|
699
|
+
return Ok((
|
|
700
|
+
Self {
|
|
701
|
+
transport,
|
|
702
|
+
connection_id,
|
|
703
|
+
quack_version,
|
|
704
|
+
query_counter: AtomicU64::new(1),
|
|
705
|
+
query_uuid_counter: AtomicU64::new(1),
|
|
706
|
+
state: Arc::new(ConnectionState::new()),
|
|
707
|
+
},
|
|
708
|
+
info,
|
|
709
|
+
));
|
|
710
|
+
}
|
|
711
|
+
other => {
|
|
712
|
+
return Err(QuackError::protocol(format!(
|
|
713
|
+
"expected CONNECTION_RESPONSE, got {:?}",
|
|
714
|
+
other.message_type()
|
|
715
|
+
)));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
Err(previous_error.unwrap_or_else(|| {
|
|
721
|
+
QuackError::protocol("no compatible Quack protocol version was attempted")
|
|
722
|
+
}))
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Records wire failures against the session before returning them, so a
|
|
726
|
+
// pool can retire the connection instead of handing it on.
|
|
727
|
+
async fn send(&self, message: &QuackMessage) -> Result<QuackMessage> {
|
|
728
|
+
match self.transport.send(message, self.quack_version).await {
|
|
729
|
+
Ok(response) => {
|
|
730
|
+
self.state.record_success();
|
|
731
|
+
Ok(response)
|
|
732
|
+
}
|
|
733
|
+
Err(err) => {
|
|
734
|
+
if err.is_connection_fatal() {
|
|
735
|
+
self.state.degrade();
|
|
736
|
+
}
|
|
737
|
+
Err(err)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async fn prepare(&self, sql: &str) -> Result<QuackMessage> {
|
|
743
|
+
self.ensure_open()?;
|
|
744
|
+
let query_uuid = HugeIntParts {
|
|
745
|
+
upper: 0,
|
|
746
|
+
lower: self.query_uuid_counter.fetch_add(1, Ordering::Relaxed),
|
|
747
|
+
};
|
|
748
|
+
let message = QuackMessage::PrepareRequest {
|
|
749
|
+
header: self.scoped_header(MessageType::PrepareRequest),
|
|
750
|
+
sql: sql.to_string(),
|
|
751
|
+
query_uuid: (self.quack_version == QUACK_V3).then_some(query_uuid),
|
|
752
|
+
inline_rows: None,
|
|
753
|
+
};
|
|
754
|
+
self.send(&message).await
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async fn fetch(
|
|
758
|
+
&self,
|
|
759
|
+
result_uuid: HugeIntParts,
|
|
760
|
+
batch_index: u64,
|
|
761
|
+
ack_index: u64,
|
|
762
|
+
) -> Result<QuackMessage> {
|
|
763
|
+
self.ensure_open()?;
|
|
764
|
+
let message = QuackMessage::FetchRequest {
|
|
765
|
+
header: self.scoped_header(MessageType::FetchRequest),
|
|
766
|
+
result_uuid,
|
|
767
|
+
batch_index: (self.quack_version == QUACK_V3).then_some(batch_index),
|
|
768
|
+
ack_index: (self.quack_version == QUACK_V3).then_some(ack_index),
|
|
769
|
+
};
|
|
770
|
+
self.send(&message).await
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async fn append(
|
|
774
|
+
&self,
|
|
775
|
+
table_name: String,
|
|
776
|
+
schema_name: Option<String>,
|
|
777
|
+
chunk: DataChunk,
|
|
778
|
+
) -> Result<()> {
|
|
779
|
+
self.ensure_open()?;
|
|
780
|
+
if self.quack_version != QUACK_V1 {
|
|
781
|
+
return Err(QuackError::protocol(
|
|
782
|
+
"append is only supported by Quack protocol v1; protocol v3 uses SEND_DATA",
|
|
783
|
+
));
|
|
784
|
+
}
|
|
785
|
+
let message = QuackMessage::AppendRequest {
|
|
786
|
+
header: self.scoped_header(MessageType::SendDataRequest),
|
|
787
|
+
schema_name,
|
|
788
|
+
table_name,
|
|
789
|
+
append_chunk: chunk,
|
|
790
|
+
};
|
|
791
|
+
let response = self.send(&message).await?;
|
|
792
|
+
self.expect_success(response)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async fn disconnect(&self) -> Result<()> {
|
|
796
|
+
if self.ensure_open().is_err() {
|
|
797
|
+
return Ok(());
|
|
798
|
+
}
|
|
799
|
+
let message = QuackMessage::Disconnect {
|
|
800
|
+
header: self.scoped_header(MessageType::DisconnectMessage),
|
|
801
|
+
};
|
|
802
|
+
let response = self.send(&message).await?;
|
|
803
|
+
self.expect_success(response)?;
|
|
804
|
+
self.state.close();
|
|
805
|
+
Ok(())
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
fn expect_success(&self, response: QuackMessage) -> Result<()> {
|
|
809
|
+
match response {
|
|
810
|
+
QuackMessage::SuccessResponse { .. } => Ok(()),
|
|
811
|
+
other => Err(self.protocol_failure(format!(
|
|
812
|
+
"expected SUCCESS_RESPONSE, got {:?}",
|
|
813
|
+
other.message_type()
|
|
814
|
+
))),
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// A response that decoded but does not fit the protocol: the wrong message
|
|
819
|
+
// type, or inconsistent batch metadata. The session's state is unknown
|
|
820
|
+
// afterwards, so it is retired the way a transport failure is.
|
|
821
|
+
fn protocol_failure(&self, message: impl Into<String>) -> QuackError {
|
|
822
|
+
self.state.degrade();
|
|
823
|
+
QuackError::protocol(message)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
fn scoped_header(&self, message_type: MessageType) -> MessageHeader {
|
|
827
|
+
let query_id = self.query_counter.fetch_add(1, Ordering::Relaxed);
|
|
828
|
+
MessageHeader::new(message_type)
|
|
829
|
+
.with_connection(self.connection_id.clone())
|
|
830
|
+
.with_client_query_id(query_id)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
fn ensure_open(&self) -> Result<()> {
|
|
834
|
+
if self.state.is_closed() {
|
|
835
|
+
Err(QuackError::protocol("Quack client is not connected"))
|
|
836
|
+
} else {
|
|
837
|
+
Ok(())
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Best-effort session cleanup: without a DISCONNECT the server keeps the
|
|
843
|
+
// session - and its result cursor - for the connection id we are about to
|
|
844
|
+
// forget. `Drop` cannot await, so the message goes out on a detached task,
|
|
845
|
+
// which means it is delivered only while the Tokio runtime outlives the drop.
|
|
846
|
+
// `QuackClient::disconnect` remains the deterministic path.
|
|
847
|
+
impl Drop for Connection {
|
|
848
|
+
fn drop(&mut self) {
|
|
849
|
+
if self.state.is_closed() {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
let connection_id = self.connection_id.clone();
|
|
853
|
+
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
|
854
|
+
tracing::debug!(
|
|
855
|
+
connection_id,
|
|
856
|
+
"quack session dropped outside a Tokio runtime; server session left open"
|
|
857
|
+
);
|
|
858
|
+
return;
|
|
859
|
+
};
|
|
860
|
+
let message = QuackMessage::Disconnect {
|
|
861
|
+
header: self.scoped_header(MessageType::DisconnectMessage),
|
|
862
|
+
};
|
|
863
|
+
let quack_version = self.quack_version;
|
|
864
|
+
let mut transport = self.transport.clone();
|
|
865
|
+
transport.timeout = transport.timeout.min(DISCONNECT_ON_DROP_TIMEOUT);
|
|
866
|
+
runtime.spawn(async move {
|
|
867
|
+
match transport.send(&message, quack_version).await {
|
|
868
|
+
Ok(_) => tracing::debug!(connection_id, "quack session closed on drop"),
|
|
869
|
+
Err(err) => tracing::debug!(
|
|
870
|
+
connection_id,
|
|
871
|
+
%err,
|
|
872
|
+
"quack DISCONNECT on drop failed; server session left open"
|
|
873
|
+
),
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Everything needed to talk to the server, minus the session. Cheap to clone -
|
|
880
|
+
// `reqwest::Client` is itself a handle - so a dropped connection can hand it to
|
|
881
|
+
// the background task that sends its DISCONNECT.
|
|
882
|
+
#[derive(Clone, Debug)]
|
|
883
|
+
struct Transport {
|
|
884
|
+
base_url: String,
|
|
885
|
+
http: reqwest::Client,
|
|
886
|
+
headers: HeaderMap,
|
|
887
|
+
timeout: Duration,
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
impl Transport {
|
|
891
|
+
async fn send(&self, message: &QuackMessage, quack_version: u64) -> Result<QuackMessage> {
|
|
892
|
+
let bytes = encode_message_for_version(message, quack_version)?;
|
|
893
|
+
let mut request = self
|
|
894
|
+
.http
|
|
895
|
+
.post(format!("{}{}", self.base_url, QUACK_ENDPOINT))
|
|
896
|
+
.header(ACCEPT, HeaderValue::from_static(DUCKDB_MIME_TYPE))
|
|
897
|
+
.header(CONTENT_TYPE, HeaderValue::from_static(DUCKDB_MIME_TYPE))
|
|
898
|
+
.body(bytes);
|
|
899
|
+
if !self.headers.is_empty() {
|
|
900
|
+
request = request.headers(self.headers.clone());
|
|
901
|
+
}
|
|
902
|
+
request = request.timeout(self.timeout);
|
|
903
|
+
let mut response = request.send().await?;
|
|
904
|
+
if !response.status().is_success() {
|
|
905
|
+
let status = response.status();
|
|
906
|
+
let mut body = Vec::new();
|
|
907
|
+
while let Some(chunk) = response.chunk().await? {
|
|
908
|
+
if chunk.len() > 16 * 1024 - body.len() {
|
|
909
|
+
body.clear();
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
body.extend_from_slice(&chunk);
|
|
913
|
+
}
|
|
914
|
+
if let Some(error) = crate::ProxyError::decode(status.as_u16(), &body) {
|
|
915
|
+
return Err(error.into());
|
|
916
|
+
}
|
|
917
|
+
return Err(QuackError::protocol(format!(
|
|
918
|
+
"Quack HTTP request failed with {} {}",
|
|
919
|
+
response.status().as_u16(),
|
|
920
|
+
response.status().canonical_reason().unwrap_or("")
|
|
921
|
+
)));
|
|
922
|
+
}
|
|
923
|
+
let bytes = response.bytes().await?;
|
|
924
|
+
let decoded = decode_message_for_version(&bytes, quack_version)?;
|
|
925
|
+
if let QuackMessage::ErrorResponse { message, .. } = decoded {
|
|
926
|
+
return Err(QuackError::server(message));
|
|
927
|
+
}
|
|
928
|
+
Ok(decoded)
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
#[derive(Debug)]
|
|
933
|
+
struct HeartbeatGuard {
|
|
934
|
+
task: tokio::task::JoinHandle<()>,
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
impl HeartbeatGuard {
|
|
938
|
+
fn start(connection: &Connection, heartbeat_timeout: Duration) -> Self {
|
|
939
|
+
let transport = connection.transport.clone();
|
|
940
|
+
let connection_id = connection.connection_id.clone();
|
|
941
|
+
let state = Arc::clone(&connection.state);
|
|
942
|
+
let interval = (heartbeat_timeout / 3).max(Duration::from_millis(100));
|
|
943
|
+
let task = tokio::spawn(async move {
|
|
944
|
+
loop {
|
|
945
|
+
tokio::time::sleep(interval).await;
|
|
946
|
+
if state.is_closed() {
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
let heartbeat = QuackMessage::HeartbeatRequest {
|
|
950
|
+
header: MessageHeader::new(MessageType::HeartbeatRequest)
|
|
951
|
+
.with_connection(connection_id.clone()),
|
|
952
|
+
};
|
|
953
|
+
match transport.send(&heartbeat, QUACK_V3).await {
|
|
954
|
+
Ok(QuackMessage::SuccessResponse { .. }) => state.record_success(),
|
|
955
|
+
Ok(other) => {
|
|
956
|
+
tracing::warn!(
|
|
957
|
+
response = ?other.message_type(),
|
|
958
|
+
"Quack heartbeat returned an unexpected response; closing connection"
|
|
959
|
+
);
|
|
960
|
+
state.close();
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
Err(error) if state.close_if_expired(heartbeat_timeout) => {
|
|
964
|
+
tracing::warn!(
|
|
965
|
+
%error,
|
|
966
|
+
timeout_seconds = heartbeat_timeout.as_secs(),
|
|
967
|
+
"Quack heartbeat lease expired; closing connection"
|
|
968
|
+
);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
Err(error) => {
|
|
972
|
+
tracing::debug!(
|
|
973
|
+
%error,
|
|
974
|
+
"Quack heartbeat failed transiently; retrying before lease expiry"
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
Self { task }
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Connection status, shared with the owning `QuackClient` so it can be read
|
|
985
|
+
// without taking the connection mutex.
|
|
986
|
+
#[derive(Debug)]
|
|
987
|
+
struct ConnectionState {
|
|
988
|
+
lease: StdMutex<ConnectionLease>,
|
|
989
|
+
// A wire failure was seen on this session. It may still be open on the
|
|
990
|
+
// server, but a pool should not hand it to the next caller.
|
|
991
|
+
degraded: AtomicBool,
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
#[derive(Debug)]
|
|
995
|
+
struct ConnectionLease {
|
|
996
|
+
last_successful_activity: Instant,
|
|
997
|
+
closed: bool,
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
impl ConnectionState {
|
|
1001
|
+
fn new() -> Self {
|
|
1002
|
+
Self::new_at(Instant::now())
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
fn new_at(now: Instant) -> Self {
|
|
1006
|
+
Self {
|
|
1007
|
+
lease: StdMutex::new(ConnectionLease {
|
|
1008
|
+
last_successful_activity: now,
|
|
1009
|
+
closed: false,
|
|
1010
|
+
}),
|
|
1011
|
+
degraded: AtomicBool::new(false),
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
fn record_success(&self) {
|
|
1016
|
+
self.record_success_at(Instant::now());
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
fn record_success_at(&self, now: Instant) {
|
|
1020
|
+
let mut lease = self.lease();
|
|
1021
|
+
if !lease.closed {
|
|
1022
|
+
lease.last_successful_activity = now;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
fn close_if_expired(&self, timeout: Duration) -> bool {
|
|
1027
|
+
self.close_if_expired_at(Instant::now(), timeout)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
fn close_if_expired_at(&self, now: Instant, timeout: Duration) -> bool {
|
|
1031
|
+
let mut lease = self.lease();
|
|
1032
|
+
if lease.closed || now.saturating_duration_since(lease.last_successful_activity) >= timeout
|
|
1033
|
+
{
|
|
1034
|
+
lease.closed = true;
|
|
1035
|
+
}
|
|
1036
|
+
lease.closed
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
fn close(&self) {
|
|
1040
|
+
self.lease().closed = true;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
fn is_closed(&self) -> bool {
|
|
1044
|
+
self.lease().closed
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
fn degrade(&self) {
|
|
1048
|
+
self.degraded.store(true, Ordering::Relaxed);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
fn is_reusable(&self) -> bool {
|
|
1052
|
+
!self.is_closed() && !self.degraded.load(Ordering::Relaxed)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
fn lease(&self) -> std::sync::MutexGuard<'_, ConnectionLease> {
|
|
1056
|
+
self.lease
|
|
1057
|
+
.lock()
|
|
1058
|
+
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
impl Drop for HeartbeatGuard {
|
|
1063
|
+
fn drop(&mut self) {
|
|
1064
|
+
self.task.abort();
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
fn header_map(headers: &[(String, String)]) -> Result<HeaderMap> {
|
|
1069
|
+
let mut map = HeaderMap::with_capacity(headers.len());
|
|
1070
|
+
for (name, value) in headers {
|
|
1071
|
+
let name = HeaderName::try_from(name.as_str())
|
|
1072
|
+
.map_err(|_| QuackError::protocol(format!("invalid HTTP header name {name}")))?;
|
|
1073
|
+
let value = HeaderValue::from_str(value)
|
|
1074
|
+
.map_err(|_| QuackError::protocol(format!("invalid value for HTTP header {name}")))?;
|
|
1075
|
+
map.append(name, value);
|
|
1076
|
+
}
|
|
1077
|
+
Ok(map)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
fn validate_heartbeat_timeout(seconds: u64) -> Result<()> {
|
|
1081
|
+
if !(1..=MAX_HEARTBEAT_TIMEOUT_SECS).contains(&seconds) {
|
|
1082
|
+
return Err(QuackError::protocol(format!(
|
|
1083
|
+
"heartbeat timeout must be between 1 and {MAX_HEARTBEAT_TIMEOUT_SECS} seconds"
|
|
1084
|
+
)));
|
|
1085
|
+
}
|
|
1086
|
+
Ok(())
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
pub(crate) fn is_version_negotiation_error(error: &QuackError) -> bool {
|
|
1090
|
+
let message = match error {
|
|
1091
|
+
QuackError::Protocol(message) | QuackError::Server(message) => message,
|
|
1092
|
+
_ => return false,
|
|
1093
|
+
};
|
|
1094
|
+
let message = message.to_ascii_lowercase();
|
|
1095
|
+
message.contains("quack version")
|
|
1096
|
+
|| message.contains("protocol version")
|
|
1097
|
+
|| message.contains("http request failed with 500")
|
|
1098
|
+
|| message.contains("deserialize")
|
|
1099
|
+
|| message.contains("end of object")
|
|
1100
|
+
|| message.contains("unexpected field")
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// The HTTP client for one session. An HTTPS session stays on HTTPS: a
|
|
1104
|
+
// redirect to an `http://` URL is an error rather than a silent downgrade that
|
|
1105
|
+
// would send queries, and with a pin skip the pinned certificate, in plaintext.
|
|
1106
|
+
fn http_client(
|
|
1107
|
+
ssl: bool,
|
|
1108
|
+
pin: Option<CertificatePin>,
|
|
1109
|
+
timeout: Duration,
|
|
1110
|
+
) -> Result<reqwest::Client> {
|
|
1111
|
+
let mut http = reqwest::Client::builder()
|
|
1112
|
+
.connect_timeout(DEFAULT_QUACK_CONNECT_TIMEOUT.min(timeout))
|
|
1113
|
+
.pool_max_idle_per_host(0)
|
|
1114
|
+
.https_only(ssl)
|
|
1115
|
+
.timeout(timeout);
|
|
1116
|
+
if let Some(pin) = pin {
|
|
1117
|
+
http = http.use_preconfigured_tls(pinned_client_config(pin)?);
|
|
1118
|
+
}
|
|
1119
|
+
Ok(http.build()?)
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
pub(crate) fn parse_quack_uri(input: &str, ssl_override: Option<bool>) -> Result<ParsedQuackUri> {
|
|
1123
|
+
let uri = input.trim();
|
|
1124
|
+
if uri.is_empty() {
|
|
1125
|
+
return Err(QuackError::protocol("Quack URI is empty"));
|
|
1126
|
+
}
|
|
1127
|
+
if uri.starts_with("http://") || uri.starts_with("https://") {
|
|
1128
|
+
let url = url::Url::parse(uri)?;
|
|
1129
|
+
let ssl = url.scheme() == "https";
|
|
1130
|
+
let port = url
|
|
1131
|
+
.port_or_known_default()
|
|
1132
|
+
.unwrap_or(if ssl { 443 } else { 80 });
|
|
1133
|
+
let host = url
|
|
1134
|
+
.host_str()
|
|
1135
|
+
.ok_or_else(|| QuackError::protocol(format!("invalid Quack URI host {input}")))?
|
|
1136
|
+
.to_string();
|
|
1137
|
+
let host_for_base = if host.contains(':') {
|
|
1138
|
+
format!("[{host}]")
|
|
1139
|
+
} else {
|
|
1140
|
+
host.clone()
|
|
1141
|
+
};
|
|
1142
|
+
return Ok(ParsedQuackUri {
|
|
1143
|
+
base_url: format!("{}://{}:{port}", url.scheme(), host_for_base),
|
|
1144
|
+
host,
|
|
1145
|
+
port,
|
|
1146
|
+
ssl,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
let rest = uri
|
|
1151
|
+
.strip_prefix("quack://")
|
|
1152
|
+
.or_else(|| uri.strip_prefix("quack:"))
|
|
1153
|
+
.unwrap_or(uri);
|
|
1154
|
+
if rest.is_empty() {
|
|
1155
|
+
return Err(QuackError::protocol(format!("invalid Quack URI {input}")));
|
|
1156
|
+
}
|
|
1157
|
+
let (host, port) = parse_host_port(rest)?;
|
|
1158
|
+
let ssl = ssl_override.unwrap_or(false);
|
|
1159
|
+
let protocol = if ssl { "https" } else { "http" };
|
|
1160
|
+
let host_for_base = if host.contains(':') && !host.starts_with('[') {
|
|
1161
|
+
format!("[{host}]")
|
|
1162
|
+
} else {
|
|
1163
|
+
host.clone()
|
|
1164
|
+
};
|
|
1165
|
+
Ok(ParsedQuackUri {
|
|
1166
|
+
base_url: format!("{protocol}://{host_for_base}:{port}"),
|
|
1167
|
+
host,
|
|
1168
|
+
port,
|
|
1169
|
+
ssl,
|
|
1170
|
+
})
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
fn parse_host_port(value: &str) -> Result<(String, u16)> {
|
|
1174
|
+
if let Some(rest) = value.strip_prefix('[') {
|
|
1175
|
+
let end = rest
|
|
1176
|
+
.find(']')
|
|
1177
|
+
.ok_or_else(|| QuackError::protocol(format!("invalid IPv6 Quack URI host {value}")))?;
|
|
1178
|
+
let host = rest[..end].to_string();
|
|
1179
|
+
let suffix = &rest[end + 1..];
|
|
1180
|
+
let port = if let Some(port) = suffix.strip_prefix(':') {
|
|
1181
|
+
parse_port(port)?
|
|
1182
|
+
} else {
|
|
1183
|
+
DEFAULT_QUACK_PORT
|
|
1184
|
+
};
|
|
1185
|
+
return Ok((host, port));
|
|
1186
|
+
}
|
|
1187
|
+
let colon_count = value.chars().filter(|ch| *ch == ':').count();
|
|
1188
|
+
match colon_count {
|
|
1189
|
+
0 => Ok((value.to_string(), DEFAULT_QUACK_PORT)),
|
|
1190
|
+
1 => {
|
|
1191
|
+
let (host, port) = value
|
|
1192
|
+
.split_once(':')
|
|
1193
|
+
.ok_or_else(|| QuackError::protocol(format!("invalid Quack URI {value}")))?;
|
|
1194
|
+
if host.is_empty() {
|
|
1195
|
+
return Err(QuackError::protocol(format!(
|
|
1196
|
+
"invalid Quack URI host {value}"
|
|
1197
|
+
)));
|
|
1198
|
+
}
|
|
1199
|
+
Ok((host.to_string(), parse_port(port)?))
|
|
1200
|
+
}
|
|
1201
|
+
_ => Err(QuackError::protocol(format!(
|
|
1202
|
+
"IPv6 Quack URI hosts must be enclosed in []: {value}"
|
|
1203
|
+
))),
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
fn parse_port(value: &str) -> Result<u16> {
|
|
1208
|
+
let port = value
|
|
1209
|
+
.parse::<u16>()
|
|
1210
|
+
.map_err(|_| QuackError::protocol(format!("invalid Quack URI port {value}")))?;
|
|
1211
|
+
if port == 0 {
|
|
1212
|
+
return Err(QuackError::protocol(format!(
|
|
1213
|
+
"invalid Quack URI port {value}"
|
|
1214
|
+
)));
|
|
1215
|
+
}
|
|
1216
|
+
Ok(port)
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
fn attach_column_names(chunks: &mut [DataChunk], names: &[String]) {
|
|
1220
|
+
for chunk in chunks {
|
|
1221
|
+
chunk.column_names = Some(names.to_vec());
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
#[cfg(test)]
|
|
1226
|
+
mod tests {
|
|
1227
|
+
use super::*;
|
|
1228
|
+
|
|
1229
|
+
// A connection whose session was never negotiated: enough to exercise
|
|
1230
|
+
// `Drop`, the one path here that runs without a Quack server.
|
|
1231
|
+
fn test_connection(base_url: &str) -> Connection {
|
|
1232
|
+
Connection {
|
|
1233
|
+
transport: Transport {
|
|
1234
|
+
base_url: base_url.to_string(),
|
|
1235
|
+
http: reqwest::Client::new(),
|
|
1236
|
+
headers: HeaderMap::new(),
|
|
1237
|
+
timeout: Duration::from_millis(500),
|
|
1238
|
+
},
|
|
1239
|
+
connection_id: "test-connection".to_string(),
|
|
1240
|
+
quack_version: QUACK_V1,
|
|
1241
|
+
query_counter: AtomicU64::new(1),
|
|
1242
|
+
query_uuid_counter: AtomicU64::new(1),
|
|
1243
|
+
state: Arc::new(ConnectionState::new()),
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// A socket that reports whether anything tried to reach it, so the
|
|
1248
|
+
// background DISCONNECT can be observed without a Quack server.
|
|
1249
|
+
fn listening_socket() -> (std::net::TcpListener, String) {
|
|
1250
|
+
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
1251
|
+
listener.set_nonblocking(true).expect("nonblocking");
|
|
1252
|
+
let base_url = format!("http://{}", listener.local_addr().expect("addr"));
|
|
1253
|
+
(listener, base_url)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async fn connected_within(listener: &std::net::TcpListener, window: Duration) -> bool {
|
|
1257
|
+
let deadline = Instant::now() + window;
|
|
1258
|
+
while Instant::now() < deadline {
|
|
1259
|
+
match listener.accept() {
|
|
1260
|
+
Ok(_) => return true,
|
|
1261
|
+
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
|
1262
|
+
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
1263
|
+
}
|
|
1264
|
+
Err(err) => panic!("accept failed: {err}"),
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
false
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
#[tokio::test]
|
|
1271
|
+
async fn https_sessions_refuse_redirects_to_http() {
|
|
1272
|
+
use crate::tls::test_server::{self, V1_CERT, V1_CERT_FINGERPRINT};
|
|
1273
|
+
|
|
1274
|
+
let (plain, plain_url) = listening_socket();
|
|
1275
|
+
let port = test_server::serve(
|
|
1276
|
+
V1_CERT,
|
|
1277
|
+
format!(
|
|
1278
|
+
"HTTP/1.1 307 Temporary Redirect\r\nlocation: {plain_url}/\r\n\
|
|
1279
|
+
content-length: 0\r\nconnection: close\r\n\r\n"
|
|
1280
|
+
),
|
|
1281
|
+
)
|
|
1282
|
+
.await;
|
|
1283
|
+
let pin = Some(CertificatePin::parse(V1_CERT_FINGERPRINT).unwrap());
|
|
1284
|
+
let post = |ssl| async move {
|
|
1285
|
+
http_client(ssl, pin, Duration::from_millis(500))
|
|
1286
|
+
.unwrap()
|
|
1287
|
+
.post(format!("https://127.0.0.1:{port}/"))
|
|
1288
|
+
.body("SELECT 42")
|
|
1289
|
+
.send()
|
|
1290
|
+
.await
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
let err = post(true).await.unwrap_err();
|
|
1294
|
+
assert!(err.is_redirect(), "{err:?}");
|
|
1295
|
+
assert!(!connected_within(&plain, Duration::from_millis(100)).await);
|
|
1296
|
+
|
|
1297
|
+
// Without `https_only` the same request is followed onto plain HTTP.
|
|
1298
|
+
let _ = post(false).await;
|
|
1299
|
+
assert!(connected_within(&plain, Duration::from_millis(500)).await);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
#[test]
|
|
1303
|
+
fn debug_output_redacts_the_auth_token() {
|
|
1304
|
+
let options = QuackClientOptions {
|
|
1305
|
+
auth_token: Some("super_secret".to_string()),
|
|
1306
|
+
client_id: Some("visible".to_string()),
|
|
1307
|
+
..Default::default()
|
|
1308
|
+
};
|
|
1309
|
+
let rendered = format!("{options:?}");
|
|
1310
|
+
assert!(!rendered.contains("super_secret"), "{rendered}");
|
|
1311
|
+
assert!(rendered.contains("<redacted>"), "{rendered}");
|
|
1312
|
+
assert!(rendered.contains("visible"), "{rendered}");
|
|
1313
|
+
assert!(
|
|
1314
|
+
format!("{:?}", QuackClientOptions::default()).contains("auth_token: None"),
|
|
1315
|
+
"an absent token is shown as absent"
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
#[tokio::test]
|
|
1320
|
+
async fn a_pin_that_cannot_apply_fails_before_connecting() {
|
|
1321
|
+
let (listener, base_url) = listening_socket();
|
|
1322
|
+
let address = base_url.trim_start_matches("http://").to_string();
|
|
1323
|
+
let pin = "sha256:".to_string() + &"AB".repeat(32);
|
|
1324
|
+
let cases = [
|
|
1325
|
+
(
|
|
1326
|
+
address.clone(),
|
|
1327
|
+
Some(false),
|
|
1328
|
+
pin.clone(),
|
|
1329
|
+
"ssl is set to false",
|
|
1330
|
+
),
|
|
1331
|
+
(base_url.clone(), None, pin, "is an http URL"),
|
|
1332
|
+
(address, None, "AB:CD".to_string(), "64 hex digits"),
|
|
1333
|
+
];
|
|
1334
|
+
for (uri, ssl, fingerprint, expected) in cases {
|
|
1335
|
+
let options = QuackClientOptions {
|
|
1336
|
+
ssl,
|
|
1337
|
+
ssl_fingerprint: Some(fingerprint),
|
|
1338
|
+
..Default::default()
|
|
1339
|
+
};
|
|
1340
|
+
let err = QuackClient::connect(&uri, options).await.unwrap_err();
|
|
1341
|
+
assert!(err.to_string().contains(expected), "{uri}: {err}");
|
|
1342
|
+
}
|
|
1343
|
+
assert!(
|
|
1344
|
+
!connected_within(&listener, Duration::from_millis(100)).await,
|
|
1345
|
+
"an unusable pin should be rejected before any request"
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
#[test]
|
|
1350
|
+
fn default_options_have_request_timeout() {
|
|
1351
|
+
assert_eq!(
|
|
1352
|
+
QuackClientOptions::default().timeout,
|
|
1353
|
+
Some(DEFAULT_QUACK_REQUEST_TIMEOUT)
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
#[test]
|
|
1358
|
+
fn header_pairs_are_converted_and_validated() {
|
|
1359
|
+
let map = header_map(&[
|
|
1360
|
+
("x-trace".to_string(), "abc".to_string()),
|
|
1361
|
+
("x-trace".to_string(), "def".to_string()),
|
|
1362
|
+
])
|
|
1363
|
+
.expect("valid headers");
|
|
1364
|
+
assert_eq!(map.get_all("x-trace").iter().count(), 2);
|
|
1365
|
+
|
|
1366
|
+
assert!(header_map(&[("bad name".to_string(), "value".to_string())]).is_err());
|
|
1367
|
+
assert!(header_map(&[("x-trace".to_string(), "bad\nvalue".to_string())]).is_err());
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
#[test]
|
|
1371
|
+
fn heartbeat_timeout_is_bounded() {
|
|
1372
|
+
assert!(validate_heartbeat_timeout(1).is_ok());
|
|
1373
|
+
assert!(validate_heartbeat_timeout(MAX_HEARTBEAT_TIMEOUT_SECS).is_ok());
|
|
1374
|
+
assert!(validate_heartbeat_timeout(0).is_err());
|
|
1375
|
+
assert!(validate_heartbeat_timeout(MAX_HEARTBEAT_TIMEOUT_SECS + 1).is_err());
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
#[test]
|
|
1379
|
+
fn connection_state_expires_only_after_the_last_successful_activity() {
|
|
1380
|
+
let started = Instant::now();
|
|
1381
|
+
let timeout = Duration::from_secs(30);
|
|
1382
|
+
let state = ConnectionState::new_at(started);
|
|
1383
|
+
|
|
1384
|
+
assert!(!state.close_if_expired_at(started + Duration::from_secs(29), timeout));
|
|
1385
|
+
|
|
1386
|
+
state.record_success_at(started + Duration::from_secs(29));
|
|
1387
|
+
assert!(!state.close_if_expired_at(started + Duration::from_secs(58), timeout));
|
|
1388
|
+
assert!(state.close_if_expired_at(started + Duration::from_secs(59), timeout));
|
|
1389
|
+
assert!(state.is_closed());
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
#[test]
|
|
1393
|
+
fn a_degraded_connection_is_not_reusable() {
|
|
1394
|
+
let state = ConnectionState::new();
|
|
1395
|
+
assert!(state.is_reusable());
|
|
1396
|
+
state.degrade();
|
|
1397
|
+
assert!(!state.is_reusable());
|
|
1398
|
+
assert!(!state.is_closed(), "degraded is not the same as closed");
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
#[tokio::test]
|
|
1402
|
+
async fn dropping_an_open_connection_sends_a_disconnect() {
|
|
1403
|
+
let (listener, base_url) = listening_socket();
|
|
1404
|
+
drop(test_connection(&base_url));
|
|
1405
|
+
assert!(
|
|
1406
|
+
connected_within(&listener, Duration::from_secs(5)).await,
|
|
1407
|
+
"dropping an open session should send a DISCONNECT"
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
#[tokio::test]
|
|
1412
|
+
async fn dropping_a_closed_connection_sends_nothing() {
|
|
1413
|
+
let (listener, base_url) = listening_socket();
|
|
1414
|
+
let connection = test_connection(&base_url);
|
|
1415
|
+
connection.state.close();
|
|
1416
|
+
drop(connection);
|
|
1417
|
+
assert!(
|
|
1418
|
+
!connected_within(&listener, Duration::from_millis(300)).await,
|
|
1419
|
+
"a disconnected session should not be closed twice"
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
#[test]
|
|
1424
|
+
fn dropping_a_connection_without_a_runtime_is_harmless() {
|
|
1425
|
+
// Nothing to spawn the DISCONNECT on, so it is skipped rather than
|
|
1426
|
+
// taking the caller down with it.
|
|
1427
|
+
drop(test_connection("http://127.0.0.1:1"));
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
#[test]
|
|
1431
|
+
fn dropping_a_connection_as_the_runtime_shuts_down_is_harmless() {
|
|
1432
|
+
let runtime = tokio::runtime::Runtime::new().expect("runtime");
|
|
1433
|
+
let connection = test_connection("http://127.0.0.1:1");
|
|
1434
|
+
runtime.spawn(async move {
|
|
1435
|
+
let _connection = connection;
|
|
1436
|
+
std::future::pending::<()>().await;
|
|
1437
|
+
});
|
|
1438
|
+
// Cancels the task, so the connection is dropped from inside a runtime
|
|
1439
|
+
// that is already going away.
|
|
1440
|
+
drop(runtime);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
#[tokio::test]
|
|
1444
|
+
async fn structured_proxy_failure_is_decoded_by_transport() {
|
|
1445
|
+
use std::io::{Read, Write};
|
|
1446
|
+
let (listener, uri) = listening_socket();
|
|
1447
|
+
listener.set_nonblocking(false).unwrap();
|
|
1448
|
+
let server = std::thread::spawn(move || {
|
|
1449
|
+
let (mut socket, _) = listener.accept().unwrap();
|
|
1450
|
+
socket
|
|
1451
|
+
.set_read_timeout(Some(Duration::from_secs(3)))
|
|
1452
|
+
.unwrap();
|
|
1453
|
+
let mut request = [0; 4096];
|
|
1454
|
+
let count = socket.read(&mut request).unwrap();
|
|
1455
|
+
assert!(
|
|
1456
|
+
String::from_utf8_lossy(&request[..count])
|
|
1457
|
+
.contains("x-quaxy-assignment-id: example")
|
|
1458
|
+
);
|
|
1459
|
+
let body = r#"{"error":{"code":"at_capacity","message":"private details"}}"#;
|
|
1460
|
+
write!(socket, "HTTP/1.1 429 Too Many Requests\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap();
|
|
1461
|
+
});
|
|
1462
|
+
let transport = Transport {
|
|
1463
|
+
base_url: uri,
|
|
1464
|
+
http: reqwest::Client::new(),
|
|
1465
|
+
headers: header_map(&[("x-quaxy-assignment-id".into(), "example".into())]).unwrap(),
|
|
1466
|
+
timeout: Duration::from_secs(3),
|
|
1467
|
+
};
|
|
1468
|
+
let result = transport
|
|
1469
|
+
.send(
|
|
1470
|
+
&QuackMessage::HeartbeatRequest {
|
|
1471
|
+
header: MessageHeader {
|
|
1472
|
+
message_type: MessageType::HeartbeatRequest,
|
|
1473
|
+
connection_id: Some("example".into()),
|
|
1474
|
+
client_query_id: None,
|
|
1475
|
+
},
|
|
1476
|
+
},
|
|
1477
|
+
QUACK_V3,
|
|
1478
|
+
)
|
|
1479
|
+
.await;
|
|
1480
|
+
assert!(matches!(
|
|
1481
|
+
result,
|
|
1482
|
+
Err(QuackError::Proxy(crate::ProxyError::AtCapacity))
|
|
1483
|
+
));
|
|
1484
|
+
server.join().unwrap();
|
|
1485
|
+
}
|
|
1486
|
+
}
|