questdb 0.1.0 → 0.1.1

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.cargo/audit.toml +17 -9
  3. data/CHANGELOG.md +7 -0
  4. data/README.md +1 -1
  5. data/THIRD_PARTY_NOTICES.md +2 -2
  6. data/docs/configuration.md +3 -2
  7. data/docs/design.md +1 -1
  8. data/docs/python-parity.md +1 -1
  9. data/docs/releasing.md +1 -1
  10. data/ext/questdb_ingress/extconf.rb +2 -0
  11. data/ext/questdb_ingress/questdb_ingress.c +2 -1
  12. data/ext/questdb_ingress/unlink_build_ruby.rb +58 -0
  13. data/lib/questdb/arrow.rb +2 -0
  14. data/lib/questdb/client.rb +6 -0
  15. data/lib/questdb/config.rb +19 -0
  16. data/lib/questdb/pooled_sender.rb +4 -1
  17. data/lib/questdb/sender.rb +10 -1
  18. data/lib/questdb/version.rb +2 -2
  19. data/script/check-native-linkage +115 -0
  20. data/test/client_test.rb +30 -0
  21. data/test/extconf_link_flags_test.rb +160 -0
  22. data/vendor/c-questdb-client/.bumpversion.toml +1 -1
  23. data/vendor/c-questdb-client/CMakeLists.txt +1 -1
  24. data/vendor/c-questdb-client/cpp_test/qwp_mock_server.hpp +10 -1
  25. data/vendor/c-questdb-client/cpp_test/test_arrow_c.c +39 -0
  26. data/vendor/c-questdb-client/cpp_test/test_reader_mock.cpp +428 -0
  27. data/vendor/c-questdb-client/doc/COMPATIBILITY.md +1 -1
  28. data/vendor/c-questdb-client/doc/CONSIDERATIONS.md +6 -3
  29. data/vendor/c-questdb-client/doc/RELEASE_RUNBOOK.md +7 -7
  30. data/vendor/c-questdb-client/doc/RELEASING.md +6 -6
  31. data/vendor/c-questdb-client/include/questdb/egress/qwp_reader.h +48 -1
  32. data/vendor/c-questdb-client/include/questdb/egress/qwp_reader.hpp +52 -0
  33. data/vendor/c-questdb-client/include/questdb/ingress/line_sender.h +46 -4
  34. data/vendor/c-questdb-client/include/questdb/ingress/line_sender.hpp +7 -4
  35. data/vendor/c-questdb-client/include/questdb/ingress/line_sender_core.hpp +1 -0
  36. data/vendor/c-questdb-client/questdb-rs/Cargo.toml +1 -1
  37. data/vendor/c-questdb-client/questdb-rs/README.md +13 -13
  38. data/vendor/c-questdb-client/questdb-rs/src/egress/config.rs +78 -0
  39. data/vendor/c-questdb-client/questdb-rs/src/egress/mod.rs +2 -2
  40. data/vendor/c-questdb-client/questdb-rs/src/egress/query_request.rs +174 -1
  41. data/vendor/c-questdb-client/questdb-rs/src/egress/reader.rs +264 -14
  42. data/vendor/c-questdb-client/questdb-rs/src/egress/wire/capabilities.rs +69 -0
  43. data/vendor/c-questdb-client/questdb-rs/src/egress/wire/msg_kind.rs +33 -0
  44. data/vendor/c-questdb-client/questdb-rs/src/error.rs +33 -5
  45. data/vendor/c-questdb-client/questdb-rs/src/ingress/buffer/qwp.rs +96 -11
  46. data/vendor/c-questdb-client/questdb-rs/src/ingress/buffer.rs +6 -3
  47. data/vendor/c-questdb-client/questdb-rs/src/ingress/column_sender/sender.rs +1 -16
  48. data/vendor/c-questdb-client/questdb-rs/src/ingress/sender/qwp_ws.rs +19 -13
  49. data/vendor/c-questdb-client/questdb-rs/src/ingress/sender/qwp_ws_driver.rs +2 -9
  50. data/vendor/c-questdb-client/questdb-rs/src/ingress/sender/qwp_ws_publisher.rs +3 -3
  51. data/vendor/c-questdb-client/questdb-rs/src/ingress/sender.rs +101 -17
  52. data/vendor/c-questdb-client/questdb-rs/src/ingress/tests.rs +32 -0
  53. data/vendor/c-questdb-client/questdb-rs/src/ingress.rs +3 -2
  54. data/vendor/c-questdb-client/questdb-rs/src/tests/qwp_ws.rs +96 -16
  55. data/vendor/c-questdb-client/questdb-rs/tests/egress_failover.rs +219 -1
  56. data/vendor/c-questdb-client/questdb-rs/tests/egress_live_server.rs +34 -0
  57. data/vendor/c-questdb-client/questdb-rs-ffi/Cargo.lock +2 -2
  58. data/vendor/c-questdb-client/questdb-rs-ffi/Cargo.toml +1 -1
  59. data/vendor/c-questdb-client/questdb-rs-ffi/src/egress.rs +31 -0
  60. data/vendor/c-questdb-client/questdb-rs-ffi/src/lib.rs +70 -3
  61. data/vendor/c-questdb-client/system_test/arrow_ffi.py +1 -0
  62. data/vendor/c-questdb-client/system_test/enterprise_e2e/tests/test_failover.py +12 -6
  63. data/vendor/c-questdb-client/system_test/enterprise_e2e/tests/test_orphan_drainer_bindings.py +12 -4
  64. data/vendor/c-questdb-client/system_test/failover_clients/Cargo.lock +1 -1
  65. data/vendor/c-questdb-client/system_test/soak/workload_rs/Cargo.lock +1 -1
  66. data/vendor/c-questdb-client.version +1 -1
  67. metadata +5 -2
