ch_connect 0.3.1 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8625111f390ba7adf290bf889a5684c34003d1f318f421c62939ae13a310f569
4
- data.tar.gz: 512cc470c691018ac249647c3dc46918490705392667bee72ebb5f49bb604b44
3
+ metadata.gz: 88e326f103cde2be1cd0ed39c0615ff2861020fb2f5d334fcb08ff012196cd3b
4
+ data.tar.gz: 0c6b12e11fdfec5626fefa1cbbcf81da8ed384f4983d1caf0466758adcb617db
5
5
  SHA512:
6
- metadata.gz: 7157352591d8de2ff4f07228e2e57c9df16f0eb8ff4d75e68499bd07d402a082e938bbcaec2975ffba6dfdd697229f5c929925db13cffb643cd3001a275e7010
7
- data.tar.gz: a5c4988858f866533fb27784af8c2a0f109e2432078d4ec819e644b55a45896d47c5fb07a0dd2225b9f2660e0f6cfe9ce880cad3b95dae59feb003b25d1c0bca
6
+ metadata.gz: fda2b6e230f5a66d66a0cd4189dc324f76713756c458bd67e06f1dc0873ec589c53980e497eaa9d37a5dd985ba0f8ef60264bafe8750b2d331ce33e93d12b574
7
+ data.tar.gz: eaa8b14dd4da0af03c2c3ab66825ab27c4770d0f7a7dd596deb4daa06a6b0fc3191a9454b4bd0f3411b3cbe1f85dd5eaf1ff2546a2e20c9160d7f94ae1572b43
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.4.1] - 2026-10-12
2
+
3
+ - Improved large result decoding speed (about 1.9x faster for the 100K-row benchmark query) and reduced its allocations from 97 MB to 55 MB
4
+ - Fixed quadratic re-parsing of large uncompressed result blocks (about 5x faster for multi-megabyte String columns)
5
+ - Faster decoding of repeated `Date`/`Date32` values, `Nullable(String)`, `UUID`, and `Map` with String keys, and faster `Response#each`
6
+
7
+ ## [0.4.0] - 2026-10-06
8
+
9
+ - Raised the minimum supported ClickHouse version to 23.3; connections to older servers are rejected during the handshake
10
+ - Updated vendored clickhouse-c headers to `916cbf5`, adding sparse column decoding
11
+ - Improved decimal decoding performance while preserving exact values across all decimal widths
12
+ - Fixed decoding of geometry types, including Point, Ring, Polygon, MultiPolygon, LineString, and MultiLineString
13
+ - Preserved complete UTF-8 server error messages during connection establishment
14
+
1
15
  ## [0.3.1] - 2026-08-19
2
16
 
3
17
  - Fixed LZ4 compression negotiation for ClickHouse users configured with `readonly = 1`
data/README.md CHANGED
@@ -22,27 +22,27 @@ Speed (iterations/second, higher is better):
22
22
 
23
23
  | Scenario | ch_connect | click_house | clickhouse | click_house-client |
24
24
  |----------|------------|-------------|------------|--------------------|
25
- | Small queries (10 rows) | **962 i/s** | 363 i/s (2.7x slower) | 317 i/s (3.0x slower) | 372 i/s (2.6x slower) |
26
- | Large queries (100K rows) | **8.2 i/s** | 1.1 i/s (7.2x slower) | 0.6 i/s (14.1x slower) | 1.5 i/s (5.3x slower) |
25
+ | Small queries (10 rows) | **1961 i/s** | 718 i/s (2.7x slower) | 524 i/s (3.7x slower) | 796 i/s (2.5x slower) |
26
+ | Large queries (100K rows) | **14.2 i/s** | 1.4 i/s (10.5x slower) | 0.5 i/s (26.4x slower) | 1.8 i/s (8.0x slower) |
27
27
 
28
28
  Multi-threaded throughput (10K-row query, total queries/second):
29
29
 
30
30
  | Threads | ch_connect | click_house | clickhouse | click_house-client |
31
31
  |---------|------------|-------------|------------|--------------------|
32
- | 1 | **292 q/s** | 69 q/s | 117 q/s | 48 q/s |
33
- | 4 | **598 q/s** | 162 q/s | 319 q/s | 82 q/s |
34
- | 8 | **606 q/s** | 158 q/s | 321 q/s | 80 q/s |
32
+ | 1 | **523 q/s** | 74 q/s | 140 q/s | 56 q/s |
33
+ | 4 | **755 q/s** | 148 q/s | 306 q/s | 78 q/s |
34
+ | 8 | **637 q/s** | 150 q/s | 326 q/s | 82 q/s |
35
35
 
