spree_core 6.0.0.rc6 → 6.0.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/app/jobs/spree/maintenance_tasks/run_job.rb +321 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/backfill_order_markets.rb +48 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/capture_methods.rb +40 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/deliveries.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/order_coupon_codes.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/promotion_option_value_rules.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/rake_step.rb +83 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/returns.rb +20 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/users_to_customers.rb +28 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade_step.rb +44 -0
- data/app/models/spree/ability.rb +9 -0
- data/app/models/spree/maintenance_task.rb +221 -0
- data/app/models/spree/maintenance_task_run.rb +78 -0
- data/app/models/spree/upgrade_record.rb +62 -0
- data/app/services/spree/maintenance_tasks/argument_parser.rb +23 -0
- data/app/services/spree/maintenance_tasks/cancel.rb +51 -0
- data/app/services/spree/maintenance_tasks/start.rb +127 -0
- data/app/services/spree/seeds/all.rb +22 -0
- data/config/locales/en.yml +1 -0
- data/db/migrate/20260813130001_add_capture_method_to_payment_methods.rb +1 -1
- data/db/migrate/20260815000001_create_spree_maintenance_task_runs.rb +40 -0
- data/db/migrate/20260816000001_create_spree_upgrade_records.rb +17 -0
- data/lib/generators/spree/api_resource/api_resource_generator.rb +2 -2
- data/lib/spree/core/configuration.rb +6 -0
- data/lib/spree/core/engine.rb +10 -1
- data/lib/spree/core/permissions/default_catalog.rb +7 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +16 -1
- data/lib/spree/testing_support/factories/maintenance_task_run_factory.rb +7 -0
- data/lib/spree/upgrade.rb +189 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +17 -10
- data/lib/tasks/maintenance_tasks.rake +42 -0
- data/lib/tasks/upgrade.rake +81 -60
- metadata +25 -6
- data/lib/tasks/capture_methods_migration.rake +0 -30
- data/lib/tasks/order_market_backfill.rake +0 -27
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# Base class for the data work an upgrade runs: one step of an upgrade
|
|
3
|
+
# manifest (docs/plans/6.0-maintenance-tasks.md).
|
|
4
|
+
#
|
|
5
|
+
# A task walks a stable-ordered collection and processes one record at a
|
|
6
|
+
# time. The runner (Spree::MaintenanceTasks::RunJob) supplies batching,
|
|
7
|
+
# checkpointing, cancelling and the audit row, so a task body says
|
|
8
|
+
# only what it does.
|
|
9
|
+
#
|
|
10
|
+
# class Spree::MaintenanceTasks::Upgrade::BackfillOrderMarkets < Spree::MaintenanceTask
|
|
11
|
+
# supports_dry_run
|
|
12
|
+
#
|
|
13
|
+
# def collection
|
|
14
|
+
# Spree::Order.where(market_id: nil).order(:id)
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# def process(order)
|
|
18
|
+
# return tally(:would_update) if dry_run?
|
|
19
|
+
#
|
|
20
|
+
# order.update_columns(market_id: order.store.default_market.id)
|
|
21
|
+
# tally(:updated)
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# A manifest step names its task class, which registers it in
|
|
26
|
+
# `Spree.maintenance_tasks`; nothing else is runnable.
|
|
27
|
+
class MaintenanceTask
|
|
28
|
+
include ActiveModel::Model
|
|
29
|
+
include ActiveModel::Attributes
|
|
30
|
+
extend Spree::ApiTyped
|
|
31
|
+
|
|
32
|
+
# A rake body that stopped itself with `abort`.
|
|
33
|
+
class RakeTaskAborted < StandardError; end
|
|
34
|
+
|
|
35
|
+
# Two threads of one worker must not both find a task undefined and both
|
|
36
|
+
# load its file — the second load would make the task run twice.
|
|
37
|
+
RAKE_LOAD_LOCK = Mutex.new
|
|
38
|
+
|
|
39
|
+
DEFAULT_BATCH_SIZE = 100
|
|
40
|
+
|
|
41
|
+
# How long one execution may run before the runner checkpoints and
|
|
42
|
+
# re-enqueues itself, so a long step cannot hold a worker indefinitely.
|
|
43
|
+
MAX_RUN_TIME = 5.minutes
|
|
44
|
+
|
|
45
|
+
class_attribute :dry_run_supported, instance_writer: false, default: false
|
|
46
|
+
class_attribute :collection_kind, instance_writer: false, default: :relation
|
|
47
|
+
class_attribute :batch_size, instance_writer: false, default: DEFAULT_BATCH_SIZE
|
|
48
|
+
|
|
49
|
+
attr_accessor :run
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
# Declares that `process` honors `dry_run?`. The runner never fakes a
|
|
53
|
+
# dry run by rolling a transaction back, which would silently lie about
|
|
54
|
+
# external calls, enqueued jobs and cache writes.
|
|
55
|
+
def supports_dry_run
|
|
56
|
+
self.dry_run_supported = true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The task performs a single operation instead of iterating; `process`
|
|
60
|
+
# is called once with no argument.
|
|
61
|
+
def no_collection
|
|
62
|
+
self.collection_kind = :none
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def collection_batch_size(size)
|
|
66
|
+
self.batch_size = size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Array<Class>] registered task classes, sorted by name
|
|
70
|
+
def registered_classes
|
|
71
|
+
Spree.maintenance_tasks.filter_map do |entry|
|
|
72
|
+
entry.is_a?(Class) ? entry : entry.to_s.safe_constantize
|
|
73
|
+
end.uniq.sort_by(&:name)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param name [String] a task class name
|
|
77
|
+
# @return [Class, nil] nil when the name is not registered — an
|
|
78
|
+
# unregistered task is never runnable, so the runner and the API can
|
|
79
|
+
# treat nil as "no such task" without a second check
|
|
80
|
+
def find_registered(name)
|
|
81
|
+
return nil if name.blank?
|
|
82
|
+
|
|
83
|
+
registered_classes.find { |klass| klass.name == name.to_s }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A task class name as the API shows it. A task that is no longer
|
|
87
|
+
# registered is shortened the same way rather than loaded by name.
|
|
88
|
+
#
|
|
89
|
+
# @param class_name [String]
|
|
90
|
+
# @return [String]
|
|
91
|
+
def api_type_for(class_name)
|
|
92
|
+
find_registered(class_name)&.api_type || class_name.to_s.demodulize.underscore
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The registered task an API caller names. Matches the short name only,
|
|
96
|
+
# so a Ruby class name arriving over the API is an unknown task.
|
|
97
|
+
#
|
|
98
|
+
# @param api_type [String] e.g. "capture_methods"
|
|
99
|
+
# @return [Class, nil]
|
|
100
|
+
def find_by_api_type(api_type)
|
|
101
|
+
Spree::ApiTyped.find(Spree.maintenance_tasks, api_type)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The records to walk, ordered by primary key — the cursor is the last id
|
|
106
|
+
# processed, so any other order would resume in the wrong place.
|
|
107
|
+
#
|
|
108
|
+
# @return [ActiveRecord::Relation]
|
|
109
|
+
def collection
|
|
110
|
+
raise NotImplementedError, "#{self.class.name} must implement #collection or declare `no_collection`"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param item [Object] one record from the collection; omitted for
|
|
114
|
+
# `no_collection` tasks
|
|
115
|
+
def process(item = nil)
|
|
116
|
+
raise NotImplementedError, "#{self.class.name} must implement #process"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Total records, for the progress bar. Return nil when counting is more
|
|
120
|
+
# expensive than the work itself — the run then reports ticks only.
|
|
121
|
+
#
|
|
122
|
+
# @return [Integer, nil]
|
|
123
|
+
def count
|
|
124
|
+
collection.count unless no_collection?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def no_collection?
|
|
128
|
+
self.class.collection_kind == :none
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def dry_run?
|
|
132
|
+
run&.dry_run? || false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Records a counter shown on the run page: what was updated, skipped, or
|
|
136
|
+
# would have changed in a dry run.
|
|
137
|
+
def tally(key, by = 1)
|
|
138
|
+
@tallies ||= Hash.new(0)
|
|
139
|
+
@tallies[key.to_s] += by
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def tallies
|
|
143
|
+
@tallies ||= Hash.new(0)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Runs an engine rake task as part of this task.
|
|
147
|
+
#
|
|
148
|
+
# Rake bodies stop themselves with `abort`, which raises SystemExit — not
|
|
149
|
+
# an error the runner records. Left alone it would end the whole process
|
|
150
|
+
# from the command line and, in a worker, leave the run marked running
|
|
151
|
+
# with nothing to finish it.
|
|
152
|
+
#
|
|
153
|
+
# @param task_name [String]
|
|
154
|
+
# @raise [RakeTaskAborted] carrying the body's own message
|
|
155
|
+
def invoke_rake_task(task_name)
|
|
156
|
+
load_engine_rake_tasks(task_name)
|
|
157
|
+
|
|
158
|
+
task = ::Rake::Task[task_name]
|
|
159
|
+
task.reenable
|
|
160
|
+
task.invoke
|
|
161
|
+
rescue SystemExit => e
|
|
162
|
+
raise RakeTaskAborted, e.message
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Finds the file that defines a task by reading the files rather than
|
|
166
|
+
# loading them: loading a .rake file to discover whether it is the right
|
|
167
|
+
# one would append its blocks to every task it defines, which is the very
|
|
168
|
+
# duplication this avoids.
|
|
169
|
+
#
|
|
170
|
+
# Searches every engine and the host application, since a step may ship in
|
|
171
|
+
# an extension gem. When two files define the same task name, the one
|
|
172
|
+
# that also declares the task's namespaces wins.
|
|
173
|
+
#
|
|
174
|
+
# @return [String, nil]
|
|
175
|
+
def rake_file_defining(task_name)
|
|
176
|
+
*namespaces, leaf = task_name.split(':')
|
|
177
|
+
candidates = rake_files.select do |path|
|
|
178
|
+
File.read(path).match?(/^\s*(?:desc\s.*\n\s*)?task\s+:?["']?#{Regexp.escape(leaf)}["']?\b/)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
candidates.find do |path|
|
|
182
|
+
source = File.read(path)
|
|
183
|
+
namespaces.all? { |name| source.match?(/namespace\s+:?["']?#{Regexp.escape(name)}["']?\b/) }
|
|
184
|
+
end || candidates.first
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Called once the last record has been processed, before the run is
|
|
188
|
+
# marked succeeded — for a summary tally of what the walk left alone.
|
|
189
|
+
def after_complete; end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
# Rake tasks are defined by the Rakefile, which a web process never loads —
|
|
194
|
+
# a task fired from the dashboard has to load the engine's tasks itself.
|
|
195
|
+
#
|
|
196
|
+
# Loads the one file that defines the task, and only when it is not already
|
|
197
|
+
# defined. Both halves matter: re-loading a .rake file APPENDS its block to
|
|
198
|
+
# a task that already exists, so a second load makes that task run twice,
|
|
199
|
+
# and loading every file would do that to every task in the engine. The
|
|
200
|
+
# symptom is invisible until something counts what it did.
|
|
201
|
+
#
|
|
202
|
+
# @param task_name [String] the rake task about to be invoked
|
|
203
|
+
def load_engine_rake_tasks(task_name)
|
|
204
|
+
require 'rake'
|
|
205
|
+
|
|
206
|
+
RAKE_LOAD_LOCK.synchronize do
|
|
207
|
+
next if ::Rake::Task.task_defined?(task_name)
|
|
208
|
+
|
|
209
|
+
::Rake::Task.define_task(:environment) unless ::Rake::Task.task_defined?(:environment)
|
|
210
|
+
|
|
211
|
+
path = rake_file_defining(task_name)
|
|
212
|
+
load(path) if path
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def rake_files
|
|
217
|
+
roots = Rails.application.railties.grep(Rails::Engine).map(&:root) + [Rails.root]
|
|
218
|
+
roots.uniq.flat_map { |root| Dir.glob(File.join(root, 'lib/tasks/**/*.rake')).sort }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# One execution of a Spree::MaintenanceTask — the durable record of what was
|
|
3
|
+
# run, by whom, with which arguments, and how far it got
|
|
4
|
+
# (docs/plans/6.0-maintenance-tasks.md).
|
|
5
|
+
#
|
|
6
|
+
# The row is both the checkpoint and the audit trail. Its `cursor` is the
|
|
7
|
+
# authoritative resume point: Active Job serializes its own continuation
|
|
8
|
+
# into the job data, but that copy is lost whenever the job is dropped by
|
|
9
|
+
# the backend, so the runner writes both in the same checkpoint and always
|
|
10
|
+
# seeds from this column.
|
|
11
|
+
#
|
|
12
|
+
# Runs are installation-wide: an upgrade step converts every store's data,
|
|
13
|
+
# so a run belongs to no store.
|
|
14
|
+
class MaintenanceTaskRun < Spree.base_class
|
|
15
|
+
has_prefix_id :mtr
|
|
16
|
+
|
|
17
|
+
include Spree::HasStatus
|
|
18
|
+
|
|
19
|
+
publishes_lifecycle_events
|
|
20
|
+
publishes_events :started, :succeeded, :cancelled, :interrupted, :errored
|
|
21
|
+
|
|
22
|
+
# Terminal statuses end a run for good; a failed run is started again
|
|
23
|
+
# rather than resumed. `cancelling` is the operator's request, written by
|
|
24
|
+
# the Cancel service and observed by the runner at its next checkpoint —
|
|
25
|
+
# the runner alone moves a running run into `cancelled`, so a stopped
|
|
26
|
+
# worker can never leave a lie in the column.
|
|
27
|
+
has_status :enqueued, :running, :interrupted, :cancelling, :cancelled,
|
|
28
|
+
:succeeded, :errored,
|
|
29
|
+
default: :enqueued
|
|
30
|
+
|
|
31
|
+
ACTIVE_STATUSES = %w[enqueued running interrupted cancelling].freeze
|
|
32
|
+
INITIATED_VIA = %w[dashboard api cli].freeze
|
|
33
|
+
|
|
34
|
+
belongs_to :admin_user, class_name: Spree.admin_user_class.to_s, optional: true
|
|
35
|
+
belongs_to :api_key, class_name: 'Spree::ApiKey', optional: true
|
|
36
|
+
|
|
37
|
+
validates :task_name, presence: true
|
|
38
|
+
validates :initiated_via, presence: true, inclusion: { in: INITIATED_VIA }
|
|
39
|
+
|
|
40
|
+
scope :active, -> { where(status: ACTIVE_STATUSES) }
|
|
41
|
+
|
|
42
|
+
self.whitelisted_ransackable_attributes = %w[task_name status created_at]
|
|
43
|
+
|
|
44
|
+
# Filtered by the task's short name, which is what responses carry; the
|
|
45
|
+
# column holds its class name. A partial value (`task_name_cont=capture`)
|
|
46
|
+
# is camelized so a search still matches part of the stored name.
|
|
47
|
+
ransacker :task_name, formatter: ->(value) {
|
|
48
|
+
Spree::MaintenanceTask.find_by_api_type(value)&.name || value.to_s.camelize
|
|
49
|
+
} do |parent|
|
|
50
|
+
parent.table[:task_name]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Class, nil] the registered task class, or nil when the task has
|
|
54
|
+
# been removed from the registry since the run was created
|
|
55
|
+
def task_class
|
|
56
|
+
Spree::MaintenanceTask.find_registered(task_name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The task's short name, as the API and the dashboard identify it.
|
|
60
|
+
#
|
|
61
|
+
# @return [String]
|
|
62
|
+
def task_api_type
|
|
63
|
+
Spree::MaintenanceTask.api_type_for(task_name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def active?
|
|
67
|
+
ACTIVE_STATUSES.include?(status)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Seconds of work. Time since the last checkpoint counts only while a
|
|
71
|
+
# worker holds the run — a queued run is not working.
|
|
72
|
+
def duration
|
|
73
|
+
return time_running unless status.in?(%w[running cancelling]) && started_at.present?
|
|
74
|
+
|
|
75
|
+
time_running + (Time.current - (updated_at || started_at))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# A release boundary whose data steps this installation has completed
|
|
3
|
+
# (docs/plans/6.0-maintenance-tasks.md).
|
|
4
|
+
#
|
|
5
|
+
# This exists because nothing else in the schema records where an
|
|
6
|
+
# installation upgraded *from*. `Spree.version` is only ever the gem that is
|
|
7
|
+
# installed now, so without a stamp the dashboard cannot tell a store that
|
|
8
|
+
# has been on 5.6 for months from one jumping straight from 5.4 — and would
|
|
9
|
+
# have to show both every step of every manifest, which grows with each
|
|
10
|
+
# release.
|
|
11
|
+
#
|
|
12
|
+
# Written from three places, none of which guesses:
|
|
13
|
+
#
|
|
14
|
+
# - a successful `spree:upgrade` walk stamps the boundary it reached
|
|
15
|
+
# - a fresh install stamps the current version, because a database created
|
|
16
|
+
# by today's schema has no historical data to convert
|
|
17
|
+
# - an operator can stamp one by hand, for a store upgraded before this
|
|
18
|
+
# record existed
|
|
19
|
+
class UpgradeRecord < Spree.base_class
|
|
20
|
+
has_prefix_id :upg
|
|
21
|
+
|
|
22
|
+
SOURCES = %w[walk install manual].freeze
|
|
23
|
+
|
|
24
|
+
validates :version, presence: true, uniqueness: true
|
|
25
|
+
validates :source, presence: true, inclusion: { in: SOURCES }
|
|
26
|
+
validates :completed_at, presence: true
|
|
27
|
+
|
|
28
|
+
# Whether this installation has ever been upgraded, as opposed to having
|
|
29
|
+
# been installed at its current version.
|
|
30
|
+
#
|
|
31
|
+
# A store installed fresh at 6.0 has no historical data to convert and no
|
|
32
|
+
# upgrade to perform — showing it a manifest of steps, current or past,
|
|
33
|
+
# describes work that does not exist for it.
|
|
34
|
+
#
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def self.upgraded?
|
|
37
|
+
where.not(source: 'install').exists?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The highest boundary recorded, or nil when this installation has never
|
|
41
|
+
# stamped one.
|
|
42
|
+
#
|
|
43
|
+
# @return [String, nil]
|
|
44
|
+
def self.current_version
|
|
45
|
+
all.map(&:version).max_by { |version| Spree::Upgrade.version_parts(version) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Records a completed boundary. Idempotent — re-running a walk that has
|
|
49
|
+
# already been stamped updates when it happened rather than failing.
|
|
50
|
+
#
|
|
51
|
+
# @param version [String] two-segment release, e.g. "6.0"
|
|
52
|
+
# @param source [String] one of SOURCES
|
|
53
|
+
# @return [Spree::UpgradeRecord]
|
|
54
|
+
def self.stamp!(version, source: 'walk')
|
|
55
|
+
record = find_or_initialize_by(version: version.to_s)
|
|
56
|
+
record.source = source.to_s
|
|
57
|
+
record.completed_at = Time.current
|
|
58
|
+
record.save!
|
|
59
|
+
record
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
# Turns the CLI's `ARGS='market_id=mkt_1 batch_size=200'` into the hash the
|
|
4
|
+
# task's ActiveModel attributes expect.
|
|
5
|
+
#
|
|
6
|
+
# Values stay strings: attribute types cast them, so the parser never has
|
|
7
|
+
# to guess whether `200` is a number or a label.
|
|
8
|
+
module ArgumentParser
|
|
9
|
+
# Quoted values may contain spaces: ARGS='label="two words" limit=5'.
|
|
10
|
+
TOKEN = /(\w+)=("[^"]*"|'[^']*'|\S+)/
|
|
11
|
+
|
|
12
|
+
# @param raw [String, nil]
|
|
13
|
+
# @return [Hash{String => String}]
|
|
14
|
+
def self.parse(raw)
|
|
15
|
+
return {} if raw.blank?
|
|
16
|
+
|
|
17
|
+
raw.scan(TOKEN).to_h do |key, value|
|
|
18
|
+
[key, value.gsub(/\A["']|["']\z/, '')]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
# Stops a run for good. The service records the request and the runner
|
|
4
|
+
# records the outcome; a run that has not started yet is cancelled
|
|
5
|
+
# outright, since no execution will ever observe the request.
|
|
6
|
+
#
|
|
7
|
+
# Cancelling a run that is already `cancelling` also cancels it outright.
|
|
8
|
+
# That is the way out when the worker that should have observed the first
|
|
9
|
+
# request is gone: a run the runner never comes back to would otherwise
|
|
10
|
+
# stay active and block every new run of its task.
|
|
11
|
+
#
|
|
12
|
+
# Cancelling is not a rollback — work already committed stays committed.
|
|
13
|
+
# What a cancelled run keeps is its cursor, so an operator can see exactly
|
|
14
|
+
# how far it got.
|
|
15
|
+
class Cancel
|
|
16
|
+
prepend ::Spree::ServiceModule::Base
|
|
17
|
+
|
|
18
|
+
# @param run [Spree::MaintenanceTaskRun]
|
|
19
|
+
# @return [Spree::ServiceModule::Base::Result] the run
|
|
20
|
+
def call(run:)
|
|
21
|
+
# Locked so the status read here is still the status when it is
|
|
22
|
+
# written: the runner may finish the run in between, and a finished
|
|
23
|
+
# run overwritten with `cancelling` would stay active for good.
|
|
24
|
+
run.with_lock do
|
|
25
|
+
return failure(run, not_cancelable_error(run)) unless run.active?
|
|
26
|
+
|
|
27
|
+
# A running run has a worker mid-batch, so it, not this service,
|
|
28
|
+
# records that the run stopped.
|
|
29
|
+
if run.running?
|
|
30
|
+
run.update!(status: 'cancelling')
|
|
31
|
+
else
|
|
32
|
+
run.update!(status: 'cancelled', ended_at: Time.current)
|
|
33
|
+
run.publish_event('maintenance_task_run.cancelled')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
success(run.reload)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def not_cancelable_error(run)
|
|
43
|
+
build_error("a run with status #{run.status} cannot be cancelled")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_error(message)
|
|
47
|
+
ActiveModel::Errors.new(Spree::MaintenanceTaskRun.new).tap { |errors| errors.add(:base, message) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module MaintenanceTasks
|
|
3
|
+
# Creates a run for a registered task and hands it to the runner
|
|
4
|
+
# (docs/plans/6.0-maintenance-tasks.md).
|
|
5
|
+
#
|
|
6
|
+
# Everything that can be known before any work happens is checked here —
|
|
7
|
+
# the task exists, its parameters validate, and no run of the same task
|
|
8
|
+
# is already in flight — so an operator sees a 422
|
|
9
|
+
# rather than a run that dies on its first record.
|
|
10
|
+
class Start
|
|
11
|
+
prepend ::Spree::ServiceModule::Base
|
|
12
|
+
|
|
13
|
+
# @param task_name [String] a registered Spree::MaintenanceTask subclass name
|
|
14
|
+
# @param api_type [String] the task's short name, in place of `task_name`
|
|
15
|
+
# — how an API caller names it, so a class name never has to cross
|
|
16
|
+
# the API
|
|
17
|
+
# @param arguments [Hash] task parameters
|
|
18
|
+
# @param dry_run [Boolean] preview mode; only honored by tasks that declare
|
|
19
|
+
# `supports_dry_run`
|
|
20
|
+
# @param initiated_via [String] dashboard / api / cli
|
|
21
|
+
# @param inline [Boolean] run synchronously (seeds, the CLI, the upgrade walk)
|
|
22
|
+
# @return [Spree::ServiceModule::Base::Result] the run
|
|
23
|
+
def call(task_name: nil, api_type: nil, arguments: {}, dry_run: false, admin_user: nil,
|
|
24
|
+
api_key: nil, initiated_via: 'api', inline: false)
|
|
25
|
+
task_class = if api_type.nil?
|
|
26
|
+
Spree::MaintenanceTask.find_registered(task_name)
|
|
27
|
+
else
|
|
28
|
+
Spree::MaintenanceTask.find_by_api_type(api_type)
|
|
29
|
+
end
|
|
30
|
+
return failure(nil, unknown_task_error(api_type || task_name)) if task_class.nil?
|
|
31
|
+
|
|
32
|
+
task_name = task_class.name
|
|
33
|
+
|
|
34
|
+
arguments = (arguments || {}).stringify_keys
|
|
35
|
+
unknown = arguments.keys - task_class.attribute_names
|
|
36
|
+
return failure(nil, unknown_arguments_error(unknown)) if unknown.any?
|
|
37
|
+
|
|
38
|
+
if initiated_via.to_s.in?(%w[api dashboard]) && superseded_step?(task_class, arguments)
|
|
39
|
+
return failure(nil, superseded_error(task_class))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
task = task_class.new(arguments)
|
|
43
|
+
return failure(task, task.errors) unless task.valid?
|
|
44
|
+
|
|
45
|
+
active = active_run_for(task_name)
|
|
46
|
+
return failure(active, already_running_error(task_class)) if active
|
|
47
|
+
|
|
48
|
+
run = build_run(task_class, arguments, dry_run, admin_user, api_key, initiated_via)
|
|
49
|
+
return failure(run, run.errors) unless run.save
|
|
50
|
+
|
|
51
|
+
dispatch(run, inline)
|
|
52
|
+
|
|
53
|
+
success(run)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def build_run(task_class, arguments, dry_run, admin_user, api_key, initiated_via)
|
|
59
|
+
Spree::MaintenanceTaskRun.new(
|
|
60
|
+
task_name: task_class.name,
|
|
61
|
+
status: Spree::MaintenanceTaskRun.default_status,
|
|
62
|
+
arguments: arguments,
|
|
63
|
+
# A task that does not declare dry-run support would ignore the flag
|
|
64
|
+
# and write for real; recording it as a preview would be a lie.
|
|
65
|
+
dry_run: dry_run && task_class.dry_run_supported,
|
|
66
|
+
admin_user: admin_user,
|
|
67
|
+
api_key: api_key,
|
|
68
|
+
initiated_via: initiated_via.to_s
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The enqueue waits for the transaction so the worker cannot pick the
|
|
73
|
+
# job up before the row it names is visible.
|
|
74
|
+
def dispatch(run, inline)
|
|
75
|
+
if inline
|
|
76
|
+
ActiveRecord.after_all_transactions_commit { Spree::MaintenanceTasks::RunJob.perform_now(run.id, inline: true) }
|
|
77
|
+
else
|
|
78
|
+
Spree::MaintenanceTasks::RunJob.perform_later(run.id)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Upgrade steps of a release this installation already completed. The
|
|
83
|
+
# dashboard never offers them; the command line still runs them, since
|
|
84
|
+
# that is where an operator deliberately repeats a step.
|
|
85
|
+
#
|
|
86
|
+
# Refused even when the completed release was assumed rather than
|
|
87
|
+
# recorded. The assumption is right for nearly every store, and for
|
|
88
|
+
# those a repeated conversion damages data; a store that really skipped
|
|
89
|
+
# a release is told to run its steps from the command line.
|
|
90
|
+
def superseded_step?(task_class, arguments)
|
|
91
|
+
Spree::Upgrade.superseded_steps.any? do |step|
|
|
92
|
+
step['task_class'] == task_class.name ||
|
|
93
|
+
(task_class <= Spree::MaintenanceTasks::UpgradeStep && step['id'] == arguments['step_id'])
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# One run per task at a time. Checked rather than locked: two requests
|
|
98
|
+
# can both pass this check, and the runner then cancels the newer run
|
|
99
|
+
# before it does any work (RunJob#duplicate_run?).
|
|
100
|
+
def active_run_for(task_name)
|
|
101
|
+
Spree::MaintenanceTaskRun.active.find_by(task_name: task_name)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unknown_task_error(task_name)
|
|
105
|
+
errors_for(:task_name, "#{task_name} is not a registered maintenance task")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def unknown_arguments_error(names)
|
|
109
|
+
errors_for(:arguments, "the task takes no parameter named #{names.to_sentence}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def superseded_error(task_class)
|
|
113
|
+
errors_for(:base, "#{task_class.api_type} belongs to a release this store has already completed")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def already_running_error(task_class)
|
|
117
|
+
errors_for(:base, "#{task_class.api_type} is already running")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def errors_for(attribute, message)
|
|
121
|
+
ActiveModel::Errors.new(Spree::MaintenanceTaskRun.new).tap do |errors|
|
|
122
|
+
errors.add(attribute, message)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -10,6 +10,8 @@ module Spree
|
|
|
10
10
|
# countries gem, and zones are migration-only, so none of them
|
|
11
11
|
# are seeded.
|
|
12
12
|
|
|
13
|
+
fresh_database = Spree::Store.none?
|
|
14
|
+
|
|
13
15
|
Stores.call
|
|
14
16
|
Spree::Store.find_each { |store| StoreResources.call(store: store) }
|
|
15
17
|
# The warehouse, delivery zones and pickup are not seeded: their
|
|
@@ -18,6 +20,26 @@ module Spree
|
|
|
18
20
|
# them from the merchant's answer — at first-run setup, or here
|
|
19
21
|
# when ADMIN_EMAIL/ADMIN_PASSWORD name an install that skips it.
|
|
20
22
|
AdminUser.call
|
|
23
|
+
|
|
24
|
+
# A database created by today's schema has no historical data to
|
|
25
|
+
# convert, so every upgrade boundary up to this release is already
|
|
26
|
+
# satisfied. Recording that is what keeps a fresh install from
|
|
27
|
+
# being shown a backlog of steps it must never run. Only a database
|
|
28
|
+
# seeded for the first time qualifies: seeding is also a deploy
|
|
29
|
+
# step, and an upgraded store seeded before its upgrade ran still
|
|
30
|
+
# has every step to do.
|
|
31
|
+
stamp_upgrade_boundaries if fresh_database
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def stamp_upgrade_boundaries
|
|
39
|
+
Spree::Upgrade.manifests.each do |manifest|
|
|
40
|
+
Spree::UpgradeRecord.find_or_create_by!(version: manifest['to']) do |record|
|
|
41
|
+
record.source = 'install'
|
|
42
|
+
record.completed_at = Time.current
|
|
21
43
|
end
|
|
22
44
|
end
|
|
23
45
|
end
|
data/config/locales/en.yml
CHANGED
|
@@ -1032,6 +1032,7 @@ en:
|
|
|
1032
1032
|
locale_not_supported_by_market: is not offered by the chosen market
|
|
1033
1033
|
locale_not_supported_by_store: is not supported by this store
|
|
1034
1034
|
locales_not_listed: 'includes locales Spree does not support: %{codes}'
|
|
1035
|
+
maintenance_tasks_turned_off: Maintenance tasks are turned off for this installation. Run upgrade steps from the command line with `spree upgrade` (or `bin/rake spree:upgrade`).
|
|
1035
1036
|
manual: Manual
|
|
1036
1037
|
market: Market
|
|
1037
1038
|
me_no_current_user: No admin user authenticated this request
|
|
@@ -10,7 +10,7 @@ class AddCaptureMethodToPaymentMethods < ActiveRecord::Migration[8.1]
|
|
|
10
10
|
# from the provider configuration form.
|
|
11
11
|
#
|
|
12
12
|
# Existing auto_capture values are carried over by
|
|
13
|
-
#
|
|
13
|
+
# the capture_methods maintenance task, never here.
|
|
14
14
|
def change
|
|
15
15
|
add_column :spree_payment_methods, :capture_method, :string
|
|
16
16
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
class CreateSpreeMaintenanceTaskRuns < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_maintenance_task_runs do |t|
|
|
4
|
+
t.string :task_name, null: false
|
|
5
|
+
t.string :status, null: false
|
|
6
|
+
t.boolean :dry_run, null: false, default: false
|
|
7
|
+
|
|
8
|
+
t.string :cursor
|
|
9
|
+
t.integer :tick_count, null: false, default: 0
|
|
10
|
+
t.integer :tick_total
|
|
11
|
+
|
|
12
|
+
t.decimal :time_running, null: false, default: 0.0, precision: 12, scale: 3
|
|
13
|
+
t.datetime :started_at
|
|
14
|
+
t.datetime :ended_at
|
|
15
|
+
|
|
16
|
+
t.string :error_class
|
|
17
|
+
t.text :error_message
|
|
18
|
+
t.text :error_backtrace
|
|
19
|
+
|
|
20
|
+
t.string :job_id
|
|
21
|
+
t.string :initiated_via, null: false
|
|
22
|
+
|
|
23
|
+
t.references :admin_user, null: true
|
|
24
|
+
t.references :api_key, null: true
|
|
25
|
+
|
|
26
|
+
if t.respond_to?(:jsonb)
|
|
27
|
+
t.jsonb :arguments
|
|
28
|
+
t.jsonb :tallies
|
|
29
|
+
else
|
|
30
|
+
t.json :arguments
|
|
31
|
+
t.json :tallies
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
t.timestamps
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
add_index :spree_maintenance_task_runs, [:task_name, :status]
|
|
38
|
+
add_index :spree_maintenance_task_runs, :created_at
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class CreateSpreeUpgradeRecords < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_upgrade_records do |t|
|
|
4
|
+
# The release boundary this installation has completed the data steps
|
|
5
|
+
# for — "5.6" means every manifest up to and including 5.5 → 5.6 is done.
|
|
6
|
+
t.string :version, null: false
|
|
7
|
+
# How we know: `walk` (a successful upgrade walk), `install` (a fresh
|
|
8
|
+
# install, which needs no historical steps) or `manual`.
|
|
9
|
+
t.string :source, null: false
|
|
10
|
+
t.datetime :completed_at, null: false
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :spree_upgrade_records, :version, unique: true
|
|
16
|
+
end
|
|
17
|
+
end
|