pgbus 0.17.1 → 0.17.3

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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +83 -13
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
  8. data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
  9. data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
  10. data/app/controllers/pgbus/application_controller.rb +5 -2
  11. data/app/controllers/pgbus/batches_controller.rb +4 -1
  12. data/app/controllers/pgbus/dashboard_controller.rb +20 -6
  13. data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
  14. data/app/controllers/pgbus/events_controller.rb +52 -3
  15. data/app/controllers/pgbus/frontends_controller.rb +25 -11
  16. data/app/controllers/pgbus/insights_controller.rb +19 -1
  17. data/app/controllers/pgbus/jobs_controller.rb +45 -26
  18. data/app/controllers/pgbus/locks_controller.rb +7 -2
  19. data/app/controllers/pgbus/outbox_controller.rb +4 -1
  20. data/app/controllers/pgbus/processes_controller.rb +3 -1
  21. data/app/controllers/pgbus/queues_controller.rb +16 -4
  22. data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
  23. data/app/frontend/pgbus/application.min.js +2 -0
  24. data/app/frontend/pgbus/application.min.js.map +1 -0
  25. data/app/frontend/pgbus/modules/charts.js +137 -77
  26. data/app/frontend/pgbus/modules/charts.min.js +2 -0
  27. data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
  28. data/app/frontend/pgbus/style.css +1 -1
  29. data/app/frontend/pgbus/tailwind.css +18 -0
  30. data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
  31. data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
  32. data/app/helpers/pgbus/application_helper.rb +155 -113
  33. data/app/helpers/pgbus/button_helper.rb +88 -0
  34. data/app/helpers/pgbus/dashboard_helper.rb +157 -0
  35. data/app/helpers/pgbus/events_helper.rb +40 -0
  36. data/app/helpers/pgbus/insights_helper.rb +39 -0
  37. data/app/helpers/pgbus/processes_helper.rb +79 -0
  38. data/app/views/layouts/pgbus/application.html.erb +0 -1
  39. data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
  40. data/app/views/pgbus/batches/show.html.erb +3 -4
  41. data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
  42. data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
  43. data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
  44. data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
  45. data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
  46. data/app/views/pgbus/dashboard/show.html.erb +1 -1
  47. data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
  48. data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
  49. data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
  50. data/app/views/pgbus/dead_letter/index.html.erb +6 -9
  51. data/app/views/pgbus/dead_letter/show.html.erb +37 -21
  52. data/app/views/pgbus/events/_list.html.erb +55 -0
  53. data/app/views/pgbus/events/_processed_table.html.erb +45 -0
  54. data/app/views/pgbus/events/_replay.html.erb +10 -0
  55. data/app/views/pgbus/events/_row.html.erb +162 -0
  56. data/app/views/pgbus/events/_tabs.html.erb +13 -0
  57. data/app/views/pgbus/events/index.html.erb +27 -48
  58. data/app/views/pgbus/events/show.html.erb +25 -7
  59. data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
  60. data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
  61. data/app/views/pgbus/insights/_status_table.html.erb +19 -0
  62. data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
  63. data/app/views/pgbus/insights/show.html.erb +83 -100
  64. data/app/views/pgbus/jobs/_list.html.erb +64 -0
  65. data/app/views/pgbus/jobs/_row.html.erb +107 -0
  66. data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
  67. data/app/views/pgbus/jobs/index.html.erb +10 -10
  68. data/app/views/pgbus/jobs/show.html.erb +18 -20
  69. data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
  70. data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
  71. data/app/views/pgbus/outbox/index.html.erb +30 -27
  72. data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
  73. data/app/views/pgbus/processes/_row.html.erb +48 -0
  74. data/app/views/pgbus/processes/_status.html.erb +4 -0
  75. data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
  76. data/app/views/pgbus/queues/show.html.erb +69 -118
  77. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
  78. data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
  79. data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
  80. data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
  81. data/app/views/pgbus/shared/_pager.html.erb +34 -0
  82. data/config/i18n-tasks.yml +11 -2
  83. data/config/locales/da.yml +405 -113
  84. data/config/locales/de.yml +405 -113
  85. data/config/locales/en.yml +405 -113
  86. data/config/locales/es.yml +404 -112
  87. data/config/locales/fi.yml +404 -112
  88. data/config/locales/fr.yml +406 -114
  89. data/config/locales/it.yml +405 -113
  90. data/config/locales/ja.yml +405 -113
  91. data/config/locales/nb.yml +405 -113
  92. data/config/locales/nl.yml +405 -113
  93. data/config/locales/pt.yml +406 -114
  94. data/config/locales/sv.yml +404 -112
  95. data/config/routes.rb +1 -1
  96. data/lib/generators/pgbus/add_presence_generator.rb +2 -1
  97. data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
  98. data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
  99. data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
  100. data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
  101. data/lib/pgbus/active_job/executor.rb +16 -6
  102. data/lib/pgbus/cli/dlq.rb +32 -5
  103. data/lib/pgbus/client.rb +4 -2
  104. data/lib/pgbus/configuration/presence_settings.rb +103 -0
  105. data/lib/pgbus/configuration.rb +51 -2
  106. data/lib/pgbus/dead_letter_header.rb +129 -0
  107. data/lib/pgbus/doctor.rb +8 -4
  108. data/lib/pgbus/engine.rb +13 -7
  109. data/lib/pgbus/event_bus/handler.rb +18 -11
  110. data/lib/pgbus/event_bus/registry.rb +6 -0
  111. data/lib/pgbus/failed_event_recorder.rb +34 -0
  112. data/lib/pgbus/generators/migration_detector.rb +9 -1
  113. data/lib/pgbus/instrumentation.rb +2 -1
  114. data/lib/pgbus/mcp/base_tool.rb +25 -0
  115. data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
  116. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
  117. data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
  118. data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
  119. data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
  120. data/lib/pgbus/outbox/poller.rb +1 -1
  121. data/lib/pgbus/process/consumer.rb +53 -3
  122. data/lib/pgbus/process/dispatcher.rb +1 -1
  123. data/lib/pgbus/process/supervisor.rb +32 -11
  124. data/lib/pgbus/process/worker.rb +45 -8
  125. data/lib/pgbus/recurring/scheduler.rb +2 -1
  126. data/lib/pgbus/streams/presence.rb +317 -81
  127. data/lib/pgbus/version.rb +1 -1
  128. data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
  129. data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
  130. data/lib/pgbus/web/data_source/event_list.rb +161 -0
  131. data/lib/pgbus/web/data_source/job_list.rb +323 -0
  132. data/lib/pgbus/web/data_source/list_counts.rb +80 -0
  133. data/lib/pgbus/web/data_source/process_list.rb +69 -0
  134. data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
  135. data/lib/pgbus/web/data_source.rb +132 -254
  136. data/lib/pgbus/web/dead_letter_reason.rb +81 -0
  137. data/lib/pgbus/web/event_state.rb +71 -0
  138. data/lib/pgbus/web/health_app.rb +7 -3
  139. data/lib/pgbus/web/health_verdict.rb +78 -0
  140. data/lib/pgbus/web/insights_charts.rb +84 -0
  141. data/lib/pgbus/web/job_state.rb +111 -0
  142. data/lib/pgbus/web/process_activity.rb +97 -0
  143. data/lib/pgbus/web/process_state.rb +187 -0
  144. data/lib/pgbus/web/queue_summary.rb +104 -0
  145. data/lib/pgbus/web/stream_app.rb +18 -6
  146. data/lib/pgbus/web/streamer/connection.rb +7 -1
  147. data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
  148. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  149. data/lib/pgbus/web/streamer/instance.rb +2 -1
  150. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  151. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
  152. data/lib/pgbus/web/time_format.rb +128 -0
  153. metadata +55 -4
  154. data/app/views/pgbus/events/_pending_table.html.erb +0 -169
  155. data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
  156. data/app/views/pgbus/jobs/_failed_table.html.erb +0 -76
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ # Turns one queue's facts into the sentences an operator reads at the top
6
+ # of its page: why it is paused, who drains it, what is waiting, and which
7
+ # priority level it is (issue #491).
8
+ #
9
+ # Pure like JobState: it takes queue_detail, queue_pause_state and
10
+ # queue_drainers as given and does no I/O. Each Line carries a translation
11
+ # key under pgbus.queues.show.summary, raw args (Times under :ago/:time,
12
+ # seconds under :age) and a tone the view maps to colours.
13
+ module QueueSummary
14
+ Line = Data.define(:key, :args, :tone)
15
+
16
+ PRIORITY_LEVEL = /_p(\d+)\z/
17
+ FAILURES = /(\d+) consecutive failures/
18
+ BACKLOG_KEYS = %w[backlog backlog_parked_only empty].freeze
19
+
20
+ module_function
21
+
22
+ def present(detail, pause_state, drainers, max_retries:)
23
+ name = detail[:name].to_s
24
+ dlq = name.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
25
+ [
26
+ pause_line(pause_state),
27
+ drain_line(dlq, detail, drainers, max_retries),
28
+ (backlog_line(detail) unless dlq || drainers[:stream]),
29
+ priority_line(drainers)
30
+ ].compact
31
+ end
32
+
33
+ def pause_line(state)
34
+ return unless state[:paused]
35
+
36
+ if state[:resumes_at]
37
+ failures = state[:reason].to_s[FAILURES, 1].to_i
38
+ return line("paused_circuit_breaker", :yellow, ago: state[:paused_at], failures: failures,
39
+ time: state[:resumes_at], trip: state[:trip_count].to_i)
40
+ end
41
+ return line("paused_operator_no_reason", :yellow, ago: state[:paused_at]) if state[:reason].to_s.strip.empty?
42
+
43
+ line("paused_operator", :yellow, ago: state[:paused_at], reason: state[:reason])
44
+ end
45
+
46
+ # First match wins, as in JobState#ready_overlay: what the queue is
47
+ # explains everything below it, then whether anything is configured to
48
+ # drain it, and only then whether a worker is alive.
49
+ def drain_line(dlq, detail, drainers, max_retries)
50
+ return line("dlq", :gray, logical: drainers[:logical], max: max_retries) if dlq
51
+ return line("stream", :gray) if drainers[:stream]
52
+
53
+ if drainers[:handler]
54
+ consumers = drainers[:live_consumers].to_i
55
+ return line("handler", :gray, count: consumers) if consumers.positive?
56
+
57
+ return line("no_consumers", :red, count: detail[:queue_visible_length].to_i)
58
+ end
59
+
60
+ capsules = Array(drainers[:capsules])
61
+ live = drainers[:live_workers].to_i
62
+ if capsules.empty? && !drainers[:wildcard]
63
+ # Capsules from `pgbus --queues` never reach the web process's
64
+ # configuration: a healthy heartbeat for this queue outranks it.
65
+ return live.zero? ? line("not_drained", :red) : line("drained_live", :gray, count: live)
66
+ end
67
+ return line("no_workers", :red, count: detail[:queue_visible_length].to_i) if live.zero?
68
+ return line("drained_wildcard", :gray, count: live) if capsules.empty?
69
+
70
+ line("drained_by", :gray, capsules: capsules.join(", "), count: live)
71
+ end
72
+
73
+ def backlog_line(detail)
74
+ visible = detail[:queue_visible_length].to_i
75
+ parked = detail[:parked_length].to_i
76
+ if visible.positive?
77
+ line("backlog", :gray, visible: visible, age: detail[:oldest_claimable_age_sec], parked: parked)
78
+ elsif parked.positive?
79
+ line("backlog_parked_only", :gray, parked: parked)
80
+ else
81
+ line("empty", :gray)
82
+ end
83
+ end
84
+
85
+ def priority_line(drainers)
86
+ level = drainers[:priority_level]
87
+ return unless level
88
+
89
+ line("priority_level", :gray, level: level, logical: drainers[:logical])
90
+ end
91
+
92
+ # The one line a queue row has room for (the home page's queues table,
93
+ # issue #492): whatever needs attention (a pause, nothing draining it),
94
+ # else what is waiting, else what the queue is.
95
+ def headline(lines)
96
+ lines.find { |l| l.tone != :gray } || lines.find { |l| BACKLOG_KEYS.include?(l.key) } || lines.first
97
+ end
98
+
99
+ def line(key, tone, **args)
100
+ Line.new(key: key, args: args, tone: tone)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -31,6 +31,9 @@ module Pgbus
31
31
  # full request lifecycle.
