event_meter 0.1.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +1081 -0
  4. data/exe/event_meter +5 -0
  5. data/lib/event_meter/auto_cleanup.rb +93 -0
  6. data/lib/event_meter/cli.rb +124 -0
  7. data/lib/event_meter/configuration.rb +244 -0
  8. data/lib/event_meter/errors.rb +9 -0
  9. data/lib/event_meter/event.rb +180 -0
  10. data/lib/event_meter/event_payload.rb +103 -0
  11. data/lib/event_meter/hash_input.rb +20 -0
  12. data/lib/event_meter/index_key.rb +19 -0
  13. data/lib/event_meter/keys.rb +63 -0
  14. data/lib/event_meter/path_name.rb +37 -0
  15. data/lib/event_meter/processor.rb +305 -0
  16. data/lib/event_meter/rails.rb +79 -0
  17. data/lib/event_meter/report_definition.rb +184 -0
  18. data/lib/event_meter/reports.rb +143 -0
  19. data/lib/event_meter/rollup.rb +148 -0
  20. data/lib/event_meter/stores/cleanup_helpers.rb +76 -0
  21. data/lib/event_meter/stores/file_helpers.rb +47 -0
  22. data/lib/event_meter/stores/lock_refresher.rb +75 -0
  23. data/lib/event_meter/stores/namespace.rb +14 -0
  24. data/lib/event_meter/stores/redis_lock.rb +77 -0
  25. data/lib/event_meter/stores/rollup/active_record_postgres.rb +135 -0
  26. data/lib/event_meter/stores/rollup/file.rb +736 -0
  27. data/lib/event_meter/stores/rollup/postgres.rb +813 -0
  28. data/lib/event_meter/stores/rollup/redis.rb +349 -0
  29. data/lib/event_meter/stores/stream/file.rb +98 -0
  30. data/lib/event_meter/stores/stream/redis.rb +79 -0
  31. data/lib/event_meter/time_buckets.rb +56 -0
  32. data/lib/event_meter/version.rb +3 -0
  33. data/lib/event_meter/write_result.rb +26 -0
  34. data/lib/event_meter.rb +150 -0
  35. data/lib/generators/event_meter/install_generator.rb +57 -0
  36. data/lib/generators/event_meter/templates/create_event_meter_tables.rb.erb +12 -0
  37. data/lib/generators/event_meter/templates/event_meter.rb.erb +12 -0
  38. metadata +156 -0
