Class: ActiveSanction::Storage::ActiveRecord
- Extended by:
- T::Sig
- Defined in:
- lib/active_sanction/storage/active_record.rb,
lib/active_sanction/storage/active_record/row.rb,
lib/active_sanction/storage/active_record/reader.rb,
lib/active_sanction/storage/active_record/writer.rb
Overview
Snapshots in the host application's database.
$ rails generate active_sanction:install && rails db:migrate
store = ActiveSanction::Storage::ActiveRecord.new
store.write_snapshot(ActiveSanction::Sources[:ofac_sdn].new.sync)
store.snapshot_meta(:ofac_sdn).age # one row read
Optional means optional
ActiveRecord is not a dependency of this gem and must not become one. Nothing requires this file unless a host has already loaded ActiveRecord itself -- see the guard at the bottom of storage.rb -- and the gem is fully usable, including Storage::FileSystem, with ActiveRecord absent. The default adapter costs a directory; this one costs a migration, and it is for an installation that already has a database and wants to query its lists, not a prerequisite for using this library.
What the database buys, and what it does not
It buys the prefilter. Scoring 19,015 OFAC records against one name in Ruby is the cost the matcher (#33) wants to avoid paying, and an equality probe on an indexed column narrows that to a few hundred candidates before any of them are loaded:
Row::Name.matching("Aiman al-Zawahiri").pluck(:entity_id)
Row::Identifier.matching("AB-123 456").pluck(:entity_id)
It does not buy a different reading model. each_entity is inherited
from Storage::Base rather than reimplemented as a cursor, and that is a
decision rather than an omission: a snapshot's checksum is computed over
the whole list, so a store that streamed rows straight to the matcher
would be handing it records it cannot prove are all of them. Reading a
list here materializes it and verifies it, exactly as the other adapters
do. Tens of megabytes is a fine price for that; screening against a list
that is quietly missing people is not.
Writing
One transaction per list, and insert_all in batches inside it. A sync
that dies partway through 19,015 entities rolls back to the list that was
there before it -- there is no half-updated state to inspect, and none to
screen against. Row-at-a-time saves would be the obvious alternative and
are not: 19,015 entities plus some 65,000 rows hanging off them is not
work to do one INSERT at a time.
Reading
Nothing partial is ever returned. The Snapshot is rebuilt with the checksum stored beside it, so construction re-derives the digest over the records that actually came back and raises CorruptSnapshot when they do not agree -- a row deleted by hand, a write that half landed, a column edited in a console. A schema_version this gem does not know raises UnsupportedSchema before a record is read.
Concurrency
The database's problem, which is the point. A write is one transaction, so a reader sees the list as it was before it or as it is after it, and readers on other processes and other machines get that for free rather than from a rename that only holds within one filesystem.
Instance Attribute Summary collapse
-
#batch_size ⇒ Integer
readonly
Rows per
insert_all-- see DEFAULT_BATCH_SIZE.
Class Method Summary collapse
-
.installed? ⇒ Boolean
Whether the migration has been run.
-
.prefilter_key(value) ⇒ String
The key a name is filed under in
active_sanction_names.normalized_valueand the key a query has to build to find it:.
Instance Method Summary collapse
- #delete_snapshot(source) ⇒ Boolean
- #initialize(batch_size: DEFAULT_BATCH_SIZE) ⇒ void constructor
- #read_snapshot(source) ⇒ Snapshot?
-
#snapshot_meta(source) ⇒ Meta?
One row read, and no entities.
-
#sources ⇒ Array<Symbol>
Sorted in Ruby rather than by the database, so a summary does not reshuffle itself when the same lists are read through a connection with a different collation.
-
#write_snapshot(snapshot) ⇒ Snapshot
Replaces the source's list inside one transaction: the previous generation is dropped and the new one written, or neither happens.
Methods inherited from Base
#clear, #each_entity, #empty?, #fetch_snapshot, #inspect, #size, #stored?
Constructor Details
#initialize(batch_size: DEFAULT_BATCH_SIZE) ⇒ void
148 149 150 151 |
# File 'lib/active_sanction/storage/active_record.rb', line 148 def initialize(batch_size: DEFAULT_BATCH_SIZE) @batch_size = T.let(batch_size!(batch_size), Integer) super() end |
Instance Attribute Details
#batch_size ⇒ Integer (readonly)
Rows per insert_all -- see DEFAULT_BATCH_SIZE.
145 146 147 |
# File 'lib/active_sanction/storage/active_record.rb', line 145 def batch_size @batch_size end |
Class Method Details
.installed? ⇒ Boolean
Whether the migration has been run. Not checked on construction: an
adapter built in a Rails initializer must not open a connection to say
hello, and a host running rails db:migrate would then be unable to
boot the application that migrates it.
137 138 139 140 141 |
# File 'lib/active_sanction/storage/active_record.rb', line 137 def self.installed? Row::ALL.all?(&:table_exists?) rescue ::ActiveRecord::ActiveRecordError false end |
.prefilter_key(value) ⇒ String
The key a name is filed under in active_sanction_names.normalized_value
and the key a query has to build to find it:
prefilter_key("Aiman al-ZAWAHIRI!") # => "aiman al zawahiri"
prefilter_key("Ayman al-Ẓawāhirī") # => "ayman al zawahiri"
Deliberately crude, and deliberately not the matcher's normalizer (#26). Its only job is candidate generation, where the cost of the two kinds of error is wildly asymmetric: a key that collides too eagerly costs a few extra records to score in Ruby, and a key that misses costs a sanctioned person who never reaches the scorer at all. So it folds width and diacritics, cases down, and reduces everything that is not alphanumeric to a single space -- and it stops there. It does not transliterate, drop legal forms (#27), or reorder tokens; those change what a name means and belong where a human can see the decision.
Because it is stored, changing this fold makes the stored keys stale. A release that changes it will say so, and the fix is a re-sync.
127 128 129 130 |
# File 'lib/active_sanction/storage/active_record.rb', line 127 def self.prefilter_key(value) folded = value.to_s.unicode_normalize(:nfkd).gsub(COMBINING_MARKS, "").downcase folded.gsub(NON_ALPHANUMERIC, " ").strip.squeeze(" ").slice(0, PREFILTER_KEY_LIMIT).to_s end |
Instance Method Details
#delete_snapshot(source) ⇒ Boolean
190 191 192 193 194 195 196 197 198 199 |
# File 'lib/active_sanction/storage/active_record.rb', line 190 def delete_snapshot(source) key = source_key!(source) connected do Row::Base.transaction do row = Row::Snapshot.find_by(source: key.to_s) row&.discard! !row.nil? end end end |
#read_snapshot(source) ⇒ Snapshot?
169 170 171 172 173 174 175 |
# File 'lib/active_sanction/storage/active_record.rb', line 169 def read_snapshot(source) row = snapshot_row(source_key!(source)) return nil if row.nil? schema_version!(row) build(row) end |
#snapshot_meta(source) ⇒ Meta?
One row read, and no entities. What makes printing how old six lists are six primary-key lookups rather than six full deserializations.
180 181 182 183 184 185 186 187 |
# File 'lib/active_sanction/storage/active_record.rb', line 180 def (source) row = snapshot_row(source_key!(source)) return nil if row.nil? Meta.new(source: row.source, fetched_at: row.fetched_at.to_time, checksum: row.checksum, record_count: row.record_count, schema_version: row.schema_version, source_version: row.source_version) end |
#sources ⇒ Array<Symbol>
Sorted in Ruby rather than by the database, so a summary does not reshuffle itself when the same lists are read through a connection with a different collation.
205 |
# File 'lib/active_sanction/storage/active_record.rb', line 205 def sources = connected { Row::Snapshot.pluck(:source) }.map(&:to_sym).sort |
#write_snapshot(snapshot) ⇒ Snapshot
Replaces the source's list inside one transaction: the previous generation is dropped and the new one written, or neither happens.
156 157 158 159 160 161 162 163 164 165 166 |
# File 'lib/active_sanction/storage/active_record.rb', line 156 def write_snapshot(snapshot) stored = snapshot!(snapshot) key = source_key!(stored.source) connected do Row::Base.transaction do Row::Snapshot.find_by(source: key.to_s)&.discard! Writer.new(create_row(key, stored), stored, batch_size: batch_size).call end end stored end |