32
32
  class StreamApp
33
33
  PATH_PREFIX = "/pgbus/streams"
34
+ # The client's per-tab presence seat (?seat=, issue #515): an opaque id
35
+ # minted by <pgbus-stream-source presence>. Anything else is dropped.
36
+ SEAT_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
34
37
  # EventSource reconnect delay handed to the streams_test_mode stub (24h).
35
38
  TEST_MODE_RETRY_MS = 86_400_000
36
39
  private_constant :PATH_PREFIX
@@ -64,14 +67,16 @@ module Pgbus
64
67
  cursor = parse_cursor(env, request)
65
68
  return bad_request("invalid cursor: #{cursor}") if cursor.is_a?(String)
66
69
 
70
+ seat = parse_seat(request)
71
+
67
72
  return test_mode_stub if config.streams_test_mode
68
73
 
69
74
  return over_capacity if streamer.registry.size >= config.streams_max_connections
70
75
 
71
76
  if config.streams_falcon_streaming_body
72
- streaming_body_response(stream_name: stream_name, since_id: cursor, context: context)
77
+ streaming_body_response(stream_name: stream_name, since_id: cursor, context: context, seat: seat)
73
78
  elsif env["rack.hijack?"]
74
- hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context)
79
+ hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context, seat: seat)
75
80
  else
