pgbus 0.0.1 → 0.1.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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +37 -3
  3. data/Rakefile +98 -1
  4. data/app/controllers/pgbus/application_controller.rb +8 -0
  5. data/app/controllers/pgbus/recurring_tasks_controller.rb +36 -0
  6. data/app/helpers/pgbus/application_helper.rb +41 -0
  7. data/app/models/pgbus/application_record.rb +7 -0
  8. data/app/models/pgbus/batch_entry.rb +31 -0
  9. data/app/models/pgbus/blocked_execution.rb +40 -0
  10. data/app/models/pgbus/process_entry.rb +9 -0
  11. data/app/models/pgbus/processed_event.rb +9 -0
  12. data/app/models/pgbus/recurring_execution.rb +33 -0
  13. data/app/models/pgbus/recurring_task.rb +42 -0
  14. data/app/models/pgbus/semaphore.rb +29 -0
  15. data/app/views/layouts/pgbus/application.html.erb +1 -0
  16. data/app/views/pgbus/dashboard/_stats_cards.html.erb +9 -1
  17. data/app/views/pgbus/dead_letter/_messages_table.html.erb +55 -18
  18. data/app/views/pgbus/jobs/_enqueued_table.html.erb +46 -8
  19. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +79 -0
  20. data/app/views/pgbus/recurring_tasks/index.html.erb +6 -0
  21. data/app/views/pgbus/recurring_tasks/show.html.erb +122 -0
  22. data/config/routes.rb +7 -0
  23. data/lib/active_job/queue_adapters/pgbus_adapter.rb +29 -0
  24. data/lib/generators/pgbus/add_recurring_generator.rb +56 -0
  25. data/lib/generators/pgbus/install_generator.rb +76 -2
  26. data/lib/generators/pgbus/templates/add_recurring_tables.rb.erb +31 -0
  27. data/lib/generators/pgbus/templates/migration.rb.erb +72 -4
  28. data/lib/generators/pgbus/templates/recurring.yml.erb +40 -0
  29. data/lib/generators/pgbus/templates/upgrade_pgmq.rb.erb +30 -0
  30. data/lib/generators/pgbus/upgrade_pgmq_generator.rb +60 -0
  31. data/lib/pgbus/active_job/adapter.rb +0 -3
  32. data/lib/pgbus/active_job/executor.rb +27 -12
  33. data/lib/pgbus/batch.rb +60 -69
  34. data/lib/pgbus/cli.rb +11 -16
  35. data/lib/pgbus/client.rb +25 -7
  36. data/lib/pgbus/concurrency/blocked_execution.rb +32 -37
  37. data/lib/pgbus/concurrency/semaphore.rb +11 -39
  38. data/lib/pgbus/concurrency.rb +10 -2
  39. data/lib/pgbus/configuration.rb +33 -0
  40. data/lib/pgbus/engine.rb +19 -1
  41. data/lib/pgbus/event_bus/handler.rb +4 -14
  42. data/lib/pgbus/instrumentation.rb +29 -0
  43. data/lib/pgbus/pgmq_schema/pgmq_v1.11.0.sql +2123 -0
  44. data/lib/pgbus/pgmq_schema.rb +159 -0
  45. data/lib/pgbus/process/consumer.rb +8 -9
  46. data/lib/pgbus/process/dispatcher.rb +26 -24
  47. data/lib/pgbus/process/heartbeat.rb +15 -23
  48. data/lib/pgbus/process/signal_handler.rb +23 -1
  49. data/lib/pgbus/process/supervisor.rb +51 -2
  50. data/lib/pgbus/process/worker.rb +37 -9
  51. data/lib/pgbus/recurring/already_recorded.rb +7 -0
  52. data/lib/pgbus/recurring/command_job.rb +16 -0
  53. data/lib/pgbus/recurring/config_loader.rb +35 -0
  54. data/lib/pgbus/recurring/schedule.rb +102 -0
  55. data/lib/pgbus/recurring/scheduler.rb +102 -0
  56. data/lib/pgbus/recurring/task.rb +111 -0
  57. data/lib/pgbus/serializer.rb +10 -6
  58. data/lib/pgbus/version.rb +1 -1
  59. data/lib/pgbus/web/data_source.rb +187 -22
  60. data/lib/pgbus.rb +8 -0
  61. data/lib/tasks/pgbus_pgmq.rake +62 -0
  62. metadata +51 -24
  63. data/.bun-version +0 -1
  64. data/.claude/commands/architect.md +0 -100
  65. data/.claude/commands/github-review-comments.md +0 -237
  66. data/.claude/commands/lfg.md +0 -271
  67. data/.claude/commands/review-pr.md +0 -69
  68. data/.claude/commands/security.md +0 -122
  69. data/.claude/commands/tdd.md +0 -148
  70. data/.claude/rules/agents.md +0 -49
  71. data/.claude/rules/coding-style.md +0 -91
  72. data/.claude/rules/git-workflow.md +0 -56
  73. data/.claude/rules/performance.md +0 -73
  74. data/.claude/rules/testing.md +0 -67
  75. data/CLAUDE.md +0 -80
  76. data/CODE_OF_CONDUCT.md +0 -10
  77. data/bun.lock +0 -18
  78. data/docs/README.md +0 -28
  79. data/docs/switch_from_good_job.md +0 -279
  80. data/docs/switch_from_sidekiq.md +0 -226
  81. data/docs/switch_from_solid_queue.md +0 -247
  82. data/package.json +0 -9
  83. data/sig/pgbus.rbs +0 -4
