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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6fec92034e9c3564a4ad1ebfcf5db83d2d29458880767082552dca3af8a2630b
4
- data.tar.gz: e12d85e1409867bbaccaa26417ee1fb1fb5c232959978a137008686b9139d34c
3
+ metadata.gz: 8ee4c4fb9e29b76cf567494acba3f14416fd364efef44e9f2d2c7d19ea0c25fb
4
+ data.tar.gz: 5abe1a191ded582c1cd6f9cb5353eaaa6cca4b4d73b985b78c9b3900c2352ed4
5
5
  SHA512:
6
- metadata.gz: a6168546fc44481d4548f927f795f267ec73a5acde1282b0c8feaf5fe4ceda1c4e4e9cd80c300e2ec096cf68ebf2d06649364ecb7c632800f83e3172917bf968
7
- data.tar.gz: 995838f049b7c23f06f4be2ce8a45a83c6bdd13e348dc6bae071cd42ff1bdb2eb73f75ec9155c425a69bb041ee794581dd6f265b3938cf24acf2409c7436f0e7
6
+ metadata.gz: 0d824b5d90da424043ffd261e185e827495afac15e76dde355f268fe1dfa80b1de6d9a1282f3ab514e8b2b5b65d793ce5871b2c9bbda8b40838de97df8aeef2c
7
+ data.tar.gz: 4648bc7748d704d652706b94744d4a739714351641c20b08a2228309246dca38a194fff11f10dc93d4fcc3cc919520015195647b7776fb86e1c128e13492f69b
data/.cargo/audit.toml CHANGED
@@ -1,17 +1,25 @@
1
- # Temporary exceptions for the 0.1.0 release, which pins c-questdb-client
2
- # 7.1.0. Its lockfile has rustls 0.23.41; the fixes need rustls 0.23.46 or
3
- # newer. Remove each entry when a c-questdb-client patch release ships it.
4
- # See docs/releasing.md.
1
+ # Temporary exceptions for the 0.1.x releases, which pin c-questdb-client
2
+ # 7.1.0 and 7.2.0. Both lockfiles have rustls 0.23.41, rustls-webpki
3
+ # 0.103.13 and webpki-roots 1.0.8; the fixes need rustls 0.23.46,
4
+ # rustls-webpki 0.103.16 and webpki-roots 1.0.10 or newer. Remove each entry
5
+ # when a c-questdb-client patch release ships it. See docs/releasing.md.
5
6
  [advisories]
6
7
  ignore = [
7
- # TLS 1.3 handshake messages accepted across encryption level boundaries.
8
+ # rustls: TLS 1.3 handshake messages accepted across encryption level
9
+ # boundaries.
8
10
  "RUSTSEC-2026-0285",
9
- # `Debug` impls for client session values expose TLS secrets.
11
+ # rustls: `Debug` impls for client session values expose TLS secrets.
10
12
  "RUSTSEC-2026-0346",
11
- # Brotli certificate decompression can allocate up to 1 GiB; the brotli
12
- # feature is not enabled in this build.
13
+ # rustls: Brotli certificate decompression can allocate up to 1 GiB; the
14
+ # brotli feature is not enabled in this build.
13
15
  "RUSTSEC-2026-0348",
14
- # Unauthenticated ServerHello can downgrade later connections to
16
+ # rustls: unauthenticated ServerHello can downgrade later connections to
15
17
  # classical key exchange.
16
18
  "RUSTSEC-2026-0349",
19
+ # webpki-roots: bundled roots allow trust in distrusted emSign
20
+ # intermediates.
21
+ "RUSTSEC-2026-0381",
22
+ # rustls-webpki: certificate verification may accept distrusted
23
+ # intermediates.
24
+ "RUSTSEC-2026-0382",
17
25
  ]
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1 (2026-10-21)
4
+
5
+ - Fixed precompiled gems that installed but failed on `require "questdb"`. The 0.1.0 native extensions were linked against the Ruby on the build machine: macOS gems referenced that machine's `libruby` path (or, for Ruby 3.1 on ARM64, symbols in its `ruby` executable), and Linux gems required `libruby.so`, so they failed with Rubies built without a shared library. Extensions now resolve Ruby's C API from the Ruby that loads them. The release pipeline checks every extension's linkage, because install tests on the build machines cannot detect this.
6
+ - Fixed `ack: :durable` without `request_durable_ack` enabled: `ingest_columns`, `ingest_arrow`, `flush(wait: true)`, and `wait` now raise `:invalid_api_call` before sending anything. Previously direct ingestion committed its rows and then raised an in-doubt error.
7
+ - Updated to `c-questdb-client` 7.2.0. The `query_timeout_ms` connect-string key sets a client-side timeout for queries on servers that support it; an expired query raises `QuestDB::Error` with code `:query_timeout`. Standalone WebSocket flushes now check the full frame-size limit before writing anything.
8
+ - Known issue: `c-questdb-client` 7.2.0 still locks rustls 0.23.41, rustls-webpki 0.103.13 and webpki-roots 1.0.8. In addition to the four rustls advisories listed for 0.1.0, RUSTSEC-2026-0381 (webpki-roots) and RUSTSEC-2026-0382 (rustls-webpki), both about trusting distrusted certificate intermediates, are temporarily ignored in `cargo audit` (`.cargo/audit.toml`) until a `c-questdb-client` patch release updates these crates.
9
+
3
10
  ## 0.1.0 (2026-10-19)
