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
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ___ _ ____ ____
4
+ # / _ \ _ _ ___ ___| |_| _ \| __ )
5
+ # | | | | | | |/ _ \/ __| __| | | | _ \
6
+ # | |_| | |_| | __/\__ \ |_| |_| | |_) |
7
+ # \__\_\\__,_|\___||___/\__|____/|____/
8
+ #
9
+ # Copyright (c) 2014-2019 Appsicle
10
+ # Copyright (c) 2019-2026 QuestDB
11
+ #
12
+ # Licensed under the Apache License, Version 2.0 (the "License");
13
+ # you may not use this file except in compliance with the License.
14
+ # You may obtain a copy of the License at
15
+ #
16
+ # http://www.apache.org/licenses/LICENSE-2.0
17
+ #
18
+ # Unless required by applicable law or agreed to in writing, software
19
+ # distributed under the License is distributed on an "AS IS" BASIS,
20
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
+ # See the License for the specific language governing permissions and
22
+ # limitations under the License.
23
+ require "minitest/autorun"
24
+ require "json"
25
+ require "open3"
26
+ require "rbconfig"
27
+ require "tmpdir"
28
+
29
+ # Generates a real Makefile with mkmf under each build Ruby's link settings
30
+ # and checks the expanded link flags. The macOS values are copied from the
31
+ # rbconfig.rb of the ruby/ruby-builder Rubies the release pipeline builds
32
+ # with; 0.1.0's macOS gems linked libruby through them.
33
+ #
34
+ # The scenarios are hermetic: each sets every RbConfig key that reaches an
35
+ # extension's link line, so the host Ruby cannot change the result. The test
36
+ # still runs only on Linux hosts. Its subject is the fake configurations, so
37
+ # other hosts add no coverage, and the release pipeline checks the real macOS
38
+ # and Windows extensions with script/check-native-linkage.
39
+ class ExtconfLinkFlagsTest < Minitest::Test
40
+ HELPER = File.expand_path("../ext/questdb_ingress/unlink_build_ruby.rb", __dir__)
41
+
42
+ # Every key mkmf's init_mkmf and the Makefile's DLDFLAGS/LIBS read.
43
+ NEUTRAL = {
44
+ "LDFLAGS" => "", "DLDFLAGS" => "", "ADDITIONAL_DLDFLAGS" => "", "EXTDLDFLAGS" => "",
45
+ "ARCH_FLAG" => "", "LIBS" => "", "DLDLIBS" => "", "ENABLE_SHARED" => "yes", "RUBY_SO_NAME" => "ruby",
46
+ "LIBRUBYARG" => "$(LIBRUBYARG_SHARED)", "LIBRUBYARG_SHARED" => "-l$(RUBY_SO_NAME)",
47
+ "LIBRUBYARG_STATIC" => "-l$(RUBY_SO_NAME)-static"
48
+ }.freeze
49
+
50
+ DARWIN_SHARED_31 = NEUTRAL.merge(
51
+ "RUBY_SO_NAME" => "ruby.3.1",
52
+ "EXTDLDFLAGS" => "$(LIBRUBYARG_SHARED)",
53
+ "DLDFLAGS" => "-L/usr/local/opt/gmp/lib -Wl,-undefined,dynamic_lookup"
54
+ ).freeze
55
+ DARWIN_STATIC_31 = NEUTRAL.merge(
56
+ "ENABLE_SHARED" => "no",
57
+ "LIBRUBYARG" => "$(LIBRUBYARG_STATIC)",
58
+ "LIBRUBYARG_SHARED" => "",
59
+ "EXTDLDFLAGS" => "-bundle_loader '$(BUILTRUBY)'",
60
+ "DLDFLAGS" => "-L/opt/homebrew/opt/gmp/lib -Wl,-undefined,dynamic_lookup"
61
+ ).freeze
62
+ DARWIN_SHARED_34 = NEUTRAL.merge(
63
+ "RUBY_SO_NAME" => "ruby.3.4",
64
+ "DLDFLAGS" => "-Wl,-multiply_defined,suppress"
65
+ ).freeze
66
+ LINUX = NEUTRAL
67
+ WINDOWS = NEUTRAL.merge("RUBY_SO_NAME" => "x64-ucrt-ruby340").freeze
68
+
69
+ # Simulated host Rubies with noisy values for every key, applied before the
70
+ # scenario: a hermetic scenario gives the same flags under each of them.
71
+ HOSTS = {
72
+ "linux" => { "LDFLAGS" => "-L. -fstack-protector-strong -rdynamic -Wl,-export-dynamic",
73
+ "DLDFLAGS" => "-Wl,-z,relro", "LIBS" => "-lm -lpthread", "RUBY_SO_NAME" => "ruby" },
74
+ "macos-shared" => { "DLDFLAGS" => "-L/usr/local/opt/gmp/lib -Wl,-undefined,dynamic_lookup",
75
+ "EXTDLDFLAGS" => "$(LIBRUBYARG_SHARED)", "RUBY_SO_NAME" => "ruby.3.2",
76
+ "LDFLAGS" => "-L/usr/local/lib -Wl,-undefined,dynamic_lookup", "LIBS" => "-lpthread" },
77
+ "macos-static" => { "ENABLE_SHARED" => "no", "LIBRUBYARG" => "$(LIBRUBYARG_STATIC)", "LIBRUBYARG_SHARED" => "",
78
+ "EXTDLDFLAGS" => "-bundle_loader '$(BUILTRUBY)'", "ADDITIONAL_DLDFLAGS" => "-Wl,-x" },
79
+ "windows" => { "RUBY_SO_NAME" => "x64-ucrt-ruby340", "LIBRUBYARG_SHARED" => "-lx64-ucrt-ruby340",
80
+ "DLDFLAGS" => "-Wl,--enable-auto-image-base", "LIBS" => "-lshell32 -lws2_32",
81
+ "DLDLIBS" => "-lx64-ucrt-ruby340", "ARCH_FLAG" => "-m64" }
82
+ }.freeze
83
+
84
+ PROBE = <<~'RUBY'
85
+ require "mkmf"
86
+ require "json"
87
+ [ENV.fetch("HOST_RBCONFIG"), ENV.fetch("FAKE_RBCONFIG")].map { |json| JSON.parse(json) }.reduce(:merge).each do |key, value|
88
+ RbConfig::MAKEFILE_CONFIG[key] = value
89
+ RbConfig::CONFIG[key] = value
90
+ end
91
+ init_mkmf(RbConfig::MAKEFILE_CONFIG, RbConfig::CONFIG)
92
+ if ENV["APPLY_HELPER"] == "1"
93
+ require ENV.fetch("HELPER")
94
+ QuestDBExtconf.unlink_build_ruby!(ENV.fetch("FAKE_HOST_OS"))
95
+ end
96
+ create_makefile("probe")
97
+ RUBY
98
+
99
+ def setup
100
+ skip "probes fake build Ruby configurations; runs on Linux hosts only" unless RUBY_PLATFORM.include?("linux")
101
+ end
102
+
103
+ def link_flags(config, host_os, apply:, host: {})
104
+ Dir.mktmpdir do |dir|
105
+ File.write(File.join(dir, "probe.c"), "void Init_probe(void) {}\n")
106
+ File.write(File.join(dir, "print.mk"), "print:\n\t@echo $(DLDFLAGS) $(LIBS)\n")
107
+ env = { "FAKE_RBCONFIG" => JSON.generate(config), "HOST_RBCONFIG" => JSON.generate(host),
108
+ "FAKE_HOST_OS" => host_os, "HELPER" => HELPER, "APPLY_HELPER" => apply ? "1" : "0" }
109
+ out, status = Open3.capture2e(env, RbConfig.ruby, "-e", PROBE, chdir: dir)
110
+ assert status.success?, out
111
+ flags, status = Open3.capture2e("make", "-s", "-f", "Makefile", "-f", "print.mk", "print", chdir: dir)
112
+ assert status.success?, flags
113
+ flags.strip
114
+ end
115
+ end
116
+
117
+ # Runs the block once per simulated host and checks the result does not
118
+ # depend on the host.
119
+ def each_host(config, host_os, apply:)
120
+ results = HOSTS.to_h { |name, host| [name, link_flags(config, host_os, apply: apply, host: host)] }
121
+ assert_equal 1, results.values.uniq.size, "flags depend on the host Ruby: #{results.inspect}"
122
+ results.values.first
123
+ end
124
+
125
+ def assert_unlinked(flags)
126
+ refute_match(/-lruby|libruby|bundle_loader|RUBY_SO_NAME|BUILTRUBY/, flags)
127
+ end
128
+
129
+ def test_macos_shared_ruby_31_does_not_link_libruby
130
+ assert_match(/-lruby\.3\.1/, each_host(DARWIN_SHARED_31, "darwin22", apply: false), "probe must see libruby")
131
+ flags = each_host(DARWIN_SHARED_31, "darwin22", apply: true)
132
+ assert_unlinked(flags)
133
+ assert_match(/-undefined,dynamic_lookup/, flags)
134
+ end
135
+
136
+ def test_macos_static_ruby_31_does_not_bind_to_the_executable
137
+ assert_match(/bundle_loader/, each_host(DARWIN_STATIC_31, "darwin23", apply: false), "probe must see -bundle_loader")
138
+ flags = each_host(DARWIN_STATIC_31, "darwin23", apply: true)
139
+ assert_unlinked(flags)
140
+ assert_match(/-undefined,dynamic_lookup/, flags)
141
+ end
142
+
143
+ def test_macos_shared_ruby_34_does_not_link_libruby
144
+ assert_match(/-lruby\.3\.4/, each_host(DARWIN_SHARED_34, "darwin24", apply: false), "probe must see libruby")
145
+ flags = each_host(DARWIN_SHARED_34, "darwin24", apply: true)
146
+ assert_unlinked(flags)
147
+ assert_match(/-undefined,dynamic_lookup/, flags)
148
+ end
149
+
150
+ def test_linux_does_not_link_libruby
151
+ assert_match(/-lruby/, each_host(LINUX, "linux-gnu", apply: false), "probe must see libruby")
152
+ flags = each_host(LINUX, "linux-gnu", apply: true)
153
+ assert_unlinked(flags)
154
+ refute_match(/dynamic_lookup/, flags)
155
+ end
156
+
157
+ def test_windows_keeps_the_ruby_dll
158
+ assert_match(/-lx64-ucrt-ruby340/, each_host(WINDOWS, "mingw32", apply: true))
159
+ end
160
+ end
@@ -1,5 +1,5 @@
1
1
  [tool.bumpversion]
