Class: ActiveSanction::Sync::Report

Inherits:
Object
  • Object
show all
Extended by:
T::Generic, T::Sig
Includes:
Enumerable
Defined in:
lib/active_sanction/sync/report.rb

Overview

What a whole sync run did, one Result per source.

report = ActiveSanction.sync!

report.failed?                  # => true
report[:un_consolidated].error  # => "Net::ReadTimeout: ..."
report.updated.map(&:source)    # => [:ofac_sdn]
puts report                     # => the table below

4 sources in 13.08s: 1 updated, 2 unchanged, 1 failed
ofac_sdn           updated    19015 records  just fetched   12.41s
ofac_consolidated  unchanged   1203 records  2h old          0.28s
canada_sema        unchanged    684 records  2h old          0.19s
un_consolidated    failed       612 records  3d old          1.11s  Net::ReadTimeout: execution expired

It is an object, not console output

This is the operational surface of a sync: it is what a host application alerts on, what a scheduled job exits with, and what the instrumentation hooks (#59) emit. So it serializes to a documented shape and .from_h rebuilds it -- a summary that only existed as printed text would mean every host that wants to notice a degrading source has to scrape a log.

Note what the table prints beside a failure: the record count and age of the snapshot that source is still being screened against. A failed sync keeps its previous snapshot, which is the right call and is only safe while the age of what is being screened against is visible.

Instances are frozen on construction and compare by value.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(results:, started_at: nil, duration: 0.0) ⇒ void

Parameters:

  • results (T.untyped)
  • started_at (T.untyped) (defaults to: nil)
  • duration (T.untyped) (defaults to: 0.0)


75
76
77
78
79
80
# File 'lib/active_sanction/sync/report.rb', line 75

def initialize(results:, started_at: nil, duration: 0.0)
  @results = T.let(results!(results), T::Array[Result])
  @started_at = T.let(time!(started_at), Time)
  @duration = T.let(duration.to_f, Float)
  freeze
end

Instance Attribute Details

#duration ⇒ Float (readonly)

Wall-clock seconds for the whole run, which is less than the sum of the per-source durations when sources ran in parallel.

Returns:

  • (Float)


61
62
63
# File 'lib/active_sanction/sync/report.rb', line 61

def duration
  @duration
end

#results ⇒ Array<Result> (readonly)

Returns:



52
53
54
# File 'lib/active_sanction/sync/report.rb', line 52

def results
  @results
end

#started_at ⇒ Time (readonly)

When the run began, UTC.

Returns:

  • (Time)


56
57
58
# File 'lib/active_sanction/sync/report.rb', line 56

def started_at
  @started_at
end

Class Method Details

.from_h(hash) ⇒ T.attached_class

Rebuilds from #to_h output, accepting string keys so a report survives the trip through JSON.

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



66
67
68
69
70
71
72
# File 'lib/active_sanction/sync/report.rb', line 66

def self.from_h(hash)
  attributes = hash.to_h.transform_keys(&:to_sym)
  unknown = attributes.keys - MEMBERS
  raise InvalidArgument, "unknown Sync::Report attribute(s): #{unknown.join(", ")}" if unknown.any?

  T.unsafe(self).new(**attributes)
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


197
198
199
200
201
# File 'lib/active_sanction/sync/report.rb', line 197

def ==(other)
  return false unless other.instance_of?(self.class)

  to_h == other.to_h
end

#[](source) ⇒ Result?

One source's result, or nil if the run did not cover it. A run that did not cover a source is not the same as one where it succeeded, which is why this does not raise: a caller asking about a source it did not sync is asking a question with an answer.

Parameters:

  • source (T.untyped)

Returns:



95
96
97
98
# File 'lib/active_sanction/sync/report.rb', line 95

def [](source)
  key = source.to_sym
  results.find { |result| result.source == key }
end

#each(&block) ⇒ T.untyped

Parameters:

  • block (T.proc.params(result: Result).void, nil)

Returns:

  • (T.untyped)


83
84
85
86
87
88
# File 'lib/active_sanction/sync/report.rb', line 83

def each(&block)
  return enum_for(:each) unless block

  results.each(&block)
  self
end

#empty? ⇒ Boolean

Returns:

  • (Boolean)


129
# File 'lib/active_sanction/sync/report.rb', line 129

def empty? = results.empty?

#exit_code ⇒ Integer

What a scheduled job should exit with, so that cron mails somebody and CI goes red when a source is failing. Deliberately here rather than left to each caller to derive: "one source failed" has to mean the same thing to every wrapper anyone writes around a sync.

exit ActiveSanction.sync!.exit_code

Returns:

  • (Integer)


148
# File 'lib/active_sanction/sync/report.rb', line 148

def exit_code = failed? ? 1 : 0

#failed ⇒ Array<Result>

Returns:



110
# File 'lib/active_sanction/sync/report.rb', line 110

def failed = results.select(&:failed?)

#failed? ⇒ Boolean

Returns:

  • (Boolean)


120
# File 'lib/active_sanction/sync/report.rb', line 120

def failed? = results.any?(&:failed?)

#failure_message ⇒ String

Returns:

  • (String)


179
180
181
182
# File 'lib/active_sanction/sync/report.rb', line 179

def failure_message
  "#{failed.size} of #{size} source(s) failed to sync: " +
    failed.map { |result| "#{result.source} (#{result.error})" }.join("; ")
end

#hash ⇒ Integer

Returns:

  • (Integer)


205
# File 'lib/active_sanction/sync/report.rb', line 205

def hash = [self.class, to_h].hash

#inspect ⇒ String

Returns:

  • (String)


208
# File 'lib/active_sanction/sync/report.rb', line 208

def inspect = "#<#{self.class} #{summary}>"

#oldest_age ⇒ Integer?

The age of the stalest list this run left behind, in seconds. The one number to alert on if a host only wants one.

Returns:

  • (Integer, nil)


139
# File 'lib/active_sanction/sync/report.rb', line 139

def oldest_age = results.filter_map(&:age).max

#record_count ⇒ Integer

Records stored across every source the run covered, failures included: what is screenable now, rather than what was downloaded.

Returns:

  • (Integer)


134
# File 'lib/active_sanction/sync/report.rb', line 134

def record_count = results.sum { |result| result.record_count || 0 }

#records(result) ⇒ String

The sentence a failing run should put in front of a human: which sources failed, out of how many, and why.

Parameters:

Returns:

  • (String)


173
# File 'lib/active_sanction/sync/report.rb', line 173

def records(result) = result.record_count&.to_s || "-"

#size ⇒ Integer

Returns:

  • (Integer)


126
# File 'lib/active_sanction/sync/report.rb', line 126

def size = results.size

#sources ⇒ Array<Symbol>

Returns:

  • (Array<Symbol>)


101
# File 'lib/active_sanction/sync/report.rb', line 101

def sources = results.map(&:source)

#success! ⇒ T.self_type

For a caller that wants any failure to be fatal, in the manner of Fetcher::Result#success!. Note that this is not what sync! does: the run has already finished and every other source has already been stored, so raising here reports a failure rather than causing one.

Returns:

  • (T.self_type)

Raises:



155
156
157
158
159
# File 'lib/active_sanction/sync/report.rb', line 155

def success!
  return self if success?

  raise Failed, self
end

#success? ⇒ Boolean

Returns:

  • (Boolean)


123
# File 'lib/active_sanction/sync/report.rb', line 123

def success? = !failed?

#summary ⇒ String

Returns:

  • (String)


185
186
187
188
189
190
191
# File 'lib/active_sanction/sync/report.rb', line 185

def summary
  counts = { updated: updated.size, unchanged: unchanged.size, failed: failed.size }
           .reject { |_status, count| count.zero? }
           .map { |status, count| "#{count} #{status}" }
  "#{size} #{size == 1 ? "source" : "sources"} in #{format("%.2f", duration)}s" \
    "#{": #{counts.join(", ")}" unless counts.empty?}"
end

#to_h ⇒ Hash{Symbol => T.untyped}

Returns:

  • (Hash{Symbol => T.untyped})


162
163
164
165
166
167
168
# File 'lib/active_sanction/sync/report.rb', line 162

def to_h
  {
    results: results.map(&:to_h),
    started_at: started_at.iso8601,
    duration: duration
  }
end

#to_s ⇒ String

Returns:

  • (String)


194
# File 'lib/active_sanction/sync/report.rb', line 194

def to_s = ([summary] + rows).join("\n")

#unchanged ⇒ Array<Result>

Returns:



107
# File 'lib/active_sanction/sync/report.rb', line 107

def unchanged = results.select(&:unchanged?)

#unscreenable ⇒ Array<Result>

Sources that came out of this run with no snapshot stored at all, and so are not covered by screening. Louder than failed and rarer: a source that failed but kept its previous list is stale, one that has nothing stored is missing.

Returns:



117
# File 'lib/active_sanction/sync/report.rb', line 117

def unscreenable = results.reject(&:stored?)

#updated ⇒ Array<Result>

Returns:



104
# File 'lib/active_sanction/sync/report.rb', line 104

def updated = results.select(&:updated?)

#width(&block) ⇒ Integer

Parameters:

  • block (T.proc.params(result: Result).returns(String))

Returns:

  • (Integer)


176
# File 'lib/active_sanction/sync/report.rb', line 176

def width(&block) = results.map { |result| block.call(result).length }.max.to_i