pgbus 0.21.4 → 0.23.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +37 -15
  4. data/Rakefile +15 -2
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +17 -3
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +4 -4
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -1
  8. data/app/controllers/pgbus/dead_letter_controller.rb +3 -1
  9. data/app/controllers/pgbus/jobs_controller.rb +2 -1
  10. data/app/models/pgbus/blocked_execution.rb +3 -2
  11. data/app/models/pgbus/queue_state.rb +6 -0
  12. data/app/models/pgbus/uniqueness_key.rb +11 -0
  13. data/config/locales/da.yml +1 -0
  14. data/config/locales/de.yml +1 -0
  15. data/config/locales/en.yml +1 -0
  16. data/config/locales/es.yml +1 -0
  17. data/config/locales/fi.yml +1 -0
  18. data/config/locales/fr.yml +1 -0
  19. data/config/locales/it.yml +1 -0
  20. data/config/locales/ja.yml +1 -0
  21. data/config/locales/nb.yml +1 -0
  22. data/config/locales/nl.yml +1 -0
  23. data/config/locales/pt.yml +1 -0
  24. data/config/locales/sv.yml +1 -0
  25. data/lib/generators/pgbus/fold_queue_name_case_generator.rb +57 -0
  26. data/lib/generators/pgbus/templates/fold_queue_name_case.rb.erb +12 -0
  27. data/lib/generators/pgbus/templates/tune_autovacuum.rb.erb +2 -2
  28. data/lib/generators/pgbus/templates/tune_fillfactor.rb.erb +1 -1
  29. data/lib/pgbus/active_job/adapter.rb +3 -2
  30. data/lib/pgbus/active_job/executor.rb +197 -46
  31. data/lib/pgbus/active_job/quiet_logs.rb +57 -0
  32. data/lib/pgbus/autovacuum_tuning.rb +2 -2
  33. data/lib/pgbus/batch/sweep.rb +2 -0
  34. data/lib/pgbus/batch.rb +4 -1
  35. data/lib/pgbus/cli/dlq.rb +2 -1
  36. data/lib/pgbus/client/async_commit.rb +149 -0
  37. data/lib/pgbus/client/native_statements.rb +432 -0
  38. data/lib/pgbus/client.rb +156 -20
  39. data/lib/pgbus/concurrency/blocked_execution.rb +10 -1
  40. data/lib/pgbus/configuration.rb +158 -6
  41. data/lib/pgbus/dead_letter_header.rb +3 -0
  42. data/lib/pgbus/doctor.rb +55 -2
  43. data/lib/pgbus/event_bus/handler.rb +2 -0
  44. data/lib/pgbus/failed_event_recorder.rb +5 -2
  45. data/lib/pgbus/generators/migration_detector.rb +29 -2
  46. data/lib/pgbus/instrumentation.rb +16 -1
  47. data/lib/pgbus/metrics/subscriber.rb +27 -1
  48. data/lib/pgbus/process/completer.rb +189 -0
  49. data/lib/pgbus/process/consumer.rb +15 -1
  50. data/lib/pgbus/process/worker.rb +57 -4
  51. data/lib/pgbus/queue_name_fold.rb +145 -0
  52. data/lib/pgbus/queue_name_validator.rb +20 -2
  53. data/lib/pgbus/recurring/schedule.rb +22 -13
  54. data/lib/pgbus/serializer.rb +1 -0
  55. data/lib/pgbus/stat_buffer.rb +67 -26
  56. data/lib/pgbus/streams/cursor.rb +17 -6
  57. data/lib/pgbus/streams/envelope.rb +16 -0
  58. data/lib/pgbus/streams/key.rb +1 -1
  59. data/lib/pgbus/table_maintenance.rb +1 -1
  60. data/lib/pgbus/uniqueness.rb +43 -16
  61. data/lib/pgbus/version.rb +1 -1
  62. data/lib/pgbus/visibility_heartbeat.rb +16 -12
  63. data/lib/pgbus/web/data_source/dead_letter.rb +14 -5
  64. data/lib/pgbus/web/data_source/resend.rb +185 -0
  65. data/lib/pgbus/web/data_source/resend_slot.rb +139 -0
  66. data/lib/pgbus/web/data_source.rb +55 -37
  67. data/lib/pgbus/web/dead_letter_reason.rb +6 -0
  68. data/lib/pgbus/web/streamer/connection.rb +36 -29
  69. data/lib/pgbus/web/streamer/falcon_connection.rb +29 -25
  70. data/lib/pgbus/web/streamer/heartbeat.rb +14 -3
  71. data/lib/pgbus/web/streamer/instance.rb +8 -5
  72. data/lib/pgbus/web/streamer/listener.rb +239 -62
  73. data/lib/pgbus/web/streamer/outbound_pump.rb +6 -4
  74. data/lib/pgbus/web/streamer/registry.rb +55 -29
  75. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +73 -12
  76. data/lib/tasks/pgbus_queues.rake +27 -0
  77. data/sig/pgbus/active_job/adapter.rbs +1 -1
  78. data/sig/pgbus/active_job/quiet_logs.rbs +11 -0
  79. data/sig/pgbus/client.rbs +7 -5
  80. data/sig/pgbus/configuration.rbs +12 -0
  81. data/sig/pgbus/process/completer.rbs +26 -0
  82. metadata +14 -3
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module ActiveJob
5
+ # config.quiet_active_job_logs (issue #618): a worker process stops
6
+ # ActiveJob's per-job "Performing …" / "Performed …" info lines by
7
+ # unsubscribing ActiveJob::LogSubscriber from perform_start.active_job and
8
+ # perform.active_job. The perform listener also writes ActiveJob's
9
+ # "Error performing …" error line, for a job that raised (with its
10
+ # backtrace) and for one a before_perform callback halted, so those go too.
11
+ # An exception that escapes ActiveJob's handlers is still logged by
12
+ # ErrorReporter (class and message at error) and recorded in
13
+ # pgbus_failed_events; one retry_on catches still logs ActiveJob's
14
+ # "Retrying …" line (and "Stopped retrying …" at error when it gives up);
15
+ # a halted job logs nothing.
16
+ # The other ActiveJob events (Enqueued, Retrying, Stopped retrying,
17
+ # Discarded) still log as the host configured them, and the web process is
18
+ # untouched. Swapping
19
+ # ActiveJob::Base.logger instead would reach the web process too, and by
20
+ # default that logger is Rails.logger.
21
+ module QuietLogs
22
+ EVENTS = %w[perform_start.active_job perform.active_job].freeze
23
+
24
+ # Built at load; the applied notifiers are read and written under it.
25
+ @mutex = Mutex.new
26
+ @applied = {}.compare_by_identity
27
+
28
+ class << self
29
+ # Unsubscribes the LogSubscriber listeners once per notifier. Returns
30
+ # true when it did, false when that notifier was already done.
31
+ def apply!(notifier: ::ActiveSupport::Notifications.notifier)
32
+ @mutex.synchronize do
33
+ return false if @applied.key?(notifier)
34
+
35
+ @applied[notifier] = true
36
+ # all_listeners_for, not listeners_for: the latter skips a
37
+ # silenced subscriber (its logger above info), which would come
38
+ # back the moment the level drops.
39
+ EVENTS.each do |name|
40
+ notifier.all_listeners_for(name).each do |listener|
41
+ notifier.unsubscribe(listener) if log_subscriber?(listener)
42
+ end
43
+ end
44
+ true
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # Fanout::Subscribers::Evented exposes #delegate on Rails 7.1 and later.
51
+ def log_subscriber?(listener)
52
+ listener.respond_to?(:delegate) && listener.delegate.is_a?(::ActiveJob::LogSubscriber)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -66,8 +66,8 @@ module Pgbus
66
66
  q RECORD;
