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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +380 -13
  4. data/db/durable_schema.rb +57 -0
  5. data/lib/active_job/continuable/callbacks.rb +48 -0
  6. data/lib/active_job/continuable/configurable.rb +75 -0
  7. data/lib/active_job/continuation/callbacks.rb +18 -0
  8. data/lib/active_job/continuation/rescue_handlers_first.rb +29 -0
  9. data/lib/active_job/durable/args_mapper.rb +41 -0
  10. data/lib/active_job/durable/config.rb +100 -0
  11. data/lib/active_job/durable/continuation.rb +24 -0
  12. data/lib/active_job/durable/execution.rb +438 -0
  13. data/lib/active_job/durable/housekeeping_job.rb +16 -0
  14. data/lib/active_job/durable/record.rb +17 -0
  15. data/lib/active_job/durable/run.rb +165 -0
  16. data/lib/active_job/durable/step.rb +14 -0
  17. data/lib/active_job/durable/wake_job.rb +16 -0
  18. data/lib/active_job/durable.rb +251 -0
  19. data/lib/ajdc/railtie.rb +18 -0
  20. data/lib/ajdc/version.rb +1 -1
  21. data/lib/ajdc.rb +1 -0
  22. data/lib/generators/ajdc/install/USAGE +19 -0
  23. data/lib/generators/ajdc/install/install_generator.rb +66 -0
  24. data/lib/generators/ajdc/install/templates/create_active_job_durable_tables.rb.tt +56 -0
  25. data/lib/generators/ajdc/install/templates/initializer.rb.tt +4 -0
  26. data/skills/ajdc/SKILL.md +117 -0
  27. data/skills/ajdc/examples/README.md +11 -0
  28. data/skills/ajdc/examples/agent-loop.md +57 -0
  29. data/skills/ajdc/examples/halting.md +84 -0
  30. data/skills/ajdc/examples/pipeline.md +73 -0
  31. data/skills/ajdc/examples/signals.md +139 -0
  32. data/skills/ajdc/examples/timers.md +136 -0
  33. data/skills/ajdc/examples/uniqueness.md +87 -0
  34. data/skills/ajdc/references/api.md +157 -0
  35. data/skills/ajdc/references/decision-guide.md +130 -0
  36. data/skills/ajdc/references/installation.md +98 -0
  37. data/skills/ajdc/references/testing.md +152 -0
  38. data/skills/ajdc/references/transactions.md +68 -0
  39. 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.