@@ -6,21 +6,59 @@
6
6
  <thead class="bg-gray-50">
7
7
  <tr>
8
8
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">ID</th>
9
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Job Class</th>
9
10
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Queue</th>
10
11
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Enqueued</th>
11
12
  <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Reads</th>
12
- <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Payload</th>
13
13
  </tr>
14
14
  </thead>
15
15
  <tbody class="divide-y divide-gray-100">
16
16
  <% @jobs.each do |j| %>
17
- <tr class="hover:bg-gray-50">
18
- <td class="px-4 py-3 text-sm font-mono text-gray-900"><%= j[:msg_id] %></td>
19
- <td class="px-4 py-3 text-sm text-gray-700"><%= j[:queue_name] %></td>
20
- <td class="px-4 py-3 text-sm text-gray-500"><%= pgbus_time_ago(j[:enqueued_at]) %></td>
21
- <td class="px-4 py-3 text-sm text-gray-500"><%= j[:read_ct] %></td>
22
- <td class="px-4 py-3 text-sm text-gray-600 font-mono text-xs max-w-md truncate">
23
- <%= pgbus_json_preview(j[:message]) %>
17
+ <% payload = pgbus_parse_message(j[:message]) %>
18
+ <tr>
19
+ <td colspan="5" class="p-0">
20
+ <details class="group">
21
+ <summary class="flex cursor-pointer hover:bg-gray-50 list-none">
22
+ <span class="w-16 shrink-0 px-4 py-3 text-sm font-mono text-gray-900"><%= j[:msg_id] %></span>
23
+ <span class="flex-1 px-4 py-3 text-sm font-medium text-gray-900"><%= payload["job_class"] || "—" %></span>
24
+ <span class="w-40 shrink-0 px-4 py-3 text-sm text-gray-700"><%= j[:queue_name] %></span>
25
+ <span class="w-28 shrink-0 px-4 py-3 text-sm text-gray-500"><%= pgbus_time_ago(j[:enqueued_at]) %></span>
26
+ <span class="w-16 shrink-0 px-4 py-3 text-sm text-gray-500"><%= j[:read_ct] %></span>
27
+ </summary>
28
+ <div class="px-4 pb-4 bg-gray-50 border-t border-gray-100">
29
+ <div class="flex items-center mt-3 mb-3">
30
+ <span class="text-xs font-mono text-gray-400">Job ID: <%= payload["job_id"] %></span>
31
+ </div>
32
+ <div class="grid grid-cols-2 gap-4 mb-3">
33
+ <div>
34
+ <span class="text-xs font-medium text-gray-500">Arguments</span>
35
+ <pre class="text-xs text-gray-700 bg-white rounded p-2 mt-1 overflow-x-auto max-h-40"><%= JSON.pretty_generate(payload["arguments"] || []) rescue "—" %></pre>
36
+ </div>
37
+ <div>
38
+ <span class="text-xs font-medium text-gray-500">Metadata</span>
39
+ <div class="text-xs text-gray-600 bg-white rounded p-2 mt-1 space-y-1">
40
+ <% if payload["queue_name"] %><p><strong>Queue:</strong> <%= payload["queue_name"] %></p><% end %>
41
+ <% if payload["priority"] %><p><strong>Priority:</strong> <%= payload["priority"] %></p><% end %>
42
+ <% if payload["locale"] %><p><strong>Locale:</strong> <%= payload["locale"] %></p><% end %>
43
+ <% if payload["timezone"] %><p><strong>Timezone:</strong> <%= payload["timezone"] %></p><% end %>
44
+ <% if payload["scheduled_at"] %><p><strong>Scheduled:</strong> <%= payload["scheduled_at"] %></p><% end %>
45
+ <% if j[:vt] %><p><strong>Visible at:</strong> <%= j[:vt] %></p><% end %>
46
+ <% if j[:last_read_at] %><p><strong>Last read:</strong> <%= j[:last_read_at] %></p><% end %>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ <details class="mt-2">
51
+ <summary class="text-xs font-medium text-gray-500 cursor-pointer hover:text-gray-700">Full JSON payload</summary>
52
+ <pre class="text-xs text-gray-600 bg-white rounded p-2 mt-1 overflow-x-auto max-h-96"><%= JSON.pretty_generate(payload) rescue j[:message] %></pre>
53
+ </details>
54
+ <% if j[:headers] %>
55
+ <details class="mt-2">
56
+ <summary class="text-xs font-medium text-gray-500 cursor-pointer hover:text-gray-700">Headers</summary>
57
+ <pre class="text-xs text-gray-600 bg-white rounded p-2 mt-1 overflow-x-auto"><%= JSON.pretty_generate(JSON.parse(j[:headers])) rescue j[:headers] %></pre>
58
+ </details>
59
+ <% end %>
60
+ </div>
61
+ </details>
24
62
  </td>
