geneva_drive 0.4.0 → 0.6.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/MANUAL.md +1037 -355
  4. data/README.md +3 -1
  5. data/Rakefile +13 -2
  6. data/bin/md2html +96 -19
  7. data/lib/generators/geneva_drive/install/install_generator.rb +25 -0
  8. data/lib/generators/geneva_drive/install/templates/add_metadata_to_step_executions.rb +17 -0
  9. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  10. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  11. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  12. data/lib/generators/geneva_drive/install/templates/allow_null_hero_on_workflows.rb +66 -0
  13. data/lib/generators/geneva_drive/install/templates/create_step_executions_migration.rb +25 -5
  14. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +14 -3
  15. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +22 -2
  16. data/lib/geneva_drive/combined_exception_policy.rb +106 -0
  17. data/lib/geneva_drive/exception_policy.rb +354 -0
  18. data/lib/geneva_drive/executor.rb +430 -148
  19. data/lib/geneva_drive/flow_control.rb +82 -11
  20. data/lib/geneva_drive/iterable_step.rb +199 -0
  21. data/lib/geneva_drive/job_options.rb +70 -0
  22. data/lib/geneva_drive/jobs/housekeeping_job.rb +174 -52
  23. data/lib/geneva_drive/jobs/perform_step_job.rb +91 -3
  24. data/lib/geneva_drive/migration_helpers.rb +55 -20
  25. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  26. data/lib/geneva_drive/step_definition.rb +160 -40
  27. data/lib/geneva_drive/step_execution/metadata_accessor.rb +121 -0
  28. data/lib/geneva_drive/step_execution.rb +124 -2
  29. data/lib/geneva_drive/test_helpers.rb +123 -4
  30. data/lib/geneva_drive/version.rb +1 -1
  31. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  32. data/lib/geneva_drive/workflow.rb +485 -73
  33. data/lib/geneva_drive.rb +100 -2
  34. data/test/dsl/class_level_on_exception_test.rb +200 -0
  35. data/test/dsl/exception_policy_test.rb +331 -0
  36. data/test/dsl/step_definition_test.rb +126 -2
  37. data/test/dsl/step_level_exception_policy_test.rb +143 -0
  38. data/test/dummy/config/storage.yml +7 -0
  39. data/test/dummy_install/config/storage.yml +7 -0
  40. data/test/jobs/housekeeping_job_test.rb +451 -5
  41. data/test/jobs/perform_step_job_double_deferral_test.rb +268 -0
  42. data/test/jobs/perform_step_job_test.rb +25 -2
  43. data/test/migration_helpers_test.rb +137 -0
  44. data/test/step_execution/metadata_accessor_test.rb +147 -0
  45. data/test/test_helper.rb +37 -0
  46. data/test/test_helper_test.rb +281 -0
  47. data/test/workflow/class_level_on_exception_integration_test.rb +313 -0
  48. data/test/workflow/composable_exception_policy_test.rb +231 -0
  49. data/test/workflow/cursor_size_limit_test.rb +75 -0
  50. data/test/workflow/executor_test.rb +100 -4
  51. data/test/workflow/external_cancel_test.rb +245 -0
  52. data/test/workflow/flow_control_test.rb +4 -3
  53. data/test/workflow/instrumentation_test.rb +28 -0
  54. data/test/workflow/max_reattempts_test.rb +51 -0
  55. data/test/workflow/pause_resume_test.rb +574 -0
  56. data/test/workflow/reattempt_on_exception_integration_test.rb +68 -0
  57. data/test/workflow/resumable_step_integration_test.rb +341 -0
  58. data/test/workflow/resumable_step_test.rb +615 -0
  59. data/test/workflow/resumable_without_migration_test.rb +103 -0
  60. data/test/workflow/resume_and_skip_test.rb +57 -41
  61. data/test/workflow/with_inline_enqueue_test.rb +137 -0
  62. data/test/workflow/workflow_test.rb +117 -2
  63. metadata +47 -61
  64. data/test/dummy/db/migrate/20241217000001_create_geneva_drive_workflows.rb +0 -68
  65. data/test/dummy/db/migrate/20241217000002_create_geneva_drive_step_executions.rb +0 -90
  66. data/test/dummy/db/migrate/20241217000003_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  67. data/test/dummy/db/migrate/20241217000004_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  68. data/test/dummy/db/schema.rb +0 -72
  69. data/test/dummy/log/development.log +0 -112
  70. data/test/dummy/log/test.log +0 -4
  71. data/test/dummy/tmp/local_secret.txt +0 -1
  72. data/test/dummy_install/Rakefile +0 -6
  73. data/test/dummy_install/app/assets/stylesheets/application.css +0 -15
  74. data/test/dummy_install/app/controllers/application_controller.rb +0 -4
  75. data/test/dummy_install/app/helpers/application_helper.rb +0 -2
  76. data/test/dummy_install/app/jobs/application_job.rb +0 -7
  77. data/test/dummy_install/app/models/application_record.rb +0 -3
  78. data/test/dummy_install/app/models/user.rb +0 -9
  79. data/test/dummy_install/app/views/layouts/application.html.erb +0 -27
  80. data/test/dummy_install/app/views/layouts/mailer.html.erb +0 -13
  81. data/test/dummy_install/app/views/layouts/mailer.text.erb +0 -1
  82. data/test/dummy_install/app/views/pwa/manifest.json.erb +0 -22
  83. data/test/dummy_install/app/views/pwa/service-worker.js +0 -26
  84. data/test/dummy_install/bin/dev +0 -2
  85. data/test/dummy_install/bin/rails +0 -4
  86. data/test/dummy_install/bin/rake +0 -4
  87. data/test/dummy_install/bin/setup +0 -34
  88. data/test/dummy_install/config/application.rb +0 -29
  89. data/test/dummy_install/config/boot.rb +0 -5
  90. data/test/dummy_install/config/cable.yml +0 -10
  91. data/test/dummy_install/config/database.yml +0 -13
  92. data/test/dummy_install/config/environment.rb +0 -5
  93. data/test/dummy_install/config/environments/development.rb +0 -51
  94. data/test/dummy_install/config/environments/production.rb +0 -70
  95. data/test/dummy_install/config/environments/test.rb +0 -39
  96. data/test/dummy_install/config/initializers/assets.rb +0 -7
  97. data/test/dummy_install/config/initializers/content_security_policy.rb +0 -25
  98. data/test/dummy_install/config/initializers/filter_parameter_logging.rb +0 -8
  99. data/test/dummy_install/config/initializers/geneva_drive.rb +0 -76
  100. data/test/dummy_install/config/initializers/inflections.rb +0 -16
  101. data/test/dummy_install/config/locales/en.yml +0 -31
  102. data/test/dummy_install/config/puma.rb +0 -38
  103. data/test/dummy_install/config/routes.rb +0 -3
  104. data/test/dummy_install/config.ru +0 -6
  105. data/test/dummy_install/db/migrate/20241217000000_create_users.rb +0 -12
  106. data/test/dummy_install/db/migrate/20260128104738_create_geneva_drive_workflows.rb +0 -74
  107. data/test/dummy_install/db/migrate/20260128104739_create_geneva_drive_step_executions.rb +0 -97
  108. data/test/dummy_install/db/migrate/20260128104740_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  109. data/test/dummy_install/db/migrate/20260128104741_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  110. data/test/dummy_install/db/migrate/20260128104742_add_resumable_step_support_to_geneva_drive_step_executions.rb +0 -25
  111. data/test/dummy_install/db/schema.rb +0 -76
  112. data/test/dummy_install/log/test.log +0 -4031
  113. data/test/dummy_install/public/400.html +0 -114
  114. data/test/dummy_install/public/404.html +0 -114
  115. data/test/dummy_install/public/406-unsupported-browser.html +0 -114
  116. data/test/dummy_install/public/422.html +0 -114
  117. data/test/dummy_install/public/500.html +0 -114
  118. data/test/dummy_install/public/icon.png +0 -0
  119. data/test/dummy_install/public/icon.svg +0 -3
  120. data/test/dummy_install/tmp/local_secret.txt +0 -1
  121. data/test/generators/install_generator_test.rb +0 -94
data/README.md CHANGED
@@ -164,12 +164,14 @@ class PremiumWorkflow < BaseWorkflow
164
164
  cancel_if { hero.subscription_expired? }
165
165
  set_step_job_options queue: :premium, priority: 10
166
166
 
167
- step :premium_feature do
167
+ step :premium_feature, job_options: {priority: -1} do
168
168
  # Inherits cancel_if from parent
169
169
  end
170
170
  end
171
171
  ```
172
172
 
173
+ Step job options override workflow defaults when the step is initially enqueued, reattempted, or resumed. Options are passed directly to Active Job's `set` method.
174
+
173
175
  ### Hero Deletion Handling
174
176
 
175
177
  By default, workflows cancel if their hero is deleted:
data/Rakefile CHANGED
@@ -1,6 +1,17 @@
1
1
  require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+
4
+ require "rake/testtask"
5
+ require "standard/rake"
6
+ require "yard"
7
+
8
+ YARD::Rake::YardocTask.new("yard")
9
+
10
+ task doc: [:yard] do
11
+ outfile = "doc/MANUAL.html"
12
+ `bin/md2html MANUAL.md #{outfile}`
13
+ warn "#{outfile} written"
14
+ end
2
15
 
3
16
  APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
17
  load "rails/tasks/engine.rake"
5
-
6
- require "bundler/gem_tasks"
data/bin/md2html CHANGED
@@ -7,9 +7,11 @@ require "bundler/inline"
7
7
  gemfile do
8
8
  source "https://rubygems.org"
9
9
  gem "redcarpet"
10
+ gem "nokogiri"
10
11
  end
11
12
 
12
13
  require "redcarpet"
14
+ require "nokogiri"
13
15
  require "cgi"
14
16
  require "erb"
15
17
 
@@ -40,7 +42,24 @@ class SyntaxHighlightingHTML < Redcarpet::Render::HTML
40
42
  end
41
43
  end
42
44
 
43
- def html_template(title, content)
45
+ def build_toc(html_body)
46
+ doc = Nokogiri::HTML::DocumentFragment.parse(html_body)
47
+ headings = doc.css("h1, h2")
48
+ return "" if headings.empty?
49
+
50
+ toc = +%(<nav class="toc-pane"><ul>\n)
51
+ headings.each do |h|
52
+ id = h["id"]
53
+ next unless id
54
+ level = h.name # "h1" or "h2"
55
+ css_class = (level == "h2") ? ' class="toc-h2"' : ""
56
+ toc << %(<li#{css_class}><a href="##{CGI.escapeHTML(id)}">#{CGI.escapeHTML(h.text)}</a></li>\n)
57
+ end
58
+ toc << %(</ul></nav>\n)
59
+ toc
60
+ end
61
+
62
+ def html_template(title, content, toc: "")
44
63
  <<~HTML
45
64
  <!DOCTYPE html>
46
65
  <html lang="en">
@@ -63,21 +82,73 @@ def html_template(title, content)
63
82
  --table-border: #dfe2e5;
64
83
  --blockquote-color: #6a737d;
65
84
  --blockquote-border: #dfe2e5;
85
+ --toc-width: 280px;
66
86
  }
67
87
 
68
88
  * {
69
89
  box-sizing: border-box;
70
90
  }
71
91
 
72
- body {
92
+ html, body {
93
+ margin: 0;
94
+ padding: 0;
73
95
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
74
96
  font-size: 16px;
75
97
  line-height: 1.6;
98
+ color: var(--text-color);
99
+ background-color: var(--bg-color);
100
+ }
101
+
102
+ /* TOC sidebar — fixed, scrolls independently */
103
+ .toc-pane {
104
+ position: fixed;
105
+ top: 0;
106
+ left: 0;
107
+ width: var(--toc-width);
108
+ height: 100vh;
109
+ overflow-y: auto;
110
+ border-right: 1px solid var(--border-color);
111
+ padding: 2rem 1.25rem;
112
+ font-size: 13px;
113
+ line-height: 1.5;
114
+ background-color: var(--bg-color);
115
+ }
116
+
117
+ .toc-pane ul {
118
+ list-style: none;
119
+ padding: 0;
120
+ margin: 0;
121
+ }
122
+
123
+ .toc-pane li {
124
+ margin: 0.25em 0;
125
+ }
126
+
127
+ .toc-pane li.toc-h2 {
128
+ padding-left: 1em;
129
+ }
130
+
131
+ .toc-pane a {
132
+ color: var(--link-color);
133
+ text-decoration: underline;
134
+ }
135
+
136
+ .toc-pane a:hover {
137
+ color: #0550ae;
138
+ }
139
+
140
+ /* Body scrolls normally — browser scrollbar at window edge */
141
+ body.has-toc {
142
+ margin-left: var(--toc-width);
143
+ padding: 2rem 3rem;
144
+ max-width: calc(960px + var(--toc-width));
145
+ }
146
+
147
+ /* Without TOC, body is a simple centered column */
148
+ body:not(.has-toc) {
76
149
  max-width: 900px;
77
150
  margin: 0 auto;
78
151
  padding: 2rem;
79
- color: var(--text-color);
80
- background-color: var(--bg-color);
81
152
  }
82
153
 
83
154
  /* Headings */
@@ -241,12 +312,7 @@ def html_template(title, content)
241
312
  height: auto;
242
313
  }
243
314
 
244
- /* Print footer element - not needed with @page margin boxes */
245
- .print-footer {
246
- display: none;
247
- }
248
-
249
- /* Print styles - using Chrome 131+ @page margin boxes */
315
+ /* Print styles */
250
316
  @media print {
251
317
  @page {
252
318
  margin: 1.5cm;
@@ -265,7 +331,12 @@ def html_template(title, content)
265
331
  }
266
332
  }
267
333
 
268
- body {
334
+ .toc-pane {
335
+ display: none;
336
+ }
337
+
338
+ body, body.has-toc {
339
+ margin-left: 0;
269
340
  max-width: none;
270
341
  padding: 0;
271
342
  font-size: 11pt;
@@ -296,8 +367,8 @@ def html_template(title, content)
296
367
  }
297
368
  </style>
298
369
  </head>
299
- <body>
300
- #{content}
370
+ <body#{' class="has-toc"' unless toc.empty?}>
371
+ #{toc}#{content}
301
372
 
302
373
  <!-- Prism.js for syntax highlighting -->
303
374
  <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
@@ -338,7 +409,7 @@ def html_template(title, content)
338
409
  HTML
339
410
  end
340
411
 
341
- def convert_markdown_to_html(input_path, output_path = nil)
412
+ def convert_markdown_to_html(input_path, output_path = nil, toc: true)
342
413
  unless File.exist?(input_path)
343
414
  abort "Error: File not found: #{input_path}"
344
415
  end
@@ -373,7 +444,8 @@ def convert_markdown_to_html(input_path, output_path = nil)
373
444
  })
374
445
 
375
446
  html_body = markdown.render(markdown_content)
376
- html_document = html_template(title, html_body)
447
+ toc_html = toc ? build_toc(html_body) : ""
448
+ html_document = html_template(title, html_body, toc: toc_html)
377
449
 
378
450
  File.write(output_path, html_document)
379
451
  puts "Created: #{output_path}"
@@ -382,7 +454,7 @@ def convert_markdown_to_html(input_path, output_path = nil)
382
454
  end
383
455
 
384
456
  if __FILE__ == $0
385
- options = {open: true}
457
+ options = {open: false, toc: true}
386
458
 
387
459
  parser = OptionParser.new do |opts|
388
460
  opts.banner = "Usage: #{$0} [options] INPUT.md [OUTPUT.html]"
@@ -390,14 +462,19 @@ if __FILE__ == $0
390
462
  opts.separator "Converts a GitHub-Flavored Markdown file to styled HTML with:"
391
463
  opts.separator " - Prism.js syntax highlighting"
392
464
  opts.separator " - Mermaid.js diagram rendering"
465
+ opts.separator " - Table of contents sidebar"
393
466
  opts.separator " - Print-optimized CSS with page numbers"
394
467
  opts.separator ""
395
468
  opts.separator "If OUTPUT is not specified, uses INPUT filename with .html extension."
396
469
  opts.separator ""
397
470
  opts.separator "Options:"
398
471
 
399
- opts.on("--no-open", "Don't open the result in the browser") do
400
- options[:open] = false
472
+ opts.on("--no-toc", "Don't generate table of contents sidebar") do
473
+ options[:toc] = false
474
+ end
475
+
476
+ opts.on("--open", "Open the result in the browser") do
477
+ options[:open] = true
401
478
  end
402
479
 
403
480
  opts.on("-h", "--help", "Show this help message") do
@@ -416,7 +493,7 @@ if __FILE__ == $0
416
493
  input_path = ARGV[0]
417
494
  output_path = ARGV[1]
418
495
 
419
- result_path = convert_markdown_to_html(input_path, output_path)
496
+ result_path = convert_markdown_to_html(input_path, output_path, toc: options[:toc])
420
497
 
421
498
  # Open in browser if on macOS and not disabled
422
499
  if options[:open] && RUBY_PLATFORM =~ /darwin/
@@ -40,6 +40,31 @@ module GenevaDrive
40
40
  "add_error_class_name_to_step_executions.rb",
41
41
  "db/migrate/add_error_class_name_to_geneva_drive_step_executions.rb"
42
42
  )
43
+
44
+ migration_template(
45
+ "add_metadata_to_step_executions.rb",
46
+ "db/migrate/add_metadata_to_geneva_drive_step_executions.rb"
47
+ )
48
+
49
+ migration_template(
50
+ "add_metadata_to_workflows.rb",
51
+ "db/migrate/add_metadata_to_geneva_drive_workflows.rb"
52
+ )
53
+
54
+ migration_template(
55
+ "allow_null_hero_on_workflows.rb",
56
+ "db/migrate/allow_null_hero_on_geneva_drive_workflows.rb"
57
+ )
58
+
59
+ migration_template(
60
+ "add_started_at_index_to_step_executions.rb",
61
+ "db/migrate/add_started_at_index_to_geneva_drive_step_executions.rb"
62
+ )
63
+
64
+ migration_template(
65
+ "add_resumable_step_support.rb",
66
+ "db/migrate/add_resumable_step_support_to_geneva_drive_step_executions.rb"
67
+ )
43
68
  end
44
69
 
45
70
  # Creates the initializer file.
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddMetadataToGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
4
+ def change
5
+ return if column_exists?(:geneva_drive_step_executions, :metadata)
6
+
7
+ adapter = connection.adapter_name.downcase
8
+
9
+ if adapter.include?("postgresql")
10
+ add_column :geneva_drive_step_executions, :metadata, :jsonb
11
+ elsif adapter.include?("mysql")
12
+ add_column :geneva_drive_step_executions, :metadata, :text, limit: 4_294_967_295
13
+ else
14
+ add_column :geneva_drive_step_executions, :metadata, :text
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddMetadataToGenevaDriveWorkflows < ActiveRecord::Migration[7.2]
4
+ def change
5
+ return if column_exists?(:geneva_drive_workflows, :metadata)
6
+
7
+ adapter = connection.adapter_name.downcase
8
+
9
+ if adapter.include?("postgresql")
10
+ add_column :geneva_drive_workflows, :metadata, :jsonb
11
+ elsif adapter.include?("mysql")
12
+ add_column :geneva_drive_workflows, :metadata, :text, limit: 4_294_967_295
13
+ else
14
+ add_column :geneva_drive_workflows, :metadata, :text
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddResumableStepSupportToGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
4
+ include GenevaDrive::MigrationHelpers
5
+
6
+ def change
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
17
+ end
18
+
19
+ unless column_exists?(:geneva_drive_step_executions, :continues_from_id)
20
+ # Link successor executions to their predecessor, chaining the execution
21
+ # records of a resumable step. Match the primary key type (bigint or uuid)
22
+ # of the step_executions table.
23
+ # No foreign key constraint - SQLite rewrites the table on add_foreign_key,
24
+ # which can destroy data.
25
+ add_column :geneva_drive_step_executions, :continues_from_id, geneva_drive_key_type
26
+ add_index :geneva_drive_step_executions, :continues_from_id
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddStartedAtIndexToGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
4
+ # Partial index scoped to state = 'in_progress'. HousekeepingJob's
5
+ # recover_stuck_in_progress! filters `state = 'in_progress' AND
6
+ # started_at < cutoff LIMIT N`; without this the planner walks the whole
7
+ # in-progress set looking for old rows and trips the caller's
8
+ # statement_timeout on large / mostly-fresh workloads.
9
+ INDEX_NAME = :index_geneva_drive_step_executions_in_progress_started_at
10
+
11
+ disable_ddl_transaction!
12
+
13
+ def up
14
+ return if index_name_exists?(:geneva_drive_step_executions, INDEX_NAME)
15
+
16
+ adapter = connection.adapter_name.downcase
17
+
18
+ if adapter.include?("postgresql")
19
+ add_index :geneva_drive_step_executions, :started_at,
20
+ name: INDEX_NAME,
21
+ where: "state = 'in_progress'",
22
+ algorithm: :concurrently
23
+ elsif adapter.include?("sqlite")
24
+ # SQLite supports partial indexes but has no CONCURRENTLY.
25
+ add_index :geneva_drive_step_executions, :started_at,
26
+ name: INDEX_NAME,
27
+ where: "state = 'in_progress'"
28
+ else
29
+ # MySQL does not support partial indexes; fall back to a plain index.
30
+ # Composite (state, started_at) so the planner can seek by state first.
31
+ add_index :geneva_drive_step_executions, [:state, :started_at],
32
+ name: INDEX_NAME
33
+ end
34
+ end
35
+
36
+ def down
37
+ return unless index_name_exists?(:geneva_drive_step_executions, INDEX_NAME)
38
+
39
+ adapter = connection.adapter_name.downcase
40
+ if adapter.include?("postgresql")
41
+ remove_index :geneva_drive_step_executions,
42
+ name: INDEX_NAME,
43
+ algorithm: :concurrently
44
+ else
45
+ remove_index :geneva_drive_step_executions, name: INDEX_NAME
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AllowNullHeroOnGenevaDriveWorkflows < ActiveRecord::Migration[7.2]
4
+ def change
5
+ return unless column_exists?(:geneva_drive_workflows, :hero_type)
6
+
7
+ adapter = connection.adapter_name.downcase
8
+ if adapter.include?("sqlite")
9
+ allow_null_hero_sqlite
10
+ else
11
+ change_column_null :geneva_drive_workflows, :hero_type, true
12
+ change_column_null :geneva_drive_workflows, :hero_id, true
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def allow_null_hero_sqlite
19
+ reversible do |direction|
20
+ direction.up do
21
+ # SQLite cannot ALTER COLUMN to remove NOT NULL without a full table
22
+ # recreation. We perform an atomic table swap to avoid foreign-key
23
+ # corruption issues. See CLAUDE.md / AGENTS.md for context.
24
+
25
+ # 1. Introspect the existing table via ActiveRecord
26
+ old_columns = connection.columns(:geneva_drive_workflows)
27
+ old_pk = connection.primary_key(:geneva_drive_workflows)
28
+ pk_col = old_columns.find { |c| c.name == old_pk }
29
+ data_cols = old_columns.reject { |c| c.name == old_pk }
30
+
31
+ # 2. Create the replacement table using the schema DSL
32
+ create_table :geneva_drive_workflows_new, id: pk_col.type do |t|
33
+ data_cols.each do |col|
34
+ nullable = %w[hero_type hero_id].include?(col.name) || col.null
35
+ opts = {null: nullable}
36
+ opts[:default] = col.default_function || col.default unless col.default.nil? && col.default_function.nil?
37
+ t.column col.name, col.type, **opts
38
+ end
39
+ end
40
+
41
+ # 3. Copy all data
42
+ col_names = old_columns.map(&:name).join(", ")
43
+ execute "INSERT INTO geneva_drive_workflows_new (#{col_names}) SELECT #{col_names} FROM geneva_drive_workflows"
44
+
45
+ # 4. Atomic swap
46
+ drop_table :geneva_drive_workflows
47
+ rename_table :geneva_drive_workflows_new, :geneva_drive_workflows
48
+
49
+ # 5. Recreate indexes (they are lost during the swap)
50
+ add_index :geneva_drive_workflows, :type
51
+ add_index :geneva_drive_workflows, :state
52
+ add_index :geneva_drive_workflows, [:hero_type, :hero_id]
53
+ execute <<-SQL
54
+ CREATE UNIQUE INDEX index_geneva_drive_workflows_unique_ongoing
55
+ ON geneva_drive_workflows (type, hero_type, hero_id)
56
+ WHERE state NOT IN ('finished', 'canceled') AND allow_multiple = 0;
57
+ SQL
58
+ end
59
+
60
+ direction.down do
61
+ raise ActiveRecord::IrreversibleMigration,
62
+ "Cannot re-add NOT NULL on hero columns in SQLite without risking data loss"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -5,14 +5,18 @@ class CreateGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
5
5
 
6
6
  def change
7
7
  key_type = geneva_drive_key_type
8
+ adapter = connection.adapter_name.downcase
9
+
10
+ # Build reference options - we add foreign key separately to avoid MySQL type mismatch
11
+ reference_options = {
12
+ null: false,
13
+ index: true
14
+ }
15
+ reference_options[:type] = key_type if key_type == :uuid
8
16
 
9
17
  create_table :geneva_drive_step_executions, **geneva_drive_table_options do |t|
10
18
  # Link to workflow (cascade delete when workflow is deleted)
11
- t.references :workflow,
12
- null: false,
13
- foreign_key: {to_table: :geneva_drive_workflows, on_delete: :cascade},
14
- index: true,
15
- type: key_type
19
+ t.references :workflow, **reference_options
16
20
 
17
21
  # Which step this execution represents
18
22
  t.string :step_name, null: false
@@ -41,6 +45,15 @@ class CreateGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
41
45
  # Job tracking (for debugging)
42
46
  t.string :job_id
43
47
 
48
+ # Freeform JSON metadata for executor bookkeeping
49
+ if adapter.include?("postgresql")
50
+ t.jsonb :metadata
51
+ elsif adapter.include?("mysql")
52
+ t.text :metadata, limit: 4_294_967_295
53
+ else
54
+ t.text :metadata
55
+ end
56
+
44
57
  t.timestamps
45
58
  end
46
59
 
@@ -55,6 +68,13 @@ class CreateGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
55
68
  # Index for common query patterns
56
69
  add_index :geneva_drive_step_executions, [:workflow_id, :state]
57
70
 
71
+ # Add foreign key separately to avoid MySQL type mismatch (UNSIGNED vs SIGNED bigint)
72
+ # MySQL creates primary keys as UNSIGNED but references as SIGNED, causing FK constraint failure
73
+ unless adapter.include?("mysql")
74
+ add_foreign_key :geneva_drive_step_executions, :geneva_drive_workflows,
75
+ column: :workflow_id, on_delete: :cascade
76
+ end
77
+
58
78
  reversible do |direction|
59
79
  direction.up { create_db_specific_indices }
60
80
  end
@@ -4,13 +4,15 @@ class CreateGenevaDriveWorkflows < ActiveRecord::Migration[7.2]
4
4
  include GenevaDrive::MigrationHelpers
5
5
 
6
6
  def change
7
+ adapter = connection.adapter_name.downcase
8
+
7
9
  create_table :geneva_drive_workflows, **geneva_drive_table_options do |t|
8
10
  # Core identification (STI)
9
11
  t.string :type, null: false, index: true
10
12
 
11
- # Polymorphic association to the hero of the workflow
12
- t.string :hero_type, null: false
13
- t.column :hero_id, geneva_drive_key_type, null: false
13
+ # Polymorphic association to the hero of the workflow (optional)
14
+ t.string :hero_type
15
+ t.column :hero_id, geneva_drive_key_type
14
16
 
15
17
  # State machine
16
18
  t.string :state, null: false, default: "ready", index: true
@@ -22,6 +24,15 @@ class CreateGenevaDriveWorkflows < ActiveRecord::Migration[7.2]
22
24
  # Multiple workflows of same type for same hero
23
25
  t.boolean :allow_multiple, default: false, null: false
24
26
 
27
+ # Freeform JSON metadata
28
+ if adapter.include?("postgresql")
29
+ t.jsonb :metadata
30
+ elsif adapter.include?("mysql")
31
+ t.text :metadata, limit: 4_294_967_295
32
+ else
33
+ t.text :metadata
34
+ end
35
+
25
36
  # Timestamps
26
37
  t.datetime :started_at
27
38
  t.datetime :transitioned_at
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # How long to keep completed/canceled workflows before automatic deletion.
4
- # Set to nil to disable automatic cleanup (default).
4
+ # Set to nil to disable automatic cleanup.
5
5
  # When enabled, finished and canceled workflows older than this threshold
6
6
  # will be deleted along with their step execution records.
7
7
  #
@@ -18,7 +18,7 @@
18
18
  # still in "scheduled" state before being considered stuck. This can
19
19
  # happen if jobs fail to enqueue or are lost by the queue backend.
20
20
  #
21
- # GenevaDrive.stuck_scheduled_threshold = 1.hour
21
+ # GenevaDrive.stuck_scheduled_threshold = 15.minutes
22
22
 
23
23
  # Action to take when recovering stuck step executions.
24
24
  # - :reattempt - Mark as recovered and schedule a retry (default)
@@ -30,3 +30,23 @@
30
30
  # housekeeping run. Prevents runaway processing if there's a large backlog.
31
31
  #
32
32
  # GenevaDrive.housekeeping_batch_size = 1000
33
+
34
+ # Maximum size in bytes of a resumable step cursor once serialized to JSON.
35
+ # The cursor is a position marker (an id, page number, or pagination token),
36
+ # not a place to store the data being processed - it is rewritten on every
37
+ # checkpoint and copied to every successor execution. Exceeding the limit
38
+ # raises GenevaDrive::CursorTooLargeError. Set to nil to disable the check.
39
+ #
40
+ # GenevaDrive.max_cursor_size = 128.kilobytes
41
+
42
+ # Whether to defer job enqueueing until after the database transaction commits.
43
+ # In production this ensures step execution records are visible to job workers
44
+ # before the job runs. In test environments (the default when Rails.env.test?)
45
+ # this is set to false so that jobs are enqueued immediately — transactional
46
+ # tests (especially with SQLite) never commit the outermost transaction, which
47
+ # can cause after_commit callbacks to misbehave or not fire at all.
48
+ #
49
+ # Set to true in tests if you need strict transaction semantics for a specific
50
+ # test suite, or if you are not using transactional tests.
51
+ #
52
+ # GenevaDrive.enqueue_after_commit = !Rails.env.test?