pgbus 0.19.3 → 0.20.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +104 -9
- data/Rakefile +6 -1
- data/app/controllers/pgbus/dashboard_controller.rb +2 -0
- data/app/controllers/pgbus/workflow_definitions_controller.rb +28 -0
- data/app/controllers/pgbus/workflows_controller.rb +197 -0
- data/app/frontend/pgbus/modules/charts.js +130 -4
- data/app/frontend/pgbus/modules/charts.min.js +1 -1
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -1
- data/app/frontend/pgbus/style.css +1 -1
- data/app/helpers/pgbus/button_helper.rb +4 -2
- data/app/helpers/pgbus/workflow_insights_helper.rb +129 -0
- data/app/helpers/pgbus/workflows_helper.rb +165 -0
- data/app/models/pgbus/workflow_entry.rb +172 -0
- data/app/models/pgbus/workflow_event.rb +43 -0
- data/app/models/pgbus/workflow_signal.rb +77 -0
- data/app/models/pgbus/workflow_step.rb +142 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +17 -0
- data/app/views/pgbus/workflows/_actions.html.erb +59 -0
- data/app/views/pgbus/workflows/_events_table.html.erb +24 -0
- data/app/views/pgbus/workflows/_filters.html.erb +29 -0
- data/app/views/pgbus/workflows/_signal_form.html.erb +31 -0
- data/app/views/pgbus/workflows/_signals_table.html.erb +40 -0
- data/app/views/pgbus/workflows/_status_filter.html.erb +12 -0
- data/app/views/pgbus/workflows/_steps_table.html.erb +79 -0
- data/app/views/pgbus/workflows/_timeline.html.erb +39 -0
- data/app/views/pgbus/workflows/_workflows_table.html.erb +59 -0
- data/app/views/pgbus/workflows/attention.html.erb +88 -0
- data/app/views/pgbus/workflows/definitions/_by_revision.html.erb +52 -0
- data/app/views/pgbus/workflows/definitions/_duration_table.html.erb +18 -0
- data/app/views/pgbus/workflows/definitions/_funnel.html.erb +49 -0
- data/app/views/pgbus/workflows/definitions/_pipeline.html.erb +16 -0
- data/app/views/pgbus/workflows/definitions/_step_card.html.erb +31 -0
- data/app/views/pgbus/workflows/definitions/_throughput_table.html.erb +32 -0
- data/app/views/pgbus/workflows/definitions/_window.html.erb +17 -0
- data/app/views/pgbus/workflows/definitions/index.html.erb +71 -0
- data/app/views/pgbus/workflows/definitions/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/definitions/show.html.erb +80 -0
- data/app/views/pgbus/workflows/index.html.erb +15 -0
- data/app/views/pgbus/workflows/not_found.html.erb +5 -0
- data/app/views/pgbus/workflows/show.html.erb +93 -0
- data/config/i18n-tasks.yml +6 -0
- data/config/locales/da.yml +442 -0
- data/config/locales/de.yml +442 -0
- data/config/locales/en.yml +442 -0
- data/config/locales/es.yml +442 -0
- data/config/locales/fi.yml +442 -0
- data/config/locales/fr.yml +442 -0
- data/config/locales/it.yml +442 -0
- data/config/locales/ja.yml +442 -0
- data/config/locales/nb.yml +442 -0
- data/config/locales/nl.yml +442 -0
- data/config/locales/pt.yml +442 -0
- data/config/locales/sv.yml +442 -0
- data/config/routes.rb +26 -0
- data/lib/generators/pgbus/add_workflow_signals_generator.rb +56 -0
- data/lib/generators/pgbus/add_workflows_generator.rb +54 -0
- data/lib/generators/pgbus/templates/add_workflow_signals.rb.erb +51 -0
- data/lib/generators/pgbus/templates/add_workflows.rb.erb +150 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +127 -0
- data/lib/pgbus/active_job/adapter.rb +67 -26
- data/lib/pgbus/active_job/batch_id.rb +23 -1
- data/lib/pgbus/active_job/delivery.rb +49 -0
- data/lib/pgbus/active_job/executor.rb +3 -1
- data/lib/pgbus/batch/builder.rb +1 -0
- data/lib/pgbus/batch.rb +35 -3
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +21 -3
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +32 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +128 -1
- data/lib/pgbus/integrations/appsignal/event_span.rb +43 -0
- data/lib/pgbus/integrations/appsignal/job_span.rb +47 -0
- data/lib/pgbus/integrations/appsignal/span_subscriber.rb +128 -0
- data/lib/pgbus/integrations/appsignal/subscriber.rb +50 -100
- data/lib/pgbus/mcp/rack_app.rb +7 -2
- data/lib/pgbus/mcp/runner.rb +2 -1
- data/lib/pgbus/mcp/serialization.rb +25 -0
- data/lib/pgbus/mcp/server.rb +53 -10
- data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +1 -10
- data/lib/pgbus/mcp/tools/workflow_cancel_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_definitions_tool.rb +36 -0
- data/lib/pgbus/mcp/tools/workflow_detail_tool.rb +38 -0
- data/lib/pgbus/mcp/tools/workflow_resume_at_tool.rb +32 -0
- data/lib/pgbus/mcp/tools/workflow_resume_tool.rb +24 -0
- data/lib/pgbus/mcp/tools/workflow_run_now_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_signal_tool.rb +77 -0
- data/lib/pgbus/mcp/tools/workflow_skip_step_tool.rb +23 -0
- data/lib/pgbus/mcp/tools/workflow_stats_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_attention_tool.rb +42 -0
- data/lib/pgbus/mcp/tools/workflows_tool.rb +60 -0
- data/lib/pgbus/mcp/workflow_insights_payload.rb +15 -0
- data/lib/pgbus/mcp/workflow_payloads.rb +53 -0
- data/lib/pgbus/mcp/write_tool.rb +134 -0
- data/lib/pgbus/mcp.rb +15 -0
- data/lib/pgbus/metrics/backends/statsd.rb +6 -0
- data/lib/pgbus/metrics/subscriber.rb +14 -1
- data/lib/pgbus/metrics/workflow_metrics.rb +65 -0
- data/lib/pgbus/process/dispatcher.rb +26 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/workflow_actions.rb +109 -0
- data/lib/pgbus/web/data_source/workflow_insights.rb +99 -0
- data/lib/pgbus/web/data_source/workflow_list.rb +248 -0
- data/lib/pgbus/web/data_source.rb +3 -0
- data/lib/pgbus/web/workflow_action_plan.rb +62 -0
- data/lib/pgbus/web/workflow_insights_queries.rb +250 -0
- data/lib/pgbus/web/workflow_insights_report.rb +171 -0
- data/lib/pgbus/web/workflow_payload.rb +36 -0
- data/lib/pgbus/web/workflow_query.rb +82 -0
- data/lib/pgbus/web/workflow_state.rb +76 -0
- data/lib/pgbus/web/workflow_timeline.rb +113 -0
- data/lib/pgbus/web/workflow_window.rb +51 -0
- data/lib/pgbus/workflow/batch_finished_job.rb +21 -0
- data/lib/pgbus/workflow/dsl.rb +105 -0
- data/lib/pgbus/workflow/error_capture.rb +63 -0
- data/lib/pgbus/workflow/exception_policy.rb +170 -0
- data/lib/pgbus/workflow/fan_out.rb +157 -0
- data/lib/pgbus/workflow/fan_out_definition.rb +41 -0
- data/lib/pgbus/workflow/flow_control.rb +94 -0
- data/lib/pgbus/workflow/instance.rb +183 -0
- data/lib/pgbus/workflow/outside_verbs.rb +138 -0
- data/lib/pgbus/workflow/payload.rb +53 -0
- data/lib/pgbus/workflow/registry.rb +53 -0
- data/lib/pgbus/workflow/removed_step.rb +14 -0
- data/lib/pgbus/workflow/runner.rb +218 -0
- data/lib/pgbus/workflow/signal_matcher.rb +53 -0
- data/lib/pgbus/workflow/signals.rb +132 -0
- data/lib/pgbus/workflow/states.rb +119 -0
- data/lib/pgbus/workflow/step_context.rb +58 -0
- data/lib/pgbus/workflow/step_definition.rb +131 -0
- data/lib/pgbus/workflow/step_execution.rb +242 -0
- data/lib/pgbus/workflow/step_job.rb +28 -0
- data/lib/pgbus/workflow/step_outcome.rb +151 -0
- data/lib/pgbus/workflow/steps.rb +103 -0
- data/lib/pgbus/workflow/sweep.rb +156 -0
- data/lib/pgbus/workflow/test_helpers.rb +152 -0
- data/lib/pgbus/workflow/wait_timeout.rb +73 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +22 -0
- metadata +95 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd8fbf7676d82bc5e1648aba550d325d6d5e1ee30b2f87b483a123ac493506ec
|
|
4
|
+
data.tar.gz: 47e9a9949d84ea0489c3d5aba6b47bb343c3895d8d8ce15255ec699ad203f752
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0dd9781845545f3203aac84f297c865604cc2b0560f6fdb2f630d99642e0b50f8a3a2a673d2d9dc91b5353b09b9b83ca073f7464a78e44cc3762b81a257ddd00
|
|
7
|
+
data.tar.gz: 791c9590b9544a5cbbf89386da81ce4b5daaceccb0df08079d95960e7b434646c63335217fcdb9960bc59124320549b6f78a54ae57e30ed692bd0eaccd50cf62
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
### Added
|
|
4
4
|
|
|
5
|
+
- **feat(mcp): workflow write tools, opt-in — resume, cancel, skip step, resume at, run now, signal (issue #550).** `config.mcp_write_tools = true` (default `false`, validated as a boolean, read when `Pgbus::MCP::Server.build` runs) adds `pgbus_workflow_resume`, `pgbus_workflow_cancel`, `pgbus_workflow_skip_step`, `pgbus_workflow_resume_at` (`step`), `pgbus_workflow_run_now` and `pgbus_workflow_signal` (`name`, a JSON object `payload` capped at `workflow_max_payload_size`, an optional `idempotency_key`; name and key at most 255 characters) to the MCP server; off, they are never constructed and `tools/list` does not show them, and the read tools are the same either way. Each takes `workflow_id`, a required `reason` (1–500 characters after trimming) and an optional `expected_status` (a run that has moved on by the time the call starts answers `ok: false` and nothing is done; the check runs just before the action, whose transition is guarded again under the run's row lock); a verb the run's `available_actions` does not allow answers `ok: false` with the allowed list; otherwise the call goes through the dashboard's `Web::DataSource` action, is recorded on the run's audit trail with its reason as actor `mcp` (`mcp:<client name>` when the transport knows the client; the name is stripped of control characters and capped at 100), and answers `{ ok:, reason:, workflow: }` with the `pgbus_workflow` detail as the run is after the call (refusals included), payloads always redacted, plus `available_actions` for a disallowed verb and `code`/`status` when the guarded transition refused it; an unexpected failure answers a fixed reason rather than the exception's text (the DataSource logged it). The tools live on their own base class, `Pgbus::MCP::WriteTool` (not `BaseTool`), annotated `readOnlyHint: false, destructiveHint: true` (`idempotentHint: true` on cancel only: a retried skip or resume at can act again once the run parked or paused again). No bulk tools: `pgbus_workflows_attention` groups the stuck runs and `pgbus_workflows` lists a group's runs, one call per run. The server's instructions name them when they are on, `pgbus mcp` logs it at start, and an unauthenticated `Pgbus::MCP.rack_app` with them on warns that anyone who reaches it can change workflow runs. `Workflow#signal!`, `Workflow.signal!` (by subject), `Signals.deliver` and `DataSource#signal_workflow` take an optional `reason:`, recorded on the `signal_received` event. Refs #550.
|
|
6
|
+
- **feat(dashboard): workflow insights — per-class pipeline, step performance, funnel, per-revision numbers, MCP stats (issue #549).** `/pgbus/workflows/definitions` (linked from the Workflows page as "Insights by class") has one row per workflow class — every registered `Pgbus::Workflow` subclass and every class name still in `pgbus_workflows` — with its ongoing, paused and stuck runs, the runs that finished or were cancelled in the window and their p50/p95 end-to-end duration, a "definition not loaded" badge for a class that no longer loads and a count of ongoing runs on removed steps. `/pgbus/workflows/definitions/:workflow_class` (`OrderWorkflow` or `Billing::OrderWorkflow`) shows the class's steps in order as a pipeline: each step with the runs sitting at it now (scheduled, running, waiting, paused), the p50/p95/p99 run time and pickup latency (scheduled → started) of its attempts that finished in the window and their failure (an error was recorded), reattempt and skip rates, with a "few samples" note under 20 attempts; then runs started, finished and cancelled per bucket and the end-to-end duration distribution (ApexCharts, each with a caption and a data table), a funnel (of the runs started in the window, how many reached each step and where each one is now) and the same numbers for the last five app revisions, newest first, marking a step whose failure rate rose more than five points over the revision before. Window 1 h, 24 h (default), 7 d or 30 d, capped at `workflow_retention`. Every count of runs a list filter can express links to the Workflows list filtered to exactly those runs (percentiles, rates, the funnel's "reached" and the per-revision rows are text), which gains `ended_from`/`ended_to` filters (when a run finished or was cancelled; also on `pgbus_workflows`); need-attention counts open `/pgbus/workflows/attention?workflow_class=…`, the attention groups narrowed to the class (also `pgbus_workflows_attention(workflow_class:)`), and each removed step a run still sits on links to those runs. Throughput buckets follow the dashboard zone's wall clock: a day bucket starts at local midnight on both sides of a DST change (at its first hour where a DST change skips midnight). Runs and attempts dated after now are not counted. Read from the step and run rows on the existing indexes (`Web::WorkflowInsightsQueries`, one query per block): on ~1 M step rows over 20 classes a class's page reads in 26 ms and the list in 86 ms over 24 h (median; budgets 250 / 500 ms, `rake bench:workflow_insights`), about 300 ms over 30 d (reported, no budget); `WorkflowEntry.stuck_reason_counts` takes `workflow_class:` so a class's page counts only its own stuck runs. A class name from the URL that no class or run has is never constantized. `Web::DataSource` gains `workflow_definitions(window:)` and `workflow_definition(workflow_class, window:)`; the MCP server gains the read-only `pgbus_workflow_definitions` and `pgbus_workflow_stats`. The workflows guide gains a Monitoring section with example Prometheus alerts. Refs #549.
|
|
7
|
+
- **feat(dashboard): the Workflows pages — list and filters, needs attention, step timeline, audit trail, operator actions, MCP read tools (issue #542).** `/pgbus/workflows` lists every run newest first (ongoing by default, each state, or all) with its class, subject, status badge, current step, start and last activity, and says in words what it is doing and what happens next ("Waiting for batch `OrderWorkflow#notify_vendors` (7 of 12 jobs done)", "Paused on `capture`: Timeout::Error — resume or cancel", "Overdue: `welcome` was due 2h ago; the sweep sends it again"); filters on class, current step, error class, subject type and id, workflow id and a created-at range combine and survive paging. `/pgbus/workflows/:workflow_id` shows the arguments, an ApexCharts range-bar timeline (one row per attempt split into waited, queued and ran, coloured by outcome, with waits and pauses as their own segments, zoomable), every attempt with the same durations as text, its worker, app revision, error, backtrace and cause chain, the signals, the audit trail ("Resumed by dashboard:alice: SMTP fixed"), whether it started on an older definition, rerun links both ways, and the actions `available_actions` allows: Resume (labelled "retry `capture`" or "continue with `receipt`"), Resume at a chosen step, Run now, Skip step, Send signal (a JSON object, sent once per form render), Rerun and Cancel, each with a confirm and an optional reason, each reporting the real outcome ("Not done: the run is cancelled now."). `/pgbus/workflows/attention` groups every stuck run by class, step, reason and error class with a count, first/last seen and the newest message, with bulk Resume/Cancel (500 runs per press, each through its own guarded transition: "Resumed 212; 2 had already moved on"). The home page gains a Workflows card ("N ongoing (M paused) · K need attention") and the nav a Workflows link. New settings: `config.web_actor` (`->(request) { name }`, recorded as `"dashboard:<name>"`, or `"dashboard"`) and `config.workflow_subject_url` (`->(subject_type, subject_id) { url }`, linked only when it is an http(s) URL or a same-site path). `Web::DataSource` gains `workflows`, `workflows_count`, `workflow_detail`, `workflow_attention_groups`, the home counts and the actions; `Pgbus::WorkflowEntry.attention_groups` / `attention_ids` share the stuck-reason `CASE` with the gauge. The MCP server gains the read-only `pgbus_workflows`, `pgbus_workflow` and `pgbus_workflows_attention`, with arguments, outputs, signal payloads and error text behind `include_payloads`. Refs #542.
|
|
8
|
+
- **feat(workflows): waiting steps — `fan_out` over a batch, signals, wait timeouts (issue #541).** `fan_out :notify_vendors do |b| b.add(*jobs) end` runs its body with a recording builder, then opens one batch (`Pgbus.batch`, described `Class#step workflow_id`) whose `on_complete` is `Pgbus::Workflow::BatchFinishedJob`; the step and the run park as `waiting` and the callback resumes the run once every member finished: the step completes and the next is scheduled, or, with a dead-lettered member, `on_batch_failure:` (`:pause!` default, `:cancel!`, `:skip!`) applies with `Pgbus::Workflow::FanOutFailed` ("1 of 3 jobs dead-lettered") recorded. A verb or an exception in the body ends the step before any batch exists, the body cannot set the batch's callbacks, an empty batch resolves at once, and a reattempt or `resume!` re-runs the whole body on a fresh batch (members must be idempotent). `step :capture, wait_for: :payment_confirmed` parks the step at its gate until `wf.signal!(:payment_confirmed, payload: {…}, idempotency_key: event.id, actor:)` arrives, or `OrderWorkflow.signal!(order, :payment_confirmed, …, missing: :raise | :ignore)` by subject; signals live in `pgbus_workflow_signals` (`Pgbus::WorkflowSignal`, `pending → claimed → consumed`), early ones are buffered, a redelivery with the same key stores nothing and answers `duplicate_delivery?`, `wait_for:` takes a name, a `Pgbus::Workflow::SignalMatcher.new(:shipped) { |payload| … }` or any `matches?(signal)` object, the body reads `received_signal.payload` with indifferent access, and a claimed signal survives a `reattempt!` or a failure that pauses. The wake is a step-row write, so its StepJob goes out only after the signal commits; a paused run takes the signal but stays paused until `resume!`, and `resume!` of a run paused while waiting puts it back to `waiting`. `wait_timeout: 2.days, on_timeout: :pause! | :cancel! | :skip! | :run` bounds a wait (`timeout:` still bounds the run): the dispatcher's sweep applies it under the row lock (`Pgbus::Workflow::WaitTimedOut` for `:pause!`; `:run` runs the body with `timed_out?` true), a late signal stays pending, and the time a run spends paused does not count against the wait. The sweep also resolves a fan_out step whose batch finished but whose callback was lost, and pauses one whose batch row is gone (outcome unknown, whatever `on_batch_failure:` says). `skip!` outside a step skips the step a waiting run waits on. New runner verbs `park`, `wake`, `resolve`, `rewait` and outside verbs `skip`, `signal` in `States`; events `step_waiting`, `signal_received`, `signal_claimed`, `wait_timed_out`, `fan_out_opened`, `fan_out_finished`; instrumentation `pgbus.workflow.signal` and `pgbus.workflow.wait_timed_out`; stuck reason `waiting_overdue` past the new `workflow_waiting_visibility_threshold` (7 days). `TestHelpers` gains `deliver_signal`, `complete_fan_out` and `expire_wait`, and `speedrun_workflow` raises `Pgbus::Workflow::StepWaiting` naming what the step waits for. Schema: `pgbus:add_workflows` and `pgbus:install` carry the five step columns and the signals table; installs that ran the core migration add them with `rails generate pgbus:add_workflow_signals [--database=pgbus]` (`pgbus:update` detects it), and `start!` raises `WaitingStepsUnmigrated` (or `BatchCallbacksUnmigrated` for a `fan_out` before `pgbus:add_batch_callback_jobs`) until then. Refs #541.
|
|
9
|
+
- **feat(workflows): `Pgbus::Workflow`, durable multi-step workflows.** A workflow class declares steps (`step :name, wait:, skip_if:, timeout:, job_options:, on_exception:`, anonymous `step { … }`, `step :method`, `step def …`, `before_step:`/`after_step:`), `cancel_if`, class-level `on_exception` policies and `set_step_job_options`; `SignupWorkflow.start!(user, plan: "pro")` writes the run and its first step row in one transaction, and each step attempt is a `pgbus_workflow_steps` row and one `Pgbus::Workflow::StepJob`, sent only by the step row's after-commit callback (so `start!` inside an app transaction on the same database sends nothing until it commits). Inside a step, `subject` (GlobalID, nil once the record is gone), `arguments` (ActiveJob::Arguments, indifferent access, capped at `workflow_max_payload_size`) and the verbs `pause!`, `cancel!`, `reattempt!(wait:)`, `skip!`, `finished!`; outside, `resume!`, `resume_at!(:step)`, `pause!`, `cancel!`, `run_now!`, `reschedule!(at:)` and `rerun!`, each with `actor:`/`reason:`, all checked against one transition table, `Pgbus::Workflow::States` (`available_actions`). An unhandled exception never re-raises into ActiveJob: the step row records the class, message, cleaned and capped backtrace, cause chain, `hostname:pid` and app revision, `Pgbus::ErrorReporter` gets it with the workflow, step and attempt as context, and the run pauses unless a policy (`action:`, `matching:`, `wait:`, `max_reattempts:`, `terminal_action:`, `report:`, or an imperative block) says otherwise. Idempotent by contract: `output(**values)` is written in the UPDATE that completes the step and `output_of(:step)` reads the latest completed attempt only; `step_idempotency_key` is the same on every attempt (`attempt_idempotency_key` differs); `start!(…, idempotency_key:)` returns the existing run with `duplicate_start?`. At most one ongoing run per `(class, subject)` (`Pgbus::Workflow::AlreadyRunning`); subject-less runs are not limited. `removed_step :name` gravestones skip a step a live run still names; an unknown step pauses with `StepNotDefined`; `definition_digest` marks runs that started on an older definition. Every transition writes a `pgbus_workflow_events` row (actor, reason, details) in its own transaction (`wf.events`). Housekeeping from the dispatcher: a redelivered StepJob whose row is still in progress means its worker died, and `workflow_stall_action` (`:pause`, `:reattempt`, `:cancel`) applies at once; the sweep (`workflow_sweep_interval`) stalls a step past its `timeout:` or `workflow_stall_threshold`, re-sends lost scheduled steps and emits `pgbus.workflow.gauge`; finished/cancelled runs are deleted after `workflow_retention` (30 days). Instrumented as `pgbus.workflow.step`, `pgbus.workflow_finished`, `pgbus.workflow_paused`, `pgbus.workflow_cancelled`, `pgbus.workflow.stall_detected`; Prometheus/StatsD and AppSignal get `pgbus_workflow_step_duration_seconds`, `pgbus_workflow_steps_total`, run counters and the `pgbus_workflows`/`pgbus_workflows_stuck` gauges (the AppSignal dashboard gains a workflows row; the StatsD backend sends any `*_seconds` histogram as a `*_ms` timer, × 1000). Instrumentation and error reports of a transition made inside an app's transaction go out when that transaction commits, never on its rollback. `Pgbus::Workflow::TestHelpers` (`perform_next_step`, `run_step_inline`, `speedrun_workflow`) runs steps inline; `Pgbus::Workflow::StepContext` and Pgbus's log context tag a running step; `Pgbus::ActiveJob::Delivery.current` exposes the running job's `msg_id`/`read_ct`. Set up with `rails generate pgbus:add_workflows` (`pgbus:update` detects the missing tables; fresh `pgbus:install` includes them). New configuration: `workflow_queue`, `workflow_retention`, `workflow_sweep_interval`, `workflow_stall_threshold`, `workflow_stall_action`, `workflow_max_payload_size`, `workflow_error_backtrace_lines`. Refs #540.
|
|
5
10
|
- **`Pgbus.stream_key` works inside a non-main Ractor.** `Pgbus.stream_key`, `Pgbus.stream_key!` and `Pgbus.stream_name_budget` (and `Streams::Key`) check the queue-name budget against the `queue_prefix`, which they read from `Pgbus.configuration`; a non-main Ractor cannot read that (it holds the logger and the app's callables), so they raised `Ractor::IsolationError` there. Each `Pgbus.finalize!` and finalizing `Pgbus.configure` (so also the engine's `after_initialize` and `pgbus start`) now publishes `Pgbus.configuration_snapshot`, a Ractor-shareable `Pgbus::Configuration::Snapshot` of the settings a non-main Ractor reads (the `queue_prefix` for now), and the stream keys read it there. On the main Ractor nothing changes: they read the live configuration, so a `queue_prefix` set through a setter still applies at once; a non-main Ractor sees it from the next finalize. Refs #527.
|
|
6
11
|
- **Deploy tasks: one-time work owed by a release, run once the fleet has rolled over.** Declare a backfill, data fix or cache warm in `config/deploy_tasks.yml` (the shape of `recurring.yml` minus `schedule:`, plus `version:` and `every_deploy:`) or `config.deploy_tasks`; the scheduler enqueues it once per `(key, version)` (or once per revision for an `every_deploy:` task) after every live pgbus process reports the new app revision and a worker on it is alive, so it never runs on old code during the overlap window and two schedulers never both fire it. It is not exactly-once: a failure between the enqueue and recording the run can repeat a task after a scheduler restart, and a scheduler killed between claiming and enqueueing loses that arm, so deploy tasks must be idempotent. Every pgbus process stamps the revision (`config.app_revision`, `PGBUS_REVISION`, `DASH_VERSION`, `KAMAL_VERSION` or the `REVISION` file) into its heartbeat. The dashboard gets a "Deploy tasks" page with the rollover status, Run now and Enable/Disable; run history lives in `pgbus_deploy_task_runs` (`deploy_task_run_retention`, default 90 days); `pgbus.deploy_task.enqueue` is instrumented and the MCP server gains `pgbus_deploy_tasks`. Set up with `rails generate pgbus:add_deploy_tasks` (`pgbus:update` detects the missing tables). Refs #536.
|
|
7
12
|
- **`pgbus.event_dead_lettered`: the event consumer says when it dead-letters an event.** A job that exhausted `max_retries` emitted `pgbus.job_dead_lettered`; an event moved to `<queue>_dlq` the same way emitted nothing, so a pager, an error tracker or a metrics counter had no push signal for dead events. `Process::Consumer` now emits `pgbus.event_dead_lettered` once per dead-lettered event, after `move_to_dead_letter` returns and the failed-event row is cleared, with `queue_name` (logical), `routing_key`, `handler` (the class that owns the queue in this process, or nil), `event_id`, `read_ct`, `msg_id` and `error` (the class of the last recorded failure, or nil). `handler` is nil unless exactly one subscriber owns the queue. A body that is not a JSON object (unparseable text, or a jsonb scalar or array written straight to the queue) is still dead-lettered, with `routing_key`, `handler` and `event_id` nil, and its `error` is the consumer's own failure to read it (`JSON::ParserError`, `NoMethodError` or `TypeError`): no handler ran. A move that raises emits nothing. A subscriber that raises is logged at debug level and does not change the outcome: the message stays dead-lettered, with one `dead_lettered` stat, no `failed` stat and no circuit-breaker failure. The AppSignal and metrics subscribers count it as `pgbus_event_count` with `status: "dead_lettered"`, tagged `handler` and `routing_key`. `pgbus.job_dead_lettered` and the consumer's success path are unchanged: the envelope is parsed a second time only on the dead-letter branch. Refs #543.
|
|
@@ -13,6 +18,9 @@
|
|
|
13
18
|
|
|
14
19
|
### Fixed
|
|
15
20
|
|
|
21
|
+
- **A blank signal `idempotency_key` is no key (issue #550).** `wf.signal!(…, idempotency_key: "")` (or whitespace) stored `""` as a real key, so every later signal sent with a blank key dedup'd against the first: it answered `duplicate_delivery?` and was never stored, leaving a step waiting for it. `Signals.deliver` now treats a blank key as none, for app code, the dashboard and the MCP tool alike. Refs #550.
|
|
22
|
+
- **`Pgbus.batch` inside a database transaction counts the jobs that enqueue after commit, and waits for them.** A job with Active Job's `enqueue_after_transaction_commit` added inside an open transaction is enqueued only when the transaction commits, after the block has returned and its thread tag is gone. The batch counted nothing, finished empty when the block returned and sent its `on_complete` (and `on_success`) callback before any member job existed; the jobs then ran outside the batch. Even a rolled-back transaction sent the callback. Now `b.add`, `batch.add` and `perform_later` record the batch on the job instance (`enqueue_batch_id`, transient: never serialized, cleared once the enqueue is done), the adapter tags and counts each job from it at the real enqueue, and the batch starts processing (or an open batch re-checks for finish) through `ActiveRecord.after_all_transactions_commit`, after every deferred enqueue has counted itself in. On a rollback nothing is sent, no callback fires and the batch row is rolled back with the transaction. A deferred first attempt discarded at enqueue (uniqueness or concurrency `on_conflict: :discard`) is uncounted as before, and a `retry_on` re-enqueue still re-joins without a second count. Outside a transaction nothing changes, including the queries a batch runs. Known gaps: a bare `ActiveJob.perform_all_later` inside the block does not record the batch (use `b.add`); a `batch.add` inside a transaction raises `AlreadyFinished` when the transaction commits, not at the `add` line, if the batch finished in the meantime; a batch row on a separate `connects_to` database is not part of the caller's transaction. Refs #553.
|
|
23
|
+
- **AppSignal: the job's transaction is current while the job runs, and carries its error (issue #552).** The AppSignal subscriber opened the `background_job` transaction from a finish-only notification callback, after the job had run, and completed it in the same call. `pgbus.job_failed` fired after that, so its `Appsignal.set_error` found no current transaction and reported nothing: a job whose class no longer exists (`ActiveJob::UnknownJobClassError`) or whose arguments cannot be deserialized retried and dead-lettered with nothing in AppSignal but the counters, an error raised inside `perform` arrived only as an action-less sample from the Rails error reporter, and job code calling `Appsignal.add_tags`, `set_error` or `instrument` attached to nothing. Event handlers had the same shape. The subscriber now listens to `pgbus.executor.execute` and `pgbus.event_processed` on both sides (`start` and `finish`): the `<JobClass>#perform` / `<Handler>#handle` transaction opens before the job or handler runs, is current for the whole execution (deserialize, perform, archive), records the exception the execution raised and completes at the end, so an error raised inside `perform` lands on it once, with its duration and queue start. A failure raised before that span opens (a message body that is not JSON, an event that cannot be built, a dead-letter move that failed) is reported from `pgbus.job_failed` / `pgbus.event_failed` on a transaction of its own with the same action and tags; an error the span already recorded is not reported twice. A transaction that is already current is reused and left to its owner to complete, as AppSignal's own ActiveJob hook does, and a span inside another of its kind restores the outer one when it finishes. Like that hook, the span records any exception that ends the execution, so a job interrupted between `perform` and the archive (an `Async::Stop` on shutdown under `execution_mode: :async`, an `Interrupt`) now shows up as an error sample too. The counters and the `pgbus_job_duration_ms` / `pgbus_event_duration_ms` distributions are recorded as before, once per execution. Refs #552.
|
|
16
24
|
- **The dashboard's confirm and alert dialogs open centred again (issue #529).** The engine's stylesheet is built with Tailwind 4, whose preflight sets `margin: 0` on every element. A modal `<dialog>` keeps the browser's `inset: 0` but lost the `margin: auto` that centres it inside that inset, so every Pause, Purge and Delete confirmation opened in the top-left corner. Both dialogs now carry `m-auto`. The confirm dialog's Confirm button was white on yellow (1.91:1). It now uses the dark-on-yellow warning colours (`bg-yellow-400 text-yellow-950`), and the axe gate audits an open confirm dialog in light and dark mode. Refs #529.
|
|
17
25
|
|
|
18
26
|
## [0.19.0] - 2026-10-16
|
data/README.md
CHANGED
|
@@ -35,6 +35,7 @@ PostgreSQL-native job processing and event bus for Rails, built on [PGMQ](https:
|
|
|
35
35
|
- [Single active consumer](#single-active-consumer)
|
|
36
36
|
- [Persistence and batching](#persistence-and-batching)
|
|
37
37
|
- [Batches](#batches)
|
|
38
|
+
- [Workflows](#workflows)
|
|
38
39
|
- [Transactional outbox](#transactional-outbox)
|
|
39
40
|
- [Archive compaction](#archive-compaction)
|
|
40
41
|
- [Observability](#observability)
|
|
@@ -264,7 +265,7 @@ This boots a supervisor that manages:
|
|
|
264
265
|
mount Pgbus::Engine => "/pgbus"
|
|
265
266
|
```
|
|
266
267
|
|
|
267
|
-
The dashboard shows queues, jobs, processes, failures, dead letter messages, and
|
|
268
|
+
The dashboard shows queues, jobs, processes, failures, dead letter messages, event subscribers, workflow runs and per-class workflow insights. It auto-refreshes via Turbo Frames with no WebSocket dependency.
|
|
268
269
|
|
|
269
270
|
Protect it in production with a simple auth lambda:
|
|
270
271
|
|
|
@@ -853,6 +854,48 @@ The lock is taken at the top of the block (or at `enqueue`) and held until the b
|
|
|
853
854
|
4. When `completed_jobs + discarded_jobs == total_jobs`, the batch status flips to `"finished"` and callback jobs are enqueued
|
|
854
855
|
5. The dispatcher cleans up finished batches older than 7 days
|
|
855
856
|
|
|
857
|
+
### Workflows
|
|
858
|
+
|
|
859
|
+
Durable, sequential steps that span hours or days. Each step attempt is a row and one job; `wait:` delays a step after the one before; the run can be paused, resumed, rescheduled or cancelled from the console, and every change lands on an audit trail.
|
|
860
|
+
|
|
861
|
+
```ruby
|
|
862
|
+
class SignupWorkflow < Pgbus::Workflow
|
|
863
|
+
cancel_if { subject.nil? || subject.closed? }
|
|
864
|
+
on_exception Net::OpenTimeout, action: :reattempt!, wait: 15.seconds, max_reattempts: 5
|
|
865
|
+
|
|
866
|
+
step :welcome do
|
|
867
|
+
WelcomeMailer.with(user: subject).welcome.deliver_later
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
step :reminder, wait: 2.days, skip_if: -> { subject.onboarded? } do
|
|
871
|
+
ReminderMailer.with(user: subject).remind.deliver_later
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
step :complete, wait: 7.days do
|
|
875
|
+
subject.update!(onboarding_completed: true)
|
|
876
|
+
end
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
wf = SignupWorkflow.start!(user, plan: "pro") # arguments[:plan] inside a step
|
|
880
|
+
wf.pause!(actor: "alice", reason: "SMTP is down") # every outside verb takes actor:/reason:
|
|
881
|
+
wf.resume! # a failed step runs again; a completed one is followed by the next
|
|
882
|
+
wf.events.map(&:event) # ["started", "step_scheduled", "step_started", …]
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
- **Flow control** inside a step: `pause!`, `cancel!`, `reattempt!(wait:)`, `skip!`, `finished!`. Outside: `resume!`, `resume_at!(:step)`, `pause!`, `cancel!`, `run_now!`, `reschedule!(at:)`, `rerun!`, `signal!`, and `skip!` for the step a waiting run waits on. `Pgbus::Workflow::States` is the one table of which verb is legal in which state (`wf.available_actions`).
|
|
886
|
+
- **Exceptions** never reach ActiveJob: the error is recorded on the step row and reported through `Pgbus::ErrorReporter`, and the run pauses unless an `on_exception` policy (step- or class-level, with `max_reattempts:`, `terminal_action:` and `report:`) says otherwise.
|
|
887
|
+
- **Idempotent by contract**: `output(charge_id: …)` is durable once the step completes and later steps read it with `output_of(:charge)`; `step_idempotency_key` is the same on every attempt, for the external API; `start!(order, idempotency_key: "checkout-#{order.id}")` returns the existing run.
|
|
888
|
+
- **One ongoing run per subject** and class: a second `start!` raises `Pgbus::Workflow::AlreadyRunning`. Subject-less runs are not limited.
|
|
889
|
+
- **Fan-out**: `fan_out :notify_vendors do |b| b.add(*jobs) end` opens one batch for the jobs and parks the step until every member finished; the batch's `on_complete` resumes the run, and a dead-lettered member applies `on_batch_failure:` (`:pause!` by default) with `Pgbus::Workflow::FanOutFailed` recorded. A reattempt, or `resume!` of a failed fan-out step, re-runs the whole body on a fresh batch, so members must be idempotent; a run resumed while its batch is still running keeps waiting on that batch.
|
|
890
|
+
- **Signals**: `step :capture, wait_for: :payment_confirmed do … received_signal.payload["amount"] … end` parks until `wf.signal!(:payment_confirmed, payload: {…}, idempotency_key: event.id)`, or by subject, `OrderWorkflow.signal!(order, :payment_confirmed, …, missing: :ignore)`. Early signals are buffered, a redelivery with the same key is a no-op (`duplicate_delivery?`), and matchers (`Pgbus::Workflow::SignalMatcher.new(:shipped) { |payload| … }`) narrow what a step takes. `wait_timeout: 2.days, on_timeout: :pause!` (or `:cancel!`, `:skip!`, `:run`) puts a deadline on the wait, fired by the dispatcher's sweep.
|
|
891
|
+
- **Housekeeping**: a worker that dies mid-step is noticed when its message comes back (the visibility heartbeat keeps a live one invisible); a hung step stalls after its `timeout:` or `workflow_stall_threshold`; the dispatcher re-sends lost steps and deletes finished runs after `workflow_retention` (30 days).
|
|
892
|
+
- **Metrics** for Prometheus/StatsD and AppSignal: per-step duration histograms and counters, and gauges of runs by status and by stuck reason.
|
|
893
|
+
- **Dashboard**: `/pgbus/workflows` lists runs with what each one does next, filters by state, class, step, error, subject, id and creation date, and shows a run's timeline, attempts, signals and audit trail with its actions (Resume, Resume at, Run now, Skip step, Send signal, Rerun, Cancel); `/pgbus/workflows/attention` groups the stuck runs for bulk Resume/Cancel. `config.web_actor = ->(request) { … }` names the operator on the audit trail and `config.workflow_subject_url = ->(type, id) { … }` links the subject. The MCP server reads the same through `pgbus_workflows`, `pgbus_workflow` and `pgbus_workflows_attention`, and with `config.mcp_write_tools = true` acts on one run per call through `pgbus_workflow_resume`, `_cancel`, `_skip_step`, `_resume_at`, `_run_now` and `_signal` ([write tools](#write-tools-opt-in)).
|
|
894
|
+
- **Insights by class**: `/pgbus/workflows/definitions` has one row per workflow class (ongoing, paused, needing attention, finished and cancelled in the window, p50/p95 end-to-end duration); `/pgbus/workflows/definitions/OrderWorkflow` shows its steps as a pipeline with the runs sitting at each one now, run-time and pickup percentiles and failure/reattempt/skip rates, runs per bucket, the duration distribution, a funnel of the runs started in the window and the same numbers per app revision, over 1 h, 24 h, 7 d or 30 d. MCP: `pgbus_workflow_definitions` and `pgbus_workflow_stats`.
|
|
895
|
+
- **Testing**: `include Pgbus::Workflow::TestHelpers`, then `perform_next_step(wf)`, `run_step_inline(wf, :reminder)` or `speedrun_workflow(wf)` (which raises `Pgbus::Workflow::StepWaiting` at a waiting step), and `deliver_signal(wf, :shipped)`, `complete_fan_out(wf)` or `expire_wait(wf)` to move a waiting run on.
|
|
896
|
+
|
|
897
|
+
When the workflow tables share your application's database, the StepJob is sent after the step row commits, so `start!` inside your own transaction sends nothing until it commits; with a separate pgbus database (`connects_to`) the run commits and its first step is sent on their own, before your transaction commits. Generate and apply the migration with `rails generate pgbus:add_workflows` and `rails db:migrate` (`pgbus:update` detects the missing tables); an install that added workflows before waiting steps adds them with `rails generate pgbus:add_workflow_signals`. The [Workflows guide](https://pgbus.zoolutions.llc/docs/workflows) covers policies, outputs, removing steps, the stall actions and the transition table.
|
|
898
|
+
|
|
856
899
|
### Transactional outbox
|
|
857
900
|
|
|
858
901
|
Publish events atomically inside your database transactions. A background poller moves outbox entries to PGMQ.
|
|
@@ -917,13 +960,19 @@ By default, Pgbus logs caught exceptions and continues. To route them to your AP
|
|
|
917
960
|
```ruby
|
|
918
961
|
Pgbus.configure do |c|
|
|
919
962
|
c.error_reporters << ->(ex, ctx) {
|
|
920
|
-
Appsignal.
|
|
963
|
+
Appsignal.report_error(ex) do |t|
|
|
964
|
+
t.set_namespace("background_job") if ctx[:job_class]
|
|
965
|
+
t.set_action("#{ctx[:job_class]}#perform") if ctx[:job_class]
|
|
966
|
+
t.add_tags(ctx)
|
|
967
|
+
end
|
|
921
968
|
}
|
|
922
969
|
end
|
|
923
970
|
```
|
|
924
971
|
|
|
925
972
|
Each reporter receives `(exception, context_hash)`. The context hash includes keys like `action`, `queue`, `job_class`, and `msg_id` depending on the call site. Reporters that accept a third argument also receive the Pgbus configuration object.
|
|
926
973
|
|
|
974
|
+
Use `Appsignal.report_error`, not `Appsignal.set_error`: a reporter may run from a rescue where no AppSignal transaction is current, and there `set_error` does nothing. `report_error` works whether a transaction is current or not. With the [AppSignal integration](#appsignal-integration) installed, job and handler errors already reach AppSignal on their own transaction, so a reporter like the one above is only needed for the other rescue paths. It does not report those job errors a second time: AppSignal marks an exception once it is on a transaction (`Transaction#add_error`) and does not add a marked exception to another one.
|
|
975
|
+
|
|
927
976
|
Reporters are wired into all critical rescue paths: job execution failures, worker fetch/process errors, dispatcher maintenance, supervisor fork failures, circuit breaker trips, outbox publish errors, and failed event recording. Non-critical paths (dashboard queries, stat recording) remain log-only.
|
|
928
977
|
|
|
929
978
|
`ErrorReporter.report` is guaranteed to never raise — if a reporter or the logger itself throws, the error is swallowed silently. This preserves fault-tolerance invariants at every rescue site.
|
|
@@ -951,7 +1000,7 @@ If you upgraded from 0.9.x and previously did `rescue ArgumentError` around `Pgb
|
|
|
951
1000
|
|
|
952
1001
|
When the `appsignal` gem is loaded in your app, Pgbus auto-installs a subscriber and a minutely probe that report into AppSignal:
|
|
953
1002
|
|
|
954
|
-
- **Background-job transactions** for every ActiveJob run and every event-bus handler invocation. Action names follow the AppSignal convention: `MyJob#perform`, `MyHandler#handle`. Tags include `queue`, `job_class`/`handler`, `routing_key`, `attempts`, and the `active_job_id` / `provider_job_id`. `enqueued_at` becomes the AppSignal `queue_start` timestamp so "time on queue" shows up correctly in the timeline.
|
|
1003
|
+
- **Background-job transactions** for every ActiveJob run and every event-bus handler invocation. Action names follow the AppSignal convention: `MyJob#perform`, `MyHandler#handle`. Tags include `queue`, `job_class`/`handler`, `routing_key`, `attempts`, and the `active_job_id` / `provider_job_id`. `enqueued_at` becomes the AppSignal `queue_start` timestamp so "time on queue" shows up correctly in the timeline. The transaction is current for the whole execution (deserialize, `perform`/`handle`, archive), so `Appsignal.add_tags`, `Appsignal.set_error` and `Appsignal.instrument` called from job or handler code attach to it, and the error an execution raised is reported on it, also when it was raised before `perform` (an unknown job class, arguments that cannot be deserialized).
|
|
955
1004
|
- **Custom counters and distributions** for sends, reads, broadcasts, outbox publishes, recurring scheduling, and worker recycles. All metric names are prefixed `pgbus_`.
|
|
956
1005
|
- **A minutely probe** that gauges queue depth (visible vs total), oldest message age per queue, DLQ depth, failed events count, dead-tuple totals, MVCC horizon age, active processes, and stream connection estimates.
|
|
957
1006
|
|
|
@@ -1019,6 +1068,13 @@ c.metrics_backend = MyOpenTelemetryBackend.new
|
|
|
1019
1068
|
| `pgbus_recurring_enqueued` | counter | `task`, `class_name` |
|
|
1020
1069
|
| `pgbus_worker_recycled` | counter | `reason`, `kind` (`worker`/`consumer`) |
|
|
1021
1070
|
| `pgbus_pool_size` / `pgbus_pool_available` | gauge | `hostname` |
|
|
1071
|
+
| `pgbus_workflow_step_duration_seconds` | histogram | `workflow_class`, `step`, `outcome` — StatsD receives it as `pgbus_workflow_step_duration_ms` |
|
|
1072
|
+
| `pgbus_workflow_steps_total` | counter | `workflow_class`, `step`, `outcome` |
|
|
1073
|
+
| `pgbus_workflows_finished_total` / `pgbus_workflows_cancelled_total` | counter | `workflow_class` |
|
|
1074
|
+
| `pgbus_workflows_paused_total` | counter | `workflow_class`, `error_class` (absent for a manual pause) |
|
|
1075
|
+
| `pgbus_workflow_stalls_total` | counter | `workflow_class`, `step`, `detection` (`redelivery`/`sweep`) |
|
|
1076
|
+
| `pgbus_workflows` | gauge | `workflow_class`, `status` |
|
|
1077
|
+
| `pgbus_workflows_stuck` | gauge | `workflow_class`, `reason` (`paused_error`/`stalled`/`paused_manual`/`overdue`/`waiting_overdue`) |
|
|
1022
1078
|
|
|
1023
1079
|
A backend that raises (registry bug, StatsD socket down) is logged and swallowed — a metrics failure never propagates into the thread that emitted the event.
|
|
1024
1080
|
|
|
@@ -1046,7 +1102,7 @@ ActiveSupport::Notifications.subscribe(/^pgbus\./) do |name, start, finish, _id,
|
|
|
1046
1102
|
end
|
|
1047
1103
|
```
|
|
1048
1104
|
|
|
1049
|
-
Metrics-relevant events emitted (the subset the metrics subscriber maps; see `lib/pgbus/instrumentation.rb` for the full `pgbus.*` catalog): `pgbus.executor.execute`, `pgbus.job_completed`, `pgbus.job_failed`, `pgbus.job_dead_lettered`, `pgbus.event_processed`, `pgbus.event_failed`, `pgbus.event_dead_lettered`, `pgbus.event_skipped`, `pgbus.job_visibility_extended`, `pgbus.client.send_message`, `pgbus.client.send_batch`, `pgbus.client.read_batch`, `pgbus.client.pool`, `pgbus.stream.broadcast`, `pgbus.outbox.publish`, `pgbus.recurring.enqueue`, `pgbus.worker.recycle`, `pgbus.consumer.recycle`. Payload keys are documented in `lib/pgbus/instrumentation.rb`.
|
|
1105
|
+
Metrics-relevant events emitted (the subset the metrics subscriber maps; see `lib/pgbus/instrumentation.rb` for the full `pgbus.*` catalog): `pgbus.executor.execute`, `pgbus.job_completed`, `pgbus.job_failed`, `pgbus.job_dead_lettered`, `pgbus.event_processed`, `pgbus.event_failed`, `pgbus.event_dead_lettered`, `pgbus.event_skipped`, `pgbus.job_visibility_extended`, `pgbus.client.send_message`, `pgbus.client.send_batch`, `pgbus.client.read_batch`, `pgbus.client.pool`, `pgbus.stream.broadcast`, `pgbus.outbox.publish`, `pgbus.recurring.enqueue`, `pgbus.worker.recycle`, `pgbus.consumer.recycle`, `pgbus.workflow.step`, `pgbus.workflow_finished`, `pgbus.workflow_paused`, `pgbus.workflow_cancelled`, `pgbus.workflow.stall_detected`, `pgbus.workflow.gauge`. Payload keys are documented in `lib/pgbus/instrumentation.rb`.
|
|
1050
1106
|
|
|
1051
1107
|
### Connection pool metrics
|
|
1052
1108
|
|
|
@@ -1134,9 +1190,9 @@ When `config.metrics_enabled = true` (default), the dashboard exposes Prometheus
|
|
|
1134
1190
|
| `pgbus_worker_pool_busy` | Currently busy worker threads |
|
|
1135
1191
|
| `pgbus_worker_pool_utilization` | Busy / capacity ratio |
|
|
1136
1192
|
|
|
1137
|
-
### MCP diagnostic server (read-only)
|
|
1193
|
+
### MCP diagnostic server (read-only by default)
|
|
1138
1194
|
|
|
1139
|
-
Pgbus ships an optional, **read-only** [MCP](https://modelcontextprotocol.io) server so an AI agent (or any MCP client) can diagnose pgbus directly — "are queues backed up?", "is `read_ct` advancing?", "are workers heart-beating but not claiming?" — instead of hand-writing `pgmq` / `pg_stat_activity` SQL against production. It is a thin adapter over the same read layer the dashboard uses, so it adds no new database access path.
|
|
1195
|
+
Pgbus ships an optional, **read-only** [MCP](https://modelcontextprotocol.io) server so an AI agent (or any MCP client) can diagnose pgbus directly — "are queues backed up?", "is `read_ct` advancing?", "are workers heart-beating but not claiming?" — instead of hand-writing `pgmq` / `pg_stat_activity` SQL against production. It is a thin adapter over the same read layer the dashboard uses, so it adds no new database access path. Acting on workflow runs is a separate opt-in: [write tools](#write-tools-opt-in), off by default.
|
|
1140
1196
|
|
|
1141
1197
|
Add the optional `mcp` gem to your `Gemfile` first (`gem "mcp"`, 0.23 or newer — 1.x is fully supported); both entry points below tell you if it's missing.
|
|
1142
1198
|
|
|
@@ -1199,7 +1255,7 @@ run Pgbus::MCP.rack_app(token: ENV["PGBUS_MCP_TOKEN"])
|
|
|
1199
1255
|
|
|
1200
1256
|
#### Tools
|
|
1201
1257
|
|
|
1202
|
-
All tools are read-only —
|
|
1258
|
+
All tools below are read-only — none mutates state, and there is no raw-SQL passthrough. The workflow write tools are a separate opt-in ([next section](#write-tools-opt-in)).
|
|
1203
1259
|
|
|
1204
1260
|
| Tool | Purpose |
|
|
1205
1261
|
|------|---------|
|
|
@@ -1212,15 +1268,47 @@ All tools are read-only — no tool mutates state, and there is no raw-SQL passt
|
|
|
1212
1268
|
| `pgbus_locks` | Active uniqueness locks (the leaked-lock diagnostic). Paginated (`page`, `per_page` max 100; returns `total`, capped at 10,000, and `has_more`). |
|
|
1213
1269
|
| `pgbus_throughput` / `pgbus_stats` | Recent throughput time series and status counts. |
|
|
1214
1270
|
| `pgbus_deploy_tasks` | Deploy tasks, their state and the rollover status (read-only). |
|
|
1271
|
+
| `pgbus_workflows` / `pgbus_workflow` | Workflow runs with the dashboard's filters (paginated, `total` capped at 10,000), and one run's attempts, durations, signals, audit trail and `available_actions`. Arguments, outputs, signal payloads and error text are redacted unless payloads are allowed and `include_payloads` is set. |
|
|
1272
|
+
| `pgbus_workflows_attention` | Stuck workflow runs grouped by class, step, reason and error class, with counts and first/last seen (`workflow_class` narrows them to one class). |
|
|
1273
|
+
| `pgbus_workflow_definitions` / `pgbus_workflow_stats` | One row per workflow class over a `window` (1h, 24h, 7d, 30d), and one class's pipeline (runs at each step now, run-time and pickup percentiles, failure/reattempt/skip rates), throughput, durations, funnel and per-revision numbers. |
|
|
1215
1274
|
| `pgbus_recurring` | Recurring task schedule + last/next run times. All tasks unless `page` / `per_page` is given (then `total` is capped at 10,000); returns `total`, `has_more`. |
|
|
1216
1275
|
|
|
1276
|
+
#### Write tools (opt-in)
|
|
1277
|
+
|
|
1278
|
+
An agent that found 30 paused runs with `pgbus_workflows_attention` (and listed them with `pgbus_workflows`) can resume them after the fix without opening the dashboard — once you turn the write tools on:
|
|
1279
|
+
|
|
1280
|
+
```ruby
|
|
1281
|
+
Pgbus.configure { |c| c.mcp_write_tools = true } # default false: the tools do not exist at all
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1284
|
+
The flag is read when the server is built (`pgbus mcp`, `Pgbus::MCP.rack_app`). Off, the six tools are never constructed, so `tools/list` cannot show them; on, they join the read tools, which stay exactly as they are.
|
|
1285
|
+
|
|
1286
|
+
| Tool | What it does |
|
|
1287
|
+
|------|--------------|
|
|
1288
|
+
| `pgbus_workflow_resume` | Resume a paused run (a failed step runs again; a completed one is followed by the next). |
|
|
1289
|
+
| `pgbus_workflow_cancel` | Cancel an ongoing run. |
|
|
1290
|
+
| `pgbus_workflow_skip_step` | Skip the step a waiting run waits on. |
|
|
1291
|
+
| `pgbus_workflow_resume_at` | Resume a paused run at a named `step`. |
|
|
1292
|
+
| `pgbus_workflow_run_now` | Send a ready run's scheduled step now instead of after its wait. |
|
|
1293
|
+
| `pgbus_workflow_signal` | Deliver signal `name` with a JSON object `payload` (at most `workflow_max_payload_size`) and an optional `idempotency_key` — pass one when the call may be retried (a blank key is no key). A retry dedups only while the run is ongoing: once it finished or was cancelled, it answers `ok: false`. Name and key are at most 255 characters. |
|
|
1294
|
+
|
|
1295
|
+
- Each takes `workflow_id`, a required `reason` (1–500 characters after trimming whitespace) and an optional `expected_status`, the status the agent read: a run that has moved on by the time the call starts answers `ok: false` and nothing is done, so an agent acting on a stale read is not surprised. The check runs just before the action; the transition itself is guarded again under the run's row lock.
|
|
1296
|
+
- Arguments the input schema rejects (no `reason`, one over 500 characters, an unknown `expected_status`, a `payload` that is not an object) come back as a tool error before the tool runs; nothing is done either way.
|
|
1297
|
+
- The verb is checked against the run's `available_actions` (not allowed: `ok: false` with the allowed list), then goes through the same `Web::DataSource` action as the dashboard's button, so the transition table and the run's row lock decide.
|
|
1298
|
+
- An action the call performs lands on the run's audit trail with actor `mcp` (`mcp:<client name>` when the transport knows the client) and the reason it was given. A refused call changes nothing and records nothing.
|
|
1299
|
+
- The answer is `{ ok:, reason:, workflow: }`: `workflow` is the `pgbus_workflow` detail as the run is after the call, with arguments, outputs, signal payloads and error text always redacted. A disallowed verb adds `available_actions`; a transition refused by `Web::DataSource` under the row lock adds `code` and, when the run is still there, the `status` it is in now. An unexpected failure answers a fixed reason (the error is in the pgbus log), never the exception's text.
|
|
1300
|
+
- The tools carry `readOnlyHint: false, destructiveHint: true`, and `idempotentHint: true` on cancel only: a cancelled run stays cancelled, while a retried skip or resume at can act again once the run has parked or paused again.
|
|
1301
|
+
- No bulk tools: `pgbus_workflows_attention` groups the stuck runs; list a group's runs with `pgbus_workflows` (`status`, `workflow_class`, `step`, `error_class`) and make one audited call per run.
|
|
1302
|
+
|
|
1303
|
+
Enable them only on an authenticated endpoint: `rack_app` with `token:` or `auth:`, or mounted inside your app's own authenticated routes — never public. An unauthenticated `rack_app` with write tools on logs a warning that anyone who reaches it can change workflow runs.
|
|
1304
|
+
|
|
1217
1305
|
#### Security
|
|
1218
1306
|
|
|
1219
1307
|
The server is built to be safe against a production datastore:
|
|
1220
1308
|
|
|
1221
|
-
- **Read-only by default.** No tool mutates state and
|
|
1309
|
+
- **Read-only by default.** No tool mutates state unless `config.mcp_write_tools` is on, and then only the six workflow write tools do, one run per call, each with a reason on the run's audit trail. No arbitrary-query tool exists.
|
|
1222
1310
|
- **Payloads redacted.** Message bodies, headers, and job arguments are replaced with `[redacted]` unless payloads are explicitly allowed **and** `include_payloads: true` is passed on the call. Both gates must be open. Allow payloads with `PGBUS_MCP_ALLOW_PAYLOADS=1` (stdio) or `Pgbus::MCP.rack_app(allow_payloads: true)` (HTTP).
|
|
1223
|
-
- **Bounded queries.** Every list tool paginates with a row cap (`pgbus_jobs` / `pgbus_dlq` / `pgbus_locks` / `pgbus_concurrency` cap at 100 rows/page; time windows cap at 1440 minutes). The
|
|
1311
|
+
- **Bounded queries.** Every list tool paginates with a row cap (`pgbus_jobs` / `pgbus_dlq` / `pgbus_locks` / `pgbus_concurrency` cap at 100 rows/page; time windows cap at 1440 minutes). The exceptions are `pgbus_deploy_tasks` and `pgbus_recurring` called without `page`/`per_page`, which return every task as they always have, and `pgbus_workflow_definitions`, which returns one row per workflow class.
|
|
1224
1312
|
- **Reuses your DB credentials.** No new privileged path — it reads through the app's existing connection config.
|
|
1225
1313
|
- **Authentication.**
|
|
1226
1314
|
- *HTTP:* set `token:` (clients send `Authorization: Bearer <token>`) or a custom `auth:` callable; unauthenticated requests get `401`.
|
|
@@ -2260,6 +2348,8 @@ The dashboard is a mountable Rails engine at `/pgbus` with:
|
|
|
2260
2348
|
- **Outbox** — transactional outbox entries pending publication
|
|
2261
2349
|
- **Locks** — active job uniqueness locks with state (queued/executing), owner PID@hostname, age
|
|
2262
2350
|
- **Insights** — throughput chart (jobs/min), status distribution donut, slowest job classes table
|
|
2351
|
+
- **Workflows** — every `Pgbus::Workflow` run with what it is doing and what happens next, filters, a per-run timeline chart with every attempt, its signals, its audit trail and the actions its state allows, and a needs-attention view that groups stuck runs for bulk Resume/Cancel
|
|
2352
|
+
- **Workflow insights** — one row per workflow class, and per class a pipeline of its steps (runs at each step now, run times, pickup latency, failure rates), throughput and duration charts, a funnel and the numbers per app revision
|
|
2263
2353
|
|
|
2264
2354
|
All tables use Turbo Frames for periodic auto-refresh without page reloads. Destructive actions use styled confirmation dialogs (not browser `confirm()`), and flash messages appear as auto-dismissing toast notifications.
|
|
2265
2355
|
|
|
@@ -2315,6 +2405,10 @@ Pgbus uses these tables (created via PGMQ and migrations):
|
|
|
2315
2405
|
| `pgbus_semaphores` | Concurrency control counting semaphores |
|
|
2316
2406
|
| `pgbus_blocked_executions` | Jobs waiting for a concurrency semaphore slot |
|
|
2317
2407
|
| `pgbus_batches` | Batch tracking with job counters and callback config |
|
|
2408
|
+
| `pgbus_workflows` | Workflow runs: class, subject, arguments, status, current step |
|
|
2409
|
+
| `pgbus_workflow_steps` | One row per workflow step attempt: timings, worker, output, error |
|
|
2410
|
+
| `pgbus_workflow_events` | Append-only workflow audit trail (event, actor, reason) |
|
|
2411
|
+
| `pgbus_workflow_signals` | Signals delivered to workflow runs: name, payload, idempotency key, pending/claimed/consumed |
|
|
2318
2412
|
| `pgbus_uniqueness_keys` | Job uniqueness locks (lock_key, queue_name, msg_id) |
|
|
2319
2413
|
| `pgbus_job_stats` | Job execution metrics (class, queue, status, duration) |
|
|
2320
2414
|
| `pgbus_queue_states` | Queue pause/resume and circuit breaker state |
|
|
@@ -2459,6 +2553,7 @@ Curated headline options for the README. The full operator reference (with types
|
|
|
2459
2553
|
| `error_reporters` | `[]` | Array of callables invoked on caught exceptions. Each receives `(exception, context_hash)`. |
|
|
2460
2554
|
| `log_format` | `:text` | Log formatter (`:text` or `:json`). Sets `logger.formatter` automatically. |
|
|
2461
2555
|
| `metrics_enabled` | `true` | Enable Prometheus-compatible metrics on the dashboard |
|
|
2556
|
+
| `mcp_write_tools` | `false` | Build the MCP server's workflow write tools (resume, cancel, skip step, resume at, run now, signal). Enable only on an authenticated endpoint. See [Write tools](#write-tools-opt-in). |
|
|
2462
2557
|
| `metrics_backend` | `nil` | Generic metrics adapter: `nil` (off), `:prometheus`, `:statsd`, or a `Pgbus::Metrics::Backend` instance |
|
|
2463
2558
|
| `statsd_host` | `"127.0.0.1"` | StatsD UDP host (used when `metrics_backend = :statsd`) |
|
|
2464
2559
|
| `statsd_port` | `8125` | StatsD UDP port (used when `metrics_backend = :statsd`) |
|
data/Rakefile
CHANGED
|
@@ -26,7 +26,7 @@ namespace :bench do
|
|
|
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
28
|
notify_wake_bench notify_chaos_bench streams_hub_bench fair_read_bench
|
|
29
|
-
worker_profile_bench].freeze
|
|
29
|
+
worker_profile_bench workflow_insights_bench].freeze
|
|
30
30
|
# The unit suite is every *_bench.rb that doesn't need a database, derived
|
|
31
31
|
# from the directory so a new unit bench is picked up automatically (kept in
|
|
32
32
|
# sync with bench:one, which globs the same files).
|
|
@@ -66,6 +66,11 @@ namespace :bench do
|
|
|
66
66
|
ruby "benchmarks/worker_profile_bench.rb"
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
desc "Run the workflow insights query budget: ~1M step rows (requires PGBUS_DATABASE_URL)"
|
|
70
|
+
task :workflow_insights do
|
|
71
|
+
ruby "benchmarks/workflow_insights_bench.rb"
|
|
72
|
+
end
|
|
73
|
+
|
|
69
74
|
desc "Run fair share read benchmark (requires PGBUS_DATABASE_URL)"
|
|
70
75
|
task :fair_read do
|
|
71
76
|
ruby "benchmarks/fair_read_bench.rb"
|
|
@@ -35,6 +35,8 @@ module Pgbus
|
|
|
35
35
|
def load_stats
|
|
36
36
|
@stats = data_source.summary_stats
|
|
37
37
|
@signals = data_source.dashboard_signals
|
|
38
|
+
@workflow_counts = { ongoing: data_source.ongoing_workflows_count, paused: data_source.paused_workflows_count,
|
|
39
|
+
attention: data_source.workflow_attention_count }
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
def load_queues
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
# The workflow insights (issue #549): one row per workflow class, and one
|
|
5
|
+
# class's pipeline, step performance, throughput, durations, funnel and
|
|
6
|
+
# per-revision numbers, over a Web::WorkflowWindow (?window=1h|24h|7d|30d).
|
|
7
|
+
# The route constrains the class name to a constant path; one no class or
|
|
8
|
+
# run has is a 404 page.
|
|
9
|
+
class WorkflowDefinitionsController < ApplicationController
|
|
10
|
+
rescue_from Web::WorkflowWindow::Invalid do |error|
|
|
11
|
+
render plain: t("pgbus.workflow_insights.bad_request", reason: error.message), status: :bad_request
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def index
|
|
15
|
+
@window = Web::WorkflowWindow.parse(params[:window])
|
|
16
|
+
@definitions = data_source.workflow_definitions(window: @window)
|
|
17
|
+
render "pgbus/workflows/definitions/index"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def show
|
|
21
|
+
@window = Web::WorkflowWindow.parse(params[:window])
|
|
22
|
+
@definition = data_source.workflow_definition(params[:workflow_class], window: @window)
|
|
23
|
+
return render("pgbus/workflows/definitions/not_found", status: :not_found) unless @definition
|
|
24
|
+
|
|
25
|
+
render "pgbus/workflows/definitions/show"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
# The Workflows pages (issue #542): the list with its filters, one run's
|
|
8
|
+
# detail with the operator actions, and the needs-attention groups with
|
|
9
|
+
# their bulk actions. Every action goes through Web::DataSource, which
|
|
10
|
+
# answers the real outcome, and is recorded as the dashboard actor
|
|
11
|
+
# ("dashboard:<config.web_actor>", or "dashboard").
|
|
12
|
+
class WorkflowsController < ApplicationController
|
|
13
|
+
REASON_LIMIT = 1_000
|
|
14
|
+
GROUP_FIELDS = %i[workflow_class step_name reason error_class].freeze
|
|
15
|
+
|
|
16
|
+
rescue_from Web::WorkflowQuery::Invalid do |error|
|
|
17
|
+
render plain: t("pgbus.workflows.bad_request", reason: error.message), status: :bad_request
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def index
|
|
21
|
+
@query = Web::WorkflowQuery.new(params)
|
|
22
|
+
@page = page_param
|
|
23
|
+
@per_page = per_page
|
|
24
|
+
@workflows = data_source.workflows(status: @query.status, page: @page, per_page: @per_page, **@query.filters)
|
|
25
|
+
@workflows_count = data_source.workflows_count(status: @query.status, **@query.filters)
|
|
26
|
+
return render_frame("pgbus/workflows/workflows_table") if params[:frame] == "list"
|
|
27
|
+
|
|
28
|
+
@attention_count = data_source.workflow_attention_count
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# ?workflow_class= narrows the groups to one class (the insights' link).
|
|
32
|
+
def attention
|
|
33
|
+
@workflow_class = attention_class
|
|
34
|
+
@groups = data_source.workflow_attention_groups(workflow_class: @workflow_class)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def show
|
|
38
|
+
@workflow = data_source.workflow_detail(params[:workflow_id])
|
|
39
|
+
render :not_found, status: :not_found unless @workflow
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resume = member_action(:resume) { |id, **who| data_source.resume_workflow(id, **who) }
|
|
43
|
+
|
|
44
|
+
def cancel = member_action(:cancel) { |id, **who| data_source.cancel_workflow(id, **who) }
|
|
45
|
+
|
|
46
|
+
def skip_step = member_action(:skip_step) { |id, **who| data_source.skip_workflow_step(id, **who) }
|
|
47
|
+
|
|
48
|
+
def run_now = member_action(:run_now) { |id, **who| data_source.run_workflow_step_now(id, **who) }
|
|
49
|
+
|
|
50
|
+
def resume_at
|
|
51
|
+
step = params[:step].to_s.strip
|
|
52
|
+
return back_to_workflow(alert: t("pgbus.workflows.flash.choose_step")) if step.empty?
|
|
53
|
+
|
|
54
|
+
member_action(:resume_at, step: step) { |id, **who| data_source.resume_workflow_at(id, step: step, **who) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def signal
|
|
58
|
+
name = params[:name].to_s.strip
|
|
59
|
+
return back_to_workflow(alert: t("pgbus.workflows.flash.signal_name_missing")) if name.empty?
|
|
60
|
+
|
|
61
|
+
payload = signal_payload
|
|
62
|
+
return back_to_workflow(alert: payload) if payload.is_a?(String)
|
|
63
|
+
|
|
64
|
+
member_action(:signal, name: name) do |id, actor:, **|
|
|
65
|
+
data_source.signal_workflow(id, name: name, payload: payload, idempotency_key: params[:idempotency_key].presence,
|
|
66
|
+
actor: actor)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def rerun
|
|
71
|
+
result = data_source.rerun_workflow(params[:workflow_id], **who)
|
|
72
|
+
return back_to_workflow(alert: failure_message(:rerun, result)) unless result[:ok]
|
|
73
|
+
|
|
74
|
+
redirect_to pgbus.workflow_path(result[:workflow_id]), notice: t("pgbus.workflows.flash.rerun")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def bulk_resume = bulk_action(:bulk_resume) { |group| data_source.bulk_resume_workflows(group: group, **who) }
|
|
78
|
+
|
|
79
|
+
def bulk_cancel = bulk_action(:bulk_cancel) { |group| data_source.bulk_cancel_workflows(group: group, **who) }
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def member_action(action, **args)
|
|
84
|
+
result = yield(params[:workflow_id], **who)
|
|
85
|
+
if result[:ok]
|
|
86
|
+
key = action == :signal && result[:duplicate] ? :signal_duplicate : action
|
|
87
|
+
back_to_workflow(notice: t("pgbus.workflows.flash.#{key}", **args))
|
|
88
|
+
else
|
|
89
|
+
back_to_workflow(alert: failure_message(action, result))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Back to the attention page it was posted from, narrowed to the same
|
|
94
|
+
# class when it was (issue #549).
|
|
95
|
+
def bulk_action(action)
|
|
96
|
+
back = pgbus.attention_workflows_path(workflow_class: attention_class)
|
|
97
|
+
group = attention_group
|
|
98
|
+
return redirect_to(back, alert: t("pgbus.workflows.flash.bad_group")) unless valid_group?(group)
|
|
99
|
+
|
|
100
|
+
result = yield(group)
|
|
101
|
+
return redirect_to(back, alert: failure_message(action, result)) unless result[:ok]
|
|
102
|
+
|
|
103
|
+
redirect_to back, notice: bulk_notice(action, result)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def attention_class
|
|
107
|
+
name = params[:workflow_class]
|
|
108
|
+
return nil if name.nil? || name == ""
|
|
109
|
+
return name if name.is_a?(String) && Web::DataSource::WorkflowInsights::CLASS_NAME.match?(name)
|
|
110
|
+
|
|
111
|
+
raise Web::WorkflowQuery::Invalid, "workflow_class must be a class name"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def valid_group?(group)
|
|
115
|
+
return false unless group
|
|
116
|
+
|
|
117
|
+
group.values_at(:workflow_class, :step_name).all? && Workflow::States::STUCK_REASONS.include?(group[:reason])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The needs-attention group a bulk form posts (group[workflow_class] and
|
|
121
|
+
# friends, each a String; a blank error_class is nil), or nil when the
|
|
122
|
+
# param is not that shape.
|
|
123
|
+
def attention_group
|
|
124
|
+
raw = params[:group]
|
|
125
|
+
return nil unless raw.is_a?(ActionController::Parameters)
|
|
126
|
+
|
|
127
|
+
GROUP_FIELDS.to_h do |field|
|
|
128
|
+
value = raw[field]
|
|
129
|
+
return nil unless value.nil? || value.is_a?(String)
|
|
130
|
+
|
|
131
|
+
[field, value.presence]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# "Resumed 212; 2 had already moved on." and, at the cap, that more may wait.
|
|
136
|
+
def bulk_notice(action, result)
|
|
137
|
+
cap = Web::DataSource::WorkflowActions::BULK_CAP
|
|
138
|
+
failed = result[:failed].to_i
|
|
139
|
+
key = result[:skipped].positive? ? "#{action}_skipped" : action.to_s
|
|
140
|
+
words = [t("pgbus.workflows.flash.#{key}", count: result[:done], skipped: result[:skipped])]
|
|
141
|
+
words << t("pgbus.workflows.flash.bulk_failed", count: failed, reason: result[:failure]) if failed.positive?
|
|
142
|
+
words << t("pgbus.workflows.flash.bulk_more", cap: cap) if result[:done] + result[:skipped] + failed >= cap
|
|
143
|
+
words.join(" ")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def failure_message(action, result)
|
|
147
|
+
case result[:code]
|
|
148
|
+
when :not_allowed
|
|
149
|
+
status = result[:status] ? t("pgbus.workflows.statuses.#{result[:status]}").downcase : t("pgbus.workflows.statuses.gone")
|
|
150
|
+
t("pgbus.workflows.flash.failed.not_allowed", status: status)
|
|
151
|
+
when :not_found then t("pgbus.workflows.flash.failed.not_found")
|
|
152
|
+
else t("pgbus.workflows.flash.failed.other", action: t("pgbus.workflows.actions.#{action}"), reason: result[:reason])
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def back_to_workflow(**flash)
|
|
157
|
+
redirect_to pgbus.workflow_path(params[:workflow_id]), **flash
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The parsed payload Hash, or the alert to show instead (nothing is sent).
|
|
161
|
+
def signal_payload
|
|
162
|
+
text = params[:payload].to_s.strip
|
|
163
|
+
return {} if text.empty?
|
|
164
|
+
|
|
165
|
+
limit = Pgbus.configuration.workflow_max_payload_size
|
|
166
|
+
return t("pgbus.workflows.flash.payload_too_large", limit: limit) if text.bytesize > limit
|
|
167
|
+
|
|
168
|
+
parsed = JSON.parse(text)
|
|
169
|
+
parsed.is_a?(Hash) ? parsed : t("pgbus.workflows.flash.payload_not_object")
|
|
170
|
+
rescue JSON::ParserError
|
|
171
|
+
t("pgbus.workflows.flash.payload_not_object")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def who = { actor: dashboard_actor, reason: operator_reason }
|
|
175
|
+
|
|
176
|
+
def operator_reason
|
|
177
|
+
text = params[:reason].to_s.strip
|
|
178
|
+
text.empty? ? nil : text[0, REASON_LIMIT]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# config.web_actor names the operator; a lambda that raises is logged
|
|
182
|
+
# and the action is recorded as plain "dashboard".
|
|
183
|
+
def dashboard_actor
|
|
184
|
+
name = Pgbus.configuration.web_actor&.call(request)
|
|
185
|
+
unless name.nil? || name.is_a?(String) || name.is_a?(Symbol)
|
|
186
|
+
Pgbus.logger.warn { "[Pgbus::Web] web_actor returned a #{name.class}, not a name; recording \"dashboard\"" }
|
|
187
|
+
return "dashboard"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
name = name.to_s.strip
|
|
191
|
+
name.empty? ? "dashboard" : "dashboard:#{name}"
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
Pgbus.logger.error { "[Pgbus::Web] web_actor raised #{e.class}: #{e.message}" }
|
|
194
|
+
"dashboard"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|