25
63
  </tr>
26
64
  <% end %>
@@ -0,0 +1,79 @@
1
+ <turbo-frame id="recurring-tasks" data-auto-refresh src="<%= pgbus.recurring_tasks_path(frame: 'recurring_tasks') %>">
2
+ <div class="rounded-lg bg-white shadow ring-1 ring-gray-200">
3
+ <% if @recurring_tasks.empty? %>
4
+ <div class="p-8 text-center text-gray-500">
5
+ <p class="text-lg font-medium">No recurring tasks configured</p>
6
+ <p class="mt-1 text-sm">Add tasks to <code class="bg-gray-100 px-1.5 py-0.5 rounded text-xs">config/recurring.yml</code></p>
7
+ </div>
8
+ <% else %>
9
+ <table class="min-w-full divide-y divide-gray-200">
10
+ <thead class="bg-gray-50">
11
+ <tr>
12
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Task</th>
13
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Schedule</th>
14
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Queue</th>
15
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Last Run</th>
16
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Next Run</th>
17
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
18
+ <th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody class="divide-y divide-gray-200">
22
+ <% @recurring_tasks.each do |task| %>
23
+ <tr class="hover:bg-gray-50">
24
+ <td class="px-4 py-3">
25
+ <div>
26
+ <%= link_to task[:key], pgbus.recurring_task_path(task[:id]),
27
+ class: "text-sm font-medium text-blue-600 hover:text-blue-800" %>
28
+ </div>
29
+ <div class="text-xs text-gray-500"><%= task[:class_name] || task[:command]&.truncate(40) %></div>
30
+ <% if task[:description] %>
31
+ <div class="text-xs text-gray-400 mt-0.5"><%= task[:description] %></div>
32
+ <% end %>
33
+ </td>
34
+ <td class="px-4 py-3">
35
+ <div class="text-sm text-gray-900"><%= task[:schedule] %></div>
36
+ <% if task[:human_schedule] && task[:human_schedule] != task[:schedule] %>
37
+ <div class="text-xs text-gray-400"><%= task[:human_schedule] %></div>
38
+ <% end %>
39
+ </td>
40
+ <td class="px-4 py-3 text-sm text-gray-600">
41
+ <%= task[:queue_name] || "default" %>
42
+ </td>
43
+ <td class="px-4 py-3 text-sm text-gray-600">
44
+ <%= task[:last_run_at] ? pgbus_time_ago(task[:last_run_at]) : "Never" %>
45
+ </td>
46
+ <td class="px-4 py-3 text-sm text-gray-600">
47
+ <% if task[:enabled] && task[:next_run_at] %>
48
+ <%= pgbus_time_ago_future(task[:next_run_at]) %>
49
+ <% else %>
50
+ <span class="text-gray-400">—</span>
51
+ <% end %>
52
+ </td>
53
+ <td class="px-4 py-3">
54
+ <% if task[:enabled] %>
55
+ <%= pgbus_recurring_health_badge(task) %>
56
+ <% else %>
57
+ <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
58
+ Disabled
59
+ </span>
60
+ <% end %>
61
+ </td>
62
+ <td class="px-4 py-3 text-right space-x-1">
63
+ <%= button_to task[:enabled] ? "Disable" : "Enable",
64
+ pgbus.toggle_recurring_task_path(task[:id]),
65
+ class: "inline-flex items-center rounded px-2 py-1 text-xs font-medium " \
66
+ "#{task[:enabled] ? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200' : 'bg-green-100 text-green-700 hover:bg-green-200'}" %>
67
+ <% if task[:enabled] %>
68
+ <%= button_to "Run Now",
69
+ pgbus.enqueue_recurring_task_path(task[:id]),
70
+ class: "inline-flex items-center rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 hover:bg-blue-200" %>
71
+ <% end %>
72
+ </td>
73
+ </tr>
74
+ <% end %>
75
+ </tbody>
76
+ </table>
77
+ <% end %>
78
+ </div>
79
+ </turbo-frame>
@@ -0,0 +1,6 @@
1
+ <div class="flex items-center justify-between mb-6">
2
+ <h1 class="text-2xl font-bold text-gray-900">Recurring Tasks</h1>
3
+ <span class="text-sm text-gray-500"><%= @recurring_tasks.size %> task(s) configured</span>
4
+ </div>
5
+
6
+ <%= render "pgbus/recurring_tasks/tasks_table" %>
@@ -0,0 +1,122 @@
1
+ <div class="mb-6">
2
+ <div class="flex items-center justify-between">
3
+ <div>
4
+ <h1 class="text-2xl font-bold text-gray-900"><%= @task[:key] %></h1>
5
+ <p class="mt-1 text-sm text-gray-500"><%= @task[:class_name] || @task[:command] %></p>
6
+ </div>
7
+ <div class="flex space-x-2">
8
+ <%= button_to @task[:enabled] ? "Disable" : "Enable",
9
+ pgbus.toggle_recurring_task_path(@task[:id]),
10
+ class: "inline-flex items-center rounded-md px-3 py-2 text-sm font-medium " \
11
+ "#{@task[:enabled] ? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200' : 'bg-green-100 text-green-700 hover:bg-green-200'}" %>
12
+ <% if @task[:enabled] %>
13
+ <%= button_to "Run Now",
14
+ pgbus.enqueue_recurring_task_path(@task[:id]),
15
+ class: "inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700" %>
16
+ <% end %>
17
+ <%= link_to "Back", pgbus.recurring_tasks_path,
18
+ class: "inline-flex items-center rounded-md bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200" %>
19
+ </div>
20
+ </div>
21
+ </div>
22
+
23
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
24
+ <div class="rounded-lg bg-white p-5 shadow ring-1 ring-gray-200">
25
+ <p class="text-sm font-medium text-gray-500">Schedule</p>
26
+ <p class="mt-1 text-lg font-semibold text-gray-900"><%= @task[:schedule] %></p>
27
+ <% if @task[:human_schedule] && @task[:human_schedule] != @task[:schedule] %>
28
+ <p class="text-xs text-gray-400"><%= @task[:human_schedule] %></p>
29
+ <% end %>
30
+ </div>
31
+
32
+ <div class="rounded-lg bg-white p-5 shadow ring-1 ring-gray-200">
33
+ <p class="text-sm font-medium text-gray-500">Next Run</p>
34
+ <% if @task[:enabled] && @task[:next_run_at] %>
35
+ <p class="mt-1 text-lg font-semibold text-gray-900"><%= pgbus_time_ago_future(@task[:next_run_at]) %></p>
36
+ <p class="text-xs text-gray-400"><%= @task[:next_run_at]&.strftime("%Y-%m-%d %H:%M:%S %Z") %></p>
37
+ <% else %>
38
+ <p class="mt-1 text-lg font-semibold text-gray-400">—</p>
39
+ <% end %>
40
+ </div>
41
+
42
+ <div class="rounded-lg bg-white p-5 shadow ring-1 ring-gray-200">
43
+ <p class="text-sm font-medium text-gray-500">Status</p>
44
+ <p class="mt-1"><%= @task[:enabled] ? pgbus_recurring_health_badge(@task) : raw('<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">Disabled</span>') %></p>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="rounded-lg bg-white shadow ring-1 ring-gray-200 mb-8">
49
+ <div class="px-4 py-3 border-b border-gray-200">
50
+ <h2 class="text-lg font-medium text-gray-900">Configuration</h2>
51
+ </div>
52
+ <div class="p-4">
53
+ <dl class="grid grid-cols-1 sm:grid-cols-2 gap-4">
54
+ <div>
55
+ <dt class="text-sm font-medium text-gray-500">Job Class</dt>
56
+ <dd class="mt-1 text-sm text-gray-900"><%= @task[:class_name] || "—" %></dd>
57
+ </div>
58
+ <% if @task[:command] %>
59
+ <div>
60
+ <dt class="text-sm font-medium text-gray-500">Command</dt>
61
+ <dd class="mt-1 text-sm text-gray-900 font-mono text-xs"><%= @task[:command] %></dd>
62
+ </div>
63
+ <% end %>
64
+ <div>
65
+ <dt class="text-sm font-medium text-gray-500">Queue</dt>
66
+ <dd class="mt-1 text-sm text-gray-900"><%= @task[:queue_name] || "default" %></dd>
67
+ </div>
68
+ <div>
69
+ <dt class="text-sm font-medium text-gray-500">Priority</dt>
70
+ <dd class="mt-1 text-sm text-gray-900"><%= @task[:priority] %></dd>
71
+ </div>
72
+ <% if @task[:arguments]&.any? %>
73
+ <div class="sm:col-span-2">
74
+ <dt class="text-sm font-medium text-gray-500">Arguments</dt>
75
+ <dd class="mt-1 text-sm text-gray-900 font-mono text-xs"><%= @task[:arguments].inspect %></dd>
76
+ </div>
77
+ <% end %>
78
+ <% if @task[:description] %>
79
+ <div class="sm:col-span-2">
80
+ <dt class="text-sm font-medium text-gray-500">Description</dt>
81
+ <dd class="mt-1 text-sm text-gray-900"><%= @task[:description] %></dd>
82
+ </div>
83
+ <% end %>
84
+ <div>
85
+ <dt class="text-sm font-medium text-gray-500">Source</dt>
86
+ <dd class="mt-1 text-sm text-gray-900"><%= @task[:static] ? "Config file" : "Dynamic" %></dd>
87
+ </div>
88
+ </dl>
89
+ </div>
90
+ </div>
91
+
92
+ <div class="rounded-lg bg-white shadow ring-1 ring-gray-200">
93
+ <div class="px-4 py-3 border-b border-gray-200">
94
+ <h2 class="text-lg font-medium text-gray-900">Recent Executions</h2>
95
+ </div>
96
+ <% if @task[:executions]&.any? %>
97
+ <table class="min-w-full divide-y divide-gray-200">
98
+ <thead class="bg-gray-50">
99
+ <tr>
100
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Scheduled For</th>
101
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Enqueued At</th>
102
+ </tr>
103
+ </thead>
104
+ <tbody class="divide-y divide-gray-200">
105
+ <% @task[:executions].each do |exec| %>
106
+ <tr class="hover:bg-gray-50">
107
+ <td class="px-4 py-3 text-sm text-gray-900">
108
+ <%= exec[:run_at]&.strftime("%Y-%m-%d %H:%M:%S %Z") %>
109
+ </td>
110
+ <td class="px-4 py-3 text-sm text-gray-600">
111
+ <%= exec[:created_at] ? pgbus_time_ago(exec[:created_at]) : "—" %>
112
+ </td>
113
+ </tr>
114
+ <% end %>
115
+ </tbody>
116
+ </table>
117
+ <% else %>
118
+ <div class="p-8 text-center text-gray-500">
119
+ <p>No executions recorded yet</p>
120
+ </div>
121
+ <% end %>
122
+ </div>
data/config/routes.rb CHANGED
@@ -20,6 +20,13 @@ Pgbus::Engine.routes.draw do
20
20
  end