76
81
  unsupported_server
77
82
  end
@@ -114,7 +119,12 @@ module Pgbus
114
119
  e.message
115
120
  end
116
121
 
117
- def hijack_and_register(env, stream_name:, since_id:, context: nil)
122
+ def parse_seat(request)
123
+ seat = request.params["seat"]
124
+ seat if seat.is_a?(String) && seat.ascii_only? && SEAT_FORMAT.match?(seat)
125
+ end
126
+
127
+ def hijack_and_register(env, stream_name:, since_id:, context: nil, seat: nil)
118
128
  # Rack hijack is a one-time transition per the Rack spec. Call
119
129
  # once, use the return value, and fall back to the side-effect
120
130
  # `rack.hijack_io` variable only if the return was nil (some
@@ -133,14 +143,15 @@ module Pgbus
133
143
  since_id: since_id,
134
144
  writer: Pgbus::Web::Streamer::IoWriter,
135
145
  write_deadline_ms: config.streams_write_deadline_ms,
136
- context: context
146
+ context: context,
147
+ seat: seat
137
148
  )
138
149
  streamer.register(connection)
139
150
 
140
151
  [-1, {}, []]
141
152
  end
142
153
 
143
- def streaming_body_response(stream_name:, since_id:, context: nil)
154
+ def streaming_body_response(stream_name:, since_id:, context: nil, seat: nil)
144
155
  require "protocol/http/body/writable" unless defined?(::Protocol::HTTP::Body::Writable)
