Class: ActiveSanction::Storage::Meta

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

Overview

What a store can say about a stored list without reading the list.

store.snapshot_meta(:ofac_sdn)
# => #<ActiveSanction::Storage::Meta ofac_sdn 19015 entities sha256:1f3b... 3h old>

Every field here is small and every field a snapshot holds besides these is not: OFAC's is 19,015 entities and tens of megabytes of JSON. The questions an operator and a sync actually ask -- when was this last fetched, how old is it now, how many records are on it, is it still the version we screened against in January -- are all answerable from these six values, so they are worth being able to answer separately.

That separation is what makes the two things downstream cheap:

  • sources in the CLI (#36) and the per-source summary in sync orchestration (#34) print an age per list. A store that had to deserialize every snapshot to print a table would make the cheapest command in the library the slowest.
  • Storage::FileSystem (#24) writes exactly this beside each snapshot as meta.json, so #to_h is that file's contents and .from_h reads it back. Base derives a Meta from the snapshot for adapters that have nothing cheaper; an adapter with a sidecar or a metadata row overrides #snapshot_meta and never opens the list.

checksum is the one that outlives the rest. A match result cites it (#33), so "which list version cleared this customer" is answered by comparing a stored meta against a checksum in an audit record -- without loading either list.

Instances are frozen on construction and compare by value.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source:, fetched_at:, checksum:, record_count:, schema_version: Snapshot::SCHEMA_VERSION, source_version: nil) ⇒ void

Parameters:

  • source (T.untyped)
  • fetched_at (T.untyped)
  • checksum (T.untyped)
  • record_count (T.untyped)
  • schema_version (T.untyped) (defaults to: Snapshot::SCHEMA_VERSION)
  • source_version (T.untyped) (defaults to: nil)


98
99
100
101
102
103
104
105
106
107
# File 'lib/active_sanction/storage/meta.rb', line 98

def initialize(source:, fetched_at:, checksum:, record_count:, schema_version: Snapshot::SCHEMA_VERSION,
               source_version: nil)
  @source = T.let(symbol!(:source, source), Symbol)
  @fetched_at = T.let(time!(fetched_at), Time)
  @checksum = T.let(string!(:checksum, checksum), String)
  @record_count = T.let(count!(record_count), Integer)
  @schema_version = T.let(Integer(schema_version), Integer)
  @source_version = T.let(source_version.nil? ? nil : -source_version.to_s, T.nilable(String))
  freeze
end

Instance Attribute Details

#checksum ⇒ String (readonly)

The one field that outlives the rest: a match result cites it, so a stored meta answers "which list version cleared this customer".

Returns:

  • (String)


60
61
62
# File 'lib/active_sanction/storage/meta.rb', line 60

def checksum
  @checksum
end

#fetched_at ⇒ Time (readonly)

Returns:

  • (Time)


55
56
57
# File 'lib/active_sanction/storage/meta.rb', line 55

def fetched_at
  @fetched_at
end

#record_count ⇒ Integer (readonly)

Returns:

  • (Integer)


63
64
65
# File 'lib/active_sanction/storage/meta.rb', line 63

def record_count
  @record_count
end

#schema_version ⇒ Integer (readonly)

Returns:

  • (Integer)


66
67
68
# File 'lib/active_sanction/storage/meta.rb', line 66

def schema_version
  @schema_version
end

#source ⇒ Symbol (readonly)

Returns:

  • (Symbol)


52
53
54
# File 'lib/active_sanction/storage/meta.rb', line 52

def source
  @source
end

#source_version ⇒ String? (readonly)

Returns:

  • (String, nil)


69
70
71
# File 'lib/active_sanction/storage/meta.rb', line 69

def source_version
  @source_version
end

Class Method Details

.from_h(hash) ⇒ T.attached_class

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

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



83
84
85
86
87
88
89
90
91
92
# File 'lib/active_sanction/storage/meta.rb', line 83

def self.from_h(hash)
  attributes = hash.to_h.transform_keys(&:to_sym)
  unknown = attributes.keys - MEMBERS
  raise InvalidArgument, "unknown Meta 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, which is where a bad sidecar is caught.
  T.unsafe(self).new(**attributes)
end

.from_snapshot(snapshot) ⇒ T.attached_class

The metadata of a snapshot already in hand. What a store that keeps no separate record of it answers #snapshot_meta with.

Parameters:

Returns:

  • (T.attached_class)


74
75
76
77
78
# File 'lib/active_sanction/storage/meta.rb', line 74

def self.from_snapshot(snapshot)
  new(source: snapshot.source, fetched_at: snapshot.fetched_at, checksum: snapshot.checksum,
      record_count: snapshot.record_count, schema_version: snapshot.schema_version,
      source_version: snapshot.source_version)
end

Instance Method Details

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

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


153
154
155
156
157
# File 'lib/active_sanction/storage/meta.rb', line 153

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

  to_h == other.to_h
end

#age(now = Time.now) ⇒ Integer

How long ago this list was fetched, in seconds. Sync (#34) reports it per source: a list that failed to refresh keeps its previous snapshot, which is the right call and only safe if the age of what is being screened against is visible.

Accurate to a second, and never fewer than the seconds that have passed. fetched_at is stored to the second -- Snapshot#time! truncates it, so that a stored snapshot reloads equal to the one that was written -- so the instant of the fetch is known only to lie somewhere inside the second this names. What comes back is therefore the largest age consistent with what was recorded, which is the direction a staleness measure has to err in: an answer that is at most a second pessimistic is a report that nobody acts on, and one that is optimistic is a list being screened against that is older than it claims.

The practical consequence, and the reason it is written down: a list fetched microseconds ago reports 0 or 1, according to whether the run crossed a second boundary on its way here. Both mean "just fetched" -- see Sync::Result#age_in_words, which is what a summary table shows -- and nothing should assert on the exact number of a fresh sync, because that is a fact about the clock rather than about this library.

Parameters:

  • now (Time) (defaults to: Time.now)

Returns:

  • (Integer)


132
# File 'lib/active_sanction/storage/meta.rb', line 132

def age(now = Time.now) = now.to_i - fetched_at.to_i

#hash ⇒ Integer

Returns:

  • (Integer)


161
# File 'lib/active_sanction/storage/meta.rb', line 161

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

#inspect ⇒ String

Returns:

  • (String)


164
165
166
# File 'lib/active_sanction/storage/meta.rb', line 164

def inspect
  "#<#{self.class} #{source} #{record_count} entities #{checksum} fetched_at=#{fetched_at.iso8601}>"
end

#same_content?(other) ⇒ Boolean

Whether this is the same list content as another meta, or as a snapshot about to be written. Ignores when either was fetched, because a refetch of an unchanged list is not a new version of it.

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


138
# File 'lib/active_sanction/storage/meta.rb', line 138

def same_content?(other) = !other.nil? && other.checksum == checksum

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

Returns:

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


141
142
143
144
145
146
147
148
149
150
# File 'lib/active_sanction/storage/meta.rb', line 141

def to_h
  {
    source: source,
    fetched_at: fetched_at.iso8601,
    checksum: checksum,
    record_count: record_count,
    schema_version: schema_version,
    source_version: source_version
  }
end