active_sanction 1.0.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.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,296 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "time"
7
+
8
+ module ActiveSanction
9
+ class Sync
10
+ # What one source did in one sync run, and what is stored for it now.
11
+ #
12
+ # result.source # => :ofac_sdn
13
+ # result.status # => :updated, :unchanged or :failed
14
+ # result.record_count # => 19015
15
+ # result.duration # => 12.41
16
+ # result.age # => 0
17
+ #
18
+ # Three statuses, and the distinction between the last two is the whole
19
+ # point of isolating sources from each other:
20
+ #
21
+ # :updated a new list version was parsed and stored
22
+ # :unchanged the publisher confirmed the copy we hold; nothing was written
23
+ # :failed something raised; **the previous snapshot was kept**
24
+ #
25
+ # ### A failed source still says what is being screened against
26
+ #
27
+ # `checksum`, `record_count`, `fetched_at` and `age` describe the snapshot
28
+ # that is in storage *now*, which for a failure is the one that was there
29
+ # before the run. That is deliberate and it is the reason this object
30
+ # carries an age at all: a source that has failed to refresh for nine days
31
+ # is still answering screening calls, and the only thing standing between
32
+ # that and an undetected compliance gap is that the age is visible. An
33
+ # alert on `result.failed?` fires once; an alert on `result.age` is what
34
+ # notices a source that has been quietly failing since Tuesday.
35
+ #
36
+ # `stored?` is the loud case underneath it: a source that failed with
37
+ # nothing stored behind it is not stale, it is absent, and screening will
38
+ # not cover that list at all.
39
+ #
40
+ # ### Serializable, on purpose
41
+ #
42
+ # A host application alerts on a degrading source, and it should not have
43
+ # to parse a log line to do it. `#to_h` is JSON-ready and `.from_h` rebuilds
44
+ # it; the one thing that does not survive the round-trip is the exception
45
+ # object, since a backtrace is not something to put in a metrics pipeline.
46
+ # `error_class` and `error_message` do survive, because those are what an
47
+ # alert is written against.
48
+ #
49
+ # Instances are frozen on construction and compare by value.
50
+ class Result
51
+ extend T::Sig
52
+
53
+ # How a sync of one source can come out. `unchanged` is the publisher
54
+ # answering 304, which is the common case on a list that changes
55
+ # daily at most, and it is a success rather than a no-op.
56
+ STATUSES = T.let(%i[updated unchanged failed].freeze, T::Array[Symbol])
57
+
58
+ # @api private
59
+ MEMBERS = T.let(
60
+ %i[source status record_count checksum fetched_at age duration error].freeze,
61
+ T::Array[Symbol]
62
+ )
63
+
64
+ sig { returns(Symbol) }
65
+ attr_reader :source
66
+
67
+ # One of STATUSES.
68
+ sig { returns(Symbol) }
69
+ attr_reader :status
70
+
71
+ # Records stored for this source now -- not records fetched. A failed
72
+ # source reports what its retained snapshot holds, and nil only when
73
+ # there is no snapshot at all.
74
+ sig { returns(T.nilable(Integer)) }
75
+ attr_reader :record_count
76
+
77
+ # The checksum of the snapshot in storage now, which is what a match
78
+ # result cites. Unchanged between two runs means the same list answered
79
+ # both.
80
+ sig { returns(T.nilable(String)) }
81
+ attr_reader :checksum
82
+
83
+ # When the snapshot in storage now was fetched -- for a failure, before
84
+ # this run.
85
+ sig { returns(T.nilable(Time)) }
86
+ attr_reader :fetched_at
87
+
88
+ # Seconds between `fetched_at` and the end of this source's sync, fixed
89
+ # here rather than computed on demand so that a serialized report says
90
+ # how stale the data was when the run saw it and not how long the report
91
+ # has since sat in a queue.
92
+ sig { returns(T.nilable(Integer)) }
93
+ attr_reader :age
94
+
95
+ # Wall-clock seconds this source took, fetch through store.
96
+ sig { returns(Float) }
97
+ attr_reader :duration
98
+
99
+ # The exception that was captured, for a caller that wants the backtrace.
100
+ # nil after a round-trip through #to_h -- see the class comment.
101
+ sig { returns(T.nilable(Exception)) }
102
+ attr_reader :exception
103
+
104
+ # Rebuilds from #to_h output, accepting string keys so a report survives
105
+ # the trip through JSON.
106
+ sig { params(hash: T.untyped).returns(T.attached_class) }
107
+ def self.from_h(hash)
108
+ attributes = hash.to_h.transform_keys(&:to_sym)
109
+ unknown = attributes.keys - MEMBERS
110
+ raise InvalidArgument, "unknown Sync::Result attribute(s): #{unknown.join(", ")}" if unknown.any?
111
+
112
+ # `new(**hash)` past required keyword parameters is one of the few
113
+ # things Sorbet cannot check statically; #initialize validates what
114
+ # arrives.
115
+ T.unsafe(self).new(**attributes)
116
+ end
117
+
118
+ # `error` takes the exception itself -- which is what the orchestrator
119
+ # captured -- or the `{class:, message:}` pair #to_h wrote.
120
+ sig do
121
+ params(source: T.untyped, status: T.untyped, duration: T.untyped, record_count: T.untyped,
122
+ checksum: T.untyped, fetched_at: T.untyped, age: T.untyped, error: T.untyped).void
123
+ end
124
+ def initialize(source:, status:, duration: 0.0, record_count: nil, checksum: nil, fetched_at: nil,
125
+ age: nil, error: nil)
126
+ @source = T.let(symbol!(:source, source), Symbol)
127
+ @status = T.let(status!(status), Symbol)
128
+ @duration = T.let(duration.to_f, Float)
129
+ @record_count = T.let(count!(record_count), T.nilable(Integer))
130
+ @checksum = T.let(string_or_nil(checksum), T.nilable(String))
131
+ @fetched_at = T.let(time_or_nil(fetched_at), T.nilable(Time))
132
+ @age = T.let(integer_or_nil(age), T.nilable(Integer))
133
+ @exception = T.let(error.is_a?(Exception) ? error : nil, T.nilable(Exception))
134
+ @failure = T.let(failure!(error), T.nilable(T::Hash[Symbol, String]))
135
+ freeze
136
+ end
137
+
138
+ # The exception's class name, and its message. Strings rather than the
139
+ # class itself: this is what survives into a metrics pipeline, and a
140
+ # constant that no longer exists in the process reading a year-old report
141
+ # is not something to make it resolve.
142
+ sig { returns(T.nilable(String)) }
143
+ def error_class = @failure&.fetch(:class)
144
+
145
+ sig { returns(T.nilable(String)) }
146
+ def error_message = @failure&.fetch(:message)
147
+
148
+ sig { returns(T::Boolean) }
149
+ def updated? = status == :updated
150
+
151
+ sig { returns(T::Boolean) }
152
+ def unchanged? = status == :unchanged
153
+
154
+ sig { returns(T::Boolean) }
155
+ def failed? = status == :failed
156
+
157
+ # Whether there is a snapshot to screen this source against. False is the
158
+ # state that matters more than a failure: a list nothing is stored for is
159
+ # not covered by a screening run at all.
160
+ sig { returns(T::Boolean) }
161
+ def stored? = !checksum.nil?
162
+
163
+ # A failure that kept its previous good snapshot -- the behaviour this
164
+ # whole run is arranged around.
165
+ sig { returns(T::Boolean) }
166
+ def retained? = failed? && stored?
167
+
168
+ # The failure on one line, for a log or a table. nil when nothing failed.
169
+ sig { returns(T.nilable(String)) }
170
+ def error
171
+ return nil unless error_class
172
+
173
+ message = error_message
174
+ # `raise SomeError` with no message gives a message that is the class
175
+ # name, and "SomeError: SomeError" is not a line worth logging.
176
+ return error_class if message.nil? || message.empty? || message == error_class
177
+
178
+ "#{error_class}: #{message}"
179
+ end
180
+
181
+ sig { returns(T::Hash[Symbol, T.untyped]) }
182
+ def to_h
183
+ {
184
+ source: source,
185
+ status: status,
186
+ record_count: record_count,
187
+ checksum: checksum,
188
+ fetched_at: fetched_at&.iso8601,
189
+ age: age,
190
+ duration: duration,
191
+ error: @failure
192
+ }
193
+ end
194
+
195
+ # "3h old" rather than "10800 seconds": this is read by a human in a
196
+ # summary table, and the question being asked of it is only ever "is that
197
+ # a long time?". A source with nothing stored says so instead, because
198
+ # the age of a list that is not there is not the problem with it.
199
+ sig { returns(String) }
200
+ def age_in_words
201
+ return "nothing stored" unless stored?
202
+
203
+ seconds = age
204
+ return "unknown age" if seconds.nil?
205
+
206
+ [[86_400, "d"], [3_600, "h"], [60, "m"]].each do |(unit, suffix)|
207
+ return "#{seconds / unit}#{suffix} old" if seconds >= unit
208
+ end
209
+ "just fetched"
210
+ end
211
+
212
+ sig { returns(String) }
213
+ def to_s
214
+ detail = failed? ? error.to_s : "#{record_count || 0} records, #{age_in_words}"
215
+ "#{source} #{status} in #{format("%.2f", duration)}s: #{detail}"
216
+ end
217
+
218
+ sig { params(other: T.untyped).returns(T::Boolean) }
219
+ def ==(other)
220
+ return false unless other.instance_of?(self.class)
221
+
222
+ to_h == other.to_h
223
+ end
224
+ alias eql? ==
225
+
226
+ sig { returns(Integer) }
227
+ def hash = [self.class, to_h].hash
228
+
229
+ sig { returns(String) }
230
+ def inspect = "#<#{self.class} #{self}>"
231
+
232
+ private
233
+
234
+ sig { params(value: T.untyped).returns(Symbol) }
235
+ def status!(value)
236
+ status = symbol!(:status, value)
237
+ return status if STATUSES.include?(status)
238
+
239
+ raise InvalidArgument, "status must be one of #{STATUSES.join(", ")}, got #{value.inspect}"
240
+ end
241
+
242
+ # Takes the exception the orchestrator captured, or the pair #to_h wrote
243
+ # -- with string keys, since a report that has been through JSON has
244
+ # string keys all the way down.
245
+ sig { params(value: T.untyped).returns(T.nilable(T::Hash[Symbol, String])) }
246
+ def failure!(value)
247
+ case value
248
+ when nil then nil
249
+ when Exception then { class: value.class.name.to_s, message: value.message.to_s }.freeze
250
+ else
251
+ pair = value.to_h.transform_keys(&:to_sym)
252
+ name = string_or_nil(pair[:class])
253
+ name && { class: name, message: pair[:message].to_s }.freeze
254
+ end
255
+ end
256
+
257
+ sig { params(value: T.untyped).returns(T.nilable(Integer)) }
258
+ def count!(value)
259
+ integer = integer_or_nil(value)
260
+ raise InvalidArgument, "record_count cannot be negative, got #{integer}" if integer&.negative?
261
+
262
+ integer
263
+ end
264
+
265
+ # An age may be negative where a publisher's clock is ahead of ours, so
266
+ # unlike a record count it is not checked for it.
267
+ sig { params(value: T.untyped).returns(T.nilable(Integer)) }
268
+ def integer_or_nil(value) = value.nil? ? nil : Integer(value)
269
+
270
+ sig { params(value: T.untyped).returns(T.nilable(Time)) }
271
+ def time_or_nil(value)
272
+ case value
273
+ when nil then nil
274
+ when Time then Time.at(value.to_i).utc
275
+ when String then Time.at(Time.parse(value).to_i).utc
276
+ else raise InvalidArgument, "fetched_at is not a time: #{value.inspect}"
277
+ end
278
+ end
279
+
280
+ sig { params(member: Symbol, value: T.untyped).returns(Symbol) }
281
+ def symbol!(member, value)
282
+ raise InvalidArgument, "#{member} is required" if value.nil? || value.to_s.empty?
283
+
284
+ value.to_sym
285
+ end
286
+
287
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
288
+ def string_or_nil(value)
289
+ return nil if value.nil?
290
+
291
+ string = value.to_s.strip
292
+ string.empty? ? nil : -string
293
+ end
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,390 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "uri"
7
+ require "active_sanction/error"
8
+ require "active_sanction/sources"
9
+ require "active_sanction/storage"
10
+ require "active_sanction/sync/result"
11
+ require "active_sanction/sync/report"
12
+
13
+ module ActiveSanction
14
+ # Fetch, parse and store every configured list in one run, isolating each
15
+ # source from the others.
16
+ #
17
+ # report = ActiveSanction.sync! # every configured source
18
+ # report = ActiveSanction.sync!(:ofac_sdn) # one
19
+ # report = ActiveSanction.sync!(force: true) # bypass conditional GET
20
+ #
21
+ # report.failed? # => false
22
+ # report[:ofac_sdn].record_count # => 19015
23
+ #
24
+ # One source at a time is Sources::Base#sync, which fetches, parses and
25
+ # checksums, and deliberately rescues nothing. This is the layer above it:
26
+ # what a *run* does, which is a different set of decisions.
27
+ #
28
+ # ### One source failing must not abort the others
29
+ #
30
+ # Government endpoints go down, change format without notice, and
31
+ # occasionally serve half a file. If a UN outage stopped OFAC from syncing,
32
+ # the library would fail exactly when it is most needed -- during an
33
+ # incident, which is when lists move. So every source runs inside its own
34
+ # rescue: the failure is captured into its Result, the remaining sources
35
+ # carry on, and the run ends with a summary that says which one broke.
36
+ #
37
+ # StandardError and not Exception. An Interrupt or a SIGTERM is somebody
38
+ # stopping this run on purpose, and swallowing it to go on downloading three
39
+ # more lists is not isolation, it is a job that will not die.
40
+ #
41
+ # ### A failed source keeps its previous snapshot
42
+ #
43
+ # This is the single most important behaviour here, and it is a decision
44
+ # about what "no data" costs. Nothing clears a source's stored list on
45
+ # failure -- not a 500, not a parse error, not a publisher that started
46
+ # serving HTML where XML used to be. Screening against yesterday's OFAC list
47
+ # produces a report with a known, visible age on it; screening against an
48
+ # empty list produces a clean report for every customer, which is the most
49
+ # expensive thing this library can get wrong.
50
+ #
51
+ # That trade is only safe while the age is visible, so every Result carries
52
+ # the age and record count of the snapshot that source is being screened
53
+ # against now -- see Sync::Result, which is where the failure ends up.
54
+ #
55
+ # ### Unchanged sources cost nothing
56
+ #
57
+ # The launch lists change daily at most and every one of them serves ETag and
58
+ # Last-Modified, so an hourly sync should transfer bytes once a day. A source
59
+ # whose publisher answers 304 is never parsed and never stored: the whole
60
+ # saving of conditional GET (#10) is that the parse -- the expensive half for
61
+ # OFAC's three-file join -- is skipped along with the download.
62
+ #
63
+ # A source whose bytes changed but whose *content* hashes to what is already
64
+ # stored is also reported unchanged and not rewritten. A publisher
65
+ # regenerating an identical file with a new timestamp is not a new list
66
+ # version, and rewriting tens of megabytes to say so would churn the
67
+ # checksum every audit record cites.
68
+ #
69
+ # ### Optional parallelism, with a politeness limit
70
+ #
71
+ # ActiveSanction.sync!(concurrency: 3)
72
+ #
73
+ # Sources that share a publisher are never fetched at the same time. They are
74
+ # grouped by the host they download from and each group runs sequentially, so
75
+ # raising concurrency fetches from more governments at once and never harder
76
+ # from any one of them -- which matters because two of the built-in adapters
77
+ # (OFAC SDN and OFAC Consolidated) are the same file server. The default is 1
78
+ # and a run of four lists takes about as long as its slowest list.
79
+ #
80
+ # Each source is fetched by its own adapter, so nothing is shared between two
81
+ # sources in flight except the files underneath them: the validator store is
82
+ # one small JSON file, written by rename and resolving last-writer-wins,
83
+ # exactly as it already does for a sync running beside a CLI command. The
84
+ # cost of losing that race is one avoidable download on the next run, and it
85
+ # is the reason the default is sequential rather than the reason parallelism
86
+ # is unsafe.
87
+ #
88
+ # ### Where this belongs
89
+ #
90
+ # Syncing is a capability of the local backend (#56), not of every backend: a
91
+ # hosted one does not sync, because data freshness is exactly what its user
92
+ # is paying somebody else to handle, and it should say so through
93
+ # `supports?(:sync)`. When that seam lands this becomes `Backend::Local#sync`
94
+ # unchanged -- which is why the report is a serializable object rather than
95
+ # console output, and why nothing here writes to `$stdout`.
96
+ class Sync
97
+ extend T::Sig
98
+
99
+ # Raised by Report#success!, for a caller that wants any failure fatal.
100
+ # Never raised by the run itself: by the time a failure is known, every
101
+ # other source has already been fetched and stored.
102
+ #
103
+ # The one error in the hierarchy that is about a *run* rather than about
104
+ # one list, which is why it hangs off Error directly and carries the whole
105
+ # report rather than a single #source_id.
106
+ class Failed < StandardError
107
+ include ActiveSanction::Error
108
+ extend T::Sig
109
+
110
+ sig { returns(Report) }
111
+ attr_reader :report
112
+
113
+ sig { params(report: Report).void }
114
+ def initialize(report)
115
+ @report = T.let(report, Report)
116
+ super(report.failure_message)
117
+ end
118
+
119
+ # True only when every source that failed failed retryably -- a run with
120
+ # one publisher timing out is worth re-running, and a run with a parse
121
+ # error in it is not going to come out differently in five minutes. A
122
+ # failure whose exception did not survive a round-trip through #to_h
123
+ # counts as not retryable, since nothing is known about it.
124
+ sig { returns(T::Boolean) }
125
+ def retryable?
126
+ failures = report.failed.map(&:exception)
127
+ retryable_or(failures.any? && failures.all? { |e| e.is_a?(ActiveSanction::Error) && e.retryable? })
128
+ end
129
+ end
130
+
131
+ # The adapters this run covers: classes as registered, or instances a
132
+ # caller passed in.
133
+ sig { returns(T::Array[T.untyped]) }
134
+ attr_reader :sources
135
+
136
+ sig { returns(T::Array[Symbol]) }
137
+ attr_reader :keys
138
+
139
+ sig { returns(T.untyped) }
140
+ attr_reader :store
141
+
142
+ sig { returns(T::Boolean) }
143
+ attr_reader :force
144
+
145
+ # How many publishers to fetch from at once. See the class comment.
146
+ sig { returns(Integer) }
147
+ attr_reader :concurrency
148
+
149
+ sig { returns(T.untyped) }
150
+ attr_reader :logger
151
+
152
+ sig { params(options: T.untyped, block: T.untyped).returns(Report) }
153
+ def self.call(**options, &block) = T.unsafe(self).new(**options).call(&block)
154
+
155
+ # `sources:` takes source keys, adapter classes, adapter instances, or nil
156
+ # for whatever `config.sources` names. A key nothing is registered under
157
+ # raises here, before the first list is downloaded, rather than after.
158
+ sig do
159
+ params(sources: T.untyped, store: T.untyped, force: T::Boolean, concurrency: T.untyped,
160
+ logger: T.untyped).void
161
+ end
162
+ def initialize(sources: nil, store: nil, force: false, concurrency: nil, logger: ActiveSanction.config.logger)
163
+ @sources = T.let(resolve(sources), T::Array[T.untyped])
164
+ @keys = T.let(@sources.map { |source| Sources::Definition.key!(source.key) }, T::Array[Symbol])
165
+ @store = T.let(store || ActiveSanction.storage, T.untyped)
166
+ @force = T.let(force, T::Boolean)
167
+ @concurrency = T.let(
168
+ Configuration.sync_concurrency!(concurrency || ActiveSanction.config.sync_concurrency), Integer
169
+ )
170
+ @logger = T.let(logger, T.untyped)
171
+ @lock = T.let(Mutex.new, Mutex)
172
+ # The settings this run was started under, so a worker thread reads them
173
+ # rather than the default client's. A configuration is fiber-local and a
174
+ # `Thread.new` does not inherit one -- see
175
+ # ActiveSanction.with_configuration -- so a run through a Client with its
176
+ # own User-Agent would otherwise identify itself as that client on the
177
+ # first source and as the default on the next three, purely according to
178
+ # `concurrency:`.
179
+ @configuration = T.let(ActiveSanction.config, Configuration)
180
+ end
181
+
182
+ # Runs the sync and returns the Report. Never raises for a source that
183
+ # failed -- that is what the report is for -- and does raise for anything
184
+ # that makes the run itself impossible, such as a store that cannot be
185
+ # written to at all.
186
+ #
187
+ # The optional block is the progress hook: it is called with each Result as
188
+ # that source finishes, so a long run says something before it ends. It is
189
+ # called under a lock, so a block that appends to an array or writes a line
190
+ # does not have to be thread-safe to be correct under `concurrency:`.
191
+ #
192
+ # ActiveSanction.sync! { |result| puts result }
193
+ sig { params(block: T.nilable(T.proc.params(result: Result).void)).returns(Report) }
194
+ def call(&block)
195
+ started_at = Time.now.utc
196
+ began = monotonic
197
+ log_start
198
+ work = keys.each_with_index.map { |key, at| [at, key, sources.fetch(at)] }
199
+ results = run(work, &block).sort_by(&:first).map(&:last)
200
+ report = Report.new(results: results, started_at: started_at, duration: elapsed(began))
201
+ log_finish(report)
202
+ report
203
+ end
204
+
205
+ sig { returns(String) }
206
+ def inspect = "#<#{self.class} #{keys.join(", ")}#{" forced" if force} concurrency=#{concurrency}>"
207
+
208
+ private
209
+
210
+ sig { params(requested: T.untyped).returns(T::Array[T.untyped]) }
211
+ def resolve(requested)
212
+ listed = Array(requested).flatten.compact
213
+ return Sources.enabled if listed.empty?
214
+
215
+ listed.map { |source| adapter!(source) }
216
+ end
217
+
218
+ # A key is looked up in the registry; a class is left to be built inside
219
+ # the run's rescue, so a source whose constructor raises is that source's
220
+ # failure and not the whole run's; an instance a caller built itself is
221
+ # used as it stands.
222
+ sig { params(source: T.untyped).returns(T.untyped) }
223
+ def adapter!(source)
224
+ return Sources[source] if source.is_a?(Symbol) || source.is_a?(String)
225
+ return source if source.respond_to?(:key) && source.respond_to?(source.is_a?(Class) ? :new : :sync)
226
+
227
+ raise InvalidArgument, "a source must be a registered key, or answer .key and .new, got #{source.inspect}"
228
+ end
229
+
230
+ # Groups that may run at the same time, each of which runs in order. See
231
+ # the class comment on politeness.
232
+ sig do
233
+ params(work: T::Array[T.untyped], block: T.nilable(T.proc.params(result: Result).void))
234
+ .returns(T::Array[T.untyped])
235
+ end
236
+ def run(work, &block)
237
+ groups = work.group_by { |(_at, key, source)| publisher(key, source) }.values
238
+ workers = [concurrency, groups.size].min
239
+ return groups.flatten(1).map { |item| pair(item, &block) } if workers < 2
240
+
241
+ queue = Queue.new
242
+ groups.each { |group| queue << group }
243
+ queue.close
244
+ Array.new(workers) do
245
+ Thread.new { ActiveSanction.with_configuration(@configuration) { drain(queue, &block) } }
246
+ end.flat_map(&:value)
247
+ end
248
+
249
+ sig do
250
+ params(queue: Queue, block: T.nilable(T.proc.params(result: Result).void)).returns(T::Array[T.untyped])
251
+ end
252
+ def drain(queue, &block)
253
+ collected = []
254
+ while (group = queue.pop)
255
+ group.each { |item| collected << pair(item, &block) }
256
+ end
257
+ collected
258
+ end
259
+
260
+ sig do
261
+ params(item: T::Array[T.untyped], block: T.nilable(T.proc.params(result: Result).void))
262
+ .returns(T::Array[T.untyped])
263
+ end
264
+ def pair(item, &block)
265
+ at, key, source = item
266
+ result = sync_source(key, source)
267
+ @lock.synchronize do
268
+ log_result(result)
269
+ block&.call(result)
270
+ end
271
+ [at, result]
272
+ end
273
+
274
+ # One source, start to finish, inside its own rescue. Nothing in here may
275
+ # raise past this method, and nothing in here may clear what is stored.
276
+ sig { params(key: Symbol, source: T.untyped).returns(Result) }
277
+ def sync_source(key, source)
278
+ started = monotonic
279
+ previous = T.let(nil, T.nilable(Storage::Meta))
280
+ begin
281
+ # What is stored is read before the adapter is built, so that a source
282
+ # whose constructor raises still reports the snapshot it is keeping.
283
+ previous = readable_meta(key)
284
+ adapter = source.is_a?(Class) ? T.unsafe(source).new : source
285
+ snapshot = adapter.sync(force: force || previous.nil?)
286
+ return complete(key, :unchanged, previous, started) if unchanged?(previous, snapshot)
287
+
288
+ store.write_snapshot(snapshot)
289
+ complete(key, :updated, Storage::Meta.from_snapshot(snapshot), started)
290
+ rescue StandardError => e
291
+ complete(key, :failed, previous, started, stamp(key, e))
292
+ end
293
+ end
294
+
295
+ # A failure captured for a source names that source, even when it was
296
+ # raised somewhere that could not know -- a store that will not open, an
297
+ # adapter constructor. Only ever fills a blank; see Error#in_source.
298
+ sig { params(key: Symbol, error: StandardError).returns(StandardError) }
299
+ def stamp(key, error)
300
+ error.is_a?(ActiveSanction::Error) ? error.in_source(key) : error
301
+ end
302
+
303
+ # A publisher that answered 304, or one that served a file whose parsed
304
+ # content is what is already stored.
305
+ sig { params(previous: T.nilable(Storage::Meta), snapshot: T.untyped).returns(T::Boolean) }
306
+ def unchanged?(previous, snapshot)
307
+ snapshot.nil? || (!previous.nil? && previous.same_content?(snapshot))
308
+ end
309
+
310
+ sig do
311
+ params(key: Symbol, status: Symbol, meta: T.nilable(Storage::Meta), started: Float, error: T.untyped)
312
+ .returns(Result)
313
+ end
314
+ def complete(key, status, meta, started, error = nil)
315
+ Result.new(source: key, status: status, duration: elapsed(started), record_count: meta&.record_count,
316
+ checksum: meta&.checksum, fetched_at: meta&.fetched_at, age: meta&.age, error: error)
317
+ end
318
+
319
+ # What is stored for a source, or nil -- and nil for a stored snapshot that
320
+ # cannot be read, which is why this is not simply `store.snapshot_meta`.
321
+ #
322
+ # Both of those answers make the run fetch this source in full rather than
323
+ # conditionally, and the reason is the same for each: a conditional request
324
+ # asks the publisher whether the copy we hold is current, and we do not
325
+ # hold one. Letting a 304 stand against a list that is missing or corrupt
326
+ # would report a source unchanged that cannot be screened at all.
327
+ sig { params(key: Symbol).returns(T.nilable(Storage::Meta)) }
328
+ def readable_meta(key)
329
+ store.snapshot_meta(key)
330
+ rescue StandardError => e
331
+ log(:warn, "#{key} has a stored snapshot that cannot be read (#{e.class}: #{e.message}); fetching in full")
332
+ nil
333
+ end
334
+
335
+ # Which publisher a source downloads from, as the grouping key. A source
336
+ # that cannot say -- one backed by a database table, one whose declaration
337
+ # raises -- gets a group of its own, since there is nobody it could be
338
+ # impolite towards.
339
+ sig { params(key: Symbol, source: T.untyped).returns(String) }
340
+ def publisher(key, source)
341
+ address = source.respond_to?(:urls) ? source.urls.values.first : nil
342
+ host = address.nil? ? nil : URI.parse(address.to_s).host
343
+ host.nil? || host.empty? ? "source:#{key}" : host.downcase
344
+ rescue StandardError
345
+ "source:#{key}"
346
+ end
347
+
348
+ sig { returns(Float) }
349
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
350
+
351
+ sig { params(started: Float).returns(Float) }
352
+ def elapsed(started) = (monotonic - started).round(3).to_f
353
+
354
+ sig { void }
355
+ def log_start
356
+ log(:info, "syncing #{keys.size} source(s): #{keys.join(", ")}#{" (forced)" if force}" \
357
+ "#{", #{concurrency} publishers at a time" if concurrency > 1}")
358
+ end
359
+
360
+ # The line an operator greps for, and the one an on-call engineer reads at
361
+ # three in the morning: a failure has to say what is still being screened
362
+ # against, not merely that something broke.
363
+ sig { params(result: Result).void }
364
+ def log_result(result)
365
+ message = +result.to_s
366
+ if result.retained?
367
+ message << "; keeping the previous snapshot of #{result.record_count} records (#{result.age_in_words})"
368
+ elsif result.failed?
369
+ message << "; nothing is stored for this source, so screening does not cover it"
370
+ end
371
+ log(result.failed? ? :warn : :info, message)
372
+ end
373
+
374
+ sig { params(report: Report).void }
375
+ def log_finish(report)
376
+ log(report.failed? ? :warn : :info, "synced #{report.summary}")
377
+ end
378
+
379
+ # Configuration only promises a logger that answers #info, so a failure is
380
+ # logged at warn where the logger has one and at info where it does not --
381
+ # rather than not at all.
382
+ sig { params(level: Symbol, message: String).void }
383
+ def log(level, message)
384
+ return unless logger
385
+
386
+ line = "[active_sanction] #{message}"
387
+ logger.respond_to?(level) ? logger.public_send(level, line) : logger.info(line)
388
+ end
389
+ end
390
+ end