4
11
 
5
12
  Initial pre-1.0 release for early adopters. Public APIs may evolve before 1.0; delivery and acknowledgement semantics are intentional compatibility commitments and changes will be documented here.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # QuestDB Ruby client
2
2
 
3
- `questdb` is an MRI Ruby client for QuestDB's native **QuestDB Wire Protocol (QWP)** over WebSocket. It is backed by the official `c-questdb-client` 7.1.0 C ABI and does not implement the protocol, networking, TLS, authentication, failover, or store-and-forward in Ruby.
3
+ `questdb` is an MRI Ruby client for QuestDB's native **QuestDB Wire Protocol (QWP)** over WebSocket. It is backed by the official `c-questdb-client` 7.2.0 C ABI and does not implement the protocol, networking, TLS, authentication, failover, or store-and-forward in Ruby.
4
4
 
5
5
  The client provides a deployment-level connection pool, pooled row and direct columnar ingestion, streaming SQL queries, safe Ruby dispatch for lifecycle/rejection callbacks, and standalone QWP WebSocket senders. Query batches and callback values are copied into ordinary Ruby objects before borrowed native data expires. Legacy ILP transports are intentionally not part of this client.
6
6
 
@@ -130,8 +130,8 @@ powerfmt | 0.2.0 | MIT OR Apache-2.0
130
130
  ppv-lite86 | 0.2.21 | MIT OR Apache-2.0
131
131
  proc-macro2 | 1.0.106 | MIT OR Apache-2.0
132
132
  questdb-confstr | 0.1.1 | Apache-2.0
133
- questdb-rs | 7.1.0 | Apache-2.0
134
- questdb-rs-ffi | 7.1.0 | Apache-2.0 (upstream workspace license)
133
+ questdb-rs | 7.2.0 | Apache-2.0
134
+ questdb-rs-ffi | 7.2.0 | Apache-2.0 (upstream workspace license)
135
135
  quote | 1.0.46 | MIT OR Apache-2.0
136
136
  r-efi | 5.3.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later
137
137
  r-efi | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later
