maintenance_on_steroids 0.2.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 +7 -0
- data/CHANGELOG.md +267 -0
- data/LICENSE.txt +21 -0
- data/README.md +948 -0
- data/app/controllers/maintenance_on_steroids/application_controller.rb +66 -0
- data/app/controllers/maintenance_on_steroids/dashboard_controller.rb +18 -0
- data/app/controllers/maintenance_on_steroids/jobs_controller.rb +59 -0
- data/app/controllers/maintenance_on_steroids/runs_controller.rb +223 -0
- data/app/jobs/maintenance_on_steroids/run_job.rb +292 -0
- data/app/models/maintenance_on_steroids/application_record.rb +6 -0
- data/app/models/maintenance_on_steroids/artifact.rb +255 -0
- data/app/models/maintenance_on_steroids/run.rb +310 -0
- data/app/views/layouts/maintenance_on_steroids/application.html.erb +48 -0
- data/app/views/maintenance_on_steroids/dashboard/index.html.erb +163 -0
- data/app/views/maintenance_on_steroids/jobs/index.html.erb +35 -0
- data/app/views/maintenance_on_steroids/jobs/show.html.erb +107 -0
- data/app/views/maintenance_on_steroids/jobs/source.html.erb +79 -0
- data/app/views/maintenance_on_steroids/runs/new.html.erb +98 -0
- data/app/views/maintenance_on_steroids/runs/show.html.erb +410 -0
- data/app/views/maintenance_on_steroids/shared/_auto_refresh.html.erb +85 -0
- data/app/views/maintenance_on_steroids/shared/_javascript.html.erb +16 -0
- data/app/views/maintenance_on_steroids/shared/_pager.html.erb +20 -0
- data/app/views/maintenance_on_steroids/shared/_styles.html.erb +649 -0
- data/app/views/maintenance_on_steroids/shared/_task_list_item.html.erb +31 -0
- data/config/routes.rb +22 -0
- data/lib/generators/maintenance_on_steroids/install/install_generator.rb +56 -0
- data/lib/generators/maintenance_on_steroids/install/templates/create_maintenance_on_steroids_tables.rb.erb +56 -0
- data/lib/generators/maintenance_on_steroids/install/templates/initializer.rb +42 -0
- data/lib/generators/maintenance_on_steroids/job/job_generator.rb +17 -0
- data/lib/generators/maintenance_on_steroids/job/templates/job.rb.erb +30 -0
- data/lib/maintenance_on_steroids/about_dsl.rb +51 -0
- data/lib/maintenance_on_steroids/artifact_dsl.rb +82 -0
- data/lib/maintenance_on_steroids/artifacts_proxy.rb +205 -0
- data/lib/maintenance_on_steroids/callbacks_dsl.rb +61 -0
- data/lib/maintenance_on_steroids/csv_artifact.rb +88 -0
- data/lib/maintenance_on_steroids/engine.rb +26 -0
- data/lib/maintenance_on_steroids/form_dsl.rb +63 -0
- data/lib/maintenance_on_steroids/instrumentation.rb +33 -0
- data/lib/maintenance_on_steroids/job_dsl.rb +61 -0
- data/lib/maintenance_on_steroids/job_registry.rb +83 -0
- data/lib/maintenance_on_steroids/jsonb_artifact.rb +39 -0
- data/lib/maintenance_on_steroids/params_proxy.rb +93 -0
- data/lib/maintenance_on_steroids/task.rb +109 -0
- data/lib/maintenance_on_steroids/text_artifact.rb +74 -0
- data/lib/maintenance_on_steroids/version.rb +3 -0
- data/lib/maintenance_on_steroids.rb +187 -0
- metadata +125 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class Artifact < ApplicationRecord
|
|
3
|
+
belongs_to :run
|
|
4
|
+
|
|
5
|
+
attr_accessor :worker_token
|
|
6
|
+
around_save :guard_worker_write
|
|
7
|
+
|
|
8
|
+
def guard_worker_write(&block)
|
|
9
|
+
return yield unless worker_token
|
|
10
|
+
|
|
11
|
+
run.worker_token = worker_token
|
|
12
|
+
run.with_execution_lock(&block)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check_buffer_size!(size)
|
|
16
|
+
limit = MaintenanceOnSteroids.max_artifact_size
|
|
17
|
+
return if limit.nil? || size <= limit
|
|
18
|
+
|
|
19
|
+
errors.add(:base, "artifact #{name.inspect} exceeds max_artifact_size (#{limit} bytes); use object storage for large outputs")
|
|
20
|
+
raise ActiveRecord::RecordInvalid, self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
validates :name, presence: true
|
|
24
|
+
validates :kind, inclusion: { in: %w[input output] }
|
|
25
|
+
validates :artifact_type, inclusion: { in: %w[jsonb blob text csv] }
|
|
26
|
+
|
|
27
|
+
# Cap on how much of an artifact is materialised for an in-page preview.
|
|
28
|
+
# Parsing or HTML-escaping a payload costs several times its size in live
|
|
29
|
+
# objects, so an uncapped preview of a large export OOMs the web process
|
|
30
|
+
# to show the first hundred rows. The full payload is always downloadable.
|
|
31
|
+
# ponytail: the column itself is still SELECTed whole -- if multi-hundred-MB
|
|
32
|
+
# artifacts become normal, push the cap into SQL (substr) and out of Ruby.
|
|
33
|
+
PREVIEW_BYTES = 256 * 1024
|
|
34
|
+
|
|
35
|
+
# Top-level entries kept when previewing an oversized jsonb document.
|
|
36
|
+
# Slicing generated JSON would mean generating all of it first, which is
|
|
37
|
+
# exactly the allocation the byte cap exists to avoid -- so the structure
|
|
38
|
+
# is trimmed before it is rendered, and the byte cap is a second backstop.
|
|
39
|
+
PREVIEW_ENTRIES = 200
|
|
40
|
+
|
|
41
|
+
validate :payload_within_size_limit
|
|
42
|
+
|
|
43
|
+
def data
|
|
44
|
+
case artifact_type
|
|
45
|
+
when "jsonb" then data_jsonb
|
|
46
|
+
when "blob", "csv" then data_blob
|
|
47
|
+
when "text" then data_text
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def downloadable?
|
|
52
|
+
%w[blob csv].include?(artifact_type) && data_blob.present?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Parsed CSV rows for table preview, capped at PREVIEW_BYTES.
|
|
56
|
+
# nil for non-CSV artifacts.
|
|
57
|
+
def csv_rows
|
|
58
|
+
return nil unless artifact_type == "csv" && data_blob.present?
|
|
59
|
+
require "csv"
|
|
60
|
+
CSV.parse(preview_slice(data_blob))
|
|
61
|
+
rescue CSV::MalformedCSVError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Capped textual content for inline display; nil for types with no
|
|
66
|
+
# text preview (blob) or with nothing stored yet.
|
|
67
|
+
def preview_text
|
|
68
|
+
case artifact_type
|
|
69
|
+
when "jsonb" then preview_slice(JSON.pretty_generate(previewable_jsonb)) if data_jsonb.present?
|
|
70
|
+
when "text" then preview_slice(data_text) if data_text.present?
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# True when the inline preview shows less than the whole artifact -- either
|
|
75
|
+
# because the payload is over the byte cap or because a jsonb document had
|
|
76
|
+
# more than PREVIEW_ENTRIES top-level entries.
|
|
77
|
+
def preview_truncated?
|
|
78
|
+
return true if (byte_size || 0) > PREVIEW_BYTES
|
|
79
|
+
|
|
80
|
+
jsonb_over_entry_cap?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Download attributes -- correct MIME and filename even for generated files
|
|
84
|
+
# whose content_type wasn't set explicitly.
|
|
85
|
+
def download_file_name
|
|
86
|
+
file_name.presence || "#{name}.#{artifact_type == 'csv' ? 'csv' : 'bin'}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def download_content_type
|
|
90
|
+
content_type.presence ||
|
|
91
|
+
Rack::Mime.mime_type(::File.extname(download_file_name), "application/octet-stream")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def display_value
|
|
95
|
+
case artifact_type
|
|
96
|
+
when "jsonb"
|
|
97
|
+
data_jsonb.present? ? JSON.pretty_generate(data_jsonb) : "{}"
|
|
98
|
+
when "text"
|
|
99
|
+
data_text.to_s
|
|
100
|
+
when "csv"
|
|
101
|
+
data_blob.to_s
|
|
102
|
+
when "blob"
|
|
103
|
+
if file_name.present?
|
|
104
|
+
"#{file_name} (#{human_size})"
|
|
105
|
+
else
|
|
106
|
+
"Binary data (#{human_size})"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Recomputes lightweight stats (entry/line count, byte size, timestamp)
|
|
112
|
+
# into the metadata column. Called on every artifact save so the UI can
|
|
113
|
+
# show size/counts without loading the full payload.
|
|
114
|
+
def refresh_metadata!
|
|
115
|
+
self.metadata = (metadata || {}).merge(computed_metadata)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def computed_metadata
|
|
119
|
+
base = { "generated_at" => Time.current.utc.iso8601 }
|
|
120
|
+
case artifact_type
|
|
121
|
+
when "jsonb"
|
|
122
|
+
data = data_jsonb || {}
|
|
123
|
+
base.merge("entries" => (data.respond_to?(:size) ? data.size : 1), "bytes" => data.to_json.bytesize)
|
|
124
|
+
when "text"
|
|
125
|
+
text = data_text || ""
|
|
126
|
+
base.merge("lines" => text.lines.size, "bytes" => text.bytesize)
|
|
127
|
+
when "csv"
|
|
128
|
+
blob = data_blob || ""
|
|
129
|
+
# Newline count == row count (CSV writes a trailing newline per row).
|
|
130
|
+
base.merge("rows" => (blob.empty? ? 0 : blob.count("\n")), "bytes" => blob.bytesize)
|
|
131
|
+
when "blob"
|
|
132
|
+
base.merge("bytes" => (data_blob ? data_blob.bytesize : 0))
|
|
133
|
+
else
|
|
134
|
+
base
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Prefers the precomputed metadata byte count; falls back to measuring the
|
|
139
|
+
# column that actually holds this type's payload. The old fallback only
|
|
140
|
+
# looked at data_blob, so jsonb and text rows written before metadata
|
|
141
|
+
# tracking reported nil -- which made preview_truncated? answer false and
|
|
142
|
+
# bypassed every preview cap that depends on it.
|
|
143
|
+
def byte_size
|
|
144
|
+
metadata&.dig("bytes") || measured_byte_size
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def generated_at
|
|
148
|
+
ts = metadata&.dig("generated_at")
|
|
149
|
+
Time.iso8601(ts) if ts.present?
|
|
150
|
+
rescue ArgumentError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# One-line human summary for the UI: "12 entries · 3.4 KB" etc.
|
|
155
|
+
def summary
|
|
156
|
+
case artifact_type
|
|
157
|
+
when "jsonb"
|
|
158
|
+
"#{metadata&.dig('entries') || 0} entries · #{human_size}"
|
|
159
|
+
when "text"
|
|
160
|
+
"#{metadata&.dig('lines') || 0} lines · #{human_size}"
|
|
161
|
+
when "csv"
|
|
162
|
+
"#{metadata&.dig('rows') || 0} rows · #{human_size}"
|
|
163
|
+
when "blob"
|
|
164
|
+
file_name.present? ? "#{file_name} (#{human_size})" : "Binary data (#{human_size})"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# data_jsonb trimmed to PREVIEW_ENTRIES top-level entries. Driven by the
|
|
169
|
+
# entry count itself rather than by preview_truncated?, so a row with no
|
|
170
|
+
# recorded byte size still gets trimmed instead of being generated whole.
|
|
171
|
+
# Scalars and small documents pass through untouched.
|
|
172
|
+
def previewable_jsonb
|
|
173
|
+
case data_jsonb
|
|
174
|
+
when Hash then data_jsonb.size > PREVIEW_ENTRIES ? data_jsonb.first(PREVIEW_ENTRIES).to_h : data_jsonb
|
|
175
|
+
when Array then data_jsonb.size > PREVIEW_ENTRIES ? data_jsonb.first(PREVIEW_ENTRIES) : data_jsonb
|
|
176
|
+
else data_jsonb
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def jsonb_over_entry_cap?
|
|
181
|
+
artifact_type == "jsonb" &&
|
|
182
|
+
data_jsonb.respond_to?(:size) &&
|
|
183
|
+
!data_jsonb.is_a?(String) &&
|
|
184
|
+
data_jsonb.size > PREVIEW_ENTRIES
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# One choke point for every write path (explicit save, accumulator flush,
|
|
188
|
+
# file input). Failing the run with a legible message beats an OOM killer
|
|
189
|
+
# or a database-level error halfway through a long task.
|
|
190
|
+
def payload_within_size_limit
|
|
191
|
+
limit = MaintenanceOnSteroids.max_artifact_size
|
|
192
|
+
return if limit.nil?
|
|
193
|
+
|
|
194
|
+
size = measured_byte_size
|
|
195
|
+
return if size.nil? || size <= limit
|
|
196
|
+
|
|
197
|
+
errors.add(
|
|
198
|
+
:base,
|
|
199
|
+
"artifact #{name.inspect} is #{ActiveSupport::NumberHelper.number_to_human_size(size)}, " \
|
|
200
|
+
"over the #{ActiveSupport::NumberHelper.number_to_human_size(limit)} limit " \
|
|
201
|
+
"(MaintenanceOnSteroids.max_artifact_size). Write large outputs to object storage " \
|
|
202
|
+
"and store a reference instead."
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def measured_byte_size
|
|
207
|
+
case artifact_type
|
|
208
|
+
when "jsonb" then data_jsonb && data_jsonb.to_json.bytesize
|
|
209
|
+
when "text" then data_text&.bytesize
|
|
210
|
+
when "blob", "csv" then data_blob&.bytesize
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# At most PREVIEW_BYTES of a payload, always returned as valid UTF-8, cut
|
|
215
|
+
# back to the last complete line when it had to be truncated.
|
|
216
|
+
#
|
|
217
|
+
# Two encoding traps here, both of which used to reach the run page:
|
|
218
|
+
# - data_blob comes back as ASCII-8BIT, and interpolating a BINARY string
|
|
219
|
+
# holding non-ASCII bytes into the UTF-8 template raises
|
|
220
|
+
# Encoding::CompatibilityError -- so any CSV export containing an
|
|
221
|
+
# accented character took the page down, at any size.
|
|
222
|
+
# - byteslice cuts on a byte boundary, landing inside a multibyte
|
|
223
|
+
# character often enough to matter, and matching a Regexp against the
|
|
224
|
+
# resulting invalid string raises ArgumentError.
|
|
225
|
+
# Slice first (bounded work), then transcode and scrub the small result.
|
|
226
|
+
def preview_slice(content)
|
|
227
|
+
truncated = content.bytesize > PREVIEW_BYTES
|
|
228
|
+
sliced = truncated ? content.byteslice(0, PREVIEW_BYTES) : content
|
|
229
|
+
|
|
230
|
+
sliced = sliced.dup.force_encoding(Encoding::UTF_8)
|
|
231
|
+
sliced = sliced.scrub("") unless sliced.valid_encoding?
|
|
232
|
+
|
|
233
|
+
return sliced unless truncated
|
|
234
|
+
|
|
235
|
+
# Drop the trailing partial line -- but only when there is an earlier
|
|
236
|
+
# newline to fall back to. A single-line payload (a minified blob, a log
|
|
237
|
+
# with no line breaks) has no newline in the slice at all, and the regex
|
|
238
|
+
# would match the whole thing and leave an empty preview.
|
|
239
|
+
trimmed = sliced.sub(/[^\n]*\z/, "")
|
|
240
|
+
trimmed.empty? ? sliced : trimmed
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def human_size
|
|
244
|
+
size = byte_size
|
|
245
|
+
return "0 B" unless size && size.positive?
|
|
246
|
+
if size < 1024
|
|
247
|
+
"#{size} B"
|
|
248
|
+
elsif size < 1024 * 1024
|
|
249
|
+
"#{(size / 1024.0).round(1)} KB"
|
|
250
|
+
else
|
|
251
|
+
"#{(size / (1024.0 * 1024)).round(1)} MB"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
module MaintenanceOnSteroids
|
|
2
|
+
class Run < ApplicationRecord
|
|
3
|
+
has_many :artifacts, dependent: :destroy
|
|
4
|
+
|
|
5
|
+
# Set only on the worker's instance, never accepted from request parameters.
|
|
6
|
+
attr_accessor :worker_token
|
|
7
|
+
|
|
8
|
+
STATUSES = %w[enqueued running pausing paused cancelling cancelled completed errored].freeze
|
|
9
|
+
ACTIVE_STATUSES = %w[enqueued running pausing paused].freeze
|
|
10
|
+
# Statuses an operator may restart from. "errored" is included because a
|
|
11
|
+
# run that failed can resume after its last committed checkpoint, retrying
|
|
12
|
+
# the failed record and any effects it made before checkpointing.
|
|
13
|
+
RESUMABLE_STATUSES = %w[paused errored].freeze
|
|
14
|
+
# Statuses that mean "a worker currently holds this run" -- candidates
|
|
15
|
+
# for staleness reaping when the worker died without updating the row.
|
|
16
|
+
STALE_CANDIDATE_STATUSES = %w[running pausing cancelling].freeze
|
|
17
|
+
# Runs that will never change again, and so are safe to prune.
|
|
18
|
+
TERMINAL_STATUSES = %w[completed cancelled errored].freeze
|
|
19
|
+
|
|
20
|
+
validates :task_class, presence: true
|
|
21
|
+
validates :status, inclusion: { in: STATUSES }
|
|
22
|
+
|
|
23
|
+
scope :recent, -> { order(created_at: :desc, id: :desc) }
|
|
24
|
+
scope :active, -> { where(status: ACTIVE_STATUSES) }
|
|
25
|
+
|
|
26
|
+
STATUSES.each do |s|
|
|
27
|
+
define_method(:"#{s}?") { status == s }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Transitions runs stuck in an in-flight status to "errored" when the row
|
|
31
|
+
# hasn't been touched for `threshold`. RunJob updates the row at least
|
|
32
|
+
# once per processed record, so updated_at acts as a heartbeat. Call this
|
|
33
|
+
# periodically (cron, recurring job) to recover from worker crashes. Queued
|
|
34
|
+
# runs are included only with an explicit enqueued_threshold longer than
|
|
35
|
+
# normal queue latency. Revoking the token fences out a surviving worker.
|
|
36
|
+
# Returns the number of reaped runs.
|
|
37
|
+
def self.reap_stale!(threshold: 30.minutes, enqueued_threshold: nil)
|
|
38
|
+
stale = where(status: STALE_CANDIDATE_STATUSES).where(updated_at: ...threshold.ago)
|
|
39
|
+
if enqueued_threshold
|
|
40
|
+
stale = stale.or(where(status: "enqueued").where(updated_at: ...enqueued_threshold.ago))
|
|
41
|
+
end
|
|
42
|
+
stale.update_all(
|
|
43
|
+
status: "errored",
|
|
44
|
+
execution_token: nil,
|
|
45
|
+
error_message: "Run marked as stale: no progress before the configured timeout. The worker or dispatch may have failed.",
|
|
46
|
+
completed_at: Time.current,
|
|
47
|
+
updated_at: Time.current
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Deletes finished runs older than `older_than`, with their artifacts.
|
|
52
|
+
# Nothing expires these rows on its own, so a long-lived app accumulates
|
|
53
|
+
# every run, backtrace and stored blob forever. Call this periodically the
|
|
54
|
+
# same way as reap_stale!:
|
|
55
|
+
#
|
|
56
|
+
# MaintenanceOnSteroids::Run.prune!(older_than: 90.days)
|
|
57
|
+
#
|
|
58
|
+
# Only terminal runs are eligible -- anything still active or paused is
|
|
59
|
+
# left alone regardless of age. Returns the number of runs deleted.
|
|
60
|
+
def self.prune!(older_than: 90.days, statuses: TERMINAL_STATUSES)
|
|
61
|
+
scope = where(status: Array(statuses) & TERMINAL_STATUSES)
|
|
62
|
+
.where(created_at: ...older_than.ago)
|
|
63
|
+
|
|
64
|
+
# destroy_all rather than delete_all so dependent artifacts go too;
|
|
65
|
+
# batched so pruning a large backlog doesn't build one huge transaction.
|
|
66
|
+
deleted = 0
|
|
67
|
+
scope.in_batches(of: 500) do |batch|
|
|
68
|
+
deleted += batch.destroy_all.size
|
|
69
|
+
end
|
|
70
|
+
deleted
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def active?
|
|
74
|
+
ACTIVE_STATUSES.include?(status)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def stoppable?
|
|
78
|
+
%w[running pausing].include?(status)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def pausable?
|
|
82
|
+
status == "running"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resumable?
|
|
86
|
+
RESUMABLE_STATUSES.include?(status)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def cancellable?
|
|
90
|
+
ACTIVE_STATUSES.include?(status)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def progress_percentage
|
|
94
|
+
return 0 if progress_total.zero?
|
|
95
|
+
[(progress_current.to_f / progress_total * 100).round(1), 100.0].min
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def duration
|
|
99
|
+
return nil unless started_at
|
|
100
|
+
end_time = completed_at || Time.current
|
|
101
|
+
end_time - started_at
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def formatted_duration
|
|
105
|
+
return "—" unless duration
|
|
106
|
+
seconds = duration.to_i
|
|
107
|
+
if seconds < 60
|
|
108
|
+
"#{seconds}s"
|
|
109
|
+
elsif seconds < 3600
|
|
110
|
+
"#{seconds / 60}m #{seconds % 60}s"
|
|
111
|
+
else
|
|
112
|
+
"#{seconds / 3600}h #{(seconds % 3600) / 60}m"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Estimated time remaining for the pending records, extrapolated from the
|
|
117
|
+
# current processing rate. Only meaningful while progress is being tracked.
|
|
118
|
+
def estimated_duration
|
|
119
|
+
return nil unless running? && started_at && progress_total.positive? && progress_current.positive?
|
|
120
|
+
# No estimate once we've reached (or overshot) the total -- nothing
|
|
121
|
+
# pending, and progress_current > progress_total would go negative.
|
|
122
|
+
return nil unless progress_current < progress_total
|
|
123
|
+
duration * (progress_total - progress_current) / progress_current
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def formatted_estimated_duration
|
|
127
|
+
total = estimated_duration
|
|
128
|
+
return nil unless total
|
|
129
|
+
|
|
130
|
+
total = total.round
|
|
131
|
+
days = total / 86_400
|
|
132
|
+
hours = (total % 86_400) / 3600
|
|
133
|
+
mins = (total % 3600) / 60
|
|
134
|
+
secs = total % 60
|
|
135
|
+
|
|
136
|
+
parts = []
|
|
137
|
+
parts << "#{days}d" if days.positive?
|
|
138
|
+
parts << "#{hours}h" if hours.positive? || days.positive?
|
|
139
|
+
parts << "#{mins}m" if mins.positive? || hours.positive? || days.positive?
|
|
140
|
+
parts << "#{secs}s"
|
|
141
|
+
parts.join(" ")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def task_instance
|
|
145
|
+
@task_instance ||= begin
|
|
146
|
+
klass = JobRegistry.find(task_class)
|
|
147
|
+
raise ArgumentError, "Unknown maintenance task: #{task_class}" unless klass
|
|
148
|
+
klass.new(self)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def enqueue!(job_id: SecureRandom.uuid)
|
|
153
|
+
previous_backtrace = error_backtrace
|
|
154
|
+
job_config = task_instance.class.job_config
|
|
155
|
+
job = RunJob.new(id)
|
|
156
|
+
job.job_id = job_id
|
|
157
|
+
job.enqueue_failure_backtrace = previous_backtrace
|
|
158
|
+
job.queue_name = job_config.queue_name if job_config.queue_name
|
|
159
|
+
job.priority = job_config.priority if job_config.priority
|
|
160
|
+
|
|
161
|
+
with_lock do
|
|
162
|
+
return false unless enqueued? && execution_token.nil? && (active_job_id.nil? || active_job_id == job_id)
|
|
163
|
+
# Publish the attempt identity and reset metadata before a worker can run.
|
|
164
|
+
update!(active_job_id: job_id, completed_at: nil, error_message: nil, error_backtrace: nil)
|
|
165
|
+
end
|
|
166
|
+
unless job.enqueue
|
|
167
|
+
raise EnqueueFailed, job.enqueue_error&.message || "Enqueue callback aborted the job"
|
|
168
|
+
end
|
|
169
|
+
safe_instrument(:enqueued)
|
|
170
|
+
true
|
|
171
|
+
rescue => e
|
|
172
|
+
# A fast worker may already have completed or failed. Never overwrite it,
|
|
173
|
+
# or a newer attempt, when dispatch reports an error.
|
|
174
|
+
self.class.where(id: id, status: "enqueued", execution_token: nil, active_job_id: [nil, job_id]).update_all(
|
|
175
|
+
status: "errored", error_message: "Failed to enqueue: #{e.message}",
|
|
176
|
+
error_backtrace: previous_backtrace, completed_at: Time.current, updated_at: Time.current
|
|
177
|
+
)
|
|
178
|
+
reload
|
|
179
|
+
raise EnqueueFailed, e.message
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# All worker bookkeeping and buffered output commits are fenced by this
|
|
183
|
+
# token. The lock is short-lived; task side effects happen outside it.
|
|
184
|
+
def with_execution_lock
|
|
185
|
+
with_lock do
|
|
186
|
+
unless worker_token && execution_token == worker_token && STALE_CANDIDATE_STATUSES.include?(status)
|
|
187
|
+
raise ExecutionLost, "Run #{id} no longer belongs to this worker"
|
|
188
|
+
end
|
|
189
|
+
yield
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Compare-and-set, like #resume!: a check-then-update would let a pause
|
|
194
|
+
# clicked as the job finishes overwrite "completed" with "pausing", which
|
|
195
|
+
# nothing but reap_stale! would ever clear.
|
|
196
|
+
# Returns true when the transition was performed, false otherwise.
|
|
197
|
+
def pause!
|
|
198
|
+
claimed = self.class.where(id: id, status: "running").update_all(
|
|
199
|
+
status: "pausing",
|
|
200
|
+
updated_at: Time.current
|
|
201
|
+
) == 1
|
|
202
|
+
reload if claimed
|
|
203
|
+
claimed
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Compare-and-set so two concurrent resumes can't both enqueue a job
|
|
207
|
+
# for the same run. Returns true when this call won the transition.
|
|
208
|
+
# Raises EnqueueFailed if the job could not be queued.
|
|
209
|
+
def resume!
|
|
210
|
+
job_id = SecureRandom.uuid
|
|
211
|
+
claimed = self.class.where(id: id, status: RESUMABLE_STATUSES).update_all(
|
|
212
|
+
status: "enqueued",
|
|
213
|
+
active_job_id: job_id,
|
|
214
|
+
execution_token: nil,
|
|
215
|
+
updated_at: Time.current
|
|
216
|
+
) == 1
|
|
217
|
+
return false unless claimed
|
|
218
|
+
|
|
219
|
+
reload
|
|
220
|
+
return false unless enqueue!(job_id: job_id)
|
|
221
|
+
safe_instrument(:resumed)
|
|
222
|
+
true
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Returns true when the transition was performed, false otherwise.
|
|
226
|
+
# NOTE: an already-enqueued job cannot be portably removed from the queue
|
|
227
|
+
# via Active Job. RunJob#perform guards on terminal statuses, so a job
|
|
228
|
+
# that still fires for a cancelled run is a no-op.
|
|
229
|
+
def cancel!
|
|
230
|
+
# Compare-and-set on each group, for the same reason as #pause!.
|
|
231
|
+
if self.class.where(id: id, status: %w[enqueued paused]).update_all(
|
|
232
|
+
status: "cancelled", completed_at: Time.current, updated_at: Time.current
|
|
233
|
+
) == 1
|
|
234
|
+
reload
|
|
235
|
+
safe_instrument(:cancelled)
|
|
236
|
+
true
|
|
237
|
+
elsif self.class.where(id: id, status: %w[running pausing]).update_all(
|
|
238
|
+
status: "cancelling", updated_at: Time.current
|
|
239
|
+
) == 1
|
|
240
|
+
reload
|
|
241
|
+
true
|
|
242
|
+
else
|
|
243
|
+
false
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Store current user who triggered this run.
|
|
248
|
+
# Accepts either a user object directly, or resolves via the configured resolver.
|
|
249
|
+
def record_user!(user = nil)
|
|
250
|
+
user ||= resolve_current_user
|
|
251
|
+
return unless user
|
|
252
|
+
|
|
253
|
+
self.user_id = user.id.to_s
|
|
254
|
+
self.user_type = user.class.name
|
|
255
|
+
self.user_email = user.email if user.respond_to?(:email)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Formatted user display for the UI.
|
|
259
|
+
# Uses the configured formatter or falls back to a sensible default.
|
|
260
|
+
def user_display
|
|
261
|
+
return nil if user_id.blank?
|
|
262
|
+
|
|
263
|
+
if MaintenanceOnSteroids.user_display_formatter
|
|
264
|
+
MaintenanceOnSteroids.user_display_formatter.call(self)
|
|
265
|
+
elsif user_email.present?
|
|
266
|
+
user_email
|
|
267
|
+
else
|
|
268
|
+
"#{user_type}##{user_id}"
|
|
269
|
+
end
|
|
270
|
+
rescue => e
|
|
271
|
+
Rails.logger.debug "[MaintenanceOnSteroids] user_display_formatter error: #{e.message}"
|
|
272
|
+
"#{user_type}##{user_id}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def task_title
|
|
276
|
+
klass = JobRegistry.find(task_class)
|
|
277
|
+
klass&.task_title || task_class
|
|
278
|
+
rescue
|
|
279
|
+
task_class
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def task_exists?
|
|
283
|
+
JobRegistry.find(task_class).present?
|
|
284
|
+
rescue
|
|
285
|
+
false
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def output_artifacts
|
|
289
|
+
artifacts.where(kind: "output")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def input_artifacts
|
|
293
|
+
artifacts.where(kind: "input")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
def safe_instrument(event, extra = {})
|
|
299
|
+
Instrumentation.safe_instrument(event, self, extra)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def resolve_current_user
|
|
303
|
+
resolver = MaintenanceOnSteroids.current_user_resolver
|
|
304
|
+
resolver&.call
|
|
305
|
+
rescue => e
|
|
306
|
+
Rails.logger.debug "[MaintenanceOnSteroids] Could not resolve current user: #{e.message}"
|
|
307
|
+
nil
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Maintenance on Steroids</title>
|
|
7
|
+
<link rel="icon" href="/favicon.ico">
|
|
8
|
+
<%# Suppress the browser's automatic /favicon.ico request when the host app
|
|
9
|
+
has no favicon -- harmless if the file is absent. %>
|
|
10
|
+
<%= render "maintenance_on_steroids/shared/styles" %>
|
|
11
|
+
<script>
|
|
12
|
+
(function() {
|
|
13
|
+
var saved = localStorage.getItem('mos-theme') || 'dark';
|
|
14
|
+
document.documentElement.setAttribute('data-theme', saved);
|
|
15
|
+
})();
|
|
16
|
+
</script>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div class="topbar">
|
|
20
|
+
<div class="topbar-inner">
|
|
21
|
+
<a href="<%= root_path %>" class="topbar-brand">
|
|
22
|
+
<span class="brand-main">Maintenance</span> <span class="brand-highlight">on Steroids</span>
|
|
23
|
+
</a>
|
|
24
|
+
<div class="topbar-nav">
|
|
25
|
+
<a href="<%= root_path %>" class="<%= "active" if request.path == root_path %>">Dashboard</a>
|
|
26
|
+
<a href="<%= jobs_path %>" class="<%= "active" if request.path.start_with?(jobs_path) %>">Tasks</a>
|
|
27
|
+
<div class="topbar-sep"></div>
|
|
28
|
+
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme" id="theme-btn">
|
|
29
|
+
<span id="theme-icon"></span>
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="container">
|
|
36
|
+
<% if notice %>
|
|
37
|
+
<div class="flash flash-notice"><%= notice %></div>
|
|
38
|
+
<% end %>
|
|
39
|
+
<% if alert %>
|
|
40
|
+
<div class="flash flash-alert"><%= alert %></div>
|
|
41
|
+
<% end %>
|
|
42
|
+
|
|
43
|
+
<%= yield %>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<%= render "maintenance_on_steroids/shared/javascript" %>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|