relaton 3.0.0.pre.alpha.7 → 3.0.0.pre.alpha.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21f068205ef6a80e718953c7989c7a51219a9f5f6e25a4fecc67ac93d9fa4da5
4
- data.tar.gz: 7c438465496dc3b95370bc6990420073c63f41d5edba70022a71b39bd45f343f
3
+ metadata.gz: 2c1a04ce086b4f13d9ec8c448b4254cd3f4bc0399d0d1b4892e2201f45717f4c
4
+ data.tar.gz: f5a8286c7313b4f47a131290923ad2d23082cd51fb492f20555d859f38375cfd
5
5
  SHA512:
6
- metadata.gz: af94d0ffa5ae9cbeefecf98d9aad8a04a7ea74814b92e470b6235b2bb1c2f19593db50e0f919b2ce3bbf1bdf44b8e637fa20000e6d59bc400736159fe0f9a2d1
7
- data.tar.gz: 833bbe454eaa888a145fb9dcd5daff7d8bc8cf2e03f603c2100b0d19d981531a5b4bfce87b77bc00f3b5a5684904473f58656ebf820b2c7272b606f5c26afddc
6
+ metadata.gz: 63355667dfc69e951fd03842f8b3769dae41ffce420b4deb21349dac2a035db489b17c808d9c6b8c478f324c15715c1d6cbc7d00422f47f46d649443ea9f3a63
7
+ data.tar.gz: bbe9885dd3811d1004a2048dd0dd88846ded2075ca21f26fe7b32f5286d63f5a014b83f0fd681bd9db61400f54bc3fabc9ec4998d791bcac578be07cfe290645
@@ -29,8 +29,7 @@ module Relaton
29
29
  return unless val.is_a?(String) && !val.empty?
30
30
  return if @text.is_a?(String) && !@text.empty?
31
31
 
32
- description = Isoics.fetch(val)&.description
33
- self.text = description if description
32
+ populate_text_from_isoics
34
33
  end
35
34
 
36
35
  # When the deserializer reaches the end of the XML element and
@@ -38,9 +37,44 @@ module Relaton
38
37
  # to mark the attribute as default-valued (suppressing serialization).
39
38
  # Refuse that mark if we've already populated text from Isoics so the
40
39
  # value survives round-trip. See #112.
40
+ # lutaml-model 0.8.92 (lutaml/lutaml-model#922): assignments made
41
+ # inside `code=` while from_xml is still mid-element do not register
42
+ # as explicit values. Populate once deserialization has finished.
43
+ def self.from_xml(node)
44
+ ics = super
45
+ # lutaml-model 0.8.92 (#922): a value assigned through a custom
46
+ # writer's `super` mid-deserialization is left default-suppressed.
47
+ # Register code as explicitly set, then populate text.
48
+ ics.value_set_for(:code) if ics.code.is_a?(String) && !ics.code.empty?
49
+ ics.populate_text_from_isoics
50
+ ics
51
+ end
52
+
53
+ def populate_text_from_isoics
54
+ code.is_a?(String) && !code.empty? or return
55
+ @text.is_a?(String) && !@text.empty? and return
56
+
57
+ description = Isoics.fetch(code)&.description
58
+ self.text = description if description
59
+ end
60
+
41
61
  def using_default_for(attribute_name)
42
- return if attribute_name == :text &&
43
- @text.is_a?(String) && !@text.empty?
62
+ # lutaml-model 0.8.92 (#922) leaves values assigned through a
63
+ # custom writer's `super` default-suppressed. code, when present,
64
+ # is always explicit; text absent from the XML is filled from
65
+ # Isoics here — the element end — and emitted.
66
+ if attribute_name == :code
67
+ return value_set_for(:code) if @code.is_a?(String) && !@code.empty?
68
+
69
+ return super
70
+ end
71
+
72
+ return if attribute_name == :text && @text.is_a?(String) && !@text.empty?
73
+
74
+ if attribute_name == :text
75
+ populate_text_from_isoics
76
+ return value_set_for(:text) unless @text.nil? || @text.empty?
77
+ end
44
78
 
45
79
  super
46
80
  end
@@ -4,7 +4,8 @@ module Relaton
4
4
  # Configuration class for Relaton::Index
