pgbus 0.16.3 → 0.16.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ccb9204900296bfd53c5845faa89a91eec0eddedd7073818af147ef135daa82
4
- data.tar.gz: db8ee8c7cbfe2ac34700bce9479736f19cf658a0a728a20a4a8a5988978363fd
3
+ metadata.gz: ddf8b7dde9919fc1ad8de39311bacac6ad8c50fadeac059035c5c0ff9b5c30c6
4
+ data.tar.gz: 719cec84b8486fac601755a84ea457f926b5b1b5faed8121e61a33a9c52b4972
5
5
  SHA512:
6
- metadata.gz: 305bac06a4cdf80af363d4cdc242165e02319f9c9b060c98c44db31af833f4bb055f625b79cac14853ab8382a6458f6142b3f9543ddef55f329581bed112c0bb
7
- data.tar.gz: '009aea5c6f176d5b19e87dbd0375e5d9624cd82aa48ace058af26107df57903c0896159d4541bd66615e06973abed5cebc1e7f0230ef935a34df25cba2864e2d'
6
+ metadata.gz: 9fe2d3c3dd637bb9a6af1b4e72092f5acfedd8237804e6d8316cc38f084a573aeef4ef943db0da616e1f108f6afd2cb367b1163f29cef7cb5550f7d20adf7074
7
+ data.tar.gz: 646442c236b49fd47a37b5f3e7e988e5bcd3a8263419e7d952b526f5f194debed2821813864fbf05635c96925c579f947e259a3385347bf1f8026b142d9e9953
data/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@
6
6
 
7
7
  ### Fixed
8
8
 