36
36
  Memory allocated for a large query (lower is better):
37
37
 
38
38
  | Gem | Allocated |
39
39
  |-----|-----------|
40
- | ch_connect | **97 MB** |
40
+ | ch_connect | **55 MB** |
41
41
  | click_house | 198 MB |
42
42
  | click_house-client | 210 MB |
43
43
  | clickhouse | 436 MB |
44
44
 
45
- A small query allocates approximately **3.2 KB / 32 Ruby objects**. See the [`benchmark/`](benchmark/) directory for the comparison suite and methodology.
45
+ A small query allocates approximately **3.6 KB / 40 Ruby objects**. See the [`benchmark/`](benchmark/) directory for the comparison suite and methodology.
46
46
 
47
47
  ## Installation
48
48
 
@@ -52,7 +52,7 @@ Add the gem to your Gemfile:
52
52
  gem "ch_connect"
53
53
  ```
54
54
 
55
- The gem contains a native extension and currently requires MRI Ruby. LZ4 and ZSTD support are detected when the extension is built.
55
+ The gem contains a native extension and requires MRI Ruby and ClickHouse 23.3 or newer. LZ4 and ZSTD support are detected when the extension is built.
56
56
 
57
57
  ## Configuration
58
58
 
@@ -16,6 +16,7 @@
16
16
  #include <ruby/encoding.h>
17
17
  #include <ruby/intern.h>
18
18
 
19
+ #include <inttypes.h>
19
20
  #include <limits.h>
20
21
  #include <string.h>
21
22
  #include <time.h>
@@ -40,8 +41,6 @@ static VALUE vAF_INET6;
40
41
 
41
42
  static ID id_jd;
42
43
  static ID id_new;
43
- static ID id_pow;
44
- static ID id_div;
45
44
  static ID id_BigDecimal;
46
45
 
47
46
  static VALUE sym_read_rows;
@@ -59,6 +58,9 @@ static VALUE sym_zstd;
59
58
  /* Julian day number of 1970-01-01 (Date.jd(2440588) == Date.new(1970, 1, 1)) */
60
59
  #define UNIX_EPOCH_JD 2440588
61
60
 
61
+ /* Per-column Date cache slots; must be a power of two */
62
+ #define DATE_CACHE_SIZE 256
63
+
62
64
  /* One immutable codec shared by all connections and threads: the built-in
63
65
  * adapters are stateless wrappers over the one-shot lz4/zstd functions. Both
64
66
  * slot pairs are filled (when available) so decode survives a server-side
@@ -136,6 +138,39 @@ load_i64le(const uint8_t *p)
136
138
  return v;
137
139
  }
138
140
 
141
+ /* Little-endian two's complement value of 4, 8, 16 or 32 bytes as int64, when it fits. */
142
+ static int
143
+ load_decimal_i64(const uint8_t *p, size_t nbytes, int64_t *out)
144
+ {
145
+ if (nbytes == 4) {
146
+ *out = load_i32le(p);
147
+ return 1;
148
+ }
149
+ int64_t v = load_i64le(p);
150
+ uint8_t sign = v < 0 ? 0xFF : 0x00;
151
+ for (size_t b = 8; b < nbytes; b++) {
152
+ if (p[b] != sign) return 0;
153
+ }
154
+ *out = v;
155
+ return 1;
156
+ }
157
+
158
+ /* "<unscaled>e-<scale>" for a decimal value. BigDecimal parses it exactly,
159
+ * without the intermediate BigDecimals of dividing by 10**scale. Only values
160
+ * outside the int64 range go through a Ruby Integer. */
161
+ static VALUE
162
+ decimal_literal(const uint8_t *p, size_t nbytes, int scale)
163
+ {
164
+ int64_t v;
165
+ if (load_decimal_i64(p, nbytes, &v)) {
166
+ char buf[48];
167
+ int len = snprintf(buf, sizeof(buf), "%" PRId64 "e-%d", v, scale);
168
+ return rb_usascii_str_new(buf, len);
169
+ }
170
+ VALUE unscaled = rb_integer_unpack(p, nbytes, 1, 0, INTEGER_PACK_LITTLE_ENDIAN | INTEGER_PACK_2COMP);
171
+ return rb_str_catf(rb_big2str(unscaled, 10), "e-%d", scale);
172
+ }
173
+
139
174
  static inline float
140
175
  load_f32le(const uint8_t *p)
141
176
  {
@@ -259,17 +294,23 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
259
294
  }
260
295
  break;
261
296
  }
262
- case CHC_DATE: {
263
- for (long i = 0; i < n_rows; i++) {
264
- uint16_t days = load_u16le(data + i * 2);
265
- rb_ary_push(ary, rb_funcall(cDate, id_jd, 1, LONG2NUM(UNIX_EPOCH_JD + (long)days)));
266
- }
267
- break;
268
- }
297
+ case CHC_DATE:
269
298
  case CHC_DATE32: {
299
+ /* Date construction is a Ruby method call and columns usually repeat
300
+ * a handful of days, so reuse (immutable) Dates through a small
301
+ * direct-mapped cache. Every cached Date is already anchored by ary. */
302
+ long cache_days[DATE_CACHE_SIZE];
303
+ VALUE cache_vals[DATE_CACHE_SIZE];
304
+ for (int k = 0; k < DATE_CACHE_SIZE; k++) cache_vals[k] = Qundef;
270
305
  for (long i = 0; i < n_rows; i++) {
271
- int32_t days = load_i32le(data + i * 4);
272
- rb_ary_push(ary, rb_funcall(cDate, id_jd, 1, LONG2NUM(UNIX_EPOCH_JD + (long)days)));
306
+ long days = kind == CHC_DATE ? (long)load_u16le(data + i * 2)
307
+ : (long)load_i32le(data + i * 4);
308
+ unsigned long slot = (unsigned long)days & (DATE_CACHE_SIZE - 1);
309
+ if (cache_vals[slot] == Qundef || cache_days[slot] != days) {
310
+ cache_days[slot] = days;
311
+ cache_vals[slot] = rb_funcall(cDate, id_jd, 1, LONG2NUM(UNIX_EPOCH_JD + days));
312
+ }
313
+ rb_ary_push(ary, cache_vals[slot]);
273
314
  }
274
315
  break;
275
316
  }
@@ -299,18 +340,23 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
299
340
  break;
300
341
  }
301
342
  case CHC_UUID: {
302
- char buf[37];
343
+ /* Each 8-byte half is little-endian on the wire; emit both halves
344
+ * most significant nibble first, with dashes after hex digits
345
+ * 8, 12, 16 and 20. */
346
+ static const char hex[] = "0123456789abcdef";
303
347
  for (long i = 0; i < n_rows; i++) {
304
348
  const uint8_t *p = data + i * 16;
305
- uint64_t hi = load_u64le(p);
306
- uint64_t lo = load_u64le(p + 8);
307
- snprintf(buf, sizeof(buf), "%08llx-%04llx-%04llx-%04llx-%012llx",
308
- (unsigned long long)(hi >> 32),
309
- (unsigned long long)((hi >> 16) & 0xFFFF),
310
- (unsigned long long)(hi & 0xFFFF),
311
- (unsigned long long)(lo >> 48),
312
- (unsigned long long)(lo & 0xFFFFFFFFFFFFULL));
313
- rb_ary_push(ary, rb_utf8_str_new(buf, 36));
349
+ uint64_t halves[2] = { load_u64le(p), load_u64le(p + 8) };
350
+ VALUE str = rb_utf8_str_new(NULL, 36);
351
+ char *out = RSTRING_PTR(str);
352
+ int o = 0;
353
+ for (int h = 0; h < 2; h++) {
354
+ for (int shift = 60; shift >= 0; shift -= 4) {
355
+ if (o == 8 || o == 13 || o == 18 || o == 23) out[o++] = '-';
356
+ out[o++] = hex[(halves[h] >> shift) & 0xF];
357
+ }
358
+ }
359
+ rb_ary_push(ary, str);
314
360
  }
315
361
  break;
316
362
  }
@@ -334,23 +380,9 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
334
380
  case CHC_DECIMAL128:
335
381
  case CHC_DECIMAL256: {
336
382
  int scale = chc_type_decimal_scale(t);
337
- VALUE divisor = rb_funcall(INT2FIX(10), id_pow, 1, INT2FIX(scale));
383
+ size_t nbytes = kind == CHC_DECIMAL32 ? 4 : kind == CHC_DECIMAL64 ? 8 : kind == CHC_DECIMAL128 ? 16 : 32;
338
384
  for (long i = 0; i < n_rows; i++) {
339
- VALUE unscaled;
340
- if (kind == CHC_DECIMAL32) {
341
- int32_t v = load_i32le(data + i * 4);
342
- unscaled = INT2NUM(v);
343
- } else if (kind == CHC_DECIMAL64) {
344
- int64_t v = load_i64le(data + i * 8);
345
- unscaled = LL2NUM(v);
346
- } else {
347
- size_t nbytes = (kind == CHC_DECIMAL128) ? 16 : 32;
348
- unscaled = rb_integer_unpack(data + i * nbytes, nbytes, 1, 0,
349
- INTEGER_PACK_LITTLE_ENDIAN |
350
- INTEGER_PACK_2COMP);
351
- }
352
- VALUE bd = rb_funcall(rb_mKernel, id_BigDecimal, 1, unscaled);
353
- rb_ary_push(ary, rb_funcall(bd, id_div, 1, divisor));
385
+ rb_ary_push(ary, rb_funcall(rb_mKernel, id_BigDecimal, 1, decimal_literal(data + i * nbytes, nbytes, scale)));
354
386
  }
355
387
  break;
356
388
  }
@@ -361,17 +393,98 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
361
393
  return ary;
362
394
  }
363
395
 
396
+ /* null_map (optional) marks rows that a Nullable wrapper turns into nil, so
397
+ * their placeholder strings are never allocated. */
398
+ static VALUE
399
+ decode_string_column(const chc_column *col, long n_rows, const uint8_t *null_map)
400
+ {
401
+ const uint8_t *data = chc_column_string_data(col);
402
+ const uint64_t *offsets = chc_column_string_offsets(col);
403
+ VALUE ary = rb_ary_new_capa(n_rows);
404
+
405
+ uint64_t start = 0;
406
+ for (long i = 0; i < n_rows; i++) {
407
+ uint64_t end = offsets[i];
408
+ if (null_map && null_map[i] == 1) rb_ary_push(ary, Qnil);
409
+ else rb_ary_push(ary, rb_utf8_str_new((const char *)data + start, (long)(end - start)));
410
+ start = end;
411
+ }
412
+ return ary;
413
+ }
414
+
364
415
  static VALUE
365
- decode_string_column(const chc_column *col, long n_rows)
416
+ decode_interned_string_column(const chc_column *col, long n_rows)
366
417
  {
367
418
  const uint8_t *data = chc_column_string_data(col);
368
419
  const uint64_t *offsets = chc_column_string_offsets(col);
420
+ rb_encoding *utf8 = rb_utf8_encoding();
369
421
  VALUE ary = rb_ary_new_capa(n_rows);
370
422
 
371
423
  uint64_t start = 0;
372
424
  for (long i = 0; i < n_rows; i++) {
373
425
  uint64_t end = offsets[i];
374
- rb_ary_push(ary, rb_utf8_str_new((const char *)data + start, (long)(end - start)));
426
+ rb_ary_push(ary, rb_enc_interned_str((const char *)data + start, (long)(end - start), utf8));
427
+ start = end;
428
+ }
429
+ return ary;
430
+ }
431
+
432
+ static void
433
+ raise_invalid_geometry(native_state *state, const char *detail)
434
+ {
435
+ *state = NATIVE_BROKEN;
436
+ rb_raise(eConnectionError, "Invalid native geometry column: %s", detail);
437
+ }
438
+
439
+ /* clickhouse-c exposes geometry aliases as their wire-level Array/Tuple
440
+ * column tree, while the logical geometry type has no synthetic children. */
441
+ static VALUE
442
+ decode_geometry(const chc_column *col, long n_rows, int depth, native_state *state)
443
+ {
444
+ if (n_rows == 0) return rb_ary_new();
445
+
446
+ if (depth == 0) {
447
+ if (chc_column_layout(col) != CHC_COL_TUPLE ||
448
+ chc_column_tuple_arity(col) != 2) {
449
+ raise_invalid_geometry(state, "Point must be a two-coordinate tuple");
450
+ }
451
+
452
+ const chc_column *x_col = chc_column_tuple_child(col, 0);
453
+ const chc_column *y_col = chc_column_tuple_child(col, 1);
454
+ size_t x_size = 0;
455
+ size_t y_size = 0;
456
+ const uint8_t *x = (const uint8_t *)chc_column_fixed_data(x_col, &x_size);
457
+ const uint8_t *y = (const uint8_t *)chc_column_fixed_data(y_col, &y_size);
458
+ if (!x || !y || x_size != 8 || y_size != 8) {
459
+ raise_invalid_geometry(state, "Point coordinates must be Float64 columns");
460
+ }
461
+
462
+ VALUE points = rb_ary_new_capa(n_rows);
463
+ for (long i = 0; i < n_rows; i++) {
464
+ VALUE point = rb_ary_new_capa(2);
465
+ rb_ary_push(point, DBL2NUM(load_f64le(x + i * 8)));
466
+ rb_ary_push(point, DBL2NUM(load_f64le(y + i * 8)));
467
+ rb_ary_push(points, point);
468
+ }
469
+ return points;
470
+ }
471
+
472
+ if (chc_column_layout(col) != CHC_COL_ARRAY) {
473
+ raise_invalid_geometry(state, "expected an Array layer");
474
+ }
475
+
476
+ const uint64_t *offsets = chc_column_array_offsets(col);
477
+ const chc_column *values_col = chc_column_array_values(col);
478
+ long total = (long)chc_column_n_rows(values_col);
479
+ VALUE elements = decode_geometry(values_col, total, depth - 1, state);
480
+ VALUE ary = rb_ary_new_capa(n_rows);
481
+ uint64_t start = 0;
482
+ for (long i = 0; i < n_rows; i++) {
483
+ uint64_t end = offsets[i];
484
+ if (end < start || end > (uint64_t)RARRAY_LEN(elements)) {
485
+ raise_invalid_geometry(state, "Array offset is out of bounds");
486
+ }
487
+ rb_ary_push(ary, rb_ary_subseq(elements, (long)start, (long)(end - start)));
375
488
  start = end;
376
489
  }
377
490
  return ary;
@@ -380,6 +493,21 @@ decode_string_column(const chc_column *col, long n_rows)
380
493
  static VALUE
381
494
  decode_column(const chc_column *col, const chc_type *t, long n_rows, native_state *state)
382
495
  {
496
+ switch (chc_type_kind(t)) {
497
+ case CHC_POINT:
498
+ return decode_geometry(col, n_rows, 0, state);
499
+ case CHC_RING:
500
+ case CHC_LINE_STRING:
501
+ return decode_geometry(col, n_rows, 1, state);
502
+ case CHC_POLYGON:
503
+ case CHC_MULTI_LINE_STRING:
504
+ return decode_geometry(col, n_rows, 2, state);
505
+ case CHC_MULTI_POLYGON:
506
+ return decode_geometry(col, n_rows, 3, state);
507
+ default:
508
+ break;
509
+ }
510
+
383
511
  if (n_rows == 0) {
384
512
  chc_kind kind = chc_type_kind(t);
385
513
  if (kind == CHC_STRING) return rb_ary_new();
@@ -398,12 +526,14 @@ decode_column(const chc_column *col, const chc_type *t, long n_rows, native_stat
398
526
  return decode_fixed(col, t, n_rows, state);
399
527
 
400
528
  case CHC_COL_STRING:
401
- return decode_string_column(col, n_rows);
529
+ return decode_string_column(col, n_rows, NULL);
402
530
 
403
531
  case CHC_COL_NULLABLE: {
404
532
  const uint8_t *null_map = chc_column_null_map(col);
405
533
  const chc_column *inner = chc_column_nullable_inner(col);
406
534
  const chc_type *inner_t = chc_type_child(t, 0);
535
+ if (chc_column_layout(inner) == CHC_COL_STRING)
536
+ return decode_string_column(inner, n_rows, null_map);
407
537
  VALUE vals = decode_column(inner, inner_t, n_rows, state);
408
538
  for (long i = 0; i < n_rows; i++) {
409
539
  if (null_map[i] == 1) rb_ary_store(vals, i, Qnil);
@@ -422,7 +552,12 @@ decode_column(const chc_column *col, const chc_type *t, long n_rows, native_stat
422
552
  long total = (long)chc_column_n_rows(values_col);
423
553
  const chc_column *keys_col = chc_column_tuple_child(values_col, 0);
424
554
  const chc_column *vals_col = chc_column_tuple_child(values_col, 1);
425
- VALUE keys = decode_column(keys_col, kt, total, state);
555
+ /* Hash#[]= dups and freezes unfrozen String keys; decoding them
556
+ * as interned (frozen, deduplicated) strings skips that copy, and
557
+ * map keys typically repeat across rows. */
558
+ VALUE keys = chc_column_layout(keys_col) == CHC_COL_STRING
559
+ ? decode_interned_string_column(keys_col, total)
560
+ : decode_column(keys_col, kt, total, state);
426
561
  VALUE vals = decode_column(vals_col, vt, total, state);
427
562
 
428
563
  VALUE ary = rb_ary_new_capa(n_rows);
@@ -435,7 +570,7 @@ decode_column(const chc_column *col, const chc_type *t, long n_rows, native_stat
435
570
  "Map offset out of bounds at row %ld: %llu", i,
436
571
  (unsigned long long)end);
437
572
  }
438
- VALUE hash = rb_hash_new();
573
+ VALUE hash = rb_hash_new_capa((long)(end - start));
439
574
  for (uint64_t j = start; j < end; j++) {
440
575
  rb_hash_aset(hash, RARRAY_AREF(keys, (long)j), RARRAY_AREF(vals, (long)j));
441
576
  }
@@ -638,7 +773,8 @@ native_client_handshake_step(VALUE self)
638
773
 
639
774
  nc->state = NATIVE_ACTIVE;
640
775
  chc_err err = {0};
641
- int rc = chc_async_handshake(nc->ac, &err);
776
+ chc_exception *exception = NULL;
777
+ int rc = chc_async_handshake(nc->ac, &exception, &err);
642
778
  if (rc == CHC_OK) {
643
779
  nc->state = NATIVE_READY;
644
780
  return sym_done;
@@ -646,6 +782,16 @@ native_client_handshake_step(VALUE self)
646
782
  if (rc == CHC_WOULD_BLOCK) return sym_want_read;
647
783
 
648
784
  nc->state = NATIVE_BROKEN;
785
+ if (exception) {
786
+ /* Anchor the server exception before allocating Ruby objects so close
787
+ * or GC can still release it if message construction raises. */
788
+ nc->pending_pkt.kind = CHC_PKT_EXCEPTION;
789
+ nc->pending_pkt.exception = exception;
790
+ VALUE msg = rb_utf8_str_new(exception->display_text,
791
+ (long)exception->display_text_len);
792
+ clear_pending(nc);
793
+ rb_exc_raise(rb_exc_new_str(eConnectionError, msg));
794
+ }
649
795
  rb_raise(eConnectionError, "%s", err.msg);
650
796
  }
651
797
 
@@ -808,13 +954,16 @@ native_client_decode_block(VALUE self, native_client_t *nc, chc_block *block)
808
954
  (long)n_rows, &nc->state);
809
955
  rb_ary_push(col_vals, cols[i]);
810
956
  }
957
+ /* row_vals only holds references that cols[] already anchors */
958
+ VALUE row_buf;
959
+ VALUE *row_vals = ALLOCV_N(VALUE, row_buf, n_cols);
811
960
  for (size_t r = 0; r < n_rows; r++) {
812
- VALUE row = rb_ary_new_capa((long)n_cols);
813
961
  for (size_t c = 0; c < n_cols; c++) {
814
- rb_ary_push(row, RARRAY_AREF(cols[c], (long)r));
962
+ row_vals[c] = RARRAY_AREF(cols[c], (long)r);
815
963
  }
816
- rb_ary_push(rows, row);
964
+ rb_ary_push(rows, rb_ary_new_from_values((long)n_cols, row_vals));
817
965
  }
966
+ ALLOCV_END(row_buf);
818
967
  ALLOCV_END(cols_buf);
819
968
  RB_GC_GUARD(col_vals);
820
969
  }
@@ -880,7 +1029,7 @@ native_client_recv_step(VALUE self)
880
1029
  nc->state = NATIVE_READY;
881
1030
  return sym_done;
882
1031
  }
883
- /* PONG / TOTALS / EXTREMES / LOG / PROFILE_EVENTS — skip. */
1032
+ /* PONG / TOTALS / EXTREMES / LOG / PROFILE_EVENTS / TIMEZONE_UPDATE — skip. */
884
1033
  }
885
1034
  }
886
1035
 
@@ -969,8 +1118,6 @@ Init_ch_connect_native(void)
969
1118
 
970
1119
  id_jd = rb_intern("jd");
971
1120
  id_new = rb_intern("new");
972
- id_pow = rb_intern("**");
973
- id_div = rb_intern("/");
974
1121
  id_BigDecimal = rb_intern("BigDecimal");
975
1122
 
976
1123
  sym_read_rows = ID2SYM(rb_intern("read_rows"));
@@ -27,6 +27,7 @@ module ChConnect
27
27
 
28
28
  READ_CHUNK = 64 * 1024
29
29
  WRITE_CHUNK = 64 * 1024
30
+ DRAIN_LIMIT = 16 * 1024 * 1024
30
31
  PARAM_ESCAPES = {
31
32
  "\0" => "\\0", "\a" => "\\a", "\b" => "\\b", "\e" => "\\e",
32
33
  "\f" => "\\f", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t",
@@ -60,7 +61,7 @@ module ChConnect
60
61
  loop do
61
62
  case @client.recv_step
62
63
  when :done then break
63
- when :want_read then @client.feed(read_chunk)
64
+ when :want_read then feed_available
64
65
  end
65
66
  end
66
67
  @client.take_result
@@ -191,6 +192,23 @@ module ChConnect
191
192
  end
192
193
  end
193
194
 
195
+ # Blocks for one chunk, then drains whatever else the socket already has
196
+ # buffered (up to DRAIN_LIMIT). An uncompressed Data block that ends
197
+ # mid-column is re-parsed from that column's start on the next
198
+ # recv_step, so retrying once per burst instead of once per chunk keeps
199
+ # large blocks from going quadratic.
200
+ def feed_available
201
+ @client.feed(read_chunk)
202
+ drained = 0
203
+ while drained < DRAIN_LIMIT
204
+ chunk = @socket.read_nonblock(READ_CHUNK, @read_buf, exception: false)
205
+ break unless chunk.is_a?(String)
206
+
207
+ @client.feed(chunk)
208
+ drained += chunk.bytesize
209
+ end
210
+ end
211
+
194
212
  # Reads into a reusable buffer: feed copies the bytes synchronously,
195
213
  # so the buffer never needs to survive past the next read.
196
214
  def read_chunk(timeout = @config.read_timeout)
@@ -17,7 +17,18 @@ module ChConnect
17
17
  def each
18
18
  return to_enum(:each) unless block_given?
19
19
 
20
- rows.each { |row| yield columns.zip(row).to_h }
20
+ # An index loop avoids zip's per-row pair arrays (~3x faster under YJIT).
21
+ names = columns
22
+ count = names.size
23
+ rows.each do |row|
24
+ hash = {}
25
+ index = 0
26
+ while index < count
27
+ hash[names[index]] = row[index]
28
+ index += 1
29
+ end
30
+ yield hash
31
+ end
21
32
  end
22
33
  end
23
34
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChConnect
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.1"
5
5
  end
@@ -1,9 +1,12 @@
1
1
  # Vendored: clickhouse-c
2
2
 
3
3
  - Source: https://github.com/ClickHouse/clickhouse-c
4
- - Commit: dd47d2882646aeba5a1946c62a229d57abea6c28 (cloned 2026-07-14)
4
+ - Commit: 916cbf5ff41a218978bc073bca10f5b609a00081 (updated 2026-10-05)
5
5
  - License: Apache-2.0 (see LICENSE)
6
6
 
7
+ The native client requires ClickHouse 23.3 or newer and advertises protocol
8
+ revision 54465, including sparse column serialization support.
9
+
7
10
  Only the headers the extension includes are vendored: `clickhouse.h`,
8
11
  `clickhouse-compression.h`, `clickhouse-client.h`, `clickhouse-async.h`
9
12
  (plus LICENSE). Upstream's tests, docs, tools, and the `clickhouse-posix-io.h`
@@ -12,7 +15,7 @@ ioless (Ruby owns the socket and TLS).
12
15
 
13
16
  ## Local patches
14
17
 
15
- - `clickhouse.h` — `chc__col_read`, `case CHC_TUPLE`: ClickHouse serializes the
18
+ - `clickhouse.h` — `chc__col_read_tuple`: ClickHouse serializes the
16
19
  empty `Tuple()` as one UInt8 (zero) per row (ClickHouse/ClickHouse#55061);
17
20
  upstream reads nothing and desyncs the stream. Marked with
18
21
  `LOCAL PATCH (ch_connect)`. Should be upstreamed.
@@ -47,8 +47,15 @@ int chc_async_submit(chc_async_client *c, const void *buf, size_t len,
47
47
  void chc_async_pending_out(chc_async_client *c, const uint8_t **buf, size_t *len);
48
48
  void chc_async_consume_out(chc_async_client *c, size_t n);
49
49
 
50
- /* drive -- never block; CHC_WOULD_BLOCK = submit more / drain out */
51
- int chc_async_handshake(chc_async_client *c, chc_err *err);
50
+ /* Drive calls never block. CHC_WOULD_BLOCK requests more input or output
51
+ * draining
52
+ *
53
+ * Handshake rejection returns CHC_ERR_SERVER and leaves err->msg empty. When
54
+ * exc is not NULL, caller owns *exc and frees it with chc_exception_free.
55
+ * Other results leave *exc unchanged. Query exceptions still arrive as
56
+ * packets, but handshake rejection ends connection setup */
57
+ int chc_async_handshake(chc_async_client *c, chc_exception **exc,
58
+ chc_err *err);
52
59
  int chc_async_send_query(chc_async_client *c, const char *sql, size_t sql_len,
53
60
  const char *query_id, size_t query_id_len, chc_err *err);
54
61
  int chc_async_send_data(chc_async_client *c, const chc_block_builder *bb,
@@ -122,16 +129,7 @@ chc_async_client_init(chc_async_client **out, const chc_client_opts *opts,
122
129
  if (!c) return CHC_ERR_OOM;
123
130
 
124
131
  c->cli.al = al;
125
- c->cli.client_version_major = opts->client_version_major;
126
- c->cli.client_version_minor = opts->client_version_minor;
127
- c->cli.client_version_patch = opts->client_version_patch;
128
- c->cli.client_revision = opts->client_revision ? opts->client_revision
129
- : CHC_CLIENT_DEFAULT_REVISION;
130
- c->cli.compression = opts->codec ? opts->compression : CHC_COMP_NONE;
131
- c->cli.codec = opts->codec;
132
- /* Seed server.revision so block/packet framing is well-defined before the
133
- * handshake completes; recv after handshake uses min(client, server). */
134
- c->cli.server.revision = c->cli.client_revision;
132
+ chc__client_setup(&c->cli, opts);
135
133
 
136
134
  chc__mem_sink_init(&c->out, &c->out_io, al);
137
135
  c->cli.io = &c->out_io;
@@ -196,7 +194,7 @@ chc_async_consume_out(chc_async_client *c, size_t n)
196
194
  }
197
195
 
198
196
  int
199
- chc_async_handshake(chc_async_client *c, chc_err *err)
197
+ chc_async_handshake(chc_async_client *c, chc_exception **exc, chc_err *err)
200
198
  {
201
199
  int rc;
202
200
  chc_client *cli = &c->cli;
@@ -209,7 +207,6 @@ chc_async_handshake(chc_async_client *c, chc_err *err)
209
207
  .client_version_major = cli->client_version_major,
210
208
  .client_version_minor = cli->client_version_minor,
211
209
  .client_version_patch = cli->client_version_patch,
212
- .client_revision = cli->client_revision,
213
210
  .database = c->database,
214
211
  .user = c->user,
215
212
  .password = c->password,
@@ -222,27 +219,23 @@ chc_async_handshake(chc_async_client *c, chc_err *err)
222
219
 
223
220
  case CHC__HS_RECV_HELLO:
224
221
  chc__in_checkpoint(&cli->in);
225
- rc = chc__client_recv_hello(cli, err);
222
+ rc = chc__client_recv_hello(cli, exc, err);
226
223
  if (rc == CHC_WOULD_BLOCK) { chc__in_rewind(&cli->in); return rc; }
227
224
  if (rc != CHC_OK) return rc; /* server exception / protocol */
228
225
  chc_in_reset(&cli->in);
229
- if (cli->server.revision > cli->client_revision)
230
- cli->server.revision = cli->client_revision;
231
226
  c->hs_phase = CHC__HS_POST_HELLO;
232
227
  continue;
233
228
 
234
229
  case CHC__HS_POST_HELLO:
235
- if (cli->server.revision >= CHC__REV_ADDENDUM) {
236
- rc = chc__write_string(cli->io, "", 0, err); /* quota_key */
237
- if (rc != CHC_OK) return rc;
238
- }
230
+ rc = chc__write_string(cli->io, "", 0, err); /* quota_key */
231
+ if (rc != CHC_OK) return rc;
239
232
  rc = chc_client_send_ping(cli, err);
240
233
  if (rc != CHC_OK) return rc;
241
234
  c->hs_phase = CHC__HS_RECV_PONG;
242
235
  continue;
243
236
 
244
237
  case CHC__HS_RECV_PONG:
245
- rc = chc__recv_pong(cli, err); /* owns checkpoint/rewind */
238
+ rc = chc__recv_pong(cli, exc, err); /* owns checkpoint/rewind */
246
239
  if (rc != CHC_OK) return rc;
247
240
  chc_in_reset(&cli->in);
248
241
  c->hs_phase = CHC__HS_DONE;