@@ -35,6 +35,7 @@
35
35
  //! bind_count: varint
36
36
  //! binds: per egress::binds
37
37
  //! query_flags: varint optional trailer; omitted when 0
38
+ //! timeout_ms: varint present iff query_flags & QUERY_FLAG_TIMEOUT
38
39
  //! ```
39
40
 
40
41
  use std::net::Ipv4Addr;
@@ -54,6 +55,14 @@ pub const MAX_BINDS: usize = 1024;
54
55
  /// (query-scoped dict). Only honoured by servers advertising `CAP_QUERY_FLAGS`.
55
56
  pub const QUERY_FLAG_RESET_DICT: u64 = 0x01;
56
57
 
58
+ /// `query_flags` bit: a `timeout_ms: varint` field follows the `query_flags`
59
+ /// varint. Only honoured by servers advertising `CAP_QUERY_TIMEOUT`.
60
+ ///
61
+ /// The server rejects a missing, truncated, zero or negative `timeout_ms`
62
+ /// with `STATUS_PARSE_ERROR`, so "no timeout" is expressed by leaving this
63
+ /// bit clear rather than by sending `0`.
64
+ pub const QUERY_FLAG_TIMEOUT: u64 = 0x02;
65
+
57
66
  /// A complete, validated `QUERY_REQUEST` ready for serialization.
58
67
  #[derive(Debug, Clone)]
59
68
  pub struct QueryRequest {
@@ -62,6 +71,10 @@ pub struct QueryRequest {
62
71
  initial_credit: u64,
63
72
  binds: Vec<Bind>,
64
73
  query_flags: u64,
74
+ /// Per-query timeout in milliseconds. Serialized iff
75
+ /// [`QUERY_FLAG_TIMEOUT`] is set in `query_flags`; `build` keeps the
76
+ /// two in step.
77
+ timeout_ms: u64,
65
78
  }
66
79
 
67
80
  /// Byte offset of the 8-byte little-endian `request_id` field inside
@@ -88,6 +101,7 @@ impl QueryRequest {
88
101
  initial_credit: 0,
89
102
  binds: Vec::new(),
90
103
  query_flags: 0,
104
+ timeout_ms: 0,
91
105
  }
92
106
  }
93
107
 
@@ -112,6 +126,12 @@ impl QueryRequest {
112
126
  }
113
127
  if self.query_flags != 0 {
114
128
  varint::encode_u64(self.query_flags, out);
129
+ // Appended after the flags word, not after the binds: the
130
+ // server's decoder reads the flags varint and then this field
131
+ // only when the bit is set. `build` guarantees the pairing.
132
+ if self.query_flags & QUERY_FLAG_TIMEOUT != 0 {
133
+ varint::encode_u64(self.timeout_ms, out);
134
+ }
115
135
  }
116
136
  Ok(())
117
137
  }
@@ -129,6 +149,7 @@ pub struct QueryRequestBuilder {
129
149
  initial_credit: u64,
130
150
  binds: Vec<Bind>,
131
151
  query_flags: u64,
152
+ timeout_ms: u64,
132
153
  }
133
154
 
134
155
  impl QueryRequestBuilder {
@@ -145,12 +166,27 @@ impl QueryRequestBuilder {
145
166
  }
146
167
 
147
168
  /// Set the `query_flags` trailer (`0` = omit it). See
148
- /// [`QUERY_FLAG_RESET_DICT`]. Default `0`.
169
+ /// [`QUERY_FLAG_RESET_DICT`] and [`QUERY_FLAG_TIMEOUT`]. Default `0`.
149
170
  pub fn query_flags(mut self, flags: u64) -> Self {
150
171
  self.query_flags = flags;
151
172
  self
152
173
  }
153
174
 
175
+ /// Set the per-query `timeout_ms` field. Default `0` (field absent).
176
+ ///
177
+ /// Setting a non-zero value does **not** by itself put the field on the
178
+ /// wire: [`QUERY_FLAG_TIMEOUT`] must also be set in
179
+ /// [`query_flags`](Self::query_flags), which is what cap-gates the
180
+ /// field against servers that would reject it. [`build`](Self::build)
181
+ /// rejects the two being out of step.
182
+ ///
183
+ /// Capped at `i64::MAX`: the server decodes the field as a signed
184
+ /// long, so anything larger would arrive negative and be rejected.
185
+ pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
186
+ self.timeout_ms = timeout_ms.min(i64::MAX as u64);
187
+ self
188
+ }
189
+
154
190
  /// Append a typed bind parameter at the next position.
