geneva_drive 0.7.0 → 0.8.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/MANUAL.md +84 -1
  4. data/Rakefile +14 -1
  5. data/bin/matrix +76 -0
  6. data/bin/md2html +114 -0
  7. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
  8. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +5 -10
  9. data/lib/geneva_drive/executor.rb +18 -0
  10. data/lib/geneva_drive/json_column.rb +72 -0
  11. data/lib/geneva_drive/migration_helpers.rb +27 -0
  12. data/lib/geneva_drive/removed_step_definition.rb +34 -0
  13. data/lib/geneva_drive/signal.rb +4 -2
  14. data/lib/geneva_drive/step_collection.rb +31 -7
  15. data/lib/geneva_drive/step_definition.rb +9 -0
  16. data/lib/geneva_drive/step_execution.rb +3 -2
  17. data/lib/geneva_drive/version.rb +1 -1
  18. data/lib/geneva_drive/workflow.rb +170 -2
  19. data/lib/geneva_drive.rb +2 -0
  20. data/test/dummy/config/database.yml +40 -5
  21. data/test/dummy/config/initializers/geneva_drive.rb +46 -0
  22. data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
  23. data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
  24. data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
  25. data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
  26. data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
  27. data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
  28. data/test/dummy_install/db/schema.rb +75 -0
  29. data/test/dummy_install/log/test.log +1852 -0
  30. data/test/test_helper.rb +51 -13
  31. data/test/workflow/removed_step_test.rb +294 -0
  32. data/test/workflow/resume_at_test.rb +170 -0
  33. metadata +16 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1738e58c5f40199781a27c2345b20657e63ccc0c5a8b28010c2ac820774c2c2f
4
- data.tar.gz: f729ef68e929f39fa26b09108f446a9b53ec13013d22d89f22d788b5ac0988e3
3
+ metadata.gz: dd1756c2bb26aeb5fcf15ef77232f06314335ce3427aa6053085476e3cd78042
4
+ data.tar.gz: 0a445074432320b0040d6e9c69f6b77586cd3275a7bbf034096a5ef0dedc3334
5
5
  SHA512:
6
- metadata.gz: 87a29981a0278755e146cebf6e5e8d7075832665335b54511d5c24734a44040c62e9b2f9ae8fcd2558941d5f8cfe9eefc588efef65db09dc131d6602e2089d40
7
- data.tar.gz: 9dcbb2ca84072315438dcacfd78d5463563b0ef6f5a940a5ccd12afcef7d2f10fb40cd710ce4bd5ba72142941242ec709f92e4d33927d15ce146538eed93834f
6
+ metadata.gz: 1113718f861068971a284fcfd54ccabb2b9cdfc3fb7bbff6a75d505f9d5741ba0cdcbe0b3ce62d50b4441d5be8901e505d68bef2e95e197d7c00d7297a27e8b6
7
+ data.tar.gz: bbc4dc5c73a5c77d19725b60c2951fa1a7d4192cf97739d3cda4f021c4d892e05458df5feb6313372541d1f2a36e840ca51dfe7add125bb0b215cc5d9539392f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0]
4
+
5
+ - Add `removed_step :name` for taking a step out of a workflow that has instances in flight. Deleting a `step` outright strands every workflow holding a scheduled execution for it: the executor finds no definition and pauses with `StepNotDefinedError`, which during a rolling deploy can hit every workflow sitting on that step at once. The gem cannot recover from this on its own — it knows the step is gone, but not whether the steps after it were ever meant to run, and a step's position says nothing about reachability (a `capture_payment` that normally ends the workflow by calling `finished!` sits directly before `refund`, so skipping forward would refund a payment that was never captured). `removed_step` is how the author of the removal records that judgement in advance: declared in the position the step used to occupy, it keeps the name resolvable so executions scheduled before the removal are skipped and the workflow continues to the next runnable step, while scheduling never spools an execution for the name again and `before_step:`/`after_step:` references keep working. An execution parked on the step waiting for a signal is released on `resume!` rather than waiting for an event that can no longer match. `Workflow.removed_steps_in_flight` reports which gravestones step execution rows still reference (ignoring finished and canceled workflows), so a `removed_step` line can be deleted once it comes back empty. The default for an undeclared removal is unchanged: the workflow still pauses.
6
+ - Add `workflow.resume_at!(:step_name)` for workflows already stranded on a step that was deleted without a `removed_step` gravestone. Plain `resume!` had nowhere to send them and died with a `NoMethodError` on nil deep inside `create_step_execution`; it now raises `StepNotDefinedError` naming the missing step, the steps still defined, and the way out. Where to land is deliberately the operator's call, for the same reason the executor will not guess it.
7
+ - Support MariaDB for the JSON columns (resumable step `cursor`, signal `payload`). MariaDB has no JSON type: `json` there is an alias for LONGTEXT with an auto-generated CHECK constraint named after the column, and since `cursor` is reserved in MariaDB, loading a schema dump that re-emits that constraint died on a syntax error. Rails also types such a column as text, so a Hash written to it was stored as `#inspect` output, which the same CHECK constraint then rejected. The installer migrations now ask for plain LONGTEXT on MariaDB via the new `geneva_drive_json_column` migration helper, and the models encode and decode JSON themselves whenever the column is text. The check is on the column, not the adapter, so installs that already ran the old migration on MariaDB get the encoding without having to rewrite the column.
8
+ - Test against Rails 7.2, 8.0 and 8.1 on SQLite, PostgreSQL, MySQL and MariaDB. Previously the appraisals stopped at Rails 8.0, and the MySQL CI job could not build `mysql2`.
9
+
3
10
  ## [0.7.0]
