geneva_drive 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/MANUAL.md +299 -5
- data/README.md +14 -1
- data/Rakefile +28 -1
- data/bin/matrix +76 -0
- data/bin/md2html +114 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +5 -10
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +1 -1
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +33 -2
- data/lib/geneva_drive/dispatchers/active_job_dispatcher.rb +33 -0
- data/lib/geneva_drive/dispatchers/sidekiq_dispatcher.rb +112 -0
- data/lib/geneva_drive/engine.rb +13 -0
- data/lib/geneva_drive/executor.rb +23 -3
- data/lib/geneva_drive/job_options.rb +7 -3
- data/lib/geneva_drive/jobs/perform_step_job.rb +2 -35
- data/lib/geneva_drive/jobs/sidekiq_housekeeping_job.rb +32 -0
- data/lib/geneva_drive/jobs/sidekiq_perform_step_job.rb +35 -0
- data/lib/geneva_drive/jobs/step_execution_lookup.rb +42 -0
- data/lib/geneva_drive/json_column.rb +72 -0
- data/lib/geneva_drive/migration_helpers.rb +60 -3
- data/lib/geneva_drive/removed_step_definition.rb +34 -0
- data/lib/geneva_drive/signal.rb +4 -2
- data/lib/geneva_drive/step_collection.rb +31 -7
- data/lib/geneva_drive/step_definition.rb +9 -0
- data/lib/geneva_drive/step_execution.rb +3 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +200 -39
- data/lib/geneva_drive.rb +65 -0
- data/test/dummy/config/database.yml +40 -5
- data/test/dummy/config/initializers/geneva_drive.rb +78 -0
- data/test/dummy/db/mysql/schema-8.1.rb +95 -0
- data/test/dummy/db/postgres/schema-8.1.rb +99 -0
- data/test/dummy/db/sqlite/schema-7.2.rb +96 -0
- data/test/dummy/db/sqlite/schema-8.0.rb +96 -0
- data/test/dummy/db/sqlite/schema-8.1.rb +96 -0
- data/test/dummy/db/sqlite/test.sqlite3 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-0 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-1 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-2 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-3 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-4 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-5 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-6 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-7 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-8 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-9 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_0 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_1 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_2 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_3 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_4 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_5 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_6 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_7 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_8 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_9 +0 -0
- data/test/dummy/db/sqlite3/schema-7.2.rb +96 -0
- data/test/dummy/db/sqlite3/schema-8.1.rb +96 -0
- data/test/dummy/db/sqlite3/test.sqlite3 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_0 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_1 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_2 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_3 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_4 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_5 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_6 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_7 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_8 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_9 +0 -0
- data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
- data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
- data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
- data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
- data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
- data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
- data/test/dummy_install/db/schema.rb +75 -0
- data/test/dummy_install/log/test.log +1852 -0
- data/test/generators/install_generator_test.rb +56 -0
- data/test/geneva_drive_test.rb +10 -0
- data/test/migration_helpers_test.rb +62 -0
- data/test/sidekiq/sidekiq_dispatcher_test.rb +322 -0
- data/test/sidekiq/sidekiq_redis_test.rb +115 -0
- data/test/test_helper.rb +51 -13
- data/test/workflow/pause_resume_test.rb +15 -0
- data/test/workflow/removed_step_test.rb +294 -0
- data/test/workflow/resume_at_test.rb +170 -0
- metadata +62 -1
data/bin/md2html
CHANGED
|
@@ -42,6 +42,98 @@ class SyntaxHighlightingHTML < Redcarpet::Render::HTML
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# GitHub-Flavored Markdown alerts: `> [!NOTE]` etc. Redcarpet emits these
|
|
46
|
+
# as ordinary blockquotes, so we post-process the HTML to spot the marker,
|
|
47
|
+
# strip it, and rewrite the blockquote with an alert class + title.
|
|
48
|
+
# GitHub knows exactly these five and matches them case-sensitively —
|
|
49
|
+
# `[!note]` or `[!FOO]` stay literal text there, and so must they here.
|
|
50
|
+
GFM_ALERT_TYPES = %w[NOTE TIP IMPORTANT WARNING CAUTION].freeze
|
|
51
|
+
# Only spaces/tabs and one line ending may follow the marker. `\s*` would
|
|
52
|
+
# swallow the blank line separating the marker from a following paragraph.
|
|
53
|
+
GFM_ALERT_PATTERN = /\A\[!(#{GFM_ALERT_TYPES.join("|")})\][ \t]*\r?\n?/
|
|
54
|
+
|
|
55
|
+
# The marker opens the text of a paragraph. GitHub insists it sit alone on the
|
|
56
|
+
# blockquote's first line; we are laxer and also accept body text trailing it
|
|
57
|
+
# on the same line, rather than leaving a half-parsed `[!NOTE]` on the page.
|
|
58
|
+
def alert_marker_of(child)
|
|
59
|
+
return nil unless child.element? && child.name == "p"
|
|
60
|
+
first_text = child.children.first
|
|
61
|
+
return nil unless first_text&.text?
|
|
62
|
+
match = first_text.content.match(GFM_ALERT_PATTERN)
|
|
63
|
+
match && match[1]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Take the marker out of the paragraph it introduces and keep everything else,
|
|
67
|
+
# element children included — that is the whole point: an alert whose body
|
|
68
|
+
# opens with `**bold**` has the marker and a <strong> in the same <p>, so we
|
|
69
|
+
# may only edit that leading text node, never replace the paragraph.
|
|
70
|
+
def strip_alert_marker!(paragraph)
|
|
71
|
+
first_text = paragraph.children.first
|
|
72
|
+
first_text.content = first_text.content.sub(GFM_ALERT_PATTERN, "") if first_text&.text?
|
|
73
|
+
|
|
74
|
+
# A marker line ending in two spaces is a Markdown hard break, so Redcarpet
|
|
75
|
+
# leaves a <br> where the marker was. Drop that leading padding, otherwise
|
|
76
|
+
# the alert body opens with a blank line.
|
|
77
|
+
while (node = paragraph.children.first)
|
|
78
|
+
blank_text = node.text? && node.content.strip.empty?
|
|
79
|
+
break unless blank_text || (node.element? && node.name == "br")
|
|
80
|
+
node.remove
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The marker was alone on its line: the paragraph has nothing left to say.
|
|
84
|
+
paragraph.remove if paragraph.children.empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def transform_gfm_alerts(html_body)
|
|
88
|
+
doc = Nokogiri::HTML::DocumentFragment.parse(html_body)
|
|
89
|
+
doc.css("blockquote").each do |bq|
|
|
90
|
+
# Redcarpet collapses adjacent blockquotes into one, so a single
|
|
91
|
+
# <blockquote> may carry several `[!TYPE]` markers. Partition its
|
|
92
|
+
# children into segments, each introduced by a marker (or the
|
|
93
|
+
# untagged preamble before any marker).
|
|
94
|
+
segments = [[nil, []]]
|
|
95
|
+
bq.children.to_a.each do |child|
|
|
96
|
+
marker = alert_marker_of(child)
|
|
97
|
+
if marker
|
|
98
|
+
segments << [marker, [child]]
|
|
99
|
+
else
|
|
100
|
+
segments.last[1] << child
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
segments.reject! do |_, nodes|
|
|
104
|
+
nodes.all? { |n| n.text? && n.content.strip.empty? }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Nothing to do if no markers appeared at all.
|
|
108
|
+
next if segments.all? { |type, _| type.nil? }
|
|
109
|
+
|
|
110
|
+
prev = bq
|
|
111
|
+
segments.each_with_index do |(type, children), i|
|
|
112
|
+
new_bq = Nokogiri::XML::Node.new("blockquote", bq.document)
|
|
113
|
+
children.each { |c| new_bq.add_child(c) }
|
|
114
|
+
|
|
115
|
+
if type
|
|
116
|
+
first_p = new_bq.children.find(&:element?)
|
|
117
|
+
strip_alert_marker!(first_p) if first_p && first_p.name == "p"
|
|
118
|
+
|
|
119
|
+
title = Nokogiri::XML::Node.new("p", bq.document)
|
|
120
|
+
title["class"] = "markdown-alert-title"
|
|
121
|
+
title.content = type.capitalize
|
|
122
|
+
new_bq.prepend_child(title)
|
|
123
|
+
new_bq["class"] = "markdown-alert markdown-alert-#{type.downcase}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if i == 0
|
|
127
|
+
bq.replace(new_bq)
|
|
128
|
+
else
|
|
129
|
+
prev.add_next_sibling(new_bq)
|
|
130
|
+
end
|
|
131
|
+
prev = new_bq
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
doc.to_html
|
|
135
|
+
end
|
|
136
|
+
|
|
45
137
|
def build_toc(html_body)
|
|
46
138
|
doc = Nokogiri::HTML::DocumentFragment.parse(html_body)
|
|
47
139
|
headings = doc.css("h1, h2")
|
|
@@ -82,6 +174,11 @@ def html_template(title, content, toc: "")
|
|
|
82
174
|
--table-border: #dfe2e5;
|
|
83
175
|
--blockquote-color: #6a737d;
|
|
84
176
|
--blockquote-border: #dfe2e5;
|
|
177
|
+
--alert-note: #0969da;
|
|
178
|
+
--alert-tip: #1a7f37;
|
|
179
|
+
--alert-important: #8250df;
|
|
180
|
+
--alert-warning: #9a6700;
|
|
181
|
+
--alert-caution: #cf222e;
|
|
85
182
|
--toc-width: 280px;
|
|
86
183
|
}
|
|
87
184
|
|
|
@@ -247,6 +344,22 @@ def html_template(title, content, toc: "")
|
|
|
247
344
|
margin-bottom: 0;
|
|
248
345
|
}
|
|
249
346
|
|
|
347
|
+
/* GitHub-Flavored Markdown alerts (> [!NOTE] etc.) */
|
|
348
|
+
.markdown-alert {
|
|
349
|
+
color: var(--text-color);
|
|
350
|
+
padding: 0.5em 1em;
|
|
351
|
+
}
|
|
352
|
+
.markdown-alert-title {
|
|
353
|
+
font-weight: 600;
|
|
354
|
+
margin: 0 0 0.25em;
|
|
355
|
+
color: var(--alert-accent, inherit);
|
|
356
|
+
}
|
|
357
|
+
.markdown-alert-note { border-left-color: var(--alert-note); --alert-accent: var(--alert-note); }
|
|
358
|
+
.markdown-alert-tip { border-left-color: var(--alert-tip); --alert-accent: var(--alert-tip); }
|
|
359
|
+
.markdown-alert-important { border-left-color: var(--alert-important); --alert-accent: var(--alert-important); }
|
|
360
|
+
.markdown-alert-warning { border-left-color: var(--alert-warning); --alert-accent: var(--alert-warning); }
|
|
361
|
+
.markdown-alert-caution { border-left-color: var(--alert-caution); --alert-accent: var(--alert-caution); }
|
|
362
|
+
|
|
250
363
|
/* Horizontal rules */
|
|
251
364
|
hr {
|
|
252
365
|
border: 0;
|
|
@@ -444,6 +557,7 @@ def convert_markdown_to_html(input_path, output_path = nil, toc: true)
|
|
|
444
557
|
})
|
|
445
558
|
|
|
446
559
|
html_body = markdown.render(markdown_content)
|
|
560
|
+
html_body = transform_gfm_alerts(html_body)
|
|
447
561
|
toc_html = toc ? build_toc(html_body) : ""
|
|
448
562
|
html_document = html_template(title, html_body, toc: toc_html)
|
|
449
563
|
|
|
@@ -5,15 +5,11 @@ class AddResumableStepSupportToGenevaDriveStepExecutions < ActiveRecord::Migrati
|
|
|
5
5
|
|
|
6
6
|
def change
|
|
7
7
|
unless column_exists?(:geneva_drive_step_executions, :cursor)
|
|
8
|
-
# Cursor for resumable steps.
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
add_column :geneva_drive_step_executions, :cursor, :jsonb
|
|
14
|
-
else
|
|
15
|
-
add_column :geneva_drive_step_executions, :cursor, :json
|
|
16
|
-
end
|
|
8
|
+
# Cursor for resumable steps. Database-native JSON where there is one -
|
|
9
|
+
# jsonb on PostgreSQL, json on MySQL and SQLite - and plain LONGTEXT on
|
|
10
|
+
# MariaDB, which has no JSON type. See geneva_drive_json_column.
|
|
11
|
+
cursor_type, cursor_options = geneva_drive_json_column
|
|
12
|
+
add_column :geneva_drive_step_executions, :cursor, cursor_type, **cursor_options
|
|
17
13
|
end
|
|
18
14
|
|
|
19
15
|
unless column_exists?(:geneva_drive_step_executions, :continues_from_id)
|
|
@@ -34,16 +34,11 @@ class AddSignalsSupportToGenevaDrive < ActiveRecord::Migration[7.2]
|
|
|
34
34
|
# Lifecycle: pending -> claimed -> consumed
|
|
35
35
|
t.string :state, null: false, default: "pending"
|
|
36
36
|
|
|
37
|
-
# Serialized payload
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if adapter.include?("postgresql")
|
|
43
|
-
t.jsonb :payload
|
|
44
|
-
else
|
|
45
|
-
t.json :payload
|
|
46
|
-
end
|
|
37
|
+
# Serialized payload, same flavour logic as the resumable step cursor:
|
|
38
|
+
# jsonb on PostgreSQL, json on MySQL and SQLite, plain LONGTEXT on
|
|
39
|
+
# MariaDB, which has no JSON type. See geneva_drive_json_column.
|
|
40
|
+
payload_type, payload_options = geneva_drive_json_column
|
|
41
|
+
t.column :payload, payload_type, **payload_options
|
|
47
42
|
|
|
48
43
|
t.datetime :claimed_at
|
|
49
44
|
t.datetime :consumed_at
|
|
@@ -12,7 +12,7 @@ class CreateGenevaDriveWorkflows < ActiveRecord::Migration[7.2]
|
|
|
12
12
|
|
|
13
13
|
# Polymorphic association to the hero of the workflow (optional)
|
|
14
14
|
t.string :hero_type
|
|
15
|
-
t.column :hero_id,
|
|
15
|
+
t.column :hero_id, geneva_drive_hero_key_type
|
|
16
16
|
|
|
17
17
|
# State machine
|
|
18
18
|
t.string :state, null: false, default: "ready", index: true
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# How step jobs reach your queue.
|
|
4
|
+
# - :active_job (the default) enqueues GenevaDrive::PerformStepJob through
|
|
5
|
+
# Active Job, onto whatever adapter the app uses.
|
|
6
|
+
# - :sidekiq pushes GenevaDrive::SidekiqPerformStepJob, a plain Sidekiq::Job,
|
|
7
|
+
# straight through Sidekiq::Client - for apps that write Sidekiq jobs rather
|
|
8
|
+
# than Active Job ones. Schedule GenevaDrive::SidekiqHousekeepingJob then.
|
|
9
|
+
# Supports Sidekiq 7.3 and later 7.x, and 8.x.
|
|
10
|
+
#
|
|
11
|
+
<% if GenevaDrive::SidekiqDispatcher.recommended? -%>
|
|
12
|
+
# Active Job was set up for Sidekiq when this file was generated.
|
|
13
|
+
GenevaDrive.dispatcher = :sidekiq
|
|
14
|
+
<% else -%>
|
|
15
|
+
# GenevaDrive.dispatcher = :active_job
|
|
16
|
+
<% end -%>
|
|
17
|
+
|
|
3
18
|
# How long to keep completed/canceled workflows before automatic deletion.
|
|
4
19
|
# Set to nil to disable automatic cleanup.
|
|
5
20
|
# When enabled, finished and canceled workflows older than this threshold
|
|
@@ -7,12 +22,12 @@
|
|
|
7
22
|
#
|
|
8
23
|
# GenevaDrive.delete_completed_workflows_after = 30.days
|
|
9
24
|
|
|
10
|
-
# How long a step execution can be in "
|
|
25
|
+
# How long a step execution can be in "in_progress" state before being
|
|
11
26
|
# considered stuck. This typically happens when a worker process crashes
|
|
12
27
|
# mid-execution. The HousekeepingJob will recover these based on the
|
|
13
28
|
# stuck_recovery_action setting.
|
|
14
29
|
#
|
|
15
|
-
# GenevaDrive.
|
|
30
|
+
# GenevaDrive.stuck_in_progress_threshold = 1.hour
|
|
16
31
|
|
|
17
32
|
# How long a step execution can be past its scheduled_for time while
|
|
18
33
|
# still in "scheduled" state before being considered stuck. This can
|
|
@@ -39,6 +54,22 @@
|
|
|
39
54
|
#
|
|
40
55
|
# GenevaDrive.max_cursor_size = 128.kilobytes
|
|
41
56
|
|
|
57
|
+
# Maximum size in bytes of a signal payload once serialized to JSON.
|
|
58
|
+
# A payload describes an event (an order id, an amount, a decision), not the
|
|
59
|
+
# data the step is going to work on - that belongs on the hero. Exceeding the
|
|
60
|
+
# limit raises GenevaDrive::SignalPayloadTooLargeError from Workflow#signal!,
|
|
61
|
+
# before anything is persisted. Set to nil to disable the check.
|
|
62
|
+
#
|
|
63
|
+
# GenevaDrive.max_signal_payload_size = 128.kilobytes
|
|
64
|
+
|
|
65
|
+
# How long a step execution may sit in the "waiting" state before
|
|
66
|
+
# housekeeping counts it in the geneva_drive.waiting_overdue gauge.
|
|
67
|
+
# Waiting indefinitely is legitimate, so this is not a timeout - it keeps
|
|
68
|
+
# a stalled rendezvous from going unnoticed. Alert on the overdue gauge.
|
|
69
|
+
# Set to nil to disable the gauge.
|
|
70
|
+
#
|
|
71
|
+
# GenevaDrive.waiting_visibility_threshold = 7.days
|
|
72
|
+
|
|
42
73
|
# Whether to defer job enqueueing until after the database transaction commits.
|
|
43
74
|
# In production this ensures step execution records are visible to job workers
|
|
44
75
|
# before the job runs. In test environments (the default when Rails.env.test?)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Enqueues step jobs through Active Job, onto whatever adapter the app uses.
|
|
4
|
+
# This is the default dispatcher.
|
|
5
|
+
#
|
|
6
|
+
# GenevaDrive calls the dispatcher from an `after_all_transactions_commit`
|
|
7
|
+
# callback, once the step execution row is visible to other connections.
|
|
8
|
+
# PerformStepJob opts out of Active Job's own transaction deferral for that
|
|
9
|
+
# reason - see the comment there.
|
|
10
|
+
#
|
|
11
|
+
# @see GenevaDrive.dispatcher
|
|
12
|
+
class GenevaDrive::ActiveJobDispatcher
|
|
13
|
+
# Enqueues the job that performs a step execution.
|
|
14
|
+
#
|
|
15
|
+
# @param step_execution_id [Integer, String] the step execution to perform
|
|
16
|
+
# @param job_options [Hash] validated `queue`, `priority`, `wait`, `wait_until`
|
|
17
|
+
# @param workflow_class_name [String, nil] unused here
|
|
18
|
+
# @return [String, nil] the Active Job job_id, or nil if the enqueue was aborted
|
|
19
|
+
def enqueue_step(step_execution_id, job_options, workflow_class_name: nil)
|
|
20
|
+
job = GenevaDrive::PerformStepJob.set(**job_options).perform_later(step_execution_id)
|
|
21
|
+
# perform_later returns false, not nil, when a callback aborts the enqueue
|
|
22
|
+
return nil unless job
|
|
23
|
+
job.job_id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Every key GenevaDrive::JobOptions allows maps onto Active Job's `set`.
|
|
27
|
+
#
|
|
28
|
+
# @param options [Hash{Symbol=>Object}]
|
|
29
|
+
# @param context [String]
|
|
30
|
+
# @return [void]
|
|
31
|
+
def validate_job_options!(options, context:)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Pushes step jobs straight to Sidekiq as GenevaDrive::SidekiqPerformStepJob,
|
|
4
|
+
# a plain Sidekiq::Job, for apps that use Sidekiq without Active Job.
|
|
5
|
+
#
|
|
6
|
+
# Pushes go through a fresh Sidekiq::Client, never `perform_async`, so that
|
|
7
|
+
# `Sidekiq.transactional_push!` cannot defer them a second time from inside
|
|
8
|
+
# GenevaDrive's own after-commit callback - see PerformStepJob for why that
|
|
9
|
+
# loses jobs. Client middleware still runs.
|
|
10
|
+
#
|
|
11
|
+
# @example config/initializers/geneva_drive.rb
|
|
12
|
+
# GenevaDrive.dispatcher = :sidekiq
|
|
13
|
+
#
|
|
14
|
+
# @see GenevaDrive.dispatcher
|
|
15
|
+
class GenevaDrive::SidekiqDispatcher
|
|
16
|
+
# 7.3 and later 7.x, and 8.x - the versions CI runs. 9.0 stays out until tested.
|
|
17
|
+
SUPPORTED_SIDEKIQ_VERSIONS = Gem::Requirement.new(">= 7.3", "< 9")
|
|
18
|
+
|
|
19
|
+
# @return [Boolean] whether the loaded Sidekiq is a supported version
|
|
20
|
+
def self.supported_sidekiq_version?
|
|
21
|
+
SUPPORTED_SIDEKIQ_VERSIONS.satisfied_by?(Gem::Version.new(::Sidekiq::VERSION))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Whether the install generator should write `GenevaDrive.dispatcher = :sidekiq`
|
|
25
|
+
# into the initializer it creates: a supported Sidekiq is loaded, and the app
|
|
26
|
+
# has pointed Active Job at Sidekiq. An unset adapter does not count - the
|
|
27
|
+
# generator runs in development, and production may well use another backend.
|
|
28
|
+
#
|
|
29
|
+
# @param adapter [Symbol, String, Object, nil] Active Job's configured adapter
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def self.recommended?(adapter = Rails.application&.config&.active_job&.queue_adapter)
|
|
32
|
+
return false unless defined?(::Sidekiq::Client) && supported_sidekiq_version?
|
|
33
|
+
|
|
34
|
+
adapter.to_s == "sidekiq" || adapter.class.name.to_s.end_with?("SidekiqAdapter")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param job_class [Class, nil] a Sidekiq::Job that performs step executions,
|
|
38
|
+
# GenevaDrive::SidekiqPerformStepJob by default
|
|
39
|
+
# @raise [LoadError] if Sidekiq is not loaded
|
|
40
|
+
# @raise [ArgumentError] if the loaded Sidekiq is not a supported version
|
|
41
|
+
def initialize(job_class: nil)
|
|
42
|
+
unless defined?(::Sidekiq::Client)
|
|
43
|
+
raise LoadError, "GenevaDrive::SidekiqDispatcher needs the sidekiq gem. Add it to your Gemfile."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
unless self.class.supported_sidekiq_version?
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"GenevaDrive::SidekiqDispatcher supports Sidekiq #{SUPPORTED_SIDEKIQ_VERSIONS}, " \
|
|
49
|
+
"but Sidekiq #{::Sidekiq::VERSION} is loaded. Use GenevaDrive.dispatcher = :active_job with this Sidekiq."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@job_class = job_class
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Class] the Sidekiq::Job class step jobs are pushed as
|
|
56
|
+
def job_class
|
|
57
|
+
@job_class || GenevaDrive::SidekiqPerformStepJob
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Pushes the job that performs a step execution.
|
|
61
|
+
#
|
|
62
|
+
# @param step_execution_id [Integer, String] the step execution to perform
|
|
63
|
+
# @param job_options [Hash] validated `queue`, `wait`, `wait_until`
|
|
64
|
+
# @param workflow_class_name [String, nil] added to the job's tags, for the Web UI
|
|
65
|
+
# @return [String, nil] the jid, or nil if client middleware stopped the push
|
|
66
|
+
def enqueue_step(step_execution_id, job_options, workflow_class_name: nil)
|
|
67
|
+
item = {"class" => job_class, "args" => [step_execution_id]}
|
|
68
|
+
item["queue"] = job_options[:queue].to_s if job_options[:queue]
|
|
69
|
+
|
|
70
|
+
if (at = run_at(job_options))
|
|
71
|
+
item["at"] = at.to_f
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if workflow_class_name
|
|
75
|
+
class_tags = Array(job_class.get_sidekiq_options["tags"])
|
|
76
|
+
item["tags"] = (class_tags + [workflow_class_name]).uniq
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
::Sidekiq::Client.new.push(item)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Sidekiq has queues with weights, not job priorities. Rejecting
|
|
83
|
+
# `priority:` keeps it from being ignored without anyone noticing.
|
|
84
|
+
#
|
|
85
|
+
# @param options [Hash{Symbol=>Object}]
|
|
86
|
+
# @param context [String]
|
|
87
|
+
# @return [void]
|
|
88
|
+
# @raise [GenevaDrive::StepConfigurationError] if `priority` is given
|
|
89
|
+
def validate_job_options!(options, context:)
|
|
90
|
+
return if options[:priority].nil?
|
|
91
|
+
|
|
92
|
+
raise GenevaDrive::StepConfigurationError,
|
|
93
|
+
"#{context} sets job_options[:priority], but Sidekiq has no job priority - it has queues with weights. " \
|
|
94
|
+
"Put the steps that must run first on a queue with a higher weight, e.g. `queue: :workflows_urgent`."
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Workflow already drops an overdue wait_until; this also covers callers that
|
|
100
|
+
# pass a time in the past, which would otherwise wait for the scheduled-set poller.
|
|
101
|
+
def run_at(job_options)
|
|
102
|
+
at = if job_options[:wait_until]
|
|
103
|
+
job_options[:wait_until]
|
|
104
|
+
elsif job_options[:wait]
|
|
105
|
+
Time.current + job_options[:wait]
|
|
106
|
+
end
|
|
107
|
+
return nil unless at
|
|
108
|
+
|
|
109
|
+
at = at.respond_to?(:to_time) ? at.to_time : Time.at(at.to_f)
|
|
110
|
+
(at > Time.current) ? at : nil
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/geneva_drive/engine.rb
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
1
|
class GenevaDrive::Engine < ::Rails::Engine
|
|
2
2
|
isolate_namespace GenevaDrive
|
|
3
|
+
|
|
4
|
+
# Sidekiq fires :quiet on TSTP (what Enterprise rolling restarts send first)
|
|
5
|
+
# and at the start of every shutdown. Resumable steps then checkpoint and
|
|
6
|
+
# hand off to a successor execution instead of being killed mid-iteration
|
|
7
|
+
# when the shutdown timeout runs out. Installed whichever dispatcher is in
|
|
8
|
+
# use - Active Job on the Sidekiq adapter needs it just as much.
|
|
9
|
+
initializer "geneva_drive.sidekiq_lifecycle" do
|
|
10
|
+
if defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
|
|
11
|
+
::Sidekiq.configure_server do |config|
|
|
12
|
+
config.on(:quiet) { GenevaDrive.request_exit! }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
3
16
|
end
|
|
@@ -327,6 +327,24 @@ class GenevaDrive::Executor
|
|
|
327
327
|
next nil
|
|
328
328
|
end
|
|
329
329
|
|
|
330
|
+
# A step declared with `removed_step` is a gravestone: scheduling stopped
|
|
331
|
+
# spooling executions for it, but one scheduled before the removal was
|
|
332
|
+
# deployed can still arrive here. The author of the removal has already
|
|
333
|
+
# said this step no longer needs to happen, so skip it and move on rather
|
|
334
|
+
# than pausing the workflow the way an undeclared removal does.
|
|
335
|
+
if step_def.removed?
|
|
336
|
+
logger.info("Step '#{step_execution.step_name}' is declared removed_step — skipping it and continuing")
|
|
337
|
+
|
|
338
|
+
transition_step!("skipped", outcome: "skipped")
|
|
339
|
+
# The execution may have been carrying a signal when the step was
|
|
340
|
+
# removed under it; settle it here rather than leaving it claimed
|
|
341
|
+
# forever, exactly as a skip_if skip does.
|
|
342
|
+
workflow.settle_signal_for_skipped!(step_execution)
|
|
343
|
+
transition_workflow!("ready")
|
|
344
|
+
workflow.schedule_next_step!
|
|
345
|
+
next nil
|
|
346
|
+
end
|
|
347
|
+
|
|
330
348
|
# Resumable steps need the cursor and continues_from_id columns. Fail
|
|
331
349
|
# loudly (instead of degrading) - without cursor persistence an
|
|
332
350
|
# interrupted iteration would silently restart from the beginning.
|
|
@@ -1061,11 +1079,13 @@ class GenevaDrive::Executor
|
|
|
1061
1079
|
|
|
1062
1080
|
# Checks if the job should exit (e.g., Sidekiq shutdown).
|
|
1063
1081
|
#
|
|
1082
|
+
# The process-wide flag is raised by Sidekiq's :quiet event (see the
|
|
1083
|
+
# engine). Asking Sidekiq::CLI directly does not work: the CLI has no
|
|
1084
|
+
# stopping? of its own, only its launcher does.
|
|
1085
|
+
#
|
|
1064
1086
|
# @return [Boolean]
|
|
1065
1087
|
def job_should_exit?
|
|
1066
|
-
Thread.current[:geneva_drive_should_exit] ||
|
|
1067
|
-
(defined?(Sidekiq) && Sidekiq.const_defined?(:CLI) &&
|
|
1068
|
-
Sidekiq::CLI.instance&.stopping?)
|
|
1088
|
+
Thread.current[:geneva_drive_should_exit] || GenevaDrive.exit_requested?
|
|
1069
1089
|
end
|
|
1070
1090
|
|
|
1071
1091
|
# Checks if the workflow has been externally paused or canceled.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Validation helper for
|
|
3
|
+
# Validation helper for step job option hashes passed through the workflow DSL
|
|
4
4
|
# (`set_step_job_options`, `step(job_options: ...)`, and `workflow.step_job_options=`).
|
|
5
5
|
#
|
|
6
6
|
# Active Job itself silently ignores unknown keys in `set` — a typo like
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
# priority. This helper enforces the known key set so misconfigurations surface
|
|
9
9
|
# at definition time, or at assignment time for per-instance overrides.
|
|
10
10
|
#
|
|
11
|
-
# The allowed keys mirror what `ActiveJob::Core#set` actually consumes.
|
|
11
|
+
# The allowed keys mirror what `ActiveJob::Core#set` actually consumes. The
|
|
12
|
+
# configured dispatcher then gets a say: the Sidekiq one rejects `priority`,
|
|
13
|
+
# which Sidekiq has no notion of.
|
|
12
14
|
#
|
|
13
15
|
# @api private
|
|
14
16
|
module GenevaDrive::JobOptions
|
|
@@ -25,7 +27,8 @@ module GenevaDrive::JobOptions
|
|
|
25
27
|
# (e.g. "Step 'foo'" or "MyWorkflow.step_job_options")
|
|
26
28
|
# @return [Hash{Symbol=>Object}] symbolized copy safe to pass to `.set`
|
|
27
29
|
# @raise [GenevaDrive::StepConfigurationError] if options can't be coerced to
|
|
28
|
-
# a Hash, contains unknown keys,
|
|
30
|
+
# a Hash, contains unknown keys, has values of the wrong type, or uses a
|
|
31
|
+
# key the configured dispatcher cannot honour
|
|
29
32
|
def self.validate!(options, context:)
|
|
30
33
|
hash = begin
|
|
31
34
|
options.to_h
|
|
@@ -44,6 +47,7 @@ module GenevaDrive::JobOptions
|
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
symbolized.each { |k, v| validate_value!(k, v, context) }
|
|
50
|
+
GenevaDrive.dispatcher.validate_job_options!(symbolized, context: context)
|
|
47
51
|
symbolized
|
|
48
52
|
end
|
|
49
53
|
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
# PerformStepJob.perform_now(step_execution.id)
|
|
14
14
|
#
|
|
15
15
|
class GenevaDrive::PerformStepJob < ActiveJob::Base
|
|
16
|
+
include GenevaDrive::StepExecutionLookup
|
|
17
|
+
|
|
16
18
|
# == CRITICAL: Prevent double-deferral of job enqueueing ==
|
|
17
19
|
#
|
|
18
20
|
# GenevaDrive already defers job enqueueing using `ActiveRecord.after_all_transactions_commit`
|
|
@@ -83,39 +85,4 @@ class GenevaDrive::PerformStepJob < ActiveJob::Base
|
|
|
83
85
|
logger.debug("PerformStepJob starting execution for step #{step_execution_id}")
|
|
84
86
|
GenevaDrive::Executor.execute!(step_execution, logger: logger)
|
|
85
87
|
end
|
|
86
|
-
|
|
87
|
-
private
|
|
88
|
-
|
|
89
|
-
# Retries the step execution lookup briefly to handle the (unlikely) case where
|
|
90
|
-
# the job is picked up before the step execution's transaction is visible to this
|
|
91
|
-
# database connection.
|
|
92
|
-
#
|
|
93
|
-
# This is a defense-in-depth measure. The primary protection against visibility
|
|
94
|
-
# issues is the combination of:
|
|
95
|
-
# 1. `after_all_transactions_commit` in Workflow#create_step_execution, which ensures
|
|
96
|
-
# `perform_later` is only called after the step execution row is committed
|
|
97
|
-
# 2. `enqueue_after_transaction_commit = :never` (above), which ensures the queue
|
|
98
|
-
# adapter INSERT happens synchronously, not deferred into a dead callback
|
|
99
|
-
#
|
|
100
|
-
# However, this retry handles additional edge cases:
|
|
101
|
-
# - Database replication lag (read replica hasn't caught up with the writer)
|
|
102
|
-
# - Future code paths that might call perform_later inside a transaction
|
|
103
|
-
# - Any other visibility delay between the commit and the job worker's read
|
|
104
|
-
#
|
|
105
|
-
# The 500ms total window (5 x 100ms) is generous enough to handle typical
|
|
106
|
-
# replication lag while being short enough to fail fast if the record truly
|
|
107
|
-
# doesn't exist (e.g. it was already deleted by cleanup).
|
|
108
|
-
#
|
|
109
|
-
# @param id [Integer, String] the step execution ID to look up
|
|
110
|
-
# @param attempts [Integer] maximum number of lookup attempts
|
|
111
|
-
# @param delay [Float] seconds to sleep between attempts
|
|
112
|
-
# @return [GenevaDrive::StepExecution, nil] the step execution or nil if not found
|
|
113
|
-
def find_step_execution(id, attempts: 5, delay: 0.1)
|
|
114
|
-
attempts.times do |i|
|
|
115
|
-
record = GenevaDrive::StepExecution.find_by(id: id)
|
|
116
|
-
return record if record
|
|
117
|
-
sleep(delay) if i < attempts - 1
|
|
118
|
-
end
|
|
119
|
-
nil
|
|
120
|
-
end
|
|
121
88
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sidekiq"
|
|
4
|
+
|
|
5
|
+
# Sidekiq job that runs GenevaDrive's housekeeping, for apps that schedule
|
|
6
|
+
# periodic work with Sidekiq Enterprise or sidekiq-cron rather than an
|
|
7
|
+
# Active Job backend. It runs the same code as HousekeepingJob, in-process.
|
|
8
|
+
#
|
|
9
|
+
# @example Sidekiq Enterprise periodic jobs
|
|
10
|
+
# Sidekiq.configure_server do |config|
|
|
11
|
+
# config.periodic do |mgr|
|
|
12
|
+
# mgr.register("*/15 * * * *", "GenevaDrive::SidekiqHousekeepingJob")
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example sidekiq-cron
|
|
17
|
+
# Sidekiq::Cron::Job.create(
|
|
18
|
+
# name: "GenevaDrive housekeeping",
|
|
19
|
+
# cron: "*/15 * * * *",
|
|
20
|
+
# class: "GenevaDrive::SidekiqHousekeepingJob"
|
|
21
|
+
# )
|
|
22
|
+
class GenevaDrive::SidekiqHousekeepingJob
|
|
23
|
+
include ::Sidekiq::Job
|
|
24
|
+
|
|
25
|
+
# The next scheduled run is the retry.
|
|
26
|
+
sidekiq_options queue: "default", retry: false, tags: ["geneva_drive"]
|
|
27
|
+
|
|
28
|
+
# @return [Hash] summary of actions taken, as returned by HousekeepingJob
|
|
29
|
+
def perform
|
|
30
|
+
GenevaDrive::HousekeepingJob.perform_now
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sidekiq"
|
|
4
|
+
|
|
5
|
+
# Sidekiq job that executes a scheduled step execution - the native
|
|
6
|
+
# counterpart of PerformStepJob, pushed by GenevaDrive::SidekiqDispatcher.
|
|
7
|
+
#
|
|
8
|
+
# Retries are off because exception policies decide reattempts, each as a new
|
|
9
|
+
# step execution. The step's exception is still raised, so error handlers report it.
|
|
10
|
+
class GenevaDrive::SidekiqPerformStepJob
|
|
11
|
+
include ::Sidekiq::Job
|
|
12
|
+
include GenevaDrive::StepExecutionLookup
|
|
13
|
+
|
|
14
|
+
sidekiq_options queue: "default", retry: false, tags: ["geneva_drive"]
|
|
15
|
+
|
|
16
|
+
# @param step_execution_id [Integer, String] the ID of the step execution
|
|
17
|
+
# @return [void]
|
|
18
|
+
def perform(step_execution_id)
|
|
19
|
+
step_execution = find_step_execution(step_execution_id)
|
|
20
|
+
|
|
21
|
+
unless step_execution
|
|
22
|
+
logger.warn("StepExecution #{step_execution_id} not found, skipping")
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# So that Sidekiq's own log lines for this job carry the workflow and step
|
|
27
|
+
::Sidekiq::Context.with(
|
|
28
|
+
geneva_drive_workflow_id: step_execution.workflow_id,
|
|
29
|
+
geneva_drive_step: step_execution.step_name,
|
|
30
|
+
geneva_drive_step_execution_id: step_execution.id
|
|
31
|
+
) do
|
|
32
|
+
GenevaDrive::Executor.execute!(step_execution)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Finds the step execution a job was enqueued for, retrying briefly when it is
|
|
4
|
+
# not visible yet. Shared by PerformStepJob and SidekiqPerformStepJob.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module GenevaDrive::StepExecutionLookup
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# Retries the step execution lookup briefly to handle the (unlikely) case where
|
|
11
|
+
# the job is picked up before the step execution's transaction is visible to this
|
|
12
|
+
# database connection.
|
|
13
|
+
#
|
|
14
|
+
# This is a defense-in-depth measure. The primary protection against visibility
|
|
15
|
+
# issues is the combination of:
|
|
16
|
+
# 1. `after_all_transactions_commit` in Workflow#create_step_execution, which ensures
|
|
17
|
+
# the dispatcher is only called after the step execution row is committed
|
|
18
|
+
# 2. Both dispatchers pushing synchronously from that callback, rather than
|
|
19
|
+
# deferring a second time into a callback that may never fire
|
|
20
|
+
#
|
|
21
|
+
# However, this retry handles additional edge cases:
|
|
22
|
+
# - Database replication lag (read replica hasn't caught up with the writer)
|
|
23
|
+
# - Future code paths that might enqueue inside a transaction
|
|
24
|
+
# - Any other visibility delay between the commit and the job worker's read
|
|
25
|
+
#
|
|
26
|
+
# The 500ms total window (5 x 100ms) is generous enough to handle typical
|
|
27
|
+
# replication lag while being short enough to fail fast if the record truly
|
|
28
|
+
# doesn't exist (e.g. it was already deleted by cleanup).
|
|
29
|
+
#
|
|
30
|
+
# @param id [Integer, String] the step execution ID to look up
|
|
31
|
+
# @param attempts [Integer] maximum number of lookup attempts
|
|
32
|
+
# @param delay [Float] seconds to sleep between attempts
|
|
33
|
+
# @return [GenevaDrive::StepExecution, nil] the step execution or nil if not found
|
|
34
|
+
def find_step_execution(id, attempts: 5, delay: 0.1)
|
|
35
|
+
attempts.times do |i|
|
|
36
|
+
record = GenevaDrive::StepExecution.find_by(id: id)
|
|
37
|
+
return record if record
|
|
38
|
+
sleep(delay) if i < attempts - 1
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|