21
21
  end
22
22
 
23
+ resources :recurring_tasks, only: %i[index show] do
24
+ member do
25
+ post :toggle
26
+ post :enqueue
27
+ end
28
+ end
29
+
23
30
  resources :processes, only: [:index]
24
31
 
25
32
  resources :events, only: %i[index show] do
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pgbus" unless defined?(Pgbus)
4
+
5
+ module ActiveJob
6
+ module QueueAdapters
7
+ # Adapter for Rails ActiveJob integration with Pgbus.
8
+ #
9
+ # This class lives in the ActiveJob::QueueAdapters namespace so that
10
+ # Rails can find it via const_get("PgbusAdapter") — the standard
11
+ # lookup mechanism used across all Rails versions (7.1+).
12
+ #
13
+ # Usage:
14
+ # config.active_job.queue_adapter = :pgbus
15
+ class PgbusAdapter
16
+ delegate :enqueue, :enqueue_at, :enqueue_all, to: :adapter
17
+
18
+ def enqueue_after_transaction_commit?
19
+ true
20
+ end
21
+
22
+ private
23
+
24
+ def adapter
25
+ @adapter ||= Pgbus::ActiveJob::Adapter.new
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Pgbus
7
+ module Generators
8
+ class AddRecurringGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Add recurring jobs tables to an existing Pgbus installation"
14
+
15
+ class_option :database,
16
+ type: :string,
17
+ default: nil,
18
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
19
+
20
+ def create_migration
21
+ if separate_database?
22
+ migration_template "add_recurring_tables.rb.erb",
23
+ "db/pgbus_migrate/add_pgbus_recurring_tables.rb"
24
+ else
25
+ migration_template "add_recurring_tables.rb.erb",
26
+ "db/migrate/add_pgbus_recurring_tables.rb"
27
+ end
28
+ end
29
+
30
+ def create_recurring_config
31
+ template "recurring.yml.erb", "config/recurring.yml"
32
+ end
33
+
34
+ def display_post_install
35
+ say ""
36
+ say "Pgbus recurring jobs installed!", :green
37
+ say ""
38
+ say "Next steps:"
39
+ say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
40
+ say " 2. Edit config/recurring.yml to define your recurring tasks"
41
+ say " 3. Restart pgbus: bin/pgbus start"
42
+ say ""
43
+ end
44
+
45
+ private
46
+
47
+ def migration_version
48
+ "[#{ActiveRecord::Migration.current_version}]"
49
+ end
50
+
51
+ def separate_database?
52
+ options[:database].present?
53
+ end
54
+ end
55
+ end
56
+ end
@@ -12,8 +12,26 @@ module Pgbus
12
12
 