@@ -20,15 +20,16 @@ The Ruby-only keywords `connection_listener`, `error_handler`, `connection_event
20
20
 
21
21
  ## Native settings
22
22
 
23
- Settings are passed directly to `c-questdb-client` 7.1.0 so new native settings do not require a Ruby release. Important groups include:
23
+ Settings are passed directly to `c-questdb-client` 7.2.0 so new native settings do not require a Ruby release. Important groups include:
24
24
 
25
25
  - pool: `sender_pool_min`, `sender_pool_max`, `query_pool_min`, `query_pool_max`, `acquire_timeout_ms`, `idle_timeout_ms`, `pool_reap`, `lazy_connect`;
26
26
  - reconnect/failover: `addr` (comma-separated endpoints), `target`, `zone`, `reconnect_initial_backoff_millis`, `reconnect_max_backoff_millis`, `reconnect_max_duration_millis`, `initial_connect_retry`;
27
27
  - store-and-forward: `sf_dir`, `sender_id`, `sf_max_segment_bytes`, `sf_max_total_bytes`, `sf_append_deadline_millis`, `sf_durability`, `sf_sync_interval_millis`, `drain_orphans`, `max_background_drainers`;
28
28
  - delivery: `request_durable_ack`, `close_flush_timeout_millis`, `max_buf_size`, `max_name_len`, `error_inbox_capacity`;
29
+ - query: `query_timeout_ms` (expiry raises `:query_timeout`; servers without per-query timeout support reject queries with `:unsupported_server`);
29
30
  - authentication/TLS: `username`, `password`, `token`, `auth_timeout_ms`, `tls_verify`, `tls_ca`, `tls_roots`, `tls_roots_password`.
30
31
 
31
- Consult the matching upstream 7.1.0 connect-string reference for exact constraints. Unknown or invalid settings are rejected by the native parser.
32
+ Consult the matching upstream 7.2.0 connect-string reference for exact constraints. Unknown or invalid settings are rejected by the native parser.
32
33
 
33
34
  Direct column ingestion borrows from the native direct sender pool and uses `acquire_timeout_ms`; initial direct connection retry is bounded by `reconnect_max_duration_millis`. `max_rows_per_batch` is a method argument rather than a connection setting. Direct writes do not use `sf_dir`, do not produce FSNs, and are unaffected by row auto-publish thresholds. `ack: :durable` still requires both `request_durable_ack=on` and server support.
34
35
 
data/docs/design.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Native boundary
4
4
 
5
- The gem links statically to official `c-questdb-client` 7.1.0. Ruby owns thin typed-data wrappers for:
5
+ The gem links statically to official `c-questdb-client` 7.2.0. Ruby owns thin typed-data wrappers for:
6
6
 
7
7
  - one ref-counted `questdb_db` pool state;
8
8
  - short-lived borrowed `qwp_sender` leases;
@@ -1,6 +1,6 @@
1
1
  # Python QWP parity
2
2
 
3
- This client tracks the QWP surface of the QuestDB Python 5.0 client at the `c-questdb-client` 7.1.0 ABI boundary. Parity means equivalent delivery, lifecycle, type, and error semantics expressed idiomatically in Ruby; it does not mean retaining Python's legacy ILP APIs.
3
+ This client tracks the QWP surface of the QuestDB Python 5.0 client at the `c-questdb-client` 7.2.0 ABI boundary. Parity means equivalent delivery, lifecycle, type, and error semantics expressed idiomatically in Ruby; it does not mean retaining Python's legacy ILP APIs.
4
4
 
5
5
  | Python QWP capability | Ruby API | Status |
6
6
  | --- | --- | --- |
data/docs/releasing.md CHANGED
@@ -17,7 +17,7 @@ The versioned layout is required because MRI's native ABI and `libruby` soname m
17
17
 
18
18
  1. Update `QuestDB::Ingress::VERSION` and change the changelog heading from `unreleased` to the intended release date.
19
19
  2. Build from a clean candidate commit with recursively initialized submodules on approved QuestDB infrastructure.
20
- 3. Require Ruby and Rust dependency audits to pass without ignored advisories. Temporary exception for 0.1.0: the rustls advisories listed in `.cargo/audit.toml` are ignored until a patch release moves `c-questdb-client` to rustls 0.23.46 or newer; remove each ignore as soon as its fix ships. Enterprise-only durable-ACK qualification remains optional, matching the Python client's policy.
20
+ 3. Require Ruby and Rust dependency audits to pass without ignored advisories. Temporary exception for 0.1.x on `c-questdb-client` 7.1.0 and 7.2.0: the rustls, rustls-webpki and webpki-roots advisories listed in `.cargo/audit.toml` are ignored until a patch release moves `c-questdb-client` to rustls 0.23.46, rustls-webpki 0.103.16 and webpki-roots 1.0.10 or newer; remove each ignore as soon as its fix ships. Enterprise-only durable-ACK qualification remains optional, matching the Python client's policy.
21
21
  4. Run unit, integration, Arrow, native-memory, package, and bounded fault qualification. Shared-host benchmark results remain informational.
22
22
  5. Produce all eight gems, then run `script/verify-gem` on each final file and inspect native linkage. Clean-install each native gem on MRI 3.1, 3.2, 3.3, and 3.4 on its matching platform.
23
23
  6. Confirm the source gem installs with `CARGO_NET_OFFLINE=true` in a clean `GEM_HOME` and contains no generated extension or nested QuestDB server source.
@@ -24,6 +24,7 @@
24
24
  require "mkmf"
25
25
  require "rbconfig"
26
26
  require "fileutils"
27
+ require_relative "unlink_build_ruby"
27
28
 
28
29
  root = File.expand_path("../..", __dir__)
29
30
  client = File.join(root, "vendor", "c-questdb-client")
@@ -73,5 +74,6 @@ when /mswin|mingw/
73
74
  else
74
75
  $LOCAL_LIBS << " -ldl -lpthread -lm -lrt -lutil"
75
76
  end
77
+ QuestDBExtconf.unlink_build_ruby!(host_os)
76
78
  $CFLAGS << " -std=c11 -Wall -Wextra"
77
79
  create_makefile("questdb_ingress")
@@ -509,6 +509,7 @@ static VALUE code_symbol(line_sender_error_code code)
509
509
  case line_sender_error_batch_too_large: name = "batch_too_large"; break;
510
510
  case line_sender_error_store_resend_required: name = "store_resend_required"; break;
511
511
  case line_sender_error_symbol_dict_full: name = "symbol_dict_full"; break;
512
+ case line_sender_error_query_timeout: name = "query_timeout"; break;
512
513
  default: name = "unknown_error"; break;
513
514
  }
514
515
  return ID2SYM(rb_intern(name));
@@ -4380,5 +4381,5 @@ void Init_questdb_ingress(void)
4380
4381
  rb_define_method(cNativeStandalone, "closed?", standalone_closed, 0);
4381
4382
  rb_define_method(cNativeStandalone, "close", standalone_close, 1);
4382
4383
 
4383
- rb_define_const(mNative, "C_CLIENT_VERSION", rb_str_new_cstr("7.1.0"));
4384
+ rb_define_const(mNative, "C_CLIENT_VERSION", rb_str_new_cstr("7.2.0"));
4384
4385
  }
@@ -0,0 +1,58 @@
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
+
24
+ # Link flags for a native extension that must load in any Ruby, not just the
25
+ # one that built it. Precompiled gems would otherwise depend on the build
26
+ # Ruby's libruby path (or, for a static build Ruby, on symbols in its
27
+ # executable). Leaving the Ruby C API unresolved lets it bind to whichever
28
+ # Ruby loads the extension, static or shared.
29
+ module QuestDBExtconf
30
+ BUNDLE_LOADER = /\s*(?:-bundle_loader\s+(?:'[^']*'|"[^"]*"|\S+)|-Wl,-bundle_loader,\S+)/
31
+ # Covers every form libruby reaches a link line in: the mkmf variables (for
32
+ # example EXTDLDFLAGS = "$(LIBRUBYARG_SHARED)" in Ruby 3.1 and 3.2 on macOS)
33
+ # and their expansions.
34
+ LIBRUBY = /\s*(?:\$\(LIBRUBYARG(?:_SHARED|_STATIC)?\)|-l\$\(RUBY_SO_NAME\)|-lruby[\w.-]*)/
35
+
36
+ module_function
37
+
38
+ # Call after `require "mkmf"` and before create_makefile.
39
+ def unlink_build_ruby!(host_os)
40
+ return if host_os.match?(/mswin|mingw/) # Windows extensions must import the Ruby DLL.
41
+
42
+ $LIBRUBYARG = ""
43
+ $LIBRUBYARG_SHARED = ""
44
+ return unless host_os.match?(/darwin/)
45
+
46
+ $DLDFLAGS = strip_build_ruby(String($DLDFLAGS))
47
+ %w[DLDFLAGS EXTDLDFLAGS].each do |key|
48
+ [RbConfig::MAKEFILE_CONFIG, RbConfig::CONFIG].each do |config|
49
+ config[key] = strip_build_ruby(config[key]) if config[key]
50
+ end
51
+ end
52
+ $DLDFLAGS << " -Wl,-undefined,dynamic_lookup" unless $DLDFLAGS.include?("dynamic_lookup")
53
+ end
54
+
55
+ def strip_build_ruby(flags)
56
+ flags.gsub(BUNDLE_LOADER, "").gsub(LIBRUBY, "")
57
+ end
58
+ end
data/lib/questdb/arrow.rb CHANGED
@@ -170,6 +170,7 @@ module QuestDB
170
170
  checkpoint_batches = Integer(checkpoint_batches)
171
171
  raise ArgumentError, "checkpoint_batches must be positive" unless checkpoint_batches.positive?
172
172
  @mutex.synchronize { ensure_open! }
173
+ ensure_durable_ack!(ack_level)
173
174
  Arrow.with_stream(source) do |capsule|
174
175
  @native.ingest_arrow(
175
176
  capsule, table, at_kind, at_value, overrides, ack_level, checkpoint_batches
@@ -201,6 +202,7 @@ module QuestDB
201
202
  checkpoint_batches = Integer(checkpoint_batches)
202
203
  raise ArgumentError, "checkpoint_batches must be positive" unless checkpoint_batches.positive?
203
204
  ensure_ws!
205
+ ensure_durable_ack!(ack_level)
204
206
  Arrow.with_stream(source) do |capsule|
205
207
  @native.ingest_arrow(
206
208
  @configuration, capsule, table, at_kind, at_value,
@@ -84,6 +84,7 @@ module QuestDB
84
84
  error_capacity = validate_capacity!(error_event_inbox_capacity, "error_event_inbox_capacity")
85
85
  parsed_settings = effective_settings || Config.parse(configuration)
86
86
  @auto_flush = parse_auto_flush(parsed_settings)
87
+ @durable_ack_requested = Config.durable_ack_requested?(parsed_settings)
87
88
  @mutex = Mutex.new
88
89
  @native = Ingress::Native::Client.new(
89
90
  configuration, !connection_listener.nil?, connection_capacity,
@@ -160,6 +161,7 @@ module QuestDB
160
161
  ack: ack
161
162
  )
162
163
  @mutex.synchronize { ensure_open! }
164
+ ensure_durable_ack!(prepared[6])
163
165
  return self if prepared[4].zero?
164
166
 
165
167
  @native.ingest_columns(*prepared)
@@ -249,6 +251,10 @@ module QuestDB
249
251
  end
250
252
  end
251
253
 
254
+ def ensure_durable_ack!(ack_level)
255
+ Config.ensure_durable_ack!(@durable_ack_requested, ack_level, PooledSender::ACK_LEVELS[:durable])
256
+ end
257
+
252
258
  def ensure_open!
253
259
  raise Ingress::IngressError.new("client is closed", code: :invalid_api_call) if closed?
254
260
  end
@@ -117,6 +117,25 @@ module QuestDB
117
117
  parts
118
118
  end
119
119
 
120
+ # Whether request_durable_ack asks for durable ACKs. Only the absent and
121
+ # explicit off values are recognised, so future tier values pass through
122
+ # to the native client unchanged.
123
+ def durable_ack_requested?(settings)
124
+ value = settings["request_durable_ack"]
125
+ !value.nil? && !value.to_s.strip.match?(/\A(?:off|false)\z/i)
126
+ end
127
+
128
+ # Fails before anything is sent when ack: :durable can never succeed,
129
+ # instead of after a direct batch or a row frame was already published.
130
+ def ensure_durable_ack!(requested, ack_level, durable_level)
131
+ return if requested || ack_level != durable_level
132
+
133
+ raise Ingress::IngressError.new(
134
+ "ack: :durable requires request_durable_ack=on in the configuration; nothing was sent",
135
+ code: :invalid_api_call
136
+ )
137
+ end
138
+
120
139
  def setting_value(value, key = nil)
121
140
  if key&.to_sym == :tls_verify && value == false
122
141
  "unsafe_off"
@@ -61,6 +61,7 @@ module QuestDB
61
61
  ensure_open!
62
62
  level = normalize_ack(ack)
63
63
  raise ArgumentError, "ack: :durable requires wait: true" if level == ACK_LEVELS[:durable] && !wait
64
+ @client.send(:ensure_durable_ack!, level)
64
65
  return self if empty? && !wait
65
66
  @native.flush(@buffer.send(:native), !!wait, level)
66
67
  @batch_started = nil if @buffer.empty?
@@ -70,7 +71,9 @@ module QuestDB
70
71
  def wait(timeout_millis: 0, ack: :ok)
71
72
  ensure_open!
72
73
  timeout = non_negative_integer(timeout_millis, "timeout_millis")
73
- @native.wait(normalize_ack(ack), timeout)
74
+ level = normalize_ack(ack)
75
+ @client.send(:ensure_durable_ack!, level)
76
+ @native.wait(level, timeout)
74
77
  self
75
78
  end
76
79
 
@@ -121,6 +121,7 @@ module QuestDB
121
121
  max_rows_per_batch: max_rows_per_batch, ack: ack
122
122
  )
123
123
  ensure_ws!
124
+ ensure_durable_ack!(prepared[6])
124
125
  return self if prepared[4].zero?
125
126
  @native.ingest_columns(@configuration, *prepared)
126
127
  self
@@ -142,6 +143,7 @@ module QuestDB
142
143
  raise ArgumentError, "clear: false requires an explicit buffer" if target.equal?(@buffer) && !clear
143
144
  level = normalize_ack(ack)
144
145
  raise ArgumentError, "ack: :durable requires wait: true" if level == ACK_LEVELS[:durable] && !wait
146
+ ensure_durable_ack!(level)
145
147
  return self if target.empty? && !wait
146
148
  @native.flush(target.send(:native), !clear)
147
149
  @native.wait(level, 0) if wait
@@ -151,7 +153,9 @@ module QuestDB
151
153
 
152
154
  def wait(timeout_millis: 0, ack: :ok)
153
155
  ensure_ws!
154
- @native.wait(normalize_ack(ack), non_negative_integer(timeout_millis, "timeout_millis"))
156
+ level = normalize_ack(ack)
157
+ ensure_durable_ack!(level)
158
+ @native.wait(level, non_negative_integer(timeout_millis, "timeout_millis"))
155
159
  self
156
160
  end
157
161
 
@@ -339,6 +343,7 @@ module QuestDB
339
343
  @dispatcher_threads = []
340
344
  @native = @buffer = nil
341
345
  @closed = false
346
+ @durable_ack_requested = Config.durable_ack_requested?(settings)
342
347
  @init_buf_size = Integer(settings.fetch("init_buf_size", 65_536))
343
348
  raise ArgumentError, "init_buf_size must be nonnegative" if @init_buf_size.negative?
344
349
  raise RangeError, "init_buf_size exceeds 100 MiB" if @init_buf_size > Ingress::Buffer::MAX_PREALLOCATE_BYTES
@@ -394,6 +399,10 @@ module QuestDB
394
399
  true
395
400
  end
396
401
 
402
+ def ensure_durable_ack!(ack_level)
403
+ Config.ensure_durable_ack!(@durable_ack_requested, ack_level, ACK_LEVELS[:durable])
404
+ end
405
+
397
406
  def normalize_ack(value)
398
407
  value = value.to_sym if value.respond_to?(:to_sym)
399
408
  ACK_LEVELS.fetch(value) { raise ArgumentError, "ack must be :ok or :durable" }
@@ -21,7 +21,7 @@
21
21
  #
22
22
  module QuestDB
23
23
  module Ingress
24
- VERSION = "0.1.0"
25
- C_CLIENT_VERSION = "7.1.0"
24
+ VERSION = "0.1.1"
25
+ C_CLIENT_VERSION = "7.2.0"
26
26
  end
27
27
  end
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ___ _ ____ ____
5
+ # / _ \ _ _ ___ ___| |_| _ \| __ )
6
+ # | | | | | | |/ _ \/ __| __| | | | _ \
7
+ # | |_| | |_| | __/\__ \ |_| |_| | |_) |
8
+ # \__\_\\__,_|\___||___/\__|____/|____/
9
+ #
10
+ # Copyright (c) 2014-2019 Appsicle
11
+ # Copyright (c) 2019-2026 QuestDB
12
+ #
13
+ # Licensed under the Apache License, Version 2.0 (the "License");
14
+ # you may not use this file except in compliance with the License.
15
+ # You may obtain a copy of the License at
16
+ #
17
+ # http://www.apache.org/licenses/LICENSE-2.0
18
+ #
19
+ # Unless required by applicable law or agreed to in writing, software
20
+ # distributed under the License is distributed on an "AS IS" BASIS,
21
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22
+ # See the License for the specific language governing permissions and
23
+ # limitations under the License.
24
+ #
25
+
26
+ # Fails when a native extension is linked to the Ruby that built it. Such an
27
+ # extension loads on the build machine but not on a user's Ruby, so an install
28
+ # test on the build machine cannot catch it. Reads ELF and Mach-O headers
29
+ # directly; PE (Windows) extensions must import the Ruby DLL and are skipped.
30
+ #
31
+ # script/check-native-linkage lib/questdb/native/*/questdb_ingress.{so,bundle}
32
+
33
+ def elf_needed(bytes)
34
+ raise "32-bit ELF is not supported" unless bytes.getbyte(4) == 2
35
+ le = bytes.getbyte(5) == 1
36
+ q = le ? "Q<" : "Q>"
37
+ l = le ? "L<" : "L>"
38
+ s = le ? "S<" : "S>"
39
+ shoff = bytes.unpack1(q, offset: 0x28)
40
+ shentsize, shnum = bytes.unpack("#{s}#{s}", offset: 0x3a)
41
+ sections = Array.new(shnum) do |i|
42
+ base = shoff + i * shentsize
43
+ { type: bytes.unpack1(l, offset: base + 4), offset: bytes.unpack1(q, offset: base + 0x18),
44
+ size: bytes.unpack1(q, offset: base + 0x20), link: bytes.unpack1(l, offset: base + 0x28) }
45
+ end
46
+ dynamic = sections.find { |sec| sec[:type] == 6 } or return []
47
+ strtab = sections.fetch(dynamic[:link])
48
+ (0...dynamic[:size] / 16).filter_map do |i|
49
+ tag, val = bytes.unpack("#{q}#{q}", offset: dynamic[:offset] + i * 16)
50
+ next unless tag == 1 # DT_NEEDED
51
+
52
+ bytes.byteslice(strtab[:offset] + val, 256).unpack1("Z*")
53
+ end
54
+ end
55
+
56
+ def macho(bytes)
57
+ raise "only 64-bit little-endian Mach-O is supported" unless bytes.unpack1("L<") == 0xfeedfacf
58
+
59
+ ncmds = bytes.unpack1("L<", offset: 16)
60
+ offset = 32
61
+ dylibs = []
62
+ symtab = nil
63
+ ncmds.times do
64
+ cmd, size = bytes.unpack("L<L<", offset: offset)
65
+ case cmd
66
+ when 0xc, 0x80000018, 0x8000001f # LC_LOAD_DYLIB, LC_LOAD_WEAK_DYLIB, LC_REEXPORT_DYLIB
67
+ dylibs << bytes.byteslice(offset + bytes.unpack1("L<", offset: offset + 8), size).unpack1("Z*")
68
+ when 0x2 # LC_SYMTAB
69
+ symtab = bytes.unpack("L<L<L<", offset: offset + 8)
70
+ end
71
+ offset += size
72
+ end
73
+ bound_to_executable = []
74
+ if symtab
75
+ symoff, nsyms, stroff = symtab
76
+ nsyms.times do |i|
77
+ strx, type, _sect, desc = bytes.unpack("L<CCS<", offset: symoff + i * 16)
78
+ next unless (type & 0x0e).zero? && (type & 1) == 1 # undefined external
79
+
80
+ name = bytes.byteslice(stroff + strx, 256).unpack1("Z*")
81
+ bound_to_executable << name if ((desc >> 8) & 0xff) == 0xff # EXECUTABLE_ORDINAL
82
+ end
83
+ end
84
+ [dylibs, bound_to_executable]
85
+ end
86
+
87
+ abort "usage: #{$PROGRAM_NAME} EXTENSION..." if ARGV.empty?
88
+
89
+ failures = []
90
+ ARGV.each do |path|
91
+ bytes = File.binread(path)
92
+ if bytes.start_with?("\x7FELF".b)
93
+ libruby = elf_needed(bytes).grep(/libruby/)
94
+ failures << "#{path}: links #{libruby.join(', ')}" unless libruby.empty?
95
+ elsif bytes.unpack1("L<") == 0xfeedfacf
96
+ dylibs, executable = macho(bytes)
97
+ pinned = dylibs.grep(%r{libruby|/Users/|hostedtoolcache})
98
+ failures << "#{path}: links #{pinned.join(', ')}" unless pinned.empty?
99
+ unless executable.empty?
100
+ failures << "#{path}: #{executable.size} symbols bound to the build Ruby executable " \
101
+ "(e.g. #{executable.first}); link with -undefined dynamic_lookup"
102
+ end
103
+ elsif bytes.start_with?("MZ".b)
104
+ next
105
+ else
106
+ failures << "#{path}: unrecognized binary format"
107
+ end
108
+ end
109
+
110
+ if failures.empty?
111
+ puts "native linkage ok: #{ARGV.size} extension(s) resolve Ruby's C API at load time"
112
+ else
113
+ warn failures
114
+ exit 1
115
+ end
data/test/client_test.rb CHANGED
@@ -35,6 +35,36 @@ class ClientTest < Minitest::Test
35
35
  assert_raises(RangeError) { QuestDB.connect(host: "localhost", port: 0) }
36
36
  end
37
37
 
38
+ # ack: :durable without request_durable_ack can never succeed. It must fail
39
+ # before anything is sent: the unreachable address makes any borrow or
40
+ # connect attempt raise :socket_error instead.
41
+ def test_durable_ack_without_opt_in_fails_before_sending
42
+ fast = LAZY_CONF + "reconnect_max_duration_millis=1;"
43
+ [fast, fast + "request_durable_ack=off;"].each do |conf|
44
+ client = QuestDB.connect(conf)
45
+ [
46
+ -> { client.ingest_columns("t", columns: { v: [1] }, at: QuestDB::SERVER_TIMESTAMP, ack: :durable) },
47
+ -> { client.sender { |s| s.row("t", columns: { v: 1 }, at: QuestDB::SERVER_TIMESTAMP); s.flush(wait: true, ack: :durable) } },
48
+ -> { client.sender { |s| s.wait(ack: :durable) } }
49
+ ].each do |call|
50
+ error = assert_raises(IngressError) { call.call }
51
+ assert_equal :invalid_api_call, error.code, error.message
52
+ refute error.in_doubt?, error.message
53
+ assert_match(/request_durable_ack/, error.message)
54
+ end
55
+ client.close
56
+ end
57
+ end
58
+
59
+ def test_durable_ack_opt_in_is_not_rejected_up_front
60
+ client = QuestDB.connect(LAZY_CONF + "reconnect_max_duration_millis=1;request_durable_ack=on;")
61
+ error = assert_raises(IngressError) do
62
+ client.ingest_columns("t", columns: { v: [1] }, at: QuestDB::SERVER_TIMESTAMP, ack: :durable)
63
+ end
64
+ refute_match(/request_durable_ack/, error.message, "an opted-in client must reach the network")
65
+ client.close
66
+ end
67
+
38
68
  def test_config_builder_ipv6_escaping_and_duplicates
39
69
  conf, = QuestDB::Config.build(host: "::1", port: 9000, tls: true,
40
70
  settings: { username: "a;b", lazy_connect: true })