Class: ActiveSanction::Sync::Result

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/sync/result.rb

Overview

What one source did in one sync run, and what is stored for it now.

result.source        # => :ofac_sdn
result.status        # => :updated, :unchanged or :failed
result.record_count  # => 19015
result.duration      # => 12.41
result.age           # => 0

Three statuses, and the distinction between the last two is the whole point of isolating sources from each other:

:updated    a new list version was parsed and stored
:unchanged  the publisher confirmed the copy we hold; nothing was written
:failed     something raised; **the previous snapshot was kept**

A failed source still says what is being screened against

checksum, record_count, fetched_at and age describe the snapshot that is in storage now, which for a failure is the one that was there before the run. That is deliberate and it is the reason this object carries an age at all: a source that has failed to refresh for nine days is still answering screening calls, and the only thing standing between that and an undetected compliance gap is that the age is visible. An alert on result.failed? fires once; an alert on result.age is what notices a source that has been quietly failing since Tuesday.

stored? is the loud case underneath it: a source that failed with nothing stored behind it is not stale, it is absent, and screening will not cover that list at all.

Serializable, on purpose

A host application alerts on a degrading source, and it should not have to parse a log line to do it. #to_h is JSON-ready and .from_h rebuilds it; the one thing that does not survive the round-trip is the exception object, since a backtrace is not something to put in a metrics pipeline. error_class and error_message do survive, because those are what an alert is written against.

Instances are frozen on construction and compare by value.

Constant Summary collapse

STATUSES =

How a sync of one source can come out. unchanged is the publisher answering 304, which is the common case on a list that changes daily at most, and it is a success rather than a no-op.

T.let(%i[updated unchanged failed].freeze, T::Array[Symbol])

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source:, status:, duration: 0.0, record_count: nil, checksum: nil, fetched_at: nil, age: nil, error: nil) ⇒ void

error takes the exception itself -- which is what the orchestrator captured -- or the {class:, message:} pair #to_h wrote.

Parameters:

  • source (T.untyped)
  • status (T.untyped)
  • duration (T.untyped) (defaults to: 0.0)
  • record_count (T.untyped) (defaults to: nil)
  • checksum (T.untyped) (defaults to: nil)
  • fetched_at (T.untyped) (defaults to: nil)
  • age (T.untyped) (defaults to: nil)
  • error (T.untyped) (defaults to: nil)


124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/active_sanction/sync/result.rb', line 124

def initialize(source:, status:, duration: 0.0, record_count: nil, checksum: nil, fetched_at: nil,
               age: nil, error: nil)
  @source = T.let(symbol!(:source, source), Symbol)
  @status = T.let(status!(status), Symbol)
  @duration = T.let(duration.to_f, Float)
  @record_count = T.let(count!(record_count), T.nilable(Integer))
  @checksum = T.let(string_or_nil(checksum), T.nilable(String))
  @fetched_at = T.let(time_or_nil(fetched_at), T.nilable(Time))
  @age = T.let(integer_or_nil(age), T.nilable(Integer))
  @exception = T.let(error.is_a?(Exception) ? error : nil, T.nilable(Exception))
  @failure = T.let(failure!(error), T.nilable(T::Hash[Symbol, String]))
  freeze
end

Instance Attribute Details

#age ⇒ Integer? (readonly)

Seconds between fetched_at and the end of this source's sync, fixed here rather than computed on demand so that a serialized report says how stale the data was when the run saw it and not how long the report has since sat in a queue.

Returns:

  • (Integer, nil)


93
94
95
# File 'lib/active_sanction/sync/result.rb', line 93

def age
  @age
end

#checksum ⇒ String? (readonly)

The checksum of the snapshot in storage now, which is what a match result cites. Unchanged between two runs means the same list answered both.

Returns:

  • (String, nil)


81
82
83
# File 'lib/active_sanction/sync/result.rb', line 81

def checksum
  @checksum
end

#duration ⇒ Float (readonly)

Wall-clock seconds this source took, fetch through store.

Returns:

  • (Float)


97
98
99
# File 'lib/active_sanction/sync/result.rb', line 97

def duration
  @duration
end

#exception ⇒ Exception? (readonly)

The exception that was captured, for a caller that wants the backtrace. nil after a round-trip through #to_h -- see the class comment.

Returns:

  • (Exception, nil)


102
103
104
# File 'lib/active_sanction/sync/result.rb', line 102

def exception
  @exception
end

#fetched_at ⇒ Time? (readonly)

When the snapshot in storage now was fetched -- for a failure, before this run.

Returns:

  • (Time, nil)


86
87
88
# File 'lib/active_sanction/sync/result.rb', line 86

def fetched_at
  @fetched_at
end

#record_count ⇒ Integer? (readonly)