9
+ - **`limits_concurrency … on_conflict: :block` now constrains a job without ever losing it, and never runs more than `to:` at once.** The same bug class solid_queue closed in rails/solid_queue#712 and #761, checked against pgbus's implementation. Four defects, each with a regression spec:
10
+ - **A parked job was silently deleted once it had waited longer than `duration`.** The dispatcher's sweep `delete_all`-ed blocked rows past their `expires_at` (a debug log, nothing else), and `release_next!` refused to promote one — so with `to: 1` and a slow holder, the N-th parked enqueue was simply gone. Blocked rows no longer expire; the only way out of `pgbus_blocked_executions` is promotion. `expires_at` now orders the sweep, nothing more.
11
+ - **A semaphore expired under a job that was still running, and two runs of one key overlapped.** `duration` capped run time: once it passed, the sweep deleted the semaphore and promoted the next parked job beside the still-running holder — and that promotion took no slot at all, so a third enqueue then created a fresh semaphore and ran too. The visibility heartbeat now re-arms the semaphore alongside the message (`Semaphore.touch`), so `duration` bounds heartbeat silence, not run time; and every promotion — completion-time or sweep — takes its slot through the same `value < max_value` upsert an enqueue uses, so it can never push a key past `to:`.
12
+ - **A job parked while the holder was finishing was stranded until the semaphore expired.** The enqueue checked the semaphore and inserted the blocked row as two autocommit statements; a holder finishing in between found no parked row, released the slot, and nobody promoted the row it had just missed (solid_queue #456/#712). The adapter now checks and parks in one transaction — the upsert holds the semaphore row lock until commit even when it returns `:blocked` — and the completion signal decrements under that same lock before it looks for parked rows, so it waits for the in-flight park and then sees it.
13
+ - **A duplicate delivery released the slot twice.** A worker whose heartbeat lapsed had its message redelivered; when both copies finished, both signalled, and a `to: 1` key ran 2, then 3 (solid_queue #761). `pgmq.archive` returning `false` — the message was already archived by the other copy — is now the exact-once claim: that execution returns `:duplicate` and skips the concurrency, batch and uniqueness signals.
14
+
15
+ Underneath, the parked payload was **double-encoded**: `BlockedExecution.insert` handed the jsonb column an already-serialized string, so every row was a jsonb *string*, not an object. Promotion worked only because the client passes a String body through untouched; everything that read the column as a document did the wrong thing silently — the job-class lookup (so a promoted job always took the default limit), `scheduled_at` (a parked `perform_later(wait:)` job promoted immediately), `Batch.backfill_execution` (a parked batch job never got its execution row) and the batch sweep's `payload->>'job_id'` (a parked batch job looked orphaned and was un-counted, undoing #423). Rows are now stored as objects; the sweep rewrites rows written before this release in place (`repair_double_encoded!`, idempotent) and `release_next!` reads both shapes. No migration.
16
+
17
+ Six narrower holes in the same guarantee, found in review:
18
+ - **A scheduled job lost its slot while still waiting in the queue.** The slot is taken at enqueue but its lease is only renewed once a worker picks the message up, so a `perform_later(wait: 1.hour)` job under a 15-minute `duration` was presumed dead mid-wait and a second job promoted for its key. A scheduled job's lease now covers its delay. The residual — a queue backed up longer than `duration` before any worker gets to the message — is documented rather than papered over.
19
+ - **A `duration` shorter than the heartbeat interval expired before the first beat.** `duration` is now floored at twice the effective heartbeat interval, so a lease can never lapse before the beat that would have renewed it.
20
+ - **The enqueue sent the message before its slot committed.** PGMQ has its own connection, so the send can never join the AR transaction; a commit failure after a successful send left the message live with the slot rolled back, and the next enqueue ran beside it. The send now happens after the commit, so the only crash window leaves a slot held with no message — under-admitting until the sweep reclaims it, never over-admitting. A send that raises hands its slot back immediately.
21
+ - **A batch backfill failure could undo a promotion.** `Batch.backfill_execution` ran inside the transaction `Semaphore.signal` opens, so a database error there poisoned it: the commit failed, the parked row came back and the slot was released while the promoted message was already live — the job ran twice. The backfill now has its own savepoint.
22
+ - **An ambiguous archive retry was read as a duplicate.** `archive_from` retries once on a connection error; if the first archive had actually committed and only its reply was lost, the retry reported "already archived" and the executor skipped every completion signal, stranding the slot, the batch execution and the uniqueness lock. It now distinguishes `:ambiguous` from `:already_archived` and claims the former. A genuine duplicate also releases its own `:while_executing` lock, conditioned on the message id so it can never drop a successor's.
23
+ - **The sweep's key scan could starve.** It took the thousand oldest parked keys; a thousand keys whose slots were held sat at the head of that window forever, so a key behind them whose holder had died was never serviced. The scan now selects only keys that could take a slot right now.
24
+
25
+ And five more from a second review pass:
26
+ - **An ambiguous send no longer undoes the bookkeeping for a message that may be live.** A connection error can reach the enqueuer after the produce has committed. Releasing the slot then admits a second job beside that message, and rolling the uniqueness lock back leaves it unguarded — so a database-shaped failure raised from inside the produce now keeps the slot, the lock and the batch count. Each is then reclaimed by its own recovery path if the message really was never written: the lease expiry plus the dispatcher's concurrency sweep for the slot, the dispatcher's unbound-lock reaper for the uniqueness row, and `Batch::Sweep`'s orphan pass for the batch count. Anything raised before the produce is called — taking the slot, parking the job — is not ambiguous and still rolls straight back, as is a non-database failure such as a serialization or argument error. The classifier is deliberately an over-approximation in one place: the client's pre-produce queue setup runs inside the same call, so its connection errors are treated as ambiguous too, erring toward state the reapers can reclaim.
27
+ - **`release_if_bound!` is scoped by queue.** PGMQ message ids are per-queue sequences, so matching a lock on `msg_id` alone could delete a successor holding the same key on another queue.
28
+ - **Heartbeat touches are floored too.** The floor was applied when taking a slot but not when renewing one, so a `duration` below the heartbeat interval still let a lease lapse mid-run.
29
+ - **A promoted scheduled job's lease covers its remaining delay**, matching the direct-enqueue path — a parked `wait:` job promoted while its scheduled time was still far out got a lease that expired before the message was ever visible.
30
+ - The concurrency page names the one window a lease cannot cover (enqueue to first heartbeat) and how to size `duration` against it.
31
+
32
+ `Concurrency::BlockedExecution.expire_stale` is gone; `Concurrency::Semaphore.signal(key, client:)` replaces the executor's promote-then-release pair; `Concurrency::BlockedExecution.promote_pending(client:)` is the sweep; `Executor#execute` returns `:duplicate` when another worker archived its message. Docs: the concurrency page now spells out the `:block` guarantees, what `duration` means and the one window it cannot cover.
33
+
9
34
  - **The batch progress bar rendered at zero width — because it rendered with no colour at all.** `app/frontend/pgbus/style.css` is a committed Tailwind build artifact that nothing rebuilds automatically; it was last compiled in #47, before the Batches tab existed. Every Tailwind class added to a view since then resolved to no CSS rule, so the progress fill on `/batches` and `/batches/:id` sized itself correctly from its inline `width:` and then painted nothing — a batch at 483/807 looked stuck at 0% no matter how often you reloaded. 33 classes were missing in total, including the bar's own `bg-green-500` / `bg-amber-500` fill and `bg-gray-200` track, the batches-table bar geometry (`h-2`, `w-24`), and `sm:grid-cols-4`, `hover:underline`, `gap-2`, `z-10`, `break-all`, `cursor-not-allowed` elsewhere on the dashboard. The artifact is rebuilt, `tailwind.css` now names its sources explicitly with `@source` (automatic detection would also walk the nested `docs/` app), `rake frontend:css` makes the build repeatable and version-pinned from `bun.lock`, and a new spec fails the suite whenever a class used in a view is absent from the artifact — the drift can no longer ship silently.
10
35
 
11
36
  - **Concurrent first-broadcast notify-insert setup no longer pages on a Postgres deadlock / lock-wait (the residual of #403).** Two processes racing the first durable Turbo/Pgbus stream broadcast can both see "notify trigger not current" and both run `pgmq.enable_notify_insert` → `DROP TRIGGER` under `AccessExclusiveLock`. Postgres deadlocks, or hits `lock_timeout`, or — on pooled connections where `statement_timeout` < `lock_timeout` — `canceling statement due to statement timeout` **while locking**. PGMQ wraps those as `PGMQ::Errors::ConnectionError`. 0.16.1 already skips DROP when the trigger is current and retries stale sockets via `with_stale_connection_retry`, but it did not retry these lock races, so the residual still paged (getzazu/app#3817; AppSignal Zazu ma-prod #546 / za-prod #530). `ensure_stream_queue` now wraps the notify-setup path in a dedicated `with_notify_lock_retry` (up to 3 attempts, short backoff **outside** `@pgmq_mutex`): deadlock, lock-not-available, lock-timeout, and statement-timeout **with** lock-wait context retry; a bare statement timeout and permission errors still fail fast, while a missing-queue `ConnectionError` uses the existing one-time queue-recreation path without lock retries. `with_stale_connection_retry` is unchanged — that helper stays idle-socket / no-SQL-sent only. Consumers that prepended an app-level retry around `ensure_stream_queue` (getzazu/app#3827) can drop that prepend after upgrading.
@@ -5,19 +5,17 @@ module Pgbus
5
5
  self.table_name = "pgbus_blocked_executions"
6
6
 
7
7
  scope :for_key, ->(key) { where(concurrency_key: key) }
8
- scope :expired, ->(now = Time.current) { where("expires_at < ?", now) }
9
8
 
10
- # Atomic dequeue: DELETE the highest-priority non-expired row with FOR UPDATE SKIP LOCKED.
11
- # Returns { queue_name:, payload: } or nil.
9
+ # Atomic dequeue: DELETE the highest-priority row with FOR UPDATE SKIP LOCKED.
10
+ # A parked job never ages out — `expires_at` orders the sweep, nothing more.
11
+ # Returns { queue_name:, payload:, priority: } or nil.
12
12
  def self.release_next!(concurrency_key)
13
- now = Time.current
14
13
  result = connection.exec_query(
15
14
  <<~SQL,
16
15
  DELETE FROM pgbus_blocked_executions
17
16
  WHERE id = (
18
17
  SELECT id FROM pgbus_blocked_executions
19
18
  WHERE concurrency_key = $1
20
- AND expires_at >= $2
21
19
  ORDER BY priority ASC, created_at ASC
22
20
  LIMIT 1
23
21
  FOR UPDATE SKIP LOCKED
@@ -25,16 +23,44 @@ module Pgbus
25
23
  RETURNING queue_name, payload, priority
26
24
  SQL
27
25
  "Pgbus Blocked Release",
28
- [concurrency_key, now]
26
+ [concurrency_key]
29
27
  )
30
28
 
31
29
  row = result.first
32
30
  return nil unless row
33
31
 
34
32
  payload = row["payload"]
35
- payload = JSON.parse(payload) if payload.is_a?(String)
33
+ # exec_query returns jsonb as text; a row written before the
34
+ # double-encoding fix parses to a String holding the real document.
35
+ 2.times { payload = JSON.parse(payload) if payload.is_a?(String) }
36
36
 
37
37
  { queue_name: row["queue_name"], payload: payload, priority: row["priority"] }
38
38
  end
39
+
40
+ # Rewrite rows parked before the double-encoding fix (a jsonb string
41
+ # holding the document) as the document itself, so SQL readers such as
42
+ # the batch sweep's `payload->>'job_id'` see them. Idempotent; the sweep
43
+ # runs it before every promotion pass.
44
+ def self.repair_double_encoded!
45
+ where("jsonb_typeof(payload) = 'string'").update_all("payload = (payload #>> '{}')::jsonb")
46
+ end
47
+
48
+ # Keys with parked jobs that could take a slot right now — no semaphore
49
+ # row, or one with room — longest-waiting first.
50
+ #
51
+ # Filtering in SQL rather than skipping in Ruby is what keeps the scan's
52
+ # cap honest: a plain oldest-first list would fill with keys whose slots
53
+ # are held (their holders promote for themselves on completion anyway)
54
+ # and starve a key behind them whose holder died.
55
+ def self.promotable_keys(limit: 1000)
56
+ joins(<<~SQL)
57
+ LEFT JOIN pgbus_semaphores ON pgbus_semaphores.key = pgbus_blocked_executions.concurrency_key
58
+ SQL
59
+ .where("pgbus_semaphores.key IS NULL OR pgbus_semaphores.value < pgbus_semaphores.max_value")
60
+ .group(:concurrency_key)
61
+ .order(Arel.sql("MIN(pgbus_blocked_executions.created_at)"))
62
+ .limit(limit)
63
+ .pluck(:concurrency_key)
64
+ end
39
65
  end
40
66
  end
@@ -7,16 +7,21 @@ module Pgbus
7
7
  scope :expired, ->(now = Time.current) { where("expires_at < ? OR value <= 0", now) }
8
8
 
9
9
  # Atomic conditional UPSERT. Returns :acquired or :blocked.
10
+ #
11
+ # A nil max_value means "keep the limit this row already records" — used
12
+ # when promoting a parked job whose class no longer resolves, so the
13
+ # promotion is judged against the real limit rather than a guessed 1.
14
+ # On a fresh row there is no recorded limit, so nil falls back to 1.
10
15
  def self.acquire!(key, max_value, expires_at)
11
16
  result = connection.exec_query(
12
17
  <<~SQL,
13
18
  INSERT INTO pgbus_semaphores (key, value, max_value, expires_at)
14
- VALUES ($1, 1, $2, $3)
19
+ VALUES ($1, 1, COALESCE($2, 1), $3)
15
20
  ON CONFLICT (key) DO UPDATE
16
21
  SET value = pgbus_semaphores.value + 1,
17
- max_value = EXCLUDED.max_value,
22
+ max_value = COALESCE($2, pgbus_semaphores.max_value),
18
23
  expires_at = GREATEST(pgbus_semaphores.expires_at, EXCLUDED.expires_at)
19
- WHERE pgbus_semaphores.value < EXCLUDED.max_value
24
+ WHERE pgbus_semaphores.value < COALESCE($2, pgbus_semaphores.max_value)
20
25
  RETURNING value
21
26
  SQL
22
27
  "Pgbus Semaphore Acquire",
@@ -70,6 +70,22 @@ module Pgbus
70
70
  )
71
71
  end
72
72
 
73
+ # Release a lock only while it still points at this message. Used by the
74
+ # executor when it finds its message already archived by another worker:
75
+ # the :while_executing lock it took belongs to this attempt and has to go
76
+ # back, but an unconditional key-only DELETE could drop a successor that
77
+ # has since acquired the same key.
78
+ # PGMQ message ids are per-queue sequences, so a msg_id alone is not an
79
+ # identity: the same number addresses a different message on every other
80
+ # queue. The queue is part of the match.
81
+ def self.release_if_bound!(lock_key, queue_name:, msg_id:)
82
+ Thread.current[:pgbus_uniqueness_created_at]&.delete(lock_key)
83
+ connection.exec_delete(
84
+ "DELETE FROM #{table_name} WHERE lock_key = $1 AND queue_name = $2 AND msg_id = $3",
85
+ "UniquenessKey Release If Bound", [lock_key, queue_name.to_s, msg_id.to_i]
86
+ )
87
+ end
88
+
73
89
  # Check if a key is currently locked.
74
90
  def self.locked?(lock_key)
75
91
  result = connection.select_value(
@@ -70,17 +70,40 @@ module Pgbus
70
70
  priority = active_job.try(:priority)
71
71
  msg_id = nil
72
72
  blocked = false
73
+ # Only a failure raised from inside the produce can be ambiguous.
74
+ # Everything before it — taking the slot, parking the job — proves no
75
+ # message exists, however database-shaped the error looks. Never
76
+ # reset: once the send returns, msg_id is set and the rescue routes
77
+ # on that instead.
78
+ sending = false
73
79
 
74
80
  if key && concurrency
75
- result = Concurrency::Semaphore.acquire(key, concurrency[:limit], concurrency[:duration])
81
+ # The check and the park commit together, under the semaphore row
82
+ # lock the upsert holds even when it reports :blocked — so a holder
83
+ # signalling right now waits and then sees the parked row instead
84
+ # of stranding it (rails/solid_queue#712).
85
+ acquired = false
86
+ Pgbus::Semaphore.transaction(requires_new: true) do
87
+ acquired = Concurrency::Semaphore.acquire(key, concurrency[:limit], slot_lease(concurrency, delay)) ==
88
+ :acquired
89
+ blocked = handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: priority) unless
90
+ acquired
91
+ end
76
92
 
77
- if result == :acquired
78
- msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
93
+ if acquired
94
+ # Deliberately AFTER the commit. PGMQ has its own connection, so
95
+ # the send can never join this transaction; sending first would
96
+ # mean a failed commit leaves the message live with the slot
97
+ # rolled back, and the next enqueue runs beside it. This way the
98
+ # only crash window leaves a slot held with no message — the
99
+ # sweep reclaims it, and until then the key is under-admitted,
100
+ # never over-admitted.
101
+ sending = true
102
+ msg_id = send_holding_slot(key, queue, payload_hash, delay: delay, priority: priority)
79
103
  active_job.provider_job_id = msg_id
80
- else
81
- blocked = handle_conflict(concurrency, active_job, key, queue, payload_hash, priority: priority)
82
104
  end
83
105
  else
106
+ sending = true
84
107
  msg_id = Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
85
108
  active_job.provider_job_id = msg_id
86
109
  end
@@ -97,18 +120,69 @@ module Pgbus
97
120
  Thread.current[:pgbus_acquired_uniqueness_key] = nil
98
121
  active_job
99
122
  rescue StandardError => e
100
- if msg_id.nil?
123
+ # An ambiguous send is treated exactly like a live message: the
124
+ # produce may have committed with only its reply lost, and undoing
125
+ # the bookkeeping for a message that is in fact live is the worse
126
+ # error — it would leave that job running with no uniqueness lock and
127
+ # uncounted by its batch. A batch left waiting for a job that never
128
+ # existed is recovered by the stalled-batch sweep; a batch that
129
+ # finishes early and fires its callback is not recoverable. The
130
+ # `sending` guard keeps that reprieve to the produce itself: a
131
+ # database error from the slot upsert or the park is not ambiguous.
132
+ if msg_id.nil? && !(sending && ambiguous_delivery?(e))
101
133
  rollback_acquired_uniqueness_lock
102
134
  uncount_batch_job(payload_hash)
103
135
  else
104
- # Message is live: drop the thread-local so a later discard on this
105
- # thread cannot release that job's uniqueness lock, but do not
106
- # DELETE the pgbus_uniqueness_keys row.
136
+ # Drop the thread-local so a later discard on this thread cannot
137
+ # release that job's uniqueness lock, but do not DELETE the
138
+ # pgbus_uniqueness_keys row.
107
139
  Thread.current[:pgbus_acquired_uniqueness_key] = nil
108
140
  end
109
141
  raise e
110
142
  end
111
143
 
144
+ # True when a failure raised from inside the produce reached the
145
+ # database, so the message may have been written even though the reply
146
+ # did not come back. Deliberately an over-approximation: the client's
147
+ # pre-produce queue setup is inside the same call, so its connection
148
+ # errors are counted as ambiguous too. That errs toward keeping a lock
149
+ # and a batch count the reapers can reclaim, rather than dropping
150
+ # bookkeeping for a message that turns out to be live.
151
+ def ambiguous_delivery?(error)
152
+ (defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError)) ||
153
+ (defined?(PG::Error) && error.is_a?(PG::Error))
154
+ end
155
+
156
+ # A slot is leased for `duration` of silence, but the visibility
157
+ # heartbeat that renews it only starts once a worker picks the message
158
+ # up. A scheduled job waits in PGMQ until then, so its lease has to
159
+ # cover the delay as well or the sweep expires it mid-wait and promotes
160
+ # a second job for the same key.
161
+ def slot_lease(concurrency, delay)
162
+ Concurrency.effective_duration(concurrency[:duration]) + delay.to_i
163
+ end
164
+
165
+ # Hand the slot back only when the failure proves nothing was
166
+ # produced. On an ambiguous outcome the message may well be live, and
167
+ # releasing would admit a second job beside it — so the hold stands
168
+ # and the lease expiry plus the dispatcher's sweep reclaim it.
169
+ def send_holding_slot(key, queue, payload_hash, delay:, priority:)
170
+ Pgbus.client.send_message(queue, payload_hash, delay: delay, priority: priority)
171
+ rescue StandardError => e
172
+ if ambiguous_delivery?(e)
173
+ Pgbus.logger.warn do
174
+ "[Pgbus] Send outcome unknown for #{key}; holding its concurrency slot until the lease expires: #{e.message}"
175
+ end
176
+ else
177
+ begin
178
+ Concurrency::Semaphore.release(key)
179
+ rescue StandardError => release_error
180
+ Pgbus.logger.warn { "[Pgbus] Could not release concurrency slot after failed send: #{release_error.message}" }
181
+ end
182
+ end
183
+ raise
184
+ end
185
+
112
186
  def physical_queue(queue, priority)
113
187
  Pgbus.client.target_queue(queue, priority)
114
188
  end
@@ -96,13 +96,24 @@ module Pgbus
96
96
  # `batch` (and `batch.enqueue` for open batches) work inside a job.
97
97
  assign_batch_id(job, payload)
98
98
  Pgbus.logger.debug { "[Pgbus::Executor] running #{tag} job_class=#{job_class}" }
99
- with_visibility_heartbeat(job, queue_name, msg_id, source_queue) { execute_job(job) }
99
+ with_visibility_heartbeat(job, queue_name, msg_id, source_queue, payload) { execute_job(job) }
100
100
  # retry_on re-enqueues from inside perform_now and returns normally:
101
101
  # this attempt is done (archive it) but the job is not — the retry
102
102
  # message carries the batch tag and signals on its own outcome.
103
103
  retried = Batch.retry_reenqueued?(payload["job_id"])
104
104
  Pgbus.logger.debug { "[Pgbus::Executor] perform_returned #{tag} job_class=#{job_class}" }
105
- archive_from(queue_name, msg_id, source_queue: source_queue)
105
+ # Archiving is the exact-once claim on this execution.
106
+ # :already_archived means another worker archived the message (our
107
+ # heartbeat lapsed and it was redelivered): that worker owns the
108
+ # completion signals, and signalling again here would release the
109
+ # concurrency slot a second time (rails/solid_queue#761).
110
+ if archive_from(queue_name, msg_id, source_queue: source_queue) == :already_archived
111
+ Pgbus.logger.warn do
112
+ "[Pgbus::Executor] already archived elsewhere, skipping signals #{tag} job_class=#{job_class}"
113
+ end
114
+ release_duplicate_execution_lock(uniqueness_key, uniqueness_strategy, queue_name, msg_id)
115
+ return :duplicate
116
+ end
106
117
  Pgbus.logger.debug { "[Pgbus::Executor] archived #{tag} job_class=#{job_class}" }
107
118
  job_succeeded = true
108
119
  release_uniqueness_lock(uniqueness_key)
@@ -177,14 +188,18 @@ module Pgbus
177
188
  # Keep the message invisible while perform runs (see VisibilityHeartbeat).
178
189
  # Wraps only the perform: the heartbeat must be gone before archive or
179
190
  # the retry backoff touches the same message's VT.
180
- def with_visibility_heartbeat(job, queue_name, msg_id, source_queue, &)
191
+ def with_visibility_heartbeat(job, queue_name, msg_id, source_queue, payload, &)
181
192
  klass = job.class
182
193
  per_job = klass.respond_to?(:pgbus_visibility_heartbeat_enabled) ? klass.pgbus_visibility_heartbeat_enabled : nil
183
194
  return yield if per_job == false
184
195
 
196
+ # The heartbeat also keeps the job's semaphore alive, so `duration`
197
+ # bounds silence, not run time.
198
+ concurrency_key = Concurrency.extract_key(payload)
185
199
  VisibilityHeartbeat.track(
186
200
  client: client, queue_name: source_queue || queue_name, prefixed: source_queue.nil?,
187
- msg_id: msg_id, job_class: klass.name, config: config, &
201
+ msg_id: msg_id, job_class: klass.name, config: config,
202
+ concurrency: concurrency_key && [concurrency_key, Concurrency.config_for(klass)[:duration]], &
188
203
  )
189
204
  end
190
205
 
@@ -322,12 +337,10 @@ module Pgbus
322
337
  key = Concurrency.extract_key(payload)
323
338
  return unless key
324
339
 
325
- # Atomic permit handoff: try to promote a blocked job first.
326
- # promote_next wraps delete + enqueue in a transaction so neither is lost.
327
- # If promoted, the slot stays occupied (no release needed).
328
- # Only release the semaphore if there's nothing to promote.
329
- promoted = Concurrency::BlockedExecution.promote_next(key, client: client)
330
- Concurrency::Semaphore.release(key) unless promoted
340
+ # Release the slot and hand it to the next parked job in one
341
+ # transaction; the semaphore row lock orders this against a
342
+ # concurrent enqueue that is parking a job.
343
+ Concurrency::Semaphore.signal(key, client: client)
331
344
  rescue StandardError => e
332
345
  Pgbus.logger.warn { "[Pgbus] Concurrency signal failed: #{e.message}" }
333
346
  end
@@ -356,14 +369,26 @@ module Pgbus
356
369
  # that succeeded but failed to archive redelivers after VT expiry and
357
370
  # runs twice. If the retry also fails, fall through to the normal
358
371
  # failure path (recorded failure + VT-based redelivery).
372
+ # Returns :archived, :already_archived, or :ambiguous.
373
+ #
374
+ # A `false` reported after our OWN retry is ambiguous, not a duplicate:
375
+ # the first archive may well have committed with only its reply lost to
376
+ # the connection error that triggered the retry. Claiming it is the
377
+ # safe reading — the alternative strands this job's concurrency slot,
378
+ # batch and uniqueness state on every such blip, whereas a genuine
379
+ # duplicate would additionally require another worker to have taken and
380
+ # finished the same message inside the retry window.
359
381
  def archive_from(queue_name, msg_id, source_queue: nil)
360
382
  attempts = 0
361
383
  begin
362
- if source_queue
363
- client.archive_message(source_queue, msg_id, prefixed: false)
364
- else
365
- client.archive_message(queue_name, msg_id)
366
- end
384
+ archived = if source_queue
385
+ client.archive_message(source_queue, msg_id, prefixed: false)
386
+ else
387
+ client.archive_message(queue_name, msg_id)
388
+ end
389
+ return :archived unless archived == false
390
+
391
+ attempts.positive? ? :ambiguous : :already_archived
367
392
  rescue StandardError => e
368
393
  attempts += 1
369
394
  raise unless attempts == 1 && connection_error?(e)
@@ -375,6 +400,21 @@ module Pgbus
375
400
  end
376
401
  end
377
402
 
403
+ # A :while_executing lock is bound to THIS message, so an execution
404
+ # that loses the archive race still has to hand its own lock back —
405
+ # conditionally, so it can never delete a successor's row. An
406
+ # :until_executed lock belongs to the job as a whole and is released by
407
+ # the worker that actually archived it.
408
+ def release_duplicate_execution_lock(uniqueness_key, uniqueness_strategy, queue_name, msg_id)
409
+ return unless uniqueness_key && uniqueness_strategy == :while_executing
410
+
411
+ # Same queue the execution lock was acquired under, so the match
412
+ # cannot land on another queue's message of the same id.
413
+ UniquenessKey.release_if_bound!(uniqueness_key, queue_name: queue_name, msg_id: msg_id)
414
+ rescue StandardError => e
415
+ Pgbus.logger.warn { "[Pgbus] Duplicate-execution lock release failed: #{e.message}" }
416
+ end
417
+
378
418
  def connection_error?(error)
379
419
  defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError)
380
420
  end
@@ -7,11 +7,20 @@ module Pgbus
7
7
  module BlockedExecution
8
8
  class << self
9
9
  # Insert a blocked execution for a job that hit the concurrency limit.
10
+ #
11
+ # `expires_at` is a re-check hint for the sweep's ordering only — a
12
+ # parked job is never deleted for being old. The only way out of the
13
+ # table is promotion.
14
+ #
15
+ # The payload goes in as a Hash: the jsonb attribute serializes it
16
+ # once. Handing it a pre-serialized String stored a JSON *string*
17
+ # (double-encoded), which every document reader of the column —
18
+ # `payload->>'job_id'`, the job-class lookup, `scheduled_at` — misread.
10
19
  def insert(concurrency_key:, queue_name:, payload:, duration:, priority: 0)
11
20
  Pgbus::BlockedExecution.create!(
12
21
  concurrency_key: concurrency_key,
13
22
  queue_name: queue_name,
14
- payload: JSON.generate(payload),
23
+ payload: payload,
15
24
  priority: priority,
16
25
  expires_at: Time.current + duration
17
26
  )
@@ -23,42 +32,53 @@ module Pgbus
23
32
  Pgbus::BlockedExecution.release_next!(concurrency_key)
24
33
  end
25
34
 
26
- # Atomically promote the next blocked execution: delete the row and enqueue
27
- # the job in a single transaction. Returns true if a job was promoted, false
28
- # otherwise. This avoids losing a blocked row if enqueue fails.
35
+ # Atomically promote the next blocked execution: delete the row, take a
36
+ # semaphore slot for it and enqueue the job in a single transaction.
37
+ # Returns true if a job was promoted, false otherwise.
38
+ #
39
+ # The slot is taken through the same guarded upsert an enqueue uses,
40
+ # so a promotion can never push the key past its limit; when no slot
41
+ # is free the savepoint rolls back and the row stays parked. Runs as a
42
+ # savepoint so `Semaphore.signal` can wrap it with the release.
29
43
  def promote_next(concurrency_key, client:, delay: 0)
30
44
  released = nil
31
45
  msg_id = nil
32
- Pgbus::BlockedExecution.transaction do
46
+ Pgbus::BlockedExecution.transaction(requires_new: true) do
33
47
  released = release_next(concurrency_key)
34
48
  raise ActiveRecord::Rollback unless released
35
49
 
50
+ # Resolve the delay first: a parked scheduled job goes back into
51
+ # PGMQ invisible, and nothing renews its lease until a worker
52
+ # picks it up, so the wait has to be part of the lease.
36
53
  actual_delay = resolve_delay(released[:payload], delay)
54
+ raise ActiveRecord::Rollback unless slot_taken?(concurrency_key, released[:payload], actual_delay)
55
+
37
56
  # Carry the enqueuer's priority through: under priority routing it
38
57
  # picks the _pN sub-queue, not just the release order (issue #423).
39
58
  msg_id = client.send_message(released[:queue_name], released[:payload],
40
59
  delay: actual_delay, priority: released[:priority])
41
60
  end
42
61
 
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
50
- end
62
+ return false unless released && msg_id
51
63
 
52
- !!released
64
+ backfill(released, msg_id, client)
65
+ true
53
66
  rescue StandardError => e
54
67
  Pgbus.logger.warn { "[Pgbus] Promote blocked execution failed for #{concurrency_key}: #{e.message}" }
55
68
  false
56
69
  end
57
70
 
58
- # Delete blocked executions that have expired.
59
- # Returns the count of deleted rows.
60
- def expire_stale
61
- Pgbus::BlockedExecution.expired(Time.current).delete_all
71
+ # Sweep: promote every parked job that can take a slot right now.
72
+ # Covers what the completion-time signal cannot — a holder that died
73
+ # (its semaphore expired and was swept) or a promote that failed.
74
+ # Returns the number of jobs promoted.
75
+ def promote_pending(client:, per_key: 100)
76
+ Pgbus::BlockedExecution.repair_double_encoded!
77
+ Pgbus::BlockedExecution.promotable_keys.sum do |key|
78
+ promoted = 0
79
+ promoted += 1 while promoted < per_key && promote_next(key, client: client)
80
+ promoted
81
+ end
62
82
  end
63
83
 
64
84
  # Count blocked executions for a given key. Useful for testing/monitoring.
@@ -68,6 +88,27 @@ module Pgbus
68
88
 
69
89
  private
70
90
 
91
+ # The batch execution row is bookkeeping, not the promotion. Give it
92
+ # its own savepoint: `Semaphore.signal` calls promote_next inside a
93
+ # transaction, and a database error out here would poison that
94
+ # transaction — the commit then fails, un-deleting the parked row and
95
+ # un-taking the slot while the message is already live, so the job
96
+ # runs a second time.
97
+ def backfill(released, msg_id, client)
98
+ Pgbus::BlockedExecution.transaction(requires_new: true) do
99
+ Batch.backfill_execution(released[:payload], msg_id,
100
+ client.target_queue(released[:queue_name], released[:priority]))
101
+ end
102
+ rescue StandardError => e
103
+ Pgbus.logger.warn { "[Pgbus] Batch execution backfill failed after promote: #{e.message}" }
104
+ end
105
+
106
+ def slot_taken?(concurrency_key, payload, delay = 0)
107
+ config = Concurrency.config_for_payload(payload)
108
+ expires_at = Time.current + Concurrency.effective_duration(config[:duration]) + delay.to_i
109
+ Pgbus::Semaphore.acquire!(concurrency_key, config[:limit], expires_at) == :acquired
110
+ end
111
+
71
112
  def resolve_delay(payload, default_delay)
72
113
  scheduled_at = payload["scheduled_at"]
73
114
  return default_delay unless scheduled_at
@@ -6,6 +6,12 @@ module Pgbus
6
6
  class << self
7
7
  # Attempt to acquire a slot in the semaphore for the given key.
8
8
  # Returns :acquired if a slot was available, :blocked if the limit is reached.
9
+ #
10
+ # The upsert takes the semaphore row lock until the surrounding
11
+ # transaction commits — even when it returns :blocked. The adapter
12
+ # relies on that: parking the job inside the same transaction means a
13
+ # holder that signals concurrently waits on `release` below and then
14
+ # sees the parked row (rails/solid_queue#712).
9
15
  def acquire(key, max_value, duration)
10
16
  expires_at = Time.current + duration
11
17
  Pgbus::Semaphore.acquire!(key, max_value, expires_at)
@@ -16,6 +22,34 @@ module Pgbus
16
22
  Pgbus::Semaphore.where(key: key).update_all("value = GREATEST(value - 1, 0)")
17
23
  end
18
24
 
25
+ # A job holding a slot is done: give the slot back and hand it to the
26
+ # next parked job for this key, in one transaction. The decrement locks
27
+ # the semaphore row first, so an enqueue that is parking a job right
28
+ # now commits before the promote looks for parked rows.
29
+ def signal(key, client:)
30
+ Pgbus::Semaphore.transaction do
31
+ release(key)
32
+ BlockedExecution.promote_next(key, client: client)
33
+ end
34
+ end
35
+
36
+ # Push the semaphore's expiry out while a holder is still running.
37
+ # Driven by the visibility heartbeat, so `duration` is the longest a
38
+ # holder may go silent before its slot is presumed dead — not a cap on
39
+ # how long a job may run.
40
+ def touch(key, duration)
41
+ # Floored here as well as at acquire: renewing for a raw duration
42
+ # shorter than the gap to the next beat would let the lease lapse
43
+ # mid-run and the sweep promote beside a running job.
44
+ # Start the lease once the connection is in hand: waiting on a busy
45
+ # pool would otherwise be charged against the renewal.
46
+ floored = Concurrency.effective_duration(duration)
47
+ Pgbus::Semaphore.connection_pool.with_connection do
48
+ Pgbus::Semaphore.where(key: key)
49
+ .update_all(["expires_at = GREATEST(expires_at, ?)", Time.current + floored])
50
+ end
51
+ end
52
+
19
53
  # Delete semaphores that have expired (safety net for crashed workers).
20
54
  # Returns an array of hashes with expired keys.
21
55
  # Uses DELETE ... RETURNING for atomicity (no race between pluck and delete).
@@ -7,6 +7,10 @@ module Pgbus
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  METADATA_KEY = "pgbus_concurrency_key"
10
+ # How long a slot holder may go silent (no visibility heartbeat) before
11
+ # its slot is presumed dead. Also the limit/duration used to promote a
12
+ # parked job whose class no longer resolves.
13
+ DEFAULT_DURATION = 15 * 60
10
14
 
11
15
  class_methods do
12
16
  # Limit concurrent execution of jobs with the same key.
@@ -20,9 +24,12 @@ module Pgbus
20
24
  # Default: the ENQUEUED job's class name (resolved at
21
25
  # resolve time, so an inherited declaration keys each
22
26
  # subclass separately — issue #357).
23
- # duration: Safety expiry for semaphore (default: 15 minutes)
27
+ # duration: How long a running holder may go without a visibility
28
+ # heartbeat before its slot is presumed dead and swept
29
+ # (default: 15 minutes). Not a cap on run time: the
30
+ # heartbeat keeps the semaphore alive while the job runs.
24
31
  # on_conflict: What to do when limit is reached — :block, :discard, or :raise (default: :block)
25
- def limits_concurrency(to:, key: nil, duration: 15 * 60, on_conflict: :block) # rubocop:disable Naming/MethodParameterName
32
+ def limits_concurrency(to:, key: nil, duration: DEFAULT_DURATION, on_conflict: :block) # rubocop:disable Naming/MethodParameterName
26
33
  raise ArgumentError, "to: must be a positive integer" unless to.is_a?(Integer) && to.positive?
27
34
  raise ArgumentError, "on_conflict must be :block, :discard, or :raise" unless %i[block discard raise].include?(on_conflict)
28
35
  raise ArgumentError, "duration must be a positive number" unless duration.is_a?(Numeric) && duration.positive?
@@ -71,6 +78,33 @@ module Pgbus
71
78
  def extract_key(payload)
72
79
  payload[METADATA_KEY]
73
80
  end
81
+
82
+ # Limit and duration for a job class. A class with no concurrency
83
+ # config — or one that no longer resolves — gets `limit: nil`, meaning
84
+ # "whatever limit the semaphore row already records". Forcing 1 here
85
+ # would refuse every promotion for a `to: 3` key that still holds two
86
+ # slots, so its parked jobs could never reach the executor (which is
87
+ # what dead-letters a missing class).
88
+ def config_for(job_class)
89
+ config = job_class.respond_to?(:pgbus_concurrency) && job_class.pgbus_concurrency
90
+ return { limit: nil, duration: DEFAULT_DURATION } unless config
91
+
92
+ { limit: config[:limit], duration: config[:duration] }
93
+ end
94
+
95
+ # A slot's lease is only renewed by the visibility heartbeat, which
96
+ # first beats one interval after the job starts. A `duration` shorter
97
+ # than that would expire before the first touch and let the sweep
98
+ # promote beside a running job, so it is floored at two intervals.
99
+ def effective_duration(duration, config: Pgbus.configuration)
100
+ [duration, config.effective_visibility_heartbeat_interval * 2].max
101
+ end
102
+
103
+ def config_for_payload(payload)
104
+ config_for(Object.const_get(payload["job_class"].to_s))
105
+ rescue NameError
106
+ config_for(nil)
107
+ end
74
108
  end
75
109
  end
76
110
  end
@@ -355,25 +355,18 @@ module Pgbus
355
355
  end
356
356
 
357
357
  def cleanup_concurrency
358
- expired_keys = Concurrency::Semaphore.expire_stale
359
- expired_keys.each do |row|
360
- release_blocked_for_key(row["key"])
361
- end
358
+ expired = Concurrency::Semaphore.expire_stale
359
+ Pgbus.logger.debug { "[Pgbus] Swept #{expired.size} expired semaphores" } if expired.any?
362
360
 
363
- orphaned = Concurrency::BlockedExecution.expire_stale
364
- Pgbus.logger.debug { "[Pgbus] Expired #{orphaned} orphaned blocked executions" } if orphaned.positive?
361
+ # A parked job is never dropped: the sweep only promotes what can take
362
+ # a slot now behind a swept semaphore or a promote that failed.
363
+ promoted = Concurrency::BlockedExecution.promote_pending(client: Pgbus.client)
364
+ Pgbus.logger.debug { "[Pgbus] Promoted #{promoted} blocked executions" } if promoted.positive?
365
365
  rescue StandardError => e
366
366
  log_maintenance_failure("Concurrency cleanup", e)
367
367
  e
368
368
  end
369
369
 
370
- def release_blocked_for_key(key)
371
- promoted = Concurrency::BlockedExecution.promote_next(key, client: Pgbus.client)
372
- Pgbus.logger.debug { "[Pgbus] Released blocked execution for key: #{key}" } if promoted
373
- rescue StandardError => e
374
- log_maintenance_failure("Releasing blocked execution for #{key}", e)
375
- end
376
-
377
370
  def cleanup_batches
378
371
  retention = config.batch_retention
379
372
  return unless retention&.positive?
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.16.3"
4
+ VERSION = "0.16.4"
5
5
  end
@@ -24,8 +24,10 @@ module Pgbus
24
24
  # cadence with `config.visibility_heartbeat_interval`, or opt a job class
25
25
  # out with `pgbus_visibility_heartbeat false`.
26
26
  module VisibilityHeartbeat
27
+ # `concurrency` is `[key, duration]` for a concurrency-limited job, else
28
+ # nil — one member, so the struct stays inside an 80-byte slot.
27
29
  Entry = Struct.new(:client, :queue_name, :prefixed, :msg_id, :job_class, :extended_at, :extensions,
28
- keyword_init: true)
30
+ :concurrency, keyword_init: true)
29
31
 
30
32
  # Per-job opt-out, included on ActiveJob::Base by the engine:
31
33
  #
@@ -55,11 +57,15 @@ module Pgbus
55
57
  # @param prefixed [Boolean] whether queue_name still needs the prefix
56
58
  # @param job_class [String, nil] for logging and instrumentation
57
59
  # @param config [Pgbus::Configuration]
58
- def track(client:, queue_name:, msg_id:, prefixed: true, job_class: nil, config: Pgbus.configuration)
60
+ # @param concurrency [Array(String, Numeric), nil] semaphore key to keep alive alongside
61
+ # the message and how far to push its expiry on each beat
62
+ def track(client:, queue_name:, msg_id:, prefixed: true, job_class: nil, config: Pgbus.configuration,
63
+ concurrency: nil)
59
64
  return yield unless config.visibility_heartbeat
60
65
 
61
66
  entry = Entry.new(client: client, queue_name: queue_name, prefixed: prefixed, msg_id: msg_id.to_i,
62
- job_class: job_class, extended_at: monotonic_now, extensions: 0)
67
+ job_class: job_class, extended_at: monotonic_now, extensions: 0,
68
+ concurrency: concurrency)
63
69
  register(entry, config)
