Class: ActiveSanction::Storage::FileSystem
- Extended by:
- T::Sig
- Defined in:
- lib/active_sanction/storage/file_system.rb
Overview
Snapshots as gzipped JSON in a directory. The default adapter, and the reason this gem screens a name without an application having provisioned anything first.
store = ActiveSanction::Storage::FileSystem.new # ~/.active_sanction
store = ActiveSanction::Storage::FileSystem.new(root: "/srv/lists")
store.write_snapshot(ActiveSanction::Sources[:ofac_sdn].new.sync)
store.(:ofac_sdn).age # without opening the list
store.read_snapshot(:ofac_sdn) # => Snapshot, checksum verified
Nothing here is required. zlib and json are stdlib, so the cost of
persisting 19,015 OFAC records is a directory -- which is what makes this
usable from a cron job, a CLI (#36), a CI run, and an air-gapped host
that only ever gets a copied directory. Storage::ActiveRecord (#25) is
for an installation that already has a database and wants to query the
lists; it is not a prerequisite for using this library.
The layout is private
Under #62 the contents of root are @api private. What is on disk is
optimized for reading and rewriting locally, and it is expected to change
-- the portable, cross-machine, signature-verified representation is the
bundle format (#57), which has its own stability contract and is specified
in docs/bundle_format.md. An application that reads these files itself
makes every future storage optimization a breaking change for it.
As it stands:
root/ofac_sdn/meta.json
root/ofac_sdn/snapshot-sha256-9f86d081884c7d65....json.gz
Why the snapshot file is named after its checksum
Because "a sync interrupted mid-write leaves the previous good snapshot intact" cannot be honoured by two files at fixed names. Replacing a list means replacing both the list and the sidecar describing it, and whatever order those two renames happen in, a process killed between them leaves a snapshot and a meta that do not describe each other -- the new list under the old checksum, or a sidecar advertising records that are not there. Either way the previous list is gone and the source is unreadable until the next successful sync.
Naming the list file after the content it holds removes the conflict. A
new list is written to a name nothing else occupies, so it cannot destroy
the list already there, and meta.json -- one small file, replaced by one
atomic rename -- is the single point at which the new generation becomes
the live one. Interrupt anywhere before that rename and the store is
exactly as it was, plus a stray file the next write sweeps. Interrupt
after it and the new list is live and complete. There is no third state.
This is the pattern PayloadCache uses for raw payloads, for the same reason and with the same tradeoff: a brief second copy on disk.
What it refuses to do
Return anything it cannot prove. Snapshot.from_h re-derives the
checksum from the records that came back and construction fails if it
does not match what was stored, so a truncated file, an edited file and a
half-written file all raise CorruptSnapshot instead of screening against
a list that is missing records. A schema version this code does not know
raises UnsupportedSchema before the list is parsed, since a snapshot
from a newer gem will usually deserialize into a valid-looking, quietly
wrong record set.
Concurrency
Many readers and one writer, across processes, which is the arrangement
it exists for: a scheduled sync replacing a list while web workers screen
against it. Committing is a rename, so a reader sees the whole previous
generation or the whole new one; a reader that had already read the old
meta.json when the new one landed re-reads it if the file it was sent
to has since been swept.
Two processes writing the same source at once is not supported and is not made safe by anything here -- run one sync.
Instance Attribute Summary collapse
-
#root ⇒ String
readonly
The directory every source is filed under.
Instance Method Summary collapse
- #delete_snapshot(source) ⇒ Boolean
- #initialize(root: nil) ⇒ void constructor
- #inspect ⇒ String
- #read_snapshot(source) ⇒ Snapshot?
-
#snapshot_meta(source) ⇒ Meta?
Off the sidecar, without opening the list.
-
#sources ⇒ Array<Symbol>
Every directory under
rootholding a committed sidecar. -
#write_snapshot(snapshot) ⇒ Snapshot
Writes the list, then publishes it by replacing
meta.json.
Methods inherited from Base
#clear, #each_entity, #empty?, #fetch_snapshot, #size, #stored?
Constructor Details
#initialize(root: nil) ⇒ void
153 154 155 156 |
# File 'lib/active_sanction/storage/file_system.rb', line 153 def initialize(root: nil) @root = T.let(-::File.((root || ActiveSanction.config.storage_dir).to_s), String) super() end |
Instance Attribute Details
#root ⇒ String (readonly)
The directory every source is filed under. Its layout is private -- see the class comment.
150 151 152 |
# File 'lib/active_sanction/storage/file_system.rb', line 150 def root @root end |
Instance Method Details
#delete_snapshot(source) ⇒ Boolean
193 194 195 196 197 198 |
# File 'lib/active_sanction/storage/file_system.rb', line 193 def delete_snapshot(source) directory = directory_for(source_key!(source)) stored = ::File.file?(::File.join(directory, META_FILENAME)) FileUtils.rm_rf(directory) stored end |
#inspect ⇒ String
214 |
# File 'lib/active_sanction/storage/file_system.rb', line 214 def inspect = "#<#{self.class} #{root} #{list}>" |
#read_snapshot(source) ⇒ Snapshot?
175 176 177 178 179 180 181 182 183 |
# File 'lib/active_sanction/storage/file_system.rb', line 175 def read_snapshot(source) key = source_key!(source) directory = directory_for(key) = (directory) , json = read_list(directory, ) if return nil if json.nil? || .nil? build(key, , json, snapshot_path(directory, .checksum)) end |
#snapshot_meta(source) ⇒ Meta?
Off the sidecar, without opening the list. What makes sources in the
CLI (#36) and the per-source summary in sync (#34) cheap: printing how
old six lists are reads six small JSON files rather than inflating and
deserializing tens of megabytes.
190 |
# File 'lib/active_sanction/storage/file_system.rb', line 190 def (source) = (directory_for(source_key!(source))) |
#sources ⇒ Array<Symbol>
Every directory under root holding a committed sidecar. Deliberately
does not parse them: this is what stored?, empty? and clear are
built on, and one unreadable list must not make the store impossible to
inspect or to repair.
205 206 207 208 209 210 211 |
# File 'lib/active_sanction/storage/file_system.rb', line 205 def sources return [] unless ::File.directory?(root) Dir.children(root) .select { |name| name.match?(SOURCE_PATTERN) && ::File.file?(::File.join(root, name, META_FILENAME)) } .map(&:to_sym).sort end |
#write_snapshot(snapshot) ⇒ Snapshot
Writes the list, then publishes it by replacing meta.json. The
previous generation stays readable until that rename lands and is swept
immediately after it.
162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/active_sanction/storage/file_system.rb', line 162 def write_snapshot(snapshot) stored = snapshot!(snapshot) directory = directory_for(source_key!(stored.source)) FileUtils.mkdir_p(directory) path = snapshot_path(directory, stored.checksum) write_atomically(path) { |file| compress(JSON.generate(stored.to_h), file) } commit(directory, Meta.from_snapshot(stored)) prune(directory, path) stored end |