67
67
  BEGIN
68
68
  FOR q IN SELECT queue_name FROM pgmq.meta LOOP
69
- EXECUTE format('ALTER TABLE pgmq.q_%I SET (#{settings_clause(QUEUE_SETTINGS)})', q.queue_name);
70
- EXECUTE format('ALTER TABLE pgmq.a_%I SET (#{settings_clause(ARCHIVE_SETTINGS)})', q.queue_name);
69
+ EXECUTE format('ALTER TABLE pgmq.%I SET (#{settings_clause(QUEUE_SETTINGS)})', 'q_' || lower(q.queue_name));
70
+ EXECUTE format('ALTER TABLE pgmq.%I SET (#{settings_clause(ARCHIVE_SETTINGS)})', 'a_' || lower(q.queue_name));
71
71
  END LOOP;
72
72
  END $$;
73
73
  SQL
@@ -47,6 +47,8 @@ module Pgbus
47
47
 
48
48
  dlq = client.dead_letter_physical_name(row.queue_name, prefixed: false)
49
49
  return :failed if client.message_with_job_id?(dlq, job_id: row.job_id, prefixed: false) == true
50
+ # completed_jobs :delete (issue #607) leaves no archive row to find.
51
+ return :completed if Pgbus.configuration.completed_jobs == :delete
50
52
 