13
13
  desc "Install Pgbus: create migration, config file, and binstub"
14
14
 
15
+ class_option :pgmq_schema_mode,
16
+ type: :string,
17
+ default: "auto",
18
+ desc: "PGMQ install mode: auto (try extension, fallback embedded), " \
19
+ "extension (require ext), embedded (no ext)"
20
+
21
+ class_option :database,
22
+ type: :string,
23
+ default: nil,
24
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus). " \
25
+ "Migrations go to db/pgbus_migrate/ and schema to db/pgbus_schema.rb"
26
+
15
27
  def create_migration
16
- migration_template "migration.rb.erb", "db/migrate/install_pgbus.rb"
28
+ if separate_database?
29
+ migration_template "migration.rb.erb",
30
+ "db/pgbus_migrate/create_pgbus_tables.rb"
31
+ else
32
+ migration_template "migration.rb.erb",
33
+ "db/migrate/create_pgbus_tables.rb"
34
+ end
17
35
  end
18
36
 
19
37
  def create_config_file
@@ -35,12 +53,56 @@ module Pgbus
35
53
  after: "class Application < Rails::Application\n"
36
54
  end
37
55
 
56
+ def configure_separate_database
57
+ return unless separate_database?
58
+
59
+ db_config = <<~YAML
60
+
61
+ # Pgbus separate database (added by pgbus:install --database=#{database_name})
62
+ # Uncomment and configure for your environment:
63
+ # #{database_name}:
64
+ # primary:
65
+ # <<: *default
66
+ # database: #{Rails.application.class.module_parent_name.underscore}_#{database_name}_<%= Rails.env %>
67
+ # migrations_paths: db/pgbus_migrate
68
+ YAML
69
+
70
+ say ""
71
+ say "Separate database mode enabled!", :yellow
72
+ say "Add the following to your config/database.yml:", :yellow
73
+ say db_config, :cyan
74
+ say ""
75
+ say "Then add to config/application.rb or an initializer:", :yellow
76
+ say " Pgbus.configure { |c| c.connects_to = { database: { writing: :#{database_name} } } }", :cyan
77
+ say ""
78
+ end
79
+
38
80
  def display_post_install
