pgbus 0.14.2 → 0.15.1

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: 6ba7f4aa13c44c41c30fb021cbb3e087d643b147895a80a84ddbc022f40088a7
4
- data.tar.gz: 3015145e2f795d83d543d786c4f5d462854123f3a9e8eb946c2b74de059ee31a
3
+ metadata.gz: c419d130102e92e1bd355df6e98211e44eeead76298f9ce58bd6c285b1de24a9
4
+ data.tar.gz: dab0eefe9c7a23aee676b928521841adffb6d38f084993c6bb3ab56f9ade207f
5
5
  SHA512:
6
- metadata.gz: dc4d0ccfac9ab0f4f57c271fe98e2a7716cfbac10daba7ca5b0ad81b59f43a51a775a1780e36faa395f23ed8084376a7e21645390e8a5f6463e1100a4db007d6
7
- data.tar.gz: 684ce73b5e1e43be5d9616319da6d29ed22acf98e5384d662b63d7020ed234d8b94e2783f97a45062ae532633507b0cf44afe0007a68ea4894b55b23e631b018
6
+ metadata.gz: d8cedff08ffed91f3b9a9c1b9df1b2147790ec9bf7e5117b22a4af19b865b239df4883d6708153ec2efc9de5207e007cb4d2cfd06440ac3b4bd25e63d1eea369
7
+ data.tar.gz: fcb2ec48f7daf53dc46d5c388cb3b4e1acd99a568e74e92253736efb8ba4bc52fd9704d287fbaafcea893d063676b3f68ceac3d82b79fc3906ef4d7bbc43dcd3
data/CHANGELOG.md CHANGED
@@ -1,7 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Fixed
4
+
5
+ - **`current_attributes` capture skips an unpersisted record instead of raising at enqueue (issue #435).** An attribute holding an Active Record instance with no id — a dev-mode fallback record assigned to `Current`, a form-built model captured before `save`, a destroyed record whose locate is guaranteed to fail — made every `perform_later` in that context raise `Pgbus::CurrentAttributesError`, even though such a record can never round-trip (no id → no GlobalID) and capture is ambient: the enqueuer never opted into persisting that attribute per-call, so its momentary state must not abort the enqueue. `capture` now skips any attribute value that answers `persisted?` falsey (so destroyed-but-id-bearing records are skipped too, not just `new_record?`) with a debug log naming the class, attribute and why; the rest of the class's attributes still persist. The `except:` guidance and the loud `CurrentAttributesError` remain for genuinely unserializable values — objects without the Active Record duck-type (`respond_to?(:persisted?)`) are untouched. Applies to jobs and event-bus publish alike (same capture path). Refs #435.
6
+
3
7
  ### Added
4
8
 
9
+ - **First-class `Current` support: ActiveSupport::CurrentAttributes persist across enqueue → perform (issue #430).** The executor already reset `CurrentAttributes` around every job (so nothing leaked) but nothing restored it — `Current.tenant` was always nil inside a job. New `config.current_attributes` (`nil` = off; `:auto` = every `ActiveSupport::CurrentAttributes` subclass; an Array of classes/names; or a Hash of class => `{ only: [...] }` / `{ except: [...] }`). The new `Pgbus::ActiveJob::CurrentAttributes` mixin (included on `ActiveJob::Base` by the engine next to `BatchId`) captures the assigned attributes of each persisted class in `serialize` — serialized with `ActiveJob::Arguments`, so records become GlobalIDs and fall under the `allowed_global_id_models` allowlist on the way back — under the job-hash key `pgbus_current`, and restores them by wrapping the **whole** `perform_now` in nested `Current.set`, so `before_perform`, `perform`, `rescue_from`, `retry_on` / `discard_on` blocks and jobs enqueued from inside `perform` all see the context, under the pgbus worker and Rails' `:test` / `:inline` adapters alike. A deserialized job re-serializes the context it was enqueued with, so a `retry_on` re-enqueue keeps the original; concurrency-blocked promotion, dead-letter / dashboard retry and `perform_all_later` carry it by construction. An unserializable attribute raises `Pgbus::CurrentAttributesError` at `perform_later` naming the class, attribute and the `except:` fix — nothing is dropped silently; a class that no longer exists or an attribute no longer defined is skipped with a log line (Sidekiq parity). Per job class: `self.pgbus_persist_current_attributes = false` or a spec override. Unconfigured installs have byte-identical payloads. Dashboard: failed-job and dead-letter detail pages gain a **Context** card (`Pgbus::Web::JobContext`, through `PayloadFilter`). Event bus follows in #431. Refs #430.
10
+
11
+ - **Current attributes propagate publish → handler on the event bus (issue #431).** Follow-up to #430: `Pgbus.publish` from a request left `Current` empty in every event handler. With the same `config.current_attributes` switch on, the publisher now snapshots each persisted class (same `only:`/`except:` filters, same `ActiveJob::Arguments` serialization) into the **event envelope** under the same `pgbus_current` key jobs use (a sibling of `event_id`, never inside the user payload — `event.payload` is unchanged), PGMQ's topic fan-out copies it to every subscriber queue, and the handler pipeline restores it around `handle` (inside the Rails-executor wrap; previous values come back after each event — no cross-event leak on a consumer thread). `Pgbus::Event#context` exposes the raw stored form. `Pgbus::Outbox.publish_event` captures at write time — inside the app transaction, where `Current` is set — and the relay carries the envelope unchanged. GlobalIDs inside the context are gated by `allowed_global_id_models` in `Handler#build_event` before anything is located, the same boundary jobs gate at. `Pgbus::Testing` round-trips it (fake-mode events expose `context`; `drain!` and inline dispatch restore it), and the dashboard's pending-events rows show the same Context card as the failed-job pages. Default-off is byte-identical. Refs #431.
12
+ - **Fix: outbox events now dispatch to handlers.** `Outbox.publish_event` built its envelope without `routing_key` and `pgmq.send_topic` stamps nothing on the message, so a relayed outbox event reached the right subscriber queues but the consumer read no routing key, matched **zero** handlers, and archived the event unrun. The envelope now carries `routing_key:` exactly like a direct `Pgbus.publish`. Entries written by older code (already relayed or pending) still lack it — re-publish those events if any are pending when you upgrade. Refs #431.
13
+ - **Fair share scheduling for event-bus consumers (issue #427).** Follow-up to #426: a subscriber queue is FIFO, so a bulk import emitting `orders.created` 100 000 times for one tenant put every other tenant's events behind it in every handler subscribed to that topic. New `config.event_fair_share = ->(event) { key | [key, weight] | nil }` — the event twin of `fair_share` — receives the `Pgbus::Event` (routing key, the payload object as passed to publish, headers) at publish time on every path (`Pgbus.publish`, `publish_later`, `Pgbus::Outbox.publish_event`) and merges the same `pgbus_fair_key` / `pgbus_fair_weight` into the **event envelope** (a sibling of `event_id` / `published_at`, never inside the user payload, so `event.payload` is unchanged in handlers). Because the key lives at `message->>'pgbus_fair_key'` for events exactly as for jobs, #426's `read_batch_fair` SQL and `q_<queue>_fair_idx` index are reused verbatim; PGMQ's topic fan-out copies the tag to every bound subscriber queue, and the outbox (which stores the envelope) carries it across the relay with no extra work — a system writing `pgbus_outbox_entries` directly can set the key in the envelope JSON. `Consumer#fetch_messages` now fair-reads each active subscriber queue in list order with the remaining capacity (strict across queues, fair within; circuit-breaker-paused queues still skipped), ensures the fair index on its queues at boot, and `Subscriber#setup!` ensures it at queue creation. Independent of `fair_share` (enable either side or both); no interaction with `group_mode` (worker-only). Default-off is byte-identical. Refs #427.
14
+ - **Fair share scheduling across tenants — weighted, work-conserving (issue #426).** A queue is FIFO, so one tenant enqueuing 100 000 jobs put every other tenant's work behind them; nothing in pgbus prevented it (multi-queue reads are strict list-order, `group_mode` serializes PGMQ FIFO groups and pgbus never set its header for jobs, `limits_concurrency` is a non-work-conserving cap). New `config.fair_share = ->(job) { key | [key, weight] | nil }` is evaluated at enqueue on every adapter path (`enqueue`, `enqueue_at`, `perform_all_later`); the key and weight ride inside the job payload as `pgbus_fair_key` / `pgbus_fair_weight` (same pattern as `pgbus_concurrency_key`, so they survive blocked-execution promotion, DLQ retry and dashboard retry). Workers then read with the new `Client#read_batch_fair` — one statement that enumerates the keys with visible messages via a loose index scan, ranks each key's oldest visible messages, and takes the `qty` lowest `rank / weight`: weight 3 vs 1 is a 3:1 split while both have work, a lone tenant still fills the whole batch, and the cost scales with the number of keys that have visible work, not backlog depth (`rake bench:fair_read`: 1.05 ms with 1 key, 4.7 ms with 200 keys, 100k-row backlog; numbers in `docs/performance.md`). Strict list-order priority across queues is preserved (fair within each queue); with `priority_levels` it is strict between levels and fair within a level; mutually exclusive with `group_mode` (`validate!` and `Worker.new` both raise). The supporting expression index `q_<queue>_fair_idx ((COALESCE(message->>'pgbus_fair_key','')), vt, msg_id)` is created at queue creation when the option is on, and built `CONCURRENTLY` by each worker for the queues it already serves (`Client#ensure_fair_index`, memoized, logs the `DROP INDEX` remediation if a concurrent build is interrupted). Within a key messages are taken oldest-visible first (`vt, msg_id`) — a deliberate deviation from `pgmq.read`'s pure `msg_id` order so a tenant's whole visible backlog is never sorted per read. Event-bus consumers get the same in #427 (below). Refs #426.
15
+
5
16
  - **Batches v2: open batches, a `batch` accessor on jobs and callbacks, and configured callback instances (issue #415).** ⚠️ **Breaking (pre-1.0)**: `Pgbus::Batch.find` now returns a rehydrated `Pgbus::Batch` handle instead of the raw attributes Hash — read the values off the handle (`status`, `total_jobs`, `completed_jobs`, `failed_jobs`, `pending_jobs`, `progress_percentage`, `finished?`, `description`, `properties`), or query `Pgbus::BatchEntry` for a row. Three capabilities: **(1) Open batches.** `batch.enqueue` is re-callable while the batch is unfinished — the second and later calls add to the existing group (`BatchEntry.increment_total_jobs!`, guarded on an unfinished row) instead of hitting the unique index on `batch_id`, so multi-stage workflows are possible. Adding to a finished batch raises `Pgbus::Batch::AlreadyFinished`. Execution rows are inserted as the block enqueues, before `total_jobs` is bumped, so the single-winner finish always sees outstanding work; a `check_finished!` after the bump covers a block whose jobs all completed while it was still open. Membership stays explicit: only jobs enqueued inside an `enqueue` block join the batch, so a fan-out from a batched job never silently extends it. **(2) `batch` accessor.** New `Pgbus::ActiveJob::BatchId` mixin (included on `ActiveJob::Base` from the engine alongside `Concurrency` and `Uniqueness`) adds `batch_id` / `callback_batch_id` accessors, round-trips both through `serialize`/`deserialize` — omitted from the payload when unset, so an unbatched job's serialized hash is unchanged — and exposes a memoized `batch` reader. The executor assigns `batch_id` from the payload's `pgbus_batch_id` before `perform`, so a running job can call `batch.enqueue` to add siblings. **(3) Configured callback instances.** `on_finish:` / `on_success:` / `on_failure:` accept an ActiveJob instance as well as a class: `on_finish: ReportJob.new.set(queue: :critical, wait: 5.minutes)`. `.set` options resolve at batch-creation time (matching solid_queue) into new `on_finish_job` / `on_success_job` / `on_failure_job` jsonb columns; at fire time the job is deserialized, given `callback_batch_id`, has its own `batch_id` cleared (a callback is never a member of the batch it reports on) and is enqueued on its configured queue and `scheduled_at`. Legacy class-name callbacks keep the `perform_later(properties)` signature — deprecated at 1.0 in favour of `batch.properties`. The dashboard shows a configured callback's `job_class`. Existing installs: `rails generate pgbus:add_batch_callback_jobs` (or `pgbus:update`); apps that have not migrated keep storing bare classes only. Refs #415.
6
17
 
7
18
  - **Batch completion is now self-healing via execution-row tracking (issue #414).** ⚠️ **Restart pgbus workers after `rails generate pgbus:add_batch_executions` / `db:migrate`** — a process that still writes `discarded_jobs` will error once that column is gone. `pgbus_batches` no longer finishes on `completed + discarded == total` — that arithmetic stalled a batch *forever* if a worker crashed between `archive_from` and `signal_batch_completed`, and a crash mid-`Batch#enqueue` left an immortal `pending` row (`cleanup_batches` only deletes `finished`). Each batched job now gets a `pgbus_batch_executions` row (identity = ActiveJob `job_id`) inserted *before* `send_message`, with `msg_id` + `queue_name` backfilled after send. The batch finishes when no rows remain (single-winner `UPDATE … AND NOT EXISTS`, plus a fresh `exists?` re-check for Postgres READ COMMITTED). A dispatcher sweep (`config.batch_sweep_interval`, default 5 minutes) repairs four crash windows: stale rows whose PGMQ message is gone (archived → completed, DLQ → failed, missing → completed with a warning); orphan rows with `msg_id` NULL older than 5 minutes (enqueue crashed between insert and send — blocked concurrency jobs are excluded); stalled `pending` batches; stalled `processing` batches with zero rows. `on_failure:` / `failed_jobs` are canonical (`on_discard:` / `discarded_jobs` remain deprecated aliases until 1.0). `config.batch_retention` (default 7 days, `nil` disables) replaces the hardcoded cleanup window. Existing installs: `rails generate pgbus:add_batch_executions` (or `pgbus:update`). Unmigrated apps keep the counter path via `Batch.executions_migrated?`. Refs #414.
data/README.md CHANGED
@@ -646,6 +646,36 @@ end
646
646
 
647
647
  When `priority_levels` is `nil` (default), priority queues are disabled and all jobs go to a single queue per logical name.
648
648
 
649
+ ### Fair share across tenants
650
+
651
+ One tenant enqueuing 100 000 jobs must not put every other tenant's work behind them. `config.fair_share` tags each job with a key (and optional weight) at enqueue, and the worker's read interleaves across keys inside each queue — a weighted, work-conserving round-robin. No per-tenant queues.
652
+
653
+ ```ruby
654
+ Pgbus.configure do |config|
655
+ # key (String/Symbol/Integer), [key, weight], or nil to leave a job unkeyed
656
+ config.fair_share = ->(job) { [Current.tenant&.id, Current.tenant&.plan_weight || 1] }
657
+ end
658
+ ```
659
+
660
+ Weight 3 vs 1 yields a 3:1 split while both have work; a lone tenant still gets the whole worker. Composes with `priority_levels` (strict between levels, fair within) and `limits_concurrency`; mutually exclusive with `group_mode`. Workers build the supporting index `CONCURRENTLY` on queues they already serve. Details: [Routing & ordering](https://pgbus.zoolutions.llc/docs/routing-ordering).
661
+
662
+ The event bus has the same knob: `config.event_fair_share = ->(event) { key | [key, weight] | nil }` receives the `Pgbus::Event` at publish (`Pgbus.publish`, `publish_later`, `Outbox.publish_event`), tags the event envelope — never your payload — and consumers interleave reads across keys inside each subscriber queue. Details: [Event bus](https://pgbus.zoolutions.llc/docs/event-bus).
663
+
664
+ ### Current attributes
665
+
666
+ `ActiveSupport::CurrentAttributes` is reset around every job. Ask pgbus to carry it and `Current.tenant` / `Current.user` / `Current.request_id` are there inside `perform` — and inside `retry_on` / `discard_on` blocks, under the pgbus worker and Rails' `:test` / `:inline` adapters alike:
667
+
668
+ ```ruby
669
+ Pgbus.configure do |config|
670
+ config.current_attributes = :auto # or [Current, "Admin::Current"], or { Current => { except: [:request] } }
671
+ config.fair_share = ->(job) { Current.tenant&.id } # pairs naturally with fair share
672
+ end
673
+ ```
674
+
675
+ Events get the same hop: `Pgbus.publish` captures `Current` into the event envelope and the consumer restores it around every `handle` — including across the transactional outbox (captured at `Outbox.publish_event`, inside your transaction). Handlers can also read the raw form via `event.context`.
676
+
677
+ Captured at enqueue via `ActiveJob::Arguments` (records become GlobalIDs, gated by `allowed_global_id_models`), preserved across retries, concurrency-blocked promotion, dead-letter retry and `perform_all_later`; an unserializable attribute raises at `perform_later` with the `except:` fix, while an **unpersisted** record (no id — it could never be restored) is skipped with a debug log instead of aborting the enqueue. The dashboard shows the context on failed-job and dead-letter pages. Details: [Active Job → Current attributes](https://pgbus.zoolutions.llc/docs/active-job).
678
+
649
679
  ### Consumer priority
650
680
 
651
681
  When multiple workers subscribe to the same queues, higher-priority workers process messages first. Lower-priority workers back off (3x polling interval) when a higher-priority worker is active.
@@ -2176,6 +2206,9 @@ Curated headline options for the README. The full operator reference (with types
2176
2206
  | `priority_levels` | `nil` | Number of priority sub-queues (nil = disabled, 2-10) |
2177
2207
  | `default_priority` | `1` | Default priority for jobs without explicit priority |
2178
2208
  | `group_mode` | `nil` | Grouped-read ordering mode for a queue. Experimental — exempt from the 1.0 stability promise. |
2209
+ | `fair_share` | `nil` | Callable `->(job) { key \| [key, weight] \| nil }` evaluated at enqueue; workers interleave reads across keys (weighted, work-conserving). Mutually exclusive with `group_mode` |
2210
+ | `event_fair_share` | `nil` | Event-bus twin of `fair_share`: callable `->(event) { key \| [key, weight] \| nil }` receiving the `Pgbus::Event` at publish; consumers interleave reads across keys within each subscriber queue. Independent of `fair_share` |
2211
+ | `current_attributes` | `nil` | Persist `ActiveSupport::CurrentAttributes` across enqueue → perform: `:auto`, an Array of classes/names, or `{ Current => { except: [...] } }`. Restored around the whole `perform_now` |
2179
2212
  | `archive_retention` | `7.days` | How long to keep archived messages. Accepts seconds, Duration, or `nil` to disable cleanup |
2180
2213
  | `outbox_enabled` | `false` | Enable transactional outbox poller process |
2181
2214
  | `outbox_poll_interval` | `1.0` | Seconds between outbox poll cycles |
data/Rakefile CHANGED
@@ -25,7 +25,7 @@ namespace :bench do
25
25
  # no-DB unit suite that bench:all runs in CI.
26
26
  db_benches = %w[connection_pool_bench integration_bench streams_bench streams_read_pool_bench
27
27
  execution_modes_bench pool_swap_bench pool_autoscale_bench job_burst_bench
28
- notify_wake_bench notify_chaos_bench streams_hub_bench].freeze
28
+ notify_wake_bench notify_chaos_bench streams_hub_bench fair_read_bench].freeze
29
29
  # The unit suite is every *_bench.rb that doesn't need a database, derived
30
30
  # from the directory so a new unit bench is picked up automatically (kept in
31
31
  # sync with bench:one, which globs the same files).
@@ -60,6 +60,11 @@ namespace :bench do
60
60
  ruby "benchmarks/integration_bench.rb"
61
61
  end
62
62
 
63
+ desc "Run fair share read benchmark (requires PGBUS_DATABASE_URL)"
64
+ task :fair_read do
65
+ ruby "benchmarks/fair_read_bench.rb"
66
+ end
67
+
63
68
  desc "Run streams benchmarks (requires PGBUS_DATABASE_URL; boots real Puma + SSE)"
64
69
  task :streams do
65
70
  ruby "benchmarks/streams_bench.rb"
@@ -143,6 +143,13 @@ module Pgbus
143
143
  tag.span(I18n.t("pgbus.helpers.batch_status.#{status}", default: status), class: css)
144
144
  end
145
145
 
146
+ # Persisted Current attributes for a job payload, as
147
+ # { "Current" => { "tenant" => "gid://…", ... } } or nil (issue #430).
148
+ # Goes through pgbus_parse_message so PayloadFilter redaction applies.
149
+ def pgbus_job_context(message)
150
+ Pgbus::Web::JobContext.from_payload(pgbus_parse_message(message))
151
+ end
152
+
146
153
  def pgbus_parse_message(message)
147
154
  return {} unless message
148
155
 
@@ -26,6 +26,23 @@
26
26
  </dl>
27
27
  </div>
28
28
 
29
+ <% if (context = pgbus_job_context(@message[:message])) %>
30
+ <div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700 p-6 mb-6" data-testid="job-context">
31
+ <h2 class="text-sm font-medium text-gray-500 mb-2"><%= t("pgbus.dead_letter.show.context") %></h2>
32
+ <% context.each do |klass_name, attrs| %>
33
+ <h3 class="text-xs font-semibold text-gray-700 dark:text-gray-300 mt-3 mb-1 font-mono"><%= klass_name %></h3>
34
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-1 sm:grid-cols-2">
35
+ <% attrs.each do |name, value| %>
36
+ <div class="flex gap-2 text-xs">
37
+ <dt class="text-gray-500 font-mono"><%= name %></dt>
38
+ <dd class="text-gray-900 dark:text-white font-mono break-all"><%= value %></dd>
39
+ </div>
40
+ <% end %>
41
+ </dl>
42
+ <% end %>
43
+ </div>
44
+ <% end %>
45
+
29
46
  <div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700 p-6 mb-6">
30
47
  <h2 class="text-sm font-medium text-gray-500 mb-2"><%= t("pgbus.dead_letter.show.payload") %></h2>
31
48
  <pre class="text-xs text-gray-600 bg-gray-50 dark:bg-gray-900 rounded p-4 overflow-x-auto"><%= JSON.pretty_generate(JSON.parse(@message[:message])) rescue @message[:message] %></pre>
@@ -102,6 +102,27 @@
102
102
  </div>
103
103
  </div>
104
104
  </div>
105
+ <%# Persisted Current attributes (issue #431) %>
106
+ <% if (event_context = pgbus_job_context(m[:message])) %>
107
+ <div class="mb-3" data-testid="job-context">
108
+ <span class="text-xs font-medium text-gray-500"><%= t("pgbus.events.pending_table.context") %></span>
109
+ <div class="text-xs text-gray-600 bg-white dark:bg-gray-800 rounded p-2 mt-1 space-y-2">
110
+ <% event_context.each do |klass_name, attrs| %>
111
+ <div>
112
+ <p class="font-semibold text-gray-700 dark:text-gray-300 font-mono"><%= klass_name %></p>
113
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-0.5 sm:grid-cols-2">
114
+ <% attrs.each do |name, value| %>
115
+ <div class="flex gap-2">
116
+ <dt class="text-gray-500 font-mono"><%= name %></dt>
117
+ <dd class="text-gray-900 dark:text-white font-mono break-all"><%= value %></dd>
118
+ </div>
119
+ <% end %>
120
+ </dl>
121
+ </div>
122
+ <% end %>
123
+ </div>
124
+ </div>
125
+ <% end %>
105
126
  <%# Edit Payload form %>
106
127
  <details class="mt-2">
107
128
  <summary class="text-xs font-medium text-indigo-600 cursor-pointer hover:text-indigo-500"><%= t("pgbus.events.pending_table.edit_payload") %></summary>
@@ -38,6 +38,23 @@
38
38
  </div>
39
39
  <% end %>
40
40
 
41
+ <% if (context = pgbus_job_context(@job["payload"])) %>
42
+ <div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700 p-6 mb-6" data-testid="job-context">
43
+ <h2 class="text-sm font-medium text-gray-500 mb-2"><%= t("pgbus.jobs.show.context") %></h2>
44
+ <% context.each do |klass_name, attrs| %>
45
+ <h3 class="text-xs font-semibold text-gray-700 dark:text-gray-300 mt-3 mb-1 font-mono"><%= klass_name %></h3>
46
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-1 sm:grid-cols-2">
47
+ <% attrs.each do |name, value| %>
48
+ <div class="flex gap-2 text-xs">
49
+ <dt class="text-gray-500 font-mono"><%= name %></dt>
50
+ <dd class="text-gray-900 dark:text-white font-mono break-all"><%= value %></dd>
51
+ </div>
52
+ <% end %>
53
+ </dl>
54
+ <% end %>
55
+ </div>
56
+ <% end %>
57
+
41
58
  <div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700 p-6 mb-6">
42
59
  <h2 class="text-sm font-medium text-gray-500 mb-2"><%= t("pgbus.jobs.show.payload") %></h2>
43
60
  <pre class="text-xs text-gray-600 bg-gray-50 dark:bg-gray-900 rounded p-4 overflow-x-auto"><%= JSON.pretty_generate(pgbus_parse_message(@job["payload"])) %></pre>
@@ -131,6 +131,7 @@ da:
131
131
  total_label: beskeder i dead letter-kø
132
132
  show:
133
133
  back: Tilbage til DLQ
134
+ context: Kontekst
134
135
  discard: Kassér
135
136
  discard_confirm: Slet permanent?
136
137
  headers: Overskrifter
@@ -194,6 +195,7 @@ da:
194
195
  title: Begivenheder
195
196
  pending_table:
196
197
  arguments: Payload
198
+ context: Kontekst
197
199
  discard: Forkast
198
200
  discard_confirm: Forkast denne begivenhed? Den vil blive arkiveret.
199
201
  edit_payload: Rediger & prøv igen
@@ -372,6 +374,7 @@ da:
372
374
  show:
373
375
  back: Tilbage til job
374
376
  backtrace: Stacktrace
377
+ context: Kontekst
375
378
  discard: Kassér
376
379
  discard_confirm: Slet dette job permanent?
377
380
  error_message: Fejlmeddelelse
@@ -131,6 +131,7 @@ de:
131
131
  total_label: Nachrichten in der Dead-Letter-Warteschlange
132
132
  show:
133
133
  back: Zurück zur DLQ
134
+ context: Kontext
134
135
  discard: Verwerfen
135
136
  discard_confirm: Dauerhaft verwerfen?
136
137
  headers: Header
@@ -194,6 +195,7 @@ de:
194
195
  title: Ereignisse
195
196
  pending_table:
196
197
  arguments: Nutzlast
198
+ context: Kontext
197
199
  discard: Verwerfen
198
200
  discard_confirm: Dieses Ereignis verwerfen? Es wird archiviert.
199
201
  edit_payload: Bearbeiten & erneut versuchen
@@ -372,6 +374,7 @@ de:
372
374
  show:
373
375
  back: Zurück zu Jobs
374
376
  backtrace: Stacktrace
377
+ context: Kontext
375
378
  discard: Verwerfen
376
379
  discard_confirm: Diesen Auftrag dauerhaft verwerfen?
377
380
  error_message: Fehlermeldung
@@ -131,6 +131,7 @@ en:
131
131
  total_label: messages in dead letter queue
132
132
  show:
133
133
  back: Back to DLQ
134
+ context: Context
134
135
  discard: Discard
135
136
  discard_confirm: Permanently discard?
136
137
  headers: Headers
@@ -194,6 +195,7 @@ en:
194
195
  title: Events
195
196
  pending_table:
196
197
  arguments: Payload
198
+ context: Context
197
199
  discard: Discard
198
200
  discard_confirm: Discard this event? It will be archived.
199
201
  edit_payload: Edit & Retry
@@ -372,6 +374,7 @@ en:
372
374
  show:
373
375
  back: Back to Jobs
374
376
  backtrace: Backtrace
377
+ context: Context
375
378
  discard: Discard
376
379
  discard_confirm: Discard this job permanently?
377
380
  error_message: Error Message
@@ -131,6 +131,7 @@ es:
131
131
  total_label: mensajes en la cola de mensajes fallidos
132
132
  show:
133
133
  back: Volver a DLQ
134
+ context: Contexto
134
135
  discard: Descartar
135
136
  discard_confirm: "¿Descartar permanentemente?"
136
137
  headers: Encabezados
@@ -194,6 +195,7 @@ es:
194
195
  title: Eventos
195
196
  pending_table:
196
197
  arguments: Carga útil
198
+ context: Contexto
197
199
  discard: Descartar
198
200
  discard_confirm: "¿Descartar este evento? Será archivado."
199
201
  edit_payload: Editar y reintentar
@@ -372,6 +374,7 @@ es:
372
374
  show:
373
375
  back: Volver a Trabajos
374
376
  backtrace: Rastreo de pila
377
+ context: Contexto
375
378
  discard: Descartar
376
379
  discard_confirm: "¿Descartar este trabajo permanentemente?"
377
380
  error_message: Mensaje de error
@@ -131,6 +131,7 @@ fi:
131
131
  total_label: viestiä dead letter -jonossa
132
132
  show:
133
133
  back: Takaisin DLQ:hon
134
+ context: Konteksti
134
135
  discard: Hylkää
135
136
  discard_confirm: Hylätään pysyvästi?
136
137
  headers: Otsikot
@@ -194,6 +195,7 @@ fi:
194
195
  title: Tapahtumat
195
196
  pending_table:
196
197
  arguments: Kuorma
198
+ context: Konteksti
197
199
  discard: Hylkää
198
200
  discard_confirm: Hylätäänkö tämä tapahtuma? Se arkistoidaan.
199
201
  edit_payload: Muokkaa ja yritä uudelleen
@@ -372,6 +374,7 @@ fi:
372
374
  show:
373
375
  back: Takaisin töihin
374
376
  backtrace: Takautumisketju
377
+ context: Konteksti
375
378
  discard: Hylkää
376
379
  discard_confirm: Hylkää tämä työ pysyvästi?
377
380
  error_message: Virheilmoitus
@@ -131,6 +131,7 @@ fr:
131
131
  total_label: messages dans la file de lettres mortes
132
132
  show:
133
133
  back: Retour à la DLQ
134
+ context: Contexte
134
135
  discard: Ignorer
135
136
  discard_confirm: Ignorer définitivement ?
136
137
  headers: En-têtes
@@ -194,6 +195,7 @@ fr:
194
195
  title: Événements
195
196
  pending_table:
196
197
  arguments: Charge utile
198
+ context: Contexte
197
199
  discard: Jeter
198
200
  discard_confirm: Jeter cet événement ? Il sera archivé.
199
201
  edit_payload: Modifier & Réessayer
@@ -372,6 +374,7 @@ fr:
372
374
  show:
373
375
  back: Retour aux travaux
374
376
  backtrace: Trace de la pile
377
+ context: Contexte
375
378
  discard: Ignorer
376
379
  discard_confirm: Supprimer ce travail définitivement ?
377
380
  error_message: Message d'erreur
@@ -131,6 +131,7 @@ it:
131
131
  total_label: messaggi nella coda dei messaggi non recapitati
132
132
  show:
133
133
  back: Torna a DLQ
134
+ context: Contesto
134
135
  discard: Scarta
135
136
  discard_confirm: Scartare definitivamente?
136
137
  headers: Intestazioni
@@ -194,6 +195,7 @@ it:
194
195
  title: Eventi
195
196
  pending_table:
196
197
  arguments: Payload
198
+ context: Contesto
197
199
  discard: Scarta
198
200
  discard_confirm: Scartare questo evento? Verrà archiviato.
199
201
  edit_payload: Modifica e riprova
@@ -372,6 +374,7 @@ it:
372
374
  show:
373
375
  back: Torna ai Lavori
374
376
  backtrace: Traccia dello stack
377
+ context: Contesto
375
378
  discard: Scarta
376
379
  discard_confirm: Scartare definitivamente questo lavoro?
377
380
  error_message: Messaggio di errore
@@ -131,6 +131,7 @@ ja:
131
131
  total_label: デッドレターキュー内のメッセージ
132
132
  show:
133
133
  back: DLQに戻る
134
+ context: コンテキスト
134
135
  discard: 破棄
135
136
  discard_confirm: 完全に破棄しますか?
136
137
  headers: ヘッダー
@@ -194,6 +195,7 @@ ja:
194
195
  title: イベント
195
196
  pending_table:
196
197
  arguments: ペイロード
198
+ context: コンテキスト
197
199
  discard: 破棄
198
200
  discard_confirm: このイベントを破棄しますか?アーカイブされます。
199
201
  edit_payload: 編集して再試行
@@ -372,6 +374,7 @@ ja:
372
374
  show:
373
375
  back: ジョブに戻る
374
376
  backtrace: バックトレース
377
+ context: コンテキスト
375
378
  discard: 破棄
376
379
  discard_confirm: このジョブを完全に破棄しますか?
377
380
  error_message: エラーメッセージ
@@ -131,6 +131,7 @@ nb:
131
131
  total_label: meldinger i dead letter-kø
132
132
  show:
133
133
  back: Tilbake til DLQ
134
+ context: Kontekst
134
135
  discard: Forkast
135
136
  discard_confirm: Forkaste permanent?
136
137
  headers: Overskrifter
@@ -194,6 +195,7 @@ nb:
194
195
  title: Hendelser
195
196
  pending_table:
196
197
  arguments: Nyttelast
198
+ context: Kontekst
197
199
  discard: Forkast
198
200
  discard_confirm: Forkast denne hendelsen? Den vil bli arkivert.
199
201
  edit_payload: Rediger & prøv på nytt
@@ -372,6 +374,7 @@ nb:
372
374
  show:
373
375
  back: Tilbake til jobber
374
376
  backtrace: Tilbakekalling
377
+ context: Kontekst
375
378
  discard: Forkast
376
379
  discard_confirm: Slett denne jobben permanent?
377
380
  error_message: Feilmelding
@@ -131,6 +131,7 @@ nl:
131
131
  total_label: berichten in dead letter-wachtrij
132
132
  show:
133
133
  back: Terug naar DLQ
134
+ context: Context
134
135
  discard: Verwerpen
135
136
  discard_confirm: Permanent verwerpen?
136
137
  headers: Headers
@@ -194,6 +195,7 @@ nl:
194
195
  title: Gebeurtenissen
195
196
  pending_table:
196
197
  arguments: Payload
198
+ context: Context
197
199
  discard: Verwijderen
198
200
  discard_confirm: Deze gebeurtenis verwijderen? Deze wordt gearchiveerd.
199
201
  edit_payload: Bewerken & Opnieuw Proberen
@@ -372,6 +374,7 @@ nl:
372
374
  show:
373
375
  back: Terug naar Taken
374
376
  backtrace: Stacktrace
377
+ context: Context
375
378
  discard: Verwijderen
376
379
  discard_confirm: Deze taak permanent verwijderen?
377
380
  error_message: Foutmelding
@@ -131,6 +131,7 @@ pt:
131
131
  total_label: mensagens na fila de mensagens mortas
132
132
  show:
133
133
  back: Voltar para DLQ
134
+ context: Contexto
134
135
  discard: Descartar
135
136
  discard_confirm: Descartar permanentemente?
136
137
  headers: Cabeçalhos
@@ -194,6 +195,7 @@ pt:
194
195
  title: Eventos
195
196
  pending_table:
196
197
  arguments: Payload
198
+ context: Contexto
197
199
  discard: Descartar
198
200
  discard_confirm: Descartar este evento? Ele será arquivado.
199
201
  edit_payload: Editar e Tentar Novamente
@@ -372,6 +374,7 @@ pt:
372
374
  show:
373
375
  back: Voltar para Trabalhos
374
376
  backtrace: Rastreamento de Pilha
377
+ context: Contexto
375
378
  discard: Descartar
376
379
  discard_confirm: Descartar este trabalho permanentemente?
377
380
  error_message: Mensagem de Erro
@@ -131,6 +131,7 @@ sv:
131
131
  total_label: meddelanden i dead letter-kö
132
132
  show:
133
133
  back: Tillbaka till DLQ
134
+ context: Kontext
134
135
  discard: Kassera
135
136
  discard_confirm: Kassera permanent?
136
137
  headers: Rubriker
@@ -194,6 +195,7 @@ sv:
194
195
  title: Händelser
195
196
  pending_table:
196
197
  arguments: Payload
198
+ context: Kontext
197
199
  discard: Kassera
198
200
  discard_confirm: Kassera denna händelse? Den kommer att arkiveras.
199
201
  edit_payload: Redigera & Försök igen
@@ -372,6 +374,7 @@ sv:
372
374
  show:
373
375
  back: Tillbaka till jobb
374
376
  backtrace: Backtrace
377
+ context: Kontext
375
378
  discard: Kassera
376
379
  discard_confirm: Kassera detta jobb permanent?
377
380
  error_message: Felmeddelande
@@ -10,6 +10,7 @@ module Pgbus
10
10
  payload_hash = Serializer.serialize_job_hash(active_job)
11
11
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
12
12
  payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
13
+ payload_hash = FairShare.inject_metadata(active_job, payload_hash)
13
14
  payload_hash = inject_batch_metadata(payload_hash, active_job: active_job)
14
15
 
15
16
  if uniqueness_rejected?(active_job, payload_hash, queue: queue)
@@ -25,6 +26,7 @@ module Pgbus
25
26
  payload_hash = Serializer.serialize_job_hash(active_job)
26
27
  payload_hash = Concurrency.inject_metadata(active_job, payload_hash)
27
28
  payload_hash = Uniqueness.inject_metadata(active_job, payload_hash)
29
+ payload_hash = FairShare.inject_metadata(active_job, payload_hash)
28
30
  payload_hash = inject_batch_metadata(payload_hash, active_job: active_job)
29
31
  delay = [(timestamp - Time.current.to_f).ceil, 0].max
30
32
 
@@ -262,7 +264,9 @@ module Pgbus
262
264
  def enqueue_immediate(queue, jobs, priority: nil)
263
265
  return if jobs.empty?
264
266
 
265
- payloads = jobs.map { |j| inject_batch_metadata(Serializer.serialize_job_hash(j), track: false) }
267
+ payloads = jobs.map do |j|
268
+ inject_batch_metadata(FairShare.inject_metadata(j, Serializer.serialize_job_hash(j)), track: false)
269
+ end
266
270
  # One guarded increment for the whole bulk, not one per job.
267
271
  Batch.track_enqueue(payloads) if Thread.current[:pgbus_batch_id]
268
272
  physical = physical_queue(queue, priority)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module ActiveJob
5
+ # Carries ActiveSupport::CurrentAttributes across enqueue → perform
6
+ # (issue #430). Included on ActiveJob::Base by the engine next to BatchId.
7
+ #
8
+ # * +serialize+ snapshots the persisted Current classes
9
+ # (Pgbus::CurrentAttributes.capture) under +pgbus_current+. A job that
10
+ # was itself deserialized re-serializes the context it was enqueued
11
+ # with — so a +retry_on+ re-enqueue keeps the original context even if
12
+ # Current changed during the attempt. Nothing is added when the feature
13
+ # is off or no attribute is assigned: the payload is byte-for-byte what
14
+ # it was before this mixin existed.
15
+ # * +perform_now+ is wrapped (not an +around_perform+) so the restored
16
+ # context also covers +rescue_from+ / +retry_on+ / +discard_on+ blocks,
17
+ # which run in +perform_now+'s rescue outside the perform callbacks —
18
+ # and it works identically under the pgbus worker, Rails' :test and
19
+ # :inline adapters, and a bare +job.perform_now+.
20
+ #
21
+ # Per-class control: +self.pgbus_persist_current_attributes = false+
22
+ # (never persist for this job class) or a spec in the same shapes as
23
+ # +config.current_attributes+ (an Array / Hash) to replace the config's
24
+ # list for this class. +nil+ (default) follows the config.
25
+ module CurrentAttributes
26
+ extend ActiveSupport::Concern
27
+
28
+ included do
29
+ attr_accessor :pgbus_current_attributes
30
+
31
+ class_attribute :pgbus_persist_current_attributes, instance_writer: false, default: nil
32
+ end
33
+
34
+ def serialize
35
+ data = super
36
+ captured = pgbus_current_attributes ||
37
+ Pgbus::CurrentAttributes.capture(override: self.class.pgbus_persist_current_attributes)
38
+ data[Pgbus::CurrentAttributes::METADATA_KEY] = captured if captured
39
+ data
40
+ end
41
+
42
+ def deserialize(job_data)
43
+ super
44
+ self.pgbus_current_attributes = job_data[Pgbus::CurrentAttributes::METADATA_KEY]
45
+ end
46
+
47
+ def perform_now
48
+ Pgbus::CurrentAttributes.restore(pgbus_current_attributes) { super }
49
+ end
50
+ end
51
+ end
52
+ end