umbrellio-utils 1.15.0 → 1.16.0

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: c27f256cd436b6d013e2e51538fd44b9fd8004a094f10c24d5bb169d932ddfbb
4
- data.tar.gz: 68866be3591d51653d2099439bc9c1d75e627745ee75f2e7971313298c8b8a36
3
+ metadata.gz: 60839e931cbc201d236aaf7480342ae61f8186a2417aa015224891b6e5fa383c
4
+ data.tar.gz: 56a6658d397624ef5df7dacd9993755138a864a3ebe7423e656f3b4615f18ce5
5
5
  SHA512:
6
- metadata.gz: 6043a41a4db19e94a620be02aeb9481f44eaab456b944f840fa185f467a6b74abdd58ed809c5505dd736c7d77caca990ea8e26f251552dd3227cbf448fd6612d
7
- data.tar.gz: 7fc84ce2916828b0d67254ac6dcc25e92a9d9b05c6e865f27ef969a2bb7a6f7543056e48b726df522d7aa31a210a8c27f39198dfdb1bfa0b4531c07e81824a6a
6
+ metadata.gz: 81635ff47f5d31d4e7c49c13ef8fa1c7b9d7f86aa44f987c90814fc50a651ace5b1f4893b61d99db640ce3825d9854e7a13027f4b3474df999f71231b19c1c20
7
+ data.tar.gz: b5c55d4d3d0debe6d87e327141b21779be531b99c108e02eb54db78d8b3e0cc18ed488bb39e602368167dc86397a55ff2306fb8e94f8bd09678a699d81e329a9
data/Gemfile.lock CHANGED
@@ -11,7 +11,7 @@ GIT
11
11
  PATH
12
12
  remote: .
13
13
  specs:
14
- umbrellio-utils (1.15.0)
14
+ umbrellio-utils (1.16.0)
15
15
  memery (~> 1)
16
16
 
17
17
  GEM
data/README.md CHANGED
@@ -115,6 +115,37 @@ end
115
115
  Utils::Constants.useful_method #=> "Just string"
116
116
  ```
117
117
 
118
+ ### ClickHouse deduplication
119
+
120
+ Datasets built with `CH.from` understand ClickHouse's `LIMIT n BY`:
121
+
122
+ ```ruby
123
+ CH.from(:events).order(Sequel.desc(:version)).limit_by(:user_id, rows: 3)
124
+ #=> SELECT * FROM "events" ORDER BY "version" DESC LIMIT 3 BY "user_id"
125
+ ```
126
+
127
+ On a `ReplacingMergeTree` table, `#deduplicate` uses that to collapse row versions by hand rather than relying on the `final` setting, which merges the entire table even for a point lookup:
128
+
129
+ ```ruby
130
+ CH.from(:external_operations_distributed)
131
+ .where(order_id: 42) # inside the dedup subquery
132
+ .deduplicate # boundary
133
+ .order(:created_at) # outside
134
+ ```
135
+
136
+ The sorting key, version column and `is_deleted` column are read from `system.tables` and cached per process; `Distributed` tables are resolved through to the local table they wrap. Deduplicated datasets are sent with `final: 0` automatically, since running FINAL inside the subquery would be both slow and redundant. An explicit `final:` passed to `query` / `count` always wins.
137
+
138
+ `#deduplicate` is a boundary, and which side a filter lands on matters:
139
+
140
+ - **Before it — immutable selectors only** (primary keys, foreign keys). Filtering a *mutable* column first can match a superseded row version and resurrect a row that FINAL would have dropped.
141
+ - **After it — everything else**, including any filter on a column that changes over a row's lifetime.
142
+
143
+ `is_deleted` is applied after the boundary for exactly that reason, and is omitted when the engine declares no such column.
144
+
145
+ Two chain methods are handled rather than passed through, because the dedup subquery has to control them: the subquery always projects `SELECT *` (so the outer query can still filter on `is_deleted` and on columns you did not select) and any projection you set is re-applied outside it; and the version ordering leads the subquery's `ORDER BY`, since it decides which row survives, with any ordering you set kept after it as a tiebreaker.
146
+
147
+ `#deduplicate` raises on a table it cannot collapse — a non-Replacing engine, a Replacing engine declared without a version argument, or a dataset that is not a single table source (joined, multi-source, or a subquery).
148
+
118
149
  ### Instrumentation