Records stored for this source now -- not records fetched. A failed source reports what its retained snapshot holds, and nil only when there is no snapshot at all.

Returns:

  • (Integer, nil)


75
76
77
# File 'lib/active_sanction/sync/result.rb', line 75

def record_count
  @record_count
end

#source ⇒ Symbol (readonly)

Returns:

  • (Symbol)


65
66
67
# File 'lib/active_sanction/sync/result.rb', line 65

def source
  @source
end

#status ⇒ Symbol (readonly)

One of STATUSES.

Returns:

  • (Symbol)


69
70
71
# File 'lib/active_sanction/sync/result.rb', line 69

def status
  @status
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:



107
108
109
110
111
112
113
114
115
116
# File 'lib/active_sanction/sync/result.rb', line 107

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

  # `new(**hash)` past required keyword parameters is one of the few
  # things Sorbet cannot check statically; #initialize validates what
  # arrives.
  T.unsafe(self).new(**attributes)
end

Instance Method Details

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

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


219
220
221
222
223
# File 'lib/active_sanction/sync/result.rb', line 219

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

  to_h == other.to_h
end

#age_in_words ⇒ String

"3h old" rather than "10800 seconds": this is read by a human in a summary table, and the question being asked of it is only ever "is that a long time?". A source with nothing stored says so instead, because the age of a list that is not there is not the problem with it.

Returns:

  • (String)


200
201
202
203
204
205
206
207
208
209
210
# File 'lib/active_sanction/sync/result.rb', line 200

def age_in_words
  return "nothing stored" unless stored?

  seconds = age
  return "unknown age" if seconds.nil?

  [[86_400, "d"], [3_600, "h"], [60, "m"]].each do |(unit, suffix)|
    return "#{seconds / unit}#{suffix} old" if seconds >= unit
  end
  "just fetched"
end

#error ⇒ String?

The failure on one line, for a log or a table. nil when nothing failed.

Returns:

  • (String, nil)


170
171
172
173
174
175
176
177
178
179
# File 'lib/active_sanction/sync/result.rb', line 170

def error
  return nil unless error_class

  message = error_message
  # `raise SomeError` with no message gives a message that is the class
  # name, and "SomeError: SomeError" is not a line worth logging.
  return error_class if message.nil? || message.empty? || message == error_class

  "#{error_class}: #{message}"
end

#error_class ⇒ String?

The exception's class name, and its message. Strings rather than the class itself: this is what survives into a metrics pipeline, and a constant that no longer exists in the process reading a year-old report is not something to make it resolve.

Returns:

  • (String, nil)


143
# File 'lib/active_sanction/sync/result.rb', line 143

def error_class = @failure&.fetch(:class)

#error_message ⇒ String?

Returns:

  • (String, nil)


146
# File 'lib/active_sanction/sync/result.rb', line 146

def error_message = @failure&.fetch(:message)

#failed? ⇒ Boolean

Returns:

  • (Boolean)


155
# File 'lib/active_sanction/sync/result.rb', line 155

def failed? = status == :failed

#hash ⇒ Integer

Returns:

  • (Integer)


227
# File 'lib/active_sanction/sync/result.rb', line 227

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

#inspect ⇒ String

Returns:

  • (String)


230
# File 'lib/active_sanction/sync/result.rb', line 230

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

#retained? ⇒ Boolean

A failure that kept its previous good snapshot -- the behaviour this whole run is arranged around.

Returns:

  • (Boolean)


166
# File 'lib/active_sanction/sync/result.rb', line 166

def retained? = failed? && stored?

#stored? ⇒ Boolean

Whether there is a snapshot to screen this source against. False is the state that matters more than a failure: a list nothing is stored for is not covered by a screening run at all.

Returns:

  • (Boolean)


161
# File 'lib/active_sanction/sync/result.rb', line 161

def stored? = !checksum.nil?

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

Returns:

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


182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/active_sanction/sync/result.rb', line 182

def to_h
  {
    source: source,
    status: status,
    record_count: record_count,
    checksum: checksum,
    fetched_at: fetched_at&.iso8601,
    age: age,
    duration: duration,
    error: @failure
  }
end

#to_s ⇒ String

Returns:

  • (String)


213
214
215
216
# File 'lib/active_sanction/sync/result.rb', line 213

def to_s
  detail = failed? ? error.to_s : "#{record_count || 0} records, #{age_in_words}"
  "#{source} #{status} in #{format("%.2f", duration)}s: #{detail}"
end

#unchanged? ⇒ Boolean

Returns:

  • (Boolean)


152
# File 'lib/active_sanction/sync/result.rb', line 152

def unchanged? = status == :unchanged

#updated? ⇒ Boolean

Returns:

  • (Boolean)


149
# File 'lib/active_sanction/sync/result.rb', line 149

def updated? = status == :updated