145
156
  require_relative "streamer/falcon_connection" unless defined?(Pgbus::Web::Streamer::FalconConnection)
146
157
 
@@ -159,7 +170,8 @@ module Pgbus
159
170
  body: body,
160
171
  since_id: since_id,
161
172
  write_deadline_ms: config.streams_write_deadline_ms,
162
- context: context
173
+ context: context,
174
+ seat: seat
163
175
  )
164
176
  streamer.register(connection)
165
177
 
@@ -14,10 +14,15 @@ module Pgbus
14
14
  # the client-side leg of the replay-race fix (§6.5 of the design doc).
15
15
  class Connection
16
16
  attr_reader :id, :stream_name, :io, :mutex, :context
17
+ # The client's per-tab presence seat (?seat=), or nil (issue #515).
18
+ attr_reader :seat
17
19
  # The presence member id this connection auto-joined as, or nil for
18
20
  # non-presence streams / anonymous connections. Set by the
19
21
  # Dispatcher on connect; read on disconnect and heartbeat touch.
20
22
  attr_accessor :presence_member
23
+ # True when presence_member joined as a seat row, false when as a
24
+ # member-keyed row (table not yet migrated). Leave follows the join.
25
+ attr_accessor :presence_seat
21
26
 
22
27
  # last_msg_id_sent is the ONE field whose writer can cross threads: with
23
28
  # streams_writer_threads > 0 (issue #321) the OutboundPump worker thread
@@ -31,7 +36,7 @@ module Pgbus
31
36
  @last_msg_id_sent.get
32
37
  end
33
38
 
34
- def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil)
39
+ def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil, seat: nil)
35
40
  @id = id
36
41
  @stream_name = stream_name
37
42
  @io = io
@@ -50,6 +55,7 @@ module Pgbus
50
55
  # registry when evaluating visible_to predicates. Defaults to
51
56
  # nil for tests that don't need audience filtering.
52
57
  @context = context
58
+ @seat = seat
53
59
  end
54
60
 
55
61
  # deadline_ms defaults to the connection's own write deadline so every
@@ -13,7 +13,12 @@ module Pgbus
13
13
  # is fiber-safe under Falcon's scheduler.
14
14
  class FalconConnection
15
15
  attr_reader :id, :stream_name, :io, :mutex, :context