5
5
  #
6
6
  class Config
7
- attr_reader :storage, :storage_dir, :filename, :build_sidecar_in_child
7
+ attr_reader :storage, :storage_dir, :filename, :build_sidecar_in_child,
8
+ :sqlite_index
8
9
 
9
10
  #
10
11
  # Set default values
@@ -14,6 +15,7 @@ module Relaton
14
15
  @storage_dir = Dir.home
15
16
  @filename = "index.yaml"
16
17
  @build_sidecar_in_child = true
18
+ @sqlite_index = true
17
19
  end
18
20
 
19
21
  # Build the sidecar in a forked child when available, so the one-time
@@ -23,6 +25,14 @@ module Relaton
23
25
  @build_sidecar_in_child = flag
24
26
  end
25
27
 
28
+ # Materialize a downloaded index into SQLite so a search answers from
29
+ # a bucket query and the process never holds the whole index
30
+ # (relaton#242 phase 2). Set to false to keep the raw-row sidecar path
31
+ # only.
32
+ def sqlite_index=(flag)
33
+ @sqlite_index = flag
34
+ end
35
+
26
36
  #
27
37
  # Set storage
28
38
  #
@@ -275,6 +275,8 @@ module Relaton
275
275
  end
276
276
  end.to_yaml
277
277
  Index.config.storage.write file, yaml
278
+ delete_sidecar
279
+ delete_sqlite
278
280
  end
279
281
 
280
282
  def sort_structured_index(index)
@@ -293,14 +295,15 @@ module Relaton
293
295
  def remove
294
296
  Index.config.storage.remove file
295
297
  delete_sidecar
298
+ delete_sqlite
296
299
  []
297
300
  end
298
301
 
299
302
  #
300
303
  # Raw-row read for the lazy search path (relaton#242 stopgap): returns
301
304
  # precomputed root-number sort keys and the rows as plain hashes — no
302
- # pubid objects. A Marshal sidecar next to the yaml holds the keys+rows
303
- # so repeat loads skip the YAML parse and the one-time full
305
+ # pubid objects. A Marshal sidecar next to the yaml holds them so
306
+ # repeat loads skip the YAML parse and the one-time full
304
307
  # materialization; the sidecar is rebuilt whenever the yaml is newer.
305
308
  #
306
309
  # @return [Array<Array<String>, Array<Hash>] sort keys and raw rows
@@ -316,6 +319,147 @@ module Relaton
316
319
  end
317
320
  end
318
321
 