2
- current_version = "7.1.0"
2
+ current_version = "7.2.0"
3
3
  commit = false
4
4
  tag = false
5
5
 
@@ -1,5 +1,5 @@
1
1
  cmake_minimum_required(VERSION 3.15.0)
2
- project(c-questdb-client VERSION 7.1.0)
2
+ project(c-questdb-client VERSION 7.2.0)
3
3
 
4
4
  set(CPACK_PROJECT_NAME ${PROJECT_NAME})
5
5
  set(CPACK_PROJECT_VERSION ${PROJECT_VERSION})
@@ -67,8 +67,12 @@ inline constexpr uint8_t ROLE_PRIMARY = 0x01;
67
67
  inline constexpr uint8_t ROLE_REPLICA = 0x02;
68
68
  inline constexpr uint8_t ROLE_PRIMARY_CATCHUP = 0x03;
69
69
 
70
- // SERVER_INFO capability bits.
70
+ // SERVER_INFO capability bits (mirror
71
+ // questdb-rs/src/egress/wire/capabilities.rs).
71
72
  inline constexpr uint32_t CAP_ZONE = 0x00000001u;
73
+ inline constexpr uint32_t CAP_QUERY_FLAGS = 0x00000002u;
74
+ inline constexpr uint32_t CAP_COMPRESSION = 0x00000004u;
75
+ inline constexpr uint32_t CAP_QUERY_TIMEOUT = 0x00000008u;
72
76
 