39
81
  say ""
40
82
  say "Pgbus installed successfully!", :green
83
+ say ""
84
+ say "PGMQ schema mode: #{pgmq_schema_mode}", :yellow
85
+ case pgmq_schema_mode
86
+ when "auto"
87
+ say " The migration will try the pgmq extension first."
88
+ say " If unavailable, it falls back to embedded SQL (no extension needed)."
89
+ when "extension"
90
+ say " The migration requires the pgmq PostgreSQL extension."
91
+ say " Install it: CREATE EXTENSION pgmq;"
92
+ when "embedded"
93
+ say " The migration uses embedded SQL — no pgmq extension needed."
94
+ say " PGMQ #{Pgbus::PgmqSchema.latest_version} schema will be created directly."
95
+ end
96
+
97
+ if separate_database?
98
+ say ""
99
+ say "Migrations path: db/pgbus_migrate/", :yellow
100
+ say "Schema dump: db/pgbus_schema.rb", :yellow
101
+ end
102
+
41
103
  say ""
42
104
  say "Next steps:"
43
- say " 1. Run: rails db:migrate"
105
+ say " 1. Run: rails db:migrate#{":#{database_name}" if separate_database?}"
44
106
  say " 2. Edit config/pgbus.yml to configure workers"
45
107
  say " 3. Start processing: bin/pgbus start"
