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,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Durable
|
|
5
|
+
# One row per run of a durable job: from the first enqueue to a terminal status,
|
|
6
|
+
# across every retry, interrupt and resume.
|
|
7
|
+
class Run < Record
|
|
8
|
+
LIVE_STATUSES = %w[enqueued running waiting awaiting].freeze
|
|
9
|
+
ATTENTION_STATUSES = %w[failed halted].freeze
|
|
10
|
+
TERMINAL_STATUSES = %w[completed discarded cancelled].freeze
|
|
11
|
+
STATUSES = (LIVE_STATUSES + ATTENTION_STATUSES + TERMINAL_STATUSES).freeze
|
|
12
|
+
CANCELLABLE_STATUSES = (LIVE_STATUSES + ATTENTION_STATUSES).freeze
|
|
13
|
+
WAKEABLE_STATUSES = %w[waiting awaiting].freeze
|
|
14
|
+
|
|
15
|
+
# The `state` column keeps the attribute values in Active Job argument form
|
|
16
|
+
# (the form `ActiveJob::Attributes` restores from), while `Run#state` reads as
|
|
17
|
+
# a plain hash: `{"verdict" => "unsure"}`. Writes accept either form.
|
|
18
|
+
class StateType < ActiveRecord::Type::Json
|
|
19
|
+
def deserialize(value)
|
|
20
|
+
decoded = super
|
|
21
|
+
decoded.present? ? ActiveJob::Arguments.deserialize([decoded]).first : {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def serialize(value) = super(self.class.serialized(value))
|
|
25
|
+
|
|
26
|
+
# The argument form of `value`; an already-serialized hash (marked with
|
|
27
|
+
# `_aj_` keys) is returned as is.
|
|
28
|
+
def self.serialized(value)
|
|
29
|
+
value = value.to_h
|
|
30
|
+
if value.each_key.any? { |key| key.to_s.start_with?("_aj_") }
|
|
31
|
+
value
|
|
32
|
+
else
|
|
33
|
+
ActiveJob::Arguments.serialize([value]).first
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
self.table_name = "active_job_durable_runs"
|
|
39
|
+
|
|
40
|
+
has_many :steps, -> { order(:position, :attempt) }, class_name: "ActiveJob::Durable::Step", dependent: :delete_all
|
|
41
|
+
|
|
42
|
+
attribute :completed_steps, default: -> { [] }
|
|
43
|
+
attribute :state, StateType.new, default: -> { {} }
|
|
44
|
+
attribute :pending_signals, default: -> { {} }
|
|
45
|
+
|
|
46
|
+
scope :newest_first, -> { order(created_at: :desc, id: :desc) }
|
|
47
|
+
scope :live, -> { where(status: LIVE_STATUSES) }
|
|
48
|
+
scope :attention, -> { where(status: ATTENTION_STATUSES) }
|
|
49
|
+
scope :terminal, -> { where(status: TERMINAL_STATUSES) }
|
|
50
|
+
STATUSES.each { |status| scope status, -> { where(status:) } }
|
|
51
|
+
|
|
52
|
+
scope :at_step, ->(name) { where(current_step: name.to_s) }
|
|
53
|
+
# A parked run is stuck when its wake time passed and the clock did not wake it.
|
|
54
|
+
scope :stuck_for, ->(duration) {
|
|
55
|
+
since = duration.ago
|
|
56
|
+
where(status: "running").where(last_heartbeat_at: ...since)
|
|
57
|
+
.or(where(status: "enqueued").where(transitioned_at: ...since))
|
|
58
|
+
.or(where(status: WAKEABLE_STATUSES).where(wake_at: ...since))
|
|
59
|
+
}
|
|
60
|
+
scope :due, ->(now = Time.current) { where(status: WAKEABLE_STATUSES, wake_at: ..now) }
|
|
61
|
+
scope :for, ->(*args, **kwargs) {
|
|
62
|
+
next where(key: kwargs[:workflow_key]) if kwargs.key?(:workflow_key)
|
|
63
|
+
|
|
64
|
+
job_class = where_values_hash["job_class"] or raise ArgumentError, "for needs a job class; use MyJob.workflow_runs.for(...)"
|
|
65
|
+
where(key: job_class.constantize.durable_config.workflow_key(*args, **kwargs))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def self.wake_due(now = Time.current)
|
|
69
|
+
due(now).find_each.count { |run| run.reenqueue_parked_job!(from: WAKEABLE_STATUSES) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Deletes the terminal runs that ended before `ended_before`, with their
|
|
73
|
+
# steps, one batch per transaction; returns how many. A terminal row is
|
|
74
|
+
# never written again, so `transitioned_at` is when it ended (and is indexed).
|
|
75
|
+
def self.clear_terminal(ended_before:, batch_size: 1_000)
|
|
76
|
+
terminal.where(transitioned_at: ...ended_before).in_batches(of: batch_size).sum do |batch|
|
|
77
|
+
ids = batch.ids
|
|
78
|
+
transaction do
|
|
79
|
+
Step.where(run_id: ids).delete_all
|
|
80
|
+
where(id: ids).delete_all
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def live? = LIVE_STATUSES.include?(status)
|
|
86
|
+
|
|
87
|
+
def attention? = ATTENTION_STATUSES.include?(status)
|
|
88
|
+
|
|
89
|
+
def terminal? = TERMINAL_STATUSES.include?(status)
|
|
90
|
+
|
|
91
|
+
# The attribute values in the form the job restores them from.
|
|
92
|
+
def serialized_state = StateType.serialized(state)
|
|
93
|
+
|
|
94
|
+
# Puts a `halted` or `failed` run back in the queue, in place: the step that
|
|
95
|
+
# stopped re-runs from its cursor as a new attempt, and the step rows keep
|
|
96
|
+
# the earlier attempts with their errors. Raises `NotResumable` for any
|
|
97
|
+
# other status, or when another caller resumed the run first.
|
|
98
|
+
def resume!
|
|
99
|
+
reenqueue_parked_job!(from: ATTENTION_STATUSES) ||
|
|
100
|
+
raise(NotResumable, "Run #{id} is #{reload.status}; only a halted or failed run can be resumed")
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Ends a run that is not terminal, in one status-guarded update, and returns
|
|
105
|
+
# it reloaded. Never touches the queue: a queued job for a cancelled run
|
|
106
|
+
# performs nothing, a running one stops at its next checkpoint. Raises
|
|
107
|
+
# `NotCancellable` for a terminal run, or when another caller ended it first.
|
|
108
|
+
def cancel!
|
|
109
|
+
now = Time.current
|
|
110
|
+
updated = self.class.where(id:, status: CANCELLABLE_STATUSES).update_all(
|
|
111
|
+
status: "cancelled", active_key: nil, parked_job: nil, finished_at: now, transitioned_at: now, updated_at: now
|
|
112
|
+
)
|
|
113
|
+
raise NotCancellable, "Run #{id} is #{reload.status}; a terminal run cannot be cancelled" if updated.zero?
|
|
114
|
+
|
|
115
|
+
reload
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Delivers a signal: `value` (any JSON value, raw) under `name`. A run
|
|
119
|
+
# parked at that name goes back to the queue and the step runs with the
|
|
120
|
+
# value; any other live run keeps it in `pending_signals` for the `await`
|
|
121
|
+
# to consume when the line is reached. A second signal for the same name
|
|
122
|
+
# overwrites the first. Without a name, the parked step is the one woken:
|
|
123
|
+
# a timer ends now, an `await` receives `nil`. Raises `NotLive` when the run
|
|
124
|
+
# is not live, `NotWaiting` for a nameless wake of a run that is not parked.
|
|
125
|
+
# The row lock makes a signal and the job's own park or consume atomic.
|
|
126
|
+
def wake_up(name = nil, value = nil)
|
|
127
|
+
transaction do
|
|
128
|
+
lock!
|
|
129
|
+
unless name
|
|
130
|
+
WAKEABLE_STATUSES.include?(status) or raise NotWaiting, "Run #{id} is #{status}; only a waiting or awaiting run can be woken up"
|
|
131
|
+
name = current_step
|
|
132
|
+
end
|
|
133
|
+
live? or raise NotLive, "Run #{id} is #{status}; a signal needs a live run"
|
|
134
|
+
|
|
135
|
+
signals = pending_signals.merge(name.to_s => value)
|
|
136
|
+
if WAKEABLE_STATUSES.include?(status) && current_step == name.to_s
|
|
137
|
+
reenqueue_parked_job!(from: status, pending_signals: signals)
|
|
138
|
+
else
|
|
139
|
+
self.class.where(id:, status:).update_all(pending_signals: signals, updated_at: Time.current)
|
|
140
|
+
reload
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# One status-guarded transition to `enqueued`, then the parked job goes
|
|
147
|
+
# back to the queue once every open transaction has committed. False when
|
|
148
|
+
# the status was not in `from` any more (a concurrent resume, a cancel).
|
|
149
|
+
def reenqueue_parked_job!(from:, **changes) # :nodoc:
|
|
150
|
+
now = Time.current
|
|
151
|
+
updated = self.class.where(id:, status: from).update_all(
|
|
152
|
+
status: "enqueued", error_class: nil, error_message: nil, halt_reason: nil,
|
|
153
|
+
finished_at: nil, transitioned_at: now, updated_at: now, **changes
|
|
154
|
+
)
|
|
155
|
+
return false if updated.zero?
|
|
156
|
+
|
|
157
|
+
reload
|
|
158
|
+
job = ActiveJob::Base.deserialize(parked_job)
|
|
159
|
+
job.scheduled_at = nil # a retry's or a resume's delay does not carry over
|
|
160
|
+
ActiveRecord.after_all_transactions_commit { job.enqueue }
|
|
161
|
+
true
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Durable
|
|
5
|
+
# One row per step attempt. A step that is interrupted or fails and then re-enters
|
|
6
|
+
# gets a new row with the next `attempt`; `cursor` is the last committed cursor of
|
|
7
|
+
# that attempt, in Active Job argument serialization form.
|
|
8
|
+
class Step < Record
|
|
9
|
+
self.table_name = "active_job_durable_steps"
|
|
10
|
+
|
|
11
|
+
belongs_to :run, class_name: "ActiveJob::Durable::Run"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Durable
|
|
5
|
+
# The clock for `wait:`, `wait_until:` and `await` deadlines: one scheduled call to
|
|
6
|
+
# `ActiveJob::Durable.wake_up_due`. Add it to your schedule, e.g., Solid Queue:
|
|
7
|
+
#
|
|
8
|
+
# # config/recurring.yml
|
|
9
|
+
# durable_wake:
|
|
10
|
+
# class: ActiveJob::Durable::WakeJob
|
|
11
|
+
# schedule: every minute
|
|
12
|
+
class WakeJob < ActiveJob::Base # rubocop:disable Rails/ApplicationJob
|
|
13
|
+
def perform = Durable.wake_up_due
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha2"
|
|
4
|
+
require "active_job"
|
|
5
|
+
require "active_support/core_ext/module/attribute_accessors"
|
|
6
|
+
require "active_support/core_ext/numeric/time"
|
|
7
|
+
require "active_job/continuable/callbacks"
|
|
8
|
+
|
|
9
|
+
module ActiveJob
|
|
10
|
+
# = Active Job Durable
|
|
11
|
+
#
|
|
12
|
+
# A Continuable job whose run is also a record. Include it instead of
|
|
13
|
+
# `ActiveJob::Continuable`:
|
|
14
|
+
#
|
|
15
|
+
# class ProcessImportJob < ApplicationJob
|
|
16
|
+
# include ActiveJob::Durable
|
|
17
|
+
#
|
|
18
|
+
# def perform(import)
|
|
19
|
+
# step :validate
|
|
20
|
+
# step :process, isolated: true
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Steps, cursors and attributes behave exactly as in `ActiveJob::Continuable`,
|
|
25
|
+
# but the run row is the single source of truth: the payload carries
|
|
26
|
+
# `"durable_run_id"` instead of `"continuation"`, `"attributes"` and
|
|
27
|
+
# `"resumptions"`, and every execution rebuilds the continuation, the attribute
|
|
28
|
+
# values and the resumption count from `active_job_durable_runs` and the
|
|
29
|
+
# current attempt in `active_job_durable_steps`. The rows are written before
|
|
30
|
+
# the job is re-enqueued, in every path.
|
|
31
|
+
module Durable
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
include ActiveJob::Continuable::Callbacks
|
|
34
|
+
|
|
35
|
+
autoload :ArgsMapper, "active_job/durable/args_mapper"
|
|
36
|
+
autoload :Config, "active_job/durable/config"
|
|
37
|
+
autoload :Continuation, "active_job/durable/continuation"
|
|
38
|
+
autoload :Execution, "active_job/durable/execution"
|
|
39
|
+
autoload :HousekeepingJob, "active_job/durable/housekeeping_job"
|
|
40
|
+
autoload :Record, "active_job/durable/record"
|
|
41
|
+
autoload :Run, "active_job/durable/run"
|
|
42
|
+
autoload :Step, "active_job/durable/step"
|
|
43
|
+
autoload :WakeJob, "active_job/durable/wake_job"
|
|
44
|
+
|
|
45
|
+
mattr_accessor :connects_to, instance_accessor: false
|
|
46
|
+
|
|
47
|
+
# How long `clean_up` keeps a `completed`, `discarded` or `cancelled` run
|
|
48
|
+
# (and its steps) after it ended; `nil` keeps them forever.
|
|
49
|
+
mattr_accessor :keep_terminal_runs_for, instance_accessor: false, default: 14.days
|
|
50
|
+
|
|
51
|
+
# Wakes every `waiting` or `awaiting` run whose `wake_at` has passed, once each, and
|
|
52
|
+
# returns how many. `WakeJob` calls it on schedule; tests call it inside `travel_to`.
|
|
53
|
+
def self.wake_up_due = Run.wake_due
|
|
54
|
+
|
|
55
|
+
# Deletes the terminal runs older than `keep_terminal_runs_for`, with their
|
|
56
|
+
# steps, and returns how many. `HousekeepingJob` calls it on schedule.
|
|
57
|
+
def self.clean_up
|
|
58
|
+
return 0 unless keep_terminal_runs_for
|
|
59
|
+
|
|
60
|
+
Run.clear_terminal(ended_before: keep_terminal_runs_for.ago)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Raised inside `perform_now` when the payload references a run row that does
|
|
64
|
+
# not exist (or belongs to another job), so `retry_on`, `discard_on` and
|
|
65
|
+
# `rescue_from` handlers can see it.
|
|
66
|
+
class RunNotFoundError < StandardError; end
|
|
67
|
+
|
|
68
|
+
# Raised by `Run#resume!` when the run is not `halted` or `failed`, or when
|
|
69
|
+
# another caller resumed it first.
|
|
70
|
+
class NotResumable < StandardError; end
|
|
71
|
+
|
|
72
|
+
# Raised by `Run#cancel!` when the run is terminal, or when another caller
|
|
73
|
+
# ended it first.
|
|
74
|
+
class NotCancellable < StandardError; end
|
|
75
|
+
|
|
76
|
+
# Raised by `Run#wake_up` when the run is not `waiting`, or when the clock
|
|
77
|
+
# or another caller woke it first.
|
|
78
|
+
class NotWaiting < StandardError; end
|
|
79
|
+
|
|
80
|
+
# Raised by `Run#wake_up(name, value)` when the run is not live: a signal
|
|
81
|
+
# for a run that ended, or that needs attention first, has no one to consume it.
|
|
82
|
+
class NotLive < StandardError; end
|
|
83
|
+
|
|
84
|
+
# Raised by `unique_by ..., on_conflict: :reject` when a run with the same
|
|
85
|
+
# `active_key` is live or needs attention; `run` is that run.
|
|
86
|
+
class RunAlreadyExists < StandardError
|
|
87
|
+
attr_reader :run
|
|
88
|
+
|
|
89
|
+
def initialize(run)
|
|
90
|
+
@run = run
|
|
91
|
+
super("Run #{run.id} for #{run.job_class} (#{run.active_key}) is #{run.status}")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Raised by `halt!`
|
|
96
|
+
class Halt < Exception # rubocop:disable Lint/InheritException
|
|
97
|
+
attr_reader :reason
|
|
98
|
+
|
|
99
|
+
def initialize(reason = nil)
|
|
100
|
+
@reason = reason
|
|
101
|
+
super(reason ? "Halted (#{reason})" : "Halted")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Raised at a checkpoint or a step boundary when `Run#cancel!` ended the run
|
|
106
|
+
# meanwhile: the job stops, the open step row is `cancelled` and the job
|
|
107
|
+
# handles the error itself, so nothing reaches the backend.
|
|
108
|
+
class Cancelled < Exception # rubocop:disable Lint/InheritException
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
included do
|
|
112
|
+
self.continuation_class = Continuation
|
|
113
|
+
|
|
114
|
+
# Ensure run checkpoints are written before any other callback
|
|
115
|
+
around_step(prepend: true) { |_job, block| durable_job.step(&block) }
|
|
116
|
+
|
|
117
|
+
# Add our hook before Continuable's `around_perform :continue`,
|
|
118
|
+
# so we can wrap it
|
|
119
|
+
around_perform(prepend: true) { |_job, block| durable_job.perform(&block) }
|
|
120
|
+
|
|
121
|
+
after_discard { |_job, error| durable_job.record_discard(error) }
|
|
122
|
+
rescue_from(Halt) { |error| durable_job.halted!(error) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
module ClassMethods
|
|
126
|
+
# Names the components of a run's `key`: `perform` parameters (positional by
|
|
127
|
+
# name, keywords by key), or a block called with the `perform` arguments
|
|
128
|
+
# whose return value is the key (rendered like any component, or joined
|
|
129
|
+
# with ":" when it is an array):
|
|
130
|
+
#
|
|
131
|
+
# identified_by :card # key "cards/42", whatever the other arguments
|
|
132
|
+
# identified_by :card, :style # key "cards/42:plain" for perform(card, style: "plain")
|
|
133
|
+
# identified_by { |card, **| [card.account, :export] }
|
|
134
|
+
#
|
|
135
|
+
# A name that is not a `perform` parameter raises `ArgumentError`.
|
|
136
|
+
#
|
|
137
|
+
# Without a declaration every argument is a component: positional in order,
|
|
138
|
+
# then keywords sorted by name as `name=value`.
|
|
139
|
+
def identified_by(...) = durable_config.identified_by(...)
|
|
140
|
+
|
|
141
|
+
# Specicy the run uniqueness components in the same forms as `identified_by`.
|
|
142
|
+
# Uniqueness is only enforced for runs that hasn't been terminated: either _live_ runs
|
|
143
|
+
# (with "enqueued", "running", "waiting", "awaiting" status) or _paused_ runs ("failed" or "halted").
|
|
144
|
+
# The `on_conflict:` option defines what to do if the mathing run exists:
|
|
145
|
+
#
|
|
146
|
+
# unique_by :import # :skip — enqueue nothing, `perform_later` returns false
|
|
147
|
+
# unique_by :payout, on_conflict: :reject # raise `RunAlreadyExists`
|
|
148
|
+
# unique_by :license, on_conflict: :replace # cancel that run and start this one
|
|
149
|
+
#
|
|
150
|
+
def unique_by(*names, on_conflict: :skip, &block) = durable_config.unique_by(*names, on_conflict:, &block)
|
|
151
|
+
|
|
152
|
+
# Errors that could be resolved by a human (or alike), so the run
|
|
153
|
+
# could be restarted from the current step/cursor.
|
|
154
|
+
def halt_on(*errors)
|
|
155
|
+
durable_config.halt_on(*errors)
|
|
156
|
+
rescue_from(*errors) { |error| durable_job.halted!(error) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# This class's runs, newest first. `for(*args, **kwargs)` on the relation
|
|
160
|
+
# finds the runs `perform_later(*args, **kwargs)` would have created;
|
|
161
|
+
# `for(workflow_key: "...")` matches a key verbatim.
|
|
162
|
+
def workflow_runs = Run.where(job_class: name).newest_first
|
|
163
|
+
|
|
164
|
+
# Macros write to this class's own copy of its parent's configuration.
|
|
165
|
+
def durable_config # :nodoc:
|
|
166
|
+
@durable_config ||= superclass.respond_to?(:durable_config) ? superclass.durable_config.inherit(self) : Config.new(self)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Creates the run before the job is handed to the adapter, so that the row is
|
|
171
|
+
# part of the caller's transaction when enqueuing is deferred to after commit.
|
|
172
|
+
# A retry or resume (same `job_id`) finds the row and only updates its status.
|
|
173
|
+
def enqueue(options = {})
|
|
174
|
+
durable_job.workflow_key = options[:workflow_key]&.to_s
|
|
175
|
+
return false unless durable_job.enqueued! # `on_conflict: :skip`: nothing to enqueue
|
|
176
|
+
|
|
177
|
+
super
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Supports `set(workflow_key: "...")`to provide an explicit workflow (not run) identifier.
|
|
181
|
+
def set(options = {}) # :nodoc:
|
|
182
|
+
durable_job.workflow_key = options[:workflow_key]&.to_s
|
|
183
|
+
super
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Continuable's `step`, plus a timer (`wait:` or
|
|
187
|
+
# `wait_until:`):
|
|
188
|
+
#
|
|
189
|
+
# step :remind, wait_until: license.expires_at - 2.weeks
|
|
190
|
+
# step :revoke, wait: 2.weeks
|
|
191
|
+
#
|
|
192
|
+
def step(step_name, wait: nil, wait_until: nil, **, &block)
|
|
193
|
+
raise ArgumentError, "Step '#{step_name}' takes wait: or wait_until:, not both" if wait && wait_until
|
|
194
|
+
|
|
195
|
+
super
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A step that waits for a signal from outside (`Run#wake_up(name, value)`):
|
|
199
|
+
#
|
|
200
|
+
# await :confirmation, wait: 10.minutes
|
|
201
|
+
#
|
|
202
|
+
# def confirmation(signal) = self.confirmed = signal.presence
|
|
203
|
+
#
|
|
204
|
+
# Reaching the line parks the run as `awaiting` until the signal arrives or
|
|
205
|
+
# the deadline (`wait:` or `wait_until:`, none by default) passes; the method
|
|
206
|
+
# named after the signal, or the block, then runs as the step's body with the
|
|
207
|
+
# value, `nil` at the deadline. A signal sent before the line is reached is
|
|
208
|
+
# consumed on the spot. The step row's cursor keeps the value, so a crash in
|
|
209
|
+
# the handler replays it; a `halt!` in the handler awaits again on resume.
|
|
210
|
+
def await(name, wait: nil, wait_until: nil, &block)
|
|
211
|
+
handler = block || step_method_block(name)
|
|
212
|
+
step(name, wait:, wait_until:, await: true) { handler.call(durable_job.await_value) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Stops the run from inside a step: status `halted` with `halt_reason`, the
|
|
216
|
+
# step row keeps its cursor, and the job's serialized form is parked on the run.
|
|
217
|
+
def halt!(reason = nil)
|
|
218
|
+
raise ArgumentError, "halt! must be called inside a step" unless current_step
|
|
219
|
+
|
|
220
|
+
raise Halt.new(reason)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def checkpoint! # :nodoc:
|
|
224
|
+
durable_job.checkpoint!
|
|
225
|
+
super
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def serialize = durable_job.serialize(super) # :nodoc:
|
|
229
|
+
|
|
230
|
+
def deserialize(job_data) # :nodoc:
|
|
231
|
+
super
|
|
232
|
+
durable_job.deserialize(job_data)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def perform_now = durable_job.perform_now { super } # :nodoc:
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
def durable_job = @durable_job ||= Execution.new(self)
|
|
240
|
+
|
|
241
|
+
def deserialize_arguments_if_needed
|
|
242
|
+
super
|
|
243
|
+
durable_job.restore!
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def resume_job(exception) # :nodoc:
|
|
247
|
+
error = exception.is_a?(Hash) ? exception[:exception] : exception
|
|
248
|
+
super if durable_job.resume!(error)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
data/lib/ajdc/railtie.rb
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_job/continuation/rescue_handlers_first"
|
|
4
|
+
|
|
3
5
|
module Ajdc # :nodoc:
|
|
4
6
|
class Railtie < ::Rails::Railtie # :nodoc:
|
|
7
|
+
# Every option is an `ActiveJob::Durable` setting:
|
|
8
|
+
#
|
|
9
|
+
# config.active_job_durable.connects_to = { database: { writing: :durable } }
|
|
10
|
+
# config.active_job_durable.keep_terminal_runs_for = 30.days
|
|
11
|
+
config.active_job_durable = ActiveSupport::OrderedOptions.new
|
|
12
|
+
|
|
13
|
+
# After the config initializers, so that an initializer can set them too.
|
|
14
|
+
initializer "ajdc.config", after: :load_config_initializers do |app|
|
|
15
|
+
app.config.active_job_durable.each { |name, value| ActiveJob::Durable.public_send(:"#{name}=", value) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "ajdc.rescue_handlers_first" do
|
|
19
|
+
ActiveSupport.on_load(:active_job_continuable) do
|
|
20
|
+
prepend ActiveJob::Continuation::RescueHandlersFirst
|
|
21
|
+
end
|
|
22
|
+
end
|
|
5
23
|
end
|
|
6
24
|
end
|
data/lib/ajdc/version.rb
CHANGED
data/lib/ajdc.rb
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Creates the migration for the two tables ActiveJob::Durable writes to
|
|
3
|
+
(active_job_durable_runs and active_job_durable_steps).
|
|
4
|
+
|
|
5
|
+
Without options the migration goes to db/migrate: the tables live next to
|
|
6
|
+
the application's tables, and a run is created in the same transaction as
|
|
7
|
+
the record that starts it.
|
|
8
|
+
|
|
9
|
+
With --database=NAME the migration goes to that database's migrations_paths
|
|
10
|
+
(from config/database.yml) and an initializer points ActiveJob::Durable at
|
|
11
|
+
it. Declare the database first, or follow the printed instructions.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
bin/rails generate ajdc:install
|
|
15
|
+
db/migrate/TIMESTAMP_create_active_job_durable_tables.rb
|
|
16
|
+
|
|
17
|
+
bin/rails generate ajdc:install --database=durable
|
|
18
|
+
db/durable_migrate/TIMESTAMP_create_active_job_durable_tables.rb
|
|
19
|
+
config/initializers/active_job_durable.rb
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Ajdc
|
|
7
|
+
# Creates the migration for the tables ActiveJob::Durable writes to:
|
|
8
|
+
#
|
|
9
|
+
# bin/rails generate ajdc:install # the primary database
|
|
10
|
+
# bin/rails generate ajdc:install --database=durable # a separate database, plus the initializer
|
|
11
|
+
#
|
|
12
|
+
# With `--database`, the migration lands in that database's `migrations_paths`
|
|
13
|
+
# (as `rails generate migration --database` does) and an initializer points
|
|
14
|
+
# `ActiveJob::Durable` at it. The database itself is declared in
|
|
15
|
+
# config/database.yml by the app.
|
|
16
|
+
class InstallGenerator < Rails::Generators::Base
|
|
17
|
+
include ActiveRecord::Generators::Migration
|
|
18
|
+
|
|
19
|
+
source_root File.expand_path("templates", __dir__)
|
|
20
|
+
|
|
21
|
+
class_option :database, type: :string, aliases: %i[--db],
|
|
22
|
+
desc: "The database for the tables. By default, the current environment's primary database is used."
|
|
23
|
+
|
|
24
|
+
def create_migration_file
|
|
25
|
+
migration_template "create_active_job_durable_tables.rb.tt", File.join(db_migrate_path, "create_active_job_durable_tables.rb")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_initializer
|
|
29
|
+
return unless options[:database]
|
|
30
|
+
|
|
31
|
+
template "initializer.rb.tt", "config/initializers/active_job_durable.rb"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def show_database_instructions
|
|
35
|
+
return unless options[:database]
|
|
36
|
+
return if database_configured?
|
|
37
|
+
|
|
38
|
+
say <<~TEXT
|
|
39
|
+
|
|
40
|
+
Add the database to config/database.yml, then run bin/rails db:prepare:
|
|
41
|
+
|
|
42
|
+
#{options[:database]}:
|
|
43
|
+
<<: *default
|
|
44
|
+
database: storage/#{options[:database]}.sqlite3 # or your adapter's settings
|
|
45
|
+
migrations_paths: db/#{options[:database]}_migrate
|
|
46
|
+
|
|
47
|
+
TEXT
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Rails resolves `--database` through config/database.yml; a database that is
|
|
53
|
+
# not declared yet gets the conventional path, which the instructions name.
|
|
54
|
+
def db_migrate_path
|
|
55
|
+
return super if options[:database].nil? || database_configured?
|
|
56
|
+
|
|
57
|
+
"db/#{options[:database]}_migrate"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def database_configured?
|
|
61
|
+
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: options[:database]).present?
|
|
62
|
+
rescue
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tables for ActiveJob::Durable: one row per run, one row per step attempt.
|
|
4
|
+
# The gem's own test suite loads the same definition from db/durable_schema.rb.
|
|
5
|
+
class CreateActiveJobDurableTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
6
|
+
def change
|
|
7
|
+
create_table :active_job_durable_runs, if_not_exists: true do |t|
|
|
8
|
+
t.string :job_class, null: false
|
|
9
|
+
t.string :key, null: false
|
|
10
|
+
t.string :active_key
|
|
11
|
+
t.string :active_job_id, null: false
|
|
12
|
+
t.json :arguments, null: false
|
|
13
|
+
t.string :status, null: false
|
|
14
|
+
t.string :current_step
|
|
15
|
+
t.json :completed_steps, null: false
|
|
16
|
+
t.json :state, null: false
|
|
17
|
+
t.datetime :wake_at
|
|
18
|
+
t.json :pending_signals, null: false
|
|
19
|
+
t.json :parked_job
|
|
20
|
+
t.integer :resumptions, default: 0, null: false
|
|
21
|
+
t.datetime :last_heartbeat_at
|
|
22
|
+
t.string :error_class
|
|
23
|
+
t.text :error_message
|
|
24
|
+
t.string :halt_reason
|
|
25
|
+
t.datetime :started_at
|
|
26
|
+
t.datetime :finished_at
|
|
27
|
+
t.datetime :transitioned_at
|
|
28
|
+
t.timestamps
|
|
29
|
+
|
|
30
|
+
t.index [:job_class, :active_key], name: "index_active_job_durable_runs_on_active_key", unique: true
|
|
31
|
+
t.index [:job_class, :key], name: "index_active_job_durable_runs_on_key"
|
|
32
|
+
t.index [:active_job_id], name: "index_active_job_durable_runs_on_active_job_id", unique: true
|
|
33
|
+
t.index [:status], name: "index_active_job_durable_runs_on_status"
|
|
34
|
+
t.index [:status, :wake_at], name: "index_active_job_durable_runs_on_status_and_wake_at"
|
|
35
|
+
t.index [:status, :transitioned_at], name: "index_active_job_durable_runs_on_status_and_transitioned_at"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
create_table :active_job_durable_steps, if_not_exists: true do |t|
|
|
39
|
+
t.references :run, null: false, index: false,
|
|
40
|
+
foreign_key: { to_table: :active_job_durable_runs, on_delete: :cascade }
|
|
41
|
+
t.string :name, null: false
|
|
42
|
+
t.integer :position, null: false
|
|
43
|
+
t.integer :attempt, default: 1, null: false
|
|
44
|
+
t.string :status, null: false
|
|
45
|
+
t.json :cursor
|
|
46
|
+
t.boolean :isolated, default: false, null: false
|
|
47
|
+
t.string :error_class
|
|
48
|
+
t.text :error_message
|
|
49
|
+
t.datetime :started_at
|
|
50
|
+
t.datetime :finished_at
|
|
51
|
+
|
|
52
|
+
t.index [:run_id, :name, :attempt], name: "index_active_job_durable_steps_on_attempt", unique: true
|
|
53
|
+
t.index [:run_id, :position], name: "index_active_job_durable_steps_on_position"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|