4
11
 
5
12
  - Add signals: external events delivered to a workflow instance with `workflow.signal!(:payment_confirmed, payload: {...}, idempotency_key: event["id"])`, and a step primitive that parks until one arrives with `step :capture, wait_for: :payment_confirmed`. Signals are rows in a new `geneva_drive_signals` table, persisted before any processing, so arrival order does not matter: a signal that lands before its waiting step exists is buffered and claimed when the step runs, and one that lands after the step parked wakes it immediately. Both sides of the rendezvous run under the workflow row lock, so no signal can slip through the gap, and delivery is one transaction: the insert, the waking of parked executions and every counter bump land together or not at all, so a crashed `signal!` leaves nothing half-delivered (the job enqueue stays after-commit, with the existing stuck-scheduled housekeeping sweep as its backstop). Waiting is a first-class step execution state (`waiting`) that holds no queue slot and no timer — nothing polls. Matching is by name - `wait_for:` also takes a `GenevaDrive::SignalMatcher`, whose optional block narrows by payload and is evaluated on the workflow the signal was sent to (so `hero` is in scope), or any other object responding to `#matches?(signal)`. Matchers are plain reusable objects, so a rule can live in a constant and be shared across steps. Signals move `pending` → `claimed` → `consumed`, consumed only once every attached execution chain has finished cleanly — where skipping counts as finishing, so any flavor of skip (`skip_if:` at wake, `skip!` in a step, an exception policy, or an operator `skip!` on a ready or paused workflow) settles the signal that step was delivered, while skips of steps that never attached leave buffered signals alone — and in the same transaction as the step's own completion, so a reattempt or a `resume!` re-reads the same payload and the audit trail never claims an event was handled when it was not. Two counters on the row, `claimed` and `consumed`, count attachments and clean resolutions (with `claimed?` / `consumed?` reading those counters rather than the lifecycle state). Redelivery is deduplicated by a unique index on `(workflow_id, name, idempotency_key)` — the duplicate call returns the original row flagged `duplicate_delivery?` without dispatching. Payloads go through ActiveJob serializers, come back with indifferent access, and are bounded by `GenevaDrive.max_signal_payload_size` (128 KB, `nil` disables). `signal!` on a finished or canceled workflow raises `GenevaDrive::WorkflowNotOngoing` unless it is a redelivery of a known event; on a paused workflow the row is buffered and delivered by `resume!`. Timeouts are deliberately out of scope for now; waiting is instead made loud through `waiting_since` and two housekeeping gauges (`geneva_drive.waiting_step_executions`, `geneva_drive.waiting_overdue` past `GenevaDrive.waiting_visibility_threshold`, 7 days by default), with `skip!` and `cancel!` as operator escape hatches. Needs one new installer migration (the signals table plus two columns on step executions); until it runs everything else is unaffected and executing a `wait_for:` step raises a configuration error pointing at the missing migration. Test helpers: `assert_waiting_for_signal`, `assert_signal_state`, and the step-driving helpers now raise instead of spinning on a parked execution. Mixing up `wait:` and `wait_for:` now raises `StepConfigurationError` at class load in both directions, which also tightens `wait:` to reject Strings (previously `wait: "later"` silently meant zero seconds).
