notificare 0.1.0.alpha.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +899 -0
- data/app/assets/stylesheets/active_job/notificare/engine.css +425 -0
- data/app/controllers/active_job/notificare/application_controller.rb +7 -0
- data/app/controllers/active_job/notificare/executions_controller.rb +41 -0
- data/app/controllers/active_job/notificare/notifications_controller.rb +72 -0
- data/app/helpers/active_job/notificare/view_helpers.rb +43 -0
- data/app/models/active_job/notificare/application_record.rb +7 -0
- data/app/models/active_job/notificare/execution.rb +20 -0
- data/app/models/active_job/notificare/notification.rb +50 -0
- data/app/views/active_job/notificare/_notification.html.erb +19 -0
- data/app/views/active_job/notificare/_notifications.html.erb +7 -0
- data/app/views/active_job/notificare/_progress.html.erb +17 -0
- data/app/views/active_job/notificare/executions/index.html.erb +75 -0
- data/app/views/active_job/notificare/executions/show.html.erb +66 -0
- data/app/views/active_job/notificare/notifications/clear.turbo_stream.erb +3 -0
- data/app/views/active_job/notificare/notifications/dismiss.turbo_stream.erb +1 -0
- data/app/views/active_job/notificare/notifications/read.turbo_stream.erb +3 -0
- data/app/views/layouts/active_job/notificare/application.html.erb +42 -0
- data/config/locales/en.yml +7 -0
- data/config/routes.rb +13 -0
- data/lib/active_job/notificare/concern.rb +78 -0
- data/lib/active_job/notificare/engine.rb +28 -0
- data/lib/active_job/notificare/progress_handle.rb +23 -0
- data/lib/active_job/notificare/projection.rb +145 -0
- data/lib/active_job/notificare/recipient.rb +39 -0
- data/lib/active_job/notificare/step_dsl.rb +42 -0
- data/lib/active_job/notificare/version.rb +5 -0
- data/lib/active_job/notificare.rb +14 -0
- data/lib/generators/active_job/notificare/install/install_generator.rb +56 -0
- data/lib/generators/active_job/notificare/install/templates/_notification.html.erb.tt +19 -0
- data/lib/generators/active_job/notificare/install/templates/_notifications.html.erb.tt +7 -0
- data/lib/generators/active_job/notificare/install/templates/_progress.html.erb.tt +17 -0
- data/lib/generators/active_job/notificare/install/templates/create_active_job_notificare_tables.rb.tt +36 -0
- data/lib/generators/active_job/notificare/install/templates/initializer.rb.tt +24 -0
- data/lib/generators/active_job/notificare/scaffold/scaffold_generator.rb +74 -0
- data/lib/generators/active_job/notificare/scaffold/templates/controller.rb.tt +31 -0
- data/lib/generators/active_job/notificare/scaffold/templates/index.html.erb.tt +26 -0
- data/lib/generators/active_job/notificare/scaffold/templates/locale.en.yml.tt +18 -0
- data/lib/generators/active_job/notificare/scaffold/templates/show.html.erb.tt +39 -0
- data/lib/notificare.rb +4 -0
- metadata +118 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%= turbo_stream_from "active_job_notifications", recipient.to_gid_param %>
|
|
2
|
+
<div id="active_job_notifications" class="notificare-inbox">
|
|
3
|
+
<%= button_to t(".clear_all"), notificare.clear_notifications_path, method: :delete %>
|
|
4
|
+
<% notifications.each do |notification| %>
|
|
5
|
+
<%= render "active_job/notificare/notification", notification: notification %>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%= turbo_stream_from "active_job_progress", execution.job_id %>
|
|
2
|
+
<div id="<%= dom_id(execution) %>" class="notificare-progress">
|
|
3
|
+
<% if execution.progress_total.present? %>
|
|
4
|
+
<progress class="notificare-progress__bar" value="<%= execution.progress_current %>" max="<%= execution.progress_total %>"></progress>
|
|
5
|
+
<span class="notificare-progress__label"><%= execution.progress_current %>/<%= execution.progress_total %> (<%= (execution.progress_current.to_f / execution.progress_total * 100).round %>%)</span>
|
|
6
|
+
<% if execution.current_step.present? %>
|
|
7
|
+
<span class="notificare-progress__step"><%= execution.current_step %></span>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% else %>
|
|
10
|
+
<% if !execution.failed? %>
|
|
11
|
+
<span class="notificare-progress__spinner" role="status" aria-label="Loading..."></span>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% if execution.current_step.present? %>
|
|
14
|
+
<span class="notificare-progress__step"><%= execution.current_step %></span>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<h1>Job Executions</h1>
|
|
2
|
+
<p class="nf-subtitle">Live registry of background job activity</p>
|
|
3
|
+
|
|
4
|
+
<%= form_with url: executions_path, method: :get, local: true, class: "nf-filters" do |f| %>
|
|
5
|
+
<%= f.select :status,
|
|
6
|
+
[ [ "All statuses", "" ] ] + @statuses.map { |s| [ s.titleize, s ] },
|
|
7
|
+
{ selected: params[:status] } %>
|
|
8
|
+
<%= f.select :job_class,
|
|
9
|
+
[ [ "All job classes", "" ] ] + @job_classes.map { |c| [ c, c ] },
|
|
10
|
+
{ selected: params[:job_class] } %>
|
|
11
|
+
<%= f.submit "Filter" %>
|
|
12
|
+
<% if params[:status].present? || params[:job_class].present? %>
|
|
13
|
+
<%= link_to "Clear", executions_path, class: "nf-clear" %>
|
|
14
|
+
<% end %>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<div class="nf-card">
|
|
18
|
+
<% if @executions.any? %>
|
|
19
|
+
<table class="nf-table">
|
|
20
|
+
<thead>
|
|
21
|
+
<tr>
|
|
22
|
+
<th>Status</th>
|
|
23
|
+
<th>Job class</th>
|
|
24
|
+
<th>Job ID</th>
|
|
25
|
+
<th>Step</th>
|
|
26
|
+
<th>Progress</th>
|
|
27
|
+
<th>Started</th>
|
|
28
|
+
<th>Finished</th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody>
|
|
32
|
+
<% @executions.each do |execution| %>
|
|
33
|
+
<tr>
|
|
34
|
+
<td><span class="nf-badge nf-badge--<%= execution.status %>"><%= execution.status %></span></td>
|
|
35
|
+
<td><%= link_to execution.job_class, execution_path(execution) %></td>
|
|
36
|
+
<td class="nf-mono"><%= truncate(execution.job_id, length: 22) %></td>
|
|
37
|
+
<td><%= execution.current_step %></td>
|
|
38
|
+
<td>
|
|
39
|
+
<% if execution.progress_total %>
|
|
40
|
+
<span class="nf-mono"><%= execution.progress_current %> / <%= execution.progress_total %></span>
|
|
41
|
+
<% end %>
|
|
42
|
+
</td>
|
|
43
|
+
<td class="nf-mono"><%= execution.started_at&.strftime("%Y-%m-%d %H:%M:%S") %></td>
|
|
44
|
+
<td class="nf-mono"><%= execution.completed_at&.strftime("%Y-%m-%d %H:%M:%S") %></td>
|
|
45
|
+
</tr>
|
|
46
|
+
<% end %>
|
|
47
|
+
</tbody>
|
|
48
|
+
</table>
|
|
49
|
+
<% else %>
|
|
50
|
+
<div class="nf-empty">No executions found.</div>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<% if @total_pages > 1 %>
|
|
55
|
+
<nav class="nf-pagination" aria-label="Pagination">
|
|
56
|
+
<% if @page > 1 %>
|
|
57
|
+
<%= link_to "← Prev", executions_path(page: @page - 1, status: params[:status], job_class: params[:job_class]) %>
|
|
58
|
+
<% end %>
|
|
59
|
+
|
|
60
|
+
<% window_start = [ 1, @page - 2 ].max %>
|
|
61
|
+
<% window_end = [ @total_pages, @page + 2 ].min %>
|
|
62
|
+
<% (window_start..window_end).each do |p| %>
|
|
63
|
+
<% if p == @page %>
|
|
64
|
+
<span class="nf-pagination__current"><%= p %></span>
|
|
65
|
+
<% else %>
|
|
66
|
+
<%= link_to p, executions_path(page: p, status: params[:status], job_class: params[:job_class]) %>
|
|
67
|
+
<% end %>
|
|
68
|
+
<% end %>
|
|
69
|
+
|
|
70
|
+
<% if @page < @total_pages %>
|
|
71
|
+
<%= link_to "Next →", executions_path(page: @page + 1, status: params[:status], job_class: params[:job_class]) %>
|
|
72
|
+
<% end %>
|
|
73
|
+
</nav>
|
|
74
|
+
<p class="nf-pagination__info"><%= @total_count %> total</p>
|
|
75
|
+
<% end %>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<%= link_to "← executions", executions_path, class: "nf-back" %>
|
|
2
|
+
|
|
3
|
+
<h1><%= @execution.job_class %></h1>
|
|
4
|
+
<p class="nf-subtitle nf-mono"><%= @execution.job_id %></p>
|
|
5
|
+
|
|
6
|
+
<div class="nf-detail">
|
|
7
|
+
<div>
|
|
8
|
+
<div class="nf-card">
|
|
9
|
+
<div class="nf-card__header">Execution details</div>
|
|
10
|
+
<div class="nf-card__body">
|
|
11
|
+
<dl class="nf-kv">
|
|
12
|
+
<dt>Status</dt>
|
|
13
|
+
<dd><%= tag.span class: "nf-badge nf-badge--#{@execution.status}" do %><%= @execution.status %><% end %></dd>
|
|
14
|
+
|
|
15
|
+
<dt>Current step</dt>
|
|
16
|
+
<dd><%= @execution.current_step.presence || "—" %></dd>
|
|
17
|
+
|
|
18
|
+
<dt>Started at</dt>
|
|
19
|
+
<dd><%= @execution.started_at&.strftime("%Y-%m-%d %H:%M:%S UTC") || "—" %></dd>
|
|
20
|
+
|
|
21
|
+
<dt>Completed at</dt>
|
|
22
|
+
<dd><%= @execution.completed_at&.strftime("%Y-%m-%d %H:%M:%S UTC") || "—" %></dd>
|
|
23
|
+
</dl>
|
|
24
|
+
|
|
25
|
+
<% if @execution.error.present? %>
|
|
26
|
+
<div class="nf-error"><%= @execution.error %></div>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div>
|
|
33
|
+
<div class="nf-card">
|
|
34
|
+
<div class="nf-card__header">Live progress</div>
|
|
35
|
+
<div class="nf-card__body">
|
|
36
|
+
<%= active_job_notificare(@execution) %>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<% if @notifications.any? %>
|
|
43
|
+
<div class="nf-card">
|
|
44
|
+
<div class="nf-card__header">Notifications (<%= @notifications.size %>)</div>
|
|
45
|
+
<table class="nf-table">
|
|
46
|
+
<thead>
|
|
47
|
+
<tr>
|
|
48
|
+
<th>Type</th>
|
|
49
|
+
<th>Title</th>
|
|
50
|
+
<th>Description</th>
|
|
51
|
+
<th>Time</th>
|
|
52
|
+
</tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
<% @notifications.each do |notification| %>
|
|
56
|
+
<tr>
|
|
57
|
+
<td><%= tag.span class: "nf-badge nf-badge--#{notification.event_type}" do %><%= notification.event_type %><% end %></td>
|
|
58
|
+
<td><%= notification.title %></td>
|
|
59
|
+
<td><%= notification.description %></td>
|
|
60
|
+
<td class="nf-mono"><%= notification.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
|
|
61
|
+
</tr>
|
|
62
|
+
<% end %>
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
<% end %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= turbo_stream.remove dom_id(@notification) %>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Notificare</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
8
|
+
(function(){
|
|
9
|
+
var t=localStorage.getItem('nf-theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');
|
|
10
|
+
document.documentElement.dataset.theme=t;
|
|
11
|
+
})();
|
|
12
|
+
</script>
|
|
13
|
+
<%= stylesheet_link_tag "active_job/notificare/engine" %>
|
|
14
|
+
<%= javascript_importmap_tags if respond_to?(:javascript_importmap_tags) %>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<header class="nf-header">
|
|
18
|
+
<a href="<%= executions_path %>" class="nf-brand">
|
|
19
|
+
<span class="nf-brand__mark" aria-hidden="true">✦</span>
|
|
20
|
+
Notificare
|
|
21
|
+
</a>
|
|
22
|
+
<button class="nf-theme-toggle" id="nf-theme-toggle" aria-label="Toggle colour theme"></button>
|
|
23
|
+
</header>
|
|
24
|
+
<div class="nf-folk-strip" aria-hidden="true"></div>
|
|
25
|
+
<main class="nf-main">
|
|
26
|
+
<%= yield %>
|
|
27
|
+
</main>
|
|
28
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
29
|
+
(function(){
|
|
30
|
+
var btn=document.getElementById('nf-theme-toggle');
|
|
31
|
+
function sync(t){ btn.textContent=t==='dark'?'☀':'☾'; btn.title=t==='dark'?'Switch to light':'Switch to dark'; }
|
|
32
|
+
sync(document.documentElement.dataset.theme);
|
|
33
|
+
btn.addEventListener('click',function(){
|
|
34
|
+
var next=document.documentElement.dataset.theme==='dark'?'light':'dark';
|
|
35
|
+
document.documentElement.dataset.theme=next;
|
|
36
|
+
localStorage.setItem('nf-theme',next);
|
|
37
|
+
sync(next);
|
|
38
|
+
});
|
|
39
|
+
})();
|
|
40
|
+
</script>
|
|
41
|
+
</body>
|
|
42
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
ActiveJob::Notificare::Engine.routes.draw do
|
|
2
|
+
root to: "executions#index"
|
|
3
|
+
resources :executions, only: [ :index, :show ]
|
|
4
|
+
|
|
5
|
+
resources :notifications, only: [] do
|
|
6
|
+
member do
|
|
7
|
+
patch :read
|
|
8
|
+
patch :dismiss
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
delete "notifications", to: "notifications#clear", as: :clear_notifications
|
|
13
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "active_job/continuation"
|
|
3
|
+
require "active_job/continuable"
|
|
4
|
+
|
|
5
|
+
module ActiveJob
|
|
6
|
+
module Notificare
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
include ActiveJob::Continuable
|
|
11
|
+
include StepDSL
|
|
12
|
+
include Recipient
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class_methods do
|
|
16
|
+
# Opt out of the projection. Including the module is the opt-in;
|
|
17
|
+
# `tracks_progress false` flips it off without removing the include.
|
|
18
|
+
def tracks_progress(value = true)
|
|
19
|
+
@tracks_progress = value
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tracks_progress?
|
|
23
|
+
@tracks_progress != false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Declare which lifecycle events auto-write a Notification row.
|
|
27
|
+
# Accepted values: :completed, :failed (or both).
|
|
28
|
+
def notify_on(*event_types)
|
|
29
|
+
@_notificare_notify_on = event_types.map(&:to_sym)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def notificare_notify_on
|
|
33
|
+
@_notificare_notify_on || []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Eagerly opt into recipient enforcement at enqueue time, before any instance
|
|
37
|
+
# has called notify(...). Use this when you know the job will call notify but
|
|
38
|
+
# want the ArgumentError to fire on the very first enqueue.
|
|
39
|
+
def uses_notify!
|
|
40
|
+
@_uses_notify = true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# True if uses_notify! was called explicitly, or after the first instance called
|
|
44
|
+
# notify(...) during perform (which flips this flag).
|
|
45
|
+
def uses_notify?
|
|
46
|
+
@_uses_notify == true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The polymorphic recipient for notifications. Job authors set this inside
|
|
51
|
+
# perform (e.g. `self.recipient = recipient`).
|
|
52
|
+
attr_accessor :recipient
|
|
53
|
+
|
|
54
|
+
def progress
|
|
55
|
+
@progress ||= ProgressHandle.new(job_id)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Write a custom Notification row directly. Safe to call at any point during or
|
|
59
|
+
# after perform — does not rely on lifecycle hooks (ERD §9 case 5).
|
|
60
|
+
#
|
|
61
|
+
# Also flips self.class.uses_notify? to true so subsequent enqueues are subject
|
|
62
|
+
# to recipient enforcement.
|
|
63
|
+
def notify(title:, description: nil, metadata: {}, actions: [])
|
|
64
|
+
self.class.uses_notify!
|
|
65
|
+
return unless recipient
|
|
66
|
+
|
|
67
|
+
Notification.create!(
|
|
68
|
+
recipient: recipient,
|
|
69
|
+
job_id: job_id,
|
|
70
|
+
event_type: "custom",
|
|
71
|
+
title: title,
|
|
72
|
+
description: description,
|
|
73
|
+
metadata: metadata.presence,
|
|
74
|
+
actions: actions.presence
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "active_job/notificare/projection"
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Notificare
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace ActiveJob::Notificare
|
|
7
|
+
|
|
8
|
+
initializer "active_job.notificare.projection" do
|
|
9
|
+
ActiveJob::Notificare::Projection.subscribe!
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "active_job.notificare.helpers" do
|
|
13
|
+
ActiveSupport.on_load(:action_view) do
|
|
14
|
+
include ActiveJob::Notificare::ViewHelpers
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
config.to_prepare do
|
|
19
|
+
unless ::Notificare.const_defined?(:Execution, false)
|
|
20
|
+
::Notificare.const_set(:Execution, ActiveJob::Notificare::Execution)
|
|
21
|
+
end
|
|
22
|
+
unless ::Notificare.const_defined?(:Notification, false)
|
|
23
|
+
::Notificare.const_set(:Notification, ActiveJob::Notificare::Notification)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module ActiveJob
|
|
2
|
+
module Notificare
|
|
3
|
+
class ProgressHandle
|
|
4
|
+
def initialize(job_id)
|
|
5
|
+
@job_id = job_id
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def total(n)
|
|
9
|
+
rows = Execution.where(job_id: @job_id).update_all(progress_total: n)
|
|
10
|
+
if rows == 0
|
|
11
|
+
Rails.logger.debug { "[notificare] progress.total called before execution row exists for job_id=#{@job_id}" }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def advance!(by = 1)
|
|
16
|
+
rows = Execution.where(job_id: @job_id).update_all("progress_current = progress_current + #{by.to_i}")
|
|
17
|
+
if rows == 0
|
|
18
|
+
Rails.logger.debug { "[notificare] progress.advance! called before execution row exists for job_id=#{@job_id}" }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
module ActiveJob
|
|
2
|
+
module Notificare
|
|
3
|
+
module Projection
|
|
4
|
+
SUBSCRIPTIONS = []
|
|
5
|
+
|
|
6
|
+
def self.subscribe!
|
|
7
|
+
SUBSCRIPTIONS << ActiveSupport::Notifications.subscribe("enqueue.active_job") do |event|
|
|
8
|
+
job = event.payload[:job]
|
|
9
|
+
next unless tracks_progress?(job)
|
|
10
|
+
|
|
11
|
+
begin
|
|
12
|
+
Execution.find_or_create_by!(job_id: job.job_id) do |e|
|
|
13
|
+
e.job_class = job.class.name
|
|
14
|
+
e.status = "enqueued"
|
|
15
|
+
end
|
|
16
|
+
rescue ActiveRecord::RecordNotUnique
|
|
17
|
+
# Two concurrent projections for the same job_id — uniqueness constraint caught the
|
|
18
|
+
# duplicate; the other thread won, so just find the existing row.
|
|
19
|
+
Execution.find_by!(job_id: job.job_id)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
SUBSCRIPTIONS << ActiveSupport::Notifications.subscribe("perform_start.active_job") do |event|
|
|
24
|
+
job = event.payload[:job]
|
|
25
|
+
next unless tracks_progress?(job)
|
|
26
|
+
|
|
27
|
+
execution = Execution.find_by(job_id: job.job_id)
|
|
28
|
+
next unless execution
|
|
29
|
+
|
|
30
|
+
if execution.running?
|
|
31
|
+
# Resume path (ERD §9 case 3): worker was killed before perform.active_job fired,
|
|
32
|
+
# leaving status as running. Preserve progress_current and started_at.
|
|
33
|
+
# No continuation_state column — Continuation owns that (ERD §6).
|
|
34
|
+
execution.update!(error: nil) if execution.error.present?
|
|
35
|
+
else
|
|
36
|
+
execution.update!(status: "running", started_at: Time.current)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Mirror ActiveJob::Continuation's current step name onto the execution row.
|
|
41
|
+
SUBSCRIPTIONS << ActiveSupport::Notifications.subscribe("step_started.active_job") do |event|
|
|
42
|
+
job = event.payload[:job]
|
|
43
|
+
next unless tracks_progress?(job)
|
|
44
|
+
|
|
45
|
+
step = event.payload[:step]
|
|
46
|
+
Execution.find_by(job_id: job.job_id)&.update!(current_step: step.name.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ActiveJob::Continuation fires `step.active_job` (not `step_completed`) after each step's
|
|
50
|
+
# block finishes (whether successfully or with an exception). Only write a notification
|
|
51
|
+
# when the step completed without error and was not interrupted.
|
|
52
|
+
SUBSCRIPTIONS << ActiveSupport::Notifications.subscribe("step.active_job") do |event|
|
|
53
|
+
next if event.payload[:exception_object]
|
|
54
|
+
next if event.payload[:interrupted]
|
|
55
|
+
|
|
56
|
+
job = event.payload[:job]
|
|
57
|
+
next unless tracks_progress?(job)
|
|
58
|
+
|
|
59
|
+
step = event.payload[:step]
|
|
60
|
+
notify_event = job.respond_to?(:notificare_step_notify_for) ? job.notificare_step_notify_for(step.name) : nil
|
|
61
|
+
write_step_notification(job, step.name, notify_event) if notify_event
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
SUBSCRIPTIONS << ActiveSupport::Notifications.subscribe("perform.active_job") do |event|
|
|
65
|
+
job = event.payload[:job]
|
|
66
|
+
next unless tracks_progress?(job)
|
|
67
|
+
|
|
68
|
+
execution = Execution.find_by(job_id: job.job_id)
|
|
69
|
+
next unless execution
|
|
70
|
+
|
|
71
|
+
if (exception = event.payload[:exception_object])
|
|
72
|
+
execution.update!(status: "failed", completed_at: Time.current, error: exception.message)
|
|
73
|
+
write_lifecycle_notification(job, :failed, exception.message) if notifies_on?(job, :failed)
|
|
74
|
+
else
|
|
75
|
+
execution.update!(status: "completed", completed_at: Time.current)
|
|
76
|
+
write_lifecycle_notification(job, :completed) if notifies_on?(job, :completed)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.unsubscribe!
|
|
82
|
+
SUBSCRIPTIONS.each { |s| ActiveSupport::Notifications.unsubscribe(s) }
|
|
83
|
+
SUBSCRIPTIONS.clear
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.tracks_progress?(job)
|
|
87
|
+
job.class.respond_to?(:tracks_progress?) && job.class.tracks_progress?
|
|
88
|
+
end
|
|
89
|
+
private_class_method :tracks_progress?
|
|
90
|
+
|
|
91
|
+
def self.notifies_on?(job, event_type)
|
|
92
|
+
job.class.respond_to?(:notificare_notify_on) && job.class.notificare_notify_on.include?(event_type)
|
|
93
|
+
end
|
|
94
|
+
private_class_method :notifies_on?
|
|
95
|
+
|
|
96
|
+
def self.recipient_for(job)
|
|
97
|
+
job.respond_to?(:recipient) ? job.recipient : nil
|
|
98
|
+
end
|
|
99
|
+
private_class_method :recipient_for
|
|
100
|
+
|
|
101
|
+
def self.write_lifecycle_notification(job, event_type, description = nil)
|
|
102
|
+
recipient = recipient_for(job)
|
|
103
|
+
return unless recipient
|
|
104
|
+
|
|
105
|
+
Notification.create!(
|
|
106
|
+
recipient: recipient,
|
|
107
|
+
job_id: job.job_id,
|
|
108
|
+
event_type: event_type.to_s,
|
|
109
|
+
title: "#{job.class.name} #{event_type}",
|
|
110
|
+
description: description
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
private_class_method :write_lifecycle_notification
|
|
114
|
+
|
|
115
|
+
def self.write_step_notification(job, step_name, notify_event)
|
|
116
|
+
recipient = recipient_for(job)
|
|
117
|
+
return unless recipient
|
|
118
|
+
|
|
119
|
+
Notification.create!(build_step_notification_attrs(job, step_name, notify_event))
|
|
120
|
+
end
|
|
121
|
+
private_class_method :write_step_notification
|
|
122
|
+
|
|
123
|
+
def self.build_step_notification_attrs(job, step_name, notify_event)
|
|
124
|
+
base = { recipient: recipient_for(job), job_id: job.job_id, event_type: "custom" }
|
|
125
|
+
|
|
126
|
+
case notify_event
|
|
127
|
+
when Symbol
|
|
128
|
+
base.merge(
|
|
129
|
+
title: "#{job.class.name}: #{notify_event}",
|
|
130
|
+
metadata: { "event" => notify_event.to_s }
|
|
131
|
+
)
|
|
132
|
+
when Hash
|
|
133
|
+
event = notify_event[:event] || step_name
|
|
134
|
+
extra_metadata = notify_event[:metadata]&.transform_keys(&:to_s) || {}
|
|
135
|
+
base.merge(
|
|
136
|
+
title: notify_event[:title] || "#{job.class.name}: #{event}",
|
|
137
|
+
description: notify_event[:description],
|
|
138
|
+
metadata: { "event" => event.to_s }.merge(extra_metadata)
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
private_class_method :build_step_notification_attrs
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Notificare
|
|
5
|
+
# around_enqueue guard: raises ArgumentError before the adapter receives the job
|
|
6
|
+
# when the job opts into notifications but no `recipient:` keyword was supplied.
|
|
7
|
+
#
|
|
8
|
+
# Opt-in triggers (any one is sufficient):
|
|
9
|
+
# - notify_on declared on the class
|
|
10
|
+
# - uses_notify! called on the class (or uses_notify? already true from a prior run)
|
|
11
|
+
# - step(notify:) was called in a prior run (has_step_notifications? is true)
|
|
12
|
+
module Recipient
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
around_enqueue :enforce_recipient!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def enforce_recipient!
|
|
22
|
+
if needs_recipient? && !recipient_argument_present?
|
|
23
|
+
raise ArgumentError, "#{self.class.name} requires a `recipient:` keyword argument"
|
|
24
|
+
end
|
|
25
|
+
yield
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def needs_recipient?
|
|
29
|
+
self.class.notificare_notify_on.any? ||
|
|
30
|
+
self.class.uses_notify? ||
|
|
31
|
+
self.class.has_step_notifications?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def recipient_argument_present?
|
|
35
|
+
arguments.any? { |arg| arg.is_a?(Hash) && (arg.key?(:recipient) || arg.key?("recipient")) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Notificare
|
|
5
|
+
# Wraps ActiveJob::Continuation#step with Notificare-specific kwargs.
|
|
6
|
+
#
|
|
7
|
+
# `notify:` declares a state-machine event tied to the step's successful completion.
|
|
8
|
+
# The value is stashed on the job instance keyed by step name; the projection reads it
|
|
9
|
+
# off `event.payload[:job]` at `step_completed.active_job` time. Actual Notification
|
|
10
|
+
# row writes land in ticket 06.
|
|
11
|
+
module StepDSL
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
extend ClassMethods
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module ClassMethods
|
|
19
|
+
# True after the first run of any instance that called step(notify:).
|
|
20
|
+
# Used by Recipient to enforce recipient: presence on subsequent enqueues.
|
|
21
|
+
def has_step_notifications?
|
|
22
|
+
@_has_step_notifications == true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def step(name, *args, notify: nil, **opts, &block)
|
|
27
|
+
if notify
|
|
28
|
+
self.class.instance_variable_set(:@_has_step_notifications, true)
|
|
29
|
+
@_notificare_step_notify ||= {}
|
|
30
|
+
@_notificare_step_notify[name.to_sym] = notify
|
|
31
|
+
end
|
|
32
|
+
super(name, *args, **opts, &block)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Read by Projection's `step_completed.active_job` handler.
|
|
36
|
+
def notificare_step_notify_for(step_name)
|
|
37
|
+
return nil unless defined?(@_notificare_step_notify) && @_notificare_step_notify
|
|
38
|
+
@_notificare_step_notify[step_name.to_sym]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "active_job/notificare/version"
|
|
2
|
+
require "active_job/notificare/engine"
|
|
3
|
+
require "active_job/notificare/progress_handle"
|
|
4
|
+
require "active_job/notificare/step_dsl"
|
|
5
|
+
require "active_job/notificare/recipient"
|
|
6
|
+
require "active_job/notificare/concern"
|
|
7
|
+
|
|
8
|
+
module ActiveJob
|
|
9
|
+
module Notificare
|
|
10
|
+
mattr_accessor :current_recipient_proc
|
|
11
|
+
mattr_accessor :parent_controller, default: "ApplicationController"
|
|
12
|
+
mattr_accessor :authenticate_with
|
|
13
|
+
end
|
|
14
|
+
end
|