119
150
 
120
151
  The gem ships a set of opt-in files for collecting GVL and allocation stats
@@ -13,13 +13,103 @@ module UmbrellioUtils
13
13
  class Base
14
14
  include Singleton
15
15
 
16
- # ClickHouse uses C-style escape sequences in string literals, so
17
- # backslashes must be doubled. Sequel's default (Postgres) escaping
18
- # only escapes single-quotes.
19
- module ClickHouseStringEscaping
16
+ # ClickHouse-specific dataset behaviour: string escaping plus grammar
17
+ # Sequel's Postgres dataset doesn't know about.
18
+ module ClickHouseDatasetMethods
19
+ # ClickHouse uses C-style escape sequences in string literals, so
20
+ # backslashes must be doubled. Sequel's default (Postgres) escaping
21
+ # only escapes single-quotes.
20
22
  def literal_string_append(sql, str)
21
23
  sql << "'" << str.gsub("\\") { "\\\\" }.gsub("'", "''") << "'"
22
24
  end
25
+
26
+ # ClickHouse `LIMIT n BY expr, ...` — keeps the first n rows per
27
+ # distinct combination of the expressions, applied after ORDER BY.
28
+ def limit_by(*exprs, rows: 1)
29
+ raise Sequel::Error, "limit_by requires at least one expression" if exprs.empty?
30
+ clone(limit_by: { exprs:, rows: })
31
+ end
32
+
33
+ # `LIMIT n BY` precedes the regular LIMIT/OFFSET in ClickHouse.
34
+ def select_limit_sql(sql)
35
+ if (limit_by = @opts[:limit_by])
36
+ sql << " LIMIT "
37
+ literal_append(sql, limit_by[:rows])
38
+ sql << " BY "
39
+ expression_list_append(sql, limit_by[:exprs])
40
+ end
41
+
42
+ super
43
+ end
44
+
45
+ # Collapse a ReplacingMergeTree's row versions by hand instead of
46
+ # relying on the `final` setting, which merges the whole table.
47
+ #
48
+ # This is a boundary: everything chained BEFORE it goes inside the
49
+ # dedup subquery, everything chained AFTER applies to the result.
50
+ # Only immutable selectors belong before it — filtering a mutable
51
+ # column first can match a superseded version and resurrect a row
52
+ # that FINAL would have dropped. `is_deleted` is applied after the
53
+ # boundary for the same reason.
54
+ def deduplicate
55
+ table_name, db_name = deduplication_source
56
+ meta = ClickHouse.table_metadata(table_name, **db_name)
57
+
58
+ unless meta.replacing?
59
+ raise Sequel::Error,
60
+ "#{table_name} is a #{meta.engine}; deduplicate needs a ReplacingMergeTree"
61
+ end
62
+
63
+ unless meta.version
64
+ raise Sequel::Error,
65
+ "#{table_name} declares no version column; deduplicate needs one"
66
+ end
67
+
68
+ wrapped = ClickHouse.from(wrap_source(deduplicated_source(meta)))
69
+ .clone(ch_dedup: true)
70
+ .then { |ds| @opts[:select] ? ds.select(*@opts[:select]) : ds }
71
+
72
+ meta.is_deleted ? wrapped.where(meta.is_deleted => 0) : wrapped
73
+ end
74
+
75
+ private
76
+
77
+ # The subquery must project every column, both so the outer query can
78
+ # filter on `is_deleted` and so a caller's projection still resolves;
79
+ # that projection is re-applied outside instead.
80
+ #
81
+ # The version ordering leads, since it decides which row survives —
82
+ # any ordering the caller set is kept after it as a tiebreaker.
83
+ def deduplicated_source(meta)
84
+ clone(select: nil)
85
+ .order(Sequel.desc(meta.version), *@opts[:order])
86
+ .limit_by(*meta.sorting_key.map { |expr| Sequel.lit(expr) })
87
+ end
88
+
89
+ def wrap_source(inner)
90
+ source = Array(@opts[:from]).first
91
+ source.is_a?(Sequel::SQL::AliasedExpression) ? inner.as(source.alias) : inner
92
+ end
93
+
94
+ # => [table_name, {} | { db_name: ... }]
95
+ def deduplication_source
96
+ sources = Array(@opts[:from])
97
+ source = sources.first
98
+ source = source.expression if source.is_a?(Sequel::SQL::AliasedExpression)
99
+
100
+ if sources.size != 1 || @opts[:join]
101
+ raise Sequel::Error, "deduplicate requires a single table source"
102
+ end
103
+
104
+ case source
105
+ when Sequel::SQL::QualifiedIdentifier
106
+ [source.column, { db_name: source.table }]
107
+ when Sequel::SQL::Identifier
108
+ [source.value, {}]
109
+ else
110
+ raise Sequel::Error, "deduplicate requires a single table source"
111
+ end
112
+ end
23
113
  end
