pgbus 0.20.0 → 0.20.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 +3 -0
- data/README.md +3 -2
- data/app/frontend/pgbus/modules/charts.js +18 -0
- data/app/frontend/pgbus/modules/charts.min.js +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/helpers/pgbus/workflows_helper.rb +2 -2
- data/app/models/pgbus/blocked_execution.rb +12 -0
- data/app/models/pgbus/uniqueness_key.rb +88 -3
- data/app/models/pgbus/workflow_step.rb +7 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +11 -1
- data/config/locales/da.yml +4 -1
- data/config/locales/de.yml +4 -1
- data/config/locales/en.yml +4 -1
- data/config/locales/es.yml +4 -1
- data/config/locales/fi.yml +4 -1
- data/config/locales/fr.yml +4 -1
- data/config/locales/it.yml +4 -1
- data/config/locales/ja.yml +4 -1
- data/config/locales/nb.yml +4 -1
- data/config/locales/nl.yml +4 -1
- data/config/locales/pt.yml +4 -1
- data/config/locales/sv.yml +4 -1
- data/lib/pgbus/active_job/adapter.rb +81 -3
- data/lib/pgbus/active_job/delivery.rb +20 -6
- data/lib/pgbus/active_job/executor.rb +18 -8
- data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
- data/lib/pgbus/configuration.rb +43 -1
- data/lib/pgbus/dead_letter_header.rb +9 -1
- data/lib/pgbus/instrumentation.rb +3 -2
- data/lib/pgbus/integrations/appsignal/dashboard.json +30 -1
- data/lib/pgbus/metrics/workflow_metrics.rb +11 -1
- data/lib/pgbus/process/consumer.rb +2 -1
- data/lib/pgbus/process/dispatcher.rb +13 -3
- data/lib/pgbus/process/worker.rb +10 -1
- data/lib/pgbus/recurring/schedule.rb +1 -1
- data/lib/pgbus/streams/presence.rb +65 -36
- data/lib/pgbus/uniqueness.rb +23 -6
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/workflow_list.rb +50 -7
- data/lib/pgbus/web/data_source.rb +123 -15
- data/lib/pgbus/web/workflow_state.rb +16 -3
- data/lib/pgbus/web/workflow_timeline.rb +23 -6
- data/lib/pgbus/workflow/step_definition.rb +4 -2
- data/lib/pgbus/workflow/wait_timeout.rb +25 -4
- data/sig/pgbus/configuration.rbs +1 -0
- metadata +1 -1
|
@@ -505,12 +505,22 @@ module Pgbus
|
|
|
505
505
|
# Unbound rows (synthetic "pending" queue and/or msg_id=0) must not probe
|
|
506
506
|
# pgmq.q_<prefix>_pending — that table does not exist, message_exists?
|
|
507
507
|
# returns nil, and the reaper would keep the lock forever (issue #418).
|
|
508
|
-
# Scan every live queue for the payload key instead.
|
|
508
|
+
# Scan every live queue for the payload key instead. A job parked behind
|
|
509
|
+
# its concurrency slot is in no queue: its key counts as live while it
|
|
510
|
+
# is parked (issue #564). The parked jobs are asked first: a promotion
|
|
511
|
+
# sends the job's message before its parked row's delete commits (or
|
|
512
|
+
# both in one transaction), so a job promoted between the two reads is
|
|
513
|
+
# still parked for the first or already queued for the second. Read the
|
|
514
|
+
# other way round it could be in neither.
|
|
509
515
|
def gone_unbound_locks(unbound)
|
|
510
516
|
return [] if unbound.empty?
|
|
511
517
|
|
|
512
|
-
|
|
513
|
-
unbound.reject { |key|
|
|
518
|
+
parked = BlockedExecution.parked_uniqueness_keys(unbound.map(&:lock_key))
|
|
519
|
+
unparked = unbound.reject { |key| parked.include?(key.lock_key) }
|
|
520
|
+
return [] if unparked.empty?
|
|
521
|
+
|
|
522
|
+
present = Pgbus.client.uniqueness_keys_present(unparked.map(&:lock_key))
|
|
523
|
+
unparked.reject { |key| present.include?(key.lock_key) }
|
|
514
524
|
end
|
|
515
525
|
|
|
516
526
|
# Returns true if the message referenced by this bound lock is definitely
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -407,12 +407,21 @@ module Pgbus
|
|
|
407
407
|
# so we also pass `limit: qty` to cap the total across all queues —
|
|
408
408
|
# otherwise we get `queue_count * qty` messages and overflow the
|
|
409
409
|
# execution pool, crashing the worker fork (issue #123).
|
|
410
|
+
#
|
|
411
|
+
# The logical name is the configured one the table was made from, not
|
|
412
|
+
# the table name with its prefix cut: a name normalized for its table
|
|
413
|
+
# ("orders-handler" → "orders_handler") would otherwise reach the
|
|
414
|
+
# executor spelled unlike the name its jobs' uniqueness locks are bound
|
|
415
|
+
# under, and unlike every other fetch path (issue #564).
|
|
410
416
|
def fetch_multi(active_queues, qty)
|
|
411
417
|
messages = Pgbus.client.read_multi(active_queues, qty: qty, limit: qty) || []
|
|
418
|
+
return messages if messages.empty?
|
|
419
|
+
|
|
412
420
|
prefix = "#{config.queue_prefix}_"
|
|
421
|
+
configured = active_queues.to_h { |q| [config.queue_name(q), q] }
|
|
413
422
|
|
|
414
423
|
messages.map do |m|
|
|
415
|
-
logical = m.queue_name&.delete_prefix(prefix) || active_queues.first
|
|
424
|
+
logical = configured[m.queue_name] || m.queue_name&.delete_prefix(prefix) || active_queues.first
|
|
416
425
|
[logical, m]
|
|
417
426
|
end
|
|
418
427
|
end
|
|
@@ -143,7 +143,7 @@ module Pgbus
|
|
|
143
143
|
key = resolve_uniqueness_key(job_class, config, task)
|
|
144
144
|
return nil unless key
|
|
145
145
|
|
|
146
|
-
acquired = UniquenessKey.acquire!(key, queue_name: resolve_queue(task), msg_id: 0)
|
|
146
|
+
acquired = UniquenessKey.acquire!(key, queue_name: Uniqueness.lock_queue_name(resolve_queue(task)), msg_id: 0)
|
|
147
147
|
|
|
148
148
|
if acquired
|
|
149
149
|
key
|
|
@@ -72,6 +72,7 @@ module Pgbus
|
|
|
72
72
|
@schema_mutex = Mutex.new
|
|
73
73
|
@seats_supported = nil
|
|
74
74
|
@probed_at = nil
|
|
75
|
+
@schema_generation = 0
|
|
75
76
|
|
|
76
77
|
class << self
|
|
77
78
|
# True when pgbus_presence_members has the seat columns (the
|
|
@@ -81,17 +82,29 @@ module Pgbus
|
|
|
81
82
|
# The answer is read and written under @schema_mutex only (issue
|
|
82
83
|
# #527). A successful probe is kept as above; a probe that raises is
|
|
83
84
|
# not, so the next call probes again.
|
|
85
|
+
#
|
|
86
|
+
# The probe itself runs outside @schema_mutex (issue #566): it waits
|
|
87
|
+
# for the adapter's lock, and a request inside a transaction holds
|
|
88
|
+
# that lock while it asks seats_supported?. Two threads may probe at
|
|
89
|
+
# once; a true answer one of them stored is never taken back, and a
|
|
90
|
+
# probe that a reset_schema_cache! overtook stores nothing.
|
|
84
91
|
def seats_supported?
|
|
92
|
+
cached, generation = @schema_mutex.synchronize do
|
|
93
|
+
[(@seats_supported if schema_answer_fresh?(@seats_supported, @probed_at)), @schema_generation]
|
|
94
|
+
end
|
|
95
|
+
return cached unless cached.nil?
|
|
96
|
+
|
|
97
|
+
rows = exec_params(<<~SQL, [])
|
|
98
|
+
SELECT 1 AS present FROM information_schema.columns
|
|
99
|
+
WHERE table_name = 'pgbus_presence_members' AND column_name = 'connection_id'
|
|
100
|
+
AND table_schema = ANY (current_schemas(false))
|
|
101
|
+
SQL
|
|
102
|
+
supported = !rows.to_a.empty?
|
|
85
103
|
@schema_mutex.synchronize do
|
|
86
|
-
next
|
|
104
|
+
next supported unless generation == @schema_generation
|
|
87
105
|
|
|
88
|
-
rows = connection.exec_params(<<~SQL, [])
|
|
89
|
-
SELECT 1 AS present FROM information_schema.columns
|
|
90
|
-
WHERE table_name = 'pgbus_presence_members' AND column_name = 'connection_id'
|
|
91
|
-
AND table_schema = ANY (current_schemas(false))
|
|
92
|
-
SQL
|
|
93
106
|
@probed_at = monotonic_now
|
|
94
|
-
@seats_supported
|
|
107
|
+
@seats_supported ||= supported
|
|
95
108
|
end
|
|
96
109
|
end
|
|
97
110
|
|
|
@@ -108,6 +121,7 @@ module Pgbus
|
|
|
108
121
|
|
|
109
122
|
def reset_schema_cache!
|
|
110
123
|
@schema_mutex.synchronize do
|
|
124
|
+
@schema_generation += 1
|
|
111
125
|
@probed_at = nil
|
|
112
126
|
@seats_supported = nil
|
|
113
127
|
end
|
|
@@ -124,7 +138,7 @@ module Pgbus
|
|
|
124
138
|
def expire_seats!(ttl_for:, min_ttl:)
|
|
125
139
|
return [] unless seats_supported?
|
|
126
140
|
|
|
127
|
-
candidates =
|
|
141
|
+
candidates = exec_params(<<~SQL, [min_ttl])
|
|
128
142
|
SELECT stream_name, connection_id FROM pgbus_presence_members
|
|
129
143
|
WHERE connection_id IS NOT NULL AND last_seen_at < NOW() - make_interval(secs => $1)
|
|
130
144
|
SQL
|
|
@@ -133,23 +147,23 @@ module Pgbus
|
|
|
133
147
|
end
|
|
134
148
|
end
|
|
135
149
|
|
|
150
|
+
# The PG::Connection under the adapter. Run SQL through exec_params,
|
|
151
|
+
# not on this: the adapter's lock is released once it is returned.
|
|
136
152
|
def connection
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# live in a separate database accessed via Pgbus::BusRecord;
|
|
140
|
-
# the default path uses ActiveRecord::Base. Matches the
|
|
141
|
-
# canonical pattern in Pgbus::Process::QueueLock#connection
|
|
142
|
-
# and Pgbus::Configuration's connection probe.
|
|
143
|
-
unless defined?(::ActiveRecord::Base)
|
|
144
|
-
raise Pgbus::ConfigurationError,
|
|
145
|
-
"Pgbus::Streams::Presence requires ActiveRecord (no AR connection available)"
|
|
146
|
-
end
|
|
153
|
+
adapter.raw_connection
|
|
154
|
+
end
|
|
147
155
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
156
|
+
# Runs one statement on the adapter's PG::Connection while holding
|
|
157
|
+
# the adapter's lock (issue #566). raw_connection takes that lock
|
|
158
|
+
# only to hand the connection out; on a pinned pool (transactional
|
|
159
|
+
# tests) every thread shares the one adapter, and its lock is all
|
|
160
|
+
# that keeps a second thread's statement off the libpq connection
|
|
161
|
+
# while this one is in flight. Unpinned, the lock is a NullLock. The
|
|
162
|
+
# lock is reentrant, so a thread already holding it (a request
|
|
163
|
+
# inside a transaction) re-enters it.
|
|
164
|
+
def exec_params(sql, binds)
|
|
165
|
+
adapter = self.adapter
|
|
166
|
+
adapter.lock.synchronize { adapter.raw_connection.exec_params(sql, binds) }
|
|
153
167
|
end
|
|
154
168
|
|
|
155
169
|
# Reports a seat join/leave to streams_presence_on_change (issue #515).
|
|
@@ -171,6 +185,21 @@ module Pgbus
|
|
|
171
185
|
|
|
172
186
|
private
|
|
173
187
|
|
|
188
|
+
# Respect multi-database setups (connects_to). When
|
|
189
|
+
# Pgbus.configuration.connects_to is set, the pgbus tables
|
|
190
|
+
# live in a separate database accessed via Pgbus::BusRecord;
|
|
191
|
+
# the default path uses ActiveRecord::Base. Matches the
|
|
192
|
+
# canonical pattern in Pgbus::Process::QueueLock#connection
|
|
193
|
+
# and Pgbus::Configuration's connection probe.
|
|
194
|
+
def adapter
|
|
195
|
+
unless defined?(::ActiveRecord::Base)
|
|
196
|
+
raise Pgbus::ConfigurationError,
|
|
197
|
+
"Pgbus::Streams::Presence requires ActiveRecord (no AR connection available)"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
Pgbus.configuration.connects_to ? Pgbus::BusRecord.connection : ::ActiveRecord::Base.connection
|
|
201
|
+
end
|
|
202
|
+
|
|
174
203
|
def schema_answer_fresh?(supported, probed_at)
|
|
175
204
|
return false if supported.nil? || probed_at.nil?
|
|
176
205
|
|
|
@@ -178,7 +207,7 @@ module Pgbus
|
|
|
178
207
|
end
|
|
179
208
|
|
|
180
209
|
def expire_stream_seats(stream_name, connection_ids, ttl)
|
|
181
|
-
rows =
|
|
210
|
+
rows = exec_params(<<~SQL, [stream_name, text_array(connection_ids), ttl])
|
|
182
211
|
DELETE FROM pgbus_presence_members
|
|
183
212
|
WHERE stream_name = $1 AND connection_id = ANY($2::text[])
|
|
184
213
|
AND last_seen_at < NOW() - make_interval(secs => $3)
|
|
@@ -306,7 +335,7 @@ module Pgbus
|
|
|
306
335
|
return touch_seats(Array(connection_id)) if connection_id
|
|
307
336
|
|
|
308
337
|
member_id = member_id.to_s
|
|
309
|
-
|
|
338
|
+
exec_params(<<~SQL, [@stream.name, member_id])
|
|
310
339
|
UPDATE pgbus_presence_members
|
|
311
340
|
SET last_seen_at = NOW()
|
|
312
341
|
WHERE stream_name = $1 AND member_id = $2#{" AND connection_id IS NULL" if seats_now?}
|
|
@@ -320,7 +349,7 @@ module Pgbus
|
|
|
320
349
|
def set_state(connection_id:, state:)
|
|
321
350
|
raise ArgumentError, "set_state takes a Hash (got #{state.class})" unless state.is_a?(Hash)
|
|
322
351
|
|
|
323
|
-
rows =
|
|
352
|
+
rows = exec_params(<<~SQL, [@stream.name, connection_id.to_s, JSON.generate(state)])
|
|
324
353
|
UPDATE pgbus_presence_members
|
|
325
354
|
SET state = state || $3::jsonb, last_seen_at = NOW()
|
|
326
355
|
WHERE stream_name = $1 AND connection_id = $2
|
|
@@ -335,7 +364,7 @@ module Pgbus
|
|
|
335
364
|
# table each row is one seat and also carries connection_id, seat
|
|
336
365
|
# and state; group by "id" for one entry per member.
|
|
337
366
|
def members
|
|
338
|
-
rows =
|
|
367
|
+
rows = exec_params(<<~SQL, [@stream.name])
|
|
339
368
|
SELECT #{seats? ? SEAT_RECORD_COLUMNS : RECORD_COLUMNS}
|
|
340
369
|
FROM pgbus_presence_members
|
|
341
370
|
WHERE stream_name = $1
|
|
@@ -347,7 +376,7 @@ module Pgbus
|
|
|
347
376
|
# Returns the count of current members. Faster than members.size
|
|
348
377
|
# because it doesn't deserialize metadata.
|
|
349
378
|
def count
|
|
350
|
-
rows =
|
|
379
|
+
rows = exec_params(<<~SQL, [@stream.name])
|
|
351
380
|
SELECT COUNT(*) AS n FROM pgbus_presence_members WHERE stream_name = $1
|
|
352
381
|
SQL
|
|
353
382
|
rows.first["n"].to_i
|
|
@@ -362,7 +391,7 @@ module Pgbus
|
|
|
362
391
|
# workers running the sweep concurrently won't double-emit leave
|
|
363
392
|
# events. Yields each expired record when a block is given.
|
|
364
393
|
def sweep!(older_than:)
|
|
365
|
-
rows =
|
|
394
|
+
rows = exec_params(<<~SQL, [@stream.name, older_than])
|
|
366
395
|
DELETE FROM pgbus_presence_members
|
|
367
396
|
WHERE stream_name = $1 AND last_seen_at < $2#{" AND connection_id IS NULL" if seats_now?}
|
|
368
397
|
RETURNING #{RECORD_COLUMNS}
|
|
@@ -388,7 +417,7 @@ module Pgbus
|
|
|
388
417
|
|
|
389
418
|
def upsert_member_once(member_id, metadata)
|
|
390
419
|
conflict = seats? ? "(stream_name, member_id) WHERE connection_id IS NULL" : "(stream_name, member_id)"
|
|
391
|
-
rows =
|
|
420
|
+
rows = exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata)])
|
|
392
421
|
INSERT INTO pgbus_presence_members (stream_name, member_id, metadata, joined_at, last_seen_at)
|
|
393
422
|
VALUES ($1, $2, $3::jsonb, NOW(), NOW())
|
|
394
423
|
ON CONFLICT #{conflict}
|
|
@@ -399,7 +428,7 @@ module Pgbus
|
|
|
399
428
|
end
|
|
400
429
|
|
|
401
430
|
def upsert_seat(member_id, metadata, connection_id, seat)
|
|
402
|
-
rows =
|
|
431
|
+
rows = exec_params(<<~SQL, [@stream.name, member_id, JSON.generate(metadata), connection_id, seat])
|
|
403
432
|
INSERT INTO pgbus_presence_members
|
|
404
433
|
(stream_name, member_id, metadata, connection_id, seat, joined_at, last_seen_at)
|
|
405
434
|
VALUES ($1, $2, $3::jsonb, $4, $5, NOW(), NOW())
|
|
@@ -412,7 +441,7 @@ module Pgbus
|
|
|
412
441
|
end
|
|
413
442
|
|
|
414
443
|
def delete_member(member_id)
|
|
415
|
-
rows =
|
|
444
|
+
rows = exec_params(<<~SQL, [@stream.name, member_id])
|
|
416
445
|
DELETE FROM pgbus_presence_members
|
|
417
446
|
WHERE stream_name = $1 AND member_id = $2#{" AND connection_id IS NULL" if seats_now?}
|
|
418
447
|
RETURNING #{RECORD_COLUMNS}
|
|
@@ -422,7 +451,7 @@ module Pgbus
|
|
|
422
451
|
end
|
|
423
452
|
|
|
424
453
|
def delete_seat(connection_id)
|
|
425
|
-
rows =
|
|
454
|
+
rows = exec_params(<<~SQL, [@stream.name, connection_id])
|
|
426
455
|
DELETE FROM pgbus_presence_members
|
|
427
456
|
WHERE stream_name = $1 AND connection_id = $2
|
|
428
457
|
RETURNING #{SEAT_RECORD_COLUMNS}
|
|
@@ -434,7 +463,7 @@ module Pgbus
|
|
|
434
463
|
def touch_seats(connection_ids)
|
|
435
464
|
return [] if connection_ids.empty?
|
|
436
465
|
|
|
437
|
-
rows =
|
|
466
|
+
rows = exec_params(<<~SQL, [@stream.name, self.class.text_array(connection_ids)])
|
|
438
467
|
UPDATE pgbus_presence_members
|
|
439
468
|
SET last_seen_at = NOW()
|
|
440
469
|
WHERE stream_name = $1 AND connection_id = ANY($2::text[])
|
|
@@ -443,8 +472,8 @@ module Pgbus
|
|
|
443
472
|
rows.to_a.map { |row| row["connection_id"] }
|
|
444
473
|
end
|
|
445
474
|
|
|
446
|
-
def
|
|
447
|
-
self.class.
|
|
475
|
+
def exec_params(sql, binds)
|
|
476
|
+
self.class.exec_params(sql, binds)
|
|
448
477
|
end
|
|
449
478
|
end
|
|
450
479
|
end
|
data/lib/pgbus/uniqueness.rb
CHANGED
|
@@ -16,8 +16,14 @@ module Pgbus
|
|
|
16
16
|
# or executing.
|
|
17
17
|
# 2. Execution: PGMQ's visibility timeout is the execution lock —
|
|
18
18
|
# no separate claim_for_execution step needed.
|
|
19
|
-
# 3.
|
|
20
|
-
#
|
|
19
|
+
# 3. Retry: a retry_job / retry_on re-enqueue passes through the key the
|
|
20
|
+
# running attempt holds, and the row is handed to the retry: unbound
|
|
21
|
+
# and re-stamped before the retry is sent or parked (unbind!), bound
|
|
22
|
+
# to it once sent (bind!).
|
|
23
|
+
# 4. Completion/DLQ: the finishing message deletes the row only while it
|
|
24
|
+
# holds it (release_held_by!), so a row handed to a retry, or a
|
|
25
|
+
# successor's, stays. The dashboard's discards do the same.
|
|
26
|
+
# 5. Crash recovery: if a worker dies, VT expires, the message becomes
|
|
21
27
|
# readable again. The uniqueness key row stays (correctly — the job
|
|
22
28
|
# hasn't finished). The next worker picks it up and executes.
|
|
23
29
|
#
|
|
@@ -179,14 +185,25 @@ module Pgbus
|
|
|
179
185
|
return :no_lock unless config[:strategy] == :until_executed
|
|
180
186
|
|
|
181
187
|
acquired = if msg_id && queue_name
|
|
182
|
-
UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: msg_id)
|
|
188
|
+
UniquenessKey.acquire!(key, queue_name: lock_queue_name(queue_name), msg_id: msg_id)
|
|
183
189
|
else
|
|
184
190
|
# Pre-produce check: use advisory lock + ON CONFLICT
|
|
185
|
-
UniquenessKey.acquire!(key, queue_name: queue_name || PLACEHOLDER_QUEUE,
|
|
191
|
+
UniquenessKey.acquire!(key, queue_name: lock_queue_name(queue_name || PLACEHOLDER_QUEUE),
|
|
192
|
+
msg_id: msg_id || 0)
|
|
186
193
|
end
|
|
187
194
|
acquired ? :acquired : :locked
|
|
188
195
|
end
|
|
189
196
|
|
|
197
|
+
# The queue a job's uniqueness lock records: the logical name as its
|
|
198
|
+
# table spells it ("orders-handler" → "orders_handler"), the inverse of
|
|
199
|
+
# stripping the prefix off the table name. Every writer and releaser of
|
|
200
|
+
# a job's lock goes through it, so a release that knows only the table
|
|
201
|
+
# (the dashboard) or a worker that spells the queue the table's way
|
|
202
|
+
# still finds the row it was bound under (issue #564).
|
|
203
|
+
def lock_queue_name(queue_name, config: Pgbus.configuration)
|
|
204
|
+
config.queue_name(queue_name).delete_prefix("#{config.queue_prefix}_")
|
|
205
|
+
end
|
|
206
|
+
|
|
190
207
|
# Acquire the uniqueness lock at execution time (:while_executing only).
|
|
191
208
|
# Returns true if acquired, false if another instance is running.
|
|
192
209
|
#
|
|
@@ -199,7 +216,7 @@ module Pgbus
|
|
|
199
216
|
return true unless strategy == :while_executing
|
|
200
217
|
|
|
201
218
|
queue_name ||= payload["queue_name"] || "unknown"
|
|
202
|
-
UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: msg_id.to_i,
|
|
219
|
+
UniquenessKey.acquire!(key, queue_name: lock_queue_name(queue_name), msg_id: msg_id.to_i,
|
|
203
220
|
reacquire_same_message: msg_id.to_i.positive?)
|
|
204
221
|
end
|
|
205
222
|
|
|
@@ -217,7 +234,7 @@ module Pgbus
|
|
|
217
234
|
return unless key
|
|
218
235
|
return unless msg_id.to_i.positive?
|
|
219
236
|
|
|
220
|
-
UniquenessKey.bind!(key, queue_name: queue_name, msg_id: msg_id)
|
|
237
|
+
UniquenessKey.bind!(key, queue_name: lock_queue_name(queue_name), msg_id: msg_id)
|
|
221
238
|
end
|
|
222
239
|
|
|
223
240
|
# True when the uniqueness row is not yet bound to a real PGMQ message.
|
data/lib/pgbus/version.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Pgbus
|
|
|
17
17
|
# The pause events the timeline pairs, read on their own so a long
|
|
18
18
|
# audit trail never drops a run's earliest pauses from the chart.
|
|
19
19
|
PAUSE_HISTORY = 1_000
|
|
20
|
+
WAIT_TIMEOUT_HISTORY = 200
|
|
20
21
|
SIGNAL_HISTORY = 100
|
|
21
22
|
RERUN_LINKS = 10
|
|
22
23
|
ATTENTION_GROUPS = 200
|
|
@@ -46,7 +47,8 @@ module Pgbus
|
|
|
46
47
|
|
|
47
48
|
steps = WorkflowStep.where(workflow_id: workflow_id).order(id: :desc).limit(STEP_HISTORY).to_a
|
|
48
49
|
progress = batch_progress([step_attribute(steps.first, :batch_id)].compact)
|
|
49
|
-
|
|
50
|
+
late = late_since(steps.first&.status == "waiting" ? [steps.first] : [])
|
|
51
|
+
workflow_row(record, steps.first, progress, late).merge(workflow_extras(record, steps))
|
|
50
52
|
rescue StandardError => e
|
|
51
53
|
Pgbus.logger.error { "[Pgbus::Web] Error fetching workflow #{workflow_id}: #{e.class}: #{e.message}" }
|
|
52
54
|
nil
|
|
@@ -96,7 +98,8 @@ module Pgbus
|
|
|
96
98
|
latest = latest_steps(records.map(&:workflow_id))
|
|
97
99
|
waiting = latest.values.select { |step| step.status == "waiting" }
|
|
98
100
|
progress = batch_progress(waiting.filter_map { |step| step_attribute(step, :batch_id) })
|
|
99
|
-
|
|
101
|
+
late = late_since(waiting)
|
|
102
|
+
records.map { |record| workflow_row(record, latest[record.workflow_id], progress, late) }
|
|
100
103
|
rescue StandardError => e
|
|
101
104
|
Pgbus.logger.error { "[Pgbus::Web] Error fetching workflows: #{e.class}: #{e.message}" }
|
|
102
105
|
[]
|
|
@@ -155,7 +158,37 @@ module Pgbus
|
|
|
155
158
|
end
|
|
156
159
|
end
|
|
157
160
|
|
|
158
|
-
|
|
161
|
+
# { step_id => the first deadline its wait missed } for waiting steps
|
|
162
|
+
# with a deadline, one query: the first wait_timed_out event of each.
|
|
163
|
+
# Only on_timeout: :notify leaves a step waiting after that event
|
|
164
|
+
# (issue #565): every other on_timeout ends the attempt.
|
|
165
|
+
def late_since(waiting)
|
|
166
|
+
steps = waiting.select { |step| step_attribute(step, :timeout_at) }
|
|
167
|
+
return {} if steps.empty?
|
|
168
|
+
|
|
169
|
+
WorkflowEvent.where(workflow_id: steps.map(&:workflow_id), step_id: steps.map(&:id),
|
|
170
|
+
event: WorkflowTimeline::WAIT_TIMED_OUT)
|
|
171
|
+
.select("DISTINCT ON (step_id) step_id, details, created_at").order(Arel.sql("step_id, id"))
|
|
172
|
+
.to_h { |event| [event.step_id, missed_deadline(event)] }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The deadline the event reports; when it went out, for an event
|
|
176
|
+
# written without one.
|
|
177
|
+
def missed_deadline(event)
|
|
178
|
+
deadline = event.details.to_h["timeout_at"]
|
|
179
|
+
deadline ? Time.iso8601(deadline) : event.created_at
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The waiting step's on_timeout as its class declares it: nil without
|
|
183
|
+
# a wait_timeout:, or when the class is not loaded in this process.
|
|
184
|
+
def wait_on_timeout(record, step)
|
|
185
|
+
return nil unless step_attribute(step, :timeout_at)
|
|
186
|
+
|
|
187
|
+
definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
|
|
188
|
+
definition.on_timeout.to_s if definition.is_a?(Workflow::StepDefinition)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def workflow_row(record, step, progress, late = {})
|
|
159
192
|
waiting = step&.status == "waiting"
|
|
160
193
|
{ workflow_id: record.workflow_id, workflow_class: record.workflow_class, subject_type: record.subject_type,
|
|
161
194
|
subject_id: record.subject_id, status: record.status, current_step: record.current_step,
|
|
@@ -163,6 +196,7 @@ module Pgbus
|
|
|
163
196
|
waiting_for: (step_attribute(step, :wait_for) if waiting),
|
|
164
197
|
waiting_since: (step_attribute(step, :waiting_since) if waiting),
|
|
165
198
|
timeout_at: (step_attribute(step, :timeout_at) if waiting),
|
|
199
|
+
on_timeout: (wait_on_timeout(record, step) if waiting), late_since: (late[step.id] if waiting),
|
|
166
200
|
batch_id: step_attribute(step, :batch_id),
|
|
167
201
|
batch_progress: (progress[step_attribute(step, :batch_id)] if waiting),
|
|
168
202
|
scheduled_at: (step.scheduled_at if step&.status == "scheduled"),
|
|
@@ -192,7 +226,7 @@ module Pgbus
|
|
|
192
226
|
definition_outdated: record.definition_outdated?, class_loaded: !klass.nil?,
|
|
193
227
|
step_names: klass ? klass.workflow_steps.names : [], resume_action: resume_action, resume_step: resume_step,
|
|
194
228
|
signal_names: WorkflowActionPlan.signal_names(steps.first, klass), reruns: workflow_reruns(record.workflow_id),
|
|
195
|
-
timeline: WorkflowTimeline.chart(step_hashes,
|
|
229
|
+
timeline: WorkflowTimeline.chart(step_hashes, timeline_events(record.workflow_id), status: record.status, now: now) }
|
|
196
230
|
end
|
|
197
231
|
|
|
198
232
|
def workflow_step_hash(step)
|
|
@@ -207,9 +241,18 @@ module Pgbus
|
|
|
207
241
|
signal_id: step_attribute(step, :signal_id) }
|
|
208
242
|
end
|
|
209
243
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
244
|
+
# The pauses (oldest first) and the newest wait_timed_out events the
|
|
245
|
+
# timeline marks, read apart so a wait late for weeks cannot crowd out
|
|
246
|
+
# the pauses.
|
|
247
|
+
def timeline_events(workflow_id)
|
|
248
|
+
pauses = WorkflowEvent.where(workflow_id: workflow_id, event: WorkflowTimeline::PAUSE_EVENTS).order(:id)
|
|
249
|
+
.limit(PAUSE_HISTORY).map { |event| { event: event.event, created_at: event.created_at } }
|
|
250
|
+
timeouts = WorkflowEvent.where(workflow_id: workflow_id, event: WorkflowTimeline::WAIT_TIMED_OUT)
|
|
251
|
+
.order(id: :desc).limit(WAIT_TIMEOUT_HISTORY).map do |event|
|
|
252
|
+
{ event: event.event, created_at: event.created_at, step_name: event.step_name, attempt: event.attempt,
|
|
253
|
+
details: event.details }
|
|
254
|
+
end
|
|
255
|
+
pauses + timeouts
|
|
213
256
|
end
|
|
214
257
|
|
|
215
258
|
def workflow_signals(workflow_id)
|
|
@@ -321,7 +321,7 @@ module Pgbus
|
|
|
321
321
|
def discard_failed_event(id)
|
|
322
322
|
event = failed_event(id)
|
|
323
323
|
if event
|
|
324
|
-
|
|
324
|
+
release_locks_held_by_failed_events("id = $1", [id.to_i])
|
|
325
325
|
archive_failed_message(event)
|
|
326
326
|
end
|
|
327
327
|
|
|
@@ -920,7 +920,8 @@ module Pgbus
|
|
|
920
920
|
# memory — otherwise the message is archived but the lock row stays
|
|
921
921
|
# behind, blocking later publishes with the same key. Mirrors
|
|
922
922
|
# discard_event.
|
|
923
|
-
|
|
923
|
+
release_lock_held_by(detail[:message], queue_name: lock_queue_for_table(queue_name), msg_id: detail[:msg_id],
|
|
924
|
+
enqueued_at: detail[:enqueued_at])
|
|
924
925
|
@client.archive_message(queue_name, msg_id.to_i, prefixed: false)
|
|
925
926
|
clear_event_failure(queue_name, msg_id)
|
|
926
927
|
true
|
|
@@ -1554,6 +1555,13 @@ module Pgbus
|
|
|
1554
1555
|
end
|
|
1555
1556
|
|
|
1556
1557
|
# --- Lock cleanup helpers ---
|
|
1558
|
+
#
|
|
1559
|
+
# Discarding a message releases only the uniqueness lock that message
|
|
1560
|
+
# holds (UniquenessKey.release_held_by!): its job may have handed the
|
|
1561
|
+
# lock to a retry that is still waiting (issue #564), or a successor may
|
|
1562
|
+
# have taken the key, and neither is the discarded message's to free.
|
|
1563
|
+
# The Locks page's own deletes stay key-only: there the key is the
|
|
1564
|
+
# operator's target.
|
|
1557
1565
|
|
|
1558
1566
|
# Extract uniqueness key from a queue message and release its lock.
|
|
1559
1567
|
def release_lock_for_message(queue_name, msg_id)
|
|
@@ -1564,39 +1572,139 @@ module Pgbus
|
|
|
1564
1572
|
)
|
|
1565
1573
|
return unless row
|
|
1566
1574
|
|
|
1567
|
-
|
|
1575
|
+
holder_queue, holder_msg_id, sent_at = lock_holder(queue_name, row["msg_id"], row["headers"], row["enqueued_at"])
|
|
1576
|
+
release_lock_held_by(row["message"], queue_name: holder_queue, msg_id: holder_msg_id, enqueued_at: sent_at)
|
|
1568
1577
|
rescue StandardError => e
|
|
1569
1578
|
Pgbus.logger.debug { "[Pgbus::Web] Error releasing lock for message #{msg_id}: #{e.message}" }
|
|
1570
1579
|
end
|
|
1571
1580
|
|
|
1572
|
-
#
|
|
1573
|
-
|
|
1581
|
+
# [queue, msg_id, sent_at] of the message whose lock a discard may
|
|
1582
|
+
# release. A live message is its own holder. A dead-lettered copy's is
|
|
1583
|
+
# the message it was moved from, which its dead-letter header names:
|
|
1584
|
+
# the copy's own enqueued_at is the move to the DLQ, later than a lock
|
|
1585
|
+
# its job may have handed to a retry. A copy whose header does not name
|
|
1586
|
+
# the original (dead-lettered by an earlier release, which freed its
|
|
1587
|
+
# lock then) has no send time to go by, so only a bound lock could
|
|
1588
|
+
# match, and none is bound to a DLQ copy.
|
|
1589
|
+
def lock_holder(queue_name, msg_id, headers, enqueued_at)
|
|
1590
|
+
own = [lock_queue_for_table(queue_name), msg_id.to_i]
|
|
1591
|
+
return [*own, enqueued_at] unless queue_name.to_s.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
|
|
1592
|
+
|
|
1593
|
+
block = DeadLetterHeader.parse(headers)
|
|
1594
|
+
source_msg_id = block && Integer(block["source_msg_id"], exception: false)
|
|
1595
|
+
return [*own, nil] unless source_msg_id && block["source_queue"]
|
|
1596
|
+
|
|
1597
|
+
[lock_queue_for_table(block["source_queue"]), source_msg_id, block["source_enqueued_at"]]
|
|
1598
|
+
end
|
|
1599
|
+
|
|
1600
|
+
# Release the lock held by the message whose JSON payload this is.
|
|
1601
|
+
def release_lock_held_by(payload_str, queue_name:, msg_id:, enqueued_at:)
|
|
1574
1602
|
key = extract_uniqueness_key_from_payload_str(payload_str)
|
|
1575
|
-
|
|
1603
|
+
return unless key
|
|
1604
|
+
|
|
1605
|
+
UniquenessKey.release_held_by!(key, queue_name: queue_name, msg_id: msg_id.to_i, enqueued_at: enqueued_at)
|
|
1576
1606
|
end
|
|
1577
1607
|
|
|
1578
|
-
#
|
|
1579
|
-
#
|
|
1608
|
+
# Release the locks a collection of formatted messages hold, in a single
|
|
1609
|
+
# query.
|
|
1580
1610
|
def release_locks_for_messages(messages)
|
|
1581
|
-
|
|
1582
|
-
|
|
1611
|
+
holders = messages.filter_map do |m|
|
|
1612
|
+
key = extract_uniqueness_key_from_payload_str(m[:message])
|
|
1613
|
+
[key, *lock_holder(m[:queue_name], m[:msg_id], m[:headers], m[:enqueued_at])] if key
|
|
1614
|
+
end
|
|
1615
|
+
UniquenessKey.release_all_held_by!(holders) if holders.any?
|
|
1583
1616
|
rescue StandardError => e
|
|
1584
1617
|
Pgbus.logger.debug { "[Pgbus::Web] Error releasing locks for messages: #{e.message}" }
|
|
1585
1618
|
end
|
|
1586
1619
|
|
|
1587
|
-
# Collect uniqueness keys from all failed events and release their locks.
|
|
1588
1620
|
def release_locks_for_failed_events
|
|
1621
|
+
release_locks_held_by_failed_events(NOT_HANDLER_QUEUE, [handler_queue_names_literal])
|
|
1622
|
+
end
|
|
1623
|
+
|
|
1624
|
+
# Release the locks the messages of the failed jobs matching +condition+
|
|
1625
|
+
# hold. A failed job's message is still in its queue, waiting for its
|
|
1626
|
+
# next read, and its enqueued_at is the send time: failed_at is not, a
|
|
1627
|
+
# lock handed to a retry before the failure would count as the job's.
|
|
1628
|
+
# A message that is gone has no send time, so only a bound lock goes.
|
|
1629
|
+
def release_locks_held_by_failed_events(condition, binds)
|
|
1589
1630
|
rows = connection.select_all(
|
|
1590
|
-
"SELECT payload FROM pgbus_failed_events WHERE #{
|
|
1591
|
-
|
|
1631
|
+
"SELECT queue_name, msg_id, payload FROM pgbus_failed_events WHERE #{condition}",
|
|
1632
|
+
"Pgbus Collect Failed Keys", binds
|
|
1592
1633
|
)
|
|
1593
1634
|
|
|
1594
|
-
|
|
1595
|
-
|
|
1635
|
+
# queue_name is the logical name the executor saw: the message is looked
|
|
1636
|
+
# up under it, and the lock is released under its table spelling.
|
|
1637
|
+
# A key proc may return a number; the queue lookup reads it as text.
|
|
1638
|
+
holders = rows.to_a.filter_map do |row|
|
|
1639
|
+
key = extract_uniqueness_key_from_payload_str(row["payload"])&.to_s
|
|
1640
|
+
[key, row["queue_name"], row["msg_id"].to_i] if key && row["msg_id"]
|
|
1641
|
+
end
|
|
1642
|
+
holders = holders.group_by { |_key, queue, _msg_id| queue }.flat_map do |queue, entries|
|
|
1643
|
+
failed_queue_lock_holders(queue, entries)
|
|
1644
|
+
end
|
|
1645
|
+
UniquenessKey.release_all_held_by!(holders) if holders.any?
|
|
1596
1646
|
rescue StandardError => e
|
|
1597
1647
|
Pgbus.logger.debug { "[Pgbus::Web] Error releasing locks for failed events: #{e.message}" }
|
|
1598
1648
|
end
|
|
1599
1649
|
|
|
1650
|
+
# The release tuples for one queue's failed jobs. A queue that cannot
|
|
1651
|
+
# be resolved (a stored name no longer valid as a table name) is skipped
|
|
1652
|
+
# with a warning, so it does not stop the other queues' release.
|
|
1653
|
+
def failed_queue_lock_holders(queue, entries)
|
|
1654
|
+
sent = queued_send_times(queue, entries)
|
|
1655
|
+
lock_queue = Uniqueness.lock_queue_name(queue)
|
|
1656
|
+
entries.map { |key, _queue, msg_id| [key, lock_queue, msg_id, sent[[msg_id, key]]] }
|
|
1657
|
+
rescue ActiveRecord::StatementInvalid
|
|
1658
|
+
raise
|
|
1659
|
+
rescue StandardError => e
|
|
1660
|
+
Pgbus.logger.warn { "[Pgbus::Web] Skipped releasing locks for failed jobs on #{queue.inspect}: #{e.message}" }
|
|
1661
|
+
[]
|
|
1662
|
+
end
|
|
1663
|
+
|
|
1664
|
+
# { [msg_id, uniqueness key] => enqueued_at } for the messages of
|
|
1665
|
+
# +entries+ ([key, queue, msg_id]) still in the logical queue. Every
|
|
1666
|
+
# priority sub-queue is searched: a failed event records the logical
|
|
1667
|
+
# queue only, and msg_ids repeat across sub-queues, so the key must
|
|
1668
|
+
# match too, and the earliest send wins.
|
|
1669
|
+
def queued_send_times(queue, entries)
|
|
1670
|
+
msg_ids = "{#{entries.map { |_key, _queue, msg_id| msg_id }.uniq.join(",")}}"
|
|
1671
|
+
@client.physical_queue_names(queue).each_with_object({}) do |physical, found|
|
|
1672
|
+
queued_rows(physical, msg_ids).each do |row|
|
|
1673
|
+
pair = [row["msg_id"].to_i, row["lock_key"]]
|
|
1674
|
+
found[pair] = [found[pair], row["enqueued_at"]].compact.min
|
|
1675
|
+
end
|
|
1676
|
+
end
|
|
1677
|
+
end
|
|
1678
|
+
|
|
1679
|
+
# Each read runs in its own savepoint: a sub-queue the strategy names
|
|
1680
|
+
# but that does not exist raises, and inside a caller's transaction
|
|
1681
|
+
# that would abort the discard's own statements after it.
|
|
1682
|
+
def queued_rows(physical, msg_ids)
|
|
1683
|
+
connection.transaction(requires_new: true) do
|
|
1684
|
+
connection.select_all(
|
|
1685
|
+
"SELECT msg_id, enqueued_at, message->>'#{Uniqueness::METADATA_KEY}' AS lock_key " \
|
|
1686
|
+
"FROM pgmq.q_#{sanitize_name(physical)} WHERE msg_id = ANY($1::bigint[])",
|
|
1687
|
+
"Pgbus Failed Message Sent At", [msg_ids]
|
|
1688
|
+
).to_a
|
|
1689
|
+
end
|
|
1690
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
1691
|
+
raise unless defined?(PG::UndefinedTable) && e.cause.is_a?(PG::UndefinedTable)
|
|
1692
|
+
|
|
1693
|
+
[]
|
|
1694
|
+
end
|
|
1695
|
+
|
|
1696
|
+
# The queue a uniqueness lock records for a message in this physical
|
|
1697
|
+
# queue: the table name without its prefix and a priority sub-queue's
|
|
1698
|
+
# _pN, which is how Uniqueness.lock_queue_name spells the queue the lock
|
|
1699
|
+
# was bound for. A dead-letter queue keeps its suffix, so its copy's
|
|
1700
|
+
# msg_id (another sequence) never matches a lock bound to a live message.
|
|
1701
|
+
def lock_queue_for_table(physical_name)
|
|
1702
|
+
name = physical_name.to_s.delete_prefix("#{Pgbus.configuration.queue_prefix}_")
|
|
1703
|
+
return name if name.end_with?(Pgbus::DEAD_LETTER_SUFFIX) || Pgbus.configuration.priority_levels.to_i <= 1
|
|
1704
|
+
|
|
1705
|
+
name.sub(/_p\d+\z/, "")
|
|
1706
|
+
end
|
|
1707
|
+
|
|
1600
1708
|
# Single unwrap point for PGMQ message / failed_event payload strings.
|
|
1601
1709
|
# Accepts a raw JSON string or an already-parsed Hash and returns the
|
|
1602
1710
|
# uniqueness metadata key, or nil when the payload is blank, unparseable,
|