flightdeck 0.5.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/MIT-LICENSE +21 -0
- data/README.md +190 -0
- data/app/assets/flightdeck/flightdeck-00403457c5d9.css +3 -0
- data/app/assets/flightdeck/flightdeck-c02e38040ddb.js +414 -0
- data/app/assets/flightdeck/manifest.json +16 -0
- data/app/controllers/concerns/flightdeck/toasts.rb +25 -0
- data/app/controllers/flightdeck/application_controller.rb +96 -0
- data/app/controllers/flightdeck/assets_controller.rb +39 -0
- data/app/controllers/flightdeck/jobs/actions_controller.rb +167 -0
- data/app/controllers/flightdeck/jobs/discards_controller.rb +44 -0
- data/app/controllers/flightdeck/jobs/retries_controller.rb +23 -0
- data/app/controllers/flightdeck/jobs_controller.rb +39 -0
- data/app/controllers/flightdeck/overview_controller.rb +11 -0
- data/app/controllers/flightdeck/processes_controller.rb +49 -0
- data/app/controllers/flightdeck/queues_controller.rb +51 -0
- data/app/controllers/flightdeck/recurring_tasks_controller.rb +46 -0
- data/app/helpers/flightdeck/application_helper.rb +68 -0
- data/app/helpers/flightdeck/infrastructure_helper.rb +24 -0
- data/app/helpers/flightdeck/jobs_helper.rb +136 -0
- data/app/helpers/flightdeck/metrics_helper.rb +49 -0
- data/app/models/flightdeck/arguments_preview.rb +95 -0
- data/app/models/flightdeck/bulk_action.rb +83 -0
- data/app/models/flightdeck/cache.rb +51 -0
- data/app/models/flightdeck/cron_schedule.rb +102 -0
- data/app/models/flightdeck/error_summary.rb +64 -0
- data/app/models/flightdeck/grouped_failures.rb +24 -0
- data/app/models/flightdeck/job_detail.rb +163 -0
- data/app/models/flightdeck/job_row.rb +93 -0
- data/app/models/flightdeck/jobs_query.rb +241 -0
- data/app/models/flightdeck/metrics/bar_chart.rb +49 -0
- data/app/models/flightdeck/metrics/chart.rb +111 -0
- data/app/models/flightdeck/metrics/line_chart.rb +67 -0
- data/app/models/flightdeck/metrics/series.rb +165 -0
- data/app/models/flightdeck/metrics/sparkline.rb +45 -0
- data/app/models/flightdeck/metrics/time_bucket.rb +95 -0
- data/app/models/flightdeck/overview.rb +217 -0
- data/app/models/flightdeck/process_registry.rb +108 -0
- data/app/models/flightdeck/queue_stats.rb +88 -0
- data/app/models/flightdeck/recurring_catalog.rb +116 -0
- data/app/views/flightdeck/jobs/_failed_row.html.erb +33 -0
- data/app/views/flightdeck/jobs/_filter_bar.html.erb +29 -0
- data/app/views/flightdeck/jobs/_list.html.erb +89 -0
- data/app/views/flightdeck/jobs/_pager.html.erb +22 -0
- data/app/views/flightdeck/jobs/_row.html.erb +23 -0
- data/app/views/flightdeck/jobs/_state_tabs.html.erb +10 -0
- data/app/views/flightdeck/jobs/actions/create.turbo_stream.erb +18 -0
- data/app/views/flightdeck/jobs/index.html.erb +13 -0
- data/app/views/flightdeck/jobs/show.html.erb +100 -0
- data/app/views/flightdeck/metrics/_bar_chart.html.erb +34 -0
- data/app/views/flightdeck/metrics/_line_chart.html.erb +38 -0
- data/app/views/flightdeck/metrics/_range_control.html.erb +7 -0
- data/app/views/flightdeck/metrics/_sparkline.html.erb +9 -0
- data/app/views/flightdeck/overview/_completion.html.erb +19 -0
- data/app/views/flightdeck/overview/_failures.html.erb +28 -0
- data/app/views/flightdeck/overview/_fleet.html.erb +21 -0
- data/app/views/flightdeck/overview/_queues.html.erb +41 -0
- data/app/views/flightdeck/overview/_throughput.html.erb +23 -0
- data/app/views/flightdeck/overview/_tiles.html.erb +14 -0
- data/app/views/flightdeck/overview/index.html.erb +54 -0
- data/app/views/flightdeck/processes/_fleet.html.erb +54 -0
- data/app/views/flightdeck/processes/_row.html.erb +28 -0
- data/app/views/flightdeck/processes/index.html.erb +9 -0
- data/app/views/flightdeck/processes/prune.turbo_stream.erb +15 -0
- data/app/views/flightdeck/queues/_cards.html.erb +46 -0
- data/app/views/flightdeck/queues/index.html.erb +9 -0
- data/app/views/flightdeck/queues/update.turbo_stream.erb +15 -0
- data/app/views/flightdeck/recurring_tasks/_table.html.erb +53 -0
- data/app/views/flightdeck/recurring_tasks/index.html.erb +9 -0
- data/app/views/flightdeck/recurring_tasks/run.turbo_stream.erb +15 -0
- data/app/views/flightdeck/shared/_toast.html.erb +7 -0
- data/app/views/layouts/flightdeck/application.html.erb +110 -0
- data/config/routes.rb +40 -0
- data/lib/flightdeck/assets.rb +78 -0
- data/lib/flightdeck/configuration.rb +80 -0
- data/lib/flightdeck/engine.rb +31 -0
- data/lib/flightdeck/version.rb +5 -0
- data/lib/flightdeck.rb +31 -0
- metadata +150 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
module InfrastructureHelper
|
|
5
|
+
RECURRING_STATUS = {
|
|
6
|
+
ok: [ "OK", "finished" ],
|
|
7
|
+
failed: [ "LAST RUN FAILED", "failed" ],
|
|
8
|
+
unknown: [ "PURGED", "scheduled" ]
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def dom_id_for_queue(name) = "fd-queue-#{name.parameterize.presence || Digest::MD5.hexdigest(name)}"
|
|
12
|
+
def dom_id_for_process(id) = "fd-process-#{id}"
|
|
13
|
+
def dom_id_for_recurring_task(id) = "fd-recurring-task-#{id}"
|
|
14
|
+
|
|
15
|
+
# A task that has never run has no status to report, and a task whose job has
|
|
16
|
+
# been purged is reported as purged rather than as a success we did not see.
|
|
17
|
+
def fd_recurring_status_pill(status)
|
|
18
|
+
return tag.span("—", class: "dim") if status.nil?
|
|
19
|
+
|
|
20
|
+
label, pill = RECURRING_STATUS.fetch(status, [ status.to_s.upcase, "scheduled" ])
|
|
21
|
+
tag.span(label, class: "fd-pill #{pill}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
module JobsHelper
|
|
5
|
+
STATE_LABELS = {
|
|
6
|
+
all: "All",
|
|
7
|
+
ready: "Ready",
|
|
8
|
+
scheduled: "Scheduled",
|
|
9
|
+
in_progress: "In progress",
|
|
10
|
+
blocked: "Blocked",
|
|
11
|
+
finished: "Finished",
|
|
12
|
+
failed: "Failed",
|
|
13
|
+
unknown: "Unknown"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
PILL_CLASSES = {
|
|
17
|
+
ready: "ready",
|
|
18
|
+
scheduled: "scheduled",
|
|
19
|
+
in_progress: "progress",
|
|
20
|
+
blocked: "blocked",
|
|
21
|
+
finished: "finished",
|
|
22
|
+
failed: "failed",
|
|
23
|
+
unknown: "scheduled"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def fd_state_label(state) = STATE_LABELS.fetch(state.to_sym, state.to_s.humanize)
|
|
27
|
+
|
|
28
|
+
def fd_state_pill(state)
|
|
29
|
+
tag.span(fd_state_label(state).upcase,
|
|
30
|
+
class: "fd-pill #{PILL_CLASSES.fetch(state.to_sym, "scheduled")}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fd_queue_badge(queue_name)
|
|
34
|
+
tag.span(queue_name.presence || "—", class: "fd-qbadge")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Counts are capped by JobsQuery, so a capped value is rendered as "500,000+"
|
|
38
|
+
# rather than pretending to be exact.
|
|
39
|
+
def fd_count(count, capped: false)
|
|
40
|
+
"#{number_with_delimiter(count)}#{"+" if capped}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fd_time(time)
|
|
44
|
+
return "—" if time.blank?
|
|
45
|
+
|
|
46
|
+
tag.span(time.in_time_zone(Flightdeck.config.display_timezone).strftime("%H:%M:%S"),
|
|
47
|
+
title: fd_full_time(time))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fd_full_time(time)
|
|
51
|
+
return "—" if time.blank?
|
|
52
|
+
|
|
53
|
+
time.in_time_zone(Flightdeck.config.display_timezone).strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fd_duration(seconds)
|
|
57
|
+
return "—" if seconds.nil?
|
|
58
|
+
|
|
59
|
+
seconds = seconds.to_f.abs
|
|
60
|
+
case seconds
|
|
61
|
+
when 0...1 then "#{(seconds * 1000).round}ms"
|
|
62
|
+
when 1...60 then "#{seconds.round(1)}s"
|
|
63
|
+
when 60...3600 then "#{(seconds / 60).round}m"
|
|
64
|
+
when 3600...86_400 then "#{(seconds / 3600).round(1)}h"
|
|
65
|
+
else "#{(seconds / 86_400).round(1)}d"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fd_ago(time)
|
|
70
|
+
return "—" if time.blank?
|
|
71
|
+
|
|
72
|
+
"#{fd_duration(Time.current - time)} ago"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Best-effort "what has this job been doing" column. Only ever derived from
|
|
76
|
+
# timestamps Solid Queue actually keeps — nothing here is estimated.
|
|
77
|
+
def fd_progress_label(row)
|
|
78
|
+
case row.state
|
|
79
|
+
when :ready
|
|
80
|
+
"waited #{fd_duration(Time.current - row.due_at)}"
|
|
81
|
+
when :scheduled
|
|
82
|
+
row.scheduled_at&.future? ? "due in #{fd_duration(row.scheduled_at - Time.current)}" : "due now"
|
|
83
|
+
when :in_progress
|
|
84
|
+
started = row.execution_started_at
|
|
85
|
+
return "running" unless started
|
|
86
|
+
|
|
87
|
+
"waited #{fd_duration(started - row.due_at)} · running #{fd_duration(Time.current - started)}"
|
|
88
|
+
when :blocked
|
|
89
|
+
row.concurrency_key.presence ? "concurrency: #{row.concurrency_key}" : "blocked"
|
|
90
|
+
when :finished
|
|
91
|
+
row.finished_at ? "total #{fd_duration(row.finished_at - row.enqueued_at)}" : "—"
|
|
92
|
+
when :failed
|
|
93
|
+
row.failed_at ? "failed after #{fd_duration(row.failed_at - row.due_at)}" : "—"
|
|
94
|
+
else
|
|
95
|
+
"—"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Shows the job's own arguments rather than the ActiveJob envelope they
|
|
100
|
+
# are wrapped in; see Flightdeck::ArgumentsPreview.
|
|
101
|
+
def fd_args_preview(preview, length: 120)
|
|
102
|
+
Flightdeck::ArgumentsPreview.format(preview, length: length)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fd_jobs_url(overrides = {})
|
|
106
|
+
jobs_path(list_filters.merge(state: params[:state].presence).compact.merge(overrides))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The frame refreshes itself by re-requesting the URL it is showing, so
|
|
110
|
+
# filters, state tab and cursor all survive a poll.
|
|
111
|
+
def fd_current_list_url
|
|
112
|
+
jobs_path(request.query_parameters)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def dom_id_for_job(id) = "fd-job-#{id}"
|
|
116
|
+
|
|
117
|
+
# Under an exception-class group header, repeating the class on every row is
|
|
118
|
+
# noise — the message is the part that differs.
|
|
119
|
+
def fd_error_cell(summary, grouped: false)
|
|
120
|
+
return "—" unless summary.present?
|
|
121
|
+
|
|
122
|
+
text = grouped ? summary.message.presence || summary.exception_class : summary.to_s
|
|
123
|
+
text.to_s
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# URL the refreshed frame should poll after an action, rebuilt from the
|
|
127
|
+
# filters the action carried rather than from the POST's own path.
|
|
128
|
+
def fd_refreshed_list_url
|
|
129
|
+
jobs_path(list_filters.merge(state: @refreshed_state == :all ? nil : @refreshed_state).compact)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def fd_toast_level_class(level)
|
|
133
|
+
level.to_sym == :error ? "fd-toast error" : "fd-toast"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
module MetricsHelper
|
|
5
|
+
TREND_GLYPHS = { up: "▲", down: "▼", flat: "▪" }.freeze
|
|
6
|
+
|
|
7
|
+
def fd_poll_ms = (Flightdeck.config.poll_interval.to_f * 1000).round
|
|
8
|
+
def fd_chart_poll_ms = (Flightdeck.config.chart_poll_interval.to_f * 1000).round
|
|
9
|
+
|
|
10
|
+
def fd_poll_seconds = Flightdeck.config.poll_interval.to_i
|
|
11
|
+
|
|
12
|
+
def fd_trend_glyph(trend) = TREND_GLYPHS.fetch(trend.to_sym, TREND_GLYPHS[:flat])
|
|
13
|
+
|
|
14
|
+
# --- topbar clock ---------------------------------------------------------
|
|
15
|
+
#
|
|
16
|
+
# Rendered server-side so the clock is right before JavaScript runs, and
|
|
17
|
+
# driven by an offset so it keeps showing the configured display timezone
|
|
18
|
+
# rather than the viewer's.
|
|
19
|
+
|
|
20
|
+
def fd_display_zone
|
|
21
|
+
ActiveSupport::TimeZone[Flightdeck.config.display_timezone] || ActiveSupport::TimeZone["UTC"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fd_clock_now = "#{Time.current.in_time_zone(fd_display_zone).strftime("%H:%M:%S")} #{fd_zone_label}"
|
|
25
|
+
|
|
26
|
+
def fd_zone_label
|
|
27
|
+
zone = fd_display_zone
|
|
28
|
+
zone.name == "UTC" ? "UTC" : Time.current.in_time_zone(zone).strftime("%Z")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fd_zone_offset_seconds
|
|
32
|
+
fd_display_zone.now.utc_offset
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Colour follows whether the movement is good news, not which way the arrow
|
|
36
|
+
# points: more failures is an "up" arrow but bad news.
|
|
37
|
+
def fd_delta_class(tile)
|
|
38
|
+
return "flat" if tile.trend.to_sym == :flat
|
|
39
|
+
|
|
40
|
+
tile.good ? "up" : "down"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fd_depth_share(depth, max)
|
|
44
|
+
return 0 if max.to_i.zero?
|
|
45
|
+
|
|
46
|
+
[ (depth.to_f / max * 100).round, 100 ].min
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
# Turns a job's stored payload into the one part of it a list row wants: the
|
|
5
|
+
# actual job arguments.
|
|
6
|
+
#
|
|
7
|
+
# What Solid Queue stores is the whole ActiveJob serialization —
|
|
8
|
+
# `{"job_class":…,"job_id":…,"queue_name":…,"arguments":[…],…}` — so a raw
|
|
9
|
+
# preview is almost entirely boilerplate that is identical on every row. The
|
|
10
|
+
# job detail page still shows the full payload, where it is genuinely useful.
|
|
11
|
+
#
|
|
12
|
+
# The input is a byte prefix truncated by SQL, so the JSON is usually cut off
|
|
13
|
+
# mid-value. Parsing is lenient by design: extract what is there, and fall
|
|
14
|
+
# back to the raw prefix rather than showing nothing.
|
|
15
|
+
class ArgumentsPreview
|
|
16
|
+
KEY = /"arguments"\s*:\s*/
|
|
17
|
+
ELLIPSIS = "…"
|
|
18
|
+
|
|
19
|
+
def self.format(raw, length: 120)
|
|
20
|
+
new(raw).to_s(length: length)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(raw)
|
|
24
|
+
@raw = raw.to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s(length: 120)
|
|
28
|
+
value = extract
|
|
29
|
+
return "" if value.blank?
|
|
30
|
+
|
|
31
|
+
value.gsub(/\s+/, " ").strip.truncate(length, omission: ELLIPSIS)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
attr_reader :raw
|
|
36
|
+
|
|
37
|
+
def extract
|
|
38
|
+
return "" if raw.blank?
|
|
39
|
+
|
|
40
|
+
from_parsed_payload || from_truncated_payload || raw
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The happy path: the prefix happened to contain the whole payload.
|
|
44
|
+
def from_parsed_payload
|
|
45
|
+
parsed = JSON.parse(raw)
|
|
46
|
+
return nil unless parsed.is_a?(Hash) && parsed.key?("arguments")
|
|
47
|
+
|
|
48
|
+
JSON.generate(parsed["arguments"])
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The usual path: the payload was cut off, so walk the arguments value
|
|
54
|
+
# ourselves and take as much of it as survived.
|
|
55
|
+
def from_truncated_payload
|
|
56
|
+
match = KEY.match(raw)
|
|
57
|
+
return nil unless match
|
|
58
|
+
|
|
59
|
+
balanced_prefix(raw[match.end(0)..].to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Reads one JSON value from the front of `text`, stopping either when it
|
|
63
|
+
# closes cleanly or when the string runs out mid-value.
|
|
64
|
+
def balanced_prefix(text)
|
|
65
|
+
return nil if text.empty?
|
|
66
|
+
|
|
67
|
+
depth = 0
|
|
68
|
+
in_string = false
|
|
69
|
+
escaped = false
|
|
70
|
+
|
|
71
|
+
text.each_char.with_index do |char, index|
|
|
72
|
+
if escaped
|
|
73
|
+
escaped = false
|
|
74
|
+
elsif char == "\\"
|
|
75
|
+
escaped = true
|
|
76
|
+
elsif char == '"'
|
|
77
|
+
in_string = !in_string
|
|
78
|
+
elsif !in_string
|
|
79
|
+
case char
|
|
80
|
+
when "[", "{" then depth += 1
|
|
81
|
+
when "]", "}"
|
|
82
|
+
depth -= 1
|
|
83
|
+
return text[0..index] if depth.zero?
|
|
84
|
+
when ","
|
|
85
|
+
# A top-level comma means the arguments value ended and the next
|
|
86
|
+
# payload key has begun.
|
|
87
|
+
return text[0...index] if depth.zero?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
"#{text}#{ELLIPSIS}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
# Applies a Solid Queue operation across a filtered relation in bounded,
|
|
5
|
+
# individually-committed batches.
|
|
6
|
+
#
|
|
7
|
+
# Three things keep this safe to run against a live queue:
|
|
8
|
+
# * a hard record limit (`Flightdeck.config.bulk_action_limit`),
|
|
9
|
+
# * a wall-clock deadline measured on the monotonic clock, and
|
|
10
|
+
# * a commit per batch, so stopping early still leaves completed work done.
|
|
11
|
+
#
|
|
12
|
+
# Stopping early is a normal outcome, not an error: the caller reports
|
|
13
|
+
# "Retried 1,000 of ~14,200" and the same request can simply be submitted
|
|
14
|
+
# again to continue.
|
|
15
|
+
class BulkAction
|
|
16
|
+
BATCH_SIZE = 100
|
|
17
|
+
DEADLINE = 10.seconds
|
|
18
|
+
|
|
19
|
+
Result = Struct.new(:processed, :failed, :total, :total_capped, :stopped, keyword_init: true) do
|
|
20
|
+
def stopped_early? = stopped.present?
|
|
21
|
+
def remaining = [ total - processed, 0 ].max
|
|
22
|
+
def remaining? = stopped_early? && remaining.positive?
|
|
23
|
+
def total_label = total_capped ? "#{total}+" : total.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :relation, :limit, :deadline, :count_cap
|
|
27
|
+
|
|
28
|
+
def initialize(relation:, limit: nil, deadline: DEADLINE, count_cap: nil)
|
|
29
|
+
@relation = relation
|
|
30
|
+
@limit = (limit || Flightdeck.config.bulk_action_limit).to_i
|
|
31
|
+
@deadline = deadline
|
|
32
|
+
@count_cap = (count_cap || Flightdeck.config.count_cap).to_i
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call(&operation)
|
|
36
|
+
total = relation.limit(count_cap).count
|
|
37
|
+
started_at = monotonic_now
|
|
38
|
+
processed = 0
|
|
39
|
+
failed = 0
|
|
40
|
+
stopped = nil
|
|
41
|
+
|
|
42
|
+
relation.in_batches(of: BATCH_SIZE, order: :desc) do |batch|
|
|
43
|
+
SolidQueue::Record.transaction do
|
|
44
|
+
batch.each do |record|
|
|
45
|
+
if processed + failed >= limit
|
|
46
|
+
stopped = :limit
|
|
47
|
+
break
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
operation.call(record)
|
|
52
|
+
processed += 1
|
|
53
|
+
rescue ActiveRecord::RecordNotFound, ActiveRecord::Deadlocked
|
|
54
|
+
# The queue moved under us — a worker finished or retried this job
|
|
55
|
+
# first. Skip it; the caller's counts stay honest.
|
|
56
|
+
failed += 1
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
break if stopped
|
|
62
|
+
|
|
63
|
+
if monotonic_now - started_at >= deadline
|
|
64
|
+
stopped = :deadline
|
|
65
|
+
break
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Result.new(
|
|
70
|
+
processed: processed,
|
|
71
|
+
failed: failed,
|
|
72
|
+
total: total,
|
|
73
|
+
total_capped: total >= count_cap,
|
|
74
|
+
stopped: stopped
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
def monotonic_now
|
|
80
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
# Thin wrapper over Rails.cache for the dashboard's short-lived aggregates.
|
|
5
|
+
#
|
|
6
|
+
# Two things it guarantees:
|
|
7
|
+
# * it degrades to simply calling the block, so a host running the null
|
|
8
|
+
# store (or no cache at all) still gets a working dashboard;
|
|
9
|
+
# * a mutation can ask for fresh numbers via .bypass, so the response to a
|
|
10
|
+
# retry never shows the counts from before the retry.
|
|
11
|
+
module Cache
|
|
12
|
+
NAMESPACE = "flightdeck"
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def fetch(*key_parts, expires_in:, &block)
|
|
16
|
+
store = Rails.cache
|
|
17
|
+
return block.call if store.nil? || expires_in.to_f <= 0
|
|
18
|
+
|
|
19
|
+
store.fetch(key_for(key_parts), expires_in: expires_in, force: bypass?, &block)
|
|
20
|
+
rescue StandardError
|
|
21
|
+
# A cache backend that is down must never take the dashboard with it.
|
|
22
|
+
block.call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Inside this block every fetch recomputes and rewrites, so a page
|
|
26
|
+
# rendered straight after a mutation reflects it.
|
|
27
|
+
def bypass
|
|
28
|
+
previous = Thread.current[:flightdeck_cache_bypass]
|
|
29
|
+
Thread.current[:flightdeck_cache_bypass] = true
|
|
30
|
+
yield
|
|
31
|
+
ensure
|
|
32
|
+
Thread.current[:flightdeck_cache_bypass] = previous
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def bypass? = Thread.current[:flightdeck_cache_bypass].present?
|
|
36
|
+
|
|
37
|
+
def key_for(parts)
|
|
38
|
+
[ NAMESPACE, *Array(parts).flatten.map { |part| normalize(part) } ].join(":")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
def normalize(part)
|
|
43
|
+
case part
|
|
44
|
+
when Hash then part.sort_by { |key, _| key.to_s }.map { |key, value| "#{key}=#{value}" }.join(",")
|
|
45
|
+
when nil then ""
|
|
46
|
+
else part.to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
# Turns the common cron shapes into a readable sentence.
|
|
5
|
+
#
|
|
6
|
+
# Fugit parses cron but does not describe it, so this is ours. It only claims
|
|
7
|
+
# to understand patterns it genuinely does: anything else returns nil and the
|
|
8
|
+
# view shows the raw cron on its own. A confidently wrong description of a
|
|
9
|
+
# schedule is worse than no description.
|
|
10
|
+
class CronSchedule
|
|
11
|
+
DAYS = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
|
|
12
|
+
MONTHS = %w[- January February March April May June July August September October November December].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :raw
|
|
15
|
+
|
|
16
|
+
def initialize(raw)
|
|
17
|
+
@raw = raw.to_s.strip
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.humanize(raw) = new(raw).to_human
|
|
21
|
+
|
|
22
|
+
# Cron fields, plus an optional trailing timezone that Solid Queue allows.
|
|
23
|
+
def fields
|
|
24
|
+
@fields ||= begin
|
|
25
|
+
parts = raw.split(/\s+/)
|
|
26
|
+
parts.size.between?(5, 6) ? parts : nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def timezone
|
|
31
|
+
fields && fields.size == 6 ? fields[5] : nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_human
|
|
35
|
+
return nil unless fields
|
|
36
|
+
|
|
37
|
+
minute, hour, day_of_month, month, day_of_week = fields.first(5)
|
|
38
|
+
return nil unless simple?(minute, hour, day_of_month, month, day_of_week)
|
|
39
|
+
|
|
40
|
+
[ frequency(minute, hour, day_of_month, month, day_of_week), timezone ].compact.join(" ").presence
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
# Only fields we can describe faithfully: a wildcard, a plain number, or a
|
|
45
|
+
# `*/n` step. Lists and ranges are left to the raw cron.
|
|
46
|
+
def simple?(*values)
|
|
47
|
+
values.all? { |value| value.match?(%r{\A(\*|\d+|\*/\d+)\z}) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def frequency(minute, hour, day_of_month, month, day_of_week)
|
|
51
|
+
return every_n_minutes(minute) if step(minute) && hour == "*"
|
|
52
|
+
return "every minute" if minute == "*" && hour == "*"
|
|
53
|
+
|
|
54
|
+
return nil unless minute.match?(/\A\d+\z/)
|
|
55
|
+
|
|
56
|
+
return "every #{pluralize_hours(step(hour))} at :#{pad(minute)}" if step(hour)
|
|
57
|
+
return "every hour at :#{pad(minute)}" if hour == "*"
|
|
58
|
+
return nil unless hour.match?(/\A\d+\z/)
|
|
59
|
+
|
|
60
|
+
at = "at #{pad(hour)}:#{pad(minute)}"
|
|
61
|
+
|
|
62
|
+
if day_of_week != "*"
|
|
63
|
+
# cron ORs day-of-month with day-of-week when both are restricted, so
|
|
64
|
+
# "0 0 1 1 1" is not "Mondays" — it is "January 1st or any Monday in
|
|
65
|
+
# January". Refuse to describe that rather than describe it wrongly.
|
|
66
|
+
return nil unless day_of_month == "*" && month == "*"
|
|
67
|
+
return nil unless day_of_week.match?(/\A\d+\z/)
|
|
68
|
+
|
|
69
|
+
return "#{DAYS[day_of_week.to_i % 7]}s #{at}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return "#{ordinal_day(day_of_month)} of #{month_name(month)} #{at}" if day_of_month != "*" && month != "*"
|
|
73
|
+
return "#{ordinal_day(day_of_month)} of every month #{at}" if day_of_month != "*"
|
|
74
|
+
return "every day of #{month_name(month)} #{at}" if month != "*"
|
|
75
|
+
|
|
76
|
+
"every day #{at}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def every_n_minutes(minute)
|
|
80
|
+
n = step(minute)
|
|
81
|
+
n == 1 ? "every minute" : "every #{n} minutes"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pluralize_hours(n) = n == 1 ? "hour" : "#{n} hours"
|
|
85
|
+
|
|
86
|
+
def ordinal_day(value)
|
|
87
|
+
return nil unless value.match?(/\A\d+\z/)
|
|
88
|
+
|
|
89
|
+
"day #{value.to_i}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def month_name(value)
|
|
93
|
+
value.match?(/\A\d+\z/) ? MONTHS[value.to_i] : nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def step(value)
|
|
97
|
+
value[%r{\A\*/(\d+)\z}, 1]&.to_i
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def pad(value) = format("%02d", value.to_i)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
# A failed execution's `error` column is JSON that can run to many kilobytes
|
|
5
|
+
# once a backtrace is in it. List views only ever read a bounded prefix of it,
|
|
6
|
+
# which means the JSON is usually truncated mid-string — so parse when we can
|
|
7
|
+
# and fall back to scanning for the two fields we need.
|
|
8
|
+
class ErrorSummary
|
|
9
|
+
UNKNOWN = "UnknownError"
|
|
10
|
+
|
|
11
|
+
attr_reader :exception_class, :message
|
|
12
|
+
|
|
13
|
+
def self.parse(raw)
|
|
14
|
+
new(raw)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.none
|
|
18
|
+
new(nil)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(raw)
|
|
22
|
+
@exception_class, @message = extract(raw)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def present?
|
|
26
|
+
exception_class.present?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def group_key
|
|
30
|
+
exception_class.presence || UNKNOWN
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_s
|
|
34
|
+
return "" unless present?
|
|
35
|
+
|
|
36
|
+
message.present? ? "#{exception_class}: #{message}" : exception_class
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
def extract(raw)
|
|
41
|
+
return [ nil, nil ] if raw.blank?
|
|
42
|
+
|
|
43
|
+
parsed = safe_parse(raw)
|
|
44
|
+
return [ parsed["exception_class"].presence, parsed["message"].to_s ] if parsed.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
[
|
|
47
|
+
raw[/"exception_class"\s*:\s*"((?:[^"\\]|\\.)*)"/, 1]&.then { |value| unescape(value) },
|
|
48
|
+
raw[/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/, 1]&.then { |value| unescape(value) }.to_s
|
|
49
|
+
]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def safe_parse(raw)
|
|
53
|
+
JSON.parse(raw)
|
|
54
|
+
rescue JSON::ParserError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def unescape(value)
|
|
59
|
+
JSON.parse(%("#{value}"))
|
|
60
|
+
rescue JSON::ParserError
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flightdeck
|
|
4
|
+
# Groups the failed rows of the *current page* by exception class.
|
|
5
|
+
#
|
|
6
|
+
# Grouping happens in Ruby on purpose. Extracting the exception class in SQL
|
|
7
|
+
# would mean a JSON function per adapter (and an unindexed scan of a large
|
|
8
|
+
# text column either way), which is a bad trade for a header row.
|
|
9
|
+
class GroupedFailures
|
|
10
|
+
Group = Struct.new(:key, :rows, keyword_init: true) do
|
|
11
|
+
def count = rows.size
|
|
12
|
+
def job_ids = rows.map(&:id)
|
|
13
|
+
def first_failed_at = rows.filter_map(&:failed_at).min
|
|
14
|
+
def last_failed_at = rows.filter_map(&:failed_at).max
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.build(rows)
|
|
18
|
+
rows
|
|
19
|
+
.group_by { |row| row.error_summary.group_key }
|
|
20
|
+
.map { |key, grouped| Group.new(key: key, rows: grouped) }
|
|
21
|
+
.sort_by { |group| [ -group.count, group.key ] }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|