pgbus 0.19.4 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +97 -7
- 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 +148 -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/blocked_execution.rb +12 -0
- data/app/models/pgbus/uniqueness_key.rb +88 -3
- 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 +149 -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 +49 -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 +445 -0
- data/config/locales/de.yml +445 -0
- data/config/locales/en.yml +445 -0
- data/config/locales/es.yml +445 -0
- data/config/locales/fi.yml +445 -0
- data/config/locales/fr.yml +445 -0
- data/config/locales/it.yml +445 -0
- data/config/locales/ja.yml +445 -0
- data/config/locales/nb.yml +445 -0
- data/config/locales/nl.yml +445 -0
- data/config/locales/pt.yml +445 -0
- data/config/locales/sv.yml +445 -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 +81 -3
- data/lib/pgbus/active_job/delivery.rb +63 -0
- data/lib/pgbus/active_job/executor.rb +20 -8
- data/lib/pgbus/concurrency/blocked_execution.rb +14 -6
- data/lib/pgbus/configuration/workflow_settings.rb +99 -0
- data/lib/pgbus/configuration.rb +64 -4
- data/lib/pgbus/dead_letter_header.rb +9 -1
- data/lib/pgbus/generators/migration_detector.rb +40 -0
- data/lib/pgbus/instrumentation.rb +33 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +157 -1
- data/lib/pgbus/integrations/appsignal/subscriber.rb +16 -1
- 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 +75 -0
- data/lib/pgbus/process/consumer.rb +2 -1
- data/lib/pgbus/process/dispatcher.rb +39 -3
- data/lib/pgbus/process/worker.rb +10 -1
- data/lib/pgbus/recurring/schedule.rb +1 -1
- data/lib/pgbus/streams/presence.rb +65 -36
- data/lib/pgbus/uniqueness.rb +23 -6
- 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 +291 -0
- data/lib/pgbus/web/data_source.rb +126 -15
- 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 +89 -0
- data/lib/pgbus/web/workflow_timeline.rb +130 -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 +133 -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 +94 -0
- data/lib/pgbus/workflow/waiting.rb +82 -0
- data/lib/pgbus/workflow.rb +183 -0
- data/sig/pgbus/configuration.rbs +23 -0
- metadata +92 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
class AddPgbusWorkflows < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
# if_not_exists throughout: pgbus:update queues this migration when any of
|
|
3
|
+
# the three core tables is missing, so a half-present schema must not fail
|
|
4
|
+
# it. The waiting-step columns and pgbus_workflow_signals (issue #541) are
|
|
5
|
+
# here too, so a fresh install needs no pgbus:add_workflow_signals.
|
|
6
|
+
# up/down rather than change: Rails 7.1 cannot reverse an add_foreign_key
|
|
7
|
+
# that names its primary_key, and dropping the tables drops the keys.
|
|
8
|
+
def up
|
|
9
|
+
# One row per workflow run (Pgbus::WorkflowEntry).
|
|
10
|
+
create_table :pgbus_workflows, if_not_exists: true do |t|
|
|
11
|
+
t.string :workflow_id, null: false
|
|
12
|
+
t.string :workflow_class, null: false
|
|
13
|
+
t.string :subject_type
|
|
14
|
+
t.string :subject_id
|
|
15
|
+
t.string :subject_gid
|
|
16
|
+
t.jsonb :arguments, null: false, default: {}
|
|
17
|
+
t.string :status, null: false, default: "ready"
|
|
18
|
+
t.string :current_step
|
|
19
|
+
t.jsonb :step_job_options, null: false, default: {}
|
|
20
|
+
t.string :error_class
|
|
21
|
+
t.text :error_message
|
|
22
|
+
t.text :error_backtrace
|
|
23
|
+
t.datetime :paused_at
|
|
24
|
+
t.datetime :finished_at
|
|
25
|
+
t.datetime :cancelled_at
|
|
26
|
+
t.string :idempotency_key
|
|
27
|
+
t.string :definition_digest, null: false
|
|
28
|
+
t.string :rerun_of_workflow_id
|
|
29
|
+
t.datetime :created_at, null: false
|
|
30
|
+
t.datetime :updated_at, null: false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
add_index :pgbus_workflows, :workflow_id,
|
|
34
|
+
unique: true, name: "idx_pgbus_workflows_workflow_id", if_not_exists: true
|
|
35
|
+
add_index :pgbus_workflows, [:workflow_class, :subject_type, :subject_id],
|
|
36
|
+
unique: true, where: "status IN ('ready','performing','waiting','paused')",
|
|
37
|
+
name: "idx_pgbus_workflows_one_ongoing", if_not_exists: true
|
|
38
|
+
add_index :pgbus_workflows, [:workflow_class, :idempotency_key],
|
|
39
|
+
unique: true, where: "idempotency_key IS NOT NULL",
|
|
40
|
+
name: "idx_pgbus_workflows_idempotency", if_not_exists: true
|
|
41
|
+
add_index :pgbus_workflows, [:status, :updated_at],
|
|
42
|
+
name: "idx_pgbus_workflows_status", if_not_exists: true
|
|
43
|
+
add_index :pgbus_workflows, [:workflow_class, :status, :created_at],
|
|
44
|
+
name: "idx_pgbus_workflows_class_status", if_not_exists: true
|
|
45
|
+
add_index :pgbus_workflows, [:subject_type, :subject_id],
|
|
46
|
+
name: "idx_pgbus_workflows_subject", if_not_exists: true
|
|
47
|
+
|
|
48
|
+
# One row per step attempt (Pgbus::WorkflowStep).
|
|
49
|
+
create_table :pgbus_workflow_steps, if_not_exists: true do |t|
|
|
50
|
+
t.string :workflow_id, null: false
|
|
51
|
+
t.string :workflow_class, null: false
|
|
52
|
+
t.string :step_name, null: false
|
|
53
|
+
t.integer :attempt, null: false, default: 1
|
|
54
|
+
t.string :status, null: false, default: "scheduled"
|
|
55
|
+
t.string :job_id
|
|
56
|
+
t.bigint :msg_id
|
|
57
|
+
t.datetime :scheduled_at, null: false
|
|
58
|
+
t.datetime :enqueued_at
|
|
59
|
+
t.datetime :started_at
|
|
60
|
+
t.datetime :finished_at
|
|
61
|
+
t.datetime :stall_at
|
|
62
|
+
t.string :outcome
|
|
63
|
+
t.jsonb :output
|
|
64
|
+
t.string :error_class
|
|
65
|
+
t.text :error_message
|
|
66
|
+
t.text :error_backtrace
|
|
67
|
+
t.jsonb :error_causes
|
|
68
|
+
t.string :worker
|
|
69
|
+
t.string :app_revision
|
|
70
|
+
# Waiting steps (fan_out, wait_for:): also added by pgbus:add_workflow_signals.
|
|
71
|
+
t.string :batch_id
|
|
72
|
+
t.string :wait_for
|
|
73
|
+
t.bigint :signal_id
|
|
74
|
+
t.datetime :waiting_since
|
|
75
|
+
t.datetime :timeout_at
|
|
76
|
+
t.datetime :created_at, null: false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
add_index :pgbus_workflow_steps, [:workflow_id, :step_name, :attempt],
|
|
80
|
+
unique: true, name: "idx_pgbus_workflow_steps_attempt", if_not_exists: true
|
|
81
|
+
add_index :pgbus_workflow_steps, [:status, :scheduled_at],
|
|
82
|
+
name: "idx_pgbus_workflow_steps_scheduled", if_not_exists: true
|
|
83
|
+
add_index :pgbus_workflow_steps, [:status, :stall_at],
|
|
84
|
+
name: "idx_pgbus_workflow_steps_stall", if_not_exists: true
|
|
85
|
+
add_index :pgbus_workflow_steps, [:workflow_class, :step_name, :finished_at],
|
|
86
|
+
name: "idx_pgbus_workflow_steps_insights", if_not_exists: true
|
|
87
|
+
# A steps table that predates waiting steps lacks these columns (the
|
|
88
|
+
# queued pgbus:add_workflow_signals adds them and their indexes).
|
|
89
|
+
if column_exists?(:pgbus_workflow_steps, :batch_id)
|
|
90
|
+
add_index :pgbus_workflow_steps, :batch_id,
|
|
91
|
+
name: "idx_pgbus_workflow_steps_batch", if_not_exists: true
|
|
92
|
+
end
|
|
93
|
+
if column_exists?(:pgbus_workflow_steps, :timeout_at)
|
|
94
|
+
add_index :pgbus_workflow_steps, [:status, :timeout_at],
|
|
95
|
+
name: "idx_pgbus_workflow_steps_timeout", if_not_exists: true
|
|
96
|
+
end
|
|
97
|
+
add_foreign_key :pgbus_workflow_steps, :pgbus_workflows,
|
|
98
|
+
column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade, if_not_exists: true
|
|
99
|
+
|
|
100
|
+
# Append-only audit trail: one row per transition or operator action
|
|
101
|
+
# (Pgbus::WorkflowEvent).
|
|
102
|
+
create_table :pgbus_workflow_events, if_not_exists: true do |t|
|
|
103
|
+
t.string :workflow_id, null: false
|
|
104
|
+
t.bigint :step_id
|
|
105
|
+
t.string :step_name
|
|
106
|
+
t.integer :attempt
|
|
107
|
+
t.string :event, null: false
|
|
108
|
+
t.string :actor, null: false
|
|
109
|
+
t.text :reason
|
|
110
|
+
t.jsonb :details, null: false, default: {}
|
|
111
|
+
t.datetime :created_at, null: false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
add_index :pgbus_workflow_events, [:workflow_id, :id],
|
|
115
|
+
name: "idx_pgbus_workflow_events_workflow", if_not_exists: true
|
|
116
|
+
add_foreign_key :pgbus_workflow_events, :pgbus_workflows,
|
|
117
|
+
column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade, if_not_exists: true
|
|
118
|
+
|
|
119
|
+
# One row per signal delivered to a run (Pgbus::WorkflowSignal):
|
|
120
|
+
# pending → claimed → consumed.
|
|
121
|
+
create_table :pgbus_workflow_signals, if_not_exists: true do |t|
|
|
122
|
+
t.string :workflow_id, null: false
|
|
123
|
+
t.string :name, null: false
|
|
124
|
+
t.jsonb :payload, null: false, default: {}
|
|
125
|
+
t.string :idempotency_key
|
|
126
|
+
t.string :status, null: false, default: "pending"
|
|
127
|
+
t.bigint :claimed_by_step_id
|
|
128
|
+
t.datetime :created_at, null: false
|
|
129
|
+
t.datetime :claimed_at
|
|
130
|
+
t.datetime :consumed_at
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
add_index :pgbus_workflow_signals, [:workflow_id, :idempotency_key],
|
|
134
|
+
unique: true, where: "idempotency_key IS NOT NULL",
|
|
135
|
+
name: "idx_pgbus_workflow_signals_idempotency", if_not_exists: true
|
|
136
|
+
add_index :pgbus_workflow_signals, [:workflow_id, :name, :status],
|
|
137
|
+
name: "idx_pgbus_workflow_signals_pending", if_not_exists: true
|
|
138
|
+
add_index :pgbus_workflow_signals, [:workflow_id, :status, :claimed_by_step_id],
|
|
139
|
+
name: "idx_pgbus_workflow_signals_claimed", if_not_exists: true
|
|
140
|
+
add_foreign_key :pgbus_workflow_signals, :pgbus_workflows,
|
|
141
|
+
column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade, if_not_exists: true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def down
|
|
145
|
+
drop_table :pgbus_workflow_signals, if_exists: true
|
|
146
|
+
drop_table :pgbus_workflow_events, if_exists: true
|
|
147
|
+
drop_table :pgbus_workflow_steps, if_exists: true
|
|
148
|
+
drop_table :pgbus_workflows, if_exists: true
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -211,6 +211,129 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
211
211
|
add_index :pgbus_deploy_task_runs, :run_at,
|
|
212
212
|
name: "idx_pgbus_deploy_task_runs_cleanup"
|
|
213
213
|
|
|
214
|
+
# Workflows (Pgbus::Workflow): one row per run, one per step attempt, and
|
|
215
|
+
# the append-only audit trail. At most one ongoing run per (class, subject).
|
|
216
|
+
create_table :pgbus_workflows do |t|
|
|
217
|
+
t.string :workflow_id, null: false
|
|
218
|
+
t.string :workflow_class, null: false
|
|
219
|
+
t.string :subject_type
|
|
220
|
+
t.string :subject_id
|
|
221
|
+
t.string :subject_gid
|
|
222
|
+
t.jsonb :arguments, null: false, default: {}
|
|
223
|
+
t.string :status, null: false, default: "ready"
|
|
224
|
+
t.string :current_step
|
|
225
|
+
t.jsonb :step_job_options, null: false, default: {}
|
|
226
|
+
t.string :error_class
|
|
227
|
+
t.text :error_message
|
|
228
|
+
t.text :error_backtrace
|
|
229
|
+
t.datetime :paused_at
|
|
230
|
+
t.datetime :finished_at
|
|
231
|
+
t.datetime :cancelled_at
|
|
232
|
+
t.string :idempotency_key
|
|
233
|
+
t.string :definition_digest, null: false
|
|
234
|
+
t.string :rerun_of_workflow_id
|
|
235
|
+
t.datetime :created_at, null: false
|
|
236
|
+
t.datetime :updated_at, null: false
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
add_index :pgbus_workflows, :workflow_id,
|
|
240
|
+
unique: true, name: "idx_pgbus_workflows_workflow_id"
|
|
241
|
+
add_index :pgbus_workflows, [:workflow_class, :subject_type, :subject_id],
|
|
242
|
+
unique: true, where: "status IN ('ready','performing','waiting','paused')",
|
|
243
|
+
name: "idx_pgbus_workflows_one_ongoing"
|
|
244
|
+
add_index :pgbus_workflows, [:workflow_class, :idempotency_key],
|
|
245
|
+
unique: true, where: "idempotency_key IS NOT NULL",
|
|
246
|
+
name: "idx_pgbus_workflows_idempotency"
|
|
247
|
+
add_index :pgbus_workflows, [:status, :updated_at],
|
|
248
|
+
name: "idx_pgbus_workflows_status"
|
|
249
|
+
add_index :pgbus_workflows, [:workflow_class, :status, :created_at],
|
|
250
|
+
name: "idx_pgbus_workflows_class_status"
|
|
251
|
+
add_index :pgbus_workflows, [:subject_type, :subject_id],
|
|
252
|
+
name: "idx_pgbus_workflows_subject"
|
|
253
|
+
|
|
254
|
+
create_table :pgbus_workflow_steps do |t|
|
|
255
|
+
t.string :workflow_id, null: false
|
|
256
|
+
t.string :workflow_class, null: false
|
|
257
|
+
t.string :step_name, null: false
|
|
258
|
+
t.integer :attempt, null: false, default: 1
|
|
259
|
+
t.string :status, null: false, default: "scheduled"
|
|
260
|
+
t.string :job_id
|
|
261
|
+
t.bigint :msg_id
|
|
262
|
+
t.datetime :scheduled_at, null: false
|
|
263
|
+
t.datetime :enqueued_at
|
|
264
|
+
t.datetime :started_at
|
|
265
|
+
t.datetime :finished_at
|
|
266
|
+
t.datetime :stall_at
|
|
267
|
+
t.string :outcome
|
|
268
|
+
t.jsonb :output
|
|
269
|
+
t.string :error_class
|
|
270
|
+
t.text :error_message
|
|
271
|
+
t.text :error_backtrace
|
|
272
|
+
t.jsonb :error_causes
|
|
273
|
+
t.string :worker
|
|
274
|
+
t.string :app_revision
|
|
275
|
+
t.string :batch_id
|
|
276
|
+
t.string :wait_for
|
|
277
|
+
t.bigint :signal_id
|
|
278
|
+
t.datetime :waiting_since
|
|
279
|
+
t.datetime :timeout_at
|
|
280
|
+
t.datetime :created_at, null: false
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
add_index :pgbus_workflow_steps, [:workflow_id, :step_name, :attempt],
|
|
284
|
+
unique: true, name: "idx_pgbus_workflow_steps_attempt"
|
|
285
|
+
add_index :pgbus_workflow_steps, [:status, :scheduled_at],
|
|
286
|
+
name: "idx_pgbus_workflow_steps_scheduled"
|
|
287
|
+
add_index :pgbus_workflow_steps, [:status, :stall_at],
|
|
288
|
+
name: "idx_pgbus_workflow_steps_stall"
|
|
289
|
+
add_index :pgbus_workflow_steps, [:workflow_class, :step_name, :finished_at],
|
|
290
|
+
name: "idx_pgbus_workflow_steps_insights"
|
|
291
|
+
add_index :pgbus_workflow_steps, :batch_id,
|
|
292
|
+
name: "idx_pgbus_workflow_steps_batch"
|
|
293
|
+
add_index :pgbus_workflow_steps, [:status, :timeout_at],
|
|
294
|
+
name: "idx_pgbus_workflow_steps_timeout"
|
|
295
|
+
add_foreign_key :pgbus_workflow_steps, :pgbus_workflows,
|
|
296
|
+
column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade
|
|
297
|
+
|
|
298
|
+
create_table :pgbus_workflow_events do |t|
|
|
299
|
+
t.string :workflow_id, null: false
|
|
300
|
+
t.bigint :step_id
|
|
301
|
+
t.string :step_name
|
|
302
|
+
t.integer :attempt
|
|
303
|
+
t.string :event, null: false
|
|
304
|
+
t.string :actor, null: false
|
|
305
|
+
t.text :reason
|
|
306
|
+
t.jsonb :details, null: false, default: {}
|
|
307
|
+
t.datetime :created_at, null: false
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
add_index :pgbus_workflow_events, [:workflow_id, :id],
|
|
311
|
+
name: "idx_pgbus_workflow_events_workflow"
|
|
312
|
+
add_foreign_key :pgbus_workflow_events, :pgbus_workflows,
|
|
313
|
+
column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade
|
|
314
|
+
|
|
315
|
+
create_table :pgbus_workflow_signals do |t|
|
|
316
|
+
t.string :workflow_id, null: false
|
|
317
|
+
t.string :name, null: false
|
|
318
|
+
t.jsonb :payload, null: false, default: {}
|
|
319
|
+
t.string :idempotency_key
|
|
320
|
+
t.string :status, null: false, default: "pending"
|
|
321
|
+
t.bigint :claimed_by_step_id
|
|
322
|
+
t.datetime :created_at, null: false
|
|
323
|
+
t.datetime :claimed_at
|
|
324
|
+
t.datetime :consumed_at
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
add_index :pgbus_workflow_signals, [:workflow_id, :idempotency_key],
|
|
328
|
+
unique: true, where: "idempotency_key IS NOT NULL",
|
|
329
|
+
name: "idx_pgbus_workflow_signals_idempotency"
|
|
330
|
+
add_index :pgbus_workflow_signals, [:workflow_id, :name, :status],
|
|
331
|
+
name: "idx_pgbus_workflow_signals_pending"
|
|
332
|
+
add_index :pgbus_workflow_signals, [:workflow_id, :status, :claimed_by_step_id],
|
|
333
|
+
name: "idx_pgbus_workflow_signals_claimed"
|
|
334
|
+
add_foreign_key :pgbus_workflow_signals, :pgbus_workflows,
|
|
335
|
+
column: :workflow_id, primary_key: :workflow_id, on_delete: :cascade
|
|
336
|
+
|
|
214
337
|
# Stream queue registry — records which PGMQ queues back streams (vs jobs).
|
|
215
338
|
# Stream and job queues share the pgbus_ namespace, so maintenance
|
|
216
339
|
# (per-stream retention, orphan sweep) and wildcard workers rely on this
|
|
@@ -245,6 +368,10 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
245
368
|
execute "SELECT pgmq.drop_queue('pgbus_default')"
|
|
246
369
|
|
|
247
370
|
drop_table :pgbus_stream_queues
|
|
371
|
+
drop_table :pgbus_workflow_signals
|
|
372
|
+
drop_table :pgbus_workflow_events
|
|
373
|
+
drop_table :pgbus_workflow_steps
|
|
374
|
+
drop_table :pgbus_workflows
|
|
248
375
|
drop_table :pgbus_deploy_task_runs
|
|
249
376
|
drop_table :pgbus_deploy_tasks
|
|
250
377
|
drop_table :pgbus_recurring_executions
|
|
@@ -80,6 +80,7 @@ module Pgbus
|
|
|
80
80
|
# reset: once the send returns, msg_id is set and the rescue routes
|
|
81
81
|
# on that instead.
|
|
82
82
|
sending = false
|
|
83
|
+
retry_lock = hand_lock_to_retry(active_job, payload_hash, queue)
|
|
83
84
|
|
|
84
85
|
if key && concurrency
|
|
85
86
|
# The check and the park commit together, under the semaphore row
|
|
@@ -115,6 +116,9 @@ module Pgbus
|
|
|
115
116
|
# Bind before backfill so a live message is never left with an unbound
|
|
116
117
|
# uniqueness row if execution-row bookkeeping raises.
|
|
117
118
|
bind_acquired_uniqueness_lock(queue, msg_id) if msg_id
|
|
119
|
+
bind_retry_lock(retry_lock, queue, msg_id) if msg_id
|
|
120
|
+
# Dropped by its concurrency limit: no retry will run, the attempt does.
|
|
121
|
+
return_retry_lock(retry_lock) unless msg_id || blocked
|
|
118
122
|
Batch.backfill_execution(payload_hash, msg_id, physical_queue(queue, priority)) if msg_id
|
|
119
123
|
# A retry re-enqueue that is now live (sent, or parked as a blocked
|
|
120
124
|
# execution) must stop the original attempt from signalling completion.
|
|
@@ -135,6 +139,7 @@ module Pgbus
|
|
|
135
139
|
# database error from the slot upsert or the park is not ambiguous.
|
|
136
140
|
if msg_id.nil? && !(sending && ambiguous_delivery?(e))
|
|
137
141
|
rollback_acquired_uniqueness_lock
|
|
142
|
+
return_retry_lock(retry_lock)
|
|
138
143
|
uncount_batch_job(payload_hash, active_job)
|
|
139
144
|
else
|
|
140
145
|
# Drop the thread-local so a later discard on this thread cannot
|
|
@@ -247,9 +252,10 @@ module Pgbus
|
|
|
247
252
|
# the executor still holds the key (it releases only on success/DLQ). So
|
|
248
253
|
# a re-enqueue with executions > 0 would otherwise hit its own held key,
|
|
249
254
|
# be rejected as a duplicate (JobNotUnique), and dead-letter the original
|
|
250
|
-
# while losing the retry. Let the retry through; the
|
|
251
|
-
#
|
|
252
|
-
#
|
|
255
|
+
# while losing the retry. Let the retry through (issue #333); the key
|
|
256
|
+
# row is handed to it (hand_lock_to_retry, then bind_retry_lock once
|
|
257
|
+
# sent), so the attempt's release leaves it held until the job finally
|
|
258
|
+
# succeeds or dead-letters (issue #564).
|
|
253
259
|
return false if active_job.executions.to_i.positive?
|
|
254
260
|
|
|
255
261
|
result = Uniqueness.acquire_enqueue_lock(uniqueness_key, active_job, queue_name: queue)
|
|
@@ -285,6 +291,78 @@ module Pgbus
|
|
|
285
291
|
Pgbus.logger.warn { "[Pgbus] Uniqueness bind failed: #{e.message}" }
|
|
286
292
|
end
|
|
287
293
|
|
|
294
|
+
# The job's :until_executed key and the delivery of the attempt running
|
|
295
|
+
# it, when this enqueue is that attempt's retry: the retry is let
|
|
296
|
+
# through the key the attempt holds and never acquires one, so the key
|
|
297
|
+
# row has to follow it. nil for a first attempt (it acquired its own
|
|
298
|
+
# row), a retry enqueued outside the executor (no attempt to take it
|
|
299
|
+
# from), or a job run inline inside another job, whose delivery it sees.
|
|
300
|
+
def retry_uniqueness_lock(active_job, payload_hash)
|
|
301
|
+
key = Uniqueness.extract_key(payload_hash)
|
|
302
|
+
return unless key && Uniqueness.extract_strategy(payload_hash) == :until_executed
|
|
303
|
+
return unless active_job.executions.to_i.positive?
|
|
304
|
+
|
|
305
|
+
delivery = Delivery.current
|
|
306
|
+
[key, delivery] if delivery && delivery[:job_id] == active_job.job_id
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Take the attempt's row for its retry before the retry exists — sent or
|
|
310
|
+
# parked — the way a first enqueue acquires before its send (see
|
|
311
|
+
# UniquenessKey.unbind!). Returns [key, delivery, stamp] for
|
|
312
|
+
# bind_retry_lock / return_retry_lock, or nil when there is nothing to
|
|
313
|
+
# hand over. A failure is logged and the retry goes out without the row,
|
|
314
|
+
# which then stays with the attempt.
|
|
315
|
+
def hand_lock_to_retry(active_job, payload_hash, queue)
|
|
316
|
+
key, delivery = retry_uniqueness_lock(active_job, payload_hash)
|
|
317
|
+
return unless key
|
|
318
|
+
|
|
319
|
+
stamp = UniquenessKey.unbind!(key, from_queue_name: Uniqueness.lock_queue_name(delivery[:queue_name]),
|
|
320
|
+
from_msg_id: delivery[:msg_id], from_enqueued_at: delivery[:enqueued_at],
|
|
321
|
+
queue_name: Uniqueness.lock_queue_name(queue))
|
|
322
|
+
return [key, delivery, stamp] if stamp
|
|
323
|
+
|
|
324
|
+
warn_retry_lock_not_held(key)
|
|
325
|
+
nil
|
|
326
|
+
rescue StandardError => e
|
|
327
|
+
Pgbus.logger.warn { "[Pgbus] Uniqueness handoff to retry failed for #{key}: #{e.message}" }
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Point the handed-over row at the sent retry. The retry is live, so a
|
|
332
|
+
# failure is logged, not raised: the row stays unbound and stamped
|
|
333
|
+
# before the send, and the retry's own release takes it.
|
|
334
|
+
def bind_retry_lock(retry_lock, queue, msg_id)
|
|
335
|
+
return unless retry_lock
|
|
336
|
+
|
|
337
|
+
key, _delivery, stamp = retry_lock
|
|
338
|
+
UniquenessKey.bind!(key, queue_name: Uniqueness.lock_queue_name(queue), msg_id: msg_id, acquired_at: stamp)
|
|
339
|
+
rescue StandardError => e
|
|
340
|
+
Pgbus.logger.warn { "[Pgbus] Uniqueness bind to retry failed for #{key}: #{e.message}" }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# No retry came of it (dropped by its concurrency limit, or the send
|
|
344
|
+
# failed before any message existed): bind the row back to the attempt,
|
|
345
|
+
# which runs on and releases it when it finishes.
|
|
346
|
+
def return_retry_lock(retry_lock)
|
|
347
|
+
return unless retry_lock
|
|
348
|
+
|
|
349
|
+
key, delivery, stamp = retry_lock
|
|
350
|
+
UniquenessKey.bind!(key, queue_name: Uniqueness.lock_queue_name(delivery[:queue_name]),
|
|
351
|
+
msg_id: delivery[:msg_id], acquired_at: stamp)
|
|
352
|
+
rescue StandardError => e
|
|
353
|
+
Pgbus.logger.warn { "[Pgbus] Uniqueness return to the attempt failed for #{key}: #{e.message}" }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# The row is gone (released by an operator, reaped) or a successor's:
|
|
357
|
+
# the retry runs without the key, and a fresh enqueue is not deduped
|
|
358
|
+
# against it.
|
|
359
|
+
def warn_retry_lock_not_held(key)
|
|
360
|
+
Pgbus.logger.warn do
|
|
361
|
+
"[Pgbus] Uniqueness key #{key} is no longer held by the attempt that enqueued its retry; " \
|
|
362
|
+
"the retry runs without it"
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
288
366
|
# Reverses inject_batch_metadata for a job discarded at enqueue time
|
|
289
367
|
# (uniqueness duplicate or concurrency :discard conflict): the message is
|
|
290
368
|
# never sent, so it can never signal completion. Only applies to a job
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module ActiveJob
|
|
5
|
+
# The PGMQ delivery of the job running in this fiber:
|
|
6
|
+
#
|
|
7
|
+
# Pgbus::ActiveJob::Delivery.current
|
|
8
|
+
# # => { msg_id: 42, read_ct: 2, queue_name: "default",
|
|
9
|
+
# # enqueued_at: "2026-10-18 10:30:00+00", job_id: "8f0c…" }
|
|
10
|
+
#
|
|
11
|
+
# Set by Executor#execute around perform_now and cleared after, also when
|
|
12
|
+
# the job raises; nil outside the executor (perform_now in a test, a
|
|
13
|
+
# top-level inline-adapter job). A job run inline from inside an executing
|
|
14
|
+
# job sees the enclosing job's delivery; StepJob only acts on it when the
|
|
15
|
+
# step row's msg_id matches, and the adapter only when job_id is its own,
|
|
16
|
+
# so that is harmless. Fiber-local (Thread.current[]), so concurrent jobs under
|
|
17
|
+
# execution_mode: :async each see their own. Pgbus::Workflow::StepJob reads
|
|
18
|
+
# read_ct to tell a redelivery after a worker died from a first delivery;
|
|
19
|
+
# the adapter reads the message identity and enqueued_at to move the
|
|
20
|
+
# job's uniqueness lock onto a retry the job enqueues (issue #564).
|
|
21
|
+
#
|
|
22
|
+
# The values sit in one fiber-local each and the Hash is built only
|
|
23
|
+
# when read: setting them allocates nothing, which keeps the executor's
|
|
24
|
+
# per-job allocation budget (spec/pgbus/active_job/executor_allocation_budget_spec.rb).
|
|
25
|
+
module Delivery
|
|
26
|
+
MSG_ID_KEY = :pgbus_delivery_msg_id
|
|
27
|
+
READ_CT_KEY = :pgbus_delivery_read_ct
|
|
28
|
+
QUEUE_NAME_KEY = :pgbus_delivery_queue_name
|
|
29
|
+
ENQUEUED_AT_KEY = :pgbus_delivery_enqueued_at
|
|
30
|
+
JOB_ID_KEY = :pgbus_delivery_job_id
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def current
|
|
35
|
+
msg_id = Thread.current[MSG_ID_KEY]
|
|
36
|
+
return nil if msg_id.nil?
|
|
37
|
+
|
|
38
|
+
{ msg_id: msg_id, read_ct: Thread.current[READ_CT_KEY], queue_name: Thread.current[QUEUE_NAME_KEY],
|
|
39
|
+
enqueued_at: Thread.current[ENQUEUED_AT_KEY], job_id: Thread.current[JOB_ID_KEY] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def with(msg_id:, read_ct:, queue_name:, enqueued_at: nil, job_id: nil)
|
|
43
|
+
previous_msg_id = Thread.current[MSG_ID_KEY]
|
|
44
|
+
previous_read_ct = Thread.current[READ_CT_KEY]
|
|
45
|
+
previous_queue_name = Thread.current[QUEUE_NAME_KEY]
|
|
46
|
+
previous_enqueued_at = Thread.current[ENQUEUED_AT_KEY]
|
|
47
|
+
previous_job_id = Thread.current[JOB_ID_KEY]
|
|
48
|
+
Thread.current[MSG_ID_KEY] = msg_id
|
|
49
|
+
Thread.current[READ_CT_KEY] = read_ct
|
|
50
|
+
Thread.current[QUEUE_NAME_KEY] = queue_name
|
|
51
|
+
Thread.current[ENQUEUED_AT_KEY] = enqueued_at
|
|
52
|
+
Thread.current[JOB_ID_KEY] = job_id
|
|
53
|
+
yield
|
|
54
|
+
ensure
|
|
55
|
+
Thread.current[MSG_ID_KEY] = previous_msg_id
|
|
56
|
+
Thread.current[READ_CT_KEY] = previous_read_ct
|
|
57
|
+
Thread.current[QUEUE_NAME_KEY] = previous_queue_name
|
|
58
|
+
Thread.current[ENQUEUED_AT_KEY] = previous_enqueued_at
|
|
59
|
+
Thread.current[JOB_ID_KEY] = previous_job_id
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -33,7 +33,7 @@ module Pgbus
|
|
|
33
33
|
FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i)
|
|
34
34
|
signal_concurrency(payload)
|
|
35
35
|
signal_batch_discarded(payload)
|
|
36
|
-
|
|
36
|
+
release_uniqueness_lock(Uniqueness.extract_key(payload), queue_name, message)
|
|
37
37
|
record_stat(payload, queue_name, "dead_lettered", execution_start, message: message)
|
|
38
38
|
instrument(
|
|
39
39
|
"pgbus.job_dead_lettered",
|
|
@@ -99,7 +99,12 @@ module Pgbus
|
|
|
99
99
|
# `batch` (and `batch.enqueue` for open batches) work inside a job.
|
|
100
100
|
assign_batch_id(job, payload)
|
|
101
101
|
Pgbus.logger.debug { "[Pgbus::Executor] running #{log_tag(message, queue_name)} job_class=#{job_class}" }
|
|
102
|
-
with_visibility_heartbeat(job, queue_name, msg_id, source_queue, payload)
|
|
102
|
+
with_visibility_heartbeat(job, queue_name, msg_id, source_queue, payload) do
|
|
103
|
+
Delivery.with(msg_id: msg_id, read_ct: read_count, queue_name: queue_name,
|
|
104
|
+
enqueued_at: message.enqueued_at, job_id: payload["job_id"]) do
|
|
105
|
+
execute_job(job)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
103
108
|
# retry_on re-enqueues from inside perform_now and returns normally:
|
|
104
109
|
# this attempt is done (archive it) but the job is not — the retry
|
|
105
110
|
# message carries the batch tag and signals on its own outcome.
|
|
@@ -119,7 +124,7 @@ module Pgbus
|
|
|
119
124
|
end
|
|
120
125
|
Pgbus.logger.debug { "[Pgbus::Executor] archived #{log_tag(message, queue_name)} job_class=#{job_class}" }
|
|
121
126
|
job_succeeded = true
|
|
122
|
-
release_uniqueness_lock(uniqueness_key)
|
|
127
|
+
release_uniqueness_lock(uniqueness_key, queue_name, message)
|
|
123
128
|
# Only FailedEventRecorder.record! writes this table, and only after a
|
|
124
129
|
# failed attempt, so a first delivery has no row: skip the per-job
|
|
125
130
|
# DELETE round trip (issue #484). A stale row from a dropped-and-
|
|
@@ -144,7 +149,7 @@ module Pgbus
|
|
|
144
149
|
# A failed :while_executing attempt is no longer executing — release
|
|
145
150
|
# so the retry (same message, after VT) can acquire. :until_executed
|
|
146
151
|
# keeps its lock until success/DLQ by design (#126, #333).
|
|
147
|
-
release_uniqueness_lock(uniqueness_key) if uniqueness_strategy == :while_executing
|
|
152
|
+
release_uniqueness_lock(uniqueness_key, queue_name, message) if uniqueness_strategy == :while_executing
|
|
148
153
|
instrument(
|
|
149
154
|
"pgbus.job_failed",
|
|
150
155
|
queue: queue_name,
|
|
@@ -190,10 +195,15 @@ module Pgbus
|
|
|
190
195
|
job.batch_id = batch_id
|
|
191
196
|
end
|
|
192
197
|
|
|
193
|
-
|
|
198
|
+
# Releases only the row this message holds, never the key as such: a
|
|
199
|
+
# row the job moved onto its retry (issue #564), or a successor's, stays.
|
|
200
|
+
# The queue is spelled as the row was bound (Uniqueness.lock_queue_name),
|
|
201
|
+
# as for release_duplicate_execution_lock.
|
|
202
|
+
def release_uniqueness_lock(key, queue_name, message)
|
|
194
203
|
return unless key
|
|
195
204
|
|
|
196
|
-
Uniqueness.
|
|
205
|
+
UniquenessKey.release_held_by!(key, queue_name: Uniqueness.lock_queue_name(queue_name, config: config),
|
|
206
|
+
msg_id: message.msg_id.to_i, enqueued_at: message.enqueued_at)
|
|
197
207
|
rescue StandardError => e
|
|
198
208
|
Pgbus.logger.warn { "[Pgbus] Uniqueness release failed: #{e.message}" }
|
|
199
209
|
end
|
|
@@ -423,7 +433,8 @@ module Pgbus
|
|
|
423
433
|
|
|
424
434
|
# Same queue the execution lock was acquired under, so the match
|
|
425
435
|
# cannot land on another queue's message of the same id.
|
|
426
|
-
UniquenessKey.release_if_bound!(uniqueness_key, queue_name: queue_name,
|
|
436
|
+
UniquenessKey.release_if_bound!(uniqueness_key, queue_name: Uniqueness.lock_queue_name(queue_name, config: config),
|
|
437
|
+
msg_id: msg_id)
|
|
427
438
|
rescue StandardError => e
|
|
428
439
|
Pgbus.logger.warn { "[Pgbus] Duplicate-execution lock release failed: #{e.message}" }
|
|
429
440
|
end
|
|
@@ -440,7 +451,8 @@ module Pgbus
|
|
|
440
451
|
end
|
|
441
452
|
headers = DeadLetterHeader.build(
|
|
442
453
|
existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "worker",
|
|
443
|
-
source_queue: source_queue || config.queue_name(queue_name),
|
|
454
|
+
source_queue: source_queue || config.queue_name(queue_name), source_msg_id: message.msg_id.to_i,
|
|
455
|
+
source_enqueued_at: message.enqueued_at, attempts: message.read_ct.to_i,
|
|
444
456
|
max_retries: config.max_retries, error: last_error
|
|
445
457
|
)
|
|
446
458
|
if source_queue
|
|
@@ -16,13 +16,21 @@ module Pgbus
|
|
|
16
16
|
# once. Handing it a pre-serialized String stored a JSON *string*
|
|
17
17
|
# (double-encoded), which every document reader of the column —
|
|
18
18
|
# `payload->>'job_id'`, the job-class lookup, `scheduled_at` — misread.
|
|
19
|
+
#
|
|
20
|
+
# created_at is left to the column's CURRENT_TIMESTAMP default: the
|
|
21
|
+
# job's uniqueness lock is stamped by the database too, and discarding
|
|
22
|
+
# a parked job releases only a lock acquired no later than the park
|
|
23
|
+
# (UniquenessKey.release_if_unbound!), so both sides need one clock.
|
|
19
24
|
def insert(concurrency_key:, queue_name:, payload:, duration:, priority: 0)
|
|
20
|
-
Pgbus::BlockedExecution.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
Pgbus::BlockedExecution.insert!(
|
|
26
|
+
{
|
|
27
|
+
concurrency_key: concurrency_key,
|
|
28
|
+
queue_name: queue_name,
|
|
29
|
+
payload: payload,
|
|
30
|
+
priority: priority,
|
|
31
|
+
expires_at: Time.current + duration
|
|
32
|
+
},
|
|
33
|
+
record_timestamps: false
|
|
26
34
|
)
|
|
27
35
|
end
|
|
28
36
|
|