46
108
  say ""
@@ -51,6 +113,18 @@ module Pgbus
51
113
  def migration_version
52
114
  "[#{ActiveRecord::Migration.current_version}]"
53
115
  end
116
+
117
+ def pgmq_schema_mode
118
+ options[:pgmq_schema_mode]
119
+ end
120
+
121
+ def database_name
122
+ options[:database]
123
+ end
124
+
125
+ def separate_database?
126
+ database_name.present?
127
+ end
54
128
  end
55
129
  end
56
130
  end
@@ -0,0 +1,31 @@
1
+ class AddPgbusRecurringTables < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :pgbus_recurring_tasks do |t|
4
+ t.string :key, null: false
5
+ t.string :class_name
6
+ t.string :command, limit: 2048
7
+ t.string :schedule, null: false
8
+ t.text :arguments
9
+ t.string :queue_name
10
+ t.integer :priority, default: 0
11
+ t.boolean :static, null: false, default: true
12
+ t.boolean :enabled, null: false, default: true
13
+ t.text :description
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :pgbus_recurring_tasks, :key,
18
+ unique: true, name: "idx_pgbus_recurring_tasks_key"
19
+
20
+ create_table :pgbus_recurring_executions do |t|
21
+ t.string :task_key, null: false
22
+ t.datetime :run_at, null: false
23
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
24
+ end
25
+
26
+ add_index :pgbus_recurring_executions, [:task_key, :run_at],
27
+ unique: true, name: "idx_pgbus_recurring_executions_dedup"
28
+ add_index :pgbus_recurring_executions, :run_at,
29
+ name: "idx_pgbus_recurring_executions_cleanup"
30
+ end
31
+ end
@@ -1,7 +1,29 @@
1
- class InstallPgbus < ActiveRecord::Migration<%= migration_version %>
1
+ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
2
2
  def up
3
- # Install PGMQ extension
4
- enable_extension "pgmq"
3
+ # Install PGMQ schema based on pgmq_schema_mode configuration.
4
+ #
5
+ # Modes:
6
+ # :auto - Try extension first, fall back to embedded SQL (default)
7
+ # :extension - Require the pgmq PostgreSQL extension
8
+ # :embedded - Use vendored SQL, no extension needed
9
+ pgmq_schema_mode = :<%= pgmq_schema_mode %>
10
+
11
+ case pgmq_schema_mode
12
+ when :extension
13
+ enable_extension "pgmq"
14
+ execute Pgbus::PgmqSchema.version_tracking_extension_sql
15
+ when :embedded
16
+ execute Pgbus::PgmqSchema.install_sql
17
+ execute Pgbus::PgmqSchema.version_tracking_sql
18
+ when :auto
19
+ if extension_available?("pgmq")
20
+ enable_extension "pgmq"
21
+ execute Pgbus::PgmqSchema.version_tracking_extension_sql
22
+ else
23
+ execute Pgbus::PgmqSchema.install_sql
24
+ execute Pgbus::PgmqSchema.version_tracking_sql
25
+ end
26
+ end
5
27
 
6
28
  # Idempotent event processing deduplication
7
29
  create_table :pgbus_processed_events do |t|
@@ -93,6 +115,36 @@ class InstallPgbus < ActiveRecord::Migration<%= migration_version %>
93
115
  add_index :pgbus_batches, :status,
94
116
  name: "idx_pgbus_batches_status"
95
117
 