51
53
  return :completed if client.message_archived?(row.queue_name, msg_id: row.msg_id, prefixed: false) == true
52
54
 
data/lib/pgbus/batch.rb CHANGED
@@ -425,8 +425,11 @@ module Pgbus
425
425
  end
426
426
 
427
427
  def self.backfill_execution(payload, msg_id, queue_name)
428
- return unless executions_migrated?
429
428
  return unless payload && msg_id
429
+ # Only batch members have an execution row: without this, every plain
430
+ # enqueue paid a no-op UPDATE round trip after the send (issue #603).
431
+ return unless payload[METADATA_KEY]
432
+ return unless executions_migrated?
430
433
 
431
434
  job_id = payload["job_id"]
432
435
  return unless job_id
data/lib/pgbus/cli/dlq.rb CHANGED
@@ -70,7 +70,8 @@ module Pgbus
70
70
  if data_source.retry_dlq_message(detail[:queue_name], detail[:msg_id])
71
71
  puts "Re-enqueued msg_id #{detail[:msg_id]} to #{origin_queue(detail[:queue_name])}."
72
72
  else
73
- warn "Failed to retry msg_id #{detail[:msg_id]}."
73
+ warn "Failed to retry msg_id #{detail[:msg_id]}: it is gone, the retry failed, " \
74
+ "another copy of the job holds its uniqueness key, or its concurrency limit is full."
74
75
  exit 1
75
76
  end
