active_sanction 1.0.0 → 1.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +120 -0
- data/CONTRIBUTING.md +69 -5
- data/README.md +1 -0
- data/docs/api_stability.md +47 -2
- data/lib/active_sanction/client.rb +11 -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/storage/meta.rb +18 -0
- data/lib/active_sanction/sync.rb +54 -6
- data/lib/active_sanction/version.rb +1 -1
- data/lib/active_sanction.rb +1 -0
- metadata +5 -2
|
@@ -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,
|
|
@@ -110,6 +110,24 @@ module ActiveSanction
|
|
|
110
110
|
# per source: a list that failed to refresh keeps its previous snapshot,
|
|
111
111
|
# which is the right call and only safe if the age of what is being
|
|
112
112
|
# screened against is visible.
|
|
113
|
+
#
|
|
114
|
+
# **Accurate to a second, and never fewer than the seconds that have
|
|
115
|
+
# passed.** `fetched_at` is stored to the second -- Snapshot#time!
|
|
116
|
+
# truncates it, so that a stored snapshot reloads equal to the one that
|
|
117
|
+
# was written -- so the instant of the fetch is known only to lie
|
|
118
|
+
# somewhere inside the second this names. What comes back is therefore
|
|
119
|
+
# the largest age consistent with what was recorded, which is the
|
|
120
|
+
# direction a staleness measure has to err in: an answer that is at most
|
|
121
|
+
# a second pessimistic is a report that nobody acts on, and one that is
|
|
122
|
+
# optimistic is a list being screened against that is older than it
|
|
123
|
+
# claims.
|
|
124
|
+
#
|
|
125
|
+
# The practical consequence, and the reason it is written down: a list
|
|
126
|
+
# fetched microseconds ago reports 0 or 1, according to whether the run
|
|
127
|
+
# crossed a second boundary on its way here. Both mean "just fetched" --
|
|
128
|
+
# see Sync::Result#age_in_words, which is what a summary table shows --
|
|
129
|
+
# and nothing should assert on the exact number of a fresh sync, because
|
|
130
|
+
# that is a fact about the clock rather than about this library.
|
|
113
131
|
sig { params(now: Time).returns(Integer) }
|
|
114
132
|
def age(now = Time.now) = now.to_i - fetched_at.to_i
|
|
115
133
|
|
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.
|
|
@@ -6,7 +6,7 @@ module ActiveSanction
|
|
|
6
6
|
# new source adapter, a storage fix or a documentation release -- none of
|
|
7
7
|
# which change what a name scores. MATCHER_VERSION, below, is the one that
|
|
8
8
|
# answers that question.
|
|
9
|
-
VERSION = "1.
|
|
9
|
+
VERSION = "1.1.0"
|
|
10
10
|
|
|
11
11
|
# Which matching pipeline scored a decision, stamped onto every MatchResult
|
|
12
12
|
# and bumped whenever a change to the normalizer, the index, the similarity
|
data/lib/active_sanction.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "sorbet-runtime"
|
|
|
6
6
|
require "active_sanction/error"
|
|
7
7
|
require "active_sanction/version"
|
|
8
8
|
require "active_sanction/deprecation"
|
|
9
|
+
require "active_sanction/instrumentation"
|
|
9
10
|
require "active_sanction/configuration"
|
|
10
11
|
require "active_sanction/name"
|
|
11
12
|
require "active_sanction/address"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_sanction
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marshall Shen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: csv
|
|
@@ -102,6 +102,9 @@ files:
|
|
|
102
102
|
- lib/active_sanction/index/candidate.rb
|
|
103
103
|
- lib/active_sanction/index/entry.rb
|
|
104
104
|
- lib/active_sanction/index/features.rb
|
|
105
|
+
- lib/active_sanction/instrumentation.rb
|
|
106
|
+
- lib/active_sanction/instrumentation/event.rb
|
|
107
|
+
- lib/active_sanction/instrumentation/notifications.rb
|
|
105
108
|
- lib/active_sanction/match_result.rb
|
|
106
109
|
- lib/active_sanction/matcher.rb
|
|
107
110
|
- lib/active_sanction/name.rb
|