73
77
  // ColumnKind wire codes (mirror questdb-rs/src/egress/column_kind.rs).
74
78
  inline constexpr uint8_t COL_BOOLEAN = 0x01;
@@ -102,6 +106,11 @@ inline constexpr uint8_t STATUS_INTERNAL_ERROR = 0x06;
102
106
  inline constexpr uint8_t STATUS_SECURITY_ERROR = 0x08;
103
107
  inline constexpr uint8_t STATUS_CANCELLED = 0x0A;
104
108
  inline constexpr uint8_t STATUS_LIMIT_EXCEEDED = 0x0B;
109
+ inline constexpr uint8_t STATUS_QUERY_TIMEOUT = 0x0E;
110
+
111
+ // QUERY_REQUEST `query_flags` bits (mirror query_request.rs).
112
+ inline constexpr uint8_t QUERY_FLAG_RESET_DICT = 0x01;
113
+ inline constexpr uint8_t QUERY_FLAG_TIMEOUT = 0x02;
105
114
 
106
115
  // CACHE_RESET masks.
107
116
  inline constexpr uint8_t CACHE_RESET_SYMBOLS = 0x01;
@@ -117,6 +117,43 @@ TEST(test_symbol_dict_full_is_a_distinct_appended_code)
117
117
  "neutral alias resolves to the same enumerator");