16
+ # The client's per-tab presence seat (?seat=), or nil (issue #515).
17
+ attr_reader :seat
16
18
  attr_accessor :presence_member
19
+ # True when presence_member joined as a seat row, false when as a
20
+ # member-keyed row (table not yet migrated). Leave follows the join.
21
+ attr_accessor :presence_seat
17
22
 
18
23
  # last_msg_id_sent mirrors Connection's cross-thread cursor (issue #321):
19
24
  # a Concurrent::AtomicReference so the OutboundPump worker's advance and
@@ -23,7 +28,7 @@ module Pgbus
23
28
  @last_msg_id_sent.get
24
29
  end
25
30
 
26
- def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil)
31
+ def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil, seat: nil)
27
32
  @id = id
28
33
  @stream_name = stream_name
29
34
  @body = body
@@ -37,6 +42,7 @@ module Pgbus
37
42
  @created_at = monotonic
38
43
  @last_write_at = @created_at
39
44
  @context = context
45
+ @seat = seat
40
46
  end
41
47
 
42
48
  # deadline_ms is accepted for duck-type parity with Connection#enqueue
@@ -26,13 +26,18 @@ module Pgbus
26
26
  # the Connection's own mutex, so concurrent dispatcher + heartbeat
27
27
  # writes are safe.
28
28
  class Heartbeat
29
- def initialize(registry:, dispatch_queue:, interval:, idle_timeout:, logger: Pgbus.logger, clock: nil)
29
+ # presence_expiry: true (streams_presence_patterns is set) posts the
30
+ # presence touch every tick, even with no presence connection here,
31
+ # so the dispatcher can expire seats left by other processes.
32
+ def initialize(registry:, dispatch_queue:, interval:, idle_timeout:, logger: Pgbus.logger, clock: nil,
33
+ presence_expiry: false)
30
34
  @registry = registry
31
35
  @queue = dispatch_queue
32
36
  @interval = interval
33
37
  @idle_timeout = idle_timeout
34
38
  @logger = logger
35
39
  @clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
40
+ @presence_expiry = presence_expiry
36
41
  @running = false
37
42
  @thread = nil
38
43
  @wake = ConditionVariable.new
@@ -122,9 +127,10 @@ module Pgbus
122
127
  # Posts one batched touch per tick so the dispatcher (which owns AR
123
128
  # connection release) refreshes last_seen_at for live presence
124
129
  # members, keeping them out of the sweeper. No message when no
125
- # connection has presence — avoids waking the dispatcher for nothing.
130
+ # connection has presence and seat expiry is off — avoids waking the
131
+ # dispatcher for nothing.
126
132
  def enqueue_presence_touch(connections)
127
- return if connections.empty?
133
+ return if connections.empty? && !@presence_expiry
128
134
 
129
135
  @queue << StreamEventDispatcher::PresenceTouchMessage.new(connections: connections)
130
136
  end
@@ -74,7 +74,8 @@ module Pgbus
74
74
  dispatch_queue: @dispatch_queue,
75
75
  interval: @config.streams_heartbeat_interval,
76
76
  idle_timeout: @config.streams_idle_timeout,
77
- logger: @logger
77
+ logger: @logger,
78
+ presence_expiry: Array(@config.streams_presence_patterns).any?
78
79
  )
79
80
  @started = false
