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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/app/models/pgbus/batch_entry.rb +78 -6
  4. data/app/models/pgbus/batch_execution.rb +28 -0
  5. data/app/models/pgbus/blocked_execution.rb +2 -2
  6. data/app/models/pgbus/uniqueness_key.rb +50 -4
  7. data/app/views/pgbus/batches/_batches_table.html.erb +3 -3
  8. data/app/views/pgbus/batches/show.html.erb +7 -7
  9. data/config/locales/da.yml +2 -2
  10. data/config/locales/de.yml +2 -2
  11. data/config/locales/en.yml +2 -2
  12. data/config/locales/es.yml +2 -2
  13. data/config/locales/fi.yml +2 -2
  14. data/config/locales/fr.yml +2 -2
  15. data/config/locales/it.yml +2 -2
  16. data/config/locales/ja.yml +2 -2
  17. data/config/locales/nb.yml +2 -2
  18. data/config/locales/nl.yml +2 -2
  19. data/config/locales/pt.yml +2 -2
  20. data/config/locales/sv.yml +2 -2
  21. data/lib/generators/pgbus/add_batch_callback_jobs_generator.rb +47 -0
  22. data/lib/generators/pgbus/add_batch_executions_generator.rb +44 -0
  23. data/lib/generators/pgbus/templates/add_batch_callback_jobs.rb.erb +13 -0
  24. data/lib/generators/pgbus/templates/add_batch_executions.rb.erb +50 -0
  25. data/lib/generators/pgbus/templates/initializer.rb.erb +2 -0
  26. data/lib/generators/pgbus/templates/migration.rb.erb +23 -2
  27. data/lib/pgbus/active_job/adapter.rb +142 -28
  28. data/lib/pgbus/active_job/batch_id.rb +48 -0
  29. data/lib/pgbus/active_job/executor.rb +45 -9
  30. data/lib/pgbus/batch/sweep.rb +163 -0
  31. data/lib/pgbus/batch.rb +448 -63
  32. data/lib/pgbus/bus_record.rb +15 -1
  33. data/lib/pgbus/client.rb +191 -15
  34. data/lib/pgbus/concurrency/blocked_execution.rb +14 -1
  35. data/lib/pgbus/configuration.rb +24 -1
  36. data/lib/pgbus/engine.rb +1 -0
  37. data/lib/pgbus/generators/migration_detector.rb +30 -0
  38. data/lib/pgbus/instrumentation.rb +5 -0
  39. data/lib/pgbus/outbox/poller.rb +6 -7
  40. data/lib/pgbus/process/dispatcher.rb +41 -17
  41. data/lib/pgbus/recurring/schedule.rb +12 -1
  42. data/lib/pgbus/uniqueness.rb +35 -7
  43. data/lib/pgbus/version.rb +1 -1
  44. data/lib/pgbus/web/data_source.rb +47 -9
  45. 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