118
118
  }
119
119
 
120
+ TEST(test_query_timeout_is_a_distinct_appended_code)
121
+ {
122
+ /* A query that ran past its own timeout must be recognisable by code:
123
+ * callers branch on it to avoid retrying a write the server may have
124
+ * applied anyway. */
125
+ CHECK(
126
+ line_sender_error_query_timeout != line_sender_error_cancelled,
127
+ "query_timeout distinct from cancelled");
128
+ CHECK(
129
+ line_sender_error_query_timeout !=
130
+ line_sender_error_server_limit_exceeded,
131
+ "query_timeout distinct from server_limit_exceeded");
132
+ CHECK(
133
+ line_sender_error_query_timeout != line_sender_error_invalid_api_call,
134
+ "query_timeout distinct from invalid_api_call");
135
+ CHECK(
136
+ line_sender_error_query_timeout > line_sender_error_symbol_dict_full,
137
+ "query_timeout appended (not renumbered)");
138
+ CHECK(
139
+ line_sender_error_query_timeout == 38,
140
+ "query_timeout discriminant pinned at 38 for cached ABI consumers");
141
+ CHECK(
142
+ questdb_error_query_timeout == line_sender_error_query_timeout,
143
+ "neutral alias resolves to the same enumerator");
144
+ }
145
+
146
+ TEST(test_query_set_timeout_ms_null_query_is_tolerated)
147
+ {
148
+ /* Proves the symbol links and argument-marshals under a plain C
149
+ * compiler (the Cython contract surface), and that the NULL-handle
150
+ * contract shared by every `qwp_reader_query_*` mutator holds: report
151
+ * and return, never crash. */
152
+ qwp_reader_query_set_timeout_ms(NULL, 1000);
153
+ qwp_reader_query_set_timeout_ms(NULL, 0);
154
+ CHECK(true, "NULL query handle did not crash");
155
+ }
156
+
120
157
  TEST(test_egress_null_cursor_returns_error_tristate)
