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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/LICENSE-DuckDB +7 -0
  4. data/LICENSE-QuackProtocol +21 -0
  5. data/LICENSE-Rust-dependencies.json +2057 -0
  6. data/README.md +229 -0
  7. data/ext/quack/Cargo.lock +1803 -0
  8. data/ext/quack/Cargo.toml +17 -0
  9. data/ext/quack/extconf.rb +4 -0
  10. data/ext/quack/src/lib.rs +647 -0
  11. data/ext/quack/vendor/quack_protocol/Cargo.lock +1907 -0
  12. data/ext/quack/vendor/quack_protocol/Cargo.toml +41 -0
  13. data/ext/quack/vendor/quack_protocol/LICENSE +21 -0
  14. data/ext/quack/vendor/quack_protocol/PATCHES.md +16 -0
  15. data/ext/quack/vendor/quack_protocol/README.md +272 -0
  16. data/ext/quack/vendor/quack_protocol/src/arrow/columns.rs +399 -0
  17. data/ext/quack/vendor/quack_protocol/src/arrow/mod.rs +134 -0
  18. data/ext/quack/vendor/quack_protocol/src/arrow/schema.rs +103 -0
  19. data/ext/quack/vendor/quack_protocol/src/arrow/tests.rs +851 -0
  20. data/ext/quack/vendor/quack_protocol/src/arrow/values.rs +150 -0
  21. data/ext/quack/vendor/quack_protocol/src/binary.rs +504 -0
  22. data/ext/quack/vendor/quack_protocol/src/builders.rs +217 -0
  23. data/ext/quack/vendor/quack_protocol/src/client.rs +1486 -0
  24. data/ext/quack/vendor/quack_protocol/src/constants.rs +11 -0
  25. data/ext/quack/vendor/quack_protocol/src/errors.rs +103 -0
  26. data/ext/quack/vendor/quack_protocol/src/inspection.rs +250 -0
  27. data/ext/quack/vendor/quack_protocol/src/json.rs +225 -0
  28. data/ext/quack/vendor/quack_protocol/src/lib.rs +54 -0
  29. data/ext/quack/vendor/quack_protocol/src/logical_types.rs +977 -0
  30. data/ext/quack/vendor/quack_protocol/src/messages.rs +843 -0
  31. data/ext/quack/vendor/quack_protocol/src/pool.rs +741 -0
  32. data/ext/quack/vendor/quack_protocol/src/protocol_tests.rs +759 -0
  33. data/ext/quack/vendor/quack_protocol/src/proxy_error.rs +77 -0
  34. data/ext/quack/vendor/quack_protocol/src/sql.rs +359 -0
  35. data/ext/quack/vendor/quack_protocol/src/tls.rs +354 -0
  36. data/ext/quack/vendor/quack_protocol/src/values.rs +77 -0
  37. data/ext/quack/vendor/quack_protocol/src/vector.rs +1364 -0
  38. data/ext/quack/vendor/quack_protocol/testdata/tls/key.der +0 -0
  39. data/ext/quack/vendor/quack_protocol/testdata/tls/spki.der +0 -0
  40. data/ext/quack/vendor/quack_protocol/testdata/tls/v1-cert.der +0 -0
  41. data/ext/quack/vendor/quack_protocol/testdata/tls/v3-cert.der +0 -0
  42. data/lib/quack/codec.rb +263 -0
  43. data/lib/quack/connection.rb +131 -0
  44. data/lib/quack/logical_types.rb +72 -0
  45. data/lib/quack/result.rb +64 -0
  46. data/lib/quack/sql.rb +183 -0
  47. data/lib/quack/types.rb +40 -0
  48. data/lib/quack/variant.rb +298 -0
  49. data/lib/quack.rb +34 -0
  50. 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
+ }