155
191
  pub fn bind(mut self, value: Bind) -> Self {
156
192
  self.binds.push(value);
@@ -268,12 +304,29 @@ impl QueryRequestBuilder {
268
304
  check_bindable(bind.kind())
269
305
  .map_err(|e| fmt!(InvalidBind, "bind ${}: {}", i + 1, e.msg()))?;
270
306
  }
307
+ // Keep the flag bit and the field in step. Either half alone
308
+ // produces a frame the server reads wrong: the bit without a
309
+ // positive value is a `STATUS_PARSE_ERROR` (it rejects zero), and
310
+ // a value without the bit silently drops the timeout while the
311
+ // caller believes it was applied. Caught here rather than on the
312
+ // wire so the mistake cannot leave the process.
313
+ let flag_set = self.query_flags & QUERY_FLAG_TIMEOUT != 0;
314
+ if flag_set != (self.timeout_ms > 0) {
315
+ return Err(fmt!(
316
+ InvalidApiCall,
317
+ "QUERY_FLAG_TIMEOUT and timeout_ms disagree: flag {}, timeout_ms {} \
318
+ (set both, or neither)",
319
+ if flag_set { "set" } else { "clear" },
320
+ self.timeout_ms,
321
+ ));
322
+ }
271
323
  Ok(QueryRequest {
272
324
  request_id: self.request_id,
273
325
  sql: self.sql,
274
326
  initial_credit: self.initial_credit,
275
327
  binds: self.binds,
276
328
  query_flags: self.query_flags,
329
+ timeout_ms: self.timeout_ms,
277
330
  })
278
331
  }
279
332
  }
@@ -424,6 +477,126 @@ mod tests {
424
477
  assert_eq!(*with_flag.last().unwrap(), QUERY_FLAG_RESET_DICT as u8);
425
478
  }
426
479
 
480
+ #[test]
481
+ fn timeout_trailer_follows_flags() {
482
+ // No timeout -> byte-identical to the bindless baseline, which is
483
+ // what keeps an older server seeing the layout it has always seen.
484
+ let mut baseline = Vec::new();
485
+ QueryRequest::builder("X")
486
+ .build()
487
+ .unwrap()
488
+ .encode(&mut baseline)
489
+ .unwrap();
490
+
491
+ let mut with_timeout = Vec::new();
492
+ QueryRequest::builder("X")
493
+ .query_flags(QUERY_FLAG_TIMEOUT)
494
+ .timeout_ms(1_000)
495
+ .build()
496
+ .unwrap()
497
+ .encode(&mut with_timeout)
498
+ .unwrap();
499
+
500
+ // flags varint (0x02) then timeout varint (1000 = 0xE8 0x07).
501
+ assert_eq!(&with_timeout[..baseline.len()], &baseline[..]);
502
+ assert_eq!(&with_timeout[baseline.len()..], &[0x02, 0xE8, 0x07]);
503
+ }
504
+
505
+ #[test]
506
+ fn timeout_trailer_combines_with_reset_dict() {
507
+ // Both bits in one word, timeout field last. The server decodes
508
+ // the flags varint first, so a client that emitted the field
509
+ // before the flags would be read as a bad flags word.
510
+ let mut buf = Vec::new();
511
+ QueryRequest::builder("X")
512
+ .bind_i64(7)
513
+ .query_flags(QUERY_FLAG_RESET_DICT | QUERY_FLAG_TIMEOUT)
514
+ .timeout_ms(1)
515
+ .build()
516
+ .unwrap()
517
+ .encode(&mut buf)
518
+ .unwrap();
519
+ assert_eq!(&buf[buf.len() - 2..], &[0x03, 0x01]);
520
+ }
521
+
522
+ #[test]
523
+ fn timeout_ms_caps_at_i64_max() {
524
+ let mut buf = Vec::new();
525
+ QueryRequest::builder("X")
526
+ .query_flags(QUERY_FLAG_TIMEOUT)
527
+ .timeout_ms(u64::MAX)
528
+ .build()
529
+ .unwrap()
530
+ .encode(&mut buf)
531
+ .unwrap();
532
+ let mut expected = vec![0xFF; 8];
533
+ expected.push(0x7F);
534
+ assert_eq!(&buf[buf.len() - 9..], &expected[..]);
535
+ }
536
+
537
+ #[test]
538
+ fn timeout_ms_without_flag_rejected() {
539
+ // A value with no bit would silently not reach the server.
540
+ let err = QueryRequest::builder("X")
541
+ .timeout_ms(1_000)
542
+ .build()
543
+ .unwrap_err();
544
+ assert_eq!(err.code(), ErrorCode::InvalidApiCall);
545
+ assert!(err.msg().contains("timeout_ms"), "msg: {}", err.msg());
546
+ }
547
+
548
+ #[test]
549
+ fn timeout_flag_without_value_rejected() {
550
+ // The bit with no value is `STATUS_PARSE_ERROR` at the server,
551
+ // which rejects a zero timeout.
552
+ let err = QueryRequest::builder("X")
553
+ .query_flags(QUERY_FLAG_TIMEOUT)
554
+ .build()
555
+ .unwrap_err();
556
+ assert_eq!(err.code(), ErrorCode::InvalidApiCall);
557
+ assert!(err.msg().contains("timeout_ms"), "msg: {}", err.msg());
558
+
559
+ // An explicit zero alongside the bit is the same mistake.
560
+ let err = QueryRequest::builder("X")
561
+ .query_flags(QUERY_FLAG_TIMEOUT)
562
+ .timeout_ms(0)
563
+ .build()
564
+ .unwrap_err();
565
+ assert_eq!(err.code(), ErrorCode::InvalidApiCall);
566
+ }
567
+
568
+ #[test]
569
+ fn query_flag_bits_are_distinct() {
570
+ assert_eq!(QUERY_FLAG_RESET_DICT, 0x01);
571
+ assert_eq!(QUERY_FLAG_TIMEOUT, 0x02);
572
+ assert_eq!(QUERY_FLAG_RESET_DICT & QUERY_FLAG_TIMEOUT, 0);
573
+ }
574
+
575
+ #[test]
576
+ fn timeout_does_not_move_the_request_id() {
577
+ // The failover-replay path patches `[REQUEST_ID_OFFSET..+8]` of the
578
+ // encoded buffer. The timeout field is a trailer, so it must not
579
+ // shift that span.
580
+ const SENTINEL: i64 = 0x0123_4567_89AB_CDEF;
581
+ let mut buf = Vec::new();
582
+ QueryRequest::builder("S")
583
+ .request_id(SENTINEL)
584
+ .query_flags(QUERY_FLAG_TIMEOUT)
585
+ .timeout_ms(5_000)
586
+ .build()
587
+ .unwrap()
588
+ .encode(&mut buf)
589
+ .unwrap();
590
+ assert_eq!(
591
+ i64::from_le_bytes(
592
+ buf[REQUEST_ID_OFFSET..REQUEST_ID_OFFSET + 8]
593
+ .try_into()
594
+ .unwrap()
595
+ ),
596
+ SENTINEL
597
+ );
598
+ }
599
+
427
600
  #[test]