322
+ # ── SQLite backend (relaton#242 phase 2) ──
323
+ #
324
+ # The downloaded index is materialized once into a SQLite database
325
+ # keyed by the narrowing number. A search answers from a bucket query,
326
+ # so the process holds O(bucket) rows instead of the whole index. The
327
+ # build is pure hash transforms (no pubid objects), run in a forked
328
+ # child so even the one-time YAML parse never lands in this process.
329
+
330
+ def sqlite_ready?
331
+ url.is_a?(String) && Index.config.sqlite_index != false &&
332
+ File.file?(sqlite_db_path) && sqlite_fresh?
333
+ end
334
+
335
+ # Ensure the db exists and is current (24 h TTL, schema-versioned).
336
+ def ensure_sqlite
337
+ return false unless url.is_a?(String)
338
+ return true if sqlite_ready?
339
+
340
+ with_file_lock do
341
+ build_sqlite unless sqlite_ready?
342
+ end
343
+ sqlite_ready?
344
+ end
345
+
346
+ def sqlite_bucket(number)
347
+ sqlite_backend.bucket(number)
348
+ end
349
+
350
+ def sqlite_count
351
+ sqlite_backend.count
352
+ end
353
+
354
+ def close_sqlite
355
+ @sqlite_backend&.close
356
+ @sqlite_backend = nil
357
+ end
358
+
359
+ def delete_sqlite
360
+ close_sqlite
361
+ File.delete(sqlite_db_path) if File.file?(sqlite_db_path)
362
+ rescue Errno::EACCES
363
+ nil
364
+ end
365
+
366
+ def sqlite_db_path
367
+ "#{path_to_local_file}.db"
368
+ end
369
+
370
+ private
371
+
372
+ def sqlite_backend
373
+ @sqlite_backend ||= SqliteBackend.new(sqlite_db_path, pubid_class: @pubid_class)
374
+ end
375
+
376
+ def sqlite_fresh?
377
+ ctime = Index.config.storage.ctime(sqlite_db_path)
378
+ backend = sqlite_backend
379
+ ctime && ctime > Time.now - 86400 && !backend.stale_schema?
380
+ rescue SQLite3::SQLException
381
+ false
382
+ ensure
383
+ backend&.close
384
+ @sqlite_backend = nil
385
+ end
386
+
387
+ # The build downloads the published zip, converts its rows to
388
+ # `[number, sort_key, id_json, file]` tuples by hash transforms alone,
389
+ # and writes the db — in a forked child where the YAML parse's memory
390
+ # dies with the process.
391
+ def build_sqlite
392
+ # The whole build — download, YAML parse, db write — runs in a
393
+ # forked child where available: the parse's ~300 MB (per flavor,
394
+ # larger for IETF) dies with the child, and the serving process
395
+ # never allocates it.
396
+ if Process.respond_to?(:fork) && !ENV["RELATON_NO_FORK"]
397
+ pid = Process.fork do
398
+ tuples = download_tuples
399
+ write_sqlite(tuples) if tuples
400
+ exit!(0)
401
+ end
402
+ Process.wait(pid)
403
+ true
404
+ else
405
+ tuples = download_tuples
406
+ return false unless tuples
407
+
408
+ write_sqlite(tuples)
409
+ end
410
+ end
411
+
412
+ def write_sqlite(tuples)
413
+ File.dirname(sqlite_db_path).then { |d| require "fileutils"; FileUtils.mkdir_p(d) }
414
+ backend = SqliteBackend.new(sqlite_db_path, pubid_class: @pubid_class)
415
+ backend.build(tuples.each)
416
+ backend.close
417
+ end
418
+
419
+ # Enumerate `[number, sort_key, id_hash, file]` from the published zip.
420
+ # The root number — the base document's, walking `base` nesting — is
421
+ # computed on the raw hash; no pubid object is ever built.
422
+ def download_tuples
423
+ body = Net::HTTP.get(URI.parse(url))
424
+ # rubyzip may mutate the buffer it is handed; a StringIO over a copy
425
+ # keeps the response body intact for any later reader of it.
426
+ yaml = nil
427
+ Zip::File.open_buffer(StringIO.new(body.dup)) do |zip|
428
+ stream = zip.entries.first.get_input_stream
429
+ yaml = stream.read
430
+ end
431
+ yaml = yaml.read unless yaml.is_a?(String)
432
+ Util.info "Downloaded index from `#{url}` for sqlite materialization", progname
433
+ raw = YAML.safe_load(yaml, permitted_classes: [Symbol])
434
+ return nil unless check_format(raw)
435
+
436
+ Enumerator.new do |y|
437
+ raw.each do |r|
438
+ number = raw_root_number(r[:id]).to_s
439
+ y << [number, number, r[:id], r[:file]]
440
+ end
441
+ end
442
+ rescue Psych::SyntaxError, SocketError, OpenURI::HTTPError, Errno::ECONNRESET,
443
+ OpenSSL::SSL::SSLError => e
444
+ Util.info "SQLite index build failed (#{e.message}); falling back", progname
445
+ nil
446
+ end
447
+
448
+ # `#root` as a hash walk: a supplement nests its origin under `base`,
449
+ # and the flattened to_hash puts the supplement's own components first.
450
+ # Accepts string and symbol keys — a YAML round-trip with permitted
451
+ # Symbol may give either.
452
+ def raw_root_number(id_hash)
453
+ return nil unless id_hash.is_a?(Hash)
454
+
455
+ base = id_hash["base"] || id_hash[:base]
456
+ return raw_root_number(base) if base
457
+
458
+ id_hash["number"] || id_hash[:number]
459
+ end
460
+
461
+ public
462
+
319
463
  # Deserialize raw rows into pubid rows. Only the caller knows which
320
464
  # slice it needs, so materialization happens here rather than at load.