data/MANUAL.md CHANGED
@@ -325,6 +325,80 @@ end
325
325
 
326
326
  The referenced step must already be defined — you can only insert before or after steps that appear earlier in the class body.
327
327
 
328
+ ### Removing a Step with `removed_step`
329
+
330
+ Deleting a `step` is not safe while workflows are in flight. Step executions are rows scheduled ahead of time, so at the moment a deploy lands there are workflows holding a row that names a step the new code no longer has. When such a row's job runs, the executor finds no definition and pauses the workflow with `StepNotDefinedError`. During a rolling deploy this can happen to every workflow sitting on that step at once.
331
+
332
+ GenevaDrive cannot recover from this by itself, and the reason is worth being precise about. It knows the step is gone. It does not know whether the work that step did still needs doing, and — more importantly — it does not know which of the remaining steps would have run had it executed. A step's position in the sequence says nothing about reachability: a `capture_payment` that normally ends the workflow by calling `finished!` sits directly before `refund`, and nothing in the database distinguishes "carry on to the next one" from "stop here". Skipping forward on the workflow's behalf would, in exactly that case, issue a refund for a payment that was never captured. So the executor pauses and waits for a human, which is the only correct thing it can do on its own.
333
+
334
+ `removed_step` is how you tell it the answer in advance. Declare it in the position the step used to occupy:
335
+
336
+ ```ruby
337
+ class PaymentWorkflow < GenevaDrive::Workflow
338
+ step :authorize do
339
+ PaymentGateway.authorize(hero)
340
+ end
341
+
342
+ removed_step :capture_payment
343
+
344
+ step :send_receipt do
345
+ ReceiptMailer.receipt(hero).deliver_later
346
+ end
347
+ end
348
+ ```
349
+
350
+ The declaration is an assertion only you can make: *this step no longer needs to happen, carry on past it.* Having been told, GenevaDrive can act without guessing:
351
+
352
+ - Executions already scheduled under the old name resolve, are marked `skipped`, and the workflow proceeds to the next runnable step.
353
+ - Scheduling never spools a new execution for the name again — a new `PaymentWorkflow` goes `authorize` → `send_receipt`.
354
+ - `before_step:` / `after_step:` references to the name keep working.
355
+ - An execution parked on the step waiting for a signal is released on `resume!` instead of waiting for an event that can no longer match.
356
+
357
+ A removed step takes a name and nothing else. There is nothing to configure about a step that does not run.
358
+
359
+ Because the gravestone is in the source, in order, the decision is visible in the diff that removes the step — which is where a reviewer can still object to it.
360
+
361
+ ### Retiring the Gravestone
362
+
363
+ A `removed_step` line is temporary. It can go once no step execution row can land on it any more, which is a question you can ask directly:
364
+
365
+ ```ruby
366
+ PaymentWorkflow.removed_steps_in_flight
367
+ # => {"capture_payment" => 3} # three rows still point at it — leave the line in place
368
+ # => {} # drained — the removed_step line can be deleted
369
+ ```
370
+
371
+ Rows belonging to finished and canceled workflows are ignored, since they will never execute again. The full lifecycle is: replace `step` with `removed_step` and deploy; let in-flight workflows drain past it; delete the line in a later deploy once `removed_steps_in_flight` comes back empty.
372
+
373
+ ### Recovering a Workflow Stranded by a Deleted Step
374
+
375
+ If a step was deleted without leaving a gravestone, the workflows that were sitting on it are paused and pointed at a name that no longer resolves. Plain `resume!` has nowhere to send them and says so:
376
+
377
+ ```ruby
378
+ workflow.resume!
379
+ # GenevaDrive::StepNotDefinedError: Cannot resume PaymentWorkflow #42: step 'capture_payment'
380
+ # is no longer defined in PaymentWorkflow. Pick the step to continue from with
381
+ # resume_at!(:step_name) — the steps still defined are authorize, send_receipt — or cancel!
382
+ # the workflow. ...
383
+ ```
384
+
385
+ `resume_at!` is the escape hatch. You name the step to continue from, because only you know what the deleted step would have done:
386
+
387
+ ```ruby
388
+ workflow.resume_at!(:send_receipt)
389
+ ```
390
+
391
+ It cancels any stray execution, points the workflow at the named step, and continues from there. It refuses names that are not defined, and refuses to land on a `removed_step`. To find the affected workflows:
392
+
393
+ ```ruby
394
+ GenevaDrive::Workflow.paused
395
+ .joins(:step_executions)
396
+ .where(geneva_drive_step_executions: {error_class_name: "GenevaDrive::StepNotDefinedError"})
397
+ .distinct
398
+ ```
399
+
400
+ If the workflow should not continue at all, `cancel!` it.
401
+
328
402
  ## Conditional Execution
