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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/MANUAL.md +299 -5
  4. data/README.md +14 -1
  5. data/Rakefile +28 -1
  6. data/bin/matrix +76 -0
  7. data/bin/md2html +114 -0
  8. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
  9. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +5 -10
  10. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +1 -1
  11. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +33 -2
  12. data/lib/geneva_drive/dispatchers/active_job_dispatcher.rb +33 -0
  13. data/lib/geneva_drive/dispatchers/sidekiq_dispatcher.rb +112 -0
  14. data/lib/geneva_drive/engine.rb +13 -0
  15. data/lib/geneva_drive/executor.rb +23 -3
  16. data/lib/geneva_drive/job_options.rb +7 -3
  17. data/lib/geneva_drive/jobs/perform_step_job.rb +2 -35
  18. data/lib/geneva_drive/jobs/sidekiq_housekeeping_job.rb +32 -0
  19. data/lib/geneva_drive/jobs/sidekiq_perform_step_job.rb +35 -0
  20. data/lib/geneva_drive/jobs/step_execution_lookup.rb +42 -0
  21. data/lib/geneva_drive/json_column.rb +72 -0
  22. data/lib/geneva_drive/migration_helpers.rb +60 -3
  23. data/lib/geneva_drive/removed_step_definition.rb +34 -0
  24. data/lib/geneva_drive/signal.rb +4 -2
  25. data/lib/geneva_drive/step_collection.rb +31 -7
  26. data/lib/geneva_drive/step_definition.rb +9 -0
  27. data/lib/geneva_drive/step_execution.rb +3 -2
  28. data/lib/geneva_drive/version.rb +1 -1
  29. data/lib/geneva_drive/workflow.rb +200 -39
  30. data/lib/geneva_drive.rb +65 -0
  31. data/test/dummy/config/database.yml +40 -5
  32. data/test/dummy/config/initializers/geneva_drive.rb +78 -0
  33. data/test/dummy/db/mysql/schema-8.1.rb +95 -0
  34. data/test/dummy/db/postgres/schema-8.1.rb +99 -0
  35. data/test/dummy/db/sqlite/schema-7.2.rb +96 -0
  36. data/test/dummy/db/sqlite/schema-8.0.rb +96 -0
  37. data/test/dummy/db/sqlite/schema-8.1.rb +96 -0
  38. data/test/dummy/db/sqlite/test.sqlite3 +0 -0
  39. data/test/dummy/db/sqlite/test.sqlite3-0 +0 -0
  40. data/test/dummy/db/sqlite/test.sqlite3-1 +0 -0
  41. data/test/dummy/db/sqlite/test.sqlite3-2 +0 -0
  42. data/test/dummy/db/sqlite/test.sqlite3-3 +0 -0
  43. data/test/dummy/db/sqlite/test.sqlite3-4 +0 -0
  44. data/test/dummy/db/sqlite/test.sqlite3-5 +0 -0
  45. data/test/dummy/db/sqlite/test.sqlite3-6 +0 -0
  46. data/test/dummy/db/sqlite/test.sqlite3-7 +0 -0
  47. data/test/dummy/db/sqlite/test.sqlite3-8 +0 -0
  48. data/test/dummy/db/sqlite/test.sqlite3-9 +0 -0
  49. data/test/dummy/db/sqlite/test.sqlite3_0 +0 -0
  50. data/test/dummy/db/sqlite/test.sqlite3_1 +0 -0
  51. data/test/dummy/db/sqlite/test.sqlite3_2 +0 -0
  52. data/test/dummy/db/sqlite/test.sqlite3_3 +0 -0
  53. data/test/dummy/db/sqlite/test.sqlite3_4 +0 -0
  54. data/test/dummy/db/sqlite/test.sqlite3_5 +0 -0
  55. data/test/dummy/db/sqlite/test.sqlite3_6 +0 -0
  56. data/test/dummy/db/sqlite/test.sqlite3_7 +0 -0
  57. data/test/dummy/db/sqlite/test.sqlite3_8 +0 -0
  58. data/test/dummy/db/sqlite/test.sqlite3_9 +0 -0
  59. data/test/dummy/db/sqlite3/schema-7.2.rb +96 -0
  60. data/test/dummy/db/sqlite3/schema-8.1.rb +96 -0
  61. data/test/dummy/db/sqlite3/test.sqlite3 +0 -0
  62. data/test/dummy/db/sqlite3/test.sqlite3_0 +0 -0
  63. data/test/dummy/db/sqlite3/test.sqlite3_1 +0 -0
  64. data/test/dummy/db/sqlite3/test.sqlite3_2 +0 -0
  65. data/test/dummy/db/sqlite3/test.sqlite3_3 +0 -0
  66. data/test/dummy/db/sqlite3/test.sqlite3_4 +0 -0
  67. data/test/dummy/db/sqlite3/test.sqlite3_5 +0 -0
  68. data/test/dummy/db/sqlite3/test.sqlite3_6 +0 -0
  69. data/test/dummy/db/sqlite3/test.sqlite3_7 +0 -0
  70. data/test/dummy/db/sqlite3/test.sqlite3_8 +0 -0
  71. data/test/dummy/db/sqlite3/test.sqlite3_9 +0 -0
  72. data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
  73. data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
  74. data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
  75. data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
  76. data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
  77. data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
  78. data/test/dummy_install/db/schema.rb +75 -0
  79. data/test/dummy_install/log/test.log +1852 -0
  80. data/test/generators/install_generator_test.rb +56 -0
  81. data/test/geneva_drive_test.rb +10 -0
  82. data/test/migration_helpers_test.rb +62 -0
  83. data/test/sidekiq/sidekiq_dispatcher_test.rb +322 -0
  84. data/test/sidekiq/sidekiq_redis_test.rb +115 -0
  85. data/test/test_helper.rb +51 -13
  86. data/test/workflow/pause_resume_test.rb +15 -0
  87. data/test/workflow/removed_step_test.rb +294 -0
  88. data/test/workflow/resume_at_test.rb +170 -0
  89. 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. Use database-native JSON type:
9
- # - PostgreSQL: jsonb (indexed, efficient, supports containment queries)
10
- # - MySQL 5.7+: json (native validation and storage)
11
- # - SQLite: json (Rails handles as TEXT with serialization)
12
- if connection.adapter_name.downcase.include?("postgresql")
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. Use the database-native JSON type, same flavor
38
- # logic as the resumable step cursor:
39
- # - PostgreSQL: jsonb
40
- # - MySQL 5.7+: json
41
- # - SQLite: json (Rails handles as TEXT with serialization)
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, geneva_drive_key_type
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 "executing" state before being
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.stuck_executing_threshold = 1.hour
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
@@ -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 Active Job option hashes passed through the workflow DSL
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, or has values of the wrong type
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