80
81
  @shutdown_mutex = Mutex.new
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Web
5
+ module Streamer
6
+ # Connection-driven presence (issues #169, #515), run on the
7
+ # StreamEventDispatcher thread, which owns AR connection release.
8
+ #
9
+ # On a seat table (Pgbus::Streams::Presence.seats_supported?) every SSE
10
+ # connection is its own row, keyed by connection id and carrying the
11
+ # client's seat; two tabs of one member are two rows. On an older
12
+ # table it falls back to one member-keyed row, as before.
13
+ #
14
+ # The heartbeat posts a PresenceTouchMessage each tick; #touch refreshes
15
+ # the live seats, re-joins any whose row was expired while the
16
+ # connection stayed open, and expires silent seats across all streams
17
+ # (rows of crashed processes included) at most every min_ttl / 2 after
18
+ # a successful sweep.
19
+ #
20
+ # Each connection remembers whether it joined as a seat or as a
21
+ # member-keyed row, so a table migrated while it is open still has
22
+ # its row removed on leave (and converted to a seat on the next touch).
23
+ #
24
+ # Every failure is logged and swallowed: a presence DB hiccup must not
25
+ # knock a live SSE connection out of the registry.
26
+ class PresenceTracker
27
+ def initialize(config:, presence_provider:, logger:, sweeper: nil, clock: nil)
28
+ @config = config
29
+ @presence_provider = presence_provider
30
+ @logger = logger
31
+ @sweeper = sweeper || method(:expire_seats)
32
+ @clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
33
+ @last_sweep_at = nil
34
+ end
35
+
36
+ def join(connection, stream = connection.stream_name)
37
+ return unless @config&.stream_presence?(stream)
38
+
39
+ member = @config.presence_member_for(connection.context)
40
+ return unless member
41
+
42
+ presence = @presence_provider.call(stream)
43
+ seat = seats?(presence)
44
+ join_member(presence, connection, member, seat)
45
+ connection.presence_member = member[:id] if connection.respond_to?(:presence_member=)
46
+ connection.presence_seat = seat if connection.respond_to?(:presence_seat=)
47
+ rescue StandardError => e
48
+ log_failure("join", e)
49
+ end
50
+
51
+ def leave(connection, stream = connection.stream_name)
52
+ member_id = presence_member_of(connection)
53
+ return unless member_id
54
+
55
+ # Cleared first: a heartbeat touch already queued behind this
56
+ # disconnect must not re-join the seat it removes.
57
+ connection.presence_member = nil if connection.respond_to?(:presence_member=)
58
+ presence = @presence_provider.call(stream)
59
+ if joined_as_seat?(connection, presence)
60
+ presence.leave(connection_id: connection.id)
61
+ else
62
+ presence.leave(member_id: member_id)
63
+ end
64
+ rescue StandardError => e
65
+ log_failure("leave", e)
66
+ end
67
+
68
+ def touch(connections)
69
+ connections.select { |conn| presence_member_of(conn) }.group_by(&:stream_name).each do |stream, conns|
70
+ touch_stream(stream, conns)
71
+ rescue StandardError => e
72
+ log_failure("touch", e)
73
+ end
74
+ sweep_if_due
75
+ end
76
+
77
+ private
78
+
79
+ def join_member(presence, connection, member, seat)
80
+ metadata = member[:metadata] || {}
81
+ if seat
82
+ presence.join(member_id: member[:id], metadata: metadata,
83
+ connection_id: connection.id, seat: seat_of(connection))
84
+ else
85
+ presence.join(member_id: member[:id], metadata: metadata)
86
+ end
87
+ end
88
+
89
+ def touch_stream(stream, conns)
90
+ presence = @presence_provider.call(stream)
91
+ unless seats?(presence)
92
+ conns.each { |conn| presence.touch(member_id: presence_member_of(conn)) }
93
+ return
94
+ end
95
+
96
+ live = presence.touch(connection_id: conns.map(&:id))
97
+ conns.reject { |conn| live.include?(conn.id) }.each { |conn| rejoin_as_seat(presence, conn, stream) }
98
+ end
99
+
100
+ # A connection that joined as a member-keyed row before the table
101
+ # was migrated drops that row before it takes a seat.
102
+ def rejoin_as_seat(presence, connection, stream)
103
+ presence.leave(member_id: presence_member_of(connection)) unless joined_as_seat?(connection, presence)
104
+ join(connection, stream)
105
+ end
106
+
107
+ def sweep_if_due
108
+ return if Array(@config.streams_presence_patterns).empty?
109
+
110
+ at = now
111
+ return if @last_sweep_at && at - @last_sweep_at < @config.presence_min_ttl / 2.0
112
+
113
+ @sweeper.call
114
+ @last_sweep_at = at
115
+ rescue StandardError => e
116
+ log_failure("expiry", e)
117
+ end
118
+
119
+ def expire_seats
120
+ Pgbus::Streams::Presence.expire_seats!(
121
+ ttl_for: ->(stream) { @config.presence_ttl_for(stream) }, min_ttl: @config.presence_min_ttl
122
+ )
123
+ end
124
+
125
+ def seats?(presence)
126
+ presence.respond_to?(:seats?) && presence.seats?
127
+ end
128
+
129
+ # The mode recorded at join; a connection without the accessor
130
+ # follows the table's current schema.
131
+ def joined_as_seat?(connection, presence)
132
+ return connection.presence_seat if connection.respond_to?(:presence_seat)
133
+
134
+ seats?(presence)
135
+ end
136
+
137
+ def seat_of(connection)
138
+ connection.respond_to?(:seat) ? connection.seat : nil
139
+ end
140
+
141
+ def presence_member_of(connection)
142
+ connection.respond_to?(:presence_member) ? connection.presence_member : nil
143
+ end
144
+
145
+ def now
146
+ @clock.call
147
+ end
148
+
149
+ def log_failure(action, error)
150
+ @logger.error { "[Pgbus::Streamer::PresenceTracker] presence #{action} failed: #{error.class}: #{error.message}" }
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -105,6 +105,7 @@ module Pgbus
105
105
  # process-wide setting. Falls back to the global config
