pgbus 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +33 -0
  4. data/Rakefile +6 -1
  5. data/app/helpers/pgbus/application_helper.rb +7 -0
  6. data/app/models/pgbus/batch_entry.rb +78 -6
  7. data/app/models/pgbus/batch_execution.rb +28 -0
  8. data/app/models/pgbus/blocked_execution.rb +2 -2
  9. data/app/models/pgbus/uniqueness_key.rb +50 -4
  10. data/app/views/pgbus/batches/_batches_table.html.erb +3 -3
  11. data/app/views/pgbus/batches/show.html.erb +7 -7
  12. data/app/views/pgbus/dead_letter/show.html.erb +17 -0
  13. data/app/views/pgbus/events/_pending_table.html.erb +21 -0
  14. data/app/views/pgbus/jobs/show.html.erb +17 -0
  15. data/config/locales/da.yml +5 -2
  16. data/config/locales/de.yml +5 -2
  17. data/config/locales/en.yml +5 -2
  18. data/config/locales/es.yml +5 -2
  19. data/config/locales/fi.yml +5 -2
  20. data/config/locales/fr.yml +5 -2
  21. data/config/locales/it.yml +5 -2
  22. data/config/locales/ja.yml +5 -2
  23. data/config/locales/nb.yml +5 -2
  24. data/config/locales/nl.yml +5 -2
  25. data/config/locales/pt.yml +5 -2
  26. data/config/locales/sv.yml +5 -2
  27. data/lib/generators/pgbus/add_batch_callback_jobs_generator.rb +47 -0
  28. data/lib/generators/pgbus/add_batch_executions_generator.rb +44 -0
  29. data/lib/generators/pgbus/templates/add_batch_callback_jobs.rb.erb +13 -0
  30. data/lib/generators/pgbus/templates/add_batch_executions.rb.erb +50 -0
  31. data/lib/generators/pgbus/templates/initializer.rb.erb +2 -0
  32. data/lib/generators/pgbus/templates/migration.rb.erb +23 -2
  33. data/lib/pgbus/active_job/adapter.rb +146 -28
  34. data/lib/pgbus/active_job/batch_id.rb +48 -0
  35. data/lib/pgbus/active_job/current_attributes.rb +52 -0
  36. data/lib/pgbus/active_job/executor.rb +45 -9
  37. data/lib/pgbus/batch/sweep.rb +163 -0
  38. data/lib/pgbus/batch.rb +448 -63
  39. data/lib/pgbus/client/fair_read.rb +187 -0
  40. data/lib/pgbus/client.rb +221 -18
  41. data/lib/pgbus/concurrency/blocked_execution.rb +14 -1
  42. data/lib/pgbus/configuration.rb +81 -1
  43. data/lib/pgbus/current_attributes.rb +156 -0
  44. data/lib/pgbus/engine.rb +2 -0
  45. data/lib/pgbus/event.rb +7 -2
  46. data/lib/pgbus/event_bus/handler.rb +12 -2
  47. data/lib/pgbus/event_bus/publisher.rb +37 -2
  48. data/lib/pgbus/event_bus/subscriber.rb +4 -0
  49. data/lib/pgbus/fair_share.rb +110 -0
  50. data/lib/pgbus/generators/migration_detector.rb +30 -0
  51. data/lib/pgbus/instrumentation.rb +5 -0
  52. data/lib/pgbus/outbox/poller.rb +6 -7
  53. data/lib/pgbus/outbox.rb +8 -1
  54. data/lib/pgbus/process/consumer.rb +38 -1
  55. data/lib/pgbus/process/dispatcher.rb +41 -17
  56. data/lib/pgbus/process/worker.rb +41 -0
  57. data/lib/pgbus/recurring/schedule.rb +12 -1
  58. data/lib/pgbus/testing.rb +2 -1
  59. data/lib/pgbus/uniqueness.rb +35 -7
  60. data/lib/pgbus/version.rb +1 -1
  61. data/lib/pgbus/web/data_source.rb +47 -9
  62. data/lib/pgbus/web/job_context.rb +80 -0
  63. data/lib/pgbus.rb +2 -0
  64. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7a7416041503b893fcd514bb1a81509d6ca3b1b6dbd31b800237b782ecad5e2
4
- data.tar.gz: 831aa16289605abb51cd5e6ab9cdaa195497a3cbd58e68afe896813746fe4fb9
3
+ metadata.gz: c9e24beb40f37672bb2d5b51db5c6ffeb2894e1a2be3711ff6b17cc5c2ed52b7
4
+ data.tar.gz: 4fe85e1733dd8257b1f13af40778fbc449146015d707e9d2889b287ad83af78b
5
5
  SHA512:
6
- metadata.gz: b0734dc2d6f529c0560290a31d418165e2e2bba4b3ac175b379e9418596c304f0534adef23a390cb60ef3f8d6294b2bd2589016c9de638a3c49ada7112d1af46
7
- data.tar.gz: 610918036f7585086d6bd2c2bd71df8cd8c3035f1e47919e47853f37bc76ce218487faaa106d71327c03c7ac080eac83eb974be83f3f16995a2b6ced71bfa145
6
+ metadata.gz: b8e23d2e38e12c2dd882dcdb5de8c23763b469551c869511718cd9d52a85ccdceb344779ae48ab3a3cfec2f1d3e8373d642f8d97f712c30f98893ed0450abc0d
7
+ data.tar.gz: 142d408f3fa304d82593cf9fdf6167592c3bf2cafb64016702b057e374a98c7476e33922d6b05be00e7a71136861a77d9b8267dbd1fabb1dc6ed48c6fd1e15ae
data/CHANGELOG.md CHANGED
@@ -1,7 +1,42 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+
5
+ - **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.
6
+
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+
12
+ - **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.
13
+
14
+ - **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.
15
+
3
16
  ### Fixed
4
17
 
18
+ - **A `retry_on` re-enqueue now stays in its batch — batch completion means terminal completion (issue #424).** `retry_on` rescues inside `perform_now`, re-enqueues the same job (new PGMQ message, same ActiveJob `job_id`, `executions > 0`) and returns normally; the executor then archived the original message and signalled `job_completed` — deleting the execution row and bumping `completed_jobs` — while the retry message carried no batch tag at all. `on_success` could fire with a retry still pending, and a retry that later dead-lettered never fired `on_failure`. The adapter now recognises a retry re-enqueue (a job with a `batch_id` from the `BatchId` mixin and `executions > 0`, outside any `Batch#enqueue` block), re-tags it into its batch **without** counting it again (`Batch.track_retry`: the existing row is kept via `ON CONFLICT DO NOTHING`, the backfill re-points it at the retry message) and records the `job_id` in a per-thread set once the retry is live (sent, or parked as a blocked execution); the executor consults that set after `perform` and skips the completion signal for an attempt that re-enqueued itself, clearing the set per execute. A first-attempt job that merely has a `batch_id` outside a block is still not tagged (membership stays explicit), and `callback_batch_id` never re-tags. Batches with `retry_on` jobs finish later — correctly. Refs #424.
19
+
20
+ - **Batched jobs count themselves in per job, in one transaction, before the send — `AlreadyFinished` now raises at `perform_later`, and no batch path can leave `total_jobs` out of step with its rows (issue #423).** Open batches (#422) inserted execution rows during the block and bumped `total_jobs` once at the end. Three ways that went wrong: a re-opened block that raised mid-way left rows counted but the total un-bumped, so the batch could never satisfy `completed + failed = total` and lived forever; `AlreadyFinished` was checked against a stale memoized row and only enforced after the block, so adding to a finished batch enqueued every job *and then* raised; and the stalled-pending sweep recomputed `total_jobs` from the row count, discarding `completed_jobs`/`failed_jobs` already incremented while the block ran — a crashed `pending` batch with any early completions was immortal too. Every batched enqueue (individual, bulk, blocked-promote) now runs `BatchEntry.increment_total_jobs!` — guarded on an unfinished row — and the execution-row insert inside one `BatchEntry.transaction`, *before* `send_message`; a `perform_all_later` increments once by N. The invariant `total_jobs == outstanding rows + completed_jobs + failed_jobs` holds at every commit point, so the first block only flips `pending → processing` (guarded) and re-checks, a re-opened block never bumps, the empty-batch path goes through the same single-winner finish (and now emits `pgbus.batch_finished`), and the sweep never rewrites totals. Discards at enqueue time (`uncount`) decrement and delete in one transaction. The legacy counter path only auto-finishes a `processing` batch (totals now grow mid-block). **Internal:** `Thread.current[:pgbus_batch_job_count]` is gone — nothing reads it; a spec that stubbed it should assert on `Pgbus::Batch.track_enqueue` instead. Cost: one extra `UPDATE pgbus_batches` per batched job (one per bulk send); plain enqueue is untouched. Refs #423, #415.
21
+
22
+ - **The stalled-batch sweep no longer un-counts a live job, and no longer flips a pending batch that is still actively enqueuing (issue #423).** Phase 2 deleted every `msg_id IS NULL` execution row older than the threshold and decremented `total_jobs`, assuming the enqueue died between row insert and send — but a send that landed whose backfill then failed leaves the same shape with a **live** message, and un-counting it let the batch finish (and fire `on_success`) before that job ran. The row now stores the logical queue at insert, and the sweep probes it by `job_id` first: message present → keep; probe inconclusive → keep; present in the DLQ → resolve as failed; nowhere → orphan as before. `Client#message_with_job_id?` expands a logical name to every `_pN` sub-queue under priority routing (it probed a single bare table that does not exist there and returned "unknown"). Phase 3 treats a `pending` batch as stalled only after `config.batch_stall_threshold` (new, default 5 minutes, replaces the hardcoded constant) with **no execution row inserted in that window**, so a long-running enqueue block is not closed under the caller. Refs #423.
23
+
24
+ - **A `:while_executing` job that fails once can retry — the lock is released on failure and re-acquired by the same message after a crash (issue #423).** The lock was acquired at execution start with `msg_id = 0` and released only on success or dead-letter. After an exception the row stayed, and the retry of the *same message* hit `INSERT … ON CONFLICT DO NOTHING` against its own row → `:skipped`, on every read, until `read_ct` exceeded `max_retries` and the job dead-lettered without a second real attempt; a process kill mid-job did the same. The executor now binds the lock to the message it is executing (`msg_id` + queue) and releases it in the failure path (`:until_executed` is untouched — that lock is held until success/DLQ by design); `UniquenessKey.acquire!(reacquire_same_message: true)` treats a conflict with a row pointing at the **same** `msg_id` as this message's own previous attempt (PGMQ's visibility timeout guarantees nobody else holds it) and re-acquires, while a different `msg_id` is still a genuine duplicate. Refs #423.
25
+
26
+ - **A batch callback configured as an ActiveJob instance on a database that has not run `add_batch_callback_jobs` is no longer silently dropped (issue #423).** It degrades to its class in the legacy `on_*_class` column — the callback still fires, on its default queue — and logs a WARN once per process that `.set` options are ignored until the migration runs. Refs #423.
27
+
28
+ - **A concurrency-blocked job keeps its priority when promoted (issue #423).** `BlockedExecution.release_next!` returned only `queue_name` and `payload`, so `promote_next` re-sent without `priority:` and, under `priority_levels > 1`, every promoted job landed on the default sub-queue regardless of what the enqueuer asked for. The row's `priority` is now returned and passed to `send_message` and to the execution-row backfill's target queue. Refs #423.
29
+
30
+ - **A batch whose `enqueue` block crashed before enqueuing anything is no longer immortal (#414 follow-up).** The stalled-batch sweep moved such a batch from `pending` to `processing` with `total_jobs = 0`, but `BatchEntry.finish_if_empty!` required `total_jobs > 0` and `Sweep#counters_terminal?` required `total_jobs.positive?` — guards added so a pre-migration in-flight batch (zero execution rows, counters short of `total_jobs`) is not closed empty. Nothing could then close the crashed batch: it sat in `processing` forever, `cleanup_batches` only deletes `finished` rows so it accumulated, and `on_finish` never fired — the same leak #414 set out to remove, relocated one status along. Both guards now test `completed_jobs + failed_jobs = total_jobs` alone: `total_jobs = 0` with zero counters *is* terminal, while a legacy in-flight batch still has counters short of a positive `total_jobs` and is still left on the counter path. Refs #414.
31
+
32
+ - **`ActiveJob.perform_all_later` no longer raises `PG::UndefinedTable` when priority routing is enabled.** `Client#send_batch` targeted `config.queue_name(queue)` — the bare `pgbus_<queue>` table — while the priority strategy only ever creates `_p0.._pN`, so every bulk enqueue under `priority_levels > 1` failed outright (and, had the table existed, no worker would have read it: consumers poll the sub-queues). `send_batch` now routes through the same `QueueFactory` strategy as `send_message` and takes an optional `priority:`, `Adapter#enqueue_all` groups bulk jobs by queue **and** priority so a mixed-priority `perform_all_later` produces one batch per level, and `Outbox::Poller#publish_queue_batch` — which already grouped entries by priority and then discarded it — passes it through (its single-send fallback now does too, so both paths place messages identically). Under the default single-queue strategy the target is unchanged. This also stopped bulk-enqueued batch jobs from leaking `pgbus_batch_executions` rows on the failed send. Refs #413.
33
+
34
+ - **`:until_executed` uniqueness keys no longer stick at `pending`/`msg_id=0` so the orphan reaper can now release true orphans (issue #418).** The adapter inserted the lock row as `queue_name: "pending", msg_id: 0` before `send_message` and never UPDATEd it with the real logical queue and PGMQ `msg_id`. The dispatcher's reaper then probed `pgmq.q_<prefix>_pending` — a table that does not exist — `Client#message_exists?` returned `nil` (UndefinedTable), and `nil` is treated as "still here" so leaked keys lived forever. Heartbeat jobs using `on_conflict: :discard` stayed discarded for hours or days with no matching queue message. Three cooperating fixes: **(1)** acquire with the logical queue name, then `UniquenessKey.bind!` after a successful send (adapter and recurring scheduler; retry re-enqueues do not re-bind — issue #333). A bind error is fail-soft. **(2)** Unbound rows (`pending` and/or `msg_id=0`) are reaped by scanning live `pgmq.q_*` tables via `Client#uniqueness_keys_present` — never by querying the synthetic pending queue. In-flight jobs that still look like `pending`/`0` are kept if any queue payload still carries the key. Age floor remains `2 * visibility_timeout`. **(3)** The executor releases the lock once, immediately after archive (fail-soft), so a kill during `pgbus.job_completed` cannot leak a lock whose message is already gone. There is deliberately **no** second `ensure` DELETE: a key-only DELETE after a committed release can drop a successor that re-acquired the same key; a failed release is instead healed by the reaper once the archived message is provably gone. Bound `message_exists?` lookups on a logical name also check priority `_pN` sub-queues. Do **not** bulk-delete `pending`/`msg_id=0` rows on upgrade — the reaper heals true orphans on the next cleanup cycle. Refs #418.
35
+
36
+ - **A job that combines `ensures_uniqueness` (`:until_executed`) with `limits_concurrency on_conflict: :discard` no longer leaves the uniqueness lock held forever.** Enqueue acquires the uniqueness lock first, then the semaphore; a `:discard` conflict dropped the job without sending a message, so no executor could ever release the lock and later equivalent jobs were blocked until someone deleted the `pgbus_uniqueness_keys` row. The discard path now rolls back the lock this enqueue acquired. A uniqueness *duplicate* discard still does not release — that lock belongs to the in-flight job. `:block` conflicts still keep the lock, because `BlockedExecution` stores the tagged payload and the job runs when the semaphore frees. Refs #413.
37
+
38
+ - **`ActiveJob.perform_all_later` no longer lets jobs escape batch tracking or bypass concurrency limits (issue #413).** `Adapter#enqueue_all`'s bulk path (`enqueue_immediate`) serialized payloads directly to `send_batch`, skipping the metadata-injection steps the individual `enqueue`/`enqueue_at` paths run. Two consequences: **(1)** jobs bulk-enqueued inside a `Pgbus::Batch#enqueue` block were neither tagged with `pgbus_batch_id` nor counted into `total_jobs`, so the batch could finish — and fire `on_finish`/`on_success` — before those jobs ever ran; **(2)** jobs whose class declares `pgbus_concurrency` were sent without semaphore acquisition, silently ignoring their concurrency limit. Bulk payloads now pass through `inject_batch_metadata` (tagging + counting), and concurrency-configured jobs are partitioned out of the bulk path into the individual enqueue path — exactly how uniqueness-configured jobs were already routed, and for the same reason: per-job locks cannot be acquired in a bulk send. Plain jobs keep the fast `send_batch` path unchanged. Refs #413.
39
+
5
40
  - **The 0.14.0 purge/drop guard no longer crashes on Rails 8.0/8.1 — pool ownership is resolved version-tolerantly (issue #411).** `Pgbus::BusRecord.disconnect_all_pools!` (added in #410 for the #409 wedge) identified its own pools via `pool.connection_class` — an API that exists on Rails 7.1/7.2 but was replaced in Rails 8.0 by `ConnectionPool#connection_descriptor` (a `ConnectionDescriptor` whose `#name` is the owning class name; 8.x `PoolConfig` has no `connection_class` either, so the obvious fallback route doesn't exist). Since the guard is prepended onto every purge/drop path, the `NoMethodError` aborted **every** `db:test:purge` / `db:prepare` on current stable Rails, forcing consumers to cap at `< 0.14`. Ownership is now resolved against whichever API the running Rails exposes — `connection_descriptor&.name == name` on 8.0+ (a nil descriptor, e.g. a null pool, never matches), `connection_class == self` on 7.x — with identical semantics: only `connects_to`-created BusRecord pools are disconnected, `ActiveRecord::Base`'s pool is never touched. The reason CI missed this: the spec stubbed `connection_class` on plain RSpec doubles, which happily fake methods the running Rails doesn't define. The spec now also exercises verifying doubles (`instance_double`) against the real `ConnectionPool`, so both existing matrix legs (7.1 and latest 8.x) fail on any future drift in the ownership API. Refs #411.
6
41
 
7
42
  - **Boot-time BusRecord connections no longer wedge `db:test:purge` — pgbus disconnects its own pools before every database purge/drop (issue #409).** With pgbus on a dedicated database (`config.connects_to`), any boot-time touch of a pgbus model left an idle session on that database for the life of the process. Rails' purge/drop only disconnects the connection it establishes for the target db_config — it knows nothing about gem-owned pools — so the rake process's own idle session blocked its own `DROP DATABASE`: a hang without a `statement_timeout`, a fast `ActiveRecord::QueryCanceled` with one, and either way `db:test:prepare` / `maintain_test_schema!` permanently broken on that machine (every retry re-boots, re-opens the connection, and re-blocks; terminating sessions externally can't help). Three changes: **(1)** `Pgbus::DatabaseTasksGuard` is prepended onto `ActiveRecord::Tasks::DatabaseTasks` at boot, so every route to a purge/drop — `db:test:purge` / `db:purge` / `db:drop` and their per-database variants, `maintain_test_schema!`'s in-process purge, parallel-testing's `TestDatabases` — first runs `Pgbus::BusRecord.disconnect_all_pools!` (all roles; a no-op on primary-database installs, `ActiveRecord::Base`'s pool is never touched). **(2)** The task-aware guard the event-bus registry already used internally is now public as **`Pgbus.database_task?`** — true while the process runs a `db:*` / `assets:*`-family rake task — so apps can skip their own boot-time warm-ups in the contexts where a database may legitimately not exist. **(3)** `pgbus:tune_autovacuum` (enhanced onto `db:schema:load`) no longer holds a permanent connection lease from `BusRecord.connection`: it checks out via `connection_pool.with_connection` and disconnects the pool afterward, leaving no idle session behind inside longer rake chains. Refs #409.
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. 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
 
@@ -4,14 +4,79 @@ module Pgbus
4
4
  class BatchEntry < BusRecord
5
5
  self.table_name = "pgbus_batches"
6
6
 
7
- COUNTER_COLUMNS = %w[completed_jobs discarded_jobs].freeze
7
+ # discarded_jobs remains valid until the add_batch_executions migration
8
+ # folds it into failed_jobs. Both names are accepted so gem-before-migrate
9
+ # and gem-after-migrate stay incrementable.
10
+ COUNTER_COLUMNS = %w[completed_jobs discarded_jobs failed_jobs].freeze
8
11
 
9
12
  scope :finished, -> { where(status: "finished") }
10
13
  scope :stale, ->(before:) { finished.where("finished_at < ?", before) }
14
+ scope :pending, -> { where(status: "pending") }
15
+ scope :processing, -> { where(status: "processing") }
16
+ scope :without_executions, -> { where.not(batch_id: BatchExecution.select(:batch_id)) }
11
17
 
12
- # Atomically increment the counter and detect if this update caused the
13
- # batch to finish. Uses row-level locking to prevent duplicate callbacks.
18
+ # Deprecated alias until 1.0: after the column is dropped this reads failed_jobs.
19
+ def discarded_jobs
20
+ has_attribute?(:discarded_jobs) ? self[:discarded_jobs] : failed_jobs
21
+ end
22
+
23
+ # Atomically add n to total_jobs on an unfinished batch. Returns true.
24
+ # Raises Batch::AlreadyFinished when the row is already finished (0 rows
25
+ # updated) — the adder-before-insert contract open batches (#415) rely on.
26
+ def self.increment_total_jobs!(batch_id, count) # rubocop:disable Naming/PredicateMethod
27
+ updated = where(batch_id: batch_id, status: %w[pending processing])
28
+ .update_all(["total_jobs = total_jobs + ?", count])
29
+ raise Batch::AlreadyFinished, "Can't add jobs into an already finished batch" if updated.zero?
30
+
31
+ true
32
+ end
33
+
34
+ # Reverse of increment_total_jobs! for a job that will never run. Floored
35
+ # at zero; a finished row is left alone.
36
+ def self.decrement_total_jobs!(batch_id)
37
+ where(batch_id: batch_id, status: %w[pending processing])
38
+ .update_all(["total_jobs = GREATEST(total_jobs - 1, 0)"])
39
+ end
40
+
41
+ # Single-winner finish: status is processing AND no execution rows remain.
42
+ # Join-free NOT EXISTS so the subquery stays in this UPDATE's WHERE.
43
+ # Returns the number of rows updated (0 or 1).
44
+ def self.finish_if_empty!(batch_id)
45
+ # Counters must already be terminal so a pre-migration in-flight batch
46
+ # (zero execution rows, total_jobs = N, counters short of N) is not
47
+ # closed empty. total_jobs = 0 with zero counters IS terminal: that is a
48
+ # batch whose enqueue block crashed before it enqueued anything, and
49
+ # nothing else will ever close it.
50
+ where(batch_id: batch_id, status: "processing")
51
+ .without_executions
52
+ .where("completed_jobs + failed_jobs = total_jobs")
53
+ .update_all(status: "finished", finished_at: Time.current)
54
+ end
55
+
56
+ # Finish the batch if every job already reached a terminal state. Used
57
+ # after total_jobs is published: completion signals that arrived while the
58
+ # enqueue block was still open saw total_jobs == 0 and could not finish
59
+ # the batch themselves (PR #417). Row lock + status guard keep it
60
+ # idempotent against concurrent completion signals.
14
61
  # Returns { just_finished:, record: } or nil if batch not found.
62
+ def self.check_finished!(batch_id)
63
+ return Batch.try_finish!(batch_id) if Batch.executions_migrated?
64
+
65
+ transaction do
66
+ record = lock.find_by(batch_id: batch_id)
67
+ return nil unless record
68
+ return { record: record, just_finished: false } if record.status == "finished"
69
+ return { record: record, just_finished: false } unless record.completed_jobs + record.discarded_jobs == record.total_jobs
70
+
71
+ record.update!(status: "finished", finished_at: Time.current)
72
+ { record: record, just_finished: true }
73
+ end
74
+ end
75
+
76
+ # Atomically increment the counter and, on the pre-migration path, detect
77
+ # if this update caused the batch to finish. Uses row-level locking to
78
+ # prevent duplicate callbacks. Returns { just_finished:, record: } or nil
79
+ # if batch not found.
15
80
  def self.increment_counter!(batch_id, column)
16
81
  raise ArgumentError, "Invalid column: #{column}" unless COUNTER_COLUMNS.include?(column)
17
82
 
@@ -21,10 +86,17 @@ module Pgbus
21
86
 
22
87
  record.increment!(column)
23
88
 
24
- just_finished = record.completed_jobs + record.discarded_jobs == record.total_jobs
25
- record.update!(status: "finished", finished_at: Time.current) if just_finished && record.status != "finished"
89
+ return { record: record, just_finished: false } if Batch.executions_migrated?
90
+
91
+ # total_jobs grows per job while the block is still open (issue #423),
92
+ # so completed == total can be momentarily true on a pending batch.
93
+ # Only a processing batch may auto-finish; check_finished! at the end
94
+ # of the block covers the pending case.
95
+ counters_match = record.completed_jobs + record.discarded_jobs == record.total_jobs
96
+ just_finished = counters_match && record.status == "processing"
97
+ record.update!(status: "finished", finished_at: Time.current) if just_finished
26
98
 
27
- { record: record, just_finished: just_finished && record.status == "finished" }
99
+ { record: record, just_finished: just_finished }
28
100
  end
29
101
  end
30
102
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class BatchExecution < BusRecord
5
+ self.table_name = "pgbus_batch_executions"
6
+
7
+ # One row per outstanding batched job. Inserted before send_message so a
8
+ # crash cannot produce an untracked in-flight job. ON CONFLICT DO NOTHING
9
+ # makes a retry re-enqueue of the same ActiveJob id a no-op.
10
+ # Raw SQL rather than insert_all(unique_by:) — Rails resolves unique_by
11
+ # through the schema cache (issue #401).
12
+ # queue_name is the logical queue at insert (so the sweep can probe for a
13
+ # live message before the msg_id backfill lands); backfill! overwrites it
14
+ # with the physical target after send.
15
+ def self.insert_for!(batch_id:, job_id:, queue_name: nil)
16
+ connection.exec_query(
17
+ "INSERT INTO #{table_name} (batch_id, job_id, queue_name, created_at) " \
18
+ "VALUES ($1, $2, $3, $4) ON CONFLICT (job_id) DO NOTHING",
19
+ "BatchExecution Insert",
20
+ [batch_id, job_id, queue_name, Time.current]
21
+ )
22
+ end
23
+
24
+ def self.backfill!(job_id, msg_id:, queue_name:)
25
+ where(job_id: job_id).update_all(msg_id: msg_id, queue_name: queue_name)
26
+ end
27
+ end
28
+ end
@@ -22,7 +22,7 @@ module Pgbus
22
22
  LIMIT 1
23
23
  FOR UPDATE SKIP LOCKED
24
24
  )
25
- RETURNING queue_name, payload
25
+ RETURNING queue_name, payload, priority
26
26
  SQL
27
27
  "Pgbus Blocked Release",
28
28
  [concurrency_key, now]
@@ -34,7 +34,7 @@ module Pgbus
34
34
  payload = row["payload"]
35
35
  payload = JSON.parse(payload) if payload.is_a?(String)
36
36
 
37
- { queue_name: row["queue_name"], payload: payload }
37
+ { queue_name: row["queue_name"], payload: payload, priority: row["priority"] }
38
38
  end
39
39
  end
40
40
  end
@@ -8,16 +8,62 @@ module Pgbus
8
8
  # Atomically try to acquire a uniqueness lock via INSERT ... ON CONFLICT.
9
9
  # PostgreSQL's unique index on lock_key guarantees at most one caller wins.
10
10
  # Returns true if acquired (row inserted), false if already locked.
11
- def self.acquire!(lock_key, queue_name:, msg_id:) # rubocop:disable Naming/PredicateMethod
12
- connection.exec_query(
11
+ #
12
+ # reacquire_same_message: true (the :while_executing executor path) treats
13
+ # a conflict with a row that already points at THIS msg_id as acquired —
14
+ # it is this message's own previous attempt (PGMQ's visibility timeout
15
+ # guarantees nobody else holds the message), left behind by a crash. A
16
+ # conflict with a different msg_id is a genuine concurrent execution.
17
+ def self.acquire!(lock_key, queue_name:, msg_id:, reacquire_same_message: false) # rubocop:disable Naming/PredicateMethod
18
+ on_conflict = if reacquire_same_message && msg_id.to_i.positive?
19
+ "DO UPDATE SET queue_name = EXCLUDED.queue_name " \
20
+ "WHERE #{table_name}.msg_id = EXCLUDED.msg_id"
21
+ else
22
+ "DO NOTHING"
23
+ end
24
+ result = connection.exec_query(
13
25
  "INSERT INTO #{table_name} (lock_key, queue_name, msg_id) " \
14
- "VALUES ($1, $2, $3) ON CONFLICT (lock_key) DO NOTHING RETURNING lock_key",
26
+ "VALUES ($1, $2, $3) ON CONFLICT (lock_key) #{on_conflict} RETURNING lock_key, created_at",
15
27
  "UniquenessKey Acquire", [lock_key, queue_name, msg_id]
16
- ).rows.any?
28
+ )
29
+ row = result.rows.first
30
+ return false unless row
31
+
32
+ # Ownership stamp for bind!: a successor acquire of the same key after
33
+ # this row is released must not inherit this enqueue's msg_id.
34
+ stamps = Thread.current[:pgbus_uniqueness_created_at] ||= {}
35
+ stamps[lock_key] = row[1]
36
+ true
37
+ end
38
+
39
+ # Bind a pre-produce lock to the real queue and PGMQ msg_id after send.
40
+ # Does not touch created_at — the reaper's age floor is from acquire time.
41
+ # Restricted to this enqueue's unbound row (msg_id=0, matching created_at
42
+ # when acquire! stamped one) so a completed job's bind cannot retarget a
43
+ # successor that re-acquired the key.
44
+ def self.bind!(lock_key, queue_name:, msg_id:)
45
+ stamps = Thread.current[:pgbus_uniqueness_created_at]
46
+ created_at = stamps&.delete(lock_key)
47
+ sql = "UPDATE #{table_name} SET queue_name = $2, msg_id = $3 " \
48
+ "WHERE lock_key = $1 AND msg_id = 0"
49
+ binds = [lock_key, queue_name, msg_id]
50
+ if created_at
51
+ sql += " AND created_at = $4"
52
+ binds << created_at
53
+ end
54
+ connection.exec_update(sql, "UniquenessKey Bind", binds)
55
+ end
56
+
57
+ # Drop the bind ownership stamp without touching the lock row. Used when
58
+ # this thread acquired the key but will not bind (concurrency :block, or
59
+ # enqueue returning after a failed send already rolled the lock back).
60
+ def self.clear_bind_stamp!(lock_key)
61
+ Thread.current[:pgbus_uniqueness_created_at]&.delete(lock_key)
17
62
  end
18
63
 
19
64
  # Release a uniqueness lock after job completion or DLQ.
20
65
  def self.release!(lock_key)
66
+ Thread.current[:pgbus_uniqueness_created_at]&.delete(lock_key)
21
67
  connection.exec_delete(
22
68
  "DELETE FROM #{table_name} WHERE lock_key = $1",
23
69
  "UniquenessKey Release", [lock_key]
@@ -28,7 +28,7 @@
28
28
  <td data-label="Progress" class="px-4 py-3 text-sm">
29
29
  <div class="flex items-center space-x-2">
30
30
  <div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
31
- <div class="h-2 rounded-full <%= batch[:discarded_jobs].to_i > 0 ? 'bg-amber-500' : 'bg-green-500' %>"
31
+ <div class="h-2 rounded-full <%= batch[:failed_jobs].to_i > 0 ? 'bg-amber-500' : 'bg-green-500' %>"
32
32
  style="width: <%= batch[:progress_pct] %>%"></div>
33
33
  </div>
34
34
  <span class="text-xs text-gray-500 dark:text-gray-400 font-mono"><%= batch[:progress_pct] %>%</span>
@@ -36,8 +36,8 @@
36
36
  </td>
37
37
  <td data-label="Jobs" class="px-4 py-3 text-sm text-right font-mono text-gray-500 dark:text-gray-400">
38
38
  <%= batch[:completed_jobs] %>/<%= batch[:total_jobs] %>
39
- <% if batch[:discarded_jobs].to_i > 0 %>
40
- <span class="text-red-500">(<%= batch[:discarded_jobs] %> dlq)</span>
39
+ <% if batch[:failed_jobs].to_i > 0 %>
40
+ <span class="text-red-500">(<%= batch[:failed_jobs] %> failed)</span>
41
41
  <% end %>
42
42
  </td>
43
43
  <td data-label="Created" class="px-4 py-3 text-sm text-right text-gray-500 dark:text-gray-400">
@@ -18,7 +18,7 @@
18
18
  <h2 class="text-sm font-semibold text-gray-900 dark:text-white mb-4"><%= t("pgbus.batches.show.progress") %></h2>
19
19
 
20
20
  <div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4 mb-3">
21
- <div class="h-4 rounded-full transition-all duration-300 <%= @batch[:discarded_jobs].to_i > 0 ? 'bg-amber-500' : 'bg-green-500' %>"
21
+ <div class="h-4 rounded-full transition-all duration-300 <%= @batch[:failed_jobs].to_i > 0 ? 'bg-amber-500' : 'bg-green-500' %>"
22
22
  style="width: <%= @batch[:progress_pct] %>%"></div>
23
23
  </div>
24
24
 
@@ -32,12 +32,12 @@
32
32
  <dd class="mt-1 text-2xl font-semibold text-green-600 dark:text-green-400 font-mono"><%= @batch[:completed_jobs] %></dd>
33
33
  </div>
34
34
  <div>
35
- <dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"><%= t("pgbus.batches.show.discarded") %></dt>
36
- <dd class="mt-1 text-2xl font-semibold <%= @batch[:discarded_jobs].to_i > 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white' %> font-mono"><%= @batch[:discarded_jobs] %></dd>
35
+ <dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"><%= t("pgbus.batches.show.failed") %></dt>
36
+ <dd class="mt-1 text-2xl font-semibold <%= @batch[:failed_jobs].to_i > 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white' %> font-mono"><%= @batch[:failed_jobs] %></dd>
37
37
  </div>
38
38
  <div>
39
39
  <dt class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"><%= t("pgbus.batches.show.remaining") %></dt>
40
- <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white font-mono"><%= @batch[:total_jobs] - @batch[:completed_jobs] - @batch[:discarded_jobs] %></dd>
40
+ <dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white font-mono"><%= @batch[:pending_jobs] || (@batch[:total_jobs] - @batch[:completed_jobs] - @batch[:failed_jobs]) %></dd>
41
41
  </div>
42
42
  </div>
43
43
  </div>
@@ -74,10 +74,10 @@
74
74
  <dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @batch[:on_success_class] %></dd>
75
75
  </div>
76
76
  <% end %>
77
- <% if @batch[:on_discard_class] %>
77
+ <% if @batch[:on_failure_class] || @batch[:on_discard_class] %>
78
78
  <div class="px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4">
79
- <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.show.on_discard") %></dt>
80
- <dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @batch[:on_discard_class] %></dd>
79
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.batches.show.on_failure") %></dt>
80
+ <dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @batch[:on_failure_class] || @batch[:on_discard_class] %></dd>
81
81
  </div>
82
82
  <% end %>
83
83
  <% if @batch[:properties].present? %>
@@ -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>
@@ -19,10 +19,10 @@ da:
19
19
  completed: Fuldført
20
20
  created_at: Oprettet den
21
21
  details: Detaljer
22
- discarded: Kasseret
22
+ failed: Mislykkedes
23
23
  finished_at: Afsluttet den
24
24
  not_found: Batch ikke fundet
25
- on_discard: Ved kassering
25
+ on_failure: Ved fejl
26
26
  on_finish: Ved afslutning
27
27
  on_success: Ved succes
28
28
  progress: Fremgang
@@ -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
@@ -19,10 +19,10 @@ de:
19
19
  completed: Abgeschlossen
20
20
  created_at: Erstellt am
21
21
  details: Details
22
- discarded: Verworfen
22
+ failed: Fehlgeschlagen
23
23
  finished_at: Beendet am
24
24
  not_found: Stapel nicht gefunden
25
- on_discard: Bei Verwerfung
25
+ on_failure: Im Fehlerfall
26
26
  on_finish: Bei Beendigung
27
27
  on_success: Bei Erfolg
28
28
  progress: Fortschritt
@@ -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