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,741 @@
1
+ //! A pool of Quack sessions, for running queries concurrently.
2
+
3
+ use std::sync::atomic::{AtomicBool, Ordering};
4
+ use std::sync::{Arc, Mutex, PoisonError};
5
+
6
+ use async_stream::try_stream;
7
+ use futures_util::StreamExt;
8
+ use tokio::sync::{OwnedSemaphorePermit, Semaphore};
9
+
10
+ use crate::builders::ColumnDefinition;
11
+ use crate::client::{
12
+ QuackClient, QuackClientOptions, QuackConnectionInfo, QuackResultStream, QueryMetadata,
13
+ };
14
+ use crate::errors::{QuackError, Result};
15
+ use crate::sql::SqlParameters;
16
+ use crate::vector::{DataChunk, Row, Value};
17
+
18
+ /// Connections a [`QuackPool`] opens when no size is chosen.
19
+ pub const DEFAULT_MAX_CONNECTIONS: usize = 4;
20
+
21
+ #[derive(Clone, Debug)]
22
+ pub struct QuackPoolOptions {
23
+ /// Upper bound on sessions open against the server at once. Callers past
24
+ /// the limit wait for one to come free. Each one is a DuckDB connection on
25
+ /// the server, so a query engine should set this to the number of scans it
26
+ /// wants running in parallel.
27
+ ///
28
+ /// It must be at least as large as the number of result streams a caller
29
+ /// holds open at the same time. Reading two streams in step from a pool
30
+ /// that can only supply one connection waits forever: the parked stream
31
+ /// holds the connection the other one needs.
32
+ pub max_connections: usize,
33
+ }
34
+
35
+ impl Default for QuackPoolOptions {
36
+ fn default() -> Self {
37
+ Self {
38
+ max_connections: DEFAULT_MAX_CONNECTIONS,
39
+ }
40
+ }
41
+ }
42
+
43
+ /// A set of Quack sessions shared by concurrent callers.
44
+ ///
45
+ /// A [`QuackClient`] is one server-side session and runs one query at a time
46
+ /// (see its docs for why). A `QuackPool` keeps several of those sessions and
47
+ /// hands a free one to each caller, so N queries run on the server at once.
48
+ /// Cloning the pool is cheap and shares its connections, so one pool can back
49
+ /// every table in a catalog.
50
+ ///
51
+ /// The trade is session state. Temporary tables, `SET`, transactions, and
52
+ /// attached databases live on a single connection, and the pool gives no
53
+ /// affinity between calls - two [`query`](Self::query) calls may land on
54
+ /// different sessions. Work that depends on session state holds one connection
55
+ /// for as long as it needs it:
56
+ ///
57
+ /// ```no_run
58
+ /// # use quack_protocol::{QuackPool, Result};
59
+ /// # async fn example(pool: &QuackPool) -> Result<()> {
60
+ /// let connection = pool.acquire().await?;
61
+ /// connection
62
+ /// .execute("CREATE TEMP TABLE staging AS SELECT 1 AS id", None)
63
+ /// .await?;
64
+ /// let ids = connection.values("SELECT id FROM staging").await?;
65
+ /// # let _ = ids;
66
+ /// # Ok(())
67
+ /// # }
68
+ /// ```
69
+ ///
70
+ /// Connections open on demand up to
71
+ /// [`max_connections`](QuackPoolOptions::max_connections) and are reused
72
+ /// afterwards. A connection that saw a wire failure is retired rather than
73
+ /// handed on, and retiring it closes its session on the server. Nothing is
74
+ /// opened in its place until a later [`acquire`](Self::acquire) finds no free
75
+ /// connection, and the retired session keeps its slot until its DISCONNECT
76
+ /// has been answered, so the pool never has more sessions open than its limit.
77
+ #[derive(Clone, Debug)]
78
+ pub struct QuackPool {
79
+ inner: Arc<PoolInner>,
80
+ }
81
+
82
+ impl QuackPool {
83
+ /// Connect to a Quack server and open the pool.
84
+ ///
85
+ /// One connection is made now, so a bad URI, token, or protocol version
86
+ /// fails here rather than on the first query; the rest are opened as
87
+ /// concurrent callers need them.
88
+ pub async fn connect(
89
+ uri: &str,
90
+ options: QuackClientOptions,
91
+ pool_options: QuackPoolOptions,
92
+ ) -> Result<Self> {
93
+ if pool_options.max_connections == 0 {
94
+ return Err(QuackError::protocol(
95
+ "QuackPoolOptions::max_connections must be at least 1",
96
+ ));
97
+ }
98
+ let client = QuackClient::connect(uri, options.clone()).await?;
99
+ let info = client.info.clone();
100
+
101
+ Ok(Self {
102
+ inner: Arc::new(PoolInner {
103
+ uri: uri.to_string(),
104
+ options,
105
+ info,
106
+ permits: Arc::new(Semaphore::new(pool_options.max_connections)),
107
+ max_connections: pool_options.max_connections,
108
+ available: Mutex::new(vec![client]),
109
+ closed: AtomicBool::new(false),
110
+ }),
111
+ })
112
+ }
113
+
114
+ /// What the server reported when the pool's first connection was made.
115
+ pub fn info(&self) -> Option<&QuackConnectionInfo> {
116
+ self.inner.info.as_ref()
117
+ }
118
+
119
+ pub fn max_connections(&self) -> usize {
120
+ self.inner.max_connections
121
+ }
122
+
123
+ /// Take a connection out of the pool for exclusive use.
124
+ ///
125
+ /// Waits if every connection is busy. The connection returns to the pool
126
+ /// when the returned lease is dropped, so hold the lease for exactly as
127
+ /// long as the work that needs one session - and no longer.
128
+ pub async fn acquire(&self) -> Result<PooledClient> {
129
+ let permit = self.inner.permit().await?;
130
+ let client = match self.inner.take_available() {
131
+ Some(client) => client,
132
+ None => self.inner.connect_one().await?,
133
+ };
134
+ Ok(self.inner.lease(client, permit))
135
+ }
136
+
137
+ /// Run a query on any free connection.
138
+ ///
139
+ /// The returned stream holds its connection until it is drained or
140
+ /// dropped, the same way [`QuackClient::query`] does - so drop it once the
141
+ /// results are no longer wanted, rather than leaving it parked.
142
+ pub async fn query(
143
+ &self,
144
+ sql: &str,
145
+ metadata: Option<&QueryMetadata>,
146
+ ) -> Result<QuackResultStream> {
147
+ self.acquire().await?.query(sql, metadata).await
148
+ }
149
+
150
+ pub async fn query_with_params(
151
+ &self,
152
+ sql: &str,
153
+ params: Option<&SqlParameters>,
154
+ ) -> Result<QuackResultStream> {
155
+ self.acquire().await?.query_with_params(sql, params).await
156
+ }
157
+
158
+ /// Run a statement on any free connection and discard its result.
159
+ pub async fn execute(&self, sql: &str, metadata: Option<&QueryMetadata>) -> Result<()> {
160
+ self.query(sql, metadata).await?.drain().await
161
+ }
162
+
163
+ pub async fn first(&self, sql: &str) -> Result<Option<Row>> {
164
+ self.query(sql, None).await?.first_row().await
165
+ }
166
+
167
+ pub async fn one(&self, sql: &str) -> Result<Row> {
168
+ self.query(sql, None).await?.one_row().await
169
+ }
170
+
171
+ pub async fn values(&self, sql: &str) -> Result<Vec<Value>> {
172
+ self.query(sql, None).await?.first_column().await
173
+ }
174
+
175
+ /// Append a chunk on any free connection.
176
+ ///
177
+ /// Unlike a query, a failed append is never retried: a request that failed
178
+ /// after the server ran it is indistinguishable from one it never saw, and
179
+ /// appending twice is worse than failing once.
180
+ pub async fn append(
181
+ &self,
182
+ table_name: impl Into<String>,
183
+ schema_name: Option<String>,
184
+ chunk: DataChunk,
185
+ ) -> Result<()> {
186
+ self.acquire()
187
+ .await?
188
+ .append(table_name, schema_name, chunk)
189
+ .await
190
+ }
191
+
192
+ /// Append rows on one connection, in batches. Not retried, as
193
+ /// [`append`](Self::append) is not.
194
+ pub async fn append_rows(
195
+ &self,
196
+ table_name: impl Into<String>,
197
+ schema_name: Option<String>,
198
+ rows: &[Row],
199
+ columns: Option<Vec<ColumnDefinition>>,
200
+ batch_size: Option<usize>,
201
+ ) -> Result<()> {
202
+ self.acquire()
203
+ .await?
204
+ .append_rows(table_name, schema_name, rows, columns, batch_size)
205
+ .await
206
+ }
207
+
208
+ /// Close every session the pool holds and refuse further connections.
209
+ ///
210
+ /// Connections still leased are closed as their leases drop. Returns the
211
+ /// first failure, after trying to close all of them.
212
+ pub async fn close(&self) -> Result<()> {
213
+ let available = self.inner.close_and_drain();
214
+ // Wakes anyone waiting on `acquire` with a "pool is closed" error.
215
+ self.inner.permits.close();
216
+
217
+ let mut first_error = None;
218
+ for client in available {
219
+ if let Err(err) = client.disconnect().await {
220
+ first_error.get_or_insert(err);
221
+ }
222
+ }
223
+ match first_error {
224
+ Some(err) => Err(err),
225
+ None => Ok(()),
226
+ }
227
+ }
228
+ }
229
+
230
+ /// A connection borrowed from a [`QuackPool`].
231
+ ///
232
+ /// This handle may be cloned, and a query stream may outlive the handle that
233
+ /// created it. All such handles share ownership of the lease; the connection
234
+ /// returns to the pool only after the last handle or result stream is dropped.
235
+ ///
236
+ /// The underlying [`QuackClient`] is intentionally not exposed. Giving out a
237
+ /// raw client clone would let it keep using the session after the pool had
238
+ /// assigned that session to another borrower.
239
+ ///
240
+ /// ```compile_fail
241
+ /// # use quack_protocol::{QuackClient, QuackPool, Result};
242
+ /// # async fn cannot_extract_client(pool: &QuackPool) -> Result<()> {
243
+ /// let lease = pool.acquire().await?;
244
+ /// let raw_client = QuackClient::clone(&lease);
245
+ /// # let _ = raw_client;
246
+ /// # Ok(())
247
+ /// # }
248
+ /// ```
249
+ #[derive(Clone, Debug)]
250
+ pub struct PooledClient {
251
+ inner: Arc<PooledClientInner>,
252
+ /// Connection metadata reported when this session was opened.
253
+ pub info: Option<QuackConnectionInfo>,
254
+ }
255
+
256
+ #[derive(Debug)]
257
+ struct PooledClientInner {
258
+ // Both `Some` until `Drop` hands them back.
259
+ client: Option<QuackClient>,
260
+ permit: Option<OwnedSemaphorePermit>,
261
+ pool: Arc<PoolInner>,
262
+ }
263
+
264
+ impl PooledClient {
265
+ fn client(&self) -> &QuackClient {
266
+ self.inner
267
+ .client
268
+ .as_ref()
269
+ .expect("pooled client is taken only by Drop")
270
+ }
271
+
272
+ pub fn is_connected(&self) -> bool {
273
+ self.client().is_connected()
274
+ }
275
+
276
+ /// Run a statement on this leased session and discard its result.
277
+ pub async fn execute(&self, sql: &str, metadata: Option<&QueryMetadata>) -> Result<()> {
278
+ self.query(sql, metadata).await?.drain().await
279
+ }
280
+
281
+ /// Run a query on this leased session.
282
+ ///
283
+ /// The returned stream retains a clone of the lease, so dropping this
284
+ /// handle does not return the session while the stream is still alive.
285
+ pub async fn query(
286
+ &self,
287
+ sql: &str,
288
+ metadata: Option<&QueryMetadata>,
289
+ ) -> Result<QuackResultStream> {
290
+ let stream = self.client().query_inner(sql, None, metadata).await?;
291
+ Ok(attach_lease(stream, self.clone()))
292
+ }
293
+
294
+ pub async fn query_with_params(
295
+ &self,
296
+ sql: &str,
297
+ params: Option<&SqlParameters>,
298
+ ) -> Result<QuackResultStream> {
299
+ let stream = self.client().query_inner(sql, params, None).await?;
300
+ Ok(attach_lease(stream, self.clone()))
301
+ }
302
+
303
+ pub async fn first(&self, sql: &str) -> Result<Option<Row>> {
304
+ self.query(sql, None).await?.first_row().await
305
+ }
306
+
307
+ pub async fn one(&self, sql: &str) -> Result<Row> {
308
+ self.query(sql, None).await?.one_row().await
309
+ }
310
+
311
+ pub async fn values(&self, sql: &str) -> Result<Vec<Value>> {
312
+ self.query(sql, None).await?.first_column().await
313
+ }
314
+
315
+ pub async fn append(
316
+ &self,
317
+ table_name: impl Into<String>,
318
+ schema_name: Option<String>,
319
+ chunk: DataChunk,
320
+ ) -> Result<()> {
321
+ self.client().append(table_name, schema_name, chunk).await
322
+ }
323
+
324
+ pub async fn append_rows(
325
+ &self,
326
+ table_name: impl Into<String>,
327
+ schema_name: Option<String>,
328
+ rows: &[Row],
329
+ columns: Option<Vec<ColumnDefinition>>,
330
+ batch_size: Option<usize>,
331
+ ) -> Result<()> {
332
+ self.client()
333
+ .append_rows(table_name, schema_name, rows, columns, batch_size)
334
+ .await
335
+ }
336
+
337
+ /// Close this leased session. The pool retires it after all lease handles
338
+ /// and streams have been dropped.
339
+ pub async fn disconnect(&self) -> Result<()> {
340
+ self.client().disconnect().await
341
+ }
342
+
343
+ pub async fn close(&self) -> Result<()> {
344
+ self.disconnect().await
345
+ }
346
+ }
347
+
348
+ impl Drop for PooledClientInner {
349
+ fn drop(&mut self) {
350
+ let Some(client) = self.client.take() else {
351
+ return;
352
+ };
353
+ let Some(retired) = self.pool.release(client) else {
354
+ // The permit drops next, after the connection is back in the
355
+ // pool, so a waiter that wakes on it finds the connection waiting.
356
+ return;
357
+ };
358
+ // Retired: the permit stays with the session until its DISCONNECT is
359
+ // answered, so the caller that wakes on it opens a replacement only
360
+ // once the old session is gone. Without a runtime the client's own
361
+ // `Drop` logs and gives up, and the permit is released here.
362
+ let Ok(runtime) = tokio::runtime::Handle::try_current() else {
363
+ return;
364
+ };
365
+ let permit = self.permit.take();
366
+ runtime.spawn(async move {
367
+ // Failures are logged by `Connection`; there is no caller to tell.
368
+ let _ = retired.disconnect().await;
369
+ drop(retired);
370
+ drop(permit);
371
+ });
372
+ }
373
+ }
374
+
375
+ #[derive(Debug)]
376
+ struct PoolInner {
377
+ uri: String,
378
+ options: QuackClientOptions,
379
+ info: Option<QuackConnectionInfo>,
380
+ permits: Arc<Semaphore>,
381
+ max_connections: usize,
382
+ available: Mutex<Vec<QuackClient>>,
383
+ closed: AtomicBool,
384
+ }
385
+
386
+ impl PoolInner {
387
+ async fn permit(&self) -> Result<OwnedSemaphorePermit> {
388
+ Arc::clone(&self.permits)
389
+ .acquire_owned()
390
+ .await
391
+ .map_err(|_| QuackError::protocol("Quack pool is closed"))
392
+ }
393
+
394
+ fn lease(self: &Arc<Self>, client: QuackClient, permit: OwnedSemaphorePermit) -> PooledClient {
395
+ let info = client.info.clone();
396
+ PooledClient {
397
+ inner: Arc::new(PooledClientInner {
398
+ client: Some(client),
399
+ permit: Some(permit),
400
+ pool: Arc::clone(self),
401
+ }),
402
+ info,
403
+ }
404
+ }
405
+
406
+ async fn connect_one(&self) -> Result<QuackClient> {
407
+ if self.closed.load(Ordering::Relaxed) {
408
+ return Err(QuackError::protocol("Quack pool is closed"));
409
+ }
410
+ let client = QuackClient::connect(&self.uri, self.options.clone()).await?;
411
+ if self.closed_while_connecting() {
412
+ // `close()` ran while this connect was in flight, so it cannot
413
+ // have closed this session. Close it here rather than hand a live
414
+ // session out of a closed pool.
415
+ let _ = client.disconnect().await;
416
+ return Err(QuackError::protocol("Quack pool is closed"));
417
+ }
418
+ Ok(client)
419
+ }
420
+
421
+ // `close_and_drain` sets the flag under the available lock, so reading it
422
+ // there orders this check after a close that has already drained.
423
+ fn closed_while_connecting(&self) -> bool {
424
+ let _available = self.lock_available();
425
+ self.closed.load(Ordering::Relaxed)
426
+ }
427
+
428
+ fn take_available(&self) -> Option<QuackClient> {
429
+ let mut available = self.lock_available();
430
+ // Connections retired while idle are dropped here, which closes their
431
+ // sessions on the server.
432
+ while let Some(client) = available.pop() {
433
+ if client.is_reusable() {
434
+ return Some(client);
435
+ }
436
+ }
437
+ None
438
+ }
439
+
440
+ // Puts a connection back, or returns it when it must be retired instead:
441
+ // the pool is closed or the session saw a wire failure. The closed check
442
+ // happens under the available lock so that `close_and_drain` cannot run
443
+ // between the check and the push, leaving a live session behind in a
444
+ // closed pool.
445
+ fn release(&self, client: QuackClient) -> Option<QuackClient> {
446
+ let mut available = self.lock_available();
447
+ if self.closed.load(Ordering::Relaxed) || !client.is_reusable() {
448
+ return Some(client);
449
+ }
450
+ available.push(client);
451
+ None
452
+ }
453
+
454
+ fn close_and_drain(&self) -> Vec<QuackClient> {
455
+ let mut available = self.lock_available();
456
+ self.closed.store(true, Ordering::Relaxed);
457
+ std::mem::take(&mut *available)
458
+ }
459
+
460
+ // The lock guards a `Vec` and nothing else, so a panic elsewhere in the
461
+ // process must not take the pool down with it.
462
+ fn lock_available(&self) -> std::sync::MutexGuard<'_, Vec<QuackClient>> {
463
+ self.available
464
+ .lock()
465
+ .unwrap_or_else(PoisonError::into_inner)
466
+ }
467
+ }
468
+
469
+ // Keeps the connection out of the pool until the caller is done with the
470
+ // results, the way `QuackClient` holds its own connection lock.
471
+ fn attach_lease(stream: QuackResultStream, lease: PooledClient) -> QuackResultStream {
472
+ let (columns, chunks) = stream.into_chunks();
473
+ let chunks = try_stream! {
474
+ let _lease = lease;
475
+ for await chunk in chunks {
476
+ yield chunk?;
477
+ }
478
+ };
479
+ QuackResultStream::new(columns, chunks.boxed())
480
+ }
481
+
482
+ #[cfg(test)]
483
+ mod tests {
484
+ use std::io::{Read, Write};
485
+ use std::net::TcpListener;
486
+ use std::thread;
487
+ use std::time::Duration;
488
+
489
+ use super::*;
490
+ use crate::binary::HugeIntParts;
491
+ use crate::constants::QUACK_V1;
492
+ use crate::messages::{MessageHeader, MessageType, QuackMessage, encode_message_for_version};
493
+
494
+ fn scripted_server(responses: Vec<QuackMessage>) -> (String, thread::JoinHandle<()>) {
495
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
496
+ let address = listener.local_addr().expect("test server address");
497
+ let responses = responses
498
+ .into_iter()
499
+ .map(|response| {
500
+ encode_message_for_version(&response, QUACK_V1).expect("encode test response")
501
+ })
502
+ .collect::<Vec<_>>();
503
+ let server = thread::spawn(move || {
504
+ for response in responses {
505
+ let (mut socket, _) = listener.accept().expect("accept test request");
506
+ let mut request = [0; 4096];
507
+ let bytes_read = socket.read(&mut request).expect("read test request");
508
+ assert!(bytes_read > 0, "test request must not be empty");
509
+ write!(
510
+ socket,
511
+ "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
512
+ response.len()
513
+ )
514
+ .expect("write test response headers");
515
+ socket.write_all(&response).expect("write test response");
516
+ }
517
+ });
518
+ (format!("http://{address}"), server)
519
+ }
520
+
521
+ fn v1_options() -> QuackClientOptions {
522
+ QuackClientOptions {
523
+ min_supported_quack_version: Some(QUACK_V1),
524
+ max_supported_quack_version: Some(QUACK_V1),
525
+ timeout: Some(Duration::from_secs(2)),
526
+ ..QuackClientOptions::default()
527
+ }
528
+ }
529
+
530
+ #[tokio::test]
531
+ async fn rejects_an_empty_pool() {
532
+ let err = QuackPool::connect(
533
+ "localhost:9494",
534
+ QuackClientOptions::default(),
535
+ QuackPoolOptions { max_connections: 0 },
536
+ )
537
+ .await
538
+ .expect_err("max_connections 0 is rejected");
539
+ assert!(err.to_string().contains("max_connections"), "{err}");
540
+ }
541
+
542
+ #[test]
543
+ fn default_pool_is_small() {
544
+ assert_eq!(
545
+ QuackPoolOptions::default().max_connections,
546
+ DEFAULT_MAX_CONNECTIONS
547
+ );
548
+ }
549
+
550
+ #[test]
551
+ fn cloning_a_pooled_client_produces_another_lease() {
552
+ let clone_lease: fn(&PooledClient) -> PooledClient = PooledClient::clone;
553
+ let _ = clone_lease;
554
+ }
555
+
556
+ #[tokio::test]
557
+ async fn clones_and_query_streams_keep_the_pool_lease() {
558
+ let connection_response = QuackMessage::ConnectionResponse {
559
+ header: MessageHeader::new(MessageType::ConnectionResponse)
560
+ .with_connection("leased-session"),
561
+ server_duckdb_version: None,
562
+ server_platform: None,
563
+ quack_version: Some(QUACK_V1),
564
+ heartbeat_timeout_seconds: None,
565
+ };
566
+ let prepare_response = QuackMessage::PrepareResponse {
567
+ header: MessageHeader::new(MessageType::PrepareResponse)
568
+ .with_connection("leased-session"),
569
+ result_types: Vec::new(),
570
+ result_names: Vec::new(),
571
+ needs_more_fetch: false,
572
+ results: Vec::new(),
573
+ result_uuid: HugeIntParts { upper: 0, lower: 1 },
574
+ };
575
+ let (uri, server) = scripted_server(vec![connection_response, prepare_response]);
576
+ let pool = QuackPool::connect(&uri, v1_options(), QuackPoolOptions { max_connections: 1 })
577
+ .await
578
+ .expect("connect pool");
579
+
580
+ let lease = pool.acquire().await.expect("acquire lease");
581
+ let cloned_lease = lease.clone();
582
+ drop(lease);
583
+ assert!(
584
+ tokio::time::timeout(Duration::from_millis(50), pool.acquire())
585
+ .await
586
+ .is_err(),
587
+ "a cloned handle must keep the permit"
588
+ );
589
+
590
+ let stream = cloned_lease
591
+ .query("SELECT 1", None)
592
+ .await
593
+ .expect("create result stream");
594
+ drop(cloned_lease);
595
+ assert!(
596
+ tokio::time::timeout(Duration::from_millis(50), pool.acquire())
597
+ .await
598
+ .is_err(),
599
+ "a result stream must keep the permit"
600
+ );
601
+
602
+ drop(stream);
603
+ let returned = tokio::time::timeout(Duration::from_secs(1), pool.acquire())
604
+ .await
605
+ .expect("released stream should unblock acquire")
606
+ .expect("reacquire returned session");
607
+ drop(returned);
608
+ server.join().expect("test server");
609
+ }
610
+
611
+ fn connection_response(connection_id: &str) -> QuackMessage {
612
+ QuackMessage::ConnectionResponse {
613
+ header: MessageHeader::new(MessageType::ConnectionResponse)
614
+ .with_connection(connection_id),
615
+ server_duckdb_version: None,
616
+ server_platform: None,
617
+ quack_version: Some(QUACK_V1),
618
+ heartbeat_timeout_seconds: None,
619
+ }
620
+ }
621
+
622
+ fn success_response() -> QuackMessage {
623
+ QuackMessage::SuccessResponse {
624
+ header: MessageHeader::new(MessageType::SuccessResponse),
625
+ }
626
+ }
627
+
628
+ #[tokio::test]
629
+ async fn a_malformed_response_retires_the_session() {
630
+ // SUCCESS in answer to PREPARE decodes fine but is the wrong message.
631
+ let (uri, server) = scripted_server(vec![
632
+ connection_response("malformed-session"),
633
+ success_response(),
634
+ success_response(),
635
+ ]);
636
+ let client = QuackClient::connect(&uri, v1_options())
637
+ .await
638
+ .expect("connect client");
639
+ assert!(client.is_reusable());
640
+
641
+ let error = match client.query("SELECT 1", None).await {
642
+ Ok(_) => panic!("wrong response type is a protocol error"),
643
+ Err(error) => error,
644
+ };
645
+ assert!(
646
+ matches!(error, QuackError::Protocol(ref message) if message.contains("PREPARE_RESPONSE")),
647
+ "{error}"
648
+ );
649
+ assert!(
650
+ !client.is_reusable(),
651
+ "a session that answered out of protocol must not be handed on"
652
+ );
653
+
654
+ client.disconnect().await.expect("disconnect");
655
+ server.join().expect("test server");
656
+ }
657
+
658
+ #[tokio::test]
659
+ async fn a_retired_lease_keeps_its_slot_until_the_session_is_closed() {
660
+ // Response order is the whole assertion: with one slot, the
661
+ // replacement CONNECT must come after the retired session's
662
+ // DISCONNECT. If the permit were released early, the replacement
663
+ // would consume the DISCONNECT acknowledgement and fail to connect.
664
+ let (uri, server) = scripted_server(vec![
665
+ connection_response("retired-session"),
666
+ success_response(), // wrong answer to PREPARE: retires the session
667
+ success_response(), // acknowledges DISCONNECT
668
+ connection_response("replacement-session"),
669
+ ]);
670
+ let pool = QuackPool::connect(&uri, v1_options(), QuackPoolOptions { max_connections: 1 })
671
+ .await
672
+ .expect("connect pool");
673
+
674
+ let lease = pool.acquire().await.expect("acquire lease");
675
+ assert!(
676
+ lease.query("SELECT 1", None).await.is_err(),
677
+ "wrong response type is a protocol error"
678
+ );
679
+ drop(lease);
680
+
681
+ let replacement = tokio::time::timeout(Duration::from_secs(2), pool.acquire())
682
+ .await
683
+ .expect("slot frees once the retired session is closed")
684
+ .expect("replacement connects after the DISCONNECT");
685
+ assert!(replacement.is_connected());
686
+ drop(replacement);
687
+ server.join().expect("test server");
688
+ }
689
+
690
+ #[tokio::test]
691
+ async fn ambiguous_connection_error_text_is_returned_without_replay() {
692
+ let connection_response = QuackMessage::ConnectionResponse {
693
+ header: MessageHeader::new(MessageType::ConnectionResponse)
694
+ .with_connection("possibly-stale-session"),
695
+ server_duckdb_version: None,
696
+ server_platform: None,
697
+ quack_version: Some(QUACK_V1),
698
+ heartbeat_timeout_seconds: None,
699
+ };
700
+ let ambiguous_error = QuackMessage::ErrorResponse {
701
+ header: MessageHeader::new(MessageType::ErrorResponse),
702
+ message: "Invalid connection id".to_string(),
703
+ };
704
+ let disconnect_response = QuackMessage::SuccessResponse {
705
+ header: MessageHeader::new(MessageType::SuccessResponse),
706
+ };
707
+ let (uri, server) = scripted_server(vec![
708
+ connection_response,
709
+ ambiguous_error,
710
+ disconnect_response,
711
+ ]);
712
+ let pool = QuackPool::connect(&uri, v1_options(), QuackPoolOptions { max_connections: 1 })
713
+ .await
714
+ .expect("connect pool");
715
+
716
+ let error = match pool.query("INSERT INTO items VALUES (1)", None).await {
717
+ Ok(_) => panic!("ambiguous server text must be surfaced"),
718
+ Err(error) => error,
719
+ };
720
+ assert!(
721
+ matches!(error, QuackError::Server(ref message) if message == "Invalid connection id"),
722
+ "the original server error must be returned: {error}"
723
+ );
724
+
725
+ // The retired session is closed on a background task, and its permit
726
+ // comes back only once that has finished - so waiting for the permit
727
+ // waits for the DISCONNECT.
728
+ let _permit = tokio::time::timeout(
729
+ Duration::from_secs(5),
730
+ Arc::clone(&pool.inner.permits).acquire_owned(),
731
+ )
732
+ .await
733
+ .expect("the retired session is closed in the background")
734
+ .expect("the pool is still open");
735
+
736
+ // The third response acknowledges that DISCONNECT. If the pool replayed
737
+ // the statement, it would consume that response as another CONNECT and
738
+ // return a protocol error instead of the original server error above.
739
+ server.join().expect("test server");
740
+ }
741
+ }