ch_connect 0.4.0 → 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 +4 -4
- data/CHANGELOG.md +5 -1
- data/README.md +7 -7
- data/ext/ch_connect_native/ch_connect_native.c +71 -27
- data/lib/ch_connect/connection.rb +19 -1
- data/lib/ch_connect/response.rb +12 -1
- data/lib/ch_connect/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 88e326f103cde2be1cd0ed39c0615ff2861020fb2f5d334fcb08ff012196cd3b
|
|
4
|
+
data.tar.gz: 0c6b12e11fdfec5626fefa1cbbcf81da8ed384f4983d1caf0466758adcb617db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fda2b6e230f5a66d66a0cd4189dc324f76713756c458bd67e06f1dc0873ec589c53980e497eaa9d37a5dd985ba0f8ef60264bafe8750b2d331ce33e93d12b574
|
|
7
|
+
data.tar.gz: eaa8b14dd4da0af03c2c3ab66825ab27c4770d0f7a7dd596deb4daa06a6b0fc3191a9454b4bd0f3411b3cbe1f85dd5eaf1ff2546a2e20c9160d7f94ae1572b43
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
##
|
|
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`
|
|
2
6
|
|
|
3
7
|
## [0.4.0] - 2026-10-06
|
|
4
8
|
|
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) | **
|
|
26
|
-
| Large queries (100K rows) | **
|
|
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 | **
|
|
33
|
-
| 4 | **
|
|
34
|
-
| 8 | **
|
|
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 | **
|
|
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.
|
|
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
|
|
|
@@ -58,6 +58,9 @@ static VALUE sym_zstd;
|
|
|
58
58
|
/* Julian day number of 1970-01-01 (Date.jd(2440588) == Date.new(1970, 1, 1)) */
|
|
59
59
|
#define UNIX_EPOCH_JD 2440588
|
|
60
60
|
|
|
61
|
+
/* Per-column Date cache slots; must be a power of two */
|
|
62
|
+
#define DATE_CACHE_SIZE 256
|
|
63
|
+
|
|
61
64
|
/* One immutable codec shared by all connections and threads: the built-in
|
|
62
65
|
* adapters are stateless wrappers over the one-shot lz4/zstd functions. Both
|
|
63
66
|
* slot pairs are filled (when available) so decode survives a server-side
|
|
@@ -291,17 +294,23 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
|
|
|
291
294
|
}
|
|
292
295
|
break;
|
|
293
296
|
}
|
|
294
|
-
case CHC_DATE:
|
|
295
|
-
for (long i = 0; i < n_rows; i++) {
|
|
296
|
-
uint16_t days = load_u16le(data + i * 2);
|
|
297
|
-
rb_ary_push(ary, rb_funcall(cDate, id_jd, 1, LONG2NUM(UNIX_EPOCH_JD + (long)days)));
|
|
298
|
-
}
|
|
299
|
-
break;
|
|
300
|
-
}
|
|
297
|
+
case CHC_DATE:
|
|
301
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;
|
|
302
305
|
for (long i = 0; i < n_rows; i++) {
|
|
303
|
-
|
|
304
|
-
|
|
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]);
|
|
305
314
|
}
|
|
306
315
|
break;
|
|
307
316
|
}
|
|
@@ -331,18 +340,23 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
|
|
|
331
340
|
break;
|
|
332
341
|
}
|
|
333
342
|
case CHC_UUID: {
|
|
334
|
-
|
|
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";
|
|
335
347
|
for (long i = 0; i < n_rows; i++) {
|
|
336
348
|
const uint8_t *p = data + i * 16;
|
|
337
|
-
uint64_t
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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);
|
|
346
360
|
}
|
|
347
361
|
break;
|
|
348
362
|
}
|
|
@@ -379,17 +393,37 @@ decode_fixed(const chc_column *col, const chc_type *t, long n_rows, native_state
|
|
|
379
393
|
return ary;
|
|
380
394
|
}
|
|
381
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
|
+
|
|
382
415
|
static VALUE
|
|
383
|
-
|
|
416
|
+
decode_interned_string_column(const chc_column *col, long n_rows)
|
|
384
417
|
{
|
|
385
418
|
const uint8_t *data = chc_column_string_data(col);
|
|
386
419
|
const uint64_t *offsets = chc_column_string_offsets(col);
|
|
420
|
+
rb_encoding *utf8 = rb_utf8_encoding();
|
|
387
421
|
VALUE ary = rb_ary_new_capa(n_rows);
|
|
388
422
|
|
|
389
423
|
uint64_t start = 0;
|
|
390
424
|
for (long i = 0; i < n_rows; i++) {
|
|
391
425
|
uint64_t end = offsets[i];
|
|
392
|
-
rb_ary_push(ary,
|
|
426
|
+
rb_ary_push(ary, rb_enc_interned_str((const char *)data + start, (long)(end - start), utf8));
|
|
393
427
|
start = end;
|
|
394
428
|
}
|
|
395
429
|
return ary;
|
|
@@ -492,12 +526,14 @@ decode_column(const chc_column *col, const chc_type *t, long n_rows, native_stat
|
|
|
492
526
|
return decode_fixed(col, t, n_rows, state);
|
|
493
527
|
|
|
494
528
|
case CHC_COL_STRING:
|
|
495
|
-
return decode_string_column(col, n_rows);
|
|
529
|
+
return decode_string_column(col, n_rows, NULL);
|
|
496
530
|
|
|
497
531
|
case CHC_COL_NULLABLE: {
|
|
498
532
|
const uint8_t *null_map = chc_column_null_map(col);
|
|
499
533
|
const chc_column *inner = chc_column_nullable_inner(col);
|
|
500
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);
|
|
501
537
|
VALUE vals = decode_column(inner, inner_t, n_rows, state);
|
|
502
538
|
for (long i = 0; i < n_rows; i++) {
|
|
503
539
|
if (null_map[i] == 1) rb_ary_store(vals, i, Qnil);
|
|
@@ -516,7 +552,12 @@ decode_column(const chc_column *col, const chc_type *t, long n_rows, native_stat
|
|
|
516
552
|
long total = (long)chc_column_n_rows(values_col);
|
|
517
553
|
const chc_column *keys_col = chc_column_tuple_child(values_col, 0);
|
|
518
554
|
const chc_column *vals_col = chc_column_tuple_child(values_col, 1);
|
|
519
|
-
|
|
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);
|
|
520
561
|
VALUE vals = decode_column(vals_col, vt, total, state);
|
|
521
562
|
|
|
522
563
|
VALUE ary = rb_ary_new_capa(n_rows);
|
|
@@ -529,7 +570,7 @@ decode_column(const chc_column *col, const chc_type *t, long n_rows, native_stat
|
|
|
529
570
|
"Map offset out of bounds at row %ld: %llu", i,
|
|
530
571
|
(unsigned long long)end);
|
|
531
572
|
}
|
|
532
|
-
VALUE hash =
|
|
573
|
+
VALUE hash = rb_hash_new_capa((long)(end - start));
|
|
533
574
|
for (uint64_t j = start; j < end; j++) {
|
|
534
575
|
rb_hash_aset(hash, RARRAY_AREF(keys, (long)j), RARRAY_AREF(vals, (long)j));
|
|
535
576
|
}
|
|
@@ -913,13 +954,16 @@ native_client_decode_block(VALUE self, native_client_t *nc, chc_block *block)
|
|
|
913
954
|
(long)n_rows, &nc->state);
|
|
914
955
|
rb_ary_push(col_vals, cols[i]);
|
|
915
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);
|
|
916
960
|
for (size_t r = 0; r < n_rows; r++) {
|
|
917
|
-
VALUE row = rb_ary_new_capa((long)n_cols);
|
|
918
961
|
for (size_t c = 0; c < n_cols; c++) {
|
|
919
|
-
|
|
962
|
+
row_vals[c] = RARRAY_AREF(cols[c], (long)r);
|
|
920
963
|
}
|
|
921
|
-
rb_ary_push(rows,
|
|
964
|
+
rb_ary_push(rows, rb_ary_new_from_values((long)n_cols, row_vals));
|
|
922
965
|
}
|
|
966
|
+
ALLOCV_END(row_buf);
|
|
923
967
|
ALLOCV_END(cols_buf);
|
|
924
968
|
RB_GC_GUARD(col_vals);
|
|
925
969
|
}
|
|
@@ -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
|
|
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)
|
data/lib/ch_connect/response.rb
CHANGED
|
@@ -17,7 +17,18 @@ module ChConnect
|
|
|
17
17
|
def each
|
|
18
18
|
return to_enum(:each) unless block_given?
|
|
19
19
|
|
|
20
|
-
|
|
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
|
data/lib/ch_connect/version.rb
CHANGED