118
+ # Recurring task definitions (synced from config/recurring.yml)
119
+ create_table :pgbus_recurring_tasks do |t|
120
+ t.string :key, null: false
121
+ t.string :class_name
122
+ t.string :command, limit: 2048
123
+ t.string :schedule, null: false
124
+ t.text :arguments
125
+ t.string :queue_name
126
+ t.integer :priority, default: 0
127
+ t.boolean :static, null: false, default: true
128
+ t.boolean :enabled, null: false, default: true
129
+ t.text :description
130
+ t.timestamps
131
+ end
132
+
133
+ add_index :pgbus_recurring_tasks, :key,
134
+ unique: true, name: "idx_pgbus_recurring_tasks_key"
135
+
136
+ # Recurring execution deduplication (prevents double-enqueue)
137
+ create_table :pgbus_recurring_executions do |t|
138
+ t.string :task_key, null: false
139
+ t.datetime :run_at, null: false
140
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
141
+ end
142
+
143
+ add_index :pgbus_recurring_executions, [:task_key, :run_at],
144
+ unique: true, name: "idx_pgbus_recurring_executions_dedup"
145
+ add_index :pgbus_recurring_executions, :run_at,
146
+ name: "idx_pgbus_recurring_executions_cleanup"
147
+
96
148
  # Create default queues via PGMQ
97
149
  execute "SELECT pgmq.create('pgbus_default')"
98
150
  execute "SELECT pgmq.create('pgbus_default_dlq')"
@@ -102,13 +154,29 @@ class InstallPgbus < ActiveRecord::Migration<%= migration_version %>
102
154
  execute "SELECT pgmq.drop_queue('pgbus_default_dlq')"
103
155
  execute "SELECT pgmq.drop_queue('pgbus_default')"
104
156
 
157
+ drop_table :pgbus_recurring_executions
158
+ drop_table :pgbus_recurring_tasks
105
159
  drop_table :pgbus_batches
106
160
  drop_table :pgbus_blocked_executions
107
161
  drop_table :pgbus_semaphores
108
162
  drop_table :pgbus_failed_events
109
163
  drop_table :pgbus_processes
110
164
  drop_table :pgbus_processed_events
165
+ drop_table :pgbus_pgmq_schema_versions
166
+
167
+ # Drop PGMQ schema (functions, types, tables)
168
+ execute "DROP SCHEMA IF EXISTS pgmq CASCADE"
169
+
170
+ # Also try to disable extension in case it was installed that way
171
+ disable_extension "pgmq" if extension_available?("pgmq")
172
+ end
173
+
174
+ private
111
175
 
112
- disable_extension "pgmq"
176
+ def extension_available?(name)
177
+ result = execute("SELECT 1 FROM pg_available_extensions WHERE name = '#{name}' LIMIT 1")
178
+ result.any?
179
+ rescue ActiveRecord::StatementInvalid
180
+ false
113
181
  end
114
182
  end
@@ -0,0 +1,40 @@
1
+ # Pgbus Recurring Tasks Configuration
2
+ #
3
+ # Compatible with Solid Queue's recurring.yml format for easy migration.
4
+ # Supports environment-scoped or flat configuration.
5
+ #
6
+ # Format:
7
+ # task_key:
8
+ # class: JobClassName # ActiveJob class to run (or use command:)
9
+ # schedule: "0 * * * *" # Cron expression or natural language
10
+ # queue: default # Optional: queue name (default: pgbus default queue)
11
+ # args: [arg1, arg2] # Optional: job arguments
12
+ # priority: 0 # Optional: numeric priority
13
+ # description: "text" # Optional: human-readable description
14
+ #
15
+ # Schedule examples:
16
+ # "0 2 * * *" → Every day at 2:00 AM
17
+ # "*/5 * * * *" → Every 5 minutes
18
+ # "every hour" → Every hour at :00
19
+ # "every day at 2am" → Daily at 2:00 AM
20
+ # "@daily" → Daily at midnight
21
+ # "0 9 * * mon-fri" → Weekdays at 9:00 AM
22
+ #
23
+ # Command-based tasks (no job class needed):
24
+ # cleanup_old_records:
25
+ # command: "OldRecord.where('created_at < ?', 30.days.ago).delete_all"
26
+ # schedule: every day at 3am
27
+ #
28
+ # Examples:
29
+ #
30
+ # production:
31
+ # periodic_cleanup:
32
+ # class: CleanupJob
33
+ # queue: maintenance
34
+ # args: [1000, { batch_size: 500 }]
35
+ # schedule: every hour
36
+ #
37
+ # daily_report:
38
+ # class: DailyReportJob
39
+ # schedule: "0 8 * * mon-fri"
40
+ # description: Generate daily business report