321
465
  #
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+
5
+ module Relaton
6
+ module Index
7
+ #
8
+ # The SQLite materialization of one flavor index (relaton#242 phase 2).
9
+ #
10
+ # A downloaded index zip is built once into a local database whose rows
11
+ # are `(number, sort_key, id_json, file)` with an index on `number` — the
12
+ # narrowing key. A search materializes only its bucket; the whole index
13
+ # is never resident. WAL journaling lets one build and concurrent readers
14
+ # share the file across processes.
15
+ #
16
+ class SqliteBackend
17
+ SCHEMA_VERSION = "1"
18
+
19
+ def initialize(db_path, pubid_class: nil)
20
+ @db_path = db_path
21
+ @pubid_class = pubid_class
22
+ @db = SQLite3::Database.new(db_path)
23
+ @db.busy_timeout = 30_000
24
+ @db.results_as_hash = false
25
+ @db.execute("PRAGMA journal_mode=WAL")
26
+ @db.execute("PRAGMA synchronous=NORMAL")
27
+ create_schema
28
+ end
29
+
30
+ # Build from an enumerator of `[number, sort_key, id_hash, file]`
31
+ # tuples. Callers hand the child process's enumerator here; the tuples
32
+ # are written inside one transaction.
33
+ def build(rows_enum)
34
+ @db.transaction do
35
+ @db.execute("DELETE FROM index_rows")
36
+ @db.prepare("INSERT INTO index_rows (number, sort_key, id_json, file) VALUES (?, ?, ?, ?)") do |stmt|
37
+ rows_enum.each { |(number, sort_key, id_hash, file)| stmt.execute(number, sort_key, JSON.generate(id_hash), file) }
38
+ end
39
+ meta_set("schema_version", SCHEMA_VERSION)
40
+ end
41
+ end
42
+
43
+ # The bucket for a narrowing key: raw rows, ordered as written.
44
+ def bucket(number)
45
+ rows = []
46
+ @db.execute("SELECT id_json, file FROM index_rows WHERE number = ? ORDER BY rowid", [number]) do |row|
47
+ rows << { id: JSON.parse(row[0]), file: row[1] }
48
+ end
49
+ rows
50
+ end
51
+
52
+ def each_row
53
+ @db.execute("SELECT id_json, file FROM index_rows ORDER BY rowid") do |row|
54
+ yield({ id: JSON.parse(row[0]), file: row[1] })
55
+ end
56
+ end
57
+
58
+ def count
59
+ @db.get_first_value("SELECT COUNT(*) FROM index_rows").to_i
60
+ end
61
+
62
+ def stale_schema?
63
+ meta_get("schema_version") != SCHEMA_VERSION
64
+ end
65
+
66
+ def close
67
+ @db.close
68
+ end
69
+
70
+ private
71
+
72
+ def create_schema
73
+ @db.execute(<<~SQL)
74
+ CREATE TABLE IF NOT EXISTS index_rows (
75
+ number TEXT NOT NULL,
76
+ sort_key TEXT NOT NULL,
77
+ id_json TEXT NOT NULL,
78
+ file TEXT NOT NULL
79
+ )
80
+ SQL
81
+ @db.execute("CREATE INDEX IF NOT EXISTS idx_index_rows_number ON index_rows (number)")
82
+ @db.execute(<<~SQL)
83
+ CREATE TABLE IF NOT EXISTS index_meta (
84
+ key TEXT PRIMARY KEY,
85
+ value TEXT NOT NULL
86
+ )
87
+ SQL
88
+ end
89
+
90
+ def meta_set(key, value)
91
+ @db.execute("INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)", [key, value])
92
+ end
93
+
94
+ def meta_get(key)
95
+ @db.get_first_value("SELECT value FROM index_meta WHERE key = ?", [key])
96
+ end
97
+ end
98
+ end
99
+ end
@@ -228,17 +228,28 @@ module Relaton
228
228
  # never refreshed, so the shards stay the fresher answer.
229
229
  return @source.rows(id) if @source && id && !id.is_a?(String)
230
230
 
231
- # The lazy path narrows on the precomputed keys and materializes only
232
- # the bucket; the materialized `index` stays available to callers
233
- # that need the whole graph (`#index`, a String query, a block).
234
- # Load first: a pubid-backed load is what settles @file_io.sorted.
231
+ # The SQLite backend answers a parsed query with a bucket query:
232
+ # O(bucket) rows ever resident, no sidecar load at all. Falls back
233
+ # to the raw-row path when the backend cannot build or is disabled.
235
234
  if id && !id.is_a?(String) && @file_io.pubid_class