428
601
  fn sql_too_long_rejected() {
429
602
  let big = "a".repeat(MAX_SQL_BYTES + 1);
@@ -52,7 +52,7 @@ use crate::egress::config::{Endpoint, ReaderConfig, Target};
52
52
  use crate::egress::decoder::DecodedBatch;
53
53
  use crate::egress::decoder::ZstdScratch;
54
54
  use crate::egress::query_request::{
55
- QUERY_FLAG_RESET_DICT, QueryRequest, QueryRequestBuilder, REQUEST_ID_OFFSET,
55
+ QUERY_FLAG_RESET_DICT, QUERY_FLAG_TIMEOUT, QueryRequest, QueryRequestBuilder, REQUEST_ID_OFFSET,
56
56
  };
57
57
  use crate::egress::schema::Schema;
58
58
  use crate::egress::server_event::UpgradeReject;
@@ -60,7 +60,7 @@ use crate::egress::server_event::{ServerEvent, ServerInfo, ServerRole, decode_fr
60
60
  use crate::egress::symbol_dict::SymbolDict;
61
61
  use crate::egress::tracker::HostHealthTracker;
62
62
  use crate::egress::transport::{CLOSE_TIMEOUT, WRITE_TIMEOUT, WsTransport};
63
- use crate::egress::wire::capabilities::has_query_flags;
63
+ use crate::egress::wire::capabilities::{has_query_flags, has_query_timeout};
64
64
  use crate::egress::wire::header::HEADER_LEN;
65
65
  use crate::egress::wire::msg_kind::MsgKind;
66
66
  use crate::egress::wire::varint;
@@ -696,10 +696,12 @@ impl Reader {
696
696
  /// exclusively borrows the reader; only one in-flight cursor at a
697
697
  /// time. Append binds in placeholder order, then call `.execute()`.
698
698
  pub fn prepare<S: Into<String>>(&mut self, sql: S) -> ReaderQuery<'_> {
699
+ let timeout_ms = self.cfg.query_timeout_ms;
699
700
  ReaderQuery {
700
701
  reader: self,
701
702
  builder: QueryRequest::builder(sql),
702
703
  reset_symbol_dict: false,
704
+ timeout_ms,
703
705
  on_failover_reset: None,
704
706
  on_failover_progress: None,
705
707
  }
@@ -876,6 +878,12 @@ pub struct ReaderQuery<'r> {
876
878
  /// `query_flags` trailer at [`Self::execute`] iff the server advertised
877
879
  /// `CAP_QUERY_FLAGS`.
878
880
  reset_symbol_dict: bool,
881
+ /// Per-query timeout in milliseconds; `0` = none. Seeded from
882
+ /// [`ReaderConfig::query_timeout_ms`] by [`Reader::prepare`] and
883
+ /// overridden by [`Self::timeout`]. Turned into a `timeout_ms` field at
884
+ /// [`Self::execute`], which also rejects a non-zero value against a
885
+ /// server that does not advertise `CAP_QUERY_TIMEOUT`.
886
+ timeout_ms: u64,
879
887
  /// Optional handler called every time the cursor reconnects after a
880
888
  /// transport-level failure (see [`FailoverResetEvent`]).
881
889
  on_failover_reset: Option<FailoverResetCallback<'r>>,
@@ -912,6 +920,55 @@ impl<'r> ReaderQuery<'r> {
912
920
  self
913
921
  }
914
922
 
923
+ /// Run this query under its own timeout instead of the server-wide
924
+ /// `query.timeout`, overriding the connection's
925
+ /// [`ReaderConfig::query_timeout_ms`] default.
926
+ ///
927
+ /// [`Duration::ZERO`] means "no client timeout" and clears a default
928
+ /// inherited from the connect string. The server sets no ceiling, so a
929
+ /// value above `query.timeout` is honoured — which is the point for a
930
+ /// long transform. A sub-millisecond non-zero duration is rounded *up*
931
+ /// to 1 ms: truncating it to zero would silently mean "no timeout".
932
+ ///
933
+ /// # Errors
934
+ ///
935
+ /// [`Self::execute`] fails with [`crate::ErrorCode::UnsupportedServer`]
936
+ /// when a non-zero timeout is set and the server does not advertise
937
+ /// `CAP_QUERY_TIMEOUT`, rather than running the query under the server
938
+ /// default and letting the caller believe its timeout applied. The
939
+ /// refusal happens before anything is written, so the connection is
940
+ /// untouched. Raise the server's `query.timeout` instead, or clear the
941
+ /// timeout here. A mid-query failover onto such a server fails the same
942
+ /// way instead of replaying the query. The code is deliberately not
943
+ /// [`crate::ErrorCode::QueryTimeout`]: that one means the budget ran
944
+ /// out, and retrying it with a larger budget is a reasonable reaction
945
+ /// that would never succeed here.
946
+ ///
947
+ /// # Behaviour on expiry
948
+ ///
949
+ /// The query ends with [`crate::ErrorCode::QueryTimeout`] and the
950
+ /// connection stays open and authenticated, so the next query runs on
951
+ /// it without reconnecting. **Do not retry a write on that error:** a
952
+ /// statement that finishes past its timeout is reported as `EXEC_DONE`
953
+ /// instead (so a retry cannot apply it twice), and a DDL / `INSERT` /
954
+ /// `UPDATE` that timed out waiting for the table writer may still be
955
+ /// applied afterwards.
956
+ ///
957
+ /// The server enforces it per request, so a failover replay re-sends
958
+ /// the full timeout rather than the remainder. Nothing bounds the
959
+ /// query's end-to-end wall-clock:
960
+ /// [`ReaderConfig::failover_max_duration_ms`] only limits when a new
961
+ /// failover round may start.
962
+ pub fn timeout(mut self, timeout: Duration) -> Self {
963
+ let ms = timeout.as_millis();
964
+ self.timeout_ms = if ms == 0 && !timeout.is_zero() {
965
+ 1
966
+ } else {
967
+ ms.min(u64::MAX as u128) as u64
968
+ };
969
+ self
970
+ }
971
+
915
972
  /// Install a callback fired every time the cursor's underlying
916
973
  /// connection is replaced via mid-query failover. The closure
917
974
  /// receives a [`FailoverResetEvent`] describing the new endpoint and
@@ -1144,20 +1201,18 @@ impl<'r> ReaderQuery<'r> {
1144
1201
  // Cap-gate the query_flags trailer: only emit it when the server
1145
1202
  // advertised CAP_QUERY_FLAGS, so an older server sees the baseline
1146
1203
  // QUERY_REQUEST layout and the reset request silently degrades.
1147
- let server_supports_query_flags = self
1204
+ let capabilities = self
1148
1205
  .reader
1149
1206
  .server_info()
1150
- .map(|info| has_query_flags(info.capabilities))
1151
- .unwrap_or(false);
1152
- let query_flags = if self.reset_symbol_dict && server_supports_query_flags {
1153
- QUERY_FLAG_RESET_DICT
1154
- } else {
1155
- 0
1156
- };
1207
+ .map(|info| info.capabilities)
1208
+ .unwrap_or(0);
1209
+ let query_flags =
1210
+ derive_query_flags(capabilities, self.reset_symbol_dict, self.timeout_ms)?;
1157
1211
  let req = self
1158
1212
  .builder
1159
1213
  .request_id(request_id)
1160
1214
  .query_flags(query_flags)
1215
+ .timeout_ms(self.timeout_ms)
1161
1216
  .build()?;
1162
1217
  let credit_enabled = req.initial_credit() > 0;
1163
1218
  // Encode the QUERY_REQUEST once and stash the bytes on the
@@ -1223,6 +1278,7 @@ impl<'r> ReaderQuery<'r> {
1223
1278
  done: false,
1224
1279
  terminal_error: None,
1225
1280
  encoded_request,
1281
+ timeout_ms: self.timeout_ms,
1226
1282
  on_failover_reset: self.on_failover_reset,
1227
1283
  on_failover_progress: self.on_failover_progress,
1228
1284
  failover_budget,
@@ -1244,6 +1300,49 @@ impl<'r> ReaderQuery<'r> {
1244
1300
  }
1245
1301
  }
1246
1302
 
1303
+ /// The `query_flags` word for a query sent to a server advertising
1304
+ /// `capabilities`.
1305
+ fn derive_query_flags(capabilities: u32, reset_symbol_dict: bool, timeout_ms: u64) -> Result<u64> {
1306
+ let mut query_flags = 0u64;
1307
+ if reset_symbol_dict && has_query_flags(capabilities) {
1308
+ query_flags |= QUERY_FLAG_RESET_DICT;
1309
+ }
1310
+ if timeout_ms > 0 {
1311
+ // A timeout is a correctness request, not a hint, so it does
1312
+ // NOT degrade the way `reset_symbol_dict` does: silently
1313
+ // dropping it would leave the caller believing it had a long
1314
+ // budget while the server kills the query at `query.timeout`.
1315
+ // The field rides *inside* the query_flags trailer, so both
1316
+ // bits are required — a server advertising the timeout bit
1317
+ // without the flags bit could not parse the frame we would
1318
+ // send.
1319
+ require_query_timeout(capabilities, timeout_ms)?;
1320
+ query_flags |= QUERY_FLAG_TIMEOUT;
1321
+ }
1322
+ Ok(query_flags)
1323
+ }
1324
+
1325
+ /// Fails with `UnsupportedServer` when `capabilities` cannot carry a
1326
+ /// non-zero `timeout_ms`. Checked on every connection a query is sent to,
1327
+ /// so a failover replay cannot land the timeout on a server that ignores it.
1328
+ fn require_query_timeout(capabilities: u32, timeout_ms: u64) -> Result<()> {
1329
+ if timeout_ms > 0 && !(has_query_flags(capabilities) && has_query_timeout(capabilities)) {
1330
+ return Err(fmt!(
1331
+ UnsupportedServer,
1332
+ "server does not support per-query timeouts \
1333
+ (SERVER_INFO capabilities 0x{:08X}, need CAP_QUERY_TIMEOUT 0x{:08X} \
1334
+ and CAP_QUERY_FLAGS 0x{:08X}): the requested {} ms timeout cannot be \
1335
+ honoured. Raise the server's `query.timeout` instead, or clear the \
1336
+ per-query timeout.",
1337
+ capabilities,
1338
+ crate::egress::wire::capabilities::CAP_QUERY_TIMEOUT,
1339
+ crate::egress::wire::capabilities::CAP_QUERY_FLAGS,
1340
+ timeout_ms,
1341
+ ));
1342
+ }
1343
+ Ok(())
1344
+ }
1345
+
1247
1346
  /// Patch the request_id span of a stashed `QUERY_REQUEST` payload in
1248
1347
  /// place and return it as fresh `Bytes`.
1249
1348
  ///
@@ -1423,9 +1522,20 @@ pub enum Terminal {
1423
1522
  /// `RESULT_END` (`0x12`).
1424
1523
  End { final_seq: u64, total_rows: u64 },
1425
1524
  /// `EXEC_DONE` (`0x16`) — non-SELECT acknowledgement.
1525
+ ///
1526
+ /// `rows_affected` is the row count of an `INSERT` / `UPDATE`. The
1527
+ /// server reports no count for statements it executes at parse time
1528
+ /// (`TRUNCATE`, `RENAME TABLE`, `SET`, ...): it sends `-1`, which the
1529
+ /// unsigned varint carries as [`ROWS_AFFECTED_UNKNOWN`]. `op_type` is
1530
+ /// the server's operation-type byte, carried through opaquely.
1426
1531
  ExecDone { op_type: u8, rows_affected: u64 },
1427
1532
  }
1428
1533
 
1534
+ /// The `rows_affected` value of a [`Terminal::ExecDone`] whose statement
1535
+ /// reports no row count (`u64::MAX`, the server's `-1` as an unsigned
1536
+ /// varint). Compare against it rather than against the literal.
1537
+ pub const ROWS_AFFECTED_UNKNOWN: u64 = u64::MAX;
1538
+
1429
1539
  /// Streaming cursor over `RESULT_BATCH` frames.
1430
1540
  ///
1431
1541
  /// `next_batch` advances the stream by one batch, returning `None` once a
@@ -1453,6 +1563,8 @@ pub struct Cursor<'r> {
1453
1563
  /// across reconnects, only the 8-byte request_id span is mutated
1454
1564
  /// in place.
1455
1565
  encoded_request: Bytes,
1566
+ /// Per-query timeout baked into `encoded_request`; `0` = none.
1567
+ timeout_ms: u64,
1456
1568
  /// User callback fired right before replayed batches arrive on a
1457
1569
  /// new connection. See [`ReaderQuery::on_failover_reset`].
1458
1570
  on_failover_reset: Option<FailoverResetCallback<'r>>,
@@ -1572,9 +1684,17 @@ impl<'r> Cursor<'r> {
1572
1684
  self.terminal.as_ref()
1573
1685
  }
1574
1686
 
1575
- /// Whether dropping this cursor leaves its reader connection reusable.
1687
+ /// Whether dropping this cursor leaves its reader connection reusable:
1688
+ /// the stream reached a terminal frame (or was cancelled to one), no
1689
+ /// query is still active on the connection, and the transport is up.
1690
+ ///
1691
+ /// `done` alone is not enough. A cursor ended mid-stream by a
1692
+ /// client-side error (an Arrow conversion failure, say) is `done` but
1693
+ /// still has the query's frames in flight; [`Drop`] sends `CANCEL` and
1694
+ /// tears the transport down for it, so it must report `false` here
1695
+ /// rather than let a pool recycle a connection that is about to die.
1576
1696
  pub fn connection_reusable(&self) -> bool {
1577
- self.done && !self.reader.transport_torn_down()
1697
+ self.done && !self.reader.cursor_active && !self.reader.transport_torn_down()
1578
1698
  }
1579
1699
 
1580
1700
  /// Pass-through to [`Reader::credit_granted_total`]. Exists so
@@ -2095,8 +2215,7 @@ impl<'r> Cursor<'r> {
2095
2215
  match self.replay_query_same_connection() {
2096
2216
  Ok(()) => continue,
2097
2217
  Err(e) => {
2098
- self.reader.cursor_active = false;
2099
- self.done = true;
2218
+ self.terminate_with_close();
2100
2219
  return Err(e);
2101
2220
  }
2102
2221
  }
@@ -2448,6 +2567,33 @@ impl<'r> Cursor<'r> {
2448
2567
  // and queries that may carry multi-MB `Bind::Binary`
2449
2568
  // payloads, this is the difference between a few bytes
2450
2569
  // and gigabytes of churn per failure event.
2570
+ // The stashed bytes carry the timeout the *original* server
2571
+ // accepted; a replacement that lacks the capability would run
2572
+ // the query under its own default instead.
2573
+ let capabilities = self
2574
+ .reader
2575
+ .server_info()
2576
+ .map(|info| info.capabilities)
2577
+ .unwrap_or(0);
2578
+ if let Err(e) = require_query_timeout(capabilities, self.timeout_ms) {
2579
+ if let Some(cb) = self.on_failover_progress.as_mut() {
2580
+ let event = FailoverProgressEvent {
2581
+ phase: FailoverPhase::GaveUp,
2582
+ failed_addr: failed_addr.clone(),
2583
+ new_addr: None,
2584
+ new_server_info: None,
2585
+ new_request_id: None,
2586
+ attempt: attempts,
2587
+ trigger: trigger.clone(),
2588
+ elapsed: started.elapsed(),
2589
+ final_error: Some(e.clone()),
2590
+ };
2591
+ cb(&event);
2592
+ }
2593
+ self.reader.cursor_active = false;
2594
+ self.done = true;
2595
+ return Err(e);
2596
+ }
2451
2597
  let new_rid = self.reader.alloc_request_id();
2452
2598
  self.request_id = new_rid;
2453
2599
  self.encoded_request =
@@ -2681,6 +2827,19 @@ impl<'r> Cursor<'r> {
2681
2827
  None => fmt!(InvalidApiCall, "cursor is terminal; add_credit not allowed"),
2682
2828
  });
2683
2829
  }
2830
+ // Same first-cause capture as `next_batch`: without it a cursor
2831
+ // killed here reads as a clean EOF on the next `next_batch`.
2832
+ let result = self.add_credit_inner(additional_bytes);
2833
+ if let Err(e) = &result
2834
+ && self.done
2835
+ && self.terminal_error.is_none()
2836
+ {
2837
+ self.terminal_error = Some(e.clone());
2838
+ }
2839
+ result
2840
+ }
2841
+
2842
+ fn add_credit_inner(&mut self, additional_bytes: u64) -> Result<()> {
2684
2843
  let first_err = match self.send_credit_frame(additional_bytes) {
2685
2844
  Ok(()) => return Ok(()),
2686
2845
  Err(e) => e,
@@ -3261,6 +3420,7 @@ fn map_server_status(
3261
3420
  S::SecurityError => C::ServerSecurityError,
3262
3421
  S::Cancelled => C::Cancelled,
3263
3422
  S::LimitExceeded => C::ServerLimitExceeded,
3423
+ S::QueryTimeout => C::QueryTimeout,
3264
3424
  };
3265
3425
  crate::Error::new(code, message)
3266
3426
  }
@@ -3367,6 +3527,90 @@ mod tests {
3367
3527
  assert_eq!(i64::from_le_bytes(id_bytes), OLD_RID);
3368
3528
  }
3369
3529
 
3530
+ /// The flag word `execute()` derives from the capability bits. Pinned
3531
+ /// here as a truth table so the cap-gating cannot regress into
3532
+ /// "always send" (which an older server rejects) or "never send".
3533
+ #[test]
3534
+ fn query_flag_derivation_matrix() {
3535
+ use crate::egress::wire::capabilities::{CAP_QUERY_FLAGS, CAP_QUERY_TIMEOUT, CAP_ZONE};
3536
+
3537
+ /// `None` = execute() must fail fast.
3538
+ fn derive(caps: u32, reset_dict: bool, timeout_ms: u64) -> Option<u64> {
3539
+ derive_query_flags(caps, reset_dict, timeout_ms).ok()
3540
+ }
3541
+
3542
+ let both = CAP_QUERY_FLAGS | CAP_QUERY_TIMEOUT;
3543
+
3544
+ // No timeout requested: unchanged behaviour on every server.
3545
+ assert_eq!(derive(0, false, 0), Some(0));
3546
+ assert_eq!(
3547
+ derive(0, true, 0),
3548
+ Some(0),
3549
+ "no caps -> dict reset degrades"
3550
+ );
3551
+ assert_eq!(
3552
+ derive(CAP_QUERY_FLAGS, true, 0),
3553
+ Some(QUERY_FLAG_RESET_DICT)
3554
+ );
3555
+
3556
+ // Timeout requested against a capable server.
3557
+ assert_eq!(derive(both, false, 1_000), Some(QUERY_FLAG_TIMEOUT));
3558
+ assert_eq!(
3559
+ derive(both, true, 1_000),
3560
+ Some(QUERY_FLAG_RESET_DICT | QUERY_FLAG_TIMEOUT),
3561
+ "both bits ride in one flags word"
3562
+ );
3563
+ assert_eq!(
3564
+ derive(both | CAP_ZONE, false, 1_000),
3565
+ Some(QUERY_FLAG_TIMEOUT),
3566
+ "unrelated capability bits must not disturb the derivation"
3567
+ );
3568
+
3569
+ // Timeout requested against a server that cannot honour it.
3570
+ assert_eq!(derive(0, false, 1_000), None, "no capabilities at all");
3571
+ assert_eq!(
3572
+ derive(CAP_QUERY_FLAGS, false, 1_000),
3573
+ None,
3574
+ "flags trailer but no timeout support"
3575
+ );
3576
+ assert_eq!(
3577
+ derive(CAP_QUERY_TIMEOUT, false, 1_000),
3578
+ None,
3579
+ "timeout bit without the flags trailer it rides in"
3580
+ );
3581
+ }
3582
+
3583
+ #[test]
3584
+ fn missing_timeout_capability_is_unsupported_server_not_query_timeout() {
3585
+ use crate::egress::wire::capabilities::{CAP_QUERY_FLAGS, CAP_QUERY_TIMEOUT};
3586
+
3587
+ for caps in [0, CAP_QUERY_FLAGS, CAP_QUERY_TIMEOUT] {
3588
+ let err = require_query_timeout(caps, 1_000).expect_err("must refuse");
3589
+ assert_eq!(
3590
+ err.code(),
3591
+ ErrorCode::UnsupportedServer,
3592
+ "caps 0x{caps:08X}"
3593
+ );
3594
+ assert!(err.msg().contains("CAP_QUERY_TIMEOUT"), "{}", err.msg());
3595
+ assert!(err.msg().contains("query.timeout"), "{}", err.msg());
3596
+ }
3597
+ assert!(require_query_timeout(CAP_QUERY_FLAGS | CAP_QUERY_TIMEOUT, 1_000).is_ok());
3598
+ assert!(require_query_timeout(0, 0).is_ok());
3599
+ }
3600
+
3601
+ /// A timeout error must never be in-doubt-free silence: a write that
3602
+ /// timed out may still land, so the code has to be distinguishable
3603
+ /// from a cancel and from a server limit.
3604
+ #[test]
3605
+ fn query_timeout_is_its_own_code() {
3606
+ use crate::egress::wire::msg_kind::StatusCode as S;
3607
+ let timeout = map_server_status(S::QueryTimeout, "too slow".to_string());
3608
+ assert_eq!(timeout.code(), ErrorCode::QueryTimeout);
3609
+ assert_ne!(timeout.code(), ErrorCode::Cancelled);
3610
+ assert_ne!(timeout.code(), ErrorCode::ServerLimitExceeded);
3611
+ assert_eq!(timeout.msg(), "too slow");
3612
+ }
3613
+
3370
3614
  /// Exhaustively pin `is_failover_eligible` against every
3371
3615
  /// `ErrorCode` variant. The function is a single `matches!` arm
3372
3616
  /// today; this guards against (a) silently dropping an arm
@@ -3414,6 +3658,11 @@ mod tests {
3414
3658
  LimitExceeded,
3415
3659
  ServerLimitExceeded,
3416
3660
  Cancelled,
3661
+ // A timeout is the server having done exactly what it was
3662
+ // told. Replaying it on another endpoint would re-run the
3663
+ // work under the same budget and, for a write, risk applying
3664
+ // a statement the first attempt may already have applied.
3665
+ QueryTimeout,
3417
3666
  ] {
3418
3667
  assert!(
3419
3668
  !is_failover_eligible(code),
@@ -3441,6 +3690,7 @@ mod tests {
3441
3690
  (S::SecurityError, C::ServerSecurityError),
3442
3691
  (S::Cancelled, C::Cancelled),
3443
3692
  (S::LimitExceeded, C::ServerLimitExceeded),
3693
+ (S::QueryTimeout, C::QueryTimeout),
3444
3694
  ];
3445
3695
 
3446
3696
  for (status, expected_code) in cases {