active_sanction 1.0.1 → 1.1.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -1
- data/CONTRIBUTING.md +21 -4
- data/README.md +1 -1
- data/docs/adding_a_source.md +1 -1
- data/docs/api_stability.md +79 -7
- data/lib/active_sanction/client.rb +48 -2
- data/lib/active_sanction/configuration.rb +39 -0
- data/lib/active_sanction/fetcher.rb +38 -12
- data/lib/active_sanction/index.rb +61 -0
- data/lib/active_sanction/instrumentation/event.rb +122 -0
- data/lib/active_sanction/instrumentation/notifications.rb +122 -0
- data/lib/active_sanction/instrumentation.rb +245 -0
- data/lib/active_sanction/matcher.rb +82 -17
- data/lib/active_sanction/sources/base.rb +48 -6
- data/lib/active_sanction/sync.rb +54 -6
- data/lib/active_sanction/testing/sanction_source.rb +234 -0
- data/lib/active_sanction/testing/storage_adapter.rb +319 -0
- data/lib/active_sanction/testing/storage_adapter_defaults.rb +25 -0
- data/lib/active_sanction/testing.rb +119 -0
- data/lib/active_sanction/version.rb +1 -1
- data/lib/active_sanction.rb +1 -0
- metadata +9 -2
|
@@ -6,6 +6,7 @@ require "sorbet-runtime"
|
|
|
6
6
|
require "time"
|
|
7
7
|
require "active_sanction/error"
|
|
8
8
|
require "active_sanction/index"
|
|
9
|
+
require "active_sanction/instrumentation"
|
|
9
10
|
require "active_sanction/match_result"
|
|
10
11
|
require "active_sanction/query"
|
|
11
12
|
require "active_sanction/scorer"
|
|
@@ -131,6 +132,14 @@ module ActiveSanction
|
|
|
131
132
|
sig { returns(Symbol) }
|
|
132
133
|
attr_reader :backend
|
|
133
134
|
|
|
135
|
+
# Where the `:screen` event goes, or nil for nothing listening. Read once
|
|
136
|
+
# at construction and frozen with everything else here, which is the rule
|
|
137
|
+
# the class comment states for the whole query path: a subscriber swapped
|
|
138
|
+
# halfway through a batch cannot make half of it instrumented. See
|
|
139
|
+
# Instrumentation.
|
|
140
|
+
sig { returns(T.untyped) }
|
|
141
|
+
attr_reader :instrumenter
|
|
142
|
+
|
|
134
143
|
class << self
|
|
135
144
|
extend T::Sig
|
|
136
145
|
|
|
@@ -152,14 +161,29 @@ module ActiveSanction
|
|
|
152
161
|
# covers all three, and both report the name clear.
|
|
153
162
|
sig do
|
|
154
163
|
params(store: T.untyped, sources: T.untyped, weights: T.untyped, candidate_limit: T.untyped,
|
|
155
|
-
backend: T.untyped).returns(Matcher)
|
|
164
|
+
backend: T.untyped, instrumenter: T.untyped).returns(Matcher)
|
|
156
165
|
end
|
|
157
166
|
def build(store = nil, sources: nil, weights: nil, candidate_limit: nil,
|
|
158
|
-
backend: MatchResult::DEFAULT_BACKEND)
|
|
167
|
+
backend: MatchResult::DEFAULT_BACKEND, instrumenter: nil)
|
|
159
168
|
store ||= ActiveSanction.config.storage
|
|
169
|
+
listening = instrumenter.nil? ? ActiveSanction.config.instrumenter : instrumenter
|
|
170
|
+
built = Instrumentation.instrument(listening, :"index.build", { store: store.class.name }) do |event|
|
|
171
|
+
index_over(store, sources, event)
|
|
172
|
+
end
|
|
173
|
+
new(index: built.fetch(:index), snapshots: built.fetch(:checksums), verified: built.fetch(:attested),
|
|
174
|
+
weights: weights, candidate_limit: candidate_limit, backend: backend, instrumenter: listening)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# Every list this matcher will hold, read one at a time and released
|
|
180
|
+
# before the next is opened, and what it cost.
|
|
181
|
+
sig { params(store: T.untyped, sources: T.untyped, event: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
|
|
182
|
+
def index_over(store, sources, event)
|
|
160
183
|
builder = Index::Builder.new
|
|
161
184
|
checksums = T.let({}, T::Hash[Symbol, String])
|
|
162
185
|
attested = T.let([], T::Array[Symbol])
|
|
186
|
+
entities = 0
|
|
163
187
|
requested(store, sources).each do |key|
|
|
164
188
|
snapshot = store.fetch_snapshot(key)
|
|
165
189
|
checksums[key] = snapshot.checksum
|
|
@@ -168,12 +192,27 @@ module ActiveSanction
|
|
|
168
192
|
# different list.
|
|
169
193
|
attested << key if snapshot.trusted?
|
|
170
194
|
snapshot.entities.each { |entity| builder.add(entity) }
|
|
195
|
+
entities += snapshot.record_count
|
|
171
196
|
end
|
|
172
|
-
|
|
173
|
-
|
|
197
|
+
index = builder.build
|
|
198
|
+
measure(event, index, checksums, entities)
|
|
199
|
+
{ index: index, checksums: checksums, attested: attested }
|
|
174
200
|
end
|
|
175
201
|
|
|
176
|
-
|
|
202
|
+
# What a host watches at boot and after every sync: how long an index
|
|
203
|
+
# took to build, how much of it there is, and roughly what it weighs.
|
|
204
|
+
# `bytes` is an estimate and says so -- see Index#profile, which is
|
|
205
|
+
# where the assumptions behind the number are written down, and where
|
|
206
|
+
# the entity count deliberately does not come from.
|
|
207
|
+
sig do
|
|
208
|
+
params(event: T.untyped, index: Index, checksums: T::Hash[Symbol, String], entities: Integer).void
|
|
209
|
+
end
|
|
210
|
+
def measure(event, index, checksums, entities)
|
|
211
|
+
event[:sources] = checksums.keys
|
|
212
|
+
event[:snapshots] = checksums
|
|
213
|
+
event[:entities] = entities
|
|
214
|
+
index.profile.each { |name, value| event[name] = value }
|
|
215
|
+
end
|
|
177
216
|
|
|
178
217
|
# The lists to index, in a deterministic order, or the exception that
|
|
179
218
|
# says why there are none.
|
|
@@ -204,10 +243,10 @@ module ActiveSanction
|
|
|
204
243
|
# one name against several list versions, or a spec.
|
|
205
244
|
sig do
|
|
206
245
|
params(index: Index, snapshots: T.untyped, weights: T.untyped, candidate_limit: T.untyped,
|
|
207
|
-
backend: T.untyped, verified: T.untyped).void
|
|
246
|
+
backend: T.untyped, verified: T.untyped, instrumenter: T.untyped).void
|
|
208
247
|
end
|
|
209
248
|
def initialize(index:, snapshots:, weights: nil, candidate_limit: nil, backend: MatchResult::DEFAULT_BACKEND,
|
|
210
|
-
verified: nil)
|
|
249
|
+
verified: nil, instrumenter: nil)
|
|
211
250
|
@index = index
|
|
212
251
|
@snapshots = T.let(snapshots!(snapshots), T::Hash[Symbol, String])
|
|
213
252
|
@verified = T.let(verified!(verified), T::Array[Symbol])
|
|
@@ -216,6 +255,7 @@ module ActiveSanction
|
|
|
216
255
|
@weights = T.let(Scorer::Weights.build(weights), Scorer::Weights)
|
|
217
256
|
@candidate_limit = T.let(candidate_limit!(candidate_limit), Integer)
|
|
218
257
|
@backend = T.let(backend.to_s.to_sym, Symbol)
|
|
258
|
+
@instrumenter = T.let(instrumenter.nil? ? ActiveSanction.config.instrumenter : instrumenter, T.untyped)
|
|
219
259
|
freeze
|
|
220
260
|
end
|
|
221
261
|
|
|
@@ -303,13 +343,31 @@ module ActiveSanction
|
|
|
303
343
|
# weights, one instant, one backend. Only the snapshot checksum varies,
|
|
304
344
|
# and only because a run may cover several lists.
|
|
305
345
|
stamp = { query: query, weights: weights, backend: backend, screened_at: screened_at }
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
346
|
+
Instrumentation.instrument(instrumenter, :screen) do |event|
|
|
347
|
+
results = scored(query, event)
|
|
348
|
+
.sort_by { |result| [-result.score, result.source.to_s, result.entity.id] }
|
|
349
|
+
.first(query.limit)
|
|
350
|
+
.map do |result|
|
|
351
|
+
MatchResult.from_scorer(result, snapshot_id: snapshots.fetch(result.source),
|
|
352
|
+
verified: verified.include?(result.source), **stamp)
|
|
353
|
+
end
|
|
354
|
+
describe(event, query, results)
|
|
355
|
+
results
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# What one query cost and what it consulted. `snapshots` is the whole
|
|
360
|
+
# checksum map rather than a count, because the question an audit asks of
|
|
361
|
+
# a screening event is which list *versions* answered it -- the same
|
|
362
|
+
# question every MatchResult is stamped with, and the only one a result
|
|
363
|
+
# set of zero cannot answer for itself.
|
|
364
|
+
sig { params(event: T.untyped, query: Query, results: T::Array[MatchResult]).void }
|
|
365
|
+
def describe(event, query, results)
|
|
366
|
+
event[:results] = results.size
|
|
367
|
+
event[:threshold] = query.threshold
|
|
368
|
+
event[:limit] = query.limit
|
|
369
|
+
event[:sources] = query.sources || sources
|
|
370
|
+
event[:snapshots] = snapshots
|
|
313
371
|
end
|
|
314
372
|
|
|
315
373
|
# Every entity the index retrieved, scored once.
|
|
@@ -321,10 +379,11 @@ module ActiveSanction
|
|
|
321
379
|
# retrieves the same record under several spellings, and rescoring one
|
|
322
380
|
# that has already failed the threshold is the most expensive way to
|
|
323
381
|
# arrive at the same no.
|
|
324
|
-
sig { params(query: Query).returns(T::Array[Scorer::Result]) }
|
|
325
|
-
def scored(query)
|
|
382
|
+
sig { params(query: Query, event: T.untyped).returns(T::Array[Scorer::Result]) }
|
|
383
|
+
def scored(query, event)
|
|
326
384
|
seen = T.let({}, T::Hash[[Symbol, String], T.nilable(Scorer::Result)])
|
|
327
|
-
index.candidates(query.form, limit: candidate_limit, sources: query.sources)
|
|
385
|
+
retrieved = index.candidates(query.form, limit: candidate_limit, sources: query.sources)
|
|
386
|
+
retrieved.each do |candidate|
|
|
328
387
|
# Keyed by list as well as id, because the same person really is two
|
|
329
388
|
# records when two governments list them, and both belong in a report.
|
|
330
389
|
key = [candidate.source, candidate.entity.id]
|
|
@@ -332,6 +391,12 @@ module ActiveSanction
|
|
|
332
391
|
|
|
333
392
|
seen[key] = Scorer.call(query.subject, candidate, weights: weights, threshold: query.threshold)
|
|
334
393
|
end
|
|
394
|
+
# Names retrieved, and the entities they came down to. The two differ by
|
|
395
|
+
# however many aliases of one record the query looked like, and a
|
|
396
|
+
# candidate cap is a cap on the first rather than the second -- which is
|
|
397
|
+
# the number to watch when tuning it.
|
|
398
|
+
event[:candidates] = retrieved.size
|
|
399
|
+
event[:scored] = seen.size
|
|
335
400
|
seen.values.compact
|
|
336
401
|
end
|
|
337
402
|
|
|
@@ -8,6 +8,7 @@ require "active_sanction/error"
|
|
|
8
8
|
require "active_sanction/entity"
|
|
9
9
|
require "active_sanction/snapshot"
|
|
10
10
|
require "active_sanction/fetcher"
|
|
11
|
+
require "active_sanction/instrumentation"
|
|
11
12
|
require "active_sanction/parsers"
|
|
12
13
|
require "active_sanction/payload_cache"
|
|
13
14
|
require "active_sanction/sources"
|
|
@@ -82,17 +83,25 @@ module ActiveSanction
|
|
|
82
83
|
sig { returns(T.untyped) }
|
|
83
84
|
attr_reader :logger
|
|
84
85
|
|
|
86
|
+
# Where this adapter's `:parse` event goes, or nil for nothing
|
|
87
|
+
# listening. See Instrumentation.
|
|
88
|
+
sig { returns(T.untyped) }
|
|
89
|
+
attr_reader :instrumenter
|
|
90
|
+
|
|
85
91
|
# `cache: nil` turns off payload caching, which costs one thing worth
|
|
86
92
|
# knowing: a multi-file source can no longer answer a sync where some of
|
|
87
93
|
# its files changed and others came back 304, so the unchanged ones are
|
|
88
94
|
# downloaded again in full.
|
|
89
95
|
sig do
|
|
90
|
-
params(fetcher: Fetcher, cache: T.nilable(PayloadCache), logger: T.untyped
|
|
96
|
+
params(fetcher: Fetcher, cache: T.nilable(PayloadCache), logger: T.untyped,
|
|
97
|
+
instrumenter: T.untyped).void
|
|
91
98
|
end
|
|
92
|
-
def initialize(fetcher: Fetcher.new, cache: PayloadCache.new, logger: ActiveSanction.config.logger
|
|
99
|
+
def initialize(fetcher: Fetcher.new, cache: PayloadCache.new, logger: ActiveSanction.config.logger,
|
|
100
|
+
instrumenter: ActiveSanction.config.instrumenter)
|
|
93
101
|
@fetcher = T.let(fetcher, Fetcher)
|
|
94
102
|
@cache = T.let(cache, T.nilable(PayloadCache))
|
|
95
103
|
@logger = T.let(logger, T.untyped)
|
|
104
|
+
@instrumenter = T.let(instrumenter, T.untyped)
|
|
96
105
|
@results = T.let({}, T::Hash[Symbol, Fetcher::Result])
|
|
97
106
|
end
|
|
98
107
|
|
|
@@ -137,6 +146,20 @@ module ActiveSanction
|
|
|
137
146
|
"#{self.class} must implement #parse(raw) and return an Array of ActiveSanction::Entity"
|
|
138
147
|
end
|
|
139
148
|
|
|
149
|
+
# What the last #parse could not read: a Parsers::Warning per row that
|
|
150
|
+
# was skipped or could not be mapped, kept rather than raised. Every
|
|
151
|
+
# shipped adapter overrides this with the parser's own warnings plus
|
|
152
|
+
# whatever it noticed itself, which is what the adapter rules require of
|
|
153
|
+
# a new one.
|
|
154
|
+
#
|
|
155
|
+
# Empty here rather than abstract, because an adapter that genuinely
|
|
156
|
+
# cannot fail to read a row should not have to say so, and because the
|
|
157
|
+
# `:parse` event counts these for every source and a count that is
|
|
158
|
+
# sometimes a NoMethodError is not a metric. See Doctor, which reads the
|
|
159
|
+
# warnings themselves rather than the count.
|
|
160
|
+
sig { returns(T::Array[Parsers::Warning]) }
|
|
161
|
+
def warnings = []
|
|
162
|
+
|
|
140
163
|
# Fetches, parses, and checksums -- or returns nil when the publisher
|
|
141
164
|
# says nothing has changed, which is the outcome to expect on most runs
|
|
142
165
|
# and the reason conditional GET exists.
|
|
@@ -157,8 +180,15 @@ module ActiveSanction
|
|
|
157
180
|
# or, for a source that declares a single file, as the bytes themselves.
|
|
158
181
|
sig { params(payloads: T.untyped, files: T.untyped).returns(Snapshot) }
|
|
159
182
|
def snapshot(payloads = nil, **files)
|
|
160
|
-
|
|
161
|
-
|
|
183
|
+
raw = parse_argument(payloads || files)
|
|
184
|
+
entities = Instrumentation.instrument(instrumenter, :parse,
|
|
185
|
+
{ source: declared_key, bytes: byte_count(raw) }) do |event|
|
|
186
|
+
parsed = parse(raw)
|
|
187
|
+
event[:records] = parsed.size
|
|
188
|
+
event[:warnings] = warnings.size
|
|
189
|
+
parsed
|
|
190
|
+
end
|
|
191
|
+
Snapshot.new(source: key, entities: entities, fetched_at: Time.now.utc, source_version: source_version)
|
|
162
192
|
rescue ActiveSanction::Error => e
|
|
163
193
|
raise e.in_source(declared_key)
|
|
164
194
|
end
|
|
@@ -250,7 +280,19 @@ module ActiveSanction
|
|
|
250
280
|
|
|
251
281
|
sig { params(name: Symbol, address: String, force: T::Boolean).returns(Fetcher::Result) }
|
|
252
282
|
def fetch_file(name, address, force)
|
|
253
|
-
fetcher.fetch(address, key: file_key(name), force: force).success!
|
|
283
|
+
fetcher.fetch(address, key: file_key(name), force: force, source: declared_key).success!
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# How many bytes #parse was handed, across every file of a multi-file
|
|
287
|
+
# source. Taken before the parse rather than after, so the `:parse`
|
|
288
|
+
# event still says how large the document was when the parse is what
|
|
289
|
+
# raised.
|
|
290
|
+
sig { params(raw: T.untyped).returns(Integer) }
|
|
291
|
+
def byte_count(raw)
|
|
292
|
+
return raw.bytesize if raw.is_a?(String)
|
|
293
|
+
return raw.to_h.each_value.sum { |payload| payload.to_s.bytesize } if raw.respond_to?(:to_h)
|
|
294
|
+
|
|
295
|
+
raw.to_s.bytesize
|
|
254
296
|
end
|
|
255
297
|
|
|
256
298
|
sig { params(name: Symbol).returns(T.untyped) }
|
|
@@ -283,7 +325,7 @@ module ActiveSanction
|
|
|
283
325
|
sig { params(name: Symbol).returns(Fetcher::Result) }
|
|
284
326
|
def refetch(name)
|
|
285
327
|
logger&.info("[active_sanction] #{key} #{name} unchanged but not cached; fetching in full")
|
|
286
|
-
result = fetcher.fetch(url(name), key: file_key(name), force: true).success!
|
|
328
|
+
result = fetcher.fetch(url(name), key: file_key(name), force: true, source: declared_key).success!
|
|
287
329
|
return result if result.changed?
|
|
288
330
|
|
|
289
331
|
raise MissingPayload,
|
data/lib/active_sanction/sync.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "sorbet-runtime"
|
|
|
5
5
|
|
|
6
6
|
require "uri"
|
|
7
7
|
require "active_sanction/error"
|
|
8
|
+
require "active_sanction/instrumentation"
|
|
8
9
|
require "active_sanction/sources"
|
|
9
10
|
require "active_sanction/storage"
|
|
10
11
|
require "active_sanction/sync/result"
|
|
@@ -149,6 +150,17 @@ module ActiveSanction
|
|
|
149
150
|
sig { returns(T.untyped) }
|
|
150
151
|
attr_reader :logger
|
|
151
152
|
|
|
153
|
+
# Where the `:sync` and `:store` events go, or nil for nothing listening.
|
|
154
|
+
#
|
|
155
|
+
# The `:fetch` and `:parse` events of the sources this run covers do not
|
|
156
|
+
# come from here: an adapter is constructed by the run and reads the
|
|
157
|
+
# configuration, exactly as it does for its logger and its User-Agent. So
|
|
158
|
+
# a host that instruments through `ActiveSanction.configure` sees all six
|
|
159
|
+
# events, and one that hands a run its own instrumenter sees the two this
|
|
160
|
+
# class emits. See Instrumentation.
|
|
161
|
+
sig { returns(T.untyped) }
|
|
162
|
+
attr_reader :instrumenter
|
|
163
|
+
|
|
152
164
|
sig { params(options: T.untyped, block: T.untyped).returns(Report) }
|
|
153
165
|
def self.call(**options, &block) = T.unsafe(self).new(**options).call(&block)
|
|
154
166
|
|
|
@@ -157,9 +169,10 @@ module ActiveSanction
|
|
|
157
169
|
# raises here, before the first list is downloaded, rather than after.
|
|
158
170
|
sig do
|
|
159
171
|
params(sources: T.untyped, store: T.untyped, force: T::Boolean, concurrency: T.untyped,
|
|
160
|
-
logger: T.untyped).void
|
|
172
|
+
logger: T.untyped, instrumenter: T.untyped).void
|
|
161
173
|
end
|
|
162
|
-
def initialize(sources: nil, store: nil, force: false, concurrency: nil, logger: ActiveSanction.config.logger
|
|
174
|
+
def initialize(sources: nil, store: nil, force: false, concurrency: nil, logger: ActiveSanction.config.logger,
|
|
175
|
+
instrumenter: ActiveSanction.config.instrumenter)
|
|
163
176
|
@sources = T.let(resolve(sources), T::Array[T.untyped])
|
|
164
177
|
@keys = T.let(@sources.map { |source| Sources::Definition.key!(source.key) }, T::Array[Symbol])
|
|
165
178
|
@store = T.let(store || ActiveSanction.storage, T.untyped)
|
|
@@ -168,6 +181,7 @@ module ActiveSanction
|
|
|
168
181
|
Configuration.sync_concurrency!(concurrency || ActiveSanction.config.sync_concurrency), Integer
|
|
169
182
|
)
|
|
170
183
|
@logger = T.let(logger, T.untyped)
|
|
184
|
+
@instrumenter = T.let(instrumenter, T.untyped)
|
|
171
185
|
@lock = T.let(Mutex.new, Mutex)
|
|
172
186
|
# The settings this run was started under, so a worker thread reads them
|
|
173
187
|
# rather than the default client's. A configuration is fiber-local and a
|
|
@@ -195,9 +209,17 @@ module ActiveSanction
|
|
|
195
209
|
started_at = Time.now.utc
|
|
196
210
|
began = monotonic
|
|
197
211
|
log_start
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
212
|
+
# The run's own duration is the Report's, taken from the same clock
|
|
213
|
+
# reading, so an event and the report a caller is holding never disagree
|
|
214
|
+
# about how long a run took.
|
|
215
|
+
report = Instrumentation.instrument(instrumenter, :sync,
|
|
216
|
+
{ sources: keys, forced: force, concurrency: concurrency }) do |event|
|
|
217
|
+
work = keys.each_with_index.map { |key, at| [at, key, sources.fetch(at)] }
|
|
218
|
+
results = run(work, &block).sort_by(&:first).map(&:last)
|
|
219
|
+
finished = Report.new(results: results, started_at: started_at, duration: elapsed(began))
|
|
220
|
+
summarize(event, finished)
|
|
221
|
+
finished
|
|
222
|
+
end
|
|
201
223
|
log_finish(report)
|
|
202
224
|
report
|
|
203
225
|
end
|
|
@@ -285,13 +307,39 @@ module ActiveSanction
|
|
|
285
307
|
snapshot = adapter.sync(force: force || previous.nil?)
|
|
286
308
|
return complete(key, :unchanged, previous, started) if unchanged?(previous, snapshot)
|
|
287
309
|
|
|
288
|
-
|
|
310
|
+
write(key, snapshot)
|
|
289
311
|
complete(key, :updated, Storage::Meta.from_snapshot(snapshot), started)
|
|
290
312
|
rescue StandardError => e
|
|
291
313
|
complete(key, :failed, previous, started, stamp(key, e))
|
|
292
314
|
end
|
|
293
315
|
end
|
|
294
316
|
|
|
317
|
+
# Writes one list, and says what it cost. Separate from the rest of
|
|
318
|
+
# #sync_source so the `:store` event times the write and nothing else --
|
|
319
|
+
# a store that takes eleven seconds to persist 19,321 entities is a
|
|
320
|
+
# different operational problem from a publisher that takes eleven seconds
|
|
321
|
+
# to serve them, and a timing that covered both could not tell a host
|
|
322
|
+
# which one it had.
|
|
323
|
+
sig { params(key: Symbol, snapshot: T.untyped).void }
|
|
324
|
+
def write(key, snapshot)
|
|
325
|
+
fields = { source: key, snapshot_id: snapshot.checksum, entities: snapshot.record_count,
|
|
326
|
+
store: store.class.name }
|
|
327
|
+
Instrumentation.instrument(instrumenter, :store, fields) { store.write_snapshot(snapshot) }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# What a run did, as counts rather than as the Report itself: a subscriber
|
|
331
|
+
# forwarding an event to a metrics backend wants numbers, and one that
|
|
332
|
+
# wants the whole report already has it as the return value of the call
|
|
333
|
+
# that emitted this.
|
|
334
|
+
sig { params(event: T.untyped, report: Report).void }
|
|
335
|
+
def summarize(event, report)
|
|
336
|
+
event[:outcomes] = report.results.to_h { |result| [result.source, result.status] }
|
|
337
|
+
event[:updated] = report.updated.size
|
|
338
|
+
event[:unchanged] = report.unchanged.size
|
|
339
|
+
event[:failed] = report.failed.size
|
|
340
|
+
event[:records] = report.record_count
|
|
341
|
+
end
|
|
342
|
+
|
|
295
343
|
# A failure captured for a source names that source, even when it was
|
|
296
344
|
# raised somewhere that could not know -- a store that will not open, an
|
|
297
345
|
# adapter constructor. Only ever fills a blank; see Error#in_source.
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# typed: ignore
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The contract every source adapter must satisfy, written once.
|
|
5
|
+
#
|
|
6
|
+
# Loaded by `require "active_sanction/testing"` -- see ActiveSanction::Testing,
|
|
7
|
+
# which is where the entry point and the fixture root are documented.
|
|
8
|
+
#
|
|
9
|
+
# RSpec.describe ActiveSanction::Sources::CanadaSema do
|
|
10
|
+
# it_behaves_like "a sanction source", fixture: "canada_sema/sema.xml"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# A list its publisher splits across several files names each one, under the
|
|
14
|
+
# keys the adapter declared its URLs with:
|
|
15
|
+
#
|
|
16
|
+
# it_behaves_like "a sanction source",
|
|
17
|
+
# fixture: { sdn: "ofac_sdn/SDN.CSV", alt: "ofac_sdn/ALT.CSV", add: "ofac_sdn/ADD.CSV" }
|
|
18
|
+
#
|
|
19
|
+
# Paths are relative to `spec/fixtures` -- or to whatever
|
|
20
|
+
# `ActiveSanction::Testing.fixture_root` names, and an absolute path is taken
|
|
21
|
+
# as it stands. The bytes reach #parse exactly as they were committed --
|
|
22
|
+
# undecoded, so an adapter reading the Windows-1252 OFAC serves is held to
|
|
23
|
+
# doing its own decoding rather than to being handed a String somebody already
|
|
24
|
+
# fixed up.
|
|
25
|
+
#
|
|
26
|
+
# ### What this is for
|
|
27
|
+
#
|
|
28
|
+
# Everything downstream of an adapter -- storage, the diff between two syncs,
|
|
29
|
+
# the index, the matcher, the report an examiner reads -- is written against
|
|
30
|
+
# Entity and never against a list. That only holds if every adapter really
|
|
31
|
+
# does produce the same shape, and "the same shape" is otherwise a paragraph
|
|
32
|
+
# in a design document that each new adapter re-interprets. Here it is
|
|
33
|
+
# executable, so adding a jurisdiction is a checklist rather than an open
|
|
34
|
+
# research question.
|
|
35
|
+
#
|
|
36
|
+
# ### What it does not do
|
|
37
|
+
#
|
|
38
|
+
# It does not check that the adapter read its list *correctly*. Nothing here
|
|
39
|
+
# knows that OFAC writes `-0-` for null, that the UN means two different
|
|
40
|
+
# things by QUALITY, or which of the fixture's records is a vessel. Only a
|
|
41
|
+
# spec that knows what is in the fixture can check that, so every adapter
|
|
42
|
+
# still writes its own; this group is the floor, not the ceiling.
|
|
43
|
+
#
|
|
44
|
+
# ### Options
|
|
45
|
+
#
|
|
46
|
+
# fixture: required. A path under the fixture root, or a Hash of one path
|
|
47
|
+
# per declared URL. Real published records, not invented ones --
|
|
48
|
+
# a conformance run against a fixture somebody wrote to pass it
|
|
49
|
+
# proves nothing about the list.
|
|
50
|
+
#
|
|
51
|
+
# remarks: pass `remarks: false` for a list that publishes no free text of
|
|
52
|
+
# its own at all, so the check that the publisher's own words
|
|
53
|
+
# survive into `remarks` is skipped. Every list at launch does
|
|
54
|
+
# publish some, which is why the default is to require it: a
|
|
55
|
+
# remark quietly dropped is how a place of birth or a passport
|
|
56
|
+
# number stops reaching #19.
|
|
57
|
+
RSpec.shared_examples "a sanction source" do |options = {}|
|
|
58
|
+
publishes_remarks = options.fetch(:remarks, true)
|
|
59
|
+
|
|
60
|
+
# Read through a `let` rather than closed over directly, so that a group
|
|
61
|
+
# that forgot to name a fixture says so when an example asks for one rather
|
|
62
|
+
# than while the suite is still loading.
|
|
63
|
+
let(:fixture) do
|
|
64
|
+
options.fetch(:fixture) do
|
|
65
|
+
raise ArgumentError, %(pass the fixture to parse: it_behaves_like "a sanction source", fixture: "list.xml")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
let(:source) { described_class.new }
|
|
70
|
+
let(:payload) { fixture_payload(fixture) { |bytes| bytes } }
|
|
71
|
+
let(:records) { source.parse(payload) }
|
|
72
|
+
|
|
73
|
+
# Fixture bytes in the shape this adapter's #parse takes: the bytes
|
|
74
|
+
# themselves for a source declaring one file, a Hash keyed by declaration
|
|
75
|
+
# name for one declaring several. The same rule Base applies before calling
|
|
76
|
+
# #parse, so an adapter is exercised here exactly as `sync` exercises it.
|
|
77
|
+
def fixture_payload(paths)
|
|
78
|
+
bytes = named_fixtures(paths).to_h { |name, path| [name, yield(File.binread(fixture_path(path)))] }
|
|
79
|
+
described_class.multi_url? ? bytes : bytes.values.first
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# One fixture path per declared file. A source declaring one file may name it
|
|
83
|
+
# as a bare path, which is what reading a fixture off disk looks like.
|
|
84
|
+
def named_fixtures(paths)
|
|
85
|
+
files = paths.is_a?(Hash) ? paths : { described_class.urls.keys.first => paths }
|
|
86
|
+
missing = described_class.urls.keys - files.keys
|
|
87
|
+
raise ArgumentError, "no fixture given for the #{missing.join(", ")} file(s) this source declares" if missing.any?
|
|
88
|
+
|
|
89
|
+
files
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Resolved through Testing rather than relative to this file, which is what
|
|
93
|
+
# lets the group work for a project whose fixtures are its own. See
|
|
94
|
+
# ActiveSanction::Testing.fixture_root.
|
|
95
|
+
def fixture_path(path) = ActiveSanction::Testing.fixture_path(path)
|
|
96
|
+
|
|
97
|
+
# Every date any entity carries, wherever the model puts them.
|
|
98
|
+
def entity_dates(entities)
|
|
99
|
+
entities.flat_map do |entity|
|
|
100
|
+
[entity.listed_on, *entity.dates_of_birth,
|
|
101
|
+
*entity.identifiers.flat_map { |identifier| [identifier.issued_on, identifier.expires_on] }]
|
|
102
|
+
end.compact
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The serialized records half the bytes produce, or the marker that reading
|
|
106
|
+
# them raised -- which is the other acceptable answer, and never equal to a
|
|
107
|
+
# complete parse.
|
|
108
|
+
def parsed_truncated(paths)
|
|
109
|
+
truncated = fixture_payload(paths) { |bytes| bytes[0, bytes.bytesize / 2] }
|
|
110
|
+
described_class.new.parse(truncated).map(&:to_h)
|
|
111
|
+
rescue ActiveSanction::Error
|
|
112
|
+
:raised
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe "what it declares" do
|
|
116
|
+
it "declares a key, which is the name the list answers to everywhere" do
|
|
117
|
+
expect(described_class.key.to_s).to match(ActiveSanction::Sources::Definition::KEY_PATTERN)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "declares the jurisdiction behind the list" do
|
|
121
|
+
expect(described_class.jurisdiction).to be_a(Symbol)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "declares the authority a compliance report has to print beside a hit" do
|
|
125
|
+
expect(described_class.authority).to be_a(String)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "declares at least one URL to fetch the list from" do
|
|
129
|
+
expect(described_class.urls).not_to be_empty
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# An adapter whose file is required but which never registers is invisible:
|
|
133
|
+
# `config.sources` cannot name it and `sync` will not run it, and nothing
|
|
134
|
+
# says so out loud.
|
|
135
|
+
it "registers itself, so requiring its file is enough to reach it" do
|
|
136
|
+
expect(ActiveSanction::Sources[described_class.key]).to eq(described_class)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
describe "what #parse returns" do
|
|
141
|
+
it "builds at least one record from the fixture" do
|
|
142
|
+
expect(records).not_to be_empty
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "returns Entities and nothing else" do
|
|
146
|
+
expect(records).to all(be_an(ActiveSanction::Entity))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "stamps every entity with this adapter's own key" do
|
|
150
|
+
expect(records.map(&:source).uniq).to eq([described_class.key])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "gives every entity an id" do
|
|
154
|
+
expect(records.map(&:id)).to all(match(/\S/))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A record with no name cannot be screened against, so it is not a record.
|
|
158
|
+
it "gives every entity at least one name" do
|
|
159
|
+
expect(records.reject { |record| record.names.any? }).to be_empty
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Types are the matcher's coarsest filter: a search for a person that can
|
|
163
|
+
# rank a ship is the failure this closes. Entity refuses anything else on
|
|
164
|
+
# construction, so this fails only for something that is not an Entity.
|
|
165
|
+
it "types every entity as one of the four canonical types" do
|
|
166
|
+
expect(records.map(&:type).uniq - ActiveSanction::Entity::TYPES).to be_empty
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Two records under one id are one record to storage, and the second
|
|
170
|
+
# silently replaces the first.
|
|
171
|
+
it "gives no two entities the same id" do
|
|
172
|
+
expect(records.map(&:id).tally.select { |_id, count| count > 1 }).to be_empty
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# #35 diffs two syncs by comparing records under their ids. An id that moves
|
|
177
|
+
# because a Hash iterated differently, or because a counter was involved,
|
|
178
|
+
# reports the whole list as removed and re-added -- and a diff that says
|
|
179
|
+
# everything changed says nothing at all.
|
|
180
|
+
describe "reading the same bytes twice" do
|
|
181
|
+
it "produces the same ids" do
|
|
182
|
+
expect(described_class.new.parse(payload).map(&:id)).to eq(records.map(&:id))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "produces the same records, in the same order" do
|
|
186
|
+
expect(described_class.new.parse(payload).map(&:to_h)).to eq(records.map(&:to_h))
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
describe "the canonical model it produces" do
|
|
191
|
+
# A date left as the publisher's string cannot be compared with one from
|
|
192
|
+
# another list, and PartialDate exists because none of these lists agree on
|
|
193
|
+
# how precise a date of birth is. Entity does not coerce, so an adapter
|
|
194
|
+
# that forgets is caught here rather than in the scorer.
|
|
195
|
+
it "publishes every date as a PartialDate, never as the string it was written as" do
|
|
196
|
+
expect(entity_dates(records)).to all(be_a(ActiveSanction::PartialDate))
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# What storage (#24) does to every record on the way to disk and back.
|
|
200
|
+
it "round-trips every entity through the serialized form" do
|
|
201
|
+
expect(records.map { |record| ActiveSanction::Entity.from_h(record.to_h) }).to eq(records)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
if publishes_remarks
|
|
205
|
+
# The publisher's own prose is where the fields the canonical model has
|
|
206
|
+
# no home for still live -- OFAC's dates of birth and passport numbers
|
|
207
|
+
# are nowhere else -- so an adapter that drops it loses data no later
|
|
208
|
+
# issue can get back.
|
|
209
|
+
it "keeps the publisher's own text in remarks" do
|
|
210
|
+
expect(records.filter_map { |record| ActiveSanction::Sources::Remarks.published(record.remarks) })
|
|
211
|
+
.not_to be_empty
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
describe "a payload that is not the list" do
|
|
217
|
+
# No sanctions list has ever been empty, so an empty payload is a failed
|
|
218
|
+
# download, a moved URL or an outage -- never a day on which nobody is
|
|
219
|
+
# sanctioned. Returning [] lets a sync succeed at screening against
|
|
220
|
+
# nothing, which is the most expensive way this library can fail.
|
|
221
|
+
it "refuses an empty payload rather than reporting a list with nobody on it" do
|
|
222
|
+
expect { described_class.new.parse(fixture_payload(fixture) { "" }) }
|
|
223
|
+
.to raise_error(ActiveSanction::Error)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A truncated download may still be worth salvaging -- the XML toolkit
|
|
227
|
+
# keeps the records it read before the break and warns -- so what is
|
|
228
|
+
# required is not that it raises, only that half a list never comes back
|
|
229
|
+
# looking exactly like the whole one.
|
|
230
|
+
it "does not report a truncated document as though it were the whole list" do
|
|
231
|
+
expect(parsed_truncated(fixture)).not_to eq(records.map(&:to_h))
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|