106
106
  # for production call sites that don't specify one.
107
107
  @config = config || Pgbus.configuration
108
+ @presence = PresenceTracker.new(config: @config, presence_provider: @presence_provider, logger: @logger)
108
109
  # Two write deadlines (issue #315 item 3). Fanout writes run serially
109
110
  # on this single thread, so a slow client stalls the connections
110
111
  # queued behind it — the short fanout deadline bounds that stall.
@@ -436,19 +437,11 @@ module Pgbus
436
437
  )
437
438
  end
438
439
 
439
- # Touches (refreshes last_seen_at for) the presence members on the
440
- # given connections. Posted by the Heartbeat each tick so idle but
441
- # still-connected members don't get swept. Connections without a
442
- # presence member (non-presence streams, anonymous) are skipped.
440
+ # Touches the presence rows of the given connections and expires
441
+ # silent seats. Posted by the Heartbeat each tick so idle but
442
+ # still-connected members don't get swept (see PresenceTracker).
443
443
  def handle_presence_touch(msg)
444
- msg.connections.each do |connection|
445
- member_id = presence_member_of(connection)
446
- next unless member_id
447
-
448
- @presence_provider.call(connection.stream_name).touch(member_id: member_id)
449
- rescue StandardError => e
450
- @logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence touch failed: #{e.class}: #{e.message}" }
451
- end
444
+ @presence.touch(msg.connections)
452
445
  end
453
446
 
454
447
  # If this stream has no remaining subscribers (registered or
@@ -640,35 +633,13 @@ module Pgbus
640
633
  connection.respond_to?(:id) && connection.id.to_s == exclude.to_s
641
634
  end
642
635
 
643
- # Connection-driven presence (issue #169). Auto-joins a member when
644
- # the stream is configured for presence and the connection's
645
- # authorize-context yields a member id. Stores the member id on the
646
- # connection so handle_disconnect/handle_presence_touch can act on
647
- # it. Failures are logged and swallowed: a presence DB hiccup must
648
- # not knock a live SSE connection out of the registry.
636
+ # Connection-driven presence (issues #169, #515): see PresenceTracker.
649
637
  def presence_join(connection, stream)
650
- return unless @config&.stream_presence?(stream)
651
-
652
- member = @config.presence_member_for(connection.context)
653
- return unless member
654
-
655
- @presence_provider.call(stream).join(member_id: member[:id], metadata: member[:metadata] || {})
656
- connection.presence_member = member[:id] if connection.respond_to?(:presence_member=)
657
- rescue StandardError => e
658
- @logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence join failed: #{e.class}: #{e.message}" }
638
+ @presence.join(connection, stream)
659
639
  end
660
640
 
661
641
  def presence_leave(connection, stream)
662
- member_id = presence_member_of(connection)
663
- return unless member_id
664
-
665
- @presence_provider.call(stream).leave(member_id: member_id)
666
- rescue StandardError => e
667
- @logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence leave failed: #{e.class}: #{e.message}" }
668
- end
669
-
670
- def presence_member_of(connection)
671
- connection.respond_to?(:presence_member) ? connection.presence_member : nil
642
+ @presence.leave(connection, stream)
672
643
  end
673
644
 