235
+ begin
236
+ return sqlite_candidates(id) if @file_io.ensure_sqlite
237
+ rescue SQLite3::SQLException
238
+ nil # fall back to the sidecar path below
239
+ end
240
+
236
241
  raw_index
237
242
  return candidates_by_number(id) if @file_io.sorted
238
243
  end
239
244
  index
240
245
  end
241
246
 
247
+ def sqlite_candidates(id)
248
+ target = id.root.number.to_s
249
+ rows = @file_io.sqlite_bucket(target)
250
+ @file_io.materialize(rows)
251
+ end
252
+
242
253
  # Narrowing key: the base *document's* number as a string. `#root` walks a
243
254
  # supplement/amendment/corrigendum's `.base` chain to the origin (and
244
255
  # returns self for a base document), so a document and all its wrappers
data/lib/relaton/index.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "yaml"
4
4
  require "zip"
5
+ require "open-uri"
5
6
  require "relaton/logger"
6
7
 
7
8
  require_relative "version"
@@ -10,9 +11,10 @@ require_relative "index/file_storage"
10
11
  require_relative "index/config"
11
12
  require_relative "index/util"
12
13
  require_relative "index/pool"
13
- require_relative "index/type"
14
14
  require_relative "index/file_io"
15
15
  require_relative "index/shard_source"
16
+ require_relative "index/sqlite_backend"
17
+ require_relative "index/type"
16
18
 
17
19
  module Relaton
18
20
  module Index
@@ -1,3 +1,3 @@
1
1
  module Relaton
2
- VERSION = "3.0.0.pre.alpha.7".freeze
2
+ VERSION = "3.0.0.pre.alpha.9".freeze
3
3
  end
data/lib/relaton.rb CHANGED
@@ -7,16 +7,16 @@
7
7
  require "relaton/version"
8
8
  require "moxml"
9
9
 
10
- # YAML through yeptris (Psych-compatible drop-in; stdlib Psych is kept
11
- # as Yeptris::Psych::ORIGINAL). Must load before any YAML/Psych use.
12
- # yeptris 0.5 moved the exclusive drop-in rebind to yeptris/psych/drop_in
13
- # — plain yeptris/psych only defines the namespace and leaves stdlib
14
- # Psych in place (yeptris-ruby#95, bug 3).
15
- # Load stdlib Psych first: the drop-in keeps it as Yeptris::Psych::ORIGINAL,
16
- # and a later plain require "psych" against the rebound constant is a
17
- # superclass mismatch (yeptris-ruby#95, bug 3 follow-on).
18
- require "psych"
19
- require "yeptris/psych/drop_in"
10
+ # YAML through stdlib Psych. The former yeptris drop-in rebind
11
+ # (require "yeptris/psych/drop_in") replaced ::Psych for the WHOLE
12
+ # host process: every gem that later loaded YAML — glossarist/iev's
13
+ # ISO 639 table, metanorma-cli's collections, anything psych-lenient
14
+ # — silently changed engine, and yeptris 0.6.28.4 diverges from psych
15
+ # (block-scalar headers at column 1, permitted_classes ignored; see
16
+ # yeptris-ruby#258). A library must never rebind global constants;
17
+ # the namespaced Yeptris::Psych API remains the opt-in path for hosts
18
+ # that want it.
19
+ require "yaml"
20
20
 
21
21
  module Relaton
22
22
  autoload :Logger, "relaton/logger"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaton
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.pre.alpha.7
4
+ version: 3.0.0.pre.alpha.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -94,6 +94,20 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '1.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: sqlite3
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.7'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.7'
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: csv
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -933,6 +947,7 @@ files:
933
947
  - lib/relaton/index/file_storage.rb
934
948
  - lib/relaton/index/pool.rb
935
949
  - lib/relaton/index/shard_source.rb
950
+ - lib/relaton/index/sqlite_backend.rb
936
951
  - lib/relaton/index/type.rb
937
952
  - lib/relaton/index/util.rb
938
953
  - lib/relaton/isbn.rb