pgbus 0.20.0 → 0.21.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 +4 -4
- data/CHANGELOG.md +59 -6
- 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/workflow_insights_helper.rb +8 -0
- 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_entry.rb +3 -2
- data/app/models/pgbus/workflow_step.rb +7 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +11 -1
- data/app/views/pgbus/workflows/definitions/_step_card.html.erb +3 -0
- data/config/locales/da.yml +7 -1
- data/config/locales/de.yml +7 -1
- data/config/locales/en.yml +7 -1
- data/config/locales/es.yml +7 -1
- data/config/locales/fi.yml +7 -1
- data/config/locales/fr.yml +7 -1
- data/config/locales/it.yml +7 -1
- data/config/locales/ja.yml +7 -1
- data/config/locales/nb.yml +7 -1
- data/config/locales/nl.yml +7 -1
- data/config/locales/pt.yml +7 -1
- data/config/locales/sv.yml +7 -1
- data/lib/generators/pgbus/templates/add_workflows.rb.erb +3 -1
- data/lib/generators/pgbus/templates/migration.rb.erb +3 -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/workflow_settings.rb +1 -1
- 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/mcp/tools/workflow_stats_tool.rb +1 -1
- data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +1 -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 +52 -7
- data/lib/pgbus/web/data_source.rb +123 -15
- data/lib/pgbus/web/workflow_insights_report.rb +11 -3
- data/lib/pgbus/web/workflow_state.rb +19 -3
- data/lib/pgbus/web/workflow_timeline.rb +23 -6
- data/lib/pgbus/workflow/instance.rb +36 -3
- data/lib/pgbus/workflow/runner.rb +2 -2
- data/lib/pgbus/workflow/signal_peek.rb +47 -0
- data/lib/pgbus/workflow/signals.rb +31 -10
- data/lib/pgbus/workflow/step_definition.rb +49 -5
- data/lib/pgbus/workflow/step_execution.rb +27 -10
- data/lib/pgbus/workflow/wait_timeout.rb +56 -4
- data/lib/pgbus/workflow.rb +11 -5
- data/sig/pgbus/configuration.rbs +1 -0
- data/sig/pgbus.rbs +43 -0
- metadata +2 -1
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,39 @@ module Pgbus
|
|
|
155
158
|
end
|
|
156
159
|
end
|
|
157
160
|
|
|
158
|
-
|
|
161
|
+
# { step_id => the first deadline its wait missed } for steps waiting
|
|
162
|
+
# on a signal, 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. A per-run
|
|
165
|
+
# wait_timeout: may have cleared the deadline at that notice (issue
|
|
166
|
+
# #574), so a step without one is looked up too.
|
|
167
|
+
def late_since(waiting)
|
|
168
|
+
steps = waiting.select { |step| step_attribute(step, :wait_for) }
|
|
169
|
+
return {} if steps.empty?
|
|
170
|
+
|
|
171
|
+
WorkflowEvent.where(workflow_id: steps.map(&:workflow_id), step_id: steps.map(&:id),
|
|
172
|
+
event: WorkflowTimeline::WAIT_TIMED_OUT)
|
|
173
|
+
.select("DISTINCT ON (step_id) step_id, details, created_at").order(Arel.sql("step_id, id"))
|
|
174
|
+
.to_h { |event| [event.step_id, missed_deadline(event)] }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The deadline the event reports; when it went out, for an event
|
|
178
|
+
# written without one.
|
|
179
|
+
def missed_deadline(event)
|
|
180
|
+
deadline = event.details.to_h["timeout_at"]
|
|
181
|
+
deadline ? Time.iso8601(deadline) : event.created_at
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The waiting step's on_timeout as its class declares it: nil without
|
|
185
|
+
# a wait_timeout:, or when the class is not loaded in this process.
|
|
186
|
+
def wait_on_timeout(record, step)
|
|
187
|
+
return nil unless step_attribute(step, :timeout_at)
|
|
188
|
+
|
|
189
|
+
definition = record.workflow_klass&.workflow_steps&.fetch(step.step_name)
|
|
190
|
+
definition.on_timeout.to_s if definition.is_a?(Workflow::StepDefinition)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def workflow_row(record, step, progress, late = {})
|
|
159
194
|
waiting = step&.status == "waiting"
|
|
160
195
|
{ workflow_id: record.workflow_id, workflow_class: record.workflow_class, subject_type: record.subject_type,
|
|
161
196
|
subject_id: record.subject_id, status: record.status, current_step: record.current_step,
|
|
@@ -163,6 +198,7 @@ module Pgbus
|
|
|
163
198
|
waiting_for: (step_attribute(step, :wait_for) if waiting),
|
|
164
199
|
waiting_since: (step_attribute(step, :waiting_since) if waiting),
|
|
165
200
|
timeout_at: (step_attribute(step, :timeout_at) if waiting),
|
|
201
|
+
on_timeout: (wait_on_timeout(record, step) if waiting), late_since: (late[step.id] if waiting),
|
|
166
202
|
batch_id: step_attribute(step, :batch_id),
|
|
167
203
|
batch_progress: (progress[step_attribute(step, :batch_id)] if waiting),
|
|
168
204
|
scheduled_at: (step.scheduled_at if step&.status == "scheduled"),
|
|
@@ -192,7 +228,7 @@ module Pgbus
|
|
|
192
228
|
definition_outdated: record.definition_outdated?, class_loaded: !klass.nil?,
|
|
193
229
|
step_names: klass ? klass.workflow_steps.names : [], resume_action: resume_action, resume_step: resume_step,
|
|
194
230
|
signal_names: WorkflowActionPlan.signal_names(steps.first, klass), reruns: workflow_reruns(record.workflow_id),
|
|
195
|
-
timeline: WorkflowTimeline.chart(step_hashes,
|
|
231
|
+
timeline: WorkflowTimeline.chart(step_hashes, timeline_events(record.workflow_id), status: record.status, now: now) }
|
|
196
232
|
end
|
|
197
233
|
|
|
198
234
|
def workflow_step_hash(step)
|
|
@@ -207,9 +243,18 @@ module Pgbus
|
|
|
207
243
|
signal_id: step_attribute(step, :signal_id) }
|
|
208
244
|
end
|
|
209
245
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
246
|
+
# The pauses (oldest first) and the newest wait_timed_out events the
|
|
247
|
+
# timeline marks, read apart so a wait late for weeks cannot crowd out
|
|
248
|
+
# the pauses.
|
|
249
|
+
def timeline_events(workflow_id)
|
|
250
|
+
pauses = WorkflowEvent.where(workflow_id: workflow_id, event: WorkflowTimeline::PAUSE_EVENTS).order(:id)
|
|
251
|
+
.limit(PAUSE_HISTORY).map { |event| { event: event.event, created_at: event.created_at } }
|
|
252
|
+
timeouts = WorkflowEvent.where(workflow_id: workflow_id, event: WorkflowTimeline::WAIT_TIMED_OUT)
|
|
253
|
+
.order(id: :desc).limit(WAIT_TIMEOUT_HISTORY).map do |event|
|
|
254
|
+
{ event: event.event, created_at: event.created_at, step_name: event.step_name, attempt: event.attempt,
|
|
255
|
+
details: event.details }
|
|
256
|
+
end
|
|
257
|
+
pauses + timeouts
|
|
213
258
|
end
|
|
214
259
|
|
|
215
260
|
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,
|
|
@@ -47,14 +47,14 @@ module Pgbus
|
|
|
47
47
|
)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
# [{ name:, kind:, removed:, declared: }] in pipeline order.
|
|
50
|
+
# [{ name:, kind:, removed:, declared:, wait_timeout: }] in pipeline order.
|
|
51
51
|
def step_list(klass, stats, ongoing, reached, stopped)
|
|
52
52
|
seen = (stats.keys + ongoing.map { |row| row[:step] } + reached.keys + stopped.keys.map(&:first)).compact.uniq
|
|
53
53
|
declared = klass ? klass.workflow_steps.entries.map { |entry| declared_step(entry) } : []
|
|
54
54
|
extra = (seen - declared.map { |entry| entry[:name] }).sort_by do |name|
|
|
55
55
|
[reached.dig(name, :position) || Float::INFINITY, stats.dig(name, :first_seen_at)&.to_f || Float::INFINITY, name]
|
|
56
56
|
end
|
|
57
|
-
declared + extra.map { |name| { name: name, kind: nil, removed: false, declared: false } }
|
|
57
|
+
declared + extra.map { |name| { name: name, kind: nil, removed: false, declared: false, wait_timeout: nil } }
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def declared_step(entry)
|
|
@@ -63,7 +63,15 @@ module Pgbus
|
|
|
63
63
|
elsif entry.waits_for_signal? then "signal"
|
|
64
64
|
else "step"
|
|
65
65
|
end
|
|
66
|
-
{ name: entry.name, kind: kind, removed: entry.removed?, declared: true }
|
|
66
|
+
{ name: entry.name, kind: kind, removed: entry.removed?, declared: true, wait_timeout: wait_timeout(entry) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A signal step's wait_timeout: in seconds, "dynamic" for a per-run one
|
|
70
|
+
# (issue #574), nil without one.
|
|
71
|
+
def wait_timeout(entry)
|
|
72
|
+
return nil if entry.removed?
|
|
73
|
+
|
|
74
|
+
entry.dynamic_wait_timeout? ? "dynamic" : entry.wait_timeout
|
|
67
75
|
end
|
|
68
76
|
|
|
69
77
|
def step(entry, stats, ongoing)
|
|
@@ -6,8 +6,8 @@ module Pgbus
|
|
|
6
6
|
# (its status, or "scheduled" for a ready run whose step waits for a
|
|
7
7
|
# time still ahead) and what it is doing and what happens next, as a
|
|
8
8
|
# pgbus.workflows.next.<key>_html key and its arguments. Arguments named
|
|
9
|
-
# :step, :signal and :batch are names, :ago
|
|
10
|
-
# future one; the helper renders each. Pure.
|
|
9
|
+
# :step, :signal and :batch are names, :ago and :late past moments and
|
|
10
|
+
# :time a future one; the helper renders each. Pure.
|
|
11
11
|
module WorkflowState
|
|
12
12
|
BADGE_TONES = { "ready" => :gray, "scheduled" => :gray, "performing" => :blue, "waiting" => :yellow,
|
|
13
13
|
"paused" => :yellow, "finished" => :green, "cancelled" => :red }.freeze
|
|
@@ -51,7 +51,23 @@ module Pgbus
|
|
|
51
51
|
return ["waiting", "waiting_step", { step: step }] unless row[:waiting_for]
|
|
52
52
|
|
|
53
53
|
args = { signal: row[:waiting_for], ago: row[:waiting_since] }
|
|
54
|
-
row
|
|
54
|
+
return signal_deadline(row, args.merge(time: row[:timeout_at])) if row[:timeout_at]
|
|
55
|
+
# A per-run wait_timeout: reported once under on_timeout: :notify,
|
|
56
|
+
# then left without a deadline (issue #574).
|
|
57
|
+
return ["waiting", "waiting_signal_late_no_deadline", args.merge(late: row[:late_since])] if row[:late_since]
|
|
58
|
+
|
|
59
|
+
["waiting", "waiting_signal", args]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# An on_timeout: :notify deadline sends a late notice and the wait goes
|
|
63
|
+
# on (issue #565); late_since is the first deadline the wait missed.
|
|
64
|
+
def signal_deadline(row, args)
|
|
65
|
+
# A deploy that changed on_timeout since the last notice: the next
|
|
66
|
+
# deadline does something else (nil: the class is not loaded here).
|
|
67
|
+
late = row[:late_since] && [nil, "notify"].include?(row[:on_timeout]&.to_s)
|
|
68
|
+
return ["waiting", "waiting_signal_late", args.merge(late: row[:late_since])] if late
|
|
69
|
+
|
|
70
|
+
["waiting", row[:on_timeout].to_s == "notify" ? "waiting_signal_notify" : "waiting_signal_timeout", args]
|
|
55
71
|
end
|
|
56
72
|
|
|
57
73
|
def paused(row, step)
|
|
@@ -11,13 +11,15 @@ module Pgbus
|
|
|
11
11
|
# waiting waiting_since → … parked on a signal or its fan-out batch
|
|
12
12
|
#
|
|
13
13
|
# and a paused run's pauses (paused → resumed, from the events) are their
|
|
14
|
-
# own segments, so a long gap explains itself
|
|
15
|
-
#
|
|
16
|
-
# chart. Pure: rows in,
|
|
14
|
+
# own segments, so a long gap explains itself; each wait_timed_out event
|
|
15
|
+
# is a marker. The show page's step table prints durations; the
|
|
16
|
+
# range-bar chart (charts.js renderTimeline) draws chart. Pure: rows in,
|
|
17
|
+
# hashes out.
|
|
17
18
|
module WorkflowTimeline
|
|
18
19
|
OPEN = %w[scheduled waiting in_progress].freeze
|
|
19
20
|
PAUSE_ENDS = %w[resumed cancelled finished].freeze
|
|
20
21
|
PAUSE_EVENTS = (%w[paused] + PAUSE_ENDS).freeze
|
|
22
|
+
WAIT_TIMED_OUT = "wait_timed_out"
|
|
21
23
|
RAN_SERIES = { "completed" => "ran_ok", "skipped" => "ran_ok", "failed" => "ran_failed",
|
|
22
24
|
"in_progress" => "ran_open", "waiting" => "ran_open" }.freeze
|
|
23
25
|
|
|
@@ -69,8 +71,8 @@ module Pgbus
|
|
|
69
71
|
periods
|
|
70
72
|
end
|
|
71
73
|
|
|
72
|
-
# The chart payload: one row per attempt (oldest first)
|
|
73
|
-
# periods. Times are epoch milliseconds; each segment carries its
|
|
74
|
+
# The chart payload: one row per attempt (oldest first), the paused
|
|
75
|
+
# periods and the wait_timed_out marks (wait_timeouts). Times are epoch milliseconds; each segment carries its
|
|
74
76
|
# series (its colour: ran is coloured by how the attempt ended) and its
|
|
75
77
|
# duration in words for the tooltip.
|
|
76
78
|
def chart(steps, events, status:, now:)
|
|
@@ -82,7 +84,20 @@ module Pgbus
|
|
|
82
84
|
paused = paused_periods(events, status: status, now: now).map do |period|
|
|
83
85
|
chart_segment(period.merge(kind: :paused), "paused")
|
|
84
86
|
end
|
|
85
|
-
{ rows: rows, paused: paused }
|
|
87
|
+
{ rows: rows, paused: paused, wait_timeouts: wait_timeouts(events) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# [{ at:, step:, action: }] per wait_timed_out event, oldest first, at
|
|
91
|
+
# the deadline it reports (when it went out, without one): the chart
|
|
92
|
+
# draws each as a line across the rows, the page lists them. An
|
|
93
|
+
# on_timeout: :notify wait has one per late notice (issue #565).
|
|
94
|
+
def wait_timeouts(events)
|
|
95
|
+
timed_out = events.select { |event| event[:event] == WAIT_TIMED_OUT }.sort_by { |event| event[:created_at] }
|
|
96
|
+
timed_out.map do |event|
|
|
97
|
+
details = event[:details].to_h
|
|
98
|
+
{ at: millis(deadline(details["timeout_at"]) || event[:created_at]),
|
|
99
|
+
step: "#{event[:step_name]} ##{event[:attempt]}", action: details["action"] }
|
|
100
|
+
end
|
|
86
101
|
end
|
|
87
102
|
|
|
88
103
|
def series_for(kind, step)
|
|
@@ -98,6 +113,8 @@ module Pgbus
|
|
|
98
113
|
|
|
99
114
|
def millis(time) = (time.to_f * 1000).round
|
|
100
115
|
|
|
116
|
+
def deadline(iso) = iso && Time.iso8601(iso)
|
|
117
|
+
|
|
101
118
|
# Drops a piece without both ends or of no length, and ends every piece
|
|
102
119
|
# at the attempt's finished_at (a step cancelled before its scheduled_at).
|
|
103
120
|
def clip(pieces, finished)
|
|
@@ -94,6 +94,33 @@ module Pgbus
|
|
|
94
94
|
@timed_out == true
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# Inside a step (its guards and its body): true when this attempt holds
|
|
98
|
+
# a signal before its gate runs: signal! woke the step, or an earlier
|
|
99
|
+
# attempt of it took one and reattempted or failed into a pause. False
|
|
100
|
+
# on an attempt that has not waited yet (even one whose gate then takes
|
|
101
|
+
# a buffered signal), after an on_timeout: :run wake, and in a step
|
|
102
|
+
# without wait_for:. The same answer for the whole attempt.
|
|
103
|
+
def woken?
|
|
104
|
+
step_context!(:woken?)
|
|
105
|
+
@woken = !@signal_peek&.held.nil? if @woken.nil?
|
|
106
|
+
@woken
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Inside a step (its guards and its body): true when the gate runs this
|
|
110
|
+
# attempt with a signal: the one it holds (woken?), or a pending one its
|
|
111
|
+
# wait_for: matcher takes. The guard for "skip a wait the record already
|
|
112
|
+
# passed", which neither skips a signalled step nor leaves its signal
|
|
113
|
+
# pending. False after an on_timeout: :run wake (the gate takes no
|
|
114
|
+
# signal then) and in a step without wait_for:. Reads only: the gate
|
|
115
|
+
# claims the signal after the guards.
|
|
116
|
+
#
|
|
117
|
+
# step :await_shipped, skip_if: -> { subject.shipped? && !signal_ready? }, wait_for: :shipped do … end
|
|
118
|
+
def signal_ready?
|
|
119
|
+
step_context!(:signal_ready?)
|
|
120
|
+
@signal_ready = !@signal_peek&.ready.nil? if @signal_ready.nil?
|
|
121
|
+
@signal_ready
|
|
122
|
+
end
|
|
123
|
+
|
|
97
124
|
# Deliver a signal to this run. Stored under the run's lock; a step
|
|
98
125
|
# waiting for it (wait_for:) is woken at once, otherwise it waits,
|
|
99
126
|
# pending, for the step that claims it. With an idempotency_key a
|
|
@@ -164,19 +191,25 @@ module Pgbus
|
|
|
164
191
|
@guarding = false
|
|
165
192
|
end
|
|
166
193
|
|
|
167
|
-
# Used by the StepJob only.
|
|
168
|
-
|
|
194
|
+
# Used by the StepJob only. +signals+ is the attempt's SignalPeek, which
|
|
195
|
+
# woken? and signal_ready? read until the gate answers them.
|
|
196
|
+
def enter_step(context, signals: nil)
|
|
169
197
|
@step_context = context
|
|
198
|
+
@signal_peek = signals
|
|
170
199
|
@output_values = nil
|
|
171
200
|
@pending_output = nil
|
|
172
201
|
@received_signal = nil
|
|
173
202
|
@timed_out = false
|
|
203
|
+
@woken = nil
|
|
204
|
+
@signal_ready = nil
|
|
174
205
|
end
|
|
175
206
|
|
|
176
207
|
# Used by the StepJob's signal gate only.
|
|
177
|
-
def enter_wait(signal:, timed_out:)
|
|
208
|
+
def enter_wait(signal:, timed_out:, woken: false)
|
|
178
209
|
@received_signal = signal
|
|
179
210
|
@timed_out = timed_out
|
|
211
|
+
@woken = woken
|
|
212
|
+
@signal_ready = !signal.nil?
|
|
180
213
|
end
|
|
181
214
|
end
|
|
182
215
|
end
|
|
@@ -68,11 +68,11 @@ module Pgbus
|
|
|
68
68
|
|
|
69
69
|
# --- start! and rerun! --------------------------------------------
|
|
70
70
|
|
|
71
|
-
def start(klass, subject:, arguments:, idempotency_key: nil)
|
|
71
|
+
def start(klass, subject:, arguments:, idempotency_key: nil, actor: DEFAULT_ACTOR, reason: nil)
|
|
72
72
|
attrs = run_attributes(klass, idempotency_key: idempotency_key)
|
|
73
73
|
.merge(Payload.subject_attributes(subject))
|
|
74
74
|
.merge(arguments: Payload.dump(arguments, what: "arguments of #{klass.name}"))
|
|
75
|
-
insert_run(klass, attrs, actor:
|
|
75
|
+
insert_run(klass, attrs, actor: actor, reason: reason)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
# A finished or cancelled run started again: same subject and
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Workflow
|
|
5
|
+
# What a wait_for: step's attempt holds and what its gate would run it
|
|
6
|
+
# with (issue #572), read without claiming anything. The StepJob builds
|
|
7
|
+
# one per attempt: the step readers woken? and signal_ready? read it in
|
|
8
|
+
# the guards, and the gate decides with the same one, so the gate takes
|
|
9
|
+
# exactly the signal signal_ready? promised. All of it runs under the
|
|
10
|
+
# run's row lock, so each answer is looked up (and a matcher run) once.
|
|
11
|
+
class SignalPeek
|
|
12
|
+
def initialize(record, step, definition, now: Time.current)
|
|
13
|
+
@record = record
|
|
14
|
+
@step = step
|
|
15
|
+
@definition = definition
|
|
16
|
+
@now = now
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The claimed signal the step holds (signal! woke it, or an earlier
|
|
20
|
+
# attempt took one and reattempted or paused), or nil. Always nil for a
|
|
21
|
+
# step without wait_for:.
|
|
22
|
+
def held
|
|
23
|
+
return @held if defined?(@held)
|
|
24
|
+
|
|
25
|
+
@held = waits? ? WorkflowSignal.held_by_step(@record.workflow_id, @step.step_name) : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The sweep woke this attempt with on_timeout: :run: its body runs
|
|
29
|
+
# without a signal (Signals.timed_out?).
|
|
30
|
+
def timed_out? = Signals.timed_out?(@step, @now)
|
|
31
|
+
|
|
32
|
+
# The signal the gate runs this attempt with: the one the step holds,
|
|
33
|
+
# else the oldest pending one its matcher takes. nil when the wait timed
|
|
34
|
+
# out (the body runs without one), when nothing matches (the step
|
|
35
|
+
# parks), and for a step without wait_for:.
|
|
36
|
+
def ready
|
|
37
|
+
return @ready if defined?(@ready)
|
|
38
|
+
|
|
39
|
+
@ready = waits? && !timed_out? ? held || Signals.match(@record, @definition.wait_for) : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def waits? = @definition.waits_for_signal?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|