Class: Flightdeck::Metrics::Series

Inherits:
Object
  • Object
show all
Defined in:
app/models/flightdeck/metrics/series.rb

Overview

Time series for the Overview charts, computed live from the Solid Queue tables — no rollup tables, no background aggregation.

Every series is gap-filled in Ruby so a chart always has a complete axis: a bucket with no rows is a real zero (or a real "no data"), not a missing point that would make the chart lie about its own shape.

Defined Under Namespace

Classes: Point, Window

Constant Summary collapse

WINDOWS =
{
  "1h" => Window.new(key: "1h", label: "1H", duration: 1.hour, bucket_seconds: 5.minutes.to_i,
                     subtitle: "per 5 minutes", tick_every: 3, tick_format: "%H:%M"),
  "24h" => Window.new(key: "24h", label: "24H", duration: 24.hours, bucket_seconds: 1.hour.to_i,
                      subtitle: "per hour", tick_every: 4, tick_format: "%H:%M"),
  "7d" => Window.new(key: "7d", label: "7D", duration: 7.days, bucket_seconds: 6.hours.to_i,
                     subtitle: "per 6 hours", tick_every: 4, tick_format: "%-m/%-d")
}.freeze
DEFAULT_WINDOW =
"24h"
SPARKLINE_HOURS =
16
SPARKLINE_BUCKET =
1.hour.to_i

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(window: DEFAULT_WINDOW, now: Time.current) ⇒ Series

Returns a new instance of Series.



41
42
43
44
# File 'app/models/flightdeck/metrics/series.rb', line 41

def initialize(window: DEFAULT_WINDOW, now: Time.current)
  @window = window.is_a?(Window) ? window : self.class.window_for(window)
  @now = now.utc
end

Instance Attribute Details

#nowObject (readonly)

Returns the value of attribute now.



35
36
37
# File 'app/models/flightdeck/metrics/series.rb', line 35

def now
  @now
end

#windowObject (readonly)

Returns the value of attribute window.



35
36
37
# File 'app/models/flightdeck/metrics/series.rb', line 35

def window
  @window
end

Class Method Details

.align_to(epoch, seconds) ⇒ Object



143
# File 'app/models/flightdeck/metrics/series.rb', line 143

def self.align_to(epoch, seconds) = (epoch / seconds) * seconds

.queue_sparklines(now: Time.current, hours: SPARKLINE_HOURS) ⇒ Object

One query for every queue's hourly finished count, fanned out in Ruby. Used by the queue cards and the Overview mini-table.



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'app/models/flightdeck/metrics/series.rb', line 119

def self.queue_sparklines(now: Time.current, hours: SPARKLINE_HOURS)
  Flightdeck::Cache.fetch("sparklines", hours, align_to(now.to_i, SPARKLINE_BUCKET),
                          expires_in: Flightdeck.config.chart_cache_ttl) do
    last = align_to(now.to_i, SPARKLINE_BUCKET)
    slots = Array.new(hours) { |i| last - ((hours - 1 - i) * SPARKLINE_BUCKET) }

    counts = SolidQueue::Job
      .where(finished_at: Time.at(slots.first).utc..)
      .group(:queue_name)
      .group(TimeBucket.sql("#{SolidQueue::Job.table_name}.finished_at", SPARKLINE_BUCKET))
      .count

    fanned = Hash.new { |hash, key| hash[key] = Array.new(hours, 0) }
    counts.each do |(queue_name, bucket), count|
      index = slots.index(bucket.to_i)
      next unless index

      fanned[queue_name][index] = count
    end
    fanned.default = nil
    fanned
  end
end

.window_for(key) ⇒ Object



37
38
39
# File 'app/models/flightdeck/metrics/series.rb', line 37

def self.window_for(key)
  WINDOWS.fetch(key.to_s, WINDOWS.fetch(DEFAULT_WINDOW))
end

Instance Method Details

#bucketsObject

Bucket start times, aligned to absolute epoch multiples so that the same bucket has the same boundaries in every process and every request.



48
49
50
51
52
53
54
# File 'app/models/flightdeck/metrics/series.rb', line 48

def buckets
  @buckets ||= begin
    last = align(@now.to_i)
    count = window.bucket_count
    Array.new(count) { |i| last - ((count - 1 - i) * window.bucket_seconds) }
  end
end

#completion_timeObject

Average wall-clock time from enqueue to finish, per bucket.

This is time to completion, not time to start: Solid Queue deletes the claimed_executions row when a job finishes, so the moment a job actually started is not recoverable after the fact and no honest historical time-to-start series can be built from these tables.



83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'app/models/flightdeck/metrics/series.rb', line 83

def completion_time
  cached(:completion_time) do
    averages = SolidQueue::Job
      .where(finished_at: starts_at..)
      .group(TimeBucket.sql("#{jobs_table}.finished_at", window.bucket_seconds))
      .average(TimeBucket.elapsed_sql("#{jobs_table}.created_at", "#{jobs_table}.finished_at"))
      .transform_keys(&:to_i)

    buckets.map do |bucket|
      value = averages[bucket]
      Point.new(at: Time.at(bucket).utc, seconds: value&.to_f)
    end
  end
end

#empty?Boolean

Returns:

  • (Boolean)


101
# File 'app/models/flightdeck/metrics/series.rb', line 101

def empty? = throughput.all? { |point| point.total.zero? }

#ends_atObject



57
# File 'app/models/flightdeck/metrics/series.rb', line 57

def ends_at = Time.at(buckets.last + window.bucket_seconds).utc

#retentionObject



110
111
112
113
114
115
# File 'app/models/flightdeck/metrics/series.rb', line 110

def retention
  return nil unless SolidQueue.respond_to?(:clear_finished_jobs_after)
  return nil unless SolidQueue.preserve_finished_jobs?

  SolidQueue.clear_finished_jobs_after
end

#starts_atObject



56
# File 'app/models/flightdeck/metrics/series.rb', line 56

def starts_at = Time.at(buckets.first).utc

#throughputObject

Jobs finished (succeeded) and executions failed, per bucket.

A job that failed has no finished_at — Solid Queue leaves it null and records a failed_executions row — so the two series never double count.



63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'app/models/flightdeck/metrics/series.rb', line 63

def throughput
  cached(:throughput) do
    succeeded = grouped_count(SolidQueue::Job.where(finished_at: starts_at..), :finished_at)
    failed = grouped_count(SolidQueue::FailedExecution.where(created_at: starts_at..), :created_at,
                           table: SolidQueue::FailedExecution.table_name)

    buckets.map do |bucket|
      Point.new(at: Time.at(bucket).utc,
                succeeded: succeeded.fetch(bucket, 0),
                failed: failed.fetch(bucket, 0))
    end
  end
end

#total_failedObject



99
# File 'app/models/flightdeck/metrics/series.rb', line 99

def total_failed = throughput.sum(&:failed)

#total_succeededObject



98
# File 'app/models/flightdeck/metrics/series.rb', line 98

def total_succeeded = throughput.sum(&:succeeded)

#truncated_by_retention?Boolean

Finished-job retention shorter than the window means the oldest buckets are missing rows that really did happen. The chart says so rather than showing a decline that is an artefact of purging.

Returns:

  • (Boolean)


106
107
108
# File 'app/models/flightdeck/metrics/series.rb', line 106

def truncated_by_retention?
  retention.present? && retention < window.duration
end