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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecdae0214290d15cba0bf24838c8e4d2d2e004eaecb344c29aaa8c07d54da36b
|
|
4
|
+
data.tar.gz: c72d3f5baa535d081aae672de2013585e38c85c1cceb5c2a697d6cecc7d5f98a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5fc3e4e17279f0220f903792d952d4aa1adfa1068504512f51cbacb2247cc4ae71710b38c4de6374810e0c48bcc03052a7149eff9a69dda9ebc02389e27c8f05
|
|
7
|
+
data.tar.gz: 34df5f7d1103073a4bd50956abc77e1e87a9a3c5a213be9c18a46a6e41ccbdd5db64fcf0c7b7437d4c52e33f6876ea581ba57e04a44baa95d3ecec1666a8e315
|
data/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,113 @@ screening decision would come out as today.
|
|
|
17
17
|
|
|
18
18
|
Nothing yet.
|
|
19
19
|
|
|
20
|
+
## [1.1.1] - 2026-09-14
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **The conformance groups ship** (#144). `require "active_sanction/testing"` loads the two
|
|
25
|
+
shared example groups that define what a source adapter and a storage adapter must do, so
|
|
26
|
+
an adapter written in your own application is held to the same contract the built-in ones
|
|
27
|
+
are:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# spec/spec_helper.rb
|
|
31
|
+
require "active_sanction/testing"
|
|
32
|
+
|
|
33
|
+
# spec/internal_watchlist_spec.rb
|
|
34
|
+
RSpec.describe MyCompany::InternalWatchlist do
|
|
35
|
+
it_behaves_like "a sanction source", fixture: "internal_watchlist/list.csv"
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
They were always the stated contract — `docs/api_stability.md` calls them "the executable
|
|
40
|
+
statement of what [the extension points] require" — and they lived under `spec/`, which is
|
|
41
|
+
excluded from the packaged gem. The audience for the promise was the one group of people
|
|
42
|
+
who could not run it. Fixture paths resolve under `spec/fixtures` unless
|
|
43
|
+
`ActiveSanction::Testing.fixture_root` says otherwise, and an absolute path is taken as it
|
|
44
|
+
stands.
|
|
45
|
+
|
|
46
|
+
**`require "active_sanction"` does not load any of it**, so nothing reaches a production
|
|
47
|
+
process; requiring it without RSpec says so rather than failing somewhere stranger. The
|
|
48
|
+
group names and the options they take are public API from here, and a group gaining an
|
|
49
|
+
example is a minor with a note here naming it.
|
|
50
|
+
|
|
51
|
+
- **Preparing a release is a button** (#146). `Prepare release` in the Actions tab takes a
|
|
52
|
+
version, bumps `VERSION`, moves the accrued `Unreleased` section under a dated heading,
|
|
53
|
+
fixes the changelog's link definitions, regenerates the site data that carries the
|
|
54
|
+
version, and opens a pull request. `release.yml` verifies all of that and writes none of
|
|
55
|
+
it, so until now it was a hand-edit somebody had to remember — and hand-edits to this
|
|
56
|
+
particular shape have already gone wrong silently once, when two open pull requests
|
|
57
|
+
merged cleanly in sequence and filed a feature under a patch release that did not contain
|
|
58
|
+
it.
|
|
59
|
+
|
|
60
|
+
The edits are [`bin/prepare_release`](bin/prepare_release), with a spec over them, so the
|
|
61
|
+
workflow is reviewable as Ruby and `bin/prepare_release 1.2.3` does the same thing on a
|
|
62
|
+
laptop. It refuses a version that does not come after the current one, and refuses to
|
|
63
|
+
prepare a release out of an empty `Unreleased` — which `release.yml` also refuses, but
|
|
64
|
+
only after the gem is published. Nothing is tagged or pushed to `main`: the output is a
|
|
65
|
+
pull request, and the version number stays a judgment a person makes.
|
|
66
|
+
|
|
67
|
+
- **`Client#supports?`** (#144), so a caller can ask what an implementation does rather than
|
|
68
|
+
find out by rescuing `NoMethodError`. This client supports all of
|
|
69
|
+
`Client::CAPABILITIES`; the method is for the implementations that are not this class —
|
|
70
|
+
one answering screening questions against data somebody else keeps fresh has no `sync!`
|
|
71
|
+
to offer. An unrecognised capability is `false` rather than an error, deliberately unlike
|
|
72
|
+
`Configuration`: the caller asking is usually written against a newer version than the one
|
|
73
|
+
answering, and wants a fallback path rather than an exception.
|
|
74
|
+
|
|
75
|
+
## [1.1.0] - 2026-09-14
|
|
76
|
+
|
|
77
|
+
**A minor, because it adds.** Nothing that existed changed: no behaviour under `lib/`
|
|
78
|
+
moved, `MATCHER_VERSION` stays at `1`, and a name scores today exactly what it scored
|
|
79
|
+
under 1.0.0. Instrumentation is additive and off unless a host asks for it.
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
|
|
83
|
+
- **Instrumentation: six structured events, so a host can measure this library without
|
|
84
|
+
monkeypatching it** (#59). `ActiveSanction.configure { |c| c.instrumenter = ... }` takes
|
|
85
|
+
anything answering `#call(event)` and is handed a finished
|
|
86
|
+
`ActiveSanction::Instrumentation::Event` for each of `:fetch`, `:parse`, `:store`,
|
|
87
|
+
`:"index.build"`, `:screen` and `:sync` — every one carrying a duration and the ids
|
|
88
|
+
needed to correlate it, with no anonymous timings. The event names and every payload key
|
|
89
|
+
are public API, enumerated in [`docs/api_stability.md`](docs/api_stability.md) and on the
|
|
90
|
+
site's [instrumentation reference](https://babystep.tech/active_sanction/reference/instrumentation/),
|
|
91
|
+
and covered by the deprecation path from here.
|
|
92
|
+
|
|
93
|
+
**It is a measurement of the work and never part of it.** A subscriber that raises has its
|
|
94
|
+
exception caught, reported once through the configured logger, and dropped; the sync it
|
|
95
|
+
was watching finishes and returns the report it was going to return. The converse holds
|
|
96
|
+
too: a stage that raises emits its event with `error:` set and then the exception
|
|
97
|
+
continues exactly as if nothing were listening.
|
|
98
|
+
|
|
99
|
+
**Nothing is listening by default, and that costs nothing.** `nil` is a branch taken
|
|
100
|
+
before anything is allocated rather than a no-op object that gets called, so an
|
|
101
|
+
uninstrumented screening call builds no event and allocates no payload — which is the
|
|
102
|
+
only way a per-query event could be affordable at all. Measured rather than asserted:
|
|
103
|
+
building a matcher over 47,051 names allocates 262 more objects than before, out of
|
|
104
|
+
5.79 million, and `rake benchmark:latency` reports the same p50 either side of the change
|
|
105
|
+
(12.1–12.8 ms against a run-to-run spread that was already that wide).
|
|
106
|
+
|
|
107
|
+
Rails hosts get `ActiveSanction::Instrumentation::Notifications`, which republishes every
|
|
108
|
+
event into `ActiveSupport::Notifications` under `<event>.active_sanction`. It is an
|
|
109
|
+
adapter and not a dependency: **nothing in this gem requires ActiveSupport**, and building
|
|
110
|
+
one in a process that has not loaded it raises `ConfigurationError` rather than quietly
|
|
111
|
+
instrumenting nothing.
|
|
112
|
+
|
|
113
|
+
The issue asked for `ActiveSanction.instrumenter = ...`, and this is a configuration
|
|
114
|
+
setting instead. #55 ended process-global configuration deliberately, and a module-level
|
|
115
|
+
writer would have rebuilt the default client — dropping the matcher it had indexed every
|
|
116
|
+
stored list into — as a side effect of naming a subscriber. A `Matcher` takes its
|
|
117
|
+
instrumenter at build and freezes it with its weights, so a subscriber swapped halfway
|
|
118
|
+
through a batch cannot make half of it instrumented; that is the rule every other setting
|
|
119
|
+
on the query path already follows.
|
|
120
|
+
|
|
121
|
+
- **`Sources::Base#warnings`**, defaulting to none. Every shipped adapter already exposed
|
|
122
|
+
it and the adapter rules already required it of a new one, but the base class never said
|
|
123
|
+
so — and the `:parse` event counts warnings for every source, which a count that is
|
|
124
|
+
sometimes a `NoMethodError` cannot do. An optional hook with a default implementation, so
|
|
125
|
+
no adapter outside this repository has to change.
|
|
126
|
+
|
|
20
127
|
## [1.0.1] - 2026-09-14
|
|
21
128
|
|
|
22
129
|
A packaging and release-tooling release. **Nothing about screening changes**: no behaviour
|
|
@@ -600,6 +707,8 @@ summarized here because they are what a reader of a first release most needs:
|
|
|
600
707
|
- Recall at the default threshold is 0.939 overall on the labeled set, and every record this
|
|
601
708
|
version misses is named in the committed accuracy report.
|
|
602
709
|
|
|
603
|
-
[Unreleased]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.
|
|
710
|
+
[Unreleased]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.1...main
|
|
711
|
+
[1.1.1]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.0...v1.1.1
|
|
712
|
+
[1.1.0]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.1...v1.1.0
|
|
604
713
|
[1.0.1]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.0...v1.0.1
|
|
605
714
|
[1.0.0]: https://github.com/Babystep-Technologies/active_sanction/releases/tag/v1.0.0
|
data/CONTRIBUTING.md
CHANGED
|
@@ -234,10 +234,27 @@ comment restating the name would be noise.
|
|
|
234
234
|
|
|
235
235
|
`bundle exec rake install` installs the gem locally.
|
|
236
236
|
|
|
237
|
-
A release is a tag
|
|
238
|
-
|
|
239
|
-
`
|
|
240
|
-
|
|
237
|
+
A release is a tag, and two facts have to be true on `main` before one can
|
|
238
|
+
exist: `VERSION` says the version, and `CHANGELOG.md` has a section for it.
|
|
239
|
+
`release.yml` checks both and writes neither.
|
|
240
|
+
|
|
241
|
+
**Putting them there is a button too.** Open
|
|
242
|
+
[`Prepare release`](https://github.com/Babystep-Technologies/active_sanction/actions/workflows/prepare-release.yml),
|
|
243
|
+
press **Run workflow**, enter `1.2.3`. It bumps `VERSION`, moves the accrued
|
|
244
|
+
`Unreleased` section under a dated `## [1.2.3]` heading, leaves a fresh empty
|
|
245
|
+
`Unreleased` behind, fixes the link definitions at the foot of the changelog,
|
|
246
|
+
regenerates the one site data file that carries the version, and opens a pull
|
|
247
|
+
request. CI runs on it like any other.
|
|
248
|
+
|
|
249
|
+
The edits are [`bin/prepare_release`](bin/prepare_release), so
|
|
250
|
+
`bin/prepare_release 1.2.3` does exactly the same thing on your machine if you
|
|
251
|
+
would rather commit it yourself. It refuses a version that does not come after
|
|
252
|
+
the current one, and refuses to prepare a release out of an empty `Unreleased`
|
|
253
|
+
— which `release.yml` also refuses, but only in the job that writes the GitHub
|
|
254
|
+
release, long after the gem is published.
|
|
255
|
+
|
|
256
|
+
**Read the moved changelog section before merging.** It becomes the release
|
|
257
|
+
note verbatim.
|
|
241
258
|
|
|
242
259
|
Then the tag, which you can let the workflow write or write yourself.
|
|
243
260
|
|
data/README.md
CHANGED
|
@@ -288,7 +288,7 @@ A source registered from outside this gem is a first-class source: a bank's inte
|
|
|
288
288
|
|
|
289
289
|
There is no scaffold generator, deliberately. Roughly eight adapters at maturity do not repay one that has to be kept in step with `Sources::Base`, the conformance spec and the parser toolkits, and that goes stale silently when it is not; the document plus the closest existing adapter to copy does the same job with none of the upkeep.
|
|
290
290
|
|
|
291
|
-
Every source adapter is held to one shared example group, `"a sanction source"`
|
|
291
|
+
Every source adapter is held to one shared example group, `"a sanction source"` — shipped, so an adapter written outside this repository is held to it too: `require "active_sanction/testing"`. It checks what everything downstream of an adapter assumes and cannot check for itself — a declared key, jurisdiction, authority and URL; `Entity` objects with unique, deterministic ids; dates as `PartialDate`; a round trip through `#to_h`; the publisher's own text kept in `remarks`. It is the floor and not the ceiling, so every adapter still writes its own spec on top.
|
|
292
292
|
|
|
293
293
|
## How it works
|
|
294
294
|
|
data/docs/adding_a_source.md
CHANGED
|
@@ -691,7 +691,7 @@ There is one option: `remarks: false`, for a list that publishes no free text of
|
|
|
691
691
|
its own anywhere — Canada is the only launch source that qualifies. It has to be
|
|
692
692
|
asked for, so that dropping a remark by accident stays a failure.
|
|
693
693
|
|
|
694
|
-
The group lives in `
|
|
694
|
+
The group lives in `lib/active_sanction/testing/sanction_source.rb`, reaches a suite through `require "active_sanction/testing"`, and checks
|
|
695
695
|
what everything downstream assumes and cannot check for itself: that the adapter
|
|
696
696
|
declares a key, a jurisdiction, an authority and a URL and registers itself; that
|
|
697
697
|
`#parse` returns Entities with unique, deterministic ids, a canonical type and at
|
data/docs/api_stability.md
CHANGED
|
@@ -4,7 +4,7 @@ What this gem promises not to break, and what it reserves the right to change
|
|
|
4
4
|
in any release.
|
|
5
5
|
|
|
6
6
|
**The public surface is enumerated below, not inferred.** A constant being
|
|
7
|
-
reachable does not make it public;
|
|
7
|
+
reachable does not make it public; over 500 of them are reachable and 142 are
|
|
8
8
|
promised. Everything else is marked `@api private` in the source, is hidden
|
|
9
9
|
from the rendered documentation, and may be renamed, moved or deleted in a
|
|
10
10
|
patch release without a note anywhere. If you need something that is not on
|
|
@@ -96,10 +96,11 @@ watchlist, a store backed by somebody's own database — and those authors are
|
|
|
96
96
|
not reading this repository's release notes. So the required methods of each,
|
|
97
97
|
their arguments and what they must return do not change within a major version,
|
|
98
98
|
and the shared conformance groups
|
|
99
|
-
([`
|
|
100
|
-
[`
|
|
99
|
+
([`lib/active_sanction/testing/sanction_source.rb`](../lib/active_sanction/testing/sanction_source.rb),
|
|
100
|
+
[`lib/active_sanction/testing/storage_adapter.rb`](../lib/active_sanction/testing/storage_adapter.rb))
|
|
101
101
|
are the executable statement of what they require. An adapter that passes them
|
|
102
|
-
today passes them for the life of the major version
|
|
102
|
+
today passes them for the life of the major version — and **they ship**, so an
|
|
103
|
+
adapter outside this repository can run them: `require "active_sanction/testing"`.
|
|
103
104
|
|
|
104
105
|
New *optional* hooks may be added — a method with a default implementation on
|
|
105
106
|
the base class is not a break, because an adapter that does not define it goes
|
|
@@ -107,7 +108,7 @@ on working.
|
|
|
107
108
|
|
|
108
109
|
## Contracts that are not constants
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
Five promises here are about behaviour rather than about a name, and none of
|
|
111
112
|
them is enforceable by the surface spec.
|
|
112
113
|
|
|
113
114
|
**The error hierarchy.** Within a major version an error does not move to a
|
|
@@ -128,6 +129,35 @@ anyone may produce or consume one, in any language.
|
|
|
128
129
|
store written against one schema version keeps being readable; the file layout
|
|
129
130
|
underneath a shipped store is not public and may change.
|
|
130
131
|
|
|
132
|
+
**The instrumentation events.** `ActiveSanction::Instrumentation::EVENTS`
|
|
133
|
+
names the six, and each one's payload keys are promised the same way a method
|
|
134
|
+
signature is: within a major version a key is not removed, renamed, or made to
|
|
135
|
+
mean something else, and a dashboard written against one keeps working. Keys
|
|
136
|
+
may be **added** to an event — that is how a new measurement ships without a
|
|
137
|
+
major version — so a subscriber reads the keys it knows and ignores the rest,
|
|
138
|
+
and must not assume the set is closed.
|
|
139
|
+
|
|
140
|
+
Every event carries `name`, `started_at` and `duration`, plus the keys below.
|
|
141
|
+
`error` is present only when the stage raised, in which case the keys it had
|
|
142
|
+
not reached yet are **absent rather than zero**.
|
|
143
|
+
|
|
144
|
+
| Event | Payload keys |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `:fetch` | `source`, `key`, `url`, `forced`, `conditional`, `status`, `not_modified`, `bytes` |
|
|
147
|
+
| `:parse` | `source`, `bytes`, `records`, `warnings` |
|
|
148
|
+
| `:store` | `source`, `snapshot_id`, `entities`, `store`, `imported` (on an import only) |
|
|
149
|
+
| `:"index.build"` | `store`, `sources`, `snapshots`, `entities`, `names`, `keys`, `postings`, `bytes` |
|
|
150
|
+
| `:screen` | `candidates`, `scored`, `results`, `threshold`, `limit`, `sources`, `snapshots` |
|
|
151
|
+
| `:sync` | `sources`, `forced`, `concurrency`, `outcomes`, `updated`, `unchanged`, `failed`, `records` |
|
|
152
|
+
|
|
153
|
+
Two things about them are contracts rather than incidental. **A `:fetch` event
|
|
154
|
+
is one HTTP round trip** — a file served out of the payload cache after a 304
|
|
155
|
+
costs no request and emits nothing, and a source re-fetching one because its
|
|
156
|
+
cache was empty emits a second event rather than amending the first. And
|
|
157
|
+
**`bytes` on `:"index.build"` is an estimate**, documented as one on
|
|
158
|
+
`Index#profile`; it is good to within a factor a dashboard cares about and is
|
|
159
|
+
not a heap measurement.
|
|
160
|
+
|
|
131
161
|
**A `MatchResult` is reproducible.** The snapshot checksum, matcher version,
|
|
132
162
|
weights and query it stamps are what let a screening decision be re-derived
|
|
133
163
|
years later. Fields may be added to that stamp; the meaning of an existing one
|
|
@@ -138,6 +168,12 @@ does not change under it.
|
|
|
138
168
|
Named here because their absence from the list is a decision rather than an
|
|
139
169
|
oversight:
|
|
140
170
|
|
|
171
|
+
- **The instrumenter contract's other half.** A subscriber is anything
|
|
172
|
+
answering `#call(event)`, and that is public. `Instrumentation.instrument`
|
|
173
|
+
and `.emit`, which the library's own stages call, are not: where an event is
|
|
174
|
+
emitted from is an implementation detail of the stage, and a host that
|
|
175
|
+
wanted to emit one of these names itself would be publishing a measurement
|
|
176
|
+
of something this library did not do.
|
|
141
177
|
- **The matching internals** — `Index`, `Similarity`, `Phonetics`, and the
|
|
142
178
|
scorer's `Adjustments` and `NameScore`. These are where accuracy work
|
|
143
179
|
happens, and accuracy work that had to preserve a signature would stop.
|
|
@@ -158,8 +194,10 @@ oversight:
|
|
|
158
194
|
themselves are public — they are how an adapter is written — but the classes
|
|
159
195
|
that do the reading underneath them are not.
|
|
160
196
|
- **Anything under `spec/`, `benchmark/`, `canary/` or `bin/`.** None of it
|
|
161
|
-
ships in the gem. The
|
|
162
|
-
|
|
197
|
+
ships in the gem. The two conformance groups used to be the exception in
|
|
198
|
+
spirit — expected to be run by adapter authors who had no way to get them —
|
|
199
|
+
and they are now under `lib/active_sanction/testing/` and public, which is
|
|
200
|
+
what the section above is about.
|
|
163
201
|
|
|
164
202
|
## The enumerated public surface
|
|
165
203
|
|
|
@@ -172,6 +210,7 @@ ActiveSanction
|
|
|
172
210
|
ActiveSanction::VERSION
|
|
173
211
|
ActiveSanction::MATCHER_VERSION
|
|
174
212
|
ActiveSanction::Client
|
|
213
|
+
ActiveSanction::Client::CAPABILITIES
|
|
175
214
|
ActiveSanction::Configuration
|
|
176
215
|
ActiveSanction::Deprecation
|
|
177
216
|
ActiveSanction::Configuration::DEFAULT_CACHE_DIRNAME
|
|
@@ -193,6 +232,16 @@ ActiveSanction::Configuration::DEFAULT_USER_AGENT
|
|
|
193
232
|
ActiveSanction::Configuration::DEFAULT_XML_BACKEND
|
|
194
233
|
```
|
|
195
234
|
|
|
235
|
+
### Instrumentation
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
ActiveSanction::Instrumentation
|
|
239
|
+
ActiveSanction::Instrumentation::EVENTS
|
|
240
|
+
ActiveSanction::Instrumentation::Event
|
|
241
|
+
ActiveSanction::Instrumentation::Notifications
|
|
242
|
+
ActiveSanction::Instrumentation::Notifications::NAMESPACE
|
|
243
|
+
```
|
|
244
|
+
|
|
196
245
|
### The canonical record
|
|
197
246
|
|
|
198
247
|
```
|
|
@@ -255,6 +304,29 @@ ActiveSanction::Parsers::Warning
|
|
|
255
304
|
ActiveSanction::Parsers::ColumnShape
|
|
256
305
|
```
|
|
257
306
|
|
|
307
|
+
### Testing your own adapter
|
|
308
|
+
|
|
309
|
+
```
|
|
310
|
+
ActiveSanction::Testing
|
|
311
|
+
ActiveSanction::Testing::DEFAULT_FIXTURE_ROOT
|
|
312
|
+
ActiveSanction::Testing::StorageAdapterDefaults
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Loaded by `require "active_sanction/testing"`, never by `require
|
|
316
|
+
"active_sanction"`. The two shared example group **names** — `"a sanction
|
|
317
|
+
source"` and `"a storage adapter"` — are public on the same terms as the
|
|
318
|
+
constants: a group is not renamed or removed within a major version, and the
|
|
319
|
+
options it accepts do not change meaning under an adapter that passes it
|
|
320
|
+
today.
|
|
321
|
+
|
|
322
|
+
New examples may be **added** to a group, and that is not a breaking change
|
|
323
|
+
even though it can turn a passing adapter red. It is the same promise the
|
|
324
|
+
extension points make read from the other side: what the group checks is what
|
|
325
|
+
`Sources::Base` and `Storage::Base` required all along, and an adapter that
|
|
326
|
+
fails a newly added example was always violating the contract — the group
|
|
327
|
+
merely started saying so. Additions land in a minor, with a `CHANGELOG.md`
|
|
328
|
+
entry naming them.
|
|
329
|
+
|
|
258
330
|
### Storage, and what a fetch remembers
|
|
259
331
|
|
|
260
332
|
```
|
|
@@ -5,6 +5,7 @@ require "sorbet-runtime"
|
|
|
5
5
|
|
|
6
6
|
require "active_sanction/configuration"
|
|
7
7
|
require "active_sanction/error"
|
|
8
|
+
require "active_sanction/instrumentation"
|
|
8
9
|
require "active_sanction/match_result"
|
|
9
10
|
|
|
10
11
|
module ActiveSanction
|
|
@@ -78,6 +79,15 @@ module ActiveSanction
|
|
|
78
79
|
class Client
|
|
79
80
|
extend T::Sig
|
|
80
81
|
|
|
82
|
+
# What a client may be asked to do. Every one of them is a public method
|
|
83
|
+
# on this class, and this class supports all of them; see #supports?, and
|
|
84
|
+
# the note there on why an unknown name is false rather than an error.
|
|
85
|
+
#
|
|
86
|
+
# A name is not removed from this list within a major version, and a new
|
|
87
|
+
# one may be added -- which is the same promise the enumerated public
|
|
88
|
+
# surface makes, for the same reason.
|
|
89
|
+
CAPABILITIES = T.let(%i[screen screen_all sync diff rescreen doctor export import].freeze, T::Array[Symbol])
|
|
90
|
+
|
|
81
91
|
# The settings this client answers from, frozen. Reading one is how a host
|
|
82
92
|
# asks what a client is: `client.configuration.user_agent`.
|
|
83
93
|
sig { returns(Configuration) }
|
|
@@ -157,7 +167,8 @@ module ActiveSanction
|
|
|
157
167
|
@lock.synchronize do
|
|
158
168
|
@matcher ||= with_configuration do
|
|
159
169
|
Matcher.build(storage, sources: configuration.sources, weights: configuration.scorer_weights,
|
|
160
|
-
candidate_limit: configuration.candidate_limit, backend: backend
|
|
170
|
+
candidate_limit: configuration.candidate_limit, backend: backend,
|
|
171
|
+
instrumenter: configuration.instrumenter)
|
|
161
172
|
end
|
|
162
173
|
end
|
|
163
174
|
end
|
|
@@ -286,7 +297,14 @@ module ActiveSanction
|
|
|
286
297
|
sig { params(path: T.untyped, verify_with: T.untyped).returns(Snapshot) }
|
|
287
298
|
def import(path, verify_with: nil)
|
|
288
299
|
snapshot = ::File.open(path.to_s, "rb") { |io| Snapshot::Bundle.read(io, verify_with: verify_with) }
|
|
289
|
-
|
|
300
|
+
# The same `:store` event a sync emits, because it is the same fact: a
|
|
301
|
+
# list version was written to this store, and a host watching data
|
|
302
|
+
# freshness should not have to know which of the two ways it arrived.
|
|
303
|
+
fields = { source: snapshot.source, snapshot_id: snapshot.checksum, entities: snapshot.record_count,
|
|
304
|
+
store: storage.class.name, imported: true }
|
|
305
|
+
Instrumentation.instrument(configuration.instrumenter, :store, fields) do
|
|
306
|
+
with_configuration { storage.write_snapshot(snapshot) }
|
|
307
|
+
end
|
|
290
308
|
reload!
|
|
291
309
|
snapshot
|
|
292
310
|
end
|
|
@@ -321,6 +339,34 @@ module ActiveSanction
|
|
|
321
339
|
sig { returns(T::Boolean) }
|
|
322
340
|
def loaded? = !@lock.synchronize { @matcher }.nil?
|
|
323
341
|
|
|
342
|
+
# Whether this client does a thing, asked rather than assumed:
|
|
343
|
+
#
|
|
344
|
+
# client.supports?(:sync) # => true
|
|
345
|
+
# client.supports?(:diff) # => true
|
|
346
|
+
#
|
|
347
|
+
# This client supports all of CAPABILITIES -- it fetches, parses, stores
|
|
348
|
+
# and screens on the machine it runs on, and there is nothing in the list
|
|
349
|
+
# it cannot do. The method exists for the ones that are not this class.
|
|
350
|
+
# #56 was closed because `Client` already is the interface an out-of-tree
|
|
351
|
+
# implementation duck-types, and the one thing a duck-typed interface
|
|
352
|
+
# cannot express is *absence*: an implementation that answers screening
|
|
353
|
+
# questions against data somebody else keeps fresh has no `sync!` to
|
|
354
|
+
# offer, and a caller that cannot ask ends up finding out by rescuing
|
|
355
|
+
# NoMethodError.
|
|
356
|
+
#
|
|
357
|
+
# ### An unknown capability is false rather than an error
|
|
358
|
+
#
|
|
359
|
+
# Deliberately unlike Configuration, where an unrecognised setting raises
|
|
360
|
+
# because a silently dropped one is a client running on a default somebody
|
|
361
|
+
# thinks they changed. The direction here is the opposite: this is a
|
|
362
|
+
# forward-compatibility question, and the caller asking it is usually
|
|
363
|
+
# written against a newer version than the one answering. A host asking
|
|
364
|
+
# `supports?(:something_1_4_added)` of a 1.2 client wants `false` and a
|
|
365
|
+
# fallback path, not an exception -- raising would make the method useless
|
|
366
|
+
# for the one job it has.
|
|
367
|
+
sig { params(capability: T.untyped).returns(T::Boolean) }
|
|
368
|
+
def supports?(capability) = CAPABILITIES.include?(capability.to_sym)
|
|
369
|
+
|
|
324
370
|
sig { returns(String) }
|
|
325
371
|
def inspect
|
|
326
372
|
named = sources ? T.must(sources).join(", ") : "every registered source"
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require "sorbet-runtime"
|
|
5
5
|
|
|
6
6
|
require "active_sanction/error"
|
|
7
|
+
require "active_sanction/instrumentation"
|
|
7
8
|
require "active_sanction/normalizer"
|
|
8
9
|
require "active_sanction/scorer/weights"
|
|
9
10
|
require "active_sanction/version"
|
|
@@ -245,6 +246,11 @@ module ActiveSanction
|
|
|
245
246
|
sig { returns(T.untyped) }
|
|
246
247
|
attr_reader :logger
|
|
247
248
|
|
|
249
|
+
# Anything answering `#call(event)`, or nil for the default, which is that
|
|
250
|
+
# nothing is listening. See #instrumenter= and Instrumentation.
|
|
251
|
+
sig { returns(T.untyped) }
|
|
252
|
+
attr_reader :instrumenter
|
|
253
|
+
|
|
248
254
|
sig { void }
|
|
249
255
|
def initialize
|
|
250
256
|
@user_agent = T.let(DEFAULT_USER_AGENT, String)
|
|
@@ -267,6 +273,7 @@ module ActiveSanction
|
|
|
267
273
|
@normalizer_dictionary = T.let(Normalizer::Dictionary.default, Normalizer::Dictionary)
|
|
268
274
|
@scorer_weights = T.let(Scorer::Weights.default, Scorer::Weights)
|
|
269
275
|
@logger = T.let(nil, T.untyped)
|
|
276
|
+
@instrumenter = T.let(nil, T.untyped)
|
|
270
277
|
@storage = T.let(nil, T.nilable(Storage::Base))
|
|
271
278
|
@default_storage = T.let(nil, T.nilable(Storage::Base))
|
|
272
279
|
end
|
|
@@ -569,6 +576,38 @@ module ActiveSanction
|
|
|
569
576
|
@logger = value
|
|
570
577
|
end
|
|
571
578
|
|
|
579
|
+
# Where this library's structured events go: a lambda, a Method, or any
|
|
580
|
+
# object answering `#call(event)`.
|
|
581
|
+
#
|
|
582
|
+
# c.instrumenter = ->(event) { StatsD.timing("sanctions.#{event.name}", event.duration_ms) }
|
|
583
|
+
# c.instrumenter = ActiveSanction::Instrumentation::Notifications.new # a Rails host
|
|
584
|
+
#
|
|
585
|
+
# Six events -- `:fetch`, `:parse`, `:store`, `:"index.build"`, `:screen`
|
|
586
|
+
# and `:sync` -- each carrying a duration and the ids needed to correlate
|
|
587
|
+
# it. Their payload keys are public API; see Instrumentation, which is
|
|
588
|
+
# where all of it is documented, and docs/api_stability.md, which
|
|
589
|
+
# enumerates the keys.
|
|
590
|
+
#
|
|
591
|
+
# The default is nil, and nil is a branch rather than a no-op object: an
|
|
592
|
+
# installation that instruments nothing pays nothing, which is the only
|
|
593
|
+
# way a per-query event could be affordable at all.
|
|
594
|
+
#
|
|
595
|
+
# This is read where a stage is *built* rather than where it runs -- a
|
|
596
|
+
# Matcher takes its instrumenter at `build` and freezes it, the way it
|
|
597
|
+
# freezes its weights -- so changing it here affects the next matcher,
|
|
598
|
+
# sync or fetcher and never one already running. That is the same rule
|
|
599
|
+
# every other setting on the query path follows, and it is what keeps a
|
|
600
|
+
# batch from being half one set of numbers and half another.
|
|
601
|
+
sig { params(value: T.untyped).void }
|
|
602
|
+
def instrumenter=(value)
|
|
603
|
+
unless value.nil? || value.respond_to?(:call)
|
|
604
|
+
raise ConfigurationError,
|
|
605
|
+
"instrumenter must respond to #call(event), got #{value.class}. See ActiveSanction::Instrumentation."
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
@instrumenter = value
|
|
609
|
+
end
|
|
610
|
+
|
|
572
611
|
sig { returns(String) }
|
|
573
612
|
def self.default_storage_dir
|
|
574
613
|
-File.expand_path(File.join(Dir.home, DEFAULT_STORAGE_DIRNAME))
|
|
@@ -5,6 +5,7 @@ require "sorbet-runtime"
|
|
|
5
5
|
|
|
6
6
|
require "active_sanction/error"
|
|
7
7
|
require "active_sanction/http_client"
|
|
8
|
+
require "active_sanction/instrumentation"
|
|
8
9
|
require "active_sanction/validators"
|
|
9
10
|
require "active_sanction/validator_store"
|
|
10
11
|
require "active_sanction/fetcher/result"
|
|
@@ -64,20 +65,28 @@ module ActiveSanction
|
|
|
64
65
|
sig { returns(T.untyped) }
|
|
65
66
|
attr_reader :logger
|
|
66
67
|
|
|
68
|
+
# Where the `:fetch` event goes, or nil for nothing listening. See
|
|
69
|
+
# Instrumentation.
|
|
70
|
+
sig { returns(T.untyped) }
|
|
71
|
+
attr_reader :instrumenter
|
|
72
|
+
|
|
67
73
|
# The store defaults to disk, so the second run of a cron job benefits and
|
|
68
74
|
# not merely the second call in one process. A caller that would rather
|
|
69
75
|
# keep nothing between runs passes ValidatorStore::Memory.new.
|
|
70
76
|
sig do
|
|
71
|
-
params(client: HttpClient, store: T.untyped, stale_after: T.nilable(Numeric), logger: T.untyped
|
|
77
|
+
params(client: HttpClient, store: T.untyped, stale_after: T.nilable(Numeric), logger: T.untyped,
|
|
78
|
+
instrumenter: T.untyped).void
|
|
72
79
|
end
|
|
73
80
|
def initialize(client: HttpClient.new,
|
|
74
81
|
store: ValidatorStore::FileSystem.new,
|
|
75
82
|
stale_after: ActiveSanction.config.stale_after,
|
|
76
|
-
logger: ActiveSanction.config.logger
|
|
83
|
+
logger: ActiveSanction.config.logger,
|
|
84
|
+
instrumenter: ActiveSanction.config.instrumenter)
|
|
77
85
|
@client = T.let(client, HttpClient)
|
|
78
86
|
@store = T.let(store, T.untyped)
|
|
79
87
|
@stale_after = T.let(stale_after, T.nilable(Numeric))
|
|
80
88
|
@logger = T.let(logger, T.untyped)
|
|
89
|
+
@instrumenter = T.let(instrumenter, T.untyped)
|
|
81
90
|
end
|
|
82
91
|
|
|
83
92
|
# Fetches conditionally and buffers the body, like HttpClient#get.
|
|
@@ -91,12 +100,17 @@ module ActiveSanction
|
|
|
91
100
|
# `force: true` sends no validators, so the publisher has no way to answer
|
|
92
101
|
# 304. For the operator who suspects the cached copy is wrong and wants the
|
|
93
102
|
# bytes regardless of what the ETag says.
|
|
103
|
+
# `source:` is which list this file belongs to, and is only ever read by
|
|
104
|
+
# instrumentation: a multi-file source files its validators under
|
|
105
|
+
# `:"ofac_sdn-sdn"` and the rest, and a dashboard asking which *list* is
|
|
106
|
+
# degrading wants `:ofac_sdn`. Defaults to the fetch key, which is right
|
|
107
|
+
# for every single-file source and for a caller fetching a bare URL.
|
|
94
108
|
sig do
|
|
95
|
-
params(url: T.untyped, key: T.untyped, force: T::Boolean, headers: T::Hash[T.untyped, T.untyped]
|
|
96
|
-
|
|
109
|
+
params(url: T.untyped, key: T.untyped, force: T::Boolean, headers: T::Hash[T.untyped, T.untyped],
|
|
110
|
+
source: T.untyped).returns(Result)
|
|
97
111
|
end
|
|
98
|
-
def fetch(url, key: url, force: false, headers: {})
|
|
99
|
-
conditional(url, key, force, headers) { |request| client.get(url, headers: request) }
|
|
112
|
+
def fetch(url, key: url, force: false, headers: {}, source: nil)
|
|
113
|
+
conditional(url, key, force, headers, source) { |request| client.get(url, headers: request) }
|
|
100
114
|
end
|
|
101
115
|
|
|
102
116
|
# Streams conditionally to disk, like HttpClient#download. A 304 writes
|
|
@@ -104,10 +118,10 @@ module ActiveSanction
|
|
|
104
118
|
# is left exactly as the last download left it.
|
|
105
119
|
sig do
|
|
106
120
|
params(url: T.untyped, to: T.untyped, key: T.untyped, force: T::Boolean,
|
|
107
|
-
headers: T::Hash[T.untyped, T.untyped]).returns(Result)
|
|
121
|
+
headers: T::Hash[T.untyped, T.untyped], source: T.untyped).returns(Result)
|
|
108
122
|
end
|
|
109
|
-
def download(url, to:, key: url, force: false, headers: {})
|
|
110
|
-
conditional(url, key, force, headers) { |request| client.download(url, to: to, headers: request) }
|
|
123
|
+
def download(url, to:, key: url, force: false, headers: {}, source: nil)
|
|
124
|
+
conditional(url, key, force, headers, source) { |request| client.download(url, to: to, headers: request) }
|
|
111
125
|
end
|
|
112
126
|
|
|
113
127
|
# Whether a sync is due, answered locally and without a request.
|
|
@@ -149,14 +163,26 @@ module ActiveSanction
|
|
|
149
163
|
|
|
150
164
|
sig do
|
|
151
165
|
params(url: T.untyped, key: T.untyped, force: T::Boolean, headers: T::Hash[T.untyped, T.untyped],
|
|
166
|
+
source: T.untyped,
|
|
152
167
|
block: T.proc.params(request: T::Hash[T.untyped, T.untyped]).returns(HttpClient::Response))
|
|
153
168
|
.returns(Result)
|
|
154
169
|
end
|
|
155
|
-
def conditional(url, key, force, headers, &block)
|
|
170
|
+
def conditional(url, key, force, headers, source, &block)
|
|
156
171
|
stored = force ? nil : usable(key, url)
|
|
157
172
|
log_request(key, url, stored, force)
|
|
158
|
-
|
|
159
|
-
|
|
173
|
+
# One event per HTTP round trip, which is why `forced` is on it: a file
|
|
174
|
+
# served out of the payload cache after a 304 costs no request and emits
|
|
175
|
+
# nothing, and a source re-fetching one because its cache was empty
|
|
176
|
+
# emits a second event rather than amending the first.
|
|
177
|
+
fields = { source: source || key, key: key, url: url.to_s, forced: force }
|
|
178
|
+
Instrumentation.instrument(instrumenter, :fetch, fields) do |event|
|
|
179
|
+
response = block.call(merge(headers, stored))
|
|
180
|
+
event[:status] = response.status
|
|
181
|
+
event[:not_modified] = response.not_modified?
|
|
182
|
+
event[:bytes] = response.body.to_s.bytesize
|
|
183
|
+
event[:conditional] = !stored.nil?
|
|
184
|
+
record(key, url, stored, response)
|
|
185
|
+
end
|
|
160
186
|
end
|
|
161
187
|
|
|
162
188
|
# Validators stored against a different URL are not merely useless, they
|