24
114
 
25
115
  # Concrete backends implement the low-level ops (execute / query /
@@ -36,11 +126,23 @@ module UmbrellioUtils
36
126
  else
37
127
  DB.from(source)
38
128
  end
39
- ds.clone(ch: true).with_extend(ClickHouseStringEscaping)
129
+ ds.clone(ch: true).with_extend(ClickHouseDatasetMethods)
40
130
  end
41
131
 
42
- def count(dataset)
43
- query_value(dataset.select(SQL.ch_count))
132
+ def count(dataset, **)
133
+ query_value(dataset.select(SQL.ch_count), **)
134
+ end
135
+
136
+ # Sorting key / version / is_deleted of a ReplacingMergeTree table.
137
+ # Distributed tables carry none of these, so they are resolved through
138
+ # to the local table they wrap. Memoized per process, like the layout
139
+ # it describes: a table's engine does not change under a running app.
140
+ def table_metadata(table_name, db_name: self.db_name)
141
+ key = [db_name.to_s, table_name.to_s]
142
+ @table_metadata_cache ||= {}
143
+ return @table_metadata_cache[key] if @table_metadata_cache.key?(key)
144
+
145
+ @table_metadata_cache[key] = build_table_metadata(*key)
44
146
  end
45
147
 
46
148
  def db_name
@@ -144,6 +246,43 @@ module UmbrellioUtils
144
246
 
145
247
  protected
146
248
 
249
+ # Every read path needs both halves, and pairing them here keeps a new
250
+ # one from silently reinstating session-wide FINAL inside a dedup
251
+ # subquery — the exact cost `deduplicate` exists to remove.
252
+ def prepare_query(dataset, opts)
253
+ [sql_for(dataset), settings_for(dataset, opts)]
254
+ end
255
+
256
+ # `final` is usually a session-wide default, which would make a
257
+ # deduplicated query merge the whole table inside its own subquery —
258
+ # slow and redundant, since the subquery already collapses versions.
259
+ # An explicit `final:` from the caller always wins.
260
+ def settings_for(dataset, opts)
261
+ return opts if opts.key?(:final)
262
+ return opts unless dataset.is_a?(Sequel::Dataset) && dataset.opts[:ch_dedup]
263
+
264
+ opts.merge(final: 0)
265
+ end
266
+
267
+ def build_table_metadata(db_name, table_name)
268
+ row = query(
269
+ from(:tables, db_name: :system)
270
+ .where(database: db_name, name: table_name)
271
+ .select(:engine, :engine_full, :sorting_key),
272
+ ).first
273
+
274
+ unless row
275
+ raise ClickHouse::TableMetadata::UnknownTable, "#{db_name}.#{table_name} not found"
276
+ end
277
+
278
+ if row[:engine] == "Distributed"
279
+ database, table = ClickHouse::TableMetadata.distributed_target(row[:engine_full])
280
+ return table_metadata(table, db_name: database)
281
+ end
282
+
283
+ ClickHouse::TableMetadata.parse(**row)
284
+ end
285
+
147
286
  def log_errors(sql)
148
287
  yield
149
288
  rescue self.class::SERVER_ERROR => e
@@ -16,15 +16,15 @@ module UmbrellioUtils
16
16
  end
17
17
 
18
18
  def query(dataset, host: nil, **opts)
19
- sql = sql_for(dataset)
19
+ sql, settings = prepare_query(dataset, opts)
20
20
  log_errors(sql) do
21
- select_all(sql, host:, **opts).map { |x| Misc::StrictHash[x.symbolize_keys] }
21
+ select_all(sql, host:, **settings).map { |x| Misc::StrictHash[x.symbolize_keys] }
22
22
  end
23
23
  end
24
24
 
25
25
  def query_value(dataset, host: nil, **opts)
26
- sql = sql_for(dataset)
27
- log_errors(sql) { select_value(sql, host:, **opts) }
26
+ sql, settings = prepare_query(dataset, opts)
27
+ log_errors(sql) { select_value(sql, host:, **settings) }
28
28
  end
29
29
 
30
30
  def query_each(dataset, host: nil, **, &)
@@ -29,18 +29,18 @@ module UmbrellioUtils
29
29
  end
30
30
 
31
31
  def query(dataset, host: nil, **opts) # rubocop:disable Lint/UnusedMethodArgument
32
- sql = sql_for(dataset)
33
- log_errors(sql) { pool.query(sql, settings: opts) }
32
+ sql, settings = prepare_query(dataset, opts)
33
+ log_errors(sql) { pool.query(sql, settings:) }
34
34
  end
35
35
 
36
36
  def query_value(dataset, host: nil, **opts) # rubocop:disable Lint/UnusedMethodArgument
37
- sql = sql_for(dataset)
38
- log_errors(sql) { pool.query_value(sql, settings: opts) }
37
+ sql, settings = prepare_query(dataset, opts)
38
+ log_errors(sql) { pool.query_value(sql, settings:) }
39
39
  end
40
40
 
41
41
  def query_each(dataset, host: nil, **opts, &) # rubocop:disable Lint/UnusedMethodArgument
42
- sql = sql_for(dataset)
43
- log_errors(sql) { pool.query_each(sql, settings: opts, &) }
42
+ sql, settings = prepare_query(dataset, opts)
43
+ log_errors(sql) { pool.query_each(sql, settings:, &) }
44
44
  end
45
45
 
46
46
  def insert(table_name, db_name: self.db_name, rows: [])
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UmbrellioUtils
4
+ module ClickHouse
5
+ # Layout of a MergeTree-family table, read out of `system.tables`.
6
+ #
7
+ # ReplacingMergeTree collapses rows sharing the full sorting key, keeping
8
+ # the one with the highest version and dropping it entirely when the
9
+ # is_deleted column is set. `Dataset#deduplicate` reproduces that by hand,
10
+ # so it needs all three parts; `version` and `is_deleted` are only
11
+ # meaningful on a Replacing engine, and nil elsewhere.
12
+ TableMetadata = Struct.new(:engine, :sorting_key, :version, :is_deleted)
13
+
14
+ # Reopened rather than declared with a block: constants defined inside a
15
+ # `Struct.new do ... end` block leak to the enclosing lexical scope.
16
+ class TableMetadata
17
+ class UnknownTable < StandardError
18
+ end
19
+
20
+ REPLICATED_ARGS_COUNT = 2 # zookeeper path + replica name
21
+
22
+ def replacing?
23
+ engine.include?("Replacing")
24
+ end
25
+
26
+ class << self
27
+ # Built first so it can answer #replacing? for itself — the engine
28
+ # test lives in one place only.
29
+ def parse(engine:, engine_full:, sorting_key:)
30
+ meta = new(engine, split_args(sorting_key), nil, nil)
31
+ return meta unless meta.replacing?
32
+
33
+ args = engine_args(engine_full)
34
+ args = args.drop(REPLICATED_ARGS_COUNT) if engine.start_with?("Replicated")
35
+ meta.version, meta.is_deleted = args[0]&.to_sym, args[1]&.to_sym
36
+
37
+ meta
38
+ end
39
+
40
+ # Distributed('cluster', 'database', 'table'[, sharding_key])
41
+ def distributed_target(engine_full)
42
+ _cluster, database, table = engine_args(engine_full)
43
+ [unquote(database), unquote(table)]
44
+ end
45
+
46
+ # Arguments of the leading engine call, or [] when the engine takes none.
47
+ # Only a parenthesis directly after the engine name counts — later
48
+ # clauses such as `PARTITION BY toYYYYMM(created_at)` must not be read.
49
+ def engine_args(engine_full)
50
+ open_index = engine_full.index("(")
51
+ return [] unless open_index
52
+ return [] unless engine_full[0...open_index].match?(/\A\w+\z/)
53
+
54
+ split_args(engine_full[(open_index + 1)...close_index(engine_full, open_index)])
55
+ end
56
+
57
+ # Split on top-level commas only: sorting keys hold function calls and
58
+ # engine arguments hold quoted paths, both of which may contain commas.
59
+ def split_args(source)
60
+ args = []
61
+ current = +""
62
+
63
+ scan(source) do |char, depth, in_string|
64
+ if char == "," && depth.zero? && !in_string
65
+ args << current.strip
66
+ current = +""
67
+ else
68
+ current << char
69
+ end
70
+ end
71
+
72
+ args << current.strip
73
+ args.reject(&:empty?)
74
+ end
75
+
76
+ private
77
+
78
+ # Single lexer for both scans, so they can't disagree about what counts
79
+ # as a parenthesis: a ZooKeeper path may legally contain one inside
80
+ # quotes, and treating it as structure truncates the argument list.
81
+ def scan(source)
82
+ depth = 0
83
+ in_string = false
84
+
85
+ source.to_s.each_char.with_index do |char, index|
86
+ case char
87
+ when "'" then in_string = !in_string
88
+ when "(" then depth += 1 unless in_string
89
+ when ")" then depth -= 1 unless in_string
90
+ end
91
+
92
+ yield(char, depth, in_string, index)
93
+ end
94
+ end
95
+
96
+ def close_index(source, open_index)
97
+ scan(source[open_index..]) do |_char, depth, in_string, index|
98
+ return open_index + index if depth.zero? && !in_string
99
+ end
100
+
101
+ raise ArgumentError, "unbalanced parentheses in engine: #{source}"
102
+ end
103
+
104
+ def unquote(value)
105
+ value.to_s.delete_prefix("'").delete_suffix("'")
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -10,12 +10,13 @@ module UmbrellioUtils
10
10
  extend self
11
11
 
12
12
  autoload :Backends, "umbrellio_utils/click_house/backends"
13
+ autoload :TableMetadata, "umbrellio_utils/click_house/table_metadata"
13
14
 
14
15
  VALID_BACKENDS = %i[legacy native].freeze
15
16
 
16
17
  DELEGATED = %i[
17
18
  execute query query_value query_each count insert
18
- from describe_table server_version tables
19
+ from describe_table server_version tables table_metadata
19
20
  create_database drop_database db_name config
20
21
  truncate_table! drop_table! optimize_table! on_cluster
21
22
  parse_value pg_table_connection populate_temp_table! with_temp_table
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UmbrellioUtils
4
- VERSION = "1.15.0"
4
+ VERSION = "1.16.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: umbrellio-utils
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.15.0
4
+ version: 1.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Team Umbrellio
@@ -56,6 +56,7 @@ files:
56
56
  - lib/umbrellio_utils/click_house/backends/legacy.rb
57
57
  - lib/umbrellio_utils/click_house/backends/native.rb
58
58
  - lib/umbrellio_utils/click_house/config.rb
59
+ - lib/umbrellio_utils/click_house/table_metadata.rb
59
60
  - lib/umbrellio_utils/constants.rb
60
61
  - lib/umbrellio_utils/control.rb
61
62
  - lib/umbrellio_utils/database.rb