329
403
 
330
404
  ### Skipping Steps with `skip_if:`
@@ -1227,8 +1301,13 @@ end
1227
1301
  # Resume a specific workflow
1228
1302
  workflow = GenevaDrive::Workflow.find(id)
1229
1303
  workflow.resume! # Re-enqueues existing scheduled step or creates new one
1304
+
1305
+ # Or continue from a specific step, when the step it was pointed at is gone
1306
+ workflow.resume_at!(:send_receipt)
1230
1307
  ```
1231
1308
 
1309
+ `resume_at!` exists for workflows paused on a step that no longer exists in the code. See [Recovering a Workflow Stranded by a Deleted Step](#recovering-a-workflow-stranded-by-a-deleted-step).
1310
+
1232
1311
  ## Workflow States
1233
1312
 
1234
1313
  ### State Machine Diagram
@@ -1957,7 +2036,11 @@ The workflow will sit in `paused` state — holding the slot, preventing duplica
1957
2036
 
1958
2037
  ### Pause on missing step definitions
1959
2038
 
1960
- There is one more scenario where pause happens automatically. If a workflow references a step name that no longer exists in the class definition — typically after a deploy that removed or renamed a step — the executor pauses the workflow rather than silently skipping or crashing. This gives operators a chance to notice the mismatch and decide whether to skip, cancel, or deploy a fix.
2039
+ There is one more scenario where pause happens automatically. If a workflow references a step name that no longer exists in the class definition — typically after a deploy that removed or renamed a step — the executor pauses the workflow rather than silently skipping or crashing.
2040
+
2041
+ This is not a limitation to be worked around. The executor knows the step is gone but not whether the steps after it were ever meant to run, and guessing wrong means performing work the missing step would have prevented. Pausing hands that judgement to someone who has it.
2042
+
2043
+ Declare `removed_step :name` when you take a step out and the judgement is recorded ahead of time, so nothing pauses; see [Removing a Step with `removed_step`](#removing-a-step-with-removed_step). For workflows already stranded by a step deleted without one, `resume_at!` is the way back — see [Recovering a Workflow Stranded by a Deleted Step](#recovering-a-workflow-stranded-by-a-deleted-step).
1961
2044
 
1962
2045
  ## Complete Example Workflows
1963
2046
 
data/Rakefile CHANGED
@@ -1,7 +1,6 @@
1
1
  require "bundler/setup"
2
2
  require "bundler/gem_tasks"
3
3
 
4
- require "rake/testtask"
5
4
  require "standard/rake"
6
5
  require "yard"
7
6
 
@@ -15,3 +14,17 @@ end
15
14
 
16
15
  APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
17
16
  load "rails/tasks/engine.rake"
17
+
18
+ # Rails' own plugin test runner rather than a Rake::TestTask: it is what sets up
19
+ # and tears down the per-worker databases that ActiveSupport::TestCase.parallelize
20
+ # creates, and a plain Rake::TestTask leaves those workers hanging at exit.
21
+ desc "Run the test suite"
22
+ task :test do
23
+ ruby "-Itest", "bin/test"
24
+ end
25
+
26
+ # The root Gemfile resolves to the newest released Rails and the dummy app
27
+ # defaults to SQLite, so a plain `rake` is the "latest Rails on SQLite" run.
28
+ # The appraisal gemfiles cover the older Rails minors, and CI additionally
29
+ # points DATABASE_URL at PostgreSQL and MySQL.
30
+ task default: [:standard, :test]
data/bin/matrix ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # Local CI: run the suite for every Rails appraisal against every database
3
+ # engine the CI matrix covers, so that a one-engine failure can be reproduced
4
+ # without pushing. Brings compose.yaml up first and leaves it running.
5
+ #
6
+ # bin/matrix # the whole matrix
7
+ # bin/matrix rails_8_1 # one Rails version, all engines
8
+ # bin/matrix rails_8_1 sqlite # one cell
9
+ set -uo pipefail
10
+ cd "$(dirname "$0")/.."
11
+
12
+ # The gemfiles are generated from Appraisals, which makes them the one place
13
+ # the supported Rails versions are written down. CI reads the same list off
14
+ # them, so neither side can drift from the other.
15
+ RAILS_VERSIONS=${1:-$(ls gemfiles/*.gemfile | xargs -n1 basename | sed 's/\.gemfile$//' | tr '\n' ' ')}
16
+ ENGINES=${2:-"sqlite postgres mysql mariadb"}
17
+
18
+ for engine in $ENGINES; do
19
+ case "$engine" in
20
+ sqlite|postgres|mysql|mariadb) ;;
21
+ *) echo "Unknown engine: $engine (pick from sqlite, postgres, mysql, mariadb)" >&2; exit 1 ;;
22
+ esac
23
+ done
24
+
25
+ # Only the engines actually asked for - CI runs one cell per job and should not
26
+ # be pulling a MySQL image to test against PostgreSQL.
27
+ services=""
28
+ for engine in $ENGINES; do
29
+ case "$engine" in
30
+ postgres|mysql|mariadb) services="$services $engine" ;;
31
+ esac
32
+ done
33
+ if [ -n "$services" ]; then
34
+ docker compose up -d --wait $services || exit 1
35
+ fi
36
+
37
+ # Compose publishes to an ephemeral host port, so ask it which one it got.
38
+ port_of() {
39
+ docker compose port "$1" "$2" | sed 's/.*://'
40
+ }
41
+
42
+ # SQLite gets no DATABASE_URL, exactly as in CI: the dummy app defaults to it
43
+ # and resolves its own path relative to Rails.root.
44
+ url_for() {
45
+ case "$1" in
46
+ postgres) echo "postgresql://postgres:postgres@127.0.0.1:$(port_of postgres 5432)/geneva_drive_test" ;;
47
+ mysql) echo "mysql2://root:root@127.0.0.1:$(port_of mysql 3306)/geneva_drive_test?encoding=utf8mb4" ;;
48
+ mariadb) echo "mysql2://root:root@127.0.0.1:$(port_of mariadb 3306)/geneva_drive_test?encoding=utf8mb4" ;;
49
+ esac
50
+ }
51
+
52
+ failed=0
53
+ results=()
54
+ for rails in $RAILS_VERSIONS; do
55
+ gemfile="gemfiles/$rails.gemfile"
56
+ [ -f "$gemfile" ] || { echo "No such appraisal: $gemfile" >&2; exit 1; }
57
+ BUNDLE_GEMFILE=$gemfile bundle check >/dev/null 2>&1 || BUNDLE_GEMFILE=$gemfile bundle install || exit 1
58
+ for engine in $ENGINES; do
59
+ echo "==> $rails / $engine"
60
+ if [ "$engine" = sqlite ]; then
61
+ env -u DATABASE_URL BUNDLE_GEMFILE=$gemfile RAILS_ENV=test GENEVA_DRIVE_ENGINE=$engine bundle exec rake test
62
+ else
63
+ env BUNDLE_GEMFILE=$gemfile RAILS_ENV=test GENEVA_DRIVE_ENGINE=$engine DATABASE_URL="$(url_for "$engine")" bundle exec rake test
64
+ fi
65
+ if [ $? -eq 0 ]; then
66
+ results+=("ok $rails / $engine")
67
+ else
68
+ results+=("FAIL $rails / $engine")
69
+ failed=1
70
+ fi
71
+ done
72
+ done
73
+
74
+ printf '\n'
75
+ printf '%s\n' "${results[@]}"
76
+ exit $failed
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
@@ -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.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # JSON encoding for columns the database does not type as JSON itself.
4
+ #
5
+ # PostgreSQL and MySQL both hand Rails a real JSON attribute, so a Hash
6
+ # assigned to one is encoded by the adapter and comes back decoded. MariaDB
7
+ # has no JSON type - see GenevaDrive::MigrationHelpers#geneva_drive_json_column
8
+ # for why - so the column is plain LONGTEXT, Rails types it as text, and a Hash
9
+ # written to it would be stored as `#inspect` output. Encoding it here keeps
10
+ # the models writing and reading the same Ruby values everywhere.
11
+ #
12
+ # The branch is on the column, not on the adapter, deliberately: an install
13
+ # that already ran the old migration against MariaDB has a LONGTEXT column and
14
+ # gets the encoding too, without needing its column rewritten first.
15
+ #
16
+ # @api private
17
+ module GenevaDrive::JsonColumn
18
+ extend ActiveSupport::Concern
19
+
20
+ class_methods do
21
+ # Whether the column holds JSON as text rather than as a JSON type, and so
22
+ # needs encoding and decoding here. Lazily detected: never touches the
23
+ # database at class definition time, since the column may not exist yet.
24
+ #
25
+ # @param name [String, Symbol] the column name
26
+ # @return [Boolean]
27
+ def json_column_as_text?(name)
28
+ name = name.to_s
29
+ @_json_column_as_text ||= {}
30
+ return @_json_column_as_text[name] if @_json_column_as_text.key?(name)
31
+
32
+ @_json_column_as_text[name] = begin
33
+ column = table_exists? && columns_hash[name]
34
+ column ? column.type == :text : false
35
+ rescue ActiveRecord::ActiveRecordError
36
+ false
37
+ end
38
+ end
39
+
40
+ # Clears the cached detection. Call after migrating in-process.
41
+ #
42
+ # @return [void]
43
+ def reset_json_column_cache!
44
+ remove_instance_variable(:@_json_column_as_text) if defined?(@_json_column_as_text)
45
+ end
46
+
47
+ # Prepares an already-serialized value for writing to the column.
48
+ #
49
+ # @param name [String, Symbol] the column name
50
+ # @param value [Object, nil] a JSON-safe Ruby value
51
+ # @return [Object, nil] the value, encoded to a JSON string where needed
52
+ def encode_json_column(name, value)
53
+ return value unless json_column_as_text?(name)
54
+ return nil if value.nil?
55
+
56
+ JSON.generate(value)
57
+ end
58
+
59
+ # Reverses {encode_json_column} for a value read off the column.
60
+ #
61
+ # @param name [String, Symbol] the column name
62
+ # @param raw [Object, nil] the raw attribute value
63
+ # @return [Object, nil] the decoded value
64
+ def decode_json_column(name, raw)
65
+ return raw unless json_column_as_text?(name)
66
+ return nil if raw.nil?
67
+ return raw unless raw.is_a?(String)
68
+
69
+ JSON.parse(raw)
70
+ end
71
+ end
72
+ end
@@ -48,6 +48,33 @@ module GenevaDrive::MigrationHelpers
48
48
  options
