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
@@ -3664,6 +3664,40 @@ fn cursor_short_circuits_after_query_error() {
3664
3664
  }
3665
3665
  }
3666
3666
 
3667
+ #[test]
3668
+ #[ignore = "the pinned questdb submodule does not advertise CAP_QUERY_TIMEOUT yet"]
3669
+ fn query_timeout_ends_query_and_keeps_connection() {
3670
+ let srv = server();
3671
+ let mut reader = make_reader(srv);
3672
+ let mut cur = reader
3673
+ .prepare("sleep(10)")
3674
+ .timeout(Duration::from_secs(1))
3675
+ .execute()
3676
+ .expect("execute");
3677
+ let err = loop {
3678
+ match cur.next_batch() {
3679
+ Ok(Some(_)) => {}
3680
+ Ok(None) => panic!("sleep(10) must not finish under a 1s timeout"),
3681
+ Err(e) => break e,
3682
+ }
3683
+ };
3684
+ assert_eq!(
3685
+ err.code(),
3686
+ questdb::ErrorCode::QueryTimeout,
3687
+ "{}",
3688
+ err.msg()
3689
+ );
3690
+ assert!(cur.connection_reusable());
3691
+ drop(cur);
3692
+
3693
+ let mut cur = reader
3694
+ .prepare("SELECT x FROM long_sequence(3)")
3695
+ .execute()
3696
+ .expect("execute after timeout");
3697
+ let view = cur.next_batch().expect("next_batch").expect("Some batch");
3698
+ assert_eq!(view.row_count(), 3);
3699
+ }
3700
+
3667
3701
  #[test]