76
77
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Client
5
+ # Asynchronous completion commit (issue #619,
6
+ # `config.completion_synchronous_commit = false`). A completion statement
7
+ # (archive, delete and their batches) runs in its own transaction with
8
+ # synchronous_commit off, so the job is acknowledged without waiting for
9
+ # its WAL flush. The four queries go out in one libpq pipeline with one
10
+ # sync, so it is still one round trip (pg writes each query to the socket
11
+ # as it is sent, but waits for a reply only once, after the sync):
12
+ #
13
+ # BEGIN
14
+ # SET LOCAL synchronous_commit TO off
15
+ # <the prepared completion statement> <- the result the caller gets
16
+ # COMMIT
17
+ #
18
+ # SET LOCAL ends with the transaction, so the pooled connection never
19
+ # keeps the setting, and the statement stays prepared (no planning cost
20
+ # back, issue #605). Only a job's completion asks for it (the executor and
21
+ # the completer pass job_completion: true; the event consumer and the
22
+ # dashboard keep the durable commit), and NativeStatements#exec_native
23
+ # calls it only outside a caller's transaction: a completion inside one is
24
+ # the caller's to commit.
25
+ #
26
+ # A failed query aborts the rest of the pipeline and leaves the explicit
27
+ # BEGIN open in a failed state: the connection leaves pipeline mode, the
28
+ # transaction is rolled back, and the error is raised for run_native's
29
+ # re-run and short-row checks. A statement not yet prepared on this session
30
+ # runs through prepare_and_exec outside the pipeline, committed
31
+ # synchronously, once; the next call pipelines again.
32
+ module AsyncCommit
33
+ ASYNC_COMMIT_BEGIN = "BEGIN"
34
+ ASYNC_COMMIT_OFF = "SET LOCAL synchronous_commit TO off"
35
+ ASYNC_COMMIT_COMMIT = "COMMIT"
36
+ NO_PARAMS = [].freeze
37
+
38
+ private
39
+
40
+ def exec_async_commit(conn, statement, params, prepared)
41
+ results = pipeline_async_commit(conn, statement, params, prepared)
42
+ failed = results.find { |result| result.result_status == PG::PGRES_FATAL_ERROR }
43
+ unless failed
44
+ NativeStatements.note_executed if prepared
45
+ return results[2]
46
+ end
47
+
48
+ rollback_async_commit(conn)
49
+ begin
50
+ failed.check
51
+ rescue PG::InvalidSqlStatementName
52
+ raise unless prepared
53
+
54
+ prepare_and_exec(conn, statement, params)
55
+ end
56
+ end
57
+
58
+ # Sends the four queries and one sync, and reads every result up to the
59
+ # sync: each query answers its result and then nil.
60
+ def pipeline_async_commit(conn, statement, params, prepared)
61
+ conn.enter_pipeline_mode
62
+ begin
63
+ conn.send_query_params(ASYNC_COMMIT_BEGIN, NO_PARAMS)
64
+ conn.send_query_params(ASYNC_COMMIT_OFF, NO_PARAMS)
65
+ if prepared
66
+ conn.send_query_prepared(statement.name, params)
67
+ else
68
+ conn.send_query_params(statement.sql, params)
69
+ end
70
+ conn.send_query_params(ASYNC_COMMIT_COMMIT, NO_PARAMS)
71
+ conn.pipeline_sync
72
+ read_pipeline(conn)
73
+ ensure
74
+ leave_pipeline(conn)
75
+ end
76
+ end
77
+
78
+ def read_pipeline(conn)
79
+ results = []
80
+ ended = false
81
+ loop do
82
+ result = conn.get_result
83
+ if result.nil?
84
+ # One nil closes each query's results; two in a row means libpq
85
+ # has nothing more, so the sync will not come. That session's
86
+ # state cannot be trusted: pgmq-ruby re-runs on a fresh one.
87
+ if ended
88
+ discard_session(conn, "A completion pipeline ended before its sync")
89
+ raise PG::ConnectionBad, "pipeline ended before its sync"
90
+ end
91
+
92
+ ended = true
93
+ next
94
+ end
95
+ ended = false
96
+ break if result.result_status == PG::PGRES_PIPELINE_SYNC
97
+
98
+ results << result
99
+ end
100
+ results
101
+ end
102
+
103
+ # A connection back in the pool in pipeline mode would break the next
104
+ # caller. A lost one is left as it is: pgmq-ruby resets a bad connection
105
+ # at its next checkout, and a reset leaves pipeline mode. One that cannot
106
+ # leave (results left unread by an interrupted read) is reset here.
107
+ def leave_pipeline(conn)
108
+ return if conn.pipeline_status == PG::PQ_PIPELINE_OFF || conn.status == PG::CONNECTION_BAD
109
+
110
+ conn.exit_pipeline_mode
111
+ rescue PG::Error => e
112
+ discard_session(conn, "Could not leave pipeline mode after a completion (#{e.message})")
113
+ end
114
+
115
+ # Resets a session whose pipeline state cannot be trusted. A reset that
116
+ # fails (the server is gone) is logged and left there, so the error the
117
+ # caller is already raising is the one that surfaces; pgmq-ruby resets a
118
+ # bad connection at its next checkout.
119
+ # A reset reconnects with the same conninfo (the read bounds ride in it)
120
+ # but does not re-run the connect lambda, so the :session-mode SETs are
121
+ # applied again here.
122
+ def discard_session(conn, reason)
123
+ Pgbus.logger.warn { "[Pgbus::Client] #{reason}, resetting the connection" }
124
+ begin
125
+ conn.reset
126
+ rescue PG::Error => e
127
+ Pgbus.logger.warn { "[Pgbus::Client] Resetting the connection failed: #{e.message}" }
128
+ return
129
+ end
130
+ reapply_session_gucs(conn)
131
+ end
132
+
133
+ def reapply_session_gucs(conn)
134
+ apply_session_gucs(conn, @session_gucs) if @session_gucs
135
+ rescue PG::Error => e
136
+ Pgbus.logger.warn do
137
+ "[Pgbus::Client] Re-applying the session's database.yml variables after the reset failed: #{e.message}"
138
+ end
139
+ end
140
+
141
+ # The failed BEGIN block (INERROR) is still open after the sync. A failed
142
+ # COMMIT has already ended it (IDLE); a lost connection reports UNKNOWN
143
+ # and has nothing to roll back.
144
+ def rollback_async_commit(conn)
145
+ conn.exec("ROLLBACK") if [PG::PQTRANS_INERROR, PG::PQTRANS_INTRANS].include?(conn.transaction_status)
146
+ end
147
+ end
148
+ end
149
+ end