49
49
  end
50
50
 
51
+ # Column type and options for a column that holds a JSON document.
52
+ #
53
+ # PostgreSQL gets jsonb and MySQL gets its native json. MariaDB gets neither:
54
+ # it has no JSON type at all, `json` there being an alias for LONGTEXT with an
55
+ # auto-generated CHECK constraint named after the column. Two things go wrong
56
+ # with that alias. The schema dump comes back as `t.text` plus
57
+ # `t.check_constraint name: "cursor"`, and loading that dump re-emits the
58
+ # constraint name unquoted - `cursor` is reserved in MariaDB, so the load dies
59
+ # on a syntax error, which takes out `db:schema:load` and every parallel test
60
+ # worker with it. And because Rails types the column as text either way, a
61
+ # Hash assigned to it is written out as `#inspect` rather than JSON, which the
62
+ # very same CHECK constraint then rejects. Asking for plain LONGTEXT and doing
63
+ # the JSON encoding in the model (see GenevaDrive::JsonColumn) sidesteps both.
64
+ #
65
+ # @return [Array(Symbol, Hash)] the column type and its options
66
+ def geneva_drive_json_column
67
+ adapter = connection.adapter_name.downcase
68
+
69
+ if adapter.include?("postgresql")
70
+ [:jsonb, {}]
71
+ elsif connection.respond_to?(:mariadb?) && connection.mariadb?
72
+ [:text, {limit: 4_294_967_295}]
73
+ else
74
+ [:json, {}]
75
+ end
76
+ end
77
+
51
78
  private
