ajdc 0.0.1 → 0.1.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 +4 -0
- data/README.md +380 -13
- data/db/durable_schema.rb +57 -0
- data/lib/active_job/continuable/callbacks.rb +48 -0
- data/lib/active_job/continuable/configurable.rb +75 -0
- data/lib/active_job/continuation/callbacks.rb +18 -0
- data/lib/active_job/continuation/rescue_handlers_first.rb +29 -0
- data/lib/active_job/durable/args_mapper.rb +41 -0
- data/lib/active_job/durable/config.rb +100 -0
- data/lib/active_job/durable/continuation.rb +24 -0
- data/lib/active_job/durable/execution.rb +438 -0
- data/lib/active_job/durable/housekeeping_job.rb +16 -0
- data/lib/active_job/durable/record.rb +17 -0
- data/lib/active_job/durable/run.rb +165 -0
- data/lib/active_job/durable/step.rb +14 -0
- data/lib/active_job/durable/wake_job.rb +16 -0
- data/lib/active_job/durable.rb +251 -0
- data/lib/ajdc/railtie.rb +18 -0
- data/lib/ajdc/version.rb +1 -1
- data/lib/ajdc.rb +1 -0
- data/lib/generators/ajdc/install/USAGE +19 -0
- data/lib/generators/ajdc/install/install_generator.rb +66 -0
- data/lib/generators/ajdc/install/templates/create_active_job_durable_tables.rb.tt +56 -0
- data/lib/generators/ajdc/install/templates/initializer.rb.tt +4 -0
- data/skills/ajdc/SKILL.md +117 -0
- data/skills/ajdc/examples/README.md +11 -0
- data/skills/ajdc/examples/agent-loop.md +57 -0
- data/skills/ajdc/examples/halting.md +84 -0
- data/skills/ajdc/examples/pipeline.md +73 -0
- data/skills/ajdc/examples/signals.md +139 -0
- data/skills/ajdc/examples/timers.md +136 -0
- data/skills/ajdc/examples/uniqueness.md +87 -0
- data/skills/ajdc/references/api.md +157 -0
- data/skills/ajdc/references/decision-guide.md +130 -0
- data/skills/ajdc/references/installation.md +98 -0
- data/skills/ajdc/references/testing.md +152 -0
- data/skills/ajdc/references/transactions.md +68 -0
- metadata +83 -7
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ajdc
|
|
3
|
+
description: Design, implement, refactor and test durable workflows on Active Job with AJ/DC (`ActiveJob::Durable`). Use when a feature is a multi-step background process, a "do this later" timer (cron sweep, `set(wait_until:)`, a `*_sent_at` claim column), an approval or webhook wait (a state column plus a controller action that re-enqueues), a job that re-enqueues itself, a status enum that tracks a job's position, or a job that must survive a crash and be resumable. Also use when the user asks whether to use a workflow at all.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# AJ/DC: durable workflows on Active Job
|
|
7
|
+
|
|
8
|
+
AJ/DC makes an `ActiveJob::Continuable` job durable: the run and its steps are rows in the
|
|
9
|
+
database, so progress survives a crash (not only a graceful stop), a run can wait for a time or
|
|
10
|
+
for a signal without holding a worker or a queue slot, and every run is identified and queryable.
|
|
11
|
+
Same `step` DSL, same job runner, same `perform_later`.
|
|
12
|
+
|
|
13
|
+
Read `references/api.md` for the full surface. This file is the decision procedure.
|
|
14
|
+
|
|
15
|
+
## 1. Decide whether a workflow is the right tool
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Is it background work with more than one step, or a wait?
|
|
19
|
+
├── no → a plain job. Stop.
|
|
20
|
+
└── yes
|
|
21
|
+
├── 1. Does the process wait: for a time, or for someone (approval, webhook, reply)?
|
|
22
|
+
├── 2. Does anyone need to find a run by its subject, see where it is, or
|
|
23
|
+
│ continue it after a failure from where it stopped?
|
|
24
|
+
├── both no → ActiveJob::Continuable (Rails 8.1) if a loop must survive a deploy;
|
|
25
|
+
│ otherwise a plain job. Stop.
|
|
26
|
+
└── at least one yes → ActiveJob::Durable. Then:
|
|
27
|
+
├── it waits (1): does the wait belong to one entity, with steps before or after it?
|
|
28
|
+
│ ├── yes → a run per entity: wait:, wait_until:, await
|
|
29
|
+
│ └── no, it is a policy over a table → keep the cron sweep; hybrid for far-future
|
|
30
|
+
│ dates (references/decision-guide.md §1)
|
|
31
|
+
└── someone watches (2): name the identity (identified_by), decide whether a second
|
|
32
|
+
live run is allowed (unique_by), decide what a person fixes (halt_on).
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Questions 1 and 2 are the only decisive ones. Uniqueness and per-step error rules are choices
|
|
36
|
+
made once inside, not reasons to enter.
|
|
37
|
+
|
|
38
|
+
## 2. Recognize the shape in existing code
|
|
39
|
+
|
|
40
|
+
Each row answers question 1 or 2 of the tree.
|
|
41
|
+
|
|
42
|
+
| You see | It is | Durable shape |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| Job A enqueues job B enqueues job C, each flips a status | pipeline | one job, `step :a; step :b; step :c` |
|
|
45
|
+
| a state machine gem on a record plus a job that re-enqueues itself | pipeline | steps, `isolated: true` where each step must be its own execution |
|
|
46
|
+
| `set(wait_until:)` plus an `updated_at` argument compared at wake | timer with hand-made invalidation | `step :x, wait_until:` plus `unique_by ..., on_conflict: :replace` |
|
|
47
|
+
| a cron entry that exists only to check a date column | timer as sweep | `step :x, wait_until: record.date` per entity, or keep the sweep (see guide) |
|
|
48
|
+
| `*_sent_at` / `*_notified_at` claim columns per reminder | timers as columns | one step per reminder, `wait_until:` |
|
|
49
|
+
| a controller action that flips a state and calls `perform_later` | signal | `await :name`, `run.wake_up(:name, value)` from the action |
|
|
50
|
+
| a webhook handler that finds a record by token and continues the flow | signal | `await :name, wait:` with a deadline, `unique_by` on the record |
|
|
51
|
+
| a class that serializes "the last run" into a settings row | run record | delete it; `MyJob.workflow_runs.first` |
|
|
52
|
+
|
|
53
|
+
Secondary signals. They confirm that a workflow fits and name a feature to use once inside;
|
|
54
|
+
they do not start one:
|
|
55
|
+
|
|
56
|
+
- a dedupe guard (`Model.exists?(external_id:)`) next to a per-id `limits_concurrency` key:
|
|
57
|
+
`unique_by`, usually `:skip`;
|
|
58
|
+
- a `rescue` inside a step body that flips a status column and returns, so a person can retry
|
|
59
|
+
later: `halt_on` for that error class;
|
|
60
|
+
- a `retry_on`/`discard_on` list that grows with every new error class: the three verbs,
|
|
61
|
+
`retry_on`, `discard_on`, `halt_on`, and the rest stays as it is.
|
|
62
|
+
|
|
63
|
+
## 3. Implement
|
|
64
|
+
|
|
65
|
+
If the gem is not installed yet, follow `references/installation.md` first: it decides between
|
|
66
|
+
the primary and a separate database (the transaction property depends on it) and adds the
|
|
67
|
+
recurring wake job, without which no timer or deadline fires.
|
|
68
|
+
|
|
69
|
+
1. Decide where each piece of state lives. Facts about the record (`paid`, `signed`) stay on
|
|
70
|
+
the model. The process position (`analyzing`, `needs_review`, `generating`) moves to the run.
|
|
71
|
+
Never mirror one into the other.
|
|
72
|
+
2. `include ActiveJob::Durable` instead of `ActiveJob::Continuable`. Keep `step`, cursors and
|
|
73
|
+
`attribute` as they are. Run the existing tests; nothing should change.
|
|
74
|
+
3. Name the identity. Default key = all arguments. Declare `identified_by` when other arguments
|
|
75
|
+
must not split the key, `unique_by` when only one live run per identity may exist. Pick
|
|
76
|
+
`on_conflict`: `:skip` for sweeps and double clicks, `:reject` for money, `:replace` for
|
|
77
|
+
reschedules and renewals. See `references/api.md` §2.
|
|
78
|
+
4. Move waits into steps: `wait:` for "N after the previous step", `wait_until:` for "at this
|
|
79
|
+
time"; both take a value or a callable. Check that `ActiveJob::Durable::WakeJob` is scheduled
|
|
80
|
+
(`references/installation.md` §3).
|
|
81
|
+
5. Move signals into `await :name, wait: deadline` plus a method `name(value)`; deliver with
|
|
82
|
+
`run.wake_up(:name, value)` from the controller, webhook or console. `nil` means the deadline
|
|
83
|
+
passed. Add a model method that finds the run (`payout.job_run`) instead of a class-level
|
|
84
|
+
helper.
|
|
85
|
+
6. Classify errors: `retry_on` (the machine tries again), `discard_on` (give up), `halt_on` (a
|
|
86
|
+
person fixes it, then `run.resume!`). `halt!(reason)` from inside a step is the same stop
|
|
87
|
+
without an exception.
|
|
88
|
+
7. Replace `after_transition`/broadcast calls inside steps with `after_step` /
|
|
89
|
+
`before_step` / `around_step`; read `current_step.name` inside the callback.
|
|
90
|
+
8. Replace hand-written run records, status enums that track position, and sweeps that only
|
|
91
|
+
check a date. Keep the model facts.
|
|
92
|
+
9. Check the transaction story in `references/transactions.md` for every `perform_later`,
|
|
93
|
+
`wake_up`, `cancel!` you add inside a `transaction`/`with_lock` block.
|
|
94
|
+
10. Write the tests per `references/testing.md` before the refactor, against the old behaviour,
|
|
95
|
+
then switch.
|
|
96
|
+
|
|
97
|
+
## 4. Do not
|
|
98
|
+
|
|
99
|
+
- Do not add a `status` enum to a model to mirror the run. Query `workflow_runs`.
|
|
100
|
+
- Do not `await` inside a loop with a fixed name; step names are unique per run, so the second
|
|
101
|
+
pass would skip it. For a repeated decision that needs no value (a tool approval already
|
|
102
|
+
recorded on a model), `halt!` in the loop and `resume!` from the outside. For a known set of
|
|
103
|
+
valued decisions, one `await` per name: `await :"decision_#{approver.id}"`. For an unknown
|
|
104
|
+
number of valued decisions, store the value on a model and use `halt!`; see
|
|
105
|
+
`examples/agent-loop.md` and `references/decision-guide.md` §3.
|
|
106
|
+
- Do not `sleep` inside a step; use `wait:` on the next step.
|
|
107
|
+
- Do not call `wake_up`, `resume!` or `cancel!` on a run loaded before a long operation; reload
|
|
108
|
+
or re-find it, the guarded update fails on a stale status.
|
|
109
|
+
- Do not put `wait:` and `wait_until:` on the same step.
|
|
110
|
+
- Do not replace a sweep that is a policy over millions of rows with millions of parked runs
|
|
111
|
+
without reading `references/decision-guide.md` §2.
|
|
112
|
+
|
|
113
|
+
## 5. Report
|
|
114
|
+
|
|
115
|
+
When you finish, list: the jobs converted, the columns/classes/cron entries removed, the
|
|
116
|
+
`unique_by` strategy chosen and why, the recurring `WakeJob` entry added, and the queries the
|
|
117
|
+
UI or ops now use (`workflow_runs.awaiting`, `.halted`, `.stuck_for(...)`).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Examples: before and after
|
|
2
|
+
|
|
3
|
+
Each file shows a real shape found in Rails apps and its `ActiveJob::Durable` version. Names
|
|
4
|
+
are generic. Read the one that matches the shape you found (see SKILL.md §2).
|
|
5
|
+
|
|
6
|
+
- `pipeline.md`: a chain of jobs with a status enum, or a state machine with a self-re-enqueuing job
|
|
7
|
+
- `timers.md`: cron sweeps and `set(wait_until:)` with invalidation
|
|
8
|
+
- `signals.md`: a state column plus a controller action; a webhook that continues a flow
|
|
9
|
+
- `halting.md`: an error a person fixes; rescue-inside-the-step shapes
|
|
10
|
+
- `uniqueness.md`: one live run per identity; `:skip`, `:replace`, `:reject`; identity inside an argument
|
|
11
|
+
- `agent-loop.md`: an LLM loop with a tool approval
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Agent loop with a tool approval
|
|
2
|
+
|
|
3
|
+
## Before (ruby_llm durable-agents guide): one loop step, a finished job on approval
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class AgentRunJob < ApplicationJob
|
|
7
|
+
include ActiveJob::Continuable
|
|
8
|
+
|
|
9
|
+
def perform(chat_id)
|
|
10
|
+
step :agent_loop do |job_step|
|
|
11
|
+
chat = Chat.find(chat_id)
|
|
12
|
+
until chat.complete?
|
|
13
|
+
chat.step
|
|
14
|
+
job_step.checkpoint!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class ApprovalsController < ApplicationController
|
|
21
|
+
def create
|
|
22
|
+
chat = Chat.find(params[:chat_id])
|
|
23
|
+
params[:approved] == "true" ? chat.approve(params[:tool_call_id]) : chat.deny(params[:tool_call_id])
|
|
24
|
+
CompleteJob.perform_later(chat.id)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## After
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
class AgentRunJob < ApplicationJob
|
|
33
|
+
include ActiveJob::Durable
|
|
34
|
+
|
|
35
|
+
def perform(chat)
|
|
36
|
+
step :run do |step|
|
|
37
|
+
until chat.complete?
|
|
38
|
+
chat.step
|
|
39
|
+
halt!(:tool_approval) if chat.awaiting_approval?
|
|
40
|
+
step.checkpoint!
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class ApprovalsController < ApplicationController
|
|
47
|
+
def create
|
|
48
|
+
chat = Chat.find(params[:chat_id])
|
|
49
|
+
params[:approved] == "true" ? chat.approve(params[:tool_call_id]) : chat.deny(params[:tool_call_id])
|
|
50
|
+
chat.agent_run.resume!
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`halt!`, not `await`: the pending tool call is already a row in ruby_llm's tables, so the run
|
|
56
|
+
needs a wake, not a payload. `resume!` re-enters the loop step; `chat.step` runs the approved
|
|
57
|
+
tool and skips the ones with results. Halted runs: `AgentRunJob.workflow_runs.halted`.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Halting: an error a person fixes, then the run continues
|
|
2
|
+
|
|
3
|
+
## Before: rescue inside the step, a status column, a restart from scratch
|
|
4
|
+
|
|
5
|
+
The shape from Spree's CSV import: `discard_on` would never see an error raised after progress,
|
|
6
|
+
so the step rescues, marks the import failed, and stops. A retry starts the job over.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
class Imports::ProcessJob < ApplicationJob
|
|
10
|
+
include ActiveJob::Continuable
|
|
11
|
+
|
|
12
|
+
def perform(import)
|
|
13
|
+
@import = import
|
|
14
|
+
step :create_rows, start: 1
|
|
15
|
+
return if @csv_failed
|
|
16
|
+
step :dispatch_row_groups
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
def create_rows(step)
|
|
21
|
+
CSV.foreach(@import.file, headers: true).with_index(1) do |row, number|
|
|
22
|
+
next if number < step.cursor
|
|
23
|
+
ImportRow.create!(import: @import, number:, data: row.to_h)
|
|
24
|
+
step.set!(number)
|
|
25
|
+
end
|
|
26
|
+
rescue StorageQuotaExceeded, CSV::MalformedCSVError => e
|
|
27
|
+
@import.update_columns(status: :failed, processing_errors: e.message)
|
|
28
|
+
@csv_failed = true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# admin "Retry": import.update!(status: :pending); Imports::ProcessJob.perform_later(import) # from row 1
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## After: `halt_on`, `resume!` from the cursor
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class Imports::ProcessJob < ApplicationJob
|
|
39
|
+
include ActiveJob::Durable
|
|
40
|
+
|
|
41
|
+
discard_on CSV::MalformedCSVError # nothing can fix the file
|
|
42
|
+
halt_on StorageQuotaExceeded # a person upgrades the plan, then the run continues
|
|
43
|
+
|
|
44
|
+
def perform(import)
|
|
45
|
+
@import = import
|
|
46
|
+
step :create_rows, start: 1
|
|
47
|
+
step :dispatch_row_groups
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
def create_rows(step)
|
|
52
|
+
CSV.foreach(@import.file, headers: true).with_index(1) do |row, number|
|
|
53
|
+
next if number < step.cursor
|
|
54
|
+
ImportRow.create!(import: @import, number:, data: row.to_h)
|
|
55
|
+
step.set!(number)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# after the plan upgrade:
|
|
61
|
+
run = Imports::ProcessJob.workflow_runs.for(import).halted.sole
|
|
62
|
+
run.error_class # => "StorageQuotaExceeded"
|
|
63
|
+
run.current_step # => "create_rows"
|
|
64
|
+
run.resume! # continues from the last committed row number
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Three verbs for three kinds of failure: `retry_on` is the machine trying again, `discard_on` is
|
|
68
|
+
giving up, `halt_on` is a person deciding. The rescue, the flag and the status column are gone;
|
|
69
|
+
the import's own facts stay on the import. A halted run is in `workflow_runs.attention` with its
|
|
70
|
+
error until someone resumes or cancels it.
|
|
71
|
+
|
|
72
|
+
## `halt!`: the same stop without an exception
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
def check(name)
|
|
76
|
+
result = public_send(:"check_#{name}")
|
|
77
|
+
self.error_msg = result.reason
|
|
78
|
+
halt!(result.level) unless result.level == :success # halt_reason = "warning" / "critical"
|
|
79
|
+
metadata[name] = result.data
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`resume!` re-runs the halted step. Use `halt!` when the stop is a decision, not an error; use
|
|
84
|
+
`await` instead when the decision carries a value the run needs (see `signals.md`).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Pipeline
|
|
2
|
+
|
|
3
|
+
## Before: a chain of jobs and a status enum
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class AnalyzeCardJob < ApplicationJob
|
|
7
|
+
def perform(card)
|
|
8
|
+
card.analyzing!
|
|
9
|
+
if NSFWDetector.check(card.image) then card.analyzed!; GenerateCardJob.perform_later(card)
|
|
10
|
+
else card.fail!("NSFW check failed") end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class GenerateCardJob < ApplicationJob
|
|
15
|
+
def perform(card)
|
|
16
|
+
card.generating!
|
|
17
|
+
card.attach_rendition(CardPainter.paint(card))
|
|
18
|
+
card.generated!
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Before: a state machine and a job that re-enqueues itself
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
class Cable::DiagnosticJob < ApplicationJob
|
|
27
|
+
def perform(diagnostic)
|
|
28
|
+
level = diagnostic.check! # runs the check for the current state
|
|
29
|
+
return diagnostic.update!(state: :halted, halted_reason: level) unless level == :success
|
|
30
|
+
diagnostic.next!
|
|
31
|
+
self.class.perform_later(diagnostic) unless diagnostic.completed?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## After
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
class Cable::DiagnosticJob < ApplicationJob
|
|
40
|
+
include ActiveJob::Durable
|
|
41
|
+
|
|
42
|
+
attribute :metadata, default: {}
|
|
43
|
+
attribute :error_msg, :string
|
|
44
|
+
|
|
45
|
+
after_step :broadcast_update
|
|
46
|
+
|
|
47
|
+
def perform(cable)
|
|
48
|
+
@cable = cable
|
|
49
|
+
step :provider_status, isolated: true
|
|
50
|
+
step :websocket_status, isolated: true
|
|
51
|
+
step :admin_api_status, isolated: true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
def provider_status = check(:provider_status)
|
|
56
|
+
def websocket_status = check(:websocket_status)
|
|
57
|
+
def admin_api_status = check(:admin_api_status)
|
|
58
|
+
|
|
59
|
+
def check(name)
|
|
60
|
+
result = public_send(:"check_#{name}")
|
|
61
|
+
self.error_msg = result.reason
|
|
62
|
+
halt!(result.level) unless result.level == :success
|
|
63
|
+
metadata[name] = result.data
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def broadcast_update = @cable.broadcast_replace(partial: "cables/diagnostic", locals: { step: current_step.name })
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
What moved: the transitions table is the three `step` lines; "one check per job run" is
|
|
71
|
+
`isolated: true`; the broadcast is one `after_step`; the halt has a reason and is resumable; the
|
|
72
|
+
UI reads the run (`status`, `current_step`, `state`) instead of a cached record. The card's own
|
|
73
|
+
facts (`generated`, `failed`) stay on the card.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Signals
|
|
2
|
+
|
|
3
|
+
## Before: a state column, a controller action, a worker with no memory, a vacuum
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class BulkImport < ApplicationRecord
|
|
7
|
+
CONFIRM_PERIOD = 10.minutes
|
|
8
|
+
enum :state, { unconfirmed: 0, scheduled: 1, in_progress: 2, finished: 3 }, prefix: true
|
|
9
|
+
scope :confirmation_missed, -> { state_unconfirmed.where(created_at: ..CONFIRM_PERIOD.ago) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Settings::ImportsController
|
|
13
|
+
def confirm
|
|
14
|
+
@bulk_import.update!(state: :scheduled)
|
|
15
|
+
BulkImportWorker.perform_async(@bulk_import.id)
|
|
16
|
+
redirect_to settings_imports_path
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Vacuum::ImportsVacuum # daily
|
|
21
|
+
def perform = BulkImport.confirmation_missed.in_batches.delete_all
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## After
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class BulkImportJob < ApplicationJob
|
|
29
|
+
include ActiveJob::Durable
|
|
30
|
+
|
|
31
|
+
attribute :confirmed, :boolean, default: false
|
|
32
|
+
|
|
33
|
+
def perform(import)
|
|
34
|
+
await :confirmation, wait: 10.minutes
|
|
35
|
+
return import.destroy! unless confirmed
|
|
36
|
+
step :apply do
|
|
37
|
+
BulkImportService.new.call(import)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def confirmation(signal) = self.confirmed = signal.presence
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Form::Import#save, after the rows are inserted: BulkImportJob.perform_later(bulk_import)
|
|
45
|
+
# Settings::ImportsController#confirm: @bulk_import.job_run.wake_up(:confirmation, true)
|
|
46
|
+
# BulkImport#job_run: BulkImportJob.workflow_runs.for(self).live.sole
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The vacuum is gone because the deadline fires on the clock's next tick. Ops:
|
|
50
|
+
`BulkImportJob.workflow_runs.awaiting.count`, `.at_step(:apply).stuck_for(1.hour)`, `.attention`.
|
|
51
|
+
|
|
52
|
+
## Before: a payout with two entry points tied by a token
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
class WithdrawFundsJob < ApplicationJob
|
|
56
|
+
def perform(payout, wallet_id, amount) = Wallet::Withdraw.(wallet_id:, amount:, payout:)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class PaymentWebhooksController
|
|
60
|
+
def create
|
|
61
|
+
ProcessPaymentJob.perform_later(*params.slice(:token, :status, :amount))
|
|
62
|
+
head :ok
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class ProcessPaymentJob < ApplicationJob
|
|
67
|
+
def perform(token, status, amount)
|
|
68
|
+
payout = Payout.find_by!(token:)
|
|
69
|
+
Ledger.credit!(payout.account, amount)
|
|
70
|
+
payout.completed!
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## After
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
class PayoutJob < ApplicationJob
|
|
79
|
+
include ActiveJob::Durable
|
|
80
|
+
|
|
81
|
+
unique_by :payout, on_conflict: :reject
|
|
82
|
+
attribute :payment
|
|
83
|
+
|
|
84
|
+
def perform(payout, wallet_id, amount)
|
|
85
|
+
@payout = payout
|
|
86
|
+
step :withdraw do
|
|
87
|
+
Wallet::Withdraw.(wallet_id:, amount:, payout:)
|
|
88
|
+
end
|
|
89
|
+
await :payment, wait: 3.days
|
|
90
|
+
step :credit
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def payment(signal)
|
|
94
|
+
halt!(:payment_overdue) if signal.nil?
|
|
95
|
+
halt!(:payment_failed) if signal["status"] == "failed"
|
|
96
|
+
self.payment = signal
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def credit
|
|
100
|
+
Ledger.credit!(@payout.account, payment["amount"])
|
|
101
|
+
@payout.completed!
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class PaymentWebhooksController
|
|
106
|
+
def create
|
|
107
|
+
payout = Payout.find_by!(token: params[:token])
|
|
108
|
+
payout.job_run.wake_up(:payment, params.slice(:status, :amount).to_h)
|
|
109
|
+
head :ok
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A halted run sits in `PayoutJob.workflow_runs.attention` with a reason; nothing is silently
|
|
115
|
+
stuck. `unique_by :payout` is what lets the webhook find the run with the payout alone.
|
|
116
|
+
|
|
117
|
+
## Human review with a deadline
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
class CardGenerationJob < ApplicationJob
|
|
121
|
+
include ActiveJob::Durable
|
|
122
|
+
|
|
123
|
+
attribute :verdict, :string
|
|
124
|
+
|
|
125
|
+
def perform(card)
|
|
126
|
+
@card = card
|
|
127
|
+
step :prepare, isolated: true
|
|
128
|
+
step :moderate, isolated: true
|
|
129
|
+
await :review, wait: 1.hour if verdict == "unsure"
|
|
130
|
+
step :generate, isolated: true unless verdict == "rejected"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def moderate = self.verdict = Moderator.verdict(@card)
|
|
134
|
+
def review(value) = self.verdict = value || "rejected" # nil at the deadline
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Admin action "Approve": card.generation_run.wake_up(:review, "approved")
|
|
138
|
+
# Review queue: CardGenerationJob.workflow_runs.awaiting.at_step(:review)
|
|
139
|
+
```
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Timers
|
|
2
|
+
|
|
3
|
+
## Before: three cron entries and three sweeps
|
|
4
|
+
|
|
5
|
+
```yaml
|
|
6
|
+
license_expiration_reminder: { class: License::ReminderJob, args: [60], schedule: "26 * * * *" }
|
|
7
|
+
license_expiration: { class: License::ExpirationJob, schedule: "13 */2 * * *" }
|
|
8
|
+
license_revoke: { class: License::RevokeAccessJob, schedule: "51 */12 * * *" }
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class License::ReminderJob < ApplicationJob
|
|
13
|
+
def perform(interval, now = Time.current)
|
|
14
|
+
future = now + 2.weeks
|
|
15
|
+
shift = future.to_i % interval # bucket so each license gets one reminder
|
|
16
|
+
License.where(expires_at: (future - shift)...(future + interval - shift), status: [:trial, :active])
|
|
17
|
+
.find_each { LicenseDelivery.with(license: it).license_expiring_two_weeks.deliver_later }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## After: one run per license
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class License::LifecycleJob < ApplicationJob
|
|
26
|
+
include ActiveJob::Durable
|
|
27
|
+
|
|
28
|
+
unique_by :license, on_conflict: :replace
|
|
29
|
+
|
|
30
|
+
def perform(license)
|
|
31
|
+
@license = license
|
|
32
|
+
step :remind, wait_until: license.expires_at - 2.weeks
|
|
33
|
+
step :expire, wait_until: license.expires_at
|
|
34
|
+
step :revoke, wait: 2.weeks
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def remind = LicenseDelivery.with(license: @license).license_expiring_two_weeks.deliver_later
|
|
38
|
+
def expire = @license.expired!.then { LicenseDelivery.with(license: @license).license_expired.deliver_later }
|
|
39
|
+
def revoke = @license.revoke_later.then { LicenseDelivery.with(license: @license).access_revoked.deliver_later }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# License: after_create_commit, and after renewal: License::LifecycleJob.perform_later(self)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Renewal restarts the lifecycle with `:replace`, because `remind` is already completed and the
|
|
46
|
+
new term needs its own reminder. A moved `expires_at` while waiting re-arms by itself.
|
|
47
|
+
|
|
48
|
+
## Before: a parked job with hand-made invalidation, then a retreat to a sweep
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
class AppointmentNotificationJob < ApplicationJob
|
|
52
|
+
def self.schedule(appointment)
|
|
53
|
+
set(wait_until: appointment.start_at - 30.minutes).perform_later(appointment.id, appointment.updated_at)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def perform(appointment_id, updated_at)
|
|
57
|
+
appointment = PatientAppointment.lock.find(appointment_id)
|
|
58
|
+
return unless appointment.updated_at.to_i == updated_at.to_i # rescheduled meanwhile? drop
|
|
59
|
+
return if appointment.notification_sent_at # claim column
|
|
60
|
+
appointment.update!(notification_sent_at: Time.zone.now)
|
|
61
|
+
AppointmentNotification.with(appointment:).deliver_later(appointment.patient)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## After
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class AppointmentReminderJob < ApplicationJob
|
|
70
|
+
include ActiveJob::Durable
|
|
71
|
+
|
|
72
|
+
unique_by :appointment, on_conflict: :replace
|
|
73
|
+
|
|
74
|
+
def perform(appointment)
|
|
75
|
+
step :remind, wait_until: appointment.start_at - 30.minutes do
|
|
76
|
+
next if appointment.hide_from_patient?
|
|
77
|
+
AppointmentNotification.with(appointment:).deliver_later(appointment.patient)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# PatientAppointment: after_create_commit, and after_update_commit when start_at changed:
|
|
83
|
+
# AppointmentReminderJob.perform_later(self)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
No claim column, no `updated_at` argument, no sweep. A parked run has an identity.
|
|
87
|
+
|
|
88
|
+
## Before: a grace period as a scope and a sweep
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
class Account::IncinerateDueJob < ApplicationJob
|
|
92
|
+
include ActiveJob::Continuable
|
|
93
|
+
def perform
|
|
94
|
+
step :incineration do |step|
|
|
95
|
+
Account.due_for_incineration.find_each { it.incinerate; step.checkpoint! } # cancellation older than 30 days
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## After: the model starts and cancels the run, inside its lock
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
module Account::Cancellable
|
|
105
|
+
def cancel
|
|
106
|
+
with_lock do
|
|
107
|
+
next unless cancellable? && active?
|
|
108
|
+
create_cancellation!(initiated_by: Current.user)
|
|
109
|
+
Account::IncinerationJob.perform_later(self) # row now, message after commit
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def reactivate
|
|
114
|
+
with_lock do
|
|
115
|
+
next unless cancelled?
|
|
116
|
+
cancellation.destroy
|
|
117
|
+
Account::IncinerationJob.workflow_runs.for(self).live.sole.cancel!
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
class Account::IncinerationJob < ApplicationJob
|
|
123
|
+
include ActiveJob::Durable
|
|
124
|
+
|
|
125
|
+
unique_by :account
|
|
126
|
+
|
|
127
|
+
def perform(account)
|
|
128
|
+
step :incinerate, wait: 30.days do
|
|
129
|
+
account.incinerate
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The sweep was a fine design; this version buys one row per pending deletion that anyone can
|
|
136
|
+
query and a reactivation that cannot race the sweep.
|