64
70
  begin
65
71
  yield
@@ -123,6 +129,7 @@ module Pgbus
123
129
  entry.client.set_visibility_timeout(entry.queue_name, entry.msg_id, vt: vt, prefixed: entry.prefixed)
124
130
  entry.extended_at = now
125
131
  entry.extensions += 1
132
+ touch_semaphore(entry)
126
133
  Instrumentation.instrument(
127
134
  "pgbus.job_visibility_extended",
128
135
  queue: entry.queue_name, job_class: entry.job_class, msg_id: entry.msg_id, vt: vt,
@@ -165,6 +172,20 @@ module Pgbus
165
172
 
166
173
  # Entries registered before a fork belong to the parent's jobs; the
167
174
  # thread did not survive the fork either.
175
+ # A live holder keeps its semaphore from being swept, so `duration`
176
+ # bounds heartbeat silence rather than run time. Its own rescue: a
177
+ # failed touch must not cost the message its visibility extension.
178
+ def touch_semaphore(entry)
179
+ key, duration = entry.concurrency
180
+ return unless key
181
+
182
+ Concurrency::Semaphore.touch(key, duration || Concurrency::DEFAULT_DURATION)
183
+ rescue StandardError => e
184
+ Pgbus.logger.warn do
185
+ "[Pgbus::VisibilityHeartbeat] could not touch semaphore #{key}: #{e.class}: #{e.message}"
186
+ end
187
+ end
188
+
168
189
  def forget_parent_entries!
169
190
  return if @pid == ::Process.pid
170
191
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.3
4
+ version: 0.16.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson