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
data/lib/pgbus/client.rb
CHANGED
|
@@ -339,6 +339,10 @@ module Pgbus
|
|
|
339
339
|
end
|
|
340
340
|
end
|
|
341
341
|
|
|
342
|
+
def target_queue(queue_name, priority = nil)
|
|
343
|
+
@queue_strategy.target_queue(queue_name, priority)
|
|
344
|
+
end
|
|
345
|
+
|
|
342
346
|
def send_message(queue_name, payload, headers: nil, delay: 0, priority: nil)
|
|
343
347
|
target = @queue_strategy.target_queue(queue_name, priority)
|
|
344
348
|
Instrumentation.instrument("pgbus.client.send_message", queue: target) do
|
|
@@ -381,13 +385,17 @@ module Pgbus
|
|
|
381
385
|
msg_id
|
|
382
386
|
end
|
|
383
387
|
|
|
384
|
-
|
|
385
|
-
|
|
388
|
+
# Bulk enqueue. Routes through the queue strategy exactly like
|
|
389
|
+
# #send_message: under priority routing the bare `pgbus_<queue>` table is
|
|
390
|
+
# never created (only `_p0.._pN` are), so targeting it raised
|
|
391
|
+
# PG::UndefinedTable and no worker would have read it either.
|
|
392
|
+
def send_batch(queue_name, payloads, headers: nil, delay: 0, priority: nil)
|
|
393
|
+
target = @queue_strategy.target_queue(queue_name, priority)
|
|
386
394
|
serialized, serialized_headers = serialize_batch(payloads, headers)
|
|
387
|
-
Instrumentation.instrument("pgbus.client.send_batch", queue:
|
|
395
|
+
Instrumentation.instrument("pgbus.client.send_batch", queue: target, size: payloads.size) do
|
|
388
396
|
with_stale_connection_retry do
|
|
389
397
|
ensure_queue(queue_name)
|
|
390
|
-
synchronized { @pgmq.produce_batch(
|
|
398
|
+
synchronized { @pgmq.produce_batch(target, serialized, headers: serialized_headers, delay: delay) }
|
|
391
399
|
end
|
|
392
400
|
end
|
|
393
401
|
end
|
|
@@ -682,21 +690,55 @@ module Pgbus
|
|
|
682
690
|
# false — the message definitely does not exist
|
|
683
691
|
# nil — could not determine (e.g. queue table missing or unknown error).
|
|
684
692
|
# Callers MUST treat nil as "exists" for safety.
|
|
685
|
-
def
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
693
|
+
def message_in_queue?(queue_name, msg_id:)
|
|
694
|
+
message_exists?(queue_name, msg_id: msg_id)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Look up a queue row by ActiveJob job_id in the JSON payload. Used by the
|
|
698
|
+
# batch sweep: move_to_dead_letter produces a new DLQ msg_id, so the
|
|
699
|
+
# source msg_id is not a DLQ identity.
|
|
700
|
+
#
|
|
701
|
+
# Same tri-state as message_exists?: true / false / nil (unknown). A
|
|
702
|
+
# logical name expands to every physical table the strategy owns (the
|
|
703
|
+
# `_pN` sub-queues under priority routing); an already-prefixed physical
|
|
704
|
+
# name probes itself only.
|
|
705
|
+
def message_with_job_id?(queue_name, job_id:)
|
|
706
|
+
tables = lookup_physical_queue_names(queue_name).map { |name| QueueNameValidator.sanitize!(name) }
|
|
707
|
+
determined = false
|
|
708
|
+
|
|
709
|
+
synchronized do
|
|
710
|
+
with_raw_connection do |conn|
|
|
711
|
+
tables.each do |sanitized|
|
|
712
|
+
present = probe_job_id_presence(conn, sanitized, job_id)
|
|
713
|
+
return true if present == true
|
|
714
|
+
|
|
715
|
+
determined = true unless present == :missing
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
end
|
|
689
719
|
|
|
720
|
+
determined ? false : nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- tri-state: nil means unknown
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# DLQ companion of a logical or already-physical queue name. Does not
|
|
724
|
+
# re-prefix a name that resolve_full_queue_name already expanded.
|
|
725
|
+
def dead_letter_physical_name(queue_name)
|
|
726
|
+
full = resolve_full_queue_name(queue_name)
|
|
727
|
+
# Priority sub-queues share the logical queue's DLQ (`pgbus_default_dlq`),
|
|
728
|
+
# not a per-priority `…_pN_dlq`. Only strip when the strategy actually
|
|
729
|
+
# creates `_pN` tables — a logical queue named `orders_p0` must keep it.
|
|
730
|
+
base = @queue_strategy.priority? ? full.sub(/_p\d+\z/, "") : full
|
|
731
|
+
"#{base}#{Pgbus::DEAD_LETTER_SUFFIX}"
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# Same tri-state as message_exists?: true / false / nil (unknown).
|
|
735
|
+
def message_archived?(queue_name, msg_id:)
|
|
690
736
|
full_name = resolve_full_queue_name(queue_name)
|
|
691
737
|
sanitized = QueueNameValidator.sanitize!(full_name)
|
|
692
738
|
|
|
693
739
|
synchronized do
|
|
694
740
|
with_raw_connection do |conn|
|
|
695
|
-
|
|
696
|
-
msg_id_present?(conn, sanitized, msg_id.to_i)
|
|
697
|
-
else
|
|
698
|
-
uniqueness_key_present?(conn, sanitized, uniqueness_key)
|
|
699
|
-
end
|
|
741
|
+
msg_id_archived?(conn, sanitized, msg_id.to_i)
|
|
700
742
|
end
|
|
701
743
|
end
|
|
702
744
|
rescue ActiveRecord::StatementInvalid => e
|
|
@@ -709,6 +751,64 @@ module Pgbus
|
|
|
709
751
|
nil
|
|
710
752
|
end
|
|
711
753
|
|
|
754
|
+
def message_exists?(queue_name, msg_id: nil, uniqueness_key: nil)
|
|
755
|
+
has_msg_id = !msg_id.nil?
|
|
756
|
+
has_uniqueness_key = !uniqueness_key.nil?
|
|
757
|
+
raise ArgumentError, "pass msg_id, uniqueness_key, or both" unless has_msg_id || has_uniqueness_key
|
|
758
|
+
|
|
759
|
+
tables = lookup_physical_queue_names(queue_name).map { |name| QueueNameValidator.sanitize!(name) }
|
|
760
|
+
determined = false
|
|
761
|
+
|
|
762
|
+
synchronized do
|
|
763
|
+
with_raw_connection do |conn|
|
|
764
|
+
tables.each do |sanitized|
|
|
765
|
+
present = probe_queue_presence(
|
|
766
|
+
conn, sanitized,
|
|
767
|
+
msg_id: has_msg_id ? msg_id.to_i : nil,
|
|
768
|
+
uniqueness_key: has_uniqueness_key ? uniqueness_key : nil
|
|
769
|
+
)
|
|
770
|
+
return true if present == true
|
|
771
|
+
|
|
772
|
+
determined = true unless present == :missing
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
determined ? false : nil # rubocop:disable Style/ReturnNilInPredicateMethodDefinition -- tri-state: nil means unknown
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# Which uniqueness keys currently appear in any live PGMQ queue payload.
|
|
781
|
+
# Used by the dispatcher reaper for unbound locks (pending / msg_id=0)
|
|
782
|
+
# so it never probes the synthetic `pending` queue (issue #418).
|
|
783
|
+
#
|
|
784
|
+
# Per-queue UndefinedTable is skipped (table dropped between listing and
|
|
785
|
+
# select). Failure to list pgmq.meta — or any non-undefined error — raises;
|
|
786
|
+
# callers must treat that as "unknown" and keep the lock.
|
|
787
|
+
def uniqueness_keys_present(lock_keys)
|
|
788
|
+
keys = Array(lock_keys).compact.map(&:to_s).uniq
|
|
789
|
+
return Set.new if keys.empty?
|
|
790
|
+
|
|
791
|
+
found = Set.new
|
|
792
|
+
synchronized do
|
|
793
|
+
with_raw_connection do |conn|
|
|
794
|
+
names = conn.exec("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
795
|
+
.map { |row| row["queue_name"] }
|
|
796
|
+
names.each do |name|
|
|
797
|
+
break if found.size == keys.size
|
|
798
|
+
|
|
799
|
+
sanitized = begin
|
|
800
|
+
QueueNameValidator.sanitize!(name)
|
|
801
|
+
rescue ArgumentError
|
|
802
|
+
next
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
scan_uniqueness_keys(conn, sanitized, keys, found)
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
found
|
|
810
|
+
end
|
|
811
|
+
|
|
712
812
|
def purge_archive(queue_name, older_than:, batch_size: 1000)
|
|
713
813
|
full_name = config.queue_name(queue_name)
|
|
714
814
|
sanitized = QueueNameValidator.sanitize!(full_name)
|
|
@@ -933,9 +1033,76 @@ module Pgbus
|
|
|
933
1033
|
name.start_with?(prefix) ? name : config.queue_name(name)
|
|
934
1034
|
end
|
|
935
1035
|
|
|
936
|
-
|
|
1036
|
+
# Logical names expand through QueueFactory so a priority queue's _pN
|
|
1037
|
+
# tables are included; already-prefixed physical names stay as-is.
|
|
1038
|
+
def lookup_physical_queue_names(queue_name)
|
|
1039
|
+
name = queue_name.to_s
|
|
1040
|
+
prefix = "#{config.queue_prefix}_"
|
|
1041
|
+
name.start_with?(prefix) ? [name] : @queue_strategy.physical_queue_names(name)
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
def probe_queue_presence(conn, sanitized, msg_id:, uniqueness_key:)
|
|
1045
|
+
if msg_id
|
|
1046
|
+
msg_id_present?(conn, sanitized, msg_id, uniqueness_key: uniqueness_key)
|
|
1047
|
+
else
|
|
1048
|
+
uniqueness_key_present?(conn, sanitized, uniqueness_key)
|
|
1049
|
+
end
|
|
1050
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
1051
|
+
raise unless undefined_table_error?(e)
|
|
1052
|
+
|
|
1053
|
+
:missing
|
|
1054
|
+
rescue StandardError => e
|
|
1055
|
+
raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
|
|
1056
|
+
|
|
1057
|
+
:missing
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
def probe_job_id_presence(conn, sanitized, job_id)
|
|
1061
|
+
job_id_present?(conn, sanitized, job_id)
|
|
1062
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
1063
|
+
raise unless undefined_table_error?(e)
|
|
1064
|
+
|
|
1065
|
+
:missing
|
|
1066
|
+
rescue StandardError => e
|
|
1067
|
+
raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
|
|
1068
|
+
|
|
1069
|
+
:missing
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
def scan_uniqueness_keys(conn, sanitized, keys, found)
|
|
1073
|
+
placeholders = keys.each_index.map { |i| "$#{i + 1}" }.join(", ")
|
|
937
1074
|
result = conn.exec_params(
|
|
938
|
-
"SELECT
|
|
1075
|
+
"SELECT DISTINCT message::jsonb ->> 'pgbus_uniqueness_key' AS k " \
|
|
1076
|
+
"FROM pgmq.q_#{sanitized} " \
|
|
1077
|
+
"WHERE message::jsonb ->> 'pgbus_uniqueness_key' IN (#{placeholders})",
|
|
1078
|
+
keys
|
|
1079
|
+
)
|
|
1080
|
+
result.each { |row| found.add(row["k"]) if row["k"] }
|
|
1081
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
1082
|
+
raise unless undefined_table_error?(e)
|
|
1083
|
+
rescue StandardError => e
|
|
1084
|
+
raise unless defined?(PG::UndefinedTable) && e.is_a?(PG::UndefinedTable)
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
def msg_id_present?(conn, sanitized, msg_id, uniqueness_key: nil)
|
|
1088
|
+
result = if uniqueness_key
|
|
1089
|
+
conn.exec_params(
|
|
1090
|
+
"SELECT 1 FROM pgmq.q_#{sanitized} WHERE msg_id = $1 " \
|
|
1091
|
+
"AND message::jsonb ->> 'pgbus_uniqueness_key' = $2 LIMIT 1",
|
|
1092
|
+
[msg_id, uniqueness_key]
|
|
1093
|
+
)
|
|
1094
|
+
else
|
|
1095
|
+
conn.exec_params(
|
|
1096
|
+
"SELECT 1 FROM pgmq.q_#{sanitized} WHERE msg_id = $1 LIMIT 1",
|
|
1097
|
+
[msg_id]
|
|
1098
|
+
)
|
|
1099
|
+
end
|
|
1100
|
+
result.ntuples.positive?
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
def msg_id_archived?(conn, sanitized, msg_id)
|
|
1104
|
+
result = conn.exec_params(
|
|
1105
|
+
"SELECT 1 FROM pgmq.a_#{sanitized} WHERE msg_id = $1 LIMIT 1",
|
|
939
1106
|
[msg_id]
|
|
940
1107
|
)
|
|
941
1108
|
result.ntuples.positive?
|
|
@@ -950,6 +1117,15 @@ module Pgbus
|
|
|
950
1117
|
result.ntuples.positive?
|
|
951
1118
|
end
|
|
952
1119
|
|
|
1120
|
+
def job_id_present?(conn, sanitized, job_id)
|
|
1121
|
+
result = conn.exec_params(
|
|
1122
|
+
"SELECT 1 FROM pgmq.q_#{sanitized} " \
|
|
1123
|
+
"WHERE message::jsonb ->> 'job_id' = $1 LIMIT 1",
|
|
1124
|
+
[job_id]
|
|
1125
|
+
)
|
|
1126
|
+
result.ntuples.positive?
|
|
1127
|
+
end
|
|
1128
|
+
|
|
953
1129
|
# Detect "relation does not exist" via the underlying PG error type.
|
|
954
1130
|
# Falls back to message matching only if PG::UndefinedTable is undefined
|
|
955
1131
|
# (very old pg gem) — never relies on locale-sensitive text.
|
|
@@ -28,12 +28,25 @@ module Pgbus
|
|
|
28
28
|
# otherwise. This avoids losing a blocked row if enqueue fails.
|
|
29
29
|
def promote_next(concurrency_key, client:, delay: 0)
|
|
30
30
|
released = nil
|
|
31
|
+
msg_id = nil
|
|
31
32
|
Pgbus::BlockedExecution.transaction do
|
|
32
33
|
released = release_next(concurrency_key)
|
|
33
34
|
raise ActiveRecord::Rollback unless released
|
|
34
35
|
|
|
35
36
|
actual_delay = resolve_delay(released[:payload], delay)
|
|
36
|
-
|
|
37
|
+
# Carry the enqueuer's priority through: under priority routing it
|
|
38
|
+
# picks the _pN sub-queue, not just the release order (issue #423).
|
|
39
|
+
msg_id = client.send_message(released[:queue_name], released[:payload],
|
|
40
|
+
delay: actual_delay, priority: released[:priority])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if released && msg_id
|
|
44
|
+
begin
|
|
45
|
+
Batch.backfill_execution(released[:payload], msg_id,
|
|
46
|
+
client.target_queue(released[:queue_name], released[:priority]))
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
Pgbus.logger.warn { "[Pgbus] Batch execution backfill failed after promote: #{e.message}" }
|
|
49
|
+
end
|
|
37
50
|
end
|
|
38
51
|
|
|
39
52
|
!!released
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -75,6 +75,12 @@ module Pgbus
|
|
|
75
75
|
# Pgbus::Process::Dispatcher.
|
|
76
76
|
attr_reader :archive_retention
|
|
77
77
|
|
|
78
|
+
# Batch execution-row sweep + finished-batch cleanup. Retention nil disables
|
|
79
|
+
# cleanup (same sentinel as archive_retention). Sweep interval is required.
|
|
80
|
+
# Stall threshold: how long a pending batch may go without a new execution
|
|
81
|
+
# row (or an orphan row without a msg_id) before the sweep repairs it.
|
|
82
|
+
attr_reader :batch_retention, :batch_sweep_interval, :batch_stall_threshold
|
|
83
|
+
|
|
78
84
|
# Transactional outbox
|
|
79
85
|
attr_accessor :outbox_enabled, :outbox_poll_interval, :outbox_batch_size
|
|
80
86
|
attr_reader :outbox_retention # rubocop:disable Style/AccessorGrouping
|
|
@@ -264,6 +270,9 @@ module Pgbus
|
|
|
264
270
|
@group_mode = nil
|
|
265
271
|
|
|
266
272
|
@archive_retention = 7 * 24 * 3600 # 7 days
|
|
273
|
+
@batch_retention = 7 * 24 * 3600 # 7 days
|
|
274
|
+
@batch_sweep_interval = 300 # 5 minutes
|
|
275
|
+
@batch_stall_threshold = 300 # 5 minutes, solid_queue's stalled_for default
|
|
267
276
|
|
|
268
277
|
@outbox_enabled = false
|
|
269
278
|
@outbox_poll_interval = 1.0
|
|
@@ -818,7 +827,9 @@ module Pgbus
|
|
|
818
827
|
|
|
819
828
|
# Interval knobs: positive Numeric, never nil (mirror polling_interval).
|
|
820
829
|
{ dispatch_interval: dispatch_interval, outbox_poll_interval: outbox_poll_interval,
|
|
821
|
-
recurring_schedule_interval: recurring_schedule_interval
|
|
830
|
+
recurring_schedule_interval: recurring_schedule_interval,
|
|
831
|
+
batch_sweep_interval: batch_sweep_interval,
|
|
832
|
+
batch_stall_threshold: batch_stall_threshold }.each do |name, value|
|
|
822
833
|
raise Pgbus::ConfigurationError, "#{name} must be > 0" unless value.is_a?(Numeric) && value.positive?
|
|
823
834
|
end
|
|
824
835
|
|
|
@@ -1170,6 +1181,18 @@ module Pgbus
|
|
|
1170
1181
|
@archive_retention = coerce_duration!(value, :archive_retention)
|
|
1171
1182
|
end
|
|
1172
1183
|
|
|
1184
|
+
def batch_retention=(value)
|
|
1185
|
+
@batch_retention = coerce_duration!(value, :batch_retention)
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
def batch_sweep_interval=(value)
|
|
1189
|
+
@batch_sweep_interval = coerce_duration!(value, :batch_sweep_interval)
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
def batch_stall_threshold=(value)
|
|
1193
|
+
@batch_stall_threshold = coerce_duration!(value, :batch_stall_threshold)
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1173
1196
|
def outbox_retention=(value)
|
|
1174
1197
|
@outbox_retention = coerce_duration!(value, :outbox_retention)
|
|
1175
1198
|
end
|
data/lib/pgbus/engine.rb
CHANGED
|
@@ -78,6 +78,8 @@ module Pgbus
|
|
|
78
78
|
add_recurring: "pgbus:add_recurring",
|
|
79
79
|
add_failed_events_index: "pgbus:add_failed_events_index",
|
|
80
80
|
add_processed_event_completion: "pgbus:add_processed_event_completion",
|
|
81
|
+
add_batch_executions: "pgbus:add_batch_executions",
|
|
82
|
+
add_batch_callback_jobs: "pgbus:add_batch_callback_jobs",
|
|
81
83
|
tune_autovacuum: "pgbus:tune_autovacuum",
|
|
82
84
|
tune_fillfactor: "pgbus:tune_fillfactor"
|
|
83
85
|
}.freeze
|
|
@@ -97,6 +99,8 @@ module Pgbus
|
|
|
97
99
|
add_recurring: "recurring tasks + executions tables",
|
|
98
100
|
add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)",
|
|
99
101
|
add_processed_event_completion: "completed_at on pgbus_processed_events (two-phase idempotency claim)",
|
|
102
|
+
add_batch_executions: "batch execution-row tracking (self-healing completion, on_failure rename)",
|
|
103
|
+
add_batch_callback_jobs: "batch callback-job columns (configured ActiveJob instances as batch callbacks)",
|
|
100
104
|
tune_autovacuum: "autovacuum tuning for PGMQ queue and archive tables",
|
|
101
105
|
tune_fillfactor: "fillfactor=70 on PGMQ queue tables (reduces page density during update churn)"
|
|
102
106
|
}.freeze
|
|
@@ -123,6 +127,8 @@ module Pgbus
|
|
|
123
127
|
*recurring_migrations,
|
|
124
128
|
*failed_events_index_migrations,
|
|
125
129
|
*processed_event_completion_migrations,
|
|
130
|
+
*batch_executions_migrations,
|
|
131
|
+
*batch_callback_jobs_migrations,
|
|
126
132
|
*autovacuum_migrations,
|
|
127
133
|
*fillfactor_migrations
|
|
128
134
|
]
|
|
@@ -223,6 +229,30 @@ module Pgbus
|
|
|
223
229
|
[:add_processed_event_completion]
|
|
224
230
|
end
|
|
225
231
|
|
|
232
|
+
# Execution-row tracking plus the on_discard → on_failure column rename
|
|
233
|
+
# ship as one upgrade. Either a missing executions table or leftover
|
|
234
|
+
# pre-rename columns means this generator still needs to run.
|
|
235
|
+
def batch_executions_migrations
|
|
236
|
+
return [] unless table_exists?("pgbus_batches")
|
|
237
|
+
|
|
238
|
+
batches_columns = column_names("pgbus_batches")
|
|
239
|
+
migrated = table_exists?("pgbus_batch_executions") &&
|
|
240
|
+
batches_columns.include?("on_failure_class") &&
|
|
241
|
+
!batches_columns.include?("discarded_jobs")
|
|
242
|
+
return [] if migrated
|
|
243
|
+
|
|
244
|
+
[:add_batch_executions]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Configured callback instances (on_finish: MyJob.new.set(...)) need
|
|
248
|
+
# somewhere to store the serialized job — issue #415.
|
|
249
|
+
def batch_callback_jobs_migrations
|
|
250
|
+
return [] unless table_exists?("pgbus_batches")
|
|
251
|
+
return [] if column_names("pgbus_batches").include?("on_finish_job")
|
|
252
|
+
|
|
253
|
+
[:add_batch_callback_jobs]
|
|
254
|
+
end
|
|
255
|
+
|
|
226
256
|
# Autovacuum tuning: check if any PGMQ queue table already has
|
|
227
257
|
# custom autovacuum settings applied. If not, queue the migration.
|
|
228
258
|
def autovacuum_migrations
|
|
@@ -25,6 +25,11 @@ module Pgbus
|
|
|
25
25
|
# pgbus.consumer.recycle — consumer hit a recycle threshold (same payload shape as pgbus.worker.recycle)
|
|
26
26
|
# pgbus.serializer.serialize — job/event serialization
|
|
27
27
|
# pgbus.serializer.deserialize — job/event deserialization
|
|
28
|
+
# pgbus.batch_finished — batch flipped to finished
|
|
29
|
+
# payload: batch_id, total_jobs, completed_jobs, failed_jobs
|
|
30
|
+
# pgbus.batch_sweep — dispatcher stalled-batch sweep
|
|
31
|
+
# payload: stalled_for, stale_executions, orphan_rows,
|
|
32
|
+
# started_batches, finished_batches
|
|
28
33
|
#
|
|
29
34
|
module Instrumentation
|
|
30
35
|
module_function
|
data/lib/pgbus/outbox/poller.rb
CHANGED
|
@@ -102,12 +102,12 @@ module Pgbus
|
|
|
102
102
|
return 0 if entries.empty?
|
|
103
103
|
|
|
104
104
|
succeeded = 0
|
|
105
|
-
entries.group_by { |e| [e.queue_name, e.priority, e.delay || 0] }.each do |(queue,
|
|
105
|
+
entries.group_by { |e| [e.queue_name, e.priority, e.delay || 0] }.each do |(queue, priority, delay), group|
|
|
106
106
|
payloads = group.map(&:payload)
|
|
107
107
|
headers = group.map(&:headers)
|
|
108
108
|
headers = nil if headers.all?(&:blank?)
|
|
109
109
|
|
|
110
|
-
Pgbus.client.send_batch(queue, payloads, headers: headers, delay: delay)
|
|
110
|
+
Pgbus.client.send_batch(queue, payloads, headers: headers, delay: delay, priority: priority)
|
|
111
111
|
now = Time.current
|
|
112
112
|
group.each { |e| e.update!(published_at: now) }
|
|
113
113
|
succeeded += group.size
|
|
@@ -119,16 +119,15 @@ module Pgbus
|
|
|
119
119
|
succeeded
|
|
120
120
|
end
|
|
121
121
|
|
|
122
|
-
# Fallback for individual publishing when a batch fails.
|
|
123
|
-
#
|
|
124
|
-
# (base queue only), ensuring consistent queue placement regardless
|
|
125
|
-
# of whether the batch or fallback path is used.
|
|
122
|
+
# Fallback for individual publishing when a batch fails. Passes the same
|
|
123
|
+
# priority as the batch path so queue placement is identical either way.
|
|
126
124
|
def publish_single_queue(entry)
|
|
127
125
|
Pgbus.client.send_message(
|
|
128
126
|
entry.queue_name,
|
|
129
127
|
entry.payload,
|
|
130
128
|
headers: entry.headers,
|
|
131
|
-
delay: entry.delay || 0
|
|
129
|
+
delay: entry.delay || 0,
|
|
130
|
+
priority: entry.priority
|
|
132
131
|
)
|
|
133
132
|
entry.update!(published_at: Time.current)
|
|
134
133
|
true
|
|
@@ -51,6 +51,7 @@ module Pgbus
|
|
|
51
51
|
# Enumerated so set_maintenance_timestamp can validate its argument.
|
|
52
52
|
MAINTENANCE_TIMESTAMPS = %i[
|
|
53
53
|
@last_cleanup_at @last_reap_at @last_concurrency_at @last_batch_cleanup_at
|
|
54
|
+
@last_batch_sweep_at
|
|
54
55
|
@last_recurring_cleanup_at @last_archive_compaction_at @last_stream_archive_compaction_at
|
|
55
56
|
@last_outbox_cleanup_at @last_job_lock_cleanup_at @last_stats_cleanup_at
|
|
56
57
|
@last_orphan_stream_sweep_at @last_table_maintenance_at
|
|
@@ -107,7 +108,7 @@ module Pgbus
|
|
|
107
108
|
|
|
108
109
|
# Test seam: set one of the @last_*_at maintenance timestamps so a task
|
|
109
110
|
# becomes (or stops being) due on the next run_maintenance. The timestamps
|
|
110
|
-
# are per-task monotonic clocks with no constructor injection point (all
|
|
111
|
+
# are per-task monotonic clocks with no constructor injection point (all
|
|
111
112
|
# default to monotonic_now at construction), so a post-construction setter
|
|
112
113
|
# is the minimal way to drive the due/not-due logic deterministically.
|
|
113
114
|
# ivar must be one of the recognized @last_*_at names.
|
|
@@ -233,6 +234,7 @@ module Pgbus
|
|
|
233
234
|
results << run_if_due(now, :@last_reap_at, REAP_INTERVAL) { reap_stale_processes }
|
|
234
235
|
results << run_if_due(now, :@last_concurrency_at, CONCURRENCY_INTERVAL) { cleanup_concurrency }
|
|
235
236
|
results << run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
|
|
237
|
+
results << run_if_due(now, :@last_batch_sweep_at, config.batch_sweep_interval) { sweep_stalled_batches }
|
|
236
238
|
results << run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
|
|
237
239
|
results << run_if_due(now, :@last_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { compact_archives }
|
|
238
240
|
results << run_if_due(now, :@last_stream_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { prune_stream_archives }
|
|
@@ -373,13 +375,23 @@ module Pgbus
|
|
|
373
375
|
end
|
|
374
376
|
|
|
375
377
|
def cleanup_batches
|
|
376
|
-
|
|
378
|
+
retention = config.batch_retention
|
|
379
|
+
return unless retention&.positive?
|
|
380
|
+
|
|
381
|
+
deleted = Batch.cleanup(older_than: Time.current - retention)
|
|
377
382
|
Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} finished batches" } if deleted.positive?
|
|
378
383
|
rescue StandardError => e
|
|
379
384
|
log_maintenance_failure("Batch cleanup", e)
|
|
380
385
|
e
|
|
381
386
|
end
|
|
382
387
|
|
|
388
|
+
def sweep_stalled_batches
|
|
389
|
+
Batch.sweep_stalled
|
|
390
|
+
rescue StandardError => e
|
|
391
|
+
log_maintenance_failure("Batch sweep", e)
|
|
392
|
+
e
|
|
393
|
+
end
|
|
394
|
+
|
|
383
395
|
def cleanup_stats
|
|
384
396
|
retention = config.stats_retention
|
|
385
397
|
return unless retention&.positive?
|
|
@@ -446,34 +458,46 @@ module Pgbus
|
|
|
446
458
|
# younger could be a freshly-acquired lock whose send_message hasn't
|
|
447
459
|
# committed yet.
|
|
448
460
|
threshold = Time.current - (config.visibility_timeout * 2)
|
|
461
|
+
candidates = keys.select { |key| key.created_at && key.created_at < threshold && key.queue_name }
|
|
462
|
+
return 0 if candidates.empty?
|
|
449
463
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
message_gone?(key)
|
|
455
|
-
end
|
|
464
|
+
bound, unbound = candidates.partition { |key| bound_lock?(key) }
|
|
465
|
+
orphaned = bound.select { |key| message_gone?(key) }
|
|
466
|
+
orphaned.concat(gone_unbound_locks(unbound))
|
|
456
467
|
|
|
457
468
|
return 0 if orphaned.empty?
|
|
458
469
|
|
|
459
470
|
UniquenessKey.where(lock_key: orphaned.map(&:lock_key)).delete_all
|
|
460
471
|
end
|
|
461
472
|
|
|
462
|
-
|
|
463
|
-
|
|
473
|
+
def bound_lock?(key)
|
|
474
|
+
!Uniqueness.placeholder?(queue_name: key.queue_name, msg_id: key.msg_id)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Unbound rows (synthetic "pending" queue and/or msg_id=0) must not probe
|
|
478
|
+
# pgmq.q_<prefix>_pending — that table does not exist, message_exists?
|
|
479
|
+
# returns nil, and the reaper would keep the lock forever (issue #418).
|
|
480
|
+
# Scan every live queue for the payload key instead.
|
|
481
|
+
def gone_unbound_locks(unbound)
|
|
482
|
+
return [] if unbound.empty?
|
|
483
|
+
|
|
484
|
+
present = Pgbus.client.uniqueness_keys_present(unbound.map(&:lock_key))
|
|
485
|
+
unbound.reject { |key| present.include?(key.lock_key) }
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Returns true if the message referenced by this bound lock is definitely
|
|
489
|
+
# gone from the queue. Returns false otherwise (message present, or unknown).
|
|
464
490
|
#
|
|
465
491
|
# Routes through Pgbus::Client#message_exists? so all PGMQ access stays
|
|
466
492
|
# behind the client interface. The client returns nil when it can't
|
|
467
493
|
# determine the answer (queue table missing, etc.); we treat that as
|
|
468
494
|
# "still here" — the reaper must NEVER delete a lock when in doubt.
|
|
469
495
|
def message_gone?(key)
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
end
|
|
476
|
-
|
|
496
|
+
result = Pgbus.client.message_exists?(
|
|
497
|
+
key.queue_name,
|
|
498
|
+
msg_id: key.msg_id.to_i,
|
|
499
|
+
uniqueness_key: key.lock_key
|
|
500
|
+
)
|
|
477
501
|
result == false
|
|
478
502
|
rescue StandardError => e
|
|
479
503
|
Pgbus.logger.warn { "[Pgbus] Reap check failed for #{key.lock_key}: #{e.message}" }
|
|
@@ -29,7 +29,8 @@ module Pgbus
|
|
|
29
29
|
payload = inject_uniqueness_metadata(task, payload)
|
|
30
30
|
|
|
31
31
|
Pgbus.client.ensure_queue(queue)
|
|
32
|
-
Pgbus.client.send_message(queue, payload, headers: headers)
|
|
32
|
+
msg_id = Pgbus.client.send_message(queue, payload, headers: headers)
|
|
33
|
+
bind_uniqueness_lock(acquired_key, queue, msg_id)
|
|
33
34
|
|
|
34
35
|
Pgbus.logger.info do
|
|
35
36
|
"[Pgbus] Enqueued recurring task #{task.key} (#{task.class_name || task.command}) " \
|
|
@@ -159,6 +160,16 @@ module Pgbus
|
|
|
159
160
|
:already_locked # Fail closed — skip enqueue when lock check errors
|
|
160
161
|
end
|
|
161
162
|
|
|
163
|
+
# Point the pre-produce lock at the real PGMQ msg_id. Fail-soft: a bind
|
|
164
|
+
# error must not roll back an already-produced message (issue #418).
|
|
165
|
+
def bind_uniqueness_lock(key, queue, msg_id)
|
|
166
|
+
return unless key.is_a?(String)
|
|
167
|
+
|
|
168
|
+
Uniqueness.bind_lock(key, queue_name: queue, msg_id: msg_id)
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
Pgbus.logger.warn { "[Pgbus] Uniqueness bind failed: #{e.message}" }
|
|
171
|
+
end
|
|
172
|
+
|
|
162
173
|
# Release a uniqueness lock. Safe to call with nil or :already_locked.
|
|
163
174
|
def release_uniqueness_lock(key)
|
|
164
175
|
return if key.nil? || key == :already_locked
|
data/lib/pgbus/uniqueness.rb
CHANGED
|
@@ -10,9 +10,10 @@ module Pgbus
|
|
|
10
10
|
# at any time — from enqueue through completion.
|
|
11
11
|
#
|
|
12
12
|
# Lock lifecycle (advisory lock + thin lookup table):
|
|
13
|
-
# 1. Enqueue:
|
|
14
|
-
#
|
|
15
|
-
# The lock row lives as long as the job is in the queue
|
|
13
|
+
# 1. Enqueue: INSERT INTO pgbus_uniqueness_keys ON CONFLICT DO NOTHING
|
|
14
|
+
# (logical queue, msg_id=0). After send_message, bind! writes the
|
|
15
|
+
# real msg_id. The lock row lives as long as the job is in the queue
|
|
16
|
+
# or executing.
|
|
16
17
|
# 2. Execution: PGMQ's visibility timeout is the execution lock —
|
|
17
18
|
# no separate claim_for_execution step needed.
|
|
18
19
|
# 3. Completion/DLQ: DELETE FROM pgbus_uniqueness_keys WHERE lock_key = ?.
|
|
@@ -43,6 +44,10 @@ module Pgbus
|
|
|
43
44
|
|
|
44
45
|
METADATA_KEY = "pgbus_uniqueness_key"
|
|
45
46
|
STRATEGY_KEY = "pgbus_uniqueness_strategy"
|
|
47
|
+
# Synthetic queue stored before produce when the caller does not know the
|
|
48
|
+
# real queue yet. Never a live PGMQ queue — the reaper must not probe
|
|
49
|
+
# pgmq.q_<prefix>_pending for these rows (issue #418).
|
|
50
|
+
PLACEHOLDER_QUEUE = "pending"
|
|
46
51
|
|
|
47
52
|
VALID_STRATEGIES = %i[until_executed while_executing].freeze
|
|
48
53
|
VALID_CONFLICTS = %i[reject discard log].freeze
|
|
@@ -174,19 +179,25 @@ module Pgbus
|
|
|
174
179
|
UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: msg_id)
|
|
175
180
|
else
|
|
176
181
|
# Pre-produce check: use advisory lock + ON CONFLICT
|
|
177
|
-
UniquenessKey.acquire!(key, queue_name: queue_name ||
|
|
182
|
+
UniquenessKey.acquire!(key, queue_name: queue_name || PLACEHOLDER_QUEUE, msg_id: msg_id || 0)
|
|
178
183
|
end
|
|
179
184
|
acquired ? :acquired : :locked
|
|
180
185
|
end
|
|
181
186
|
|
|
182
187
|
# Acquire the uniqueness lock at execution time (:while_executing only).
|
|
183
188
|
# Returns true if acquired, false if another instance is running.
|
|
184
|
-
|
|
189
|
+
#
|
|
190
|
+
# Bound to the message being executed so a row left behind by a crashed
|
|
191
|
+
# attempt of the SAME message is re-acquired on retry instead of locking
|
|
192
|
+
# that message out until it dead-letters (issue #423). Callers without a
|
|
193
|
+
# message (legacy signature) fall back to the unbound placeholder.
|
|
194
|
+
def acquire_execution_lock(key, payload, msg_id: 0, queue_name: nil)
|
|
185
195
|
strategy = extract_strategy(payload)
|
|
186
196
|
return true unless strategy == :while_executing
|
|
187
197
|
|
|
188
|
-
queue_name
|
|
189
|
-
UniquenessKey.acquire!(key, queue_name: queue_name, msg_id:
|
|
198
|
+
queue_name ||= payload["queue_name"] || "unknown"
|
|
199
|
+
UniquenessKey.acquire!(key, queue_name: queue_name, msg_id: msg_id.to_i,
|
|
200
|
+
reacquire_same_message: msg_id.to_i.positive?)
|
|
190
201
|
end
|
|
191
202
|
|
|
192
203
|
# Release the uniqueness lock after execution completes.
|
|
@@ -195,6 +206,23 @@ module Pgbus
|
|
|
195
206
|
|
|
196
207
|
UniquenessKey.release!(key)
|
|
197
208
|
end
|
|
209
|
+
|
|
210
|
+
# Point a pre-produce lock at the real queue + PGMQ msg_id after send.
|
|
211
|
+
# No-op when key is nil or msg_id is not a positive integer (the reaper
|
|
212
|
+
# treats those rows as unbound and scans live queues instead).
|
|
213
|
+
def bind_lock(key, queue_name:, msg_id:)
|
|
214
|
+
return unless key
|
|
215
|
+
return unless msg_id.to_i.positive?
|
|
216
|
+
|
|
217
|
+
UniquenessKey.bind!(key, queue_name: queue_name, msg_id: msg_id)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# True when the uniqueness row is not yet bound to a real PGMQ message.
|
|
221
|
+
# Covers the synthetic pending queue and any msg_id=0 placeholder
|
|
222
|
+
# (recurring scheduler, bind-not-yet-run, bind failure).
|
|
223
|
+
def placeholder?(queue_name:, msg_id:)
|
|
224
|
+
msg_id.to_i <= 0 || queue_name.to_s == PLACEHOLDER_QUEUE
|
|
225
|
+
end
|
|
198
226
|
end
|
|
199
227
|
end
|
|
200
228
|
end
|