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,41 @@
1
+ [package]
2
+ name = "quack_protocol"
3
+ version = "0.4.1"
4
+ edition = "2024"
5
+ rust-version = "1.85"
6
+ description = "Rust client SDK for DuckDB's experimental Quack remote protocol"
7
+ license = "MIT"
8
+ readme = "README.md"
9
+ documentation = "https://docs.rs/quack_protocol"
10
+ keywords = ["duckdb", "quack", "database", "client"]
11
+ categories = ["api-bindings", "database"]
12
+
13
+ [features]
14
+ arrow = ["dep:arrow-array", "dep:arrow-schema", "dep:arrow-buffer"]
15
+
16
+ [dependencies]
17
+ arrow-array = { version = "59", optional = true, default-features = false }
18
+ arrow-buffer = { version = "59", optional = true, default-features = false }
19
+ arrow-schema = { version = "59", optional = true, default-features = false }
20
+ async-stream = "0.3"
21
+ base64 = "0.22"
22
+ futures-core = "0.3"
23
+ futures-util = { version = "0.3", default-features = false, features = ["alloc"] }
24
+ chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] }
25
+ hex = "0.4"
26
+ indexmap = "2"
27
+ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "http2"] }
28
+ ring = "0.17"
29
+ # Must stay on the rustls line reqwest uses: `use_preconfigured_tls` only
30
+ # accepts a `ClientConfig` from that version.
31
+ rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
32
+ serde_json = "1"
33
+ thiserror = "2"
34
+ tokio = { version = "1", default-features = false, features = ["rt", "sync", "time"] }
35
+ tracing = "0.1"
36
+ url = "2"
37
+ x509-parser = { version = "0.18", default-features = false }
38
+
39
+ [dev-dependencies]
40
+ tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
41
+ tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) the quack_protocol contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ # Packaged protocol fixes
2
+
3
+ Source: bnjjj/quack_protocol_rs 0.4.1, commit
4
+ ee5cf21e3450b0651dd0fe9ed94e455c3b3b66b3.
5
+
6
+ The Ruby source gem includes this MIT-licensed crate so codec fixes are present
7
+ when users build it without a separate unpublished dependency.
8
+
9
+ - Decode BIGNUM's binary payload as bytes, rather than UTF-8. The Ruby bridge
10
+ converts its sign/magnitude encoding to an arbitrary-precision Integer.
11
+ - Recognize DuckDB 2.0 TIMESTAMPTZ_NS (logical ID 33) and TUPLE (ID 110).
12
+ - Keep unnamed STRUCT/TUPLE fields by ordinal, rather than overwriting every
13
+ empty-name field in an IndexMap. Encoding and append normalization use the
14
+ same ordinal keys. UNION's empty-name tag field remains unchanged.
15
+
16
+ Real protocol integration coverage lives in the Ruby test suite.
@@ -0,0 +1,272 @@
1
+ # quack_protocol
2
+
3
+ Rust client-side SDK for DuckDB's experimental Quack remote protocol.
4
+
5
+ The `0.3.0` line supports both Quack protocol v1 and protocol v3 from
6
+ DuckDB 2.0 alpha:
7
+
8
+ - DuckDB `BinarySerializer`-compatible primitive, object, logical type, vector, and `DataChunk` codecs.
9
+ - Quack connection leases and heartbeats, prepare/query, acknowledged fetch streaming,
10
+ disconnect, success, and error messages.
11
+ - A session pool for running queries concurrently against one server.
12
+ - Async HTTP `POST /quack` transport using `application/duckdb`.
13
+ - URI parsing for `localhost:9494`, `quack:host:port`, bracketed IPv6, and direct HTTP(S) URLs.
14
+ - SQL literal formatting for positional and named parameters.
15
+
16
+ ```rust
17
+ use futures_util::TryStreamExt;
18
+ use quack_protocol::{QuackClient, QuackClientOptions, Result};
19
+
20
+ #[tokio::main]
21
+ async fn main() -> Result<()> {
22
+ let client = QuackClient::connect(
23
+ "localhost:9494",
24
+ QuackClientOptions {
25
+ auth_token: Some("super_secret".to_string()),
26
+ ..Default::default()
27
+ },
28
+ )
29
+ .await?;
30
+
31
+ let (_columns, rows) = client
32
+ .query("SELECT 42::INTEGER AS answer", None)
33
+ .await?
34
+ .into_rows();
35
+ let rows: Vec<_> = rows.try_collect().await?;
36
+ println!("{:?}", rows);
37
+
38
+ client.disconnect().await?;
39
+ Ok(())
40
+ }
41
+ ```
42
+
43
+ ## Connection model
44
+
45
+ A `QuackClient` is **one session** on the server: one connection id, for the
46
+ client's whole life and for every clone of it. Two things follow.
47
+
48
+ **Queries on one client are serialized.** The server keeps a single resumable
49
+ result cursor per connection id, and a PREPARE resets it - so a second query
50
+ would invalidate a FETCH still in flight. Queries therefore take a lock, and a
51
+ result stream holds it until the stream is drained or dropped. Cloning the
52
+ client does not change that; the clones share the session.
53
+
54
+ **Session state persists.** Temporary tables, `SET`, transactions, and attached
55
+ databases live on the connection, so consecutive calls on one client see each
56
+ other's effects.
57
+
58
+ For concurrency, use a `QuackPool`. It keeps several sessions and hands a free
59
+ one to each caller, so N queries run on the server at once - what a query engine
60
+ wants when it opens one scan per partition:
61
+
62
+ ```rust
63
+ use quack_protocol::{QuackClientOptions, QuackPool, QuackPoolOptions, Result};
64
+
65
+ #[tokio::main]
66
+ async fn main() -> Result<()> {
67
+ let pool = QuackPool::connect(
68
+ "localhost:9494",
69
+ QuackClientOptions::default(),
70
+ // Size this to the parallelism you want - DataFusion's
71
+ // `target_partitions`, say. Every connection is a DuckDB connection on
72
+ // the server.
73
+ QuackPoolOptions { max_connections: 8 },
74
+ )
75
+ .await?;
76
+
77
+ // Each concurrent query runs on its own session.
78
+ let (_columns, chunks) = pool.query("SELECT 42::INTEGER AS answer", None).await?.into_chunks();
79
+ let _ = chunks;
80
+ Ok(())
81
+ }
82
+ ```
83
+
84
+ `QuackPool` is cheap to clone and shares its connections, so one pool can back
85
+ every table in a federated catalog. Connections open on demand up to
86
+ `max_connections`; callers past the limit wait for one to come free.
87
+
88
+ The pool gives **no session affinity** between calls: two `pool.query()` calls
89
+ may land on different sessions, so a temp table created by one is invisible to
90
+ the other. Work that depends on session state takes a connection out of the pool
91
+ and holds it:
92
+
93
+ ```rust,ignore
94
+ let connection = pool.acquire().await?;
95
+ connection.execute("CREATE TEMP TABLE staging AS SELECT 1 AS id", None).await?;
96
+ let ids = connection.values("SELECT id FROM staging").await?;
97
+ // `connection` goes back to the pool when it is dropped.
98
+ ```
99
+
100
+ `QuackClientOptions` redacts `auth_token` in its `Debug` output. Extra HTTP
101
+ headers go in `QuackClientOptions::headers` as name/value pairs; an invalid
102
+ name or value is reported when the client connects.
103
+
104
+ A stream returned by `pool.query()` holds its connection until it is drained or
105
+ dropped, so drop it when the results are no longer wanted rather than leaving it
106
+ parked - the connection is not free until then.
107
+
108
+ ### TLS with a self-signed server certificate
109
+
110
+ `quack_serve` uses HTTPS unless it is bound to localhost, and by default serves a
111
+ self-signed certificate. A self-signed certificate does not verify against any
112
+ CA, so, as DuckDB's own client does, authenticate the server by pinning the
113
+ certificate's SHA-256 fingerprint - the one `quack_generate_keys()` returns, or
114
+ `openssl x509 -in server.pem -noout -fingerprint -sha256` prints:
115
+
116
+ ```rust,ignore
117
+ let client = QuackClient::connect(
118
+ "quack:myhost:9494",
119
+ QuackClientOptions {
120
+ ssl_fingerprint: Some("2B:31:F0:...:EA".to_string()),
121
+ ..Default::default()
122
+ },
123
+ )
124
+ .await?;
125
+ ```
126
+
127
+ A pinned client trusts exactly that certificate and skips the CA and hostname
128
+ checks; the TLS handshake is still verified against the certificate's key. A pin
129
+ implies HTTPS, and pinned connections use TLS 1.3. Without a pin, HTTPS
130
+ connections verify the server against the standard web PKI roots.
131
+
132
+ ### Closing sessions
133
+
134
+ A session the client no longer holds is closed, so the server does not keep a
135
+ cursor for a connection id nobody can use again:
136
+
137
+ - `disconnect()` (on a client, `close()` on a pool) closes deterministically and
138
+ reports failure.
139
+ - Dropping the last handle to a client - or the pool that holds it - sends the
140
+ DISCONNECT from a background task. That is best-effort: it only goes out while
141
+ the Tokio runtime is alive, so call `disconnect()`/`close()` when the program
142
+ is about to exit or the outcome matters.
143
+
144
+ ### When the server forgets a connection
145
+
146
+ After a server restart, a session opened against the old server is gone and the
147
+ server answers `Invalid connection id`. Current Quack protocol versions carry
148
+ only the error text, however, and SQL can raise exactly the same message after
149
+ an earlier statement committed. The client therefore never retries a statement
150
+ automatically.
151
+
152
+ The pool retires a session when the response exactly matches a known stale
153
+ connection message, but returns the error to the caller. A later pool call opens
154
+ a fresh session within `max_connections`. A plain `QuackClient` does not
155
+ reconnect; it is one session, and quietly opening another would drop the session
156
+ state that came with it.
157
+
158
+ ## Arrow output
159
+
160
+ The optional `arrow` feature adds a read-only bridge that turns query results
161
+ into `arrow_array::RecordBatch` streams. The Arrow schema is built from the
162
+ column definitions returned at prepare time, so it is correct even for results
163
+ that carry no rows.
164
+
165
+ ```toml
166
+ [dependencies]
167
+ quack_protocol = { version = "0.2", features = ["arrow"] }
168
+ ```
169
+
170
+ ```rust
171
+ use futures_util::TryStreamExt;
172
+ use quack_protocol::arrow::arrow_array::RecordBatch;
173
+ use quack_protocol::{QuackClient, QuackClientOptions, Result};
174
+
175
+ #[tokio::main]
176
+ async fn main() -> Result<()> {
177
+ let client = QuackClient::connect("localhost:9494", QuackClientOptions::default()).await?;
178
+
179
+ let (schema, batches) = client
180
+ .query("SELECT 42::INTEGER AS answer", None)
181
+ .await?
182
+ .into_record_batches()?;
183
+ let batches: Vec<RecordBatch> = batches.try_collect().await?;
184
+ println!("{schema}: {} rows", batches.iter().map(RecordBatch::num_rows).sum::<usize>());
185
+
186
+ client.disconnect().await?;
187
+ Ok(())
188
+ }
189
+ ```
190
+
191
+ `into_record_batches()` is the whole entry point. `quack_protocol::arrow`
192
+ otherwise only re-exports the `arrow-array`, `arrow-buffer`, and `arrow-schema`
193
+ crates, so downstream code can match versions.
194
+
195
+ The batch stream holds the client's connection until it is drained or dropped,
196
+ as `into_chunks()` and `into_rows()` do: a `QuackClient` runs one query at a
197
+ time, and a second query on the same client waits for the first stream to
198
+ finish. `QuackPool::query` returns the same `QuackResultStream`, so
199
+ `into_record_batches()` works there too - and concurrent scans then run on
200
+ separate sessions. See [Connection model](#connection-model).
201
+
202
+ ### Type mapping
203
+
204
+ | DuckDB | Arrow |
205
+ |---|---|
206
+ | `NULL` | `Null` |
207
+ | `BOOLEAN` | `Boolean` |
208
+ | `TINYINT` … `BIGINT` | `Int8` … `Int64` |
209
+ | `UTINYINT` … `UBIGINT` | `UInt8` … `UInt64` |
210
+ | `HUGEINT`, `UHUGEINT` | `Decimal256(39, 0)` |
211
+ | `FLOAT`, `DOUBLE` | `Float32`, `Float64` |
212
+ | `DECIMAL(w, s)` | `Decimal128(w, s)` |
213
+ | `VARCHAR`, `CHAR`, `ENUM`, `UUID` | `Utf8` |
214
+ | `BLOB`, `GEOMETRY`, `BIT` | `Binary` |
215
+ | `DATE` | `Date32` |
216
+ | `TIME`, `TIME_NS` | `Time64(Microsecond)`, `Time64(Nanosecond)` |
217
+ | `TIMESTAMP_S`, `TIMESTAMP_MS`, `TIMESTAMP`, `TIMESTAMP_NS` | `Timestamp(<unit>, None)` |
218
+ | `TIMESTAMPTZ` | `Timestamp(Microsecond, Some("UTC"))` |
219
+ | `INTERVAL` | `Interval(MonthDayNano)` |
220
+ | `LIST`, `MAP` | `List(child)` |
221
+ | `ARRAY(n)` | `FixedSizeList(child, n)` |
222
+ | `STRUCT` | `Struct(children)` |
223
+ | `VARIANT` | `Struct(keys, children, values, data)` |
224
+
225
+ Every field is nullable; the wire carries no non-null guarantee. A `NULL` list,
226
+ array, or struct is a null container, not an empty one. `TIME` values must lie
227
+ within one day, as Arrow requires, so DuckDB's `TIME '24:00:00'` is rejected.
228
+ `TIMETZ` and `UNION` have no Arrow mapping yet and produce
229
+ `QuackError::UnsupportedType`.
230
+
231
+ Three mappings are provisional and may change in a future release: `MAP` is
232
+ encoded as `List<Struct<key, value>>` rather than Arrow's native `Map`, `ENUM`
233
+ as plain `Utf8` rather than a `Dictionary`, and `VARIANT` as the struct DuckDB
234
+ shreds it into rather than Arrow's canonical `arrow.variant` extension, whose
235
+ binary encoding is not the one DuckDB sends. A `VARIANT` column therefore
236
+ arrives as its physical layout — `keys`, `children`, `values`, and a `data`
237
+ blob — the same shape the `Value` path already exposes, and reading a value
238
+ back out of it means interpreting that encoding yourself.
239
+
240
+ Quack is still experimental upstream and not yet covered by a stable official wire spec. This implementation follows DuckDB's `duckdb-quack` extension.
241
+
242
+ ## Compatibility
243
+
244
+ The client prefers protocol v3 and retries with the separate v1 codec when an
245
+ older server rejects the v3 handshake. `QuackConnectionInfo::quack_version`
246
+ reports the selected version. Set `min_supported_quack_version` and
247
+ `max_supported_quack_version` to the same value to require one version.
248
+
249
+ Protocol v1 retains its legacy prepare, fetch, and append payloads. Protocol v3
250
+ uses query UUIDs, acknowledged fetch batches, raw chunk payloads, and connection
251
+ heartbeats. The v3 send-data write path is not implemented yet, so `append` and
252
+ `append_rows` return an explicit unsupported-protocol error on a v3 connection;
253
+ they continue to work on v1 connections.
254
+
255
+ ## Admission-aware proxies
256
+
257
+ `inspection::inspect_message_header` reads only a bounded Quack header and
258
+ distinguishes incomplete, invalid, and oversized input. It returns an
259
+ `inspection::MessageHeader` containing the operation, connection ID, query ID, and
260
+ encoded header length without decoding SQL, authentication, or result data.
261
+ Callers must cap collection at `MAX_MESSAGE_HEADER_BYTES` and replay the inspected
262
+ bytes.
263
+ `inspect_control_response` accepts only bounded complete connect, success, and
264
+ error responses, including DuckDB's structured invalidation flag. Fatality is a
265
+ conservative retirement signal and never authorizes replay.
266
+
267
+ The client decodes recognized structured proxy HTTP failures as
268
+ `QuackError::Proxy(ProxyError)`. This is a new error variant: exhaustive error
269
+ matches must handle it. Unknown or oversized failures remain generic protocol
270
+ errors; raw proxy messages are discarded. Supply assignment and lifecycle headers
271
+ through `QuackClientOptions::headers`; the transport sends them on every request,
272
+ including heartbeat and disconnect. Debug output redacts these headers.