3668
3702
  fn null_handling_long_densifies() {
3669
3703
  let srv = server();
@@ -1173,7 +1173,7 @@ dependencies = [
1173
1173
 
1174
1174
  [[package]]
1175
1175
  name = "questdb-rs"
1176
- version = "7.1.0"
1176
+ version = "7.2.0"
1177
1177
  dependencies = [
1178
1178
  "aligned-vec",
1179
1179
  "arrow",
@@ -1207,7 +1207,7 @@ dependencies = [
1207
1207
 
1208
1208
  [[package]]
1209
1209
  name = "questdb-rs-ffi"
1210
- version = "7.1.0"
1210
+ version = "7.2.0"
1211
1211
  dependencies = [
1212
1212
  "arrow",
1213
1213
  "libc",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "questdb-rs-ffi"
3
- version = "7.1.0"
3
+ version = "7.2.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.91.0"
6
6
  publish = false
@@ -2877,6 +2877,10 @@ pub unsafe extern "C" fn qwp_reader_cursor_terminal_end(
2877
2877
  /// If the cursor's terminal is `EXEC_DONE`, set `*out_op_type` and
2878
2878
  /// `*out_rows_affected` and return true. Otherwise zeroes both outputs and
2879
2879
  /// returns false. NULL handle also zeroes the outputs and returns false.
2880
+ ///
2881
+ /// A statement the server executes at parse time reports no row count: it
2882
+ /// sends `-1`, which arrives as `ROWS_AFFECTED_UNKNOWN` (`u64::MAX`, the
2883
+ /// header's `QWP_READER_ROWS_AFFECTED_UNKNOWN`).
2880
2884
  #[unsafe(no_mangle)]
2881
2885
  pub unsafe extern "C" fn qwp_reader_cursor_terminal_exec_done(
2882
2886
  cursor: *const qwp_reader_cursor,
@@ -3020,6 +3024,33 @@ pub unsafe extern "C" fn qwp_reader_query_set_reset_symbol_dict(
3020
3024
  unsafe { mutate_query(query, |q| q.reset_symbol_dict(reset)) }
3021
3025
  }
3022
3026
 
3027
+ /// Run this query under its own timeout, in milliseconds, instead of the
3028
+ /// server-wide `query.timeout`. Overrides the connect string's
3029
+ /// `query_timeout_ms`; `0` clears it. Mirrors `ReaderQuery::timeout`.
3030
+ ///
3031
+ /// Unlike `qwp_reader_query_set_reset_symbol_dict`, this does NOT silently
3032
+ /// degrade: against a server that does not advertise `CAP_QUERY_TIMEOUT`,
3033
+ /// `qwp_reader_query_execute` fails with
3034
+ /// `line_sender_error_unsupported_server`, before anything is written,
3035
+ /// rather than letting the caller believe a timeout applied; the connection
3036
+ /// is untouched by that refusal. On expiry the query ends with
3037
+ /// `line_sender_error_query_timeout` and the connection stays usable — but
3038
+ /// do not retry a write on it, because a statement that outlives its
3039
+ /// timeout is reported as `EXEC_DONE` instead, and a DDL / INSERT / UPDATE
3040
+ /// that timed out waiting for the table writer may still be applied
3041
+ /// afterwards.
3042
+ #[unsafe(no_mangle)]
3043
+ pub unsafe extern "C" fn qwp_reader_query_set_timeout_ms(
3044
+ query: *mut qwp_reader_query,
3045
+ timeout_ms: u64,
3046
+ ) {
3047
+ unsafe {
3048
+ mutate_query(query, |q| {
3049
+ q.timeout(std::time::Duration::from_millis(timeout_ms))
3050
+ })
3051
+ }
3052
+ }
3053
+
3023
3054
  /// Report a NULL out-param contract violation through `err_out`.
3024
3055
  #[inline]
3025
3056
  unsafe fn null_out_param_err(err_out: *mut *mut questdb_error, fn_name: &str) {
@@ -337,6 +337,10 @@ pub enum line_sender_error_code {
337
337
  /// HTTP-upgrade or WebSocket handshake failure.
338
338
  line_sender_error_handshake_error = 20,
339
339
  /// Server returned an unsupported QWP version, encoding, or capability.
340
+ /// Also raised client-side, before anything is written, when a query
341
+ /// carries a per-query timeout and the server does not advertise
342
+ /// `CAP_QUERY_TIMEOUT` (see `qwp_reader_query_set_timeout_ms`); the
343
+ /// connection is untouched by that refusal.
340
344
  line_sender_error_unsupported_server = 21,
341
345
  /// Wire-format violation: bad magic, truncated frame, unknown
342
346
  /// discriminant, invalid varint, symbol-dict reference miss, etc.
@@ -442,6 +446,24 @@ pub enum line_sender_error_code {
442
446
  /// check `line_sender_error_in_doubt` before resending, or the rows the
443
447
  /// queued prefix already carried are duplicated.
444
448
  line_sender_error_symbol_dict_full = 37,
449
+
450
+ /// The query ran past the timeout it carried: server-reported QWP
451
+ /// `QUERY_TIMEOUT` (status `0x0E`), produced only for a request that
452
+ /// supplied `timeout_ms` (see `qwp_reader_query_set_timeout_ms`). A
453
+ /// timeout requested against a server that does not advertise
454
+ /// `CAP_QUERY_TIMEOUT` is refused with
455
+ /// `line_sender_error_unsupported_server` instead, so retrying a timeout
456
+ /// with a larger budget cannot loop against a server that ignores it.
457
+ ///
458
+ /// Per-query, not per-connection: the connection stays open and
459
+ /// authenticated, so the next query runs on it without reconnecting.
460
+ ///
461
+ /// Do not retry a write on this error. A statement that completes past its
462
+ /// timeout is reported as `EXEC_DONE` with its row count rather than as a
463
+ /// timeout, precisely so a retrying client cannot apply it twice; and a
464
+ /// DDL / INSERT / UPDATE that timed out waiting for the table writer may
465
+ /// still be applied by that writer afterwards.
466
+ line_sender_error_query_timeout = 38,
445
467
  }
446
468
 
447
469
  /// Neutral spelling of the client-wide error category. The released
@@ -533,6 +555,7 @@ impl From<ErrorCode> for line_sender_error_code {
533
555
  line_sender_error_code::line_sender_error_store_resend_required
534
556
  }
535
557
  ErrorCode::SymbolDictFull => line_sender_error_code::line_sender_error_symbol_dict_full,
558
+ ErrorCode::QueryTimeout => line_sender_error_code::line_sender_error_query_timeout,
536
559
  _ => line_sender_error_code::line_sender_error_invalid_api_call,
537
560
  }
538
561
  }
@@ -1493,9 +1516,12 @@ pub unsafe extern "C" fn line_sender_buffer_clear(buffer: *mut line_sender_buffe
1493
1516
 
1494
1517
  /// The current encoded size of the buffered data.
1495
1518
  ///
1496
- /// For ILP buffers this is the exact pending byte length. For QWP buffers this
1497
- /// is a buffered size hint, not the exact size of an eventual UDP datagram or
1498
- /// WebSocket replay frame.
1519
+ /// For ILP buffers this is the exact pending byte length. For QWP/UDP buffers
1520
+ /// this is a buffered size hint, not the exact size of an eventual datagram.
1521
+ /// For QWP/WebSocket buffers it is at least the size of the frame that
1522
+ /// flushing the buffer encodes, unless store-and-forward has fallen back to
1523
+ /// sending full symbol dictionaries because its symbol side-file could not be
1524
+ /// opened or rolled back.
1499
1525
  #[unsafe(no_mangle)]
1500
1526
  pub unsafe extern "C" fn line_sender_buffer_size(buffer: *const line_sender_buffer) -> size_t {
1501
1527
  unsafe {
@@ -3932,6 +3958,46 @@ pub unsafe extern "C" fn line_sender_qwpws_errors_dropped(
3932
3958
  }
3933
3959
  }
3934
3960
 
3961
+ #[unsafe(no_mangle)]
3962
+ pub unsafe extern "C" fn line_sender_qwpws_effective_frame_cap(
3963
+ sender: *const line_sender,
3964
+ cap_out: *mut usize,
3965
+ server_cap_known_out: *mut bool,
3966
+ err_out: *mut *mut line_sender_error,
3967
+ ) -> bool {
3968
+ unsafe {
3969
+ if cap_out.is_null() || server_cap_known_out.is_null() {
3970
+ let output = if cap_out.is_null() {
3971
+ "cap_out"
3972
+ } else {
3973
+ "server_cap_known_out"
3974
+ };
3975
+ set_err_out(
3976
+ err_out,
3977
+ ErrorCode::InvalidApiCall,
3978
+ format!("line_sender_qwpws_effective_frame_cap requires non-NULL {output}"),
3979
+ );
3980
+ return false;
3981
+ }
3982
+ let Some(sender) =
3983
+ qwpws_line_sender_ref(sender, err_out, "line_sender_qwpws_effective_frame_cap")
3984
+ else {
3985
+ return false;
3986
+ };
3987
+ match sender.effective_frame_cap() {
3988
+ Ok((cap, server_cap_known)) => {
3989
+ *cap_out = cap;
3990
+ *server_cap_known_out = server_cap_known;
3991
+ true
3992
+ }
3993
+ Err(err) => {
3994
+ set_err_out_from_sender_error(err_out, sender, err);
3995
+ false
3996
+ }
3997
+ }
3998
+ }
3999
+ }
4000
+
3935
4001
  #[unsafe(no_mangle)]
3936
4002
  pub unsafe extern "C" fn line_sender_qwpws_close_drain(
3937
4003
  sender: *mut line_sender,
@@ -5873,6 +5939,7 @@ mod tests {
5873
5939
  36,
5874
5940
  ),
5875
5941
  (E::SymbolDictFull, line_sender_error_symbol_dict_full, 37),
5942
+ (E::QueryTimeout, line_sender_error_query_timeout, 38),
5876
5943
  ]
5877
5944
  }
5878
5945
 
@@ -148,6 +148,7 @@ class ClientErrorCode:
148
148
  BATCH_TOO_LARGE = 35
149
149
  STORE_RESEND_REQUIRED = 36
150
150
  SYMBOL_DICT_FULL = 37
151
+ QUERY_TIMEOUT = 38
151
152
 
152
153
 
153
154
  def _setsig(name, restype, *argtypes):
@@ -798,19 +798,25 @@ def test_orphan_drainer_durable_ack_survives_kill_c_client_rust(
798
798
  p1.kill_9()
799
799
  wait_port_free(p1_ports.http)
800
800
  wait_port_free(p1_ports.pg)
801
+ # Stop the old drainer before P2 opens the same ports. It can reconnect
802
+ # autonomously, and the fresh CONNECT below could replay the slot again.
803
+ c_client_rust_sidecar.close()
801
804
  if p1.db_root.exists():
802
805
  shutil.rmtree(p1.db_root)
803
806
  obj_store.wipe()
804
807
 
805
808
  p2 = server_factory("p2", db_root_name="p2-fresh")
806
809
  p2.start(http_port=p1_ports.http, pg_port=p1_ports.pg)
810
+ # Replay is at least once across a lost ACK. Prepare the fresh target with
811
+ # the same dedup key used by the other SF recovery tests.
812
+ execute_ddl(port=p1_ports.pg, ddl=(
813
+ f'CREATE TABLE "{table}" ('
814
+ "v LONG, timestamp TIMESTAMP"
815
+ ") TIMESTAMP(timestamp) PARTITION BY DAY WAL "
816
+ "DEDUP UPSERT KEYS(timestamp, v)"
817
+ ))
807
818
 
808
- # When P1 died mid-drain the orphan worker returned RetryLater and stopped:
809
- # the Rust drainer (drain_orphan_to_completion) does NOT autonomously
810
- # reconnect to a successor within one attempt. The orphan slot is only
811
- # re-attempted by a *fresh* orphan scan, which runs on the next CONNECT with
812
- # drain_orphans=on. Reconnect the foreground sender so a new scan adopts the
813
- # still-un-acked ghost slot and replays it to P2.
819
+ # A new scan adopts the still-un-acked ghost slot and replays it to P2.
814
820
  c_client_rust_sidecar.connect(fg_cs)
815
821
 
816
822
  wait_for_dense_sequence(port=p1_ports.pg, table=table,
@@ -25,7 +25,7 @@ from pathlib import Path
25
25
 
26
26
  import pytest
27
27
 
28
- from lib.pg_query import count_rows, wait_for_dense_sequence
28
+ from lib.pg_query import count_rows, execute_ddl, wait_for_dense_sequence
29
29
  from lib.server import wait_port_free
30
30
 
31
31
  LOG = logging.getLogger(__name__)
@@ -76,15 +76,23 @@ def _survives_kill_body(sidecar, server_factory, obj_store, scenario_dir: Path,
76
76
  p1.kill_9()
77
77
  wait_port_free(p1_ports.http)
78
78
  wait_port_free(p1_ports.pg)
79
+ # Stop the old drainer before P2 opens the same ports; it can reconnect
80
+ # autonomously while the fresh CONNECT below is preparing another replay.
81
+ sidecar.close()
79
82
  if p1.db_root.exists():
80
83
  shutil.rmtree(p1.db_root)
81
84
  obj_store.wipe()
82
85
 
83
86
  p2 = server_factory("p2", db_root_name="p2-fresh")
84
87
  p2.start(http_port=p1_ports.http, pg_port=p1_ports.pg)
85
-
86
- # A fresh orphan scan on reconnect re-adopts the still-un-acked ghost slot
87
- # and replays it to P2.
88
+ execute_ddl(port=p1_ports.pg, ddl=(
89
+ f'CREATE TABLE "{table}" ('
90
+ "v LONG, timestamp TIMESTAMP"
91
+ ") TIMESTAMP(timestamp) PARTITION BY DAY WAL "
92
+ "DEDUP UPSERT KEYS(timestamp, v)"
93
+ ))
94
+
95
+ # A fresh scan re-adopts the still-un-acked ghost slot and replays it to P2.
88
96
  sidecar.connect(fg_cs)
89
97
  wait_for_dense_sequence(port=p1_ports.pg, table=table,
90
98
  expected_count=row_count, timeout_s=60.0)
@@ -2679,7 +2679,7 @@ checksum = "7aceffde1cbf8e67f34cdfd70d2436396176d6ff648fa719e0231fb9856ef3e9"
2679
2679
 
2680
2680
  [[package]]
2681
2681
  name = "questdb-rs"
2682
- version = "7.1.0"
2682
+ version = "7.2.0"
2683
2683
  dependencies = [
2684
2684
  "aligned-vec",
2685
2685
  "arrow",
@@ -2785,7 +2785,7 @@ checksum = "7aceffde1cbf8e67f34cdfd70d2436396176d6ff648fa719e0231fb9856ef3e9"
2785
2785
 
2786
2786
  [[package]]
2787
2787
  name = "questdb-rs"
2788
- version = "7.1.0"
2788
+ version = "7.2.0"
2789
2789
  dependencies = [
2790
2790
  "aligned-vec",
2791
2791
  "arrow",
@@ -1 +1 @@
1
- c-questdb-client 6daaea468263a289685a9a483c68a3baf69b38c4 (7.1.0)
1
+ c-questdb-client a236dbffba41239d08740ea0d24eb7e0e7fafa47 (7.2.0)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: questdb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - QuestDB
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-20 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -148,6 +148,7 @@ files:
148
148
  - examples/tls_auth.rb
149
149
  - ext/questdb_ingress/extconf.rb
150
150
  - ext/questdb_ingress/questdb_ingress.c
151
+ - ext/questdb_ingress/unlink_build_ruby.rb
151
152
  - lib/questdb.rb
152
153
  - lib/questdb/arrow.rb
153
154
  - lib/questdb/client.rb
@@ -173,6 +174,7 @@ files:
173
174
  - script/build-native-gem
174
175
  - script/build-release-ruby
175
176
  - script/check-license-headers
177
+ - script/check-native-linkage
176
178
  - script/fault-soak
177
179
  - script/query-failover-smoke
178
180
  - script/stage-native-extension
@@ -184,6 +186,7 @@ files:
184
186
  - test/buffer_test.rb
185
187
  - test/client_test.rb
186
188
  - test/direct_ingestion_test.rb
189
+ - test/extconf_link_flags_test.rb
187
190
  - test/integration_test.rb
188
191
  - test/live_callbacks_test.rb
189
192
  - test/native_lifecycle_test.rb