geneva_drive 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -1
- data/MANUAL.md +260 -1
- data/README.md +2 -0
- data/Rakefile +14 -1
- data/bin/matrix +76 -0
- data/bin/md2html +114 -0
- data/lib/generators/geneva_drive/install/install_generator.rb +5 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +89 -0
- data/lib/geneva_drive/executor.rb +131 -10
- data/lib/geneva_drive/flow_control.rb +43 -3
- data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
- data/lib/geneva_drive/json_column.rb +72 -0
- data/lib/geneva_drive/migration_helpers.rb +27 -0
- data/lib/geneva_drive/removed_step_definition.rb +34 -0
- data/lib/geneva_drive/signal.rb +245 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_collection.rb +31 -7
- data/lib/geneva_drive/step_definition.rb +91 -1
- data/lib/geneva_drive/step_execution.rb +39 -2
- data/lib/geneva_drive/test_helpers.rb +89 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +497 -24
- data/lib/geneva_drive.rb +24 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/dummy/config/database.yml +40 -5
- data/test/dummy/config/initializers/geneva_drive.rb +46 -0
- data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
- data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
- data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
- data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
- data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
- data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
- data/test/dummy_install/db/schema.rb +75 -0
- data/test/dummy_install/log/test.log +1852 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +53 -13
- data/test/workflow/removed_step_test.rb +294 -0
- data/test/workflow/resume_at_test.rb +170 -0
- data/test/workflow/signal_consumption_test.rb +397 -0
- data/test/workflow/signal_delivery_test.rb +255 -0
- data/test/workflow/signal_durability_test.rb +164 -0
- data/test/workflow/signal_rendezvous_test.rb +581 -0
- data/test/workflow/signals_without_migration_test.rb +97 -0
- metadata +26 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd1756c2bb26aeb5fcf15ef77232f06314335ce3427aa6053085476e3cd78042
|
|
4
|
+
data.tar.gz: 0a445074432320b0040d6e9c69f6b77586cd3275a7bbf034096a5ef0dedc3334
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1113718f861068971a284fcfd54ccabb2b9cdfc3fb7bbff6a75d505f9d5741ba0cdcbe0b3ce62d50b4441d5be8901e505d68bef2e95e197d7c00d7297a27e8b6
|
|
7
|
+
data.tar.gz: bbc4dc5c73a5c77d19725b60c2951fa1a7d4192cf97739d3cda4f021c4d892e05458df5feb6313372541d1f2a36e840ca51dfe7add125bb0b215cc5d9539392f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
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
|
+
|
|
10
|
+
## [0.7.0]
|
|
11
|
+
|
|
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).
|
|
4
13
|
|
|
5
14
|
## [0.6.0]
|
|
6
15
|
|
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:`
|
|
@@ -679,6 +753,179 @@ test "keeps its place across interruptions" do
|
|
|
679
753
|
end
|
|
680
754
|
```
|
|
681
755
|
|
|
756
|
+
## Waiting for Signals
|
|
757
|
+
|
|
758
|
+
Some steps cannot finish on their own. A payment needs the provider's webhook, a contract needs a countersignature, a shipment needs the carrier to scan the parcel. The usual Rails answer is to poll — schedule the step, check whether the thing happened, `reattempt!(wait: 5.minutes)` if it did not — and that works, but it burns a worker slot on every check and it puts a floor under how fast the workflow can react.
|
|
759
|
+
|
|
760
|
+
A **signal** inverts that. It is an event record addressed to one workflow instance:
|
|
761
|
+
|
|
762
|
+
```ruby
|
|
763
|
+
# app/controllers/payment_webhooks_controller.rb
|
|
764
|
+
workflow = OrderFulfillmentWorkflow.ongoing.for_hero(order).first
|
|
765
|
+
workflow.signal!(:payment_confirmed,
|
|
766
|
+
payload: {amount_cents: event["amount"]},
|
|
767
|
+
idempotency_key: event["id"])
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
And a step declares that it waits for one with `wait_for:`:
|
|
771
|
+
|
|
772
|
+
```ruby
|
|
773
|
+
class OrderFulfillmentWorkflow < GenevaDrive::Workflow
|
|
774
|
+
step :reserve_stock do
|
|
775
|
+
hero.reserve_stock!
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
step :capture_payment, wait_for: :payment_confirmed do
|
|
779
|
+
hero.capture!(received_signal.payload[:amount_cents])
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
step :ship do
|
|
783
|
+
ShippingLabel.create!(order: hero)
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
When `:capture_payment` runs and no matching signal has arrived, its execution parks in the `waiting` state and the job ends. Nothing is enqueued, nothing polls, and the parked row costs nothing until `signal!` wakes it. When the webhook lands, `signal!` wakes the execution and enqueues its job again.
|
|
789
|
+
|
|
790
|
+
Signals need one migration — it creates the `geneva_drive_signals` table and adds two columns to `geneva_drive_step_executions` — so re-run `bin/rails generate geneva_drive:install` on an existing installation to pick it up. Until then everything else keeps working, and executing a step that declares `wait_for:` fails with a configuration error pointing at the missing migration.
|
|
791
|
+
|
|
792
|
+
### Arrival Order Does Not Matter
|
|
793
|
+
|
|
794
|
+
The signal row is written before anything is dispatched, which is what makes the timing irrelevant. There are exactly two places where sender and receiver meet, and both run under the workflow row lock:
|
|
795
|
+
|
|
796
|
+
- When the waiting step's job runs, it looks for a matching unconsumed signal. Found: attach and run. Not found: park.
|
|
797
|
+
- When `signal!` is called, it looks for parked executions. Found: attach, reschedule, enqueue. Not found: the row simply sits there.
|
|
798
|
+
|
|
799
|
+
So a webhook that arrives while the workflow is still three steps away from the waiter is not lost and not early — it is buffered, and claimed when the waiting step finally runs. A webhook that arrives a week after the step parked wakes it immediately.
|
|
800
|
+
|
|
801
|
+
Delivery is one database transaction: the signal row, the waking of the parked execution and the bookkeeping around it either all land or none of them do. If `signal!` raises, nothing was written and the sender can simply try again.
|
|
802
|
+
|
|
803
|
+
> [!IMPORTANT]
|
|
804
|
+
> `signal!` is an instance method: you find the workflow the way you find anything in Rails, usually `SomeWorkflow.ongoing.for_hero(record).first`. Delivering a brand-new event to a workflow that has already finished or been canceled raises `GenevaDrive::WorkflowNotOngoing` rather than silently doing nothing.
|
|
805
|
+
|
|
806
|
+
### Deduplicating Redelivery
|
|
807
|
+
|
|
808
|
+
Webhook providers redeliver. Pass an `idempotency_key:` — the provider's event id is the natural choice — and the second delivery becomes a database-level no-op that returns the row the first one wrote:
|
|
809
|
+
|
|
810
|
+
```ruby
|
|
811
|
+
signal = workflow.signal!(:payment_confirmed, idempotency_key: event["id"])
|
|
812
|
+
Rails.logger.info("Ignoring redelivered #{event["id"]}") if signal.duplicate_delivery?
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
Redelivering the very event that finished the workflow is also a no-op, not an error — that specific case is what the deduplication is for. Signals sent without an idempotency key never deduplicate: two calls make two rows, the older one is delivered first, and the newer one stays in the table as an auditable record of what arrived.
|
|
816
|
+
|
|
817
|
+
### Narrowing What Counts as a Match
|
|
818
|
+
|
|
819
|
+
`wait_for: :document_signed` is shorthand for `wait_for: GenevaDrive::SignalMatcher.new(:document_signed)`, which matches on name alone. Give the matcher a block and it also has to like the payload:
|
|
820
|
+
|
|
821
|
+
```ruby
|
|
822
|
+
class ContractWorkflow < GenevaDrive::Workflow
|
|
823
|
+
# The block receives the payload and runs on the workflow the signal was
|
|
824
|
+
# sent to, so `hero` and the workflow's own methods are in scope.
|
|
825
|
+
step :await_countersignature,
|
|
826
|
+
wait_for: GenevaDrive::SignalMatcher.new(:document_signed) { |payload|
|
|
827
|
+
payload[:document_id] == hero.contract_id
|
|
828
|
+
} do
|
|
829
|
+
hero.mark_countersigned!
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
A matcher is a plain object with no ties to the step that uses it, so a rule worth repeating can live in a constant and be shared across steps and workflows:
|
|
835
|
+
|
|
836
|
+
```ruby
|
|
837
|
+
SETTLED = GenevaDrive::SignalMatcher.new(:payment_confirmed) { |payload| payload[:amount_cents].to_i > 0 }
|
|
838
|
+
|
|
839
|
+
step :capture_payment, wait_for: SETTLED do
|
|
840
|
+
hero.capture!
|
|
841
|
+
end
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
Anything responding to `#matches?(signal)` works too, and owns the whole predicate — which is how a single step waits on either of two names:
|
|
845
|
+
|
|
846
|
+
```ruby
|
|
847
|
+
class PaymentSettled
|
|
848
|
+
def initialize(min_cents:) = @min_cents = min_cents
|
|
849
|
+
|
|
850
|
+
def matches?(signal)
|
|
851
|
+
%w[payment_confirmed payment_captured].include?(signal.name) &&
|
|
852
|
+
signal.payload[:amount_cents].to_i >= @min_cents
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
step :capture_payment, wait_for: PaymentSettled.new(min_cents: 100) do
|
|
857
|
+
hero.capture!
|
|
858
|
+
end
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
Payloads are serialized with ActiveJob serializers, so `Date`, `Time` and ActiveRecord objects survive the round trip. Hashes come back with indifferent access, because a webhook sender produces string keys and a Ruby caller produces symbols and a matcher should not have to care. The serialized payload is limited to 128 KB (`GenevaDrive.max_signal_payload_size`, `nil` disables) — a payload describes the event; the data your workflow works on belongs on the hero.
|
|
862
|
+
|
|
863
|
+
> [!WARNING]
|
|
864
|
+
> Matchers run at the gate *and* inside `signal!`, in the sender's process. Keep them cheap and free of side effects, exactly like `skip_if:`. A matcher that raises during `signal!` raises to whoever sent the signal — and because `signal!` is a single transaction, the delivery rolls back whole: no signal row is recorded, nothing is attached or woken, no counter moves. The sender (or the webhook provider's retry) re-delivers once the matcher is fixed.
|
|
865
|
+
|
|
866
|
+
### How Waiting Composes
|
|
867
|
+
|
|
868
|
+
- `wait: 2.days, wait_for: :payment_confirmed` means "no earlier than two days from now, and only once signaled". A signal arriving during the delay is buffered and claimed when the step finally runs.
|
|
869
|
+
- `skip_if:` is evaluated before waiting, so "wait for the signature — unless the contract was pre-signed" skips immediately instead of parking forever. Both `skip_if:` and `cancel_if` are re-evaluated when a signal wakes the step; a parked workflow does not re-check them while it sleeps. A step skipped at wake consumes the signal that woke it, so the next waiter does not inherit a stale event.
|
|
870
|
+
- `resumable_step` accepts `wait_for:`. The wait happens once, at the start of the chain, and `received_signal` stays the same across every successor execution.
|
|
871
|
+
- A reattempt — yours or an exception policy's — re-reads the *same* signal. Reattempting means "process this event again", not "wait for another event".
|
|
872
|
+
|
|
873
|
+
The signal itself moves through `pending` → `claimed` → `consumed`. It is claimed when an execution attaches to it, and consumed once every attached execution has finished cleanly, in the same transaction as the step's own completion. A step that fails, pauses, or gets canceled leaves its signal claimed, so the retry picks up the same payload and the audit trail does not claim an event was handled when it was not.
|
|
874
|
+
|
|
875
|
+
Skipping counts as finishing: skipping a step that was delivered a signal consumes that signal, whether the skip came from `skip_if:`, from `skip!` inside the step, from an exception policy, or from an operator calling `workflow.skip!` on a ready or paused workflow. Moving past a step deliberately settles the event it was given. Skipping a step that never attached — one still parked, or one whose `skip_if:` fired before it ever looked for a signal — leaves buffered signals exactly as they were.
|
|
876
|
+
|
|
877
|
+
Two counters on the row make that legible: `signal.claimed` counts attachments (a step that failed and was retried attaches twice), and `signal.consumed` counts cleanly resolved ones. In a linear workflow they end at one apiece. The predicates `signal.claimed?` and `signal.consumed?` read those counters, so they answer "was this event ever picked up" and "was it ever handled" — which is not the same question as `signal.state`, and is usually the one worth asking.
|
|
878
|
+
|
|
879
|
+
> [!NOTE]
|
|
880
|
+
> Two sequential steps waiting on `:payment_confirmed` need two signals. The first step consumes the first signal, so the second step parks until another one arrives.
|
|
881
|
+
|
|
882
|
+
### Getting Unstuck
|
|
883
|
+
|
|
884
|
+
There is no `timeout:` option. Waiting forever is a legitimate state — a contract may genuinely sit unsigned for months — so instead of a timer, waiting is made loud: a distinct `waiting` state, a `waiting_since` timestamp, and two housekeeping gauges, `geneva_drive.waiting_step_executions` and `geneva_drive.waiting_overdue` (parked longer than `GenevaDrive.waiting_visibility_threshold`, seven days by default). Alert on the overdue gauge.
|
|
885
|
+
|
|
886
|
+
When something does stall, three escape hatches already exist:
|
|
887
|
+
|
|
888
|
+
- `workflow.skip!` abandons the wait and moves to the next step.
|
|
889
|
+
- `workflow.cancel!` gives up on the workflow.
|
|
890
|
+
- The queue is already a clock: schedule a job that signals a deadline, and let the step decide.
|
|
891
|
+
|
|
892
|
+
```ruby
|
|
893
|
+
# Send the workflow its own deadline; the step reads it off the payload
|
|
894
|
+
class ContractDeadlineJob < ApplicationJob
|
|
895
|
+
def perform(contract)
|
|
896
|
+
workflow = ContractWorkflow.ongoing.for_hero(contract).first
|
|
897
|
+
workflow&.signal!(:document_signed,
|
|
898
|
+
payload: {timed_out: true},
|
|
899
|
+
idempotency_key: "deadline-#{workflow.id}")
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
step :await_countersignature, wait_for: :document_signed do
|
|
904
|
+
cancel! if received_signal.payload[:timed_out]
|
|
905
|
+
hero.mark_countersigned!
|
|
906
|
+
end
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
### Testing Waiting Steps
|
|
910
|
+
|
|
911
|
+
The step-driving helpers refuse to spin on a parked execution — they raise and tell you which signal is missing, because a test that hangs on a rendezvous is far worse than one that fails:
|
|
912
|
+
|
|
913
|
+
```ruby
|
|
914
|
+
test "captures once the payment is confirmed" do
|
|
915
|
+
workflow = OrderFulfillmentWorkflow.create!(hero: order)
|
|
916
|
+
perform_next_step(workflow) # :reserve_stock
|
|
917
|
+
perform_next_step(workflow) # :capture_payment parks
|
|
918
|
+
|
|
919
|
+
assert_waiting_for_signal(workflow, :payment_confirmed)
|
|
920
|
+
|
|
921
|
+
workflow.signal!(:payment_confirmed, payload: {amount_cents: 12_500})
|
|
922
|
+
speedrun_workflow(workflow)
|
|
923
|
+
|
|
924
|
+
assert_signal_state(workflow, :payment_confirmed, :consumed)
|
|
925
|
+
assert workflow.finished?
|
|
926
|
+
end
|
|
927
|
+
```
|
|
928
|
+
|
|
682
929
|
## Exception Handling
|
|
683
930
|
|
|
684
931
|
### Default Behavior
|
|
@@ -1054,8 +1301,13 @@ end
|
|
|
1054
1301
|
# Resume a specific workflow
|
|
1055
1302
|
workflow = GenevaDrive::Workflow.find(id)
|
|
1056
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)
|
|
1057
1307
|
```
|
|
1058
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
|
+
|
|
1059
1311
|
## Workflow States
|
|
1060
1312
|
|
|
1061
1313
|
### State Machine Diagram
|
|
@@ -1080,6 +1332,7 @@ Step executions have their own state machine:
|
|
|
1080
1332
|
| State | Meaning |
|
|
1081
1333
|
|-------|---------|
|
|
1082
1334
|
| `scheduled` | Waiting to run |
|
|
1335
|
+
| `waiting` | Parked until a matching signal arrives |
|
|
1083
1336
|
| `in_progress` | Currently executing |
|
|
1084
1337
|
| `completed` | Finished successfully |
|
|
1085
1338
|
| `failed` | Exception occurred |
|
|
@@ -1783,7 +2036,11 @@ The workflow will sit in `paused` state — holding the slot, preventing duplica
|
|
|
1783
2036
|
|
|
1784
2037
|
### Pause on missing step definitions
|
|
1785
2038
|
|
|
1786
|
-
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.
|
|
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).
|
|
1787
2044
|
|
|
1788
2045
|
## Complete Example Workflows
|
|
1789
2046
|
|
|
@@ -1980,6 +2237,7 @@ end
|
|
|
1980
2237
|
| State | Meaning |
|
|
1981
2238
|
|-------|---------|
|
|
1982
2239
|
| `scheduled` | Waiting to run |
|
|
2240
|
+
| `waiting` | Parked until a matching signal arrives |
|
|
1983
2241
|
| `in_progress` | Currently executing |
|
|
1984
2242
|
| `completed` | Finished successfully |
|
|
1985
2243
|
| `failed` | Exception occurred |
|
|
@@ -1993,6 +2251,7 @@ A resumable step interrupted mid-iteration completes its execution with outcome
|
|
|
1993
2251
|
| Option | Type | Description |
|
|
1994
2252
|
|--------|------|-------------|
|
|
1995
2253
|
| `wait:` | Duration | Delay before step executes |
|
|
2254
|
+
| `wait_for:` | Symbol, String, `SignalMatcher`, matcher object | Park until a matching signal arrives |
|
|
1996
2255
|
| `job_options:` | Hash | Options passed to Active Job's `set` method for this step |
|
|
1997
2256
|
| `skip_if:` | Proc, Symbol, Boolean | Condition to skip step |
|
|
1998
2257
|
| `on_exception:` | Symbol | Exception handler (`:pause!`, `:cancel!`, `:reattempt!`, `:skip!`) |
|
data/README.md
CHANGED
|
@@ -8,6 +8,8 @@ GenevaDrive provides a clean DSL for defining multi-step workflows that execute
|
|
|
8
8
|
|
|
9
9
|
- **Hero-oriented**: Workflows are associated with a polymorphic "hero" (the subject of the workflow)
|
|
10
10
|
- **Step-based execution**: Define workflows as a series of steps with optional wait times
|
|
11
|
+
- **Resumable steps**: Iterate over large collections with a database-checkpointed cursor, surviving interruptions
|
|
12
|
+
- **Signals**: Steps can park until an external event arrives (`wait_for:`), woken by `workflow.signal!` from a webhook or controller
|
|
11
13
|
- **Durable**: Steps are persisted to the database, surviving process restarts
|
|
12
14
|
- **Idempotent**: Database constraints ensure each step runs exactly once
|
|
13
15
|
- **Flow control**: Methods like `cancel!`, `pause!`, `skip!`, `reattempt!`, and `finished!`
|
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
|
|
|
@@ -65,6 +65,11 @@ module GenevaDrive
|
|
|
65
65
|
"add_resumable_step_support.rb",
|
|
66
66
|
"db/migrate/add_resumable_step_support_to_geneva_drive_step_executions.rb"
|
|
67
67
|
)
|
|
68
|
+
|
|
69
|
+
migration_template(
|
|
70
|
+
"add_signals_support.rb",
|
|
71
|
+
"db/migrate/add_signals_support_to_geneva_drive.rb"
|
|
72
|
+
)
|
|
68
73
|
end
|
|
69
74
|
|
|
70
75
|
# Creates the initializer file.
|
|
@@ -5,15 +5,11 @@ class AddResumableStepSupportToGenevaDriveStepExecutions < ActiveRecord::Migrati
|
|
|
5
5
|
|
|
6
6
|
def change
|
|
7
7
|
unless column_exists?(:geneva_drive_step_executions, :cursor)
|
|
8
|
-
# Cursor for resumable steps.
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
add_column :geneva_drive_step_executions, :cursor, :jsonb
|
|
14
|
-
else
|
|
15
|
-
add_column :geneva_drive_step_executions, :cursor, :json
|
|
16
|
-
end
|
|
8
|
+
# Cursor for resumable steps. Database-native JSON where there is one -
|
|
9
|
+
# jsonb on PostgreSQL, json on MySQL and SQLite - and plain LONGTEXT on
|
|
10
|
+
# MariaDB, which has no JSON type. See geneva_drive_json_column.
|
|
11
|
+
cursor_type, cursor_options = geneva_drive_json_column
|
|
12
|
+
add_column :geneva_drive_step_executions, :cursor, cursor_type, **cursor_options
|
|
17
13
|
end
|
|
18
14
|
|
|
19
15
|
unless column_exists?(:geneva_drive_step_executions, :continues_from_id)
|