pgbus 0.14.0 → 0.14.2
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 +30 -0
- data/app/models/pgbus/batch_entry.rb +78 -6
- data/app/models/pgbus/batch_execution.rb +28 -0
- data/app/models/pgbus/blocked_execution.rb +2 -2
- data/app/models/pgbus/uniqueness_key.rb +50 -4
- data/app/views/pgbus/batches/_batches_table.html.erb +3 -3
- data/app/views/pgbus/batches/show.html.erb +7 -7
- data/config/locales/da.yml +2 -2
- data/config/locales/de.yml +2 -2
- data/config/locales/en.yml +2 -2
- data/config/locales/es.yml +2 -2
- data/config/locales/fi.yml +2 -2
- data/config/locales/fr.yml +2 -2
- data/config/locales/it.yml +2 -2
- data/config/locales/ja.yml +2 -2
- data/config/locales/nb.yml +2 -2
- data/config/locales/nl.yml +2 -2
- data/config/locales/pt.yml +2 -2
- data/config/locales/sv.yml +2 -2
- data/lib/generators/pgbus/add_batch_callback_jobs_generator.rb +47 -0
- data/lib/generators/pgbus/add_batch_executions_generator.rb +44 -0
- data/lib/generators/pgbus/templates/add_batch_callback_jobs.rb.erb +13 -0
- data/lib/generators/pgbus/templates/add_batch_executions.rb.erb +50 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +2 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +23 -2
- data/lib/pgbus/active_job/adapter.rb +142 -28
- data/lib/pgbus/active_job/batch_id.rb +48 -0
- data/lib/pgbus/active_job/executor.rb +45 -9
- data/lib/pgbus/batch/sweep.rb +163 -0
- data/lib/pgbus/batch.rb +448 -63
- data/lib/pgbus/bus_record.rb +15 -1
- data/lib/pgbus/client.rb +191 -15
- data/lib/pgbus/concurrency/blocked_execution.rb +14 -1
- data/lib/pgbus/configuration.rb +24 -1
- data/lib/pgbus/engine.rb +1 -0
- data/lib/pgbus/generators/migration_detector.rb +30 -0
- data/lib/pgbus/instrumentation.rb +5 -0
- data/lib/pgbus/outbox/poller.rb +6 -7
- data/lib/pgbus/process/dispatcher.rb +41 -17
- data/lib/pgbus/recurring/schedule.rb +12 -1
- data/lib/pgbus/uniqueness.rb +35 -7
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +47 -9
- metadata +8 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Batch
|
|
5
|
+
# Repairs batches the regular completion path cannot finish: worker crash
|
|
6
|
+
# between archive and row-delete, enqueue crash between row-insert and
|
|
7
|
+
# send, a pending batch whose enqueue block never returned, or a processing
|
|
8
|
+
# batch whose finish UPDATE rolled back after callbacks failed to enqueue.
|
|
9
|
+
module Sweep
|
|
10
|
+
STALL_THRESHOLD = 300 # seconds; solid_queue default stalled_for: 5.minutes
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def run(stalled_for: Pgbus.configuration.batch_stall_threshold, batch_size: 500, client: Pgbus.client)
|
|
14
|
+
return unless Batch.executions_migrated?
|
|
15
|
+
|
|
16
|
+
payload = { stale_executions: 0, orphan_rows: 0, started_batches: 0, finished_batches: 0,
|
|
17
|
+
stalled_for: stalled_for }
|
|
18
|
+
Instrumentation.instrument("pgbus.batch_sweep", payload) do |p|
|
|
19
|
+
p[:stale_executions] = sweep_stale_executions(batch_size: batch_size, client: client, stalled_for: stalled_for)
|
|
20
|
+
p[:orphan_rows] = sweep_orphan_rows(stalled_for: stalled_for, batch_size: batch_size, client: client)
|
|
21
|
+
p[:started_batches] = start_stalled_pending(stalled_for: stalled_for, batch_size: batch_size)
|
|
22
|
+
p[:finished_batches] = finish_stalled_processing(batch_size: batch_size)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def sweep_stale_executions(batch_size:, client:, stalled_for:)
|
|
29
|
+
swept = 0
|
|
30
|
+
cutoff = Time.current - stalled_for
|
|
31
|
+
BatchExecution.where.not(msg_id: nil).where("created_at < ?", cutoff).find_each(batch_size: batch_size) do |row|
|
|
32
|
+
outcome = classify_stale(row, client)
|
|
33
|
+
next if outcome == :still_present
|
|
34
|
+
|
|
35
|
+
resolve_stale(row, outcome)
|
|
36
|
+
swept += 1
|
|
37
|
+
end
|
|
38
|
+
swept
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def classify_stale(row, client)
|
|
42
|
+
in_queue = client.message_in_queue?(row.queue_name, msg_id: row.msg_id)
|
|
43
|
+
return :still_present if in_queue != false
|
|
44
|
+
|
|
45
|
+
dlq = client.dead_letter_physical_name(row.queue_name)
|
|
46
|
+
return :failed if client.message_with_job_id?(dlq, job_id: row.job_id) == true
|
|
47
|
+
|
|
48
|
+
return :completed if client.message_archived?(row.queue_name, msg_id: row.msg_id) == true
|
|
49
|
+
|
|
50
|
+
Pgbus.logger.warn do
|
|
51
|
+
"[Pgbus] Batch execution #{row.job_id} (batch #{row.batch_id}) has no queue, " \
|
|
52
|
+
"archive, or DLQ message — resolving as completed"
|
|
53
|
+
end
|
|
54
|
+
:completed
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_stale(row, outcome)
|
|
58
|
+
column = outcome == :failed ? "failed_jobs" : "completed_jobs"
|
|
59
|
+
Batch.job_completed(row.batch_id, job_id: row.job_id) if column == "completed_jobs"
|
|
60
|
+
Batch.job_discarded(row.batch_id, job_id: row.job_id) if column == "failed_jobs"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Rows with no msg_id older than the threshold. A row only stays
|
|
64
|
+
# msg_id-less when the enqueue died between row insert and send — OR
|
|
65
|
+
# when the send landed and only the backfill failed, in which case the
|
|
66
|
+
# message is live and must not be un-counted (issue #423). Probe the
|
|
67
|
+
# queue (and DLQ) by job_id before deciding; nil (unknown) keeps the row.
|
|
68
|
+
def sweep_orphan_rows(stalled_for:, batch_size:, client:)
|
|
69
|
+
swept = 0
|
|
70
|
+
cutoff = Time.current - stalled_for
|
|
71
|
+
blocked = blocked_job_ids
|
|
72
|
+
BatchExecution.where(msg_id: nil).where("created_at < ?", cutoff).find_each(batch_size: batch_size) do |row|
|
|
73
|
+
next if blocked.include?(row.job_id)
|
|
74
|
+
|
|
75
|
+
case classify_orphan(row, client)
|
|
76
|
+
when :live then next
|
|
77
|
+
when :failed
|
|
78
|
+
Batch.job_discarded(row.batch_id, job_id: row.job_id)
|
|
79
|
+
else
|
|
80
|
+
next unless uncount_orphan!(row)
|
|
81
|
+
end
|
|
82
|
+
swept += 1
|
|
83
|
+
end
|
|
84
|
+
swept
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def classify_orphan(row, client)
|
|
88
|
+
return :live if row.queue_name.nil?
|
|
89
|
+
|
|
90
|
+
in_queue = client.message_with_job_id?(row.queue_name, job_id: row.job_id)
|
|
91
|
+
return :live if in_queue != false
|
|
92
|
+
|
|
93
|
+
dlq = client.dead_letter_physical_name(row.queue_name)
|
|
94
|
+
return :failed if client.message_with_job_id?(dlq, job_id: row.job_id) == true
|
|
95
|
+
|
|
96
|
+
:orphan
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns true when this sweep removed the row (CAS on msg_id NULL).
|
|
100
|
+
def uncount_orphan!(row) # rubocop:disable Naming/PredicateMethod
|
|
101
|
+
deleted = BatchExecution.where(id: row.id, msg_id: nil).delete_all
|
|
102
|
+
return false unless deleted.positive?
|
|
103
|
+
|
|
104
|
+
BatchEntry.decrement_total_jobs!(row.batch_id)
|
|
105
|
+
Batch.send(:finish_if_needed, Batch.try_finish!(row.batch_id))
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def blocked_job_ids
|
|
110
|
+
return Set.new unless BlockedExecution.table_exists?
|
|
111
|
+
|
|
112
|
+
BlockedExecution.pluck(Arel.sql("payload->>'job_id'")).compact.to_set
|
|
113
|
+
rescue StandardError
|
|
114
|
+
Set.new
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A pending batch whose enqueue block never returned. Jobs counted
|
|
118
|
+
# themselves in as they were enqueued (issue #423), so total_jobs is
|
|
119
|
+
# already right — only the status moves. "Stalled" means no execution
|
|
120
|
+
# row was inserted within the threshold either: a long-running block
|
|
121
|
+
# that is still enqueuing is live, not stalled.
|
|
122
|
+
def start_stalled_pending(stalled_for:, batch_size:)
|
|
123
|
+
started = 0
|
|
124
|
+
cutoff = Time.current - stalled_for
|
|
125
|
+
BatchEntry.pending
|
|
126
|
+
.where("created_at < ?", cutoff)
|
|
127
|
+
.where(
|
|
128
|
+
"NOT EXISTS (SELECT 1 FROM pgbus_batch_executions e " \
|
|
129
|
+
"WHERE e.batch_id = pgbus_batches.batch_id AND e.created_at >= ?)", cutoff
|
|
130
|
+
)
|
|
131
|
+
.find_each(batch_size: batch_size) do |record|
|
|
132
|
+
BatchEntry.where(batch_id: record.batch_id, status: "pending").update_all(status: "processing")
|
|
133
|
+
Batch.send(:finish_if_needed, Batch.try_finish!(record.batch_id))
|
|
134
|
+
started += 1
|
|
135
|
+
end
|
|
136
|
+
started
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def finish_stalled_processing(batch_size:)
|
|
140
|
+
finished = 0
|
|
141
|
+
BatchEntry.processing.without_executions.find_each(batch_size: batch_size) do |record|
|
|
142
|
+
# Legacy in-flight batches (migrated with an empty executions table)
|
|
143
|
+
# have zero rows but counters short of total_jobs — leave them on
|
|
144
|
+
# the counter path. The true stalls are counters already terminal
|
|
145
|
+
# (finish UPDATE rolled back after callback enqueue) and
|
|
146
|
+
# total_jobs = 0 (enqueue block crashed before enqueuing a job).
|
|
147
|
+
next unless counters_terminal?(record)
|
|
148
|
+
|
|
149
|
+
result = Batch.try_finish!(record.batch_id)
|
|
150
|
+
Batch.send(:finish_if_needed, result)
|
|
151
|
+
finished += 1 if result&.fetch(:just_finished, false)
|
|
152
|
+
end
|
|
153
|
+
finished
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def counters_terminal?(record)
|
|
157
|
+
failures = record.discarded_jobs.to_i
|
|
158
|
+
(record.completed_jobs + failures) == record.total_jobs
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|