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,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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ajdc # :nodoc:
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/ajdc.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ajdc/version"
4
+ require "active_job/durable"
4
5
  require "ajdc/railtie" if defined?(Rails::Railtie)
@@ -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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ActiveJob::Durable keeps its runs and steps in the "<%= options[:database] %>" database.
4
+ Rails.application.config.active_job_durable.connects_to = { database: { writing: :<%= options[:database] %> } }