674
645
  def monotonic_ms
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "active_support/core_ext/time"
5
+ require "active_support/core_ext/date_time"
6
+
7
+ module Pgbus
8
+ module Web
9
+ # Every timestamp, age and duration the dashboard prints (issue #497).
10
+ #
11
+ # Accepts the shapes the data sources hand the views: Time (pg decoders),
12
+ # ActiveSupport::TimeWithZone (AR attributes), DateTime, ISO 8601 or
13
+ # Time#to_s Strings (PGMQ JSON, payload["scheduled_at"]), Numeric epoch
14
+ # seconds (ActiveJob before 7.1), and nil. Every time is shown in
15
+ # Time.zone. Every word comes from pgbus.helpers.time in the locale.
16
+ # Pure: no I/O, no view context; the view helpers wrap it in <time>.
17
+ module TimeFormat
18
+ SCOPE = "pgbus.helpers.time"
19
+ NONE = "—"
20
+ MINUTE = 60
21
+ HOUR = 3600
22
+ DAY = 86_400
23
+
24
+ module_function
25
+
26
+ def coerce(value)
27
+ case value
28
+ when nil then nil
29
+ when Time, DateTime, ActiveSupport::TimeWithZone then value.in_time_zone
30
+ when Numeric then epoch(value)
31
+ when String then parse(value)
32
+ end
33
+ end
34
+
35
+ # NaN and Infinity are not moments.
36
+ def epoch(seconds)
37
+ Time.at(seconds).in_time_zone
38
+ rescue RangeError
39
+ nil
40
+ end
41
+
42
+ def parse(string)
43
+ string = string.strip
44
+ return nil if string.empty?
45
+
46
+ Time.zone ? Time.zone.parse(string) : Time.parse(string)
47
+ rescue ArgumentError
48
+ nil
49
+ end
50
+
51
+ # "5m ago", "in 2h" or "now", measured from now:.
52
+ def relative(value, now: Time.current)
53
+ time = coerce(value)
54
+ return nil unless time
55
+
56
+ delta = time - now
57
+ return I18n.t("#{SCOPE}.now") if delta.abs < 1
58
+
59
+ distance = largest_unit(delta.abs.to_i)
60
+ I18n.t("#{SCOPE}.#{delta.negative? ? "past" : "future"}", distance: distance)
61
+ end
62
+
63
+ def absolute(value)
64
+ coerce(value)&.strftime(I18n.t("#{SCOPE}.absolute_format"))
65
+ end
66
+
67
+ def clock(value)
68
+ coerce(value)&.strftime(I18n.t("#{SCOPE}.clock_format"))
69
+ end
70
+
71
+ def iso(value)
72
+ coerce(value)&.utc&.iso8601
73
+ end
74
+
75
+ # Two units at most: "45s", "2m 5s", "1h 2m", "1d 1h".
76
+ def duration(seconds)
77
+ return NONE unless seconds
78
+
79
+ # An age is never negative; one is clock skew between hosts.
80
+ seconds = [seconds.to_i, 0].max
81
+ if seconds < MINUTE then unit(:seconds, seconds)
82
+ elsif seconds < HOUR then pair(:minutes, seconds / MINUTE, :seconds, seconds % MINUTE)
83
+ elsif seconds < DAY then pair(:hours, seconds / HOUR, :minutes, (seconds % HOUR) / MINUTE)
84
+ else pair(:days, seconds / DAY, :hours, (seconds % DAY) / HOUR)
85
+ end
86
+ end
87
+
88
+ def ms_duration(millis)
89
+ return NONE unless millis
90
+
91
+ millis = millis.to_i
92
+ if millis < 1000 then unit(:milliseconds, millis)
93
+ elsif millis < 60_000 then unit(:seconds, (millis / 1000.0).round(1))
94
+ else unit(:minutes, (millis / 60_000.0).round(1))
95
+ end
96
+ end
97
+
98
+ # "15 minutes", "6 hours", "7 days" for an Insights range in minutes.
99
+ def range_label(minutes)
100
+ minutes = [minutes.to_i, 1].max
101
+
102
+ if minutes > 1440 && (minutes % 1440).zero?
103
+ I18n.t("#{SCOPE}.range.days", count: minutes / 1440)
104
+ elsif minutes >= 60 && (minutes % 60).zero?
105
+ I18n.t("#{SCOPE}.range.hours", count: minutes / 60)
106
+ else
107
+ I18n.t("#{SCOPE}.range.minutes", count: minutes)
108
+ end
109
+ end
110
+
111
+ def largest_unit(seconds)
112
+ if seconds < MINUTE then unit(:seconds, seconds)
113
+ elsif seconds < HOUR then unit(:minutes, seconds / MINUTE)
114
+ elsif seconds < DAY then unit(:hours, seconds / HOUR)
115
+ else unit(:days, seconds / DAY)
116
+ end
117
+ end
118
+
119
+ def pair(big, big_count, small, small_count)
120
+ "#{unit(big, big_count)} #{unit(small, small_count)}"
121
+ end
122
+
123
+ def unit(name, count)
124
+ I18n.t("#{SCOPE}.units.#{name}", count: count)
125
+ end
126
+ end
127
+ end
128
+ end