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 +4 -4
- data/CHANGELOG.md +25 -0
- data/app/models/pgbus/blocked_execution.rb +33 -7
- data/app/models/pgbus/semaphore.rb +8 -3
- data/app/models/pgbus/uniqueness_key.rb +16 -0
- data/lib/pgbus/active_job/adapter.rb +83 -9
- data/lib/pgbus/active_job/executor.rb +55 -15
- data/lib/pgbus/concurrency/blocked_execution.rb +59 -18
- data/lib/pgbus/concurrency/semaphore.rb +34 -0
- data/lib/pgbus/concurrency.rb +36 -2
- data/lib/pgbus/process/dispatcher.rb +6 -13
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/visibility_heartbeat.rb +24 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ddf8b7dde9919fc1ad8de39311bacac6ad8c50fadeac059035c5c0ff9b5c30c6
|
|
4
|
+
data.tar.gz: 719cec84b8486fac601755a84ea457f926b5b1b5faed8121e61a33a9c52b4972
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
11
|
-
#
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 <
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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:
|
|
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
|
|
27
|
-
# the job in a single transaction.
|
|
28
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
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).
|
data/lib/pgbus/concurrency.rb
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|