52
79
 
53
80
  # Collects the id column from every application table (excludes system
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A gravestone left in place of a step that has been taken out of a workflow.
4
+ #
5
+ # Deleting a `step` outright is not safe while workflows are in flight: an
6
+ # execution row already scheduled for that name finds no definition when its
7
+ # job runs, and the workflow pauses with StepNotDefinedError. Declaring
8
+ # `removed_step :name` in the position the step used to occupy keeps the name
9
+ # resolvable, so those executions are skipped instead of stranding the
10
+ # workflow, while scheduling never spools a new execution for it again.
11
+ #
12
+ # A removed step holds nothing but a name - no block, no wait, no conditions.
13
+ # There is nothing left to configure about a step that does not run.
14
+ #
15
+ # @api private
16
+ class GenevaDrive::RemovedStepDefinition < GenevaDrive::StepDefinition
17
+ # Creates a gravestone for a step that no longer exists.
18
+ #
19
+ # The parent is handed a callable that never runs: a removed step is
20
+ # intercepted by the executor before it would be performed.
21
+ #
22
+ # @param name [String, Symbol] the name of the step that was removed
23
+ # @param call_location [Array<String, Integer>, nil] source location of the removed_step call
24
+ def initialize(name:, call_location: nil)
25
+ super(name: name, callable: :__removed_step_never_runs, call_location: call_location)
26
+ end
27
+
28
+ # Returns true to mark this as a gravestone rather than a runnable step.
29
+ #
30
+ # @return [Boolean]
31
+ def removed?
32
+ true
33
+ end
34
+ end
@@ -25,6 +25,8 @@
25
25
  # workflow.signal!(:payment_confirmed, payload: {amount_cents: 12_500})
26
26
  #
27
27
  class GenevaDrive::Signal < ActiveRecord::Base
28
+ include GenevaDrive::JsonColumn
29
+
28
30
  self.table_name = "geneva_drive_signals"
29
31
 
30
32
  # Signal lifecycle states as enum with string values.
@@ -109,7 +111,7 @@ class GenevaDrive::Signal < ActiveRecord::Base
109
111
  end
110
112
  end
111
113
 
112
- serialized
114
+ encode_json_column(:payload, serialized)
113
115
  end
114
116
  end
115
117
 
@@ -119,7 +121,7 @@ class GenevaDrive::Signal < ActiveRecord::Base
119
121
  #
120
122
  # @return [Object, nil] the payload
121
123
  def payload
122
- raw = self[:payload]
124
+ raw = self.class.decode_json_column(:payload, self[:payload])
123
125
  return nil if raw.nil?
124
126
 
125
127
  value = ActiveJob::Arguments.deserialize([raw]).first