121
158
  {
122
159
  struct ArrowArray arr;
@@ -1609,6 +1646,8 @@ int main(void)
1609
1646
  RUN(test_appended_query_error_codes_have_distinct_values);
1610
1647
  RUN(test_appended_sender_error_codes_exist);
1611
1648
  RUN(test_symbol_dict_full_is_a_distinct_appended_code);
1649
+ RUN(test_query_timeout_is_a_distinct_appended_code);
1650
+ RUN(test_query_set_timeout_ms_null_query_is_tolerated);
1612
1651
  RUN(test_egress_null_cursor_returns_error_tristate);
1613
1652
  RUN(test_egress_null_out_array_returns_error_tristate);
1614
1653
  RUN(test_ingress_null_conn_returns_false);
@@ -38,6 +38,7 @@
38
38
  #include <chrono>
39
39
  #include <cstring>
40
40
  #include <exception>
41
+ #include <limits>
41
42
  #include <memory>
42
43
  #include <mutex>
43
44
  #include <optional>
@@ -675,6 +676,433 @@ TEST_CASE("mock: QueryError(cancelled) surfaces as Cancelled")
675
676
  // and the server's CANCELLED status surfaces as the documented error code.
676
677
  // ---------------------------------------------------------------------------
677
678
 
679
+ TEST_CASE("mock: QueryError(query_timeout) surfaces as QueryTimeout")
680
+ {
681
+ run_query_error_test(qm::STATUS_QUERY_TIMEOUT, questdb_error_query_timeout);
682
+ }
683
+
684
+ // ---------------------------------------------------------------------------
685
+ // Per-query timeout (CAP_QUERY_TIMEOUT / QUERY_FLAG_TIMEOUT / timeout_ms).
686
+ // ---------------------------------------------------------------------------
687
+
688
+ namespace
689
+ {
690
+ // A SERVER_INFO advertising the capabilities a current server sends.
691
+ qm::ActionSendServerInfo server_info_with(uint32_t caps)
692
+ {
693
+ qm::ActionSendServerInfo si;
694
+ si.capabilities = caps;
695
+ return si;
696
+ }
697
+
698
+ // Offset of the `query_flags` trailer in a QUERY_REQUEST with no binds:
699
+ // msg_kind(1) + request_id(8) + varint sql_len(1) + sql + varint credit(1)
700
+ // + varint bind_count(1).
701
+ size_t trailer_offset(size_t sql_len)
702
+ {
703
+ return 1 + 8 + 1 + sql_len + 1 + 1;
704
+ }
705
+ } // namespace
706
+
707
+ TEST_CASE("mock: no timeout -> QUERY_REQUEST carries no trailer")
708
+ {
709
+ // The baseline an older server has always seen. Byte-exactness here is
710
+ // what makes the feature safe to ship against one.
711
+ qm::Script s = {
712
+ server_info_with(qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT),
713
+ qm::ActionAwaitQueryRequest{},
714
+ qm::ActionSendResultEnd{},
715
+ };
716
+ qm::MockServer srv({s});
717
+ auto reader = connect_to(srv);
718
+ auto cur = reader.execute("X"_utf8);
719
+ while (cur.next_batch())
720
+ {
721
+ }
722
+
723
+ auto reqs = srv.captured_requests();
724
+ REQUIRE(reqs.size() == 1);
725
+ CHECK(reqs[0].size() == trailer_offset(1));
726
+ }
727
+
728
+ TEST_CASE("mock: timeout_ms rides the wire after query_flags")
729
+ {
730
+ qm::Script s = {
731
+ server_info_with(qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT),
732
+ qm::ActionAwaitQueryRequest{},
733
+ qm::ActionSendResultEnd{},
734
+ };
735
+ qm::MockServer srv({s});
736
+ auto reader = connect_to(srv);
737
+ auto cur = reader.prepare("X"_utf8).timeout_ms(1000).execute();
738
+ while (cur.next_batch())
739
+ {
740
+ }
741
+
742
+ auto reqs = srv.captured_requests();
743
+ REQUIRE(reqs.size() == 1);
744
+ const auto& req = reqs[0];
745
+ const size_t p = trailer_offset(1);
746
+ // flags varint = QUERY_FLAG_TIMEOUT, then timeout_ms varint
747
+ // (1000 = 0xE8 0x07).
748
+ REQUIRE(req.size() == p + 3);
749
+ CHECK(req[p] == qm::QUERY_FLAG_TIMEOUT);
750
+ CHECK(req[p + 1] == 0xE8);
751
+ CHECK(req[p + 2] == 0x07);
752
+ }
753
+
754
+ TEST_CASE("mock: chrono timeout rounds a sub-millisecond value up to 1 ms")
755
+ {
756
+ // Rounding down would mean "no timeout" — the opposite of the ask.
757
+ qm::Script s = {
758
+ server_info_with(qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT),
759
+ qm::ActionAwaitQueryRequest{},
760
+ qm::ActionSendResultEnd{},
761
+ };
762
+ qm::MockServer srv({s});
763
+ auto reader = connect_to(srv);
764
+ auto cur = reader.prepare("X"_utf8)
765
+ .timeout(std::chrono::microseconds{500})
766
+ .execute();
767
+ while (cur.next_batch())
768
+ {
769
+ }
770
+
771
+ auto reqs = srv.captured_requests();
772
+ REQUIRE(reqs.size() == 1);
773
+ const auto& req = reqs[0];
774
+ const size_t p = trailer_offset(1);
775
+ REQUIRE(req.size() == p + 2);
776
+ CHECK(req[p] == qm::QUERY_FLAG_TIMEOUT);
777
+ CHECK(req[p + 1] == 1);
778
+ }
779
+
780
+ TEST_CASE("mock: chrono timeout converts seconds to milliseconds")
781
+ {
782
+ qm::Script s = {
783
+ server_info_with(qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT),
784
+ qm::ActionAwaitQueryRequest{},
785
+ qm::ActionSendResultEnd{},
786
+ };
787
+ qm::MockServer srv({s});
788
+ auto reader = connect_to(srv);
789
+ auto cur =
790
+ reader.prepare("X"_utf8).timeout(std::chrono::seconds{2}).execute();
791
+ while (cur.next_batch())
792
+ {
793
+ }
794
+
795
+ auto reqs = srv.captured_requests();
796
+ REQUIRE(reqs.size() == 1);
797
+ const auto& req = reqs[0];
798
+ const size_t p = trailer_offset(1);
799
+ // timeout_ms varint 2000 = 0xD0 0x0F.
800
+ REQUIRE(req.size() == p + 3);
801
+ CHECK(req[p] == qm::QUERY_FLAG_TIMEOUT);
802
+ CHECK(req[p + 1] == 0xD0);
803
+ CHECK(req[p + 2] == 0x0F);
804
+ }
805
+
806
+ TEST_CASE("mock: chrono timeout rejects negative and non-finite durations")
807
+ {
808
+ qm::Script s = {
809
+ server_info_with(qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT),
810
+ };
811
+ qm::MockServer srv({s});
812
+ auto reader = connect_to(srv);
813
+ using ms_f = std::chrono::duration<double, std::milli>;
814
+ for (double v :
815
+ {-1.0,
816
+ std::numeric_limits<double>::quiet_NaN(),
817
+ std::numeric_limits<double>::infinity()})
818
+ {
819
+ auto q = reader.prepare("X"_utf8);
820
+ try
821
+ {
822
+ q.timeout(ms_f{v});
823
+ FAIL("negative or non-finite timeout must throw");
824
+ }
825
+ catch (const questdb::error& e)
826
+ {
827
+ CHECK(e.code() == questdb_error_invalid_api_call);
828
+ }
829
+ }
830
+ }
831
+
832
+ TEST_CASE("mock: chrono timeout saturates instead of overflowing")
833
+ {
834
+ qm::Script s = {
835
+ server_info_with(qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT),
836
+ qm::ActionAwaitQueryRequest{},
837
+ qm::ActionSendResultEnd{},
838
+ };
839
+ qm::MockServer srv({s});
840
+ auto reader = connect_to(srv);
841
+ using hours64 = std::chrono::duration<int64_t, std::ratio<3600>>;
842
+ auto cur = reader.prepare("X"_utf8).timeout(hours64::max()).execute();
843
+ while (cur.next_batch())
844
+ {
845
+ }
846
+
847
+ auto reqs = srv.captured_requests();
848
+ REQUIRE(reqs.size() == 1);
849
+ const auto& req = reqs[0];
850
+ const size_t p = trailer_offset(1);
851
+ // timeout_ms = INT64_MAX (the server reads a signed long): eight 0xFF
852
+ // continuation bytes, then 0x7F.
853
+ REQUIRE(req.size() == p + 10);
854
+ CHECK(req[p] == qm::QUERY_FLAG_TIMEOUT);
855
+ for (size_t i = 1; i < 9; ++i)
856
+ CHECK(req[p + i] == 0xFF);
857
+ CHECK(req[p + 9] == 0x7F);
858
+ }
859
+
860
+ TEST_CASE("mock: chrono zero timeout clears the request")
861
+ {
862
+ qm::Script s = {
863
+ server_info_with(qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT),
864
+ qm::ActionAwaitQueryRequest{},
865
+ qm::ActionSendResultEnd{},
866
+ };
867
+ qm::MockServer srv({s});
868
+ auto reader = connect_to(srv);
869
+ auto cur =
870
+ reader.prepare("X"_utf8).timeout(std::chrono::seconds{0}).execute();
871
+ while (cur.next_batch())
872
+ {
873
+ }
874
+
875
+ auto reqs = srv.captured_requests();
876
+ REQUIRE(reqs.size() == 1);
877
+ CHECK(reqs[0].size() == trailer_offset(1));
878
+ }
879
+
880
+ TEST_CASE("mock: connect-string query_timeout_ms applies to every query")
881
+ {
882
+ qm::Script s = {
883
+ server_info_with(qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT),
884
+ qm::ActionAwaitQueryRequest{},
885
+ qm::ActionSendResultEnd{},
886
+ qm::ActionAwaitQueryRequest{},
887
+ qm::ActionSendResultEnd{},
888
+ };
889
+ qm::MockServer srv({s});
890
+ const std::string conf =
891
+ "ws::addr=" + srv.addr() + ";query_timeout_ms=1000;";
892
+ eg::reader reader{questdb::ingress::utf8_view{conf}};
893
+
894
+ for (int i = 0; i < 2; ++i)
895
+ {
896
+ auto cur = reader.execute("X"_utf8);
897
+ while (cur.next_batch())
898
+ {
899
+ }
900
+ }
901
+
902
+ auto reqs = srv.captured_requests();
903
+ REQUIRE(reqs.size() == 2);
904
+ for (const auto& req : reqs)
905
+ {
906
+ const size_t p = trailer_offset(1);
907
+ REQUIRE(req.size() == p + 3);
908
+ CHECK(req[p] == qm::QUERY_FLAG_TIMEOUT);
909
+ CHECK(req[p + 1] == 0xE8);
910
+ CHECK(req[p + 2] == 0x07);
911
+ }
912
+ }
913
+
914
+ TEST_CASE("mock: per-query timeout overrides the connect-string default")
915
+ {
916
+ qm::Script s = {
917
+ server_info_with(qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT),
918
+ qm::ActionAwaitQueryRequest{},
919
+ qm::ActionSendResultEnd{},
920
+ };
921
+ qm::MockServer srv({s});
922
+ const std::string conf =
923
+ "ws::addr=" + srv.addr() + ";query_timeout_ms=1000;";
924
+ eg::reader reader{questdb::ingress::utf8_view{conf}};
925
+ // Clearing it per query must also work, or a caller stuck with a
926
+ // profile-wide default could never opt a single query out.
927
+ auto cur = reader.prepare("X"_utf8).timeout_ms(0).execute();
928
+ while (cur.next_batch())
929
+ {
930
+ }
931
+
932
+ auto reqs = srv.captured_requests();
933
+ REQUIRE(reqs.size() == 1);
934
+ CHECK(reqs[0].size() == trailer_offset(1));
935
+ }
936
+
937
+ TEST_CASE("mock: failover replay re-sends the original timeout budget")
938
+ {
939
+ // The replay patches only the request_id, so the new endpoint gets the
940
+ // full timeout again. Pinned so a change to that is deliberate.
941
+ qm::Script s_a = {
942
+ qm::ActionSendServerInfo{
943
+ qm::ROLE_STANDALONE,
944
+ "c",
945
+ "a",
946
+ 0,
947
+ qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT},
948
+ qm::ActionAwaitQueryRequest{},
949
+ qm::ActionHardDrop{},
950
+ };
951
+ qm::Script s_b = {
952
+ qm::ActionSendServerInfo{
953
+ qm::ROLE_STANDALONE,
954
+ "c",
955
+ "b",
956
+ 0,
957
+ qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT},
958
+ qm::ActionAwaitQueryRequest{},
959
+ qm::ActionSendResultEnd{},
960
+ };
961
+ qm::MockServer srv_a({s_a});
962
+ qm::MockServer srv_b({s_b});
963
+
964
+ const std::string conf =
965
+ "ws::addr=" + srv_a.addr() + "," + srv_b.addr() +
966
+ ";failover_backoff_initial_ms=1;failover_backoff_max_ms=10";
967
+ eg::reader reader{questdb::ingress::utf8_view{conf}};
968
+
969
+ auto cur = reader.prepare("X"_utf8).timeout_ms(1000).execute();
970
+ while (cur.next_batch())
971
+ {
972
+ }
973
+
974
+ // Each endpoint saw one request, and both carried the same budget.
975
+ auto reqs_a = srv_a.captured_requests();
976
+ auto reqs_b = srv_b.captured_requests();
977
+ REQUIRE(reqs_a.size() == 1);
978
+ REQUIRE(reqs_b.size() == 1);
979
+ const size_t p = trailer_offset(1);
980
+ for (const auto* reqs : {&reqs_a, &reqs_b})
981
+ {
982
+ const auto& req = (*reqs)[0];
983
+ REQUIRE(req.size() == p + 3);
984
+ CHECK(req[p] == qm::QUERY_FLAG_TIMEOUT);
985
+ CHECK(req[p + 1] == 0xE8); // varint(1000), unchanged by the replay
986
+ CHECK(req[p + 2] == 0x07);
987
+ }
988
+ // The replay differs from the original only in the request_id, which is
989
+ // what makes the trailer survive byte-identically.
990
+ const auto& orig = reqs_a[0];
991
+ const auto& replay = reqs_b[0];
992
+ CHECK(orig.size() == replay.size());
993
+ CHECK(std::equal(orig.begin() + 9, orig.end(), replay.begin() + 9));
994
+ }
995
+
996
+ TEST_CASE("mock: timeout without CAP_QUERY_TIMEOUT fails before the wire")
997
+ {
998
+ // Fail fast rather than degrade: running under the server default
999
+ // while the caller believes a 10-minute budget applied is how a dbt
1000
+ // transform dies at 60 s with a confusing error.
1001
+ qm::Script s = {
1002
+ server_info_with(qm::CAP_QUERY_FLAGS),
1003
+ qm::ActionAwaitQueryRequest{},
1004
+ qm::ActionSendResultEnd{},
1005
+ };
1006
+ qm::MockServer srv({s});
1007
+ auto reader = connect_to(srv);
1008
+
1009
+ bool threw = false;
1010
+ try
1011
+ {
1012
+ auto cur = reader.prepare("X"_utf8).timeout_ms(1000).execute();
1013
+ while (cur.next_batch())
1014
+ {
1015
+ }
1016
+ }
1017
+ catch (const questdb::error& e)
1018
+ {
1019
+ threw = true;
1020
+ CHECK(e.code() == questdb_error_unsupported_server);
1021
+ // The message has to name the missing capability and point at the
1022
+ // server-side knob, or the operator cannot act on it.
1023
+ const std::string msg = e.what();
1024
+ CHECK(msg.find("CAP_QUERY_TIMEOUT") != std::string::npos);
1025
+ CHECK(msg.find("query.timeout") != std::string::npos);
1026
+ }
1027
+ CHECK(threw);
1028
+ // Nothing reached the wire: the guard runs before the QUERY_REQUEST is
1029
+ // encoded, so the server never sees a frame it would reject.
1030
+ CHECK(srv.captured_requests().empty());
1031
+ }
1032
+
1033
+ TEST_CASE("mock: timeout without any capabilities fails before the wire")
1034
+ {
1035
+ qm::Script s = {
1036
+ qm::ActionSendServerInfo{}, // capabilities = 0
1037
+ qm::ActionAwaitQueryRequest{},
1038
+ qm::ActionSendResultEnd{},
1039
+ };
1040
+ qm::MockServer srv({s});
1041
+ auto reader = connect_to(srv);
1042
+
1043
+ bool threw = false;
1044
+ try
1045
+ {
1046
+ auto cur = reader.prepare("X"_utf8).timeout_ms(1000).execute();
1047
+ while (cur.next_batch())
1048
+ {
1049
+ }
1050
+ }
1051
+ catch (const questdb::error& e)
1052
+ {
1053
+ threw = true;
1054
+ CHECK(e.code() == questdb_error_unsupported_server);
1055
+ }
1056
+ CHECK(threw);
1057
+ CHECK(srv.captured_requests().empty());
1058
+ }
1059
+
1060
+ TEST_CASE("mock: a timed-out query leaves the connection usable")
1061
+ {
1062
+ // The contract that makes pooling work: QUERY_TIMEOUT is per-query, so
1063
+ // the next query runs on the same connection with no second handshake.
1064
+ qm::Script s = {
1065
+ server_info_with(qm::CAP_QUERY_FLAGS | qm::CAP_QUERY_TIMEOUT),
1066
+ qm::ActionAwaitQueryRequest{},
1067
+ qm::ActionSendBuilt{[](int64_t rid) {
1068
+ return qm::query_error_frame(
1069
+ rid, qm::STATUS_QUERY_TIMEOUT, "timeout, query aborted");
1070
+ }},
1071
+ qm::ActionAwaitQueryRequest{},
1072
+ qm::ActionSendResultEnd{},
1073
+ };
1074
+ qm::MockServer srv({s});
1075
+ auto reader = connect_to(srv);
1076
+
1077
+ bool threw = false;
1078
+ try
1079
+ {
1080
+ auto cur = reader.prepare("slow"_utf8).timeout_ms(1).execute();
1081
+ while (cur.next_batch())
1082
+ {
1083
+ }
1084
+ }
1085
+ catch (const questdb::error& e)
1086
+ {
1087
+ threw = true;
1088
+ CHECK(e.code() == questdb_error_query_timeout);
1089
+ }
1090
+ REQUIRE(threw);
1091
+
1092
+ // Second query on the same reader: it must go through.
1093
+ auto cur2 = reader.execute("after"_utf8);
1094
+ while (cur2.next_batch())
1095
+ {
1096
+ }
1097
+
1098
+ auto reqs = srv.captured_requests();
1099
+ REQUIRE(reqs.size() == 2);
1100
+ // Both requests arrived on the one scripted connection, which the mock
1101
+ // serves per slot — so no reconnect happened between them.
1102
+ CHECK(reqs[0][0] == qm::MSG_QUERY_REQUEST);
1103
+ CHECK(reqs[1][0] == qm::MSG_QUERY_REQUEST);
1104
+ }
1105
+
678
1106
  TEST_CASE("mock: cursor::cancel writes MSG_CANCEL and surfaces Cancelled")
679
1107
  {
680
1108
  // After the QueryRequest, server holds the response open; the test