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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +1081 -0
- data/exe/event_meter +5 -0
- data/lib/event_meter/auto_cleanup.rb +93 -0
- data/lib/event_meter/cli.rb +124 -0
- data/lib/event_meter/configuration.rb +244 -0
- data/lib/event_meter/errors.rb +9 -0
- data/lib/event_meter/event.rb +180 -0
- data/lib/event_meter/event_payload.rb +103 -0
- data/lib/event_meter/hash_input.rb +20 -0
- data/lib/event_meter/index_key.rb +19 -0
- data/lib/event_meter/keys.rb +63 -0
- data/lib/event_meter/path_name.rb +37 -0
- data/lib/event_meter/processor.rb +305 -0
- data/lib/event_meter/rails.rb +79 -0
- data/lib/event_meter/report_definition.rb +184 -0
- data/lib/event_meter/reports.rb +143 -0
- data/lib/event_meter/rollup.rb +148 -0
- data/lib/event_meter/stores/cleanup_helpers.rb +76 -0
- data/lib/event_meter/stores/file_helpers.rb +47 -0
- data/lib/event_meter/stores/lock_refresher.rb +75 -0
- data/lib/event_meter/stores/namespace.rb +14 -0
- data/lib/event_meter/stores/redis_lock.rb +77 -0
- data/lib/event_meter/stores/rollup/active_record_postgres.rb +135 -0
- data/lib/event_meter/stores/rollup/file.rb +736 -0
- data/lib/event_meter/stores/rollup/postgres.rb +813 -0
- data/lib/event_meter/stores/rollup/redis.rb +349 -0
- data/lib/event_meter/stores/stream/file.rb +98 -0
- data/lib/event_meter/stores/stream/redis.rb +79 -0
- data/lib/event_meter/time_buckets.rb +56 -0
- data/lib/event_meter/version.rb +3 -0
- data/lib/event_meter/write_result.rb +26 -0
- data/lib/event_meter.rb +150 -0
- data/lib/generators/event_meter/install_generator.rb +57 -0
- data/lib/generators/event_meter/templates/create_event_meter_tables.rb.erb +12 -0
- data/lib/generators/event_meter/templates/event_meter.rb.erb +12 -0
- 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
|