@@ -0,0 +1,813 @@
1
+ require "digest"
2
+ require "json"
3
+ require "monitor"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ require_relative "../../errors"
8
+ require_relative "../../rollup"
9
+ require_relative "../cleanup_helpers"
10
+ require_relative "../lock_refresher"
11
+ require_relative "../namespace"
12
+
13
+ module EventMeter
14
+ module Stores
15
+ module Rollup
16
+ class Postgres
17
+ include CleanupHelpers
18
+ include Namespace
19
+
20
+ MAX_QUERY_PARAMS = 1_000
21
+ SCOPED_QUERY_PARAMS = 3
22
+ MAX_ENTRY_ID_QUERY_PARAMS = MAX_QUERY_PARAMS - SCOPED_QUERY_PARAMS
23
+ KEY_VALUE_PARAM_COUNT = 2
24
+ MAX_KEY_VALUE_ROWS = MAX_QUERY_PARAMS / KEY_VALUE_PARAM_COUNT
25
+ LOCK_ID_MASK = 0x7fff_ffff_ffff_ffff
26
+ LOCK_REFRESH_RATIO = 2.0
27
+ BIGINT_MAX = "9223372036854775807"
28
+ BIGINT_MIN_ABS = "9223372036854775808"
29
+ BIGINT_DIGITS = 19
30
+
31
+ attr_reader :connection, :connection_lock, :namespace, :table_prefix,
32
+ :report_name, :version, :lock_scope
33
+
34
+ def self.schema_sql(table_prefix: "event_meter")
35
+ validate_identifier!(table_prefix)
36
+
37
+ <<~SQL
38
+ CREATE TABLE IF NOT EXISTS #{table_prefix}_rollups (
39
+ key text PRIMARY KEY,
40
+ fields jsonb NOT NULL DEFAULT '{}'::jsonb,
41
+ updated_at timestamptz NOT NULL DEFAULT now()
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS #{table_prefix}_strings (
45
+ key text PRIMARY KEY,
46
+ value text NOT NULL,
47
+ updated_at timestamptz NOT NULL DEFAULT now()
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS #{table_prefix}_processed_entries (
51
+ namespace text NOT NULL,
52
+ event_name text NOT NULL,
53
+ version integer NOT NULL,
54
+ entry_id text NOT NULL,
55
+ created_at timestamptz NOT NULL DEFAULT now(),
56
+ PRIMARY KEY (namespace, event_name, version, entry_id)
57
+ );
58
+
59
+ CREATE INDEX IF NOT EXISTS #{table_prefix}_processed_created_at_idx
60
+ ON #{table_prefix}_processed_entries (created_at);
61
+
62
+ CREATE INDEX IF NOT EXISTS #{table_prefix}_rollups_key_prefix_idx
63
+ ON #{table_prefix}_rollups (key text_pattern_ops);
64
+
65
+ CREATE INDEX IF NOT EXISTS #{table_prefix}_strings_key_prefix_idx
66
+ ON #{table_prefix}_strings (key text_pattern_ops);
67
+ SQL
68
+ end
69
+
70
+ def self.install!(connection:, table_prefix: "event_meter")
71
+ connection.exec(schema_sql(table_prefix: table_prefix))
72
+ end
73
+
74
+ def initialize(connection:, namespace:, table_prefix: "event_meter", report_name: nil, version: nil,
75
+ lock_scope: nil, connection_lock: nil, lock_connection: nil, lock_connection_lock: nil)
76
+ self.class.validate_identifier!(table_prefix)
77
+
78
+ @connection = connection
79
+ @connection_lock = connection_lock || Monitor.new
80
+ @lock_connection = lock_connection
81
+ @lock_connection_lock = lock_connection_lock
82
+ @namespace = normalize_namespace(namespace)
83
+ @table_prefix = table_prefix
84
+ @report_name = report_name&.to_s
85
+ @version = version&.to_i
86
+ @lock_scope = lock_scope
87
+ end
88
+
89
+ def for_report(name:, version:)
90
+ name = name.to_s
91
+ version = version.to_i
92
+
93
+ self.class.new(
94
+ connection: connection,
95
+ connection_lock: connection_lock,
96
+ lock_connection: @lock_connection,
97
+ lock_connection_lock: @lock_connection_lock,
98
+ namespace: namespace,
99
+ table_prefix: table_prefix,
100
+ report_name: name,
101
+ version: version,
102
+ lock_scope: "#{Keys.event_name(name)}:#{Keys.version_key(version)}"
103
+ )
104
+ end
105
+
106
+ def ensure_definition(definition)
107
+ key = definition_key(definition.name, definition.version)
108
+ payload = JSON.generate(definition.to_h)
109
+
110
+ transaction do
111
+ row = exec_params("SELECT value FROM #{strings_table} WHERE key = $1 FOR UPDATE", [key]).first
112
+ if row
113
+ ensure_same_definition!(row.fetch("value"), definition)
114
+ else
115
+ insert_string_once(key, payload)
116
+ stored = exec_params("SELECT value FROM #{strings_table} WHERE key = $1 FOR UPDATE", [key]).first
117
+ raise DefinitionChangedError, "#{definition.name} v#{definition.version} definition was not stored" unless stored
118
+
119
+ ensure_same_definition!(stored.fetch("value"), definition)
120
+ end
121
+ end
122
+ end
123
+
124
+ def report_definition(name:, version:)
125
+ row = exec_params("SELECT value FROM #{strings_table} WHERE key = $1", [definition_key(name, version)]).first
126
+ row && JSON.parse(row.fetch("value"))
127
+ rescue JSON::ParserError, TypeError
128
+ nil
129
+ end
130
+
131
+ def processed_ids(ids)
132
+ ensure_scoped!
133
+ return [] if ids.empty?
134
+
135
+ rows = ids.each_slice(MAX_ENTRY_ID_QUERY_PARAMS).flat_map do |slice|
136
+ exec_params(
137
+ <<~SQL,
138
+ SELECT entry_id
139
+ FROM #{processed_table}
140
+ WHERE namespace = $1
141
+ AND event_name = $2
142
+ AND version = $3
143
+ AND entry_id IN (#{placeholders(slice, start: 4)})
144
+ SQL
145
+ scoped_params + slice
146
+ )
147
+ end
148
+ rows.map { |row| row.fetch("entry_id") }
149
+ end
150
+
151
+ def forget_processed_ids(ids)
152
+ ensure_scoped!
153
+ delete_processed_entries(ids)
154
+ end
155
+
156
+ def apply(batch)
157
+ ensure_scoped!
158
+
159
+ transaction do
160
+ merge_rollups(batch.rollups)
161
+ upsert_max_strings(batch.state_updates)
162
+ mark_processed_entries(batch.entry_ids)
163
+ end
164
+ end
165
+
166
+ def hgetall_many(keys)
167
+ return [] if keys.empty?
168
+
169
+ rows = keys.each_slice(MAX_QUERY_PARAMS).flat_map do |slice|
170
+ exec_params(
171
+ "SELECT key, fields::text AS fields FROM #{rollups_table} WHERE key IN (#{placeholders(slice)})",
172
+ slice
173
+ )
174
+ end
175
+ by_key = rows.to_h { |row| [row.fetch("key"), parse_hash(row.fetch("fields"))] }
176
+ keys.map { |key| by_key.fetch(key, {}) }
177
+ end
178
+
179
+ def keys_matching(pattern, limit: nil)
180
+ limit = positive_integer(limit, "limit") if limit
181
+ rows = exec_params(
182
+ "SELECT key FROM #{rollups_table} WHERE key LIKE $1 ESCAPE '\\' ORDER BY key",
183
+ [like_prefix_for_pattern(pattern)]
184
+ )
185
+
186
+ keys = rows.map { |row| row.fetch("key") }.select { |key| key_matches?(key, pattern) }
187
+ limit ? keys.first(limit) : keys
188
+ end
189
+
190
+ def get(key)
191
+ row = exec_params("SELECT value FROM #{strings_table} WHERE key = $1", [key]).first
192
+ row&.fetch("value")
193
+ end
194
+
195
+ def cleanup_watermark(key)
196
+ get(key)
197
+ end
198
+
199
+ def write_cleanup_watermark(key, value)
200
+ upsert_string(key, value)
201
+ end
202
+
203
+ def with_lock(ttl:)
204
+ ttl = positive_integer(ttl, "lock ttl")
205
+ ensure_independent_lock_connection!
206
+ token = SecureRandom.hex(16)
207
+ locked = acquire_lock_lease(token, ttl)
208
+ return false unless locked
209
+
210
+ refresher = start_lock_refresher(token, ttl)
211
+ yield
212
+ true
213
+ ensure
214
+ stop_lock_refresher(refresher)
215
+ release_lock_lease(token) if locked
216
+ end
217
+
218
+ def cleanup_history(before:, events:, interval_state:)
219
+ transaction do
220
+ filter = event_filter(events)
221
+ {
222
+ rollup_keys_deleted: cleanup_rollups(before, filter),
223
+ interval_state_keys_deleted: interval_state ? cleanup_interval_state(before, filter) : 0,
224
+ processed_entries_deleted: cleanup_processed_entries(before, filter)
225
+ }
226
+ end
227
+ end
228
+
229
+ def self.validate_identifier!(value)
230
+ return if value.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
231
+
232
+ raise ArgumentError, "invalid PostgreSQL identifier: #{value.inspect}"
233
+ end
234
+
235
+ private
236
+
237
+ def positive_integer(value, name)
238
+ integer = Integer(value)
239
+ return integer if integer.positive?
240
+
241
+ raise ArgumentError, "#{name} must be positive"
242
+ rescue ArgumentError, TypeError, RangeError
243
+ raise ArgumentError, "#{name} must be positive"
244
+ end
245
+
246
+ def lock_connection
247
+ @lock_connection ||= default_lock_connection
248
+ end
249
+
250
+ def lock_connection_lock
251
+ @lock_connection_lock ||= default_lock_connection_lock
252
+ end
253
+
254
+ def default_lock_connection
255
+ cloned = clone_connection(connection)
256
+ return cloned if cloned
257
+
258
+ raise ConfigurationError, "postgres rollup storage requires a separate lock_connection"
259
+ end
260
+
261
+ def clone_connection(source)
262
+ return unless source.respond_to?(:conninfo_hash) && source.class.respond_to?(:connect)
263
+
264
+ source.class.connect(source.conninfo_hash.compact)
265
+ rescue StandardError
266
+ nil
267
+ end
268
+
269
+ def default_lock_connection_lock
270
+ lock_connection.equal?(connection) ? connection_lock : Monitor.new
271
+ end
272
+
273
+ def ensure_independent_lock_connection!
274
+ return unless lock_connection.equal?(connection)
275
+
276
+ raise ConfigurationError, "postgres rollup storage lock_connection must be separate from connection"
277
+ end
278
+
279
+ def acquire_lock_lease(token, ttl)
280
+ now_ms = current_time_ms
281
+ value = lock_lease_value(token, now_ms + ttl * 1000)
282
+ rows = lock_exec_params(<<~SQL, [lock_key, value, now_ms])
283
+ INSERT INTO #{strings_table} (key, value, updated_at)
284
+ VALUES ($1, $2, now())
285
+ ON CONFLICT (key)
286
+ DO UPDATE SET value = EXCLUDED.value, updated_at = now()
287
+ WHERE #{expired_lock_value_sql("#{strings_table}.value", "$3")}
288
+ RETURNING value
289
+ SQL
290
+
291
+ rows.any? { |row| lock_lease_token(row.fetch("value")) == token }
292
+ end
293
+
294
+ def refresh_lock_lease(token, ttl)
295
+ expires_ms = current_time_ms + ttl * 1000
296
+ rows = lock_exec_params(<<~SQL, [lock_key, lock_lease_value(token, expires_ms), token])
297
+ UPDATE #{strings_table}
298
+ SET value = $2, updated_at = now()
299
+ WHERE key = $1
300
+ AND split_part(value, ':', 2) = $3
301
+ RETURNING value
302
+ SQL
303
+
304
+ rows.any?
305
+ end
306
+
307
+ def release_lock_lease(token)
308
+ lock_exec_params(<<~SQL, [lock_key, token])
309
+ DELETE FROM #{strings_table}
310
+ WHERE key = $1
311
+ AND split_part(value, ':', 2) = $2
312
+ SQL
313
+ end
314
+
315
+ def start_lock_refresher(token, ttl)
316
+ LockRefresher.new(
317
+ interval: ttl.to_f / LOCK_REFRESH_RATIO,
318
+ refresh: -> { refresh_lock_lease(token, ttl) },
319
+ failure_message: "postgres lock refresh failed",
320
+ thread_name: "event_meter postgres lock refresher"
321
+ ).start
322
+ end
323
+
324
+ def stop_lock_refresher(refresher)
325
+ return unless refresher
326
+
327
+ refresher.stop
328
+ end
329
+
330
+ def lock_lease_value(token, expires_ms)
331
+ "#{expires_ms}:#{token}"
332
+ end
333
+
334
+ def lock_lease_token(value)
335
+ value.to_s.split(":", 2).fetch(1, nil)
336
+ end
337
+
338
+ def expired_lock_value_sql(value_sql, now_param)
339
+ expires_sql = "split_part(#{value_sql}, ':', 1)"
340
+ "#{safe_bigint(expires_sql)} IS NULL OR #{safe_bigint(expires_sql)} <= #{now_param}"
341
+ end
342
+
343
+ def lock_key
344
+ [namespace, "process_lock", lock_scope].compact.join(":")
345
+ end
346
+
347
+ def current_time_ms
348
+ (Time.now.utc.to_f * 1000).to_i
349
+ end
350
+
351
+ def cleanup_rollups(before, event_filter)
352
+ cleanup_event_filter_slices(event_filter, reserved_params: 4).sum do |filter|
353
+ cleanup_rollup_slice(before, filter)
354
+ end
355
+ end
356
+
357
+ def cleanup_interval_state(before, event_filter)
358
+ cleanup_event_filter_slices(event_filter, reserved_params: 3).sum do |filter|
359
+ cleanup_interval_state_slice(before, filter)
360
+ end
361
+ end
362
+
363
+ def cleanup_processed_entries(before, event_filter)
364
+ if event_filter
365
+ return 0 if event_filter.empty?
366
+
367
+ return delete_filtered_processed_entries(before, event_filter)
368
+ end
369
+
370
+ rows = exec_params(<<~SQL, [namespace, before.iso8601])
371
+ WITH deleted AS (
372
+ DELETE FROM #{processed_table}
373
+ WHERE namespace = $1
374
+ AND created_at < $2::timestamptz
375
+ RETURNING entry_id
376
+ )
377
+ SELECT count(*) AS count FROM deleted
378
+ SQL
379
+
380
+ rows.first.fetch("count").to_i
381
+ end
382
+
383
+ def cleanup_rollup_slice(before, event_filter)
384
+ prefix = "#{namespace}:rollup:"
385
+ suffix = key_suffix_sql("$1")
386
+ params = [
387
+ prefix,
388
+ like_prefix(prefix),
389
+ TimeBuckets.id(before, :minute),
390
+ TimeBuckets.id(before, :hour)
391
+ ]
392
+ event_clause, event_params = event_filter_sql(suffix, event_filter, start: 5)
393
+
394
+ count_deleted_rows(rollups_table, <<~SQL, params + event_params)
395
+ key LIKE $2 ESCAPE '\\'
396
+ AND #{event_clause}
397
+ AND (
398
+ (split_part(#{suffix}, ':', 3) = 'minute' AND split_part(#{suffix}, ':', 4) < $3)
399
+ OR
400
+ (split_part(#{suffix}, ':', 3) = 'hour' AND split_part(#{suffix}, ':', 4) < $4)
401
+ )
402
+ SQL
403
+ end
404
+
405
+ def cleanup_interval_state_slice(before, event_filter)
406
+ prefix = "#{namespace}:state:"
407
+ before_ms = (before.to_f * 1000).to_i
408
+
409
+ if event_filter
410
+ suffix = key_suffix_sql("$1")
411
+ event_clause, event_params = event_filter_sql(suffix, event_filter, start: 4)
412
+ params = [prefix, like_prefix(prefix), before_ms] + event_params
413
+ like_param = "$2"
414
+ before_param = "$3"
415
+ else
416
+ event_clause = "TRUE"
417
+ params = [like_prefix(prefix), before_ms]
418
+ like_param = "$1"
419
+ before_param = "$2"
420
+ end
421
+
422
+ count_deleted_rows(strings_table, <<~SQL, params)
423
+ key LIKE #{like_param} ESCAPE '\\'
424
+ AND #{event_clause}
425
+ AND (#{safe_bigint("value")} IS NULL OR #{safe_bigint("value")} < #{before_param})
426
+ SQL
427
+ end
428
+
429
+ def count_deleted_rows(table_name, where_sql, params)
430
+ rows = exec_params(<<~SQL, params)
431
+ WITH deleted AS (
432
+ DELETE FROM #{table_name}
433
+ WHERE #{where_sql}
434
+ RETURNING key
435
+ )
436
+ SELECT count(*) AS count FROM deleted
437
+ SQL
438
+
439
+ rows.first&.fetch("count", 0).to_i
440
+ end
441
+
442
+ def cleanup_event_filter_slices(event_filter, reserved_params:)
443
+ return [nil] unless event_filter
444
+
445
+ event_filter.each_slice(MAX_QUERY_PARAMS - reserved_params).to_a
446
+ end
447
+
448
+ def event_filter_sql(suffix_sql, event_filter, start:)
449
+ return ["TRUE", []] unless event_filter
450
+
451
+ [
452
+ "split_part(#{suffix_sql}, ':', 1) IN (#{placeholders(event_filter, start: start)})",
453
+ event_filter
454
+ ]
455
+ end
456
+
457
+ def key_suffix_sql(prefix_param)
458
+ "substring(key from char_length(#{prefix_param}::text) + 1)"
459
+ end
460
+
461
+ def merge_rollups(rollups)
462
+ rollups.each_slice(MAX_KEY_VALUE_ROWS) do |slice|
463
+ merge_rollup_rows(slice)
464
+ end
465
+ end
466
+
467
+ def merge_rollup_rows(rows)
468
+ return if rows.empty?
469
+
470
+ params = rows.flat_map { |key, rollup| [key, rollup_json(rollup)] }
471
+
472
+ exec_params(<<~SQL, params)
473
+ INSERT INTO #{rollups_table} (key, fields, updated_at)
474
+ VALUES #{key_value_rows_sql(rows.length, value_cast: "jsonb")}
475
+ ON CONFLICT (key)
476
+ DO UPDATE SET fields = #{merged_rollup_fields_sql}, updated_at = now()
477
+ SQL
478
+ end
479
+
480
+ def upsert_max_strings(values_by_key)
481
+ values_by_key.each_slice(MAX_KEY_VALUE_ROWS) do |slice|
482
+ upsert_max_string_rows(slice)
483
+ end
484
+ end
485
+
486
+ def upsert_max_string_rows(rows)
487
+ return if rows.empty?
488
+
489
+ params = rows.flat_map { |key, value| [key, value.to_s] }
490
+
491
+ exec_params(<<~SQL, params)
492
+ INSERT INTO #{strings_table} (key, value, updated_at)
493
+ VALUES #{key_value_rows_sql(rows.length)}
494
+ ON CONFLICT (key)
495
+ DO UPDATE SET
496
+ value = COALESCE(
497
+ GREATEST(
498
+ #{safe_bigint("#{strings_table}.value")},
499
+ #{safe_bigint("EXCLUDED.value")}
500
+ ),
501
+ #{safe_bigint("#{strings_table}.value")},
502
+ #{safe_bigint("EXCLUDED.value")}
503
+ )::text,
504
+ updated_at = now()
505
+ SQL
506
+ end
507
+
508
+ def mark_processed_entries(ids)
509
+ ids.each_slice(MAX_ENTRY_ID_QUERY_PARAMS) do |slice|
510
+ exec_params(<<~SQL, scoped_params + slice)
511
+ INSERT INTO #{processed_table} (namespace, event_name, version, entry_id, created_at)
512
+ SELECT $1, $2, $3, entry_id, now()
513
+ FROM (VALUES #{single_column_rows_sql(slice.length, start: 4)}) AS entries(entry_id)
514
+ ON CONFLICT (namespace, event_name, version, entry_id) DO NOTHING
515
+ SQL
516
+ end
517
+ end
518
+
519
+ def rollup_json(rollup)
520
+ JSON.generate(rollup.fields.transform_values(&:to_s))
521
+ end
522
+
523
+ def upsert_string(key, value)
524
+ exec_params(<<~SQL, [key, value])
525
+ INSERT INTO #{strings_table} (key, value, updated_at)
526
+ VALUES ($1, $2, now())
527
+ ON CONFLICT (key)
528
+ DO UPDATE SET value = EXCLUDED.value, updated_at = now()
529
+ SQL
530
+ end
531
+
532
+ def insert_string_once(key, value)
533
+ exec_params(<<~SQL, [key, value])
534
+ INSERT INTO #{strings_table} (key, value, updated_at)
535
+ VALUES ($1, $2, now())
536
+ ON CONFLICT (key) DO NOTHING
537
+ SQL
538
+ end
539
+
540
+ def delete_processed_entries(ids)
541
+ return if ids.empty?
542
+
543
+ ids.each_slice(MAX_ENTRY_ID_QUERY_PARAMS) do |slice|
544
+ exec_params(<<~SQL, scoped_params + slice)
545
+ DELETE FROM #{processed_table}
546
+ WHERE namespace = $1
547
+ AND event_name = $2
548
+ AND version = $3
549
+ AND entry_id IN (#{placeholders(slice, start: 4)})
550
+ SQL
551
+ end
552
+ end
553
+
554
+ def delete_filtered_processed_entries(before, event_filter)
555
+ rows = event_filter.each_slice(MAX_QUERY_PARAMS - 2).flat_map do |slice|
556
+ exec_params(<<~SQL, [namespace, before.iso8601] + slice)
557
+ WITH deleted AS (
558
+ DELETE FROM #{processed_table}
559
+ WHERE namespace = $1
560
+ AND created_at < $2::timestamptz
561
+ AND event_name IN (#{placeholders(slice, start: 3)})
562
+ RETURNING entry_id
563
+ )
564
+ SELECT count(*) AS count FROM deleted
565
+ SQL
566
+ end
567
+
568
+ rows.sum { |row| row.fetch("count").to_i }
569
+ end
570
+
571
+ def delete_by_key(table_name, keys)
572
+ return if keys.empty?
573
+
574
+ keys.each_slice(MAX_QUERY_PARAMS) do |slice|
575
+ exec_params("DELETE FROM #{table_name} WHERE key IN (#{placeholders(slice)})", slice)
576
+ end
577
+ end
578
+
579
+ def transaction
580
+ connection_lock.synchronize do
581
+ exec("BEGIN")
582
+ result = yield
583
+ exec("COMMIT")
584
+ result
585
+ rescue StandardError
586
+ exec("ROLLBACK") rescue nil
587
+ raise
588
+ end
589
+ end
590
+
591
+ def advisory_lock
592
+ row = exec_params("SELECT pg_try_advisory_lock($1::bigint) AS locked", [lock_id]).first
593
+ truthy?(row.fetch("locked"))
594
+ end
595
+
596
+ def advisory_unlock
597
+ exec_params("SELECT pg_advisory_unlock($1::bigint)", [lock_id])
598
+ end
599
+
600
+ def exec(sql)
601
+ connection_lock.synchronize do
602
+ result = connection.exec(sql)
603
+ result.respond_to?(:to_a) ? result.to_a : []
604
+ end
605
+ end
606
+
607
+ def exec_params(sql, params)
608
+ connection_lock.synchronize do
609
+ result = connection.exec_params(sql, params)
610
+ result.respond_to?(:to_a) ? result.to_a : []
611
+ end
612
+ end
613
+
614
+ def lock_exec_params(sql, params)
615
+ lock_connection_lock.synchronize do
616
+ result = lock_connection.exec_params(sql, params)
617
+ result.respond_to?(:to_a) ? result.to_a : []
618
+ end
619
+ end
620
+
621
+ def placeholders(values, start: 1)
622
+ values.each_index.map { |index| "$#{index + start}" }.join(", ")
623
+ end
624
+
625
+ def key_value_rows_sql(count, value_cast: nil)
626
+ count.times.map do |index|
627
+ key = "$#{index * KEY_VALUE_PARAM_COUNT + 1}"
628
+ value = "$#{index * KEY_VALUE_PARAM_COUNT + 2}"
629
+ value = "#{value}::#{value_cast}" if value_cast
630
+
631
+ "(#{key}, #{value}, now())"
632
+ end.join(", ")
633
+ end
634
+
635
+ def single_column_rows_sql(count, start:)
636
+ count.times.map { |index| "($#{index + start})" }.join(", ")
637
+ end
638
+
639
+ def merged_rollup_fields_sql
640
+ <<~SQL
641
+ (
642
+ SELECT COALESCE(jsonb_object_agg(field, value), '{}'::jsonb)
643
+ FROM (
644
+ SELECT
645
+ field,
646
+ #{merged_rollup_value_sql} AS value
647
+ FROM (#{rollup_number_rows_sql}) numbers
648
+ ) merged
649
+ )
650
+ SQL
651
+ end
652
+
653
+ def rollup_number_rows_sql
654
+ <<~SQL
655
+ SELECT
656
+ COALESCE(existing.key, incoming.key) AS field,
657
+ #{safe_bigint("existing.value")} AS existing_value,
658
+ #{safe_bigint("incoming.value")} AS incoming_value
659
+ FROM jsonb_each_text(#{safe_json_object("#{rollups_table}.fields")}) AS existing(key, value)
660
+ FULL OUTER JOIN jsonb_each_text(#{safe_json_object("EXCLUDED.fields")}) AS incoming(key, value)
661
+ USING (key)
662
+ SQL
663
+ end
664
+
665
+ def merged_rollup_value_sql
666
+ <<~SQL
667
+ CASE
668
+ WHEN field IN (#{rollup_min_fields_sql}) THEN #{rollup_min_value_sql}
669
+ WHEN field IN (#{rollup_max_fields_sql}) THEN #{rollup_max_value_sql}
670
+ ELSE #{rollup_sum_value_sql}
671
+ END
672
+ SQL
673
+ end
674
+
675
+ def rollup_min_fields_sql
676
+ quoted_sql_strings(EventMeter::Rollup::MIN_FIELDS)
677
+ end
678
+
679
+ def rollup_max_fields_sql
680
+ quoted_sql_strings(EventMeter::Rollup::MAX_FIELDS)
681
+ end
682
+
683
+ def rollup_min_value_sql
684
+ "COALESCE(LEAST(existing_value, incoming_value), existing_value, incoming_value, 0)::text"
685
+ end
686
+
687
+ def rollup_max_value_sql
688
+ "COALESCE(GREATEST(existing_value, incoming_value), existing_value, incoming_value, 0)::text"
689
+ end
690
+
691
+ def rollup_sum_value_sql
692
+ "(COALESCE(existing_value, 0) + COALESCE(incoming_value, 0))::text"
693
+ end
694
+
695
+ def safe_bigint(sql)
696
+ digits = safe_bigint_digits(sql)
697
+ negative = "left(#{sql}, 1) = '-'"
698
+ bounded = <<~SQL
699
+ (
700
+ char_length(#{digits}) < #{BIGINT_DIGITS}
701
+ OR (
702
+ char_length(#{digits}) = #{BIGINT_DIGITS}
703
+ AND (
704
+ (#{negative} AND #{digits} <= '#{BIGINT_MIN_ABS}')
705
+ OR ((#{negative}) IS NOT TRUE AND #{digits} <= '#{BIGINT_MAX}')
706
+ )
707
+ )
708
+ )
709
+ SQL
710
+ value = "((CASE WHEN #{negative} AND #{digits} <> '0' THEN '-' ELSE '' END) || #{digits})"
711
+
712
+ "(CASE WHEN #{sql} ~ '^-?[0-9]+$' AND #{bounded} THEN #{value}::bigint END)"
713
+ end
714
+
715
+ def safe_bigint_digits(sql)
716
+ "COALESCE(NULLIF(regexp_replace(ltrim(#{sql}, '-'), '^0+', ''), ''), '0')"
717
+ end
718
+
719
+ def safe_json_object(sql)
720
+ "CASE WHEN jsonb_typeof(#{sql}) = 'object' THEN #{sql} ELSE '{}'::jsonb END"
721
+ end
722
+
723
+ def quoted_sql_strings(values)
724
+ values.map { |value| "'#{value.to_s.gsub("'", "''")}'" }.join(", ")
725
+ end
726
+
727
+ def parse_json(value)
728
+ value.is_a?(String) ? JSON.parse(value) : value
729
+ rescue JSON::ParserError, TypeError
730
+ nil
731
+ end
732
+
733
+ def parse_hash(value)
734
+ parsed = parse_json(value)
735
+ parsed.is_a?(Hash) ? parsed : {}
736
+ end
737
+
738
+ def truthy?(value)
739
+ value == true || value == "t" || value == "true" || value == "1"
740
+ end
741
+
742
+ def prefix_before_wildcard(pattern)
743
+ pattern.split("*", 2).first
744
+ end
745
+
746
+ def like_prefix_for_pattern(pattern)
747
+ prefix = "#{namespace}:"
748
+ return like_prefix(prefix_before_wildcard(pattern)) unless pattern.start_with?(prefix)
749
+
750
+ "#{escape_like(namespace)}:#{like_prefix(prefix_before_wildcard(pattern.delete_prefix(prefix)))}"
751
+ end
752
+
753
+ def like_prefix(value)
754
+ "#{escape_like(value)}%"
755
+ end
756
+
757
+ def escape_like(value)
758
+ value.to_s.gsub(/[\\%_]/) { |character| "\\#{character}" }
759
+ end
760
+
761
+ def key_matches?(key, pattern)
762
+ prefix = "#{namespace}:"
763
+ return ::File.fnmatch?(pattern, key) unless pattern.start_with?(prefix)
764
+ return false unless key.start_with?(prefix)
765
+
766
+ ::File.fnmatch?(pattern.delete_prefix(prefix), key.delete_prefix(prefix))
767
+ end
768
+
769
+ def lock_id
770
+ @lock_id ||= begin
771
+ digest = Digest::SHA256.hexdigest("#{table_prefix}:#{namespace}:process_lock:#{lock_scope}")
772
+ digest[0, 16].to_i(16) & LOCK_ID_MASK
773
+ end
774
+ end
775
+
776
+ def definition_key(name, version)
777
+ Keys.definition(namespace: namespace, name: name, version: version)
778
+ end
779
+
780
+ def scoped_params
781
+ [namespace, Keys.event_name(report_name), version]
782
+ end
783
+
784
+ def ensure_scoped!
785
+ return if report_name && version&.positive?
786
+
787
+ raise ConfigurationError, "postgres rollup storage must be scoped with for_report"
788
+ end
789
+
790
+ def ensure_same_definition!(stored, definition)
791
+ stored_definition = ReportDefinition.from_h(JSON.parse(stored))
792
+ return if stored_definition.fingerprint == definition.fingerprint
793
+
794
+ raise DefinitionChangedError, "#{definition.name} v#{definition.version} changed; bump version"
795
+ rescue JSON::ParserError, TypeError
796
+ raise DefinitionChangedError, "#{definition.name} v#{definition.version} stored definition is invalid"
797
+ end
798
+
799
+ def rollups_table
800
+ "#{table_prefix}_rollups"
801
+ end
802
+
803
+ def strings_table
804
+ "#{table_prefix}_strings"
805
+ end
806
+
807
+ def processed_table
808
+ "#{table_prefix}_processed_entries"
809
+ end
810
+ end
811
+ end
812
+ end
813
+ end