iriq 0.33.0 → 0.35.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 +93 -0
- data/README.md +187 -50
- data/lib/iriq/cli.rb +131 -60
- data/lib/iriq/cluster.rb +15 -9
- data/lib/iriq/corpus.rb +233 -117
- data/lib/iriq/errors.rb +9 -0
- data/lib/iriq/normalizer.rb +21 -18
- data/lib/iriq/observation.rb +11 -6
- data/lib/iriq/position_evidence.rb +31 -0
- data/lib/iriq/position_stats.rb +4 -2
- data/lib/iriq/recognizer_proposal.rb +13 -1
- data/lib/iriq/reducer.rb +1 -2
- data/lib/iriq/segment_classifier.rb +9 -1
- data/lib/iriq/storage/json.rb +21 -7
- data/lib/iriq/storage/memory.rb +52 -10
- data/lib/iriq/storage/sqlite.rb +291 -41
- data/lib/iriq/storage.rb +18 -0
- data/lib/iriq/trace.rb +29 -33
- data/lib/iriq/version.rb +1 -1
- data/lib/iriq.rb +1 -0
- metadata +2 -1
data/lib/iriq/storage/sqlite.rb
CHANGED
|
@@ -13,6 +13,24 @@ module Iriq
|
|
|
13
13
|
class Sqlite
|
|
14
14
|
SCHEMA_VERSION = 4
|
|
15
15
|
|
|
16
|
+
# Processes share a corpus's write lock by one rule: a writer waits up
|
|
17
|
+
# to LOCK_WAIT seconds for its turn, and none keeps the lock longer
|
|
18
|
+
# than a turn of work.
|
|
19
|
+
LOCK_WAIT = 10
|
|
20
|
+
LOCK_TURN = 1.0
|
|
21
|
+
# How long a writer that used a whole turn leaves the lock free: many
|
|
22
|
+
# of a waiter's 1ms busy retries, and a fair chance at an older iriq's
|
|
23
|
+
# 100ms ones.
|
|
24
|
+
TURN_PAUSE = 0.02
|
|
25
|
+
|
|
26
|
+
# Every table derived from the observation log.
|
|
27
|
+
VIEW_TABLES = %w[
|
|
28
|
+
host_counts path_length_counts raw_shape_counts fingerprint_counts
|
|
29
|
+
position_stats position_values position_types
|
|
30
|
+
clusters cluster_examples cluster_segments
|
|
31
|
+
cluster_params cluster_param_values cluster_param_types
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
16
34
|
SCHEMA = <<~SQL.freeze
|
|
17
35
|
CREATE TABLE IF NOT EXISTS meta (
|
|
18
36
|
key TEXT PRIMARY KEY,
|
|
@@ -128,6 +146,21 @@ module Iriq
|
|
|
128
146
|
def self.open(path, classifier: SegmentClassifier::DEFAULT,
|
|
129
147
|
max_values_per_position: PositionStats::DEFAULT_MAX_VALUES)
|
|
130
148
|
new(path: path, classifier: classifier, max_values_per_position: max_values_per_position).tap(&:setup!)
|
|
149
|
+
rescue SQLite3::Exception => e
|
|
150
|
+
raise corpus_error(path, e)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A failure SQLite reports (not a database, can't open, read-only, full
|
|
154
|
+
# disk) reads like every other corpus failure: `corpus PATH: cause`. The
|
|
155
|
+
# gem appends the failing SQL to its message; the cause is the part before.
|
|
156
|
+
def self.corpus_error(path, e)
|
|
157
|
+
# Busy only ever means the wait for another process's lock ran out.
|
|
158
|
+
cause = if e.is_a?(SQLite3::BusyException)
|
|
159
|
+
"another process held the corpus lock for over #{LOCK_WAIT}s"
|
|
160
|
+
else
|
|
161
|
+
e.message.split(":\n", 2).first
|
|
162
|
+
end
|
|
163
|
+
CorpusError.new("corpus #{path}: #{cause}")
|
|
131
164
|
end
|
|
132
165
|
|
|
133
166
|
def initialize(path:, classifier: SegmentClassifier::DEFAULT,
|
|
@@ -136,18 +169,54 @@ module Iriq
|
|
|
136
169
|
@classifier = classifier
|
|
137
170
|
@max_values_per_position = max_values_per_position
|
|
138
171
|
@db = SQLite3::Database.new(path)
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
@db.execute("PRAGMA busy_timeout = 30000")
|
|
172
|
+
# Before any PRAGMA: journal_mode itself can wait on a lock. SQLite's
|
|
173
|
+
# own busy_timeout backs off to 100ms between retries, which would
|
|
174
|
+
# mostly miss a TURN_PAUSE; this one retries every 1ms.
|
|
175
|
+
@db.busy_handler_timeout = LOCK_WAIT * 1000
|
|
144
176
|
enable_wal!
|
|
145
177
|
@db.execute("PRAGMA synchronous = NORMAL")
|
|
178
|
+
# Up to 64MB of pages (a ceiling, not an allocation): an ingest that
|
|
179
|
+
# commits a turn at a time re-reads the pages it wrote last turn, and
|
|
180
|
+
# SQLite's default 2MB cache turns that into disk reads.
|
|
181
|
+
@db.execute("PRAGMA cache_size = -64000")
|
|
146
182
|
@db.execute("PRAGMA foreign_keys = ON")
|
|
147
|
-
@in_batch
|
|
183
|
+
@in_batch = false
|
|
184
|
+
@in_transaction = false
|
|
185
|
+
# Values tracked per position, remembered so each new value needn't
|
|
186
|
+
# re-count them. Exact only inside a transaction (the write lock is
|
|
187
|
+
# held) and while @counts_version still matches.
|
|
188
|
+
@value_counts = {}
|
|
189
|
+
# PRAGMA data_version when @value_counts was last known exact; it
|
|
190
|
+
# changes only when another connection commits.
|
|
191
|
+
@counts_version = nil
|
|
192
|
+
# When the transaction in progress took the write lock, and until
|
|
193
|
+
# when a connection that used a whole turn leaves the lock free.
|
|
194
|
+
@locked_at = nil
|
|
195
|
+
@next_turn = nil
|
|
196
|
+
# A rebuild is in progress: TEMP tables named like the views shadow
|
|
197
|
+
# them for this connection alone, so every view statement writes the
|
|
198
|
+
# rebuild. Its own transaction touches only those tables; it ends
|
|
199
|
+
# before a log read, so the read sees the latest log, and before the
|
|
200
|
+
# write lock is taken.
|
|
201
|
+
@rebuilding = false
|
|
202
|
+
@rebuild_txn = false
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Checked before SCHEMA runs, so iriq never adds tables to a corpus
|
|
206
|
+
# written by a newer iriq.
|
|
207
|
+
def refuse_newer_schema!
|
|
208
|
+
has_meta = @db.get_first_value("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'")
|
|
209
|
+
return unless has_meta
|
|
210
|
+
|
|
211
|
+
version = @db.get_first_value("SELECT value FROM meta WHERE key = 'schema_version'").to_i
|
|
212
|
+
return if version <= SCHEMA_VERSION
|
|
213
|
+
|
|
214
|
+
@db.close
|
|
215
|
+
raise CorpusError, "corpus #{@path}: schema version #{version} is newer than this iriq supports (#{SCHEMA_VERSION}); upgrade iriq"
|
|
148
216
|
end
|
|
149
217
|
|
|
150
218
|
def setup!
|
|
219
|
+
refuse_newer_schema!
|
|
151
220
|
@db.execute_batch(SCHEMA)
|
|
152
221
|
existing = @db.get_first_value("SELECT value FROM meta WHERE key = 'schema_version'")
|
|
153
222
|
if existing.nil?
|
|
@@ -170,32 +239,119 @@ module Iriq
|
|
|
170
239
|
# no-ops — the outer batch wraps everything in one txn for speed.
|
|
171
240
|
return yield(self) if @in_batch
|
|
172
241
|
|
|
173
|
-
|
|
174
|
-
yield self
|
|
175
|
-
@db.commit
|
|
176
|
-
rescue
|
|
177
|
-
@db.rollback rescue nil
|
|
178
|
-
raise
|
|
242
|
+
write_transaction { yield self }
|
|
179
243
|
end
|
|
180
244
|
|
|
181
245
|
# Wrap many observations in a single transaction. Cuts SQLite write
|
|
182
|
-
# overhead from O(observations) fsyncs to O(1).
|
|
246
|
+
# overhead from O(observations) fsyncs to O(1). Yields whether another
|
|
247
|
+
# connection may have committed since this one's last transaction.
|
|
183
248
|
def batch
|
|
184
|
-
return yield if @in_batch
|
|
249
|
+
return yield(false) if @in_batch
|
|
185
250
|
|
|
186
251
|
@in_batch = true
|
|
187
|
-
@db.transaction
|
|
188
252
|
begin
|
|
189
|
-
yield
|
|
190
|
-
@db.commit
|
|
191
|
-
rescue
|
|
192
|
-
@db.rollback rescue nil
|
|
193
|
-
raise
|
|
253
|
+
write_transaction { |changed| yield changed }
|
|
194
254
|
ensure
|
|
195
255
|
@in_batch = false
|
|
196
256
|
end
|
|
197
257
|
end
|
|
198
258
|
|
|
259
|
+
# IMMEDIATE takes the write lock up front. A deferred transaction that
|
|
260
|
+
# reads first can't upgrade once another process commits: SQLite
|
|
261
|
+
# reports busy without consulting busy_timeout.
|
|
262
|
+
private def write_transaction
|
|
263
|
+
if @rebuild_txn
|
|
264
|
+
# A rebuild's writes so far are its own; keep them while waiting.
|
|
265
|
+
@db.commit
|
|
266
|
+
@rebuild_txn = false
|
|
267
|
+
end
|
|
268
|
+
pause = @next_turn && (@next_turn - monotonic_now)
|
|
269
|
+
@next_turn = nil
|
|
270
|
+
sleep(pause) if pause&.positive?
|
|
271
|
+
@db.transaction(:immediate)
|
|
272
|
+
@locked_at = monotonic_now
|
|
273
|
+
@in_transaction = true
|
|
274
|
+
# Under the write lock no one else can commit until we do, so the
|
|
275
|
+
# version read now holds for the whole transaction. An unknown last
|
|
276
|
+
# version (first transaction, or after a rollback) counts as changed.
|
|
277
|
+
version = @db.get_first_value("PRAGMA data_version")
|
|
278
|
+
changed = version != @counts_version
|
|
279
|
+
@value_counts.clear if changed
|
|
280
|
+
@counts_version = version
|
|
281
|
+
result = yield changed
|
|
282
|
+
@db.commit
|
|
283
|
+
result
|
|
284
|
+
rescue => e
|
|
285
|
+
@db.rollback rescue nil
|
|
286
|
+
@value_counts.clear
|
|
287
|
+
@counts_version = nil
|
|
288
|
+
raise corpus_error(e)
|
|
289
|
+
ensure
|
|
290
|
+
# One that used a whole turn leaves the lock free a moment.
|
|
291
|
+
@next_turn = monotonic_now + TURN_PAUSE if @locked_at && monotonic_now - @locked_at >= LOCK_TURN
|
|
292
|
+
@locked_at = nil
|
|
293
|
+
@in_transaction = false
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Whether the transaction in progress has held the write lock for a
|
|
297
|
+
# whole turn, so a long-running writer should commit and let others in.
|
|
298
|
+
def turn_over?
|
|
299
|
+
!@locked_at.nil? && monotonic_now - @locked_at >= LOCK_TURN
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# --- Rebuilding the views ---------------------------------------------
|
|
303
|
+
#
|
|
304
|
+
# Rebuild out of sight: view writes after begin_rebuild go to fresh,
|
|
305
|
+
# empty views only this connection sees, until install_rebuild (inside a
|
|
306
|
+
# transaction) makes them the corpus's views. discard_rebuild drops a
|
|
307
|
+
# rebuild not installed, and also runs after an install whose
|
|
308
|
+
# transaction rolled back, which brings the TEMP tables back. Other
|
|
309
|
+
# connections keep reading and writing the live views meanwhile.
|
|
310
|
+
|
|
311
|
+
def begin_rebuild
|
|
312
|
+
discard_rebuild
|
|
313
|
+
VIEW_TABLES.each do |table|
|
|
314
|
+
# The live table's own definition, so the copy lines up column for
|
|
315
|
+
# column whichever iriq created the corpus.
|
|
316
|
+
sql = @db.get_first_value("SELECT sql FROM main.sqlite_master WHERE type = 'table' AND name = ?", [table])
|
|
317
|
+
@db.execute(sql.sub("CREATE TABLE", "CREATE TEMP TABLE"))
|
|
318
|
+
end
|
|
319
|
+
unless @in_transaction
|
|
320
|
+
@db.transaction
|
|
321
|
+
@rebuild_txn = true
|
|
322
|
+
end
|
|
323
|
+
@value_counts.clear
|
|
324
|
+
@rebuilding = true
|
|
325
|
+
rescue SQLite3::Exception => e
|
|
326
|
+
raise corpus_error(e)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def install_rebuild
|
|
330
|
+
VIEW_TABLES.each do |table|
|
|
331
|
+
@db.execute("DELETE FROM main.#{table}")
|
|
332
|
+
@db.execute("INSERT INTO main.#{table} SELECT * FROM temp.#{table}")
|
|
333
|
+
@db.execute("DROP TABLE temp.#{table}")
|
|
334
|
+
end
|
|
335
|
+
@rebuilding = false
|
|
336
|
+
@value_counts.clear
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def discard_rebuild
|
|
340
|
+
if @rebuild_txn
|
|
341
|
+
@db.rollback rescue nil
|
|
342
|
+
@rebuild_txn = false
|
|
343
|
+
end
|
|
344
|
+
VIEW_TABLES.each { |table| @db.execute("DROP TABLE IF EXISTS temp.#{table}") rescue nil }
|
|
345
|
+
@rebuilding = false
|
|
346
|
+
@value_counts.clear
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# SQLite failures inside a transaction become CorpusErrors; anything else
|
|
350
|
+
# passes through.
|
|
351
|
+
def corpus_error(e)
|
|
352
|
+
e.is_a?(SQLite3::Exception) ? Sqlite.corpus_error(@path, e) : e
|
|
353
|
+
end
|
|
354
|
+
|
|
199
355
|
# Saving is automatic — incremental UPSERTs hit disk on commit. flush
|
|
200
356
|
# makes that explicit; close releases the connection.
|
|
201
357
|
def flush; end
|
|
@@ -261,16 +417,18 @@ module Iriq
|
|
|
261
417
|
WHERE host = ? AND scope = ? AND locator = ? AND value = ?
|
|
262
418
|
SQL
|
|
263
419
|
if @db.changes.zero?
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
420
|
+
where = [host, scope, locator]
|
|
421
|
+
card = (counts_exact? && @value_counts[where]) || @db.get_first_value(
|
|
422
|
+
"SELECT COUNT(*) FROM position_values WHERE host = ? AND scope = ? AND locator = ?", where,
|
|
267
423
|
)
|
|
268
424
|
if card < @max_values_per_position
|
|
269
425
|
@db.execute(
|
|
270
426
|
"INSERT INTO position_values (host, scope, locator, value, count) VALUES (?, ?, ?, ?, 1)",
|
|
271
427
|
[host, scope, locator, value],
|
|
272
428
|
)
|
|
429
|
+
card += 1
|
|
273
430
|
end
|
|
431
|
+
@value_counts[where] = card if counts_exact?
|
|
274
432
|
end
|
|
275
433
|
end
|
|
276
434
|
|
|
@@ -341,8 +499,6 @@ module Iriq
|
|
|
341
499
|
end
|
|
342
500
|
end
|
|
343
501
|
end
|
|
344
|
-
|
|
345
|
-
load_cluster(key)
|
|
346
502
|
end
|
|
347
503
|
|
|
348
504
|
# Append a canonical IRI to the source-IRI log. Inside the same
|
|
@@ -358,6 +514,18 @@ module Iriq
|
|
|
358
514
|
end
|
|
359
515
|
end
|
|
360
516
|
|
|
517
|
+
# The observations logged after `mark` (0 for all), in id order;
|
|
518
|
+
# returns the id of the last one (`mark` when there are none).
|
|
519
|
+
def each_observed_iri_since(mark)
|
|
520
|
+
if @rebuild_txn
|
|
521
|
+
@db.commit
|
|
522
|
+
@db.transaction
|
|
523
|
+
end
|
|
524
|
+
rows = @db.execute("SELECT id, canonical FROM observed_iris WHERE id > ? ORDER BY id", [mark])
|
|
525
|
+
rows.each { |_id, canonical| yield canonical }
|
|
526
|
+
rows.empty? ? mark : rows.last[0]
|
|
527
|
+
end
|
|
528
|
+
|
|
361
529
|
def observed_iri_count
|
|
362
530
|
@db.get_first_value("SELECT COUNT(*) FROM observed_iris") || 0
|
|
363
531
|
end
|
|
@@ -384,6 +552,7 @@ module Iriq
|
|
|
384
552
|
# Drop every materialized view without touching the source-IRI log.
|
|
385
553
|
# Corpus#reinfer calls this before replaying the log.
|
|
386
554
|
def clear_materialized_views
|
|
555
|
+
@value_counts.clear
|
|
387
556
|
@db.execute_batch(<<~SQL)
|
|
388
557
|
DELETE FROM host_counts;
|
|
389
558
|
DELETE FROM path_length_counts;
|
|
@@ -409,7 +578,7 @@ module Iriq
|
|
|
409
578
|
|
|
410
579
|
def path_length_counts
|
|
411
580
|
h = Hash.new(0)
|
|
412
|
-
@db.execute("SELECT length, count FROM path_length_counts") { |r| h[r[0]] = r[1] }
|
|
581
|
+
@db.execute("SELECT length, count FROM path_length_counts") { |r| h[r[0]] = coerce_int!(r[1], 1, "count") }
|
|
413
582
|
h
|
|
414
583
|
end
|
|
415
584
|
|
|
@@ -432,20 +601,20 @@ module Iriq
|
|
|
432
601
|
return nil if total.nil?
|
|
433
602
|
|
|
434
603
|
stats = PositionStats.new(max_values: @max_values_per_position)
|
|
435
|
-
stats.instance_variable_set(:@total, total)
|
|
604
|
+
stats.instance_variable_set(:@total, coerce_int!(total, 0, "total"))
|
|
436
605
|
|
|
437
606
|
vc = Hash.new(0)
|
|
438
607
|
@db.execute(
|
|
439
608
|
"SELECT value, count FROM position_values WHERE host = ? AND scope = ? AND locator = ?",
|
|
440
609
|
[host, scope, locator],
|
|
441
|
-
) { |r| vc[r[0]] = r[1] }
|
|
610
|
+
) { |r| vc[r[0]] = coerce_int!(r[1], 1, "count") }
|
|
442
611
|
stats.instance_variable_set(:@value_counts, vc)
|
|
443
612
|
|
|
444
613
|
tc = Hash.new(0)
|
|
445
614
|
@db.execute(
|
|
446
615
|
"SELECT type, count FROM position_types WHERE host = ? AND scope = ? AND locator = ?",
|
|
447
616
|
[host, scope, locator],
|
|
448
|
-
) { |r| tc[r[0].to_sym] = r[1] }
|
|
617
|
+
) { |r| tc[r[0].to_sym] = coerce_int!(r[1], 1, "count") }
|
|
449
618
|
stats.instance_variable_set(:@type_counts, tc)
|
|
450
619
|
|
|
451
620
|
recompute_numeric!(stats)
|
|
@@ -479,8 +648,65 @@ module Iriq
|
|
|
479
648
|
load_cluster(key)
|
|
480
649
|
end
|
|
481
650
|
|
|
651
|
+
# What Corpus#classify reads: counts, not every value tracked at the
|
|
652
|
+
# position.
|
|
653
|
+
def position_evidence(position, value)
|
|
654
|
+
where = [position.host || "", position.scope.to_s, position.locator]
|
|
655
|
+
total = @db.get_first_value(
|
|
656
|
+
"SELECT total FROM position_stats WHERE host = ? AND scope = ? AND locator = ?", where,
|
|
657
|
+
)
|
|
658
|
+
return nil if total.nil?
|
|
659
|
+
|
|
660
|
+
type_counts = Hash.new(0)
|
|
661
|
+
@db.execute(
|
|
662
|
+
"SELECT type, count FROM position_types WHERE host = ? AND scope = ? AND locator = ?", where,
|
|
663
|
+
) { |r| type_counts[r[0].to_sym] = coerce_int!(r[1], 1, "count") }
|
|
664
|
+
value_count = @db.get_first_value(
|
|
665
|
+
"SELECT count FROM position_values WHERE host = ? AND scope = ? AND locator = ? AND value = ?",
|
|
666
|
+
[*where, value],
|
|
667
|
+
)
|
|
668
|
+
PositionEvidence.new(
|
|
669
|
+
total: coerce_int!(total, 0, "total"),
|
|
670
|
+
type_counts: type_counts,
|
|
671
|
+
cardinality: @db.get_first_value(
|
|
672
|
+
"SELECT COUNT(*) FROM position_values WHERE host = ? AND scope = ? AND locator = ?", where,
|
|
673
|
+
),
|
|
674
|
+
value_count: value_count.nil? ? nil : coerce_int!(value_count, 0, "count"),
|
|
675
|
+
)
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# One query param's stats — narrower than cluster_for, which also loads
|
|
679
|
+
# the cluster's examples and segment counts.
|
|
680
|
+
def param_stats(key, name)
|
|
681
|
+
total = @db.get_first_value(
|
|
682
|
+
"SELECT total FROM cluster_params WHERE cluster_key = ? AND name = ?", [key, name],
|
|
683
|
+
)
|
|
684
|
+
return nil if total.nil?
|
|
685
|
+
|
|
686
|
+
stats = PositionStats.new(max_values: @max_values_per_position)
|
|
687
|
+
stats.instance_variable_set(:@total, coerce_int!(total, 0, "total"))
|
|
688
|
+
@db.execute(
|
|
689
|
+
"SELECT value, count FROM cluster_param_values WHERE cluster_key = ? AND name = ?", [key, name],
|
|
690
|
+
) { |r| stats.value_counts[r[0]] = coerce_int!(r[1], 1, "count") }
|
|
691
|
+
@db.execute(
|
|
692
|
+
"SELECT type, count FROM cluster_param_types WHERE cluster_key = ? AND name = ?", [key, name],
|
|
693
|
+
) { |r| stats.type_counts[r[0].to_sym] = coerce_int!(r[1], 1, "count") }
|
|
694
|
+
recompute_numeric!(stats)
|
|
695
|
+
stats
|
|
696
|
+
end
|
|
697
|
+
|
|
482
698
|
private
|
|
483
699
|
|
|
700
|
+
# Whether remembered value counts can be trusted: under the write lock,
|
|
701
|
+
# or while the views written are a rebuild nothing else writes.
|
|
702
|
+
def counts_exact?
|
|
703
|
+
@in_transaction || @rebuilding
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def monotonic_now
|
|
707
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
708
|
+
end
|
|
709
|
+
|
|
484
710
|
# Converting a rollback-mode database to WAL takes an exclusive lock,
|
|
485
711
|
# and SQLite does NOT consult the busy handler for that lock — so
|
|
486
712
|
# concurrent first-opens of a fresh corpus can fail with SQLITE_BUSY
|
|
@@ -488,11 +714,11 @@ module Iriq
|
|
|
488
714
|
# retry briefly — either this connection wins the conversion or
|
|
489
715
|
# another process already converted the file.
|
|
490
716
|
def enable_wal!
|
|
491
|
-
|
|
717
|
+
deadline = monotonic_now + LOCK_WAIT
|
|
492
718
|
begin
|
|
493
719
|
@db.execute("PRAGMA journal_mode = WAL")
|
|
494
720
|
rescue SQLite3::BusyException
|
|
495
|
-
raise if
|
|
721
|
+
raise if monotonic_now > deadline
|
|
496
722
|
|
|
497
723
|
sleep 0.01
|
|
498
724
|
retry
|
|
@@ -508,21 +734,45 @@ module Iriq
|
|
|
508
734
|
|
|
509
735
|
def rows_to_count_hash(table, key_col)
|
|
510
736
|
h = Hash.new(0)
|
|
511
|
-
@db.execute("SELECT #{key_col}, count FROM #{table}") { |r| h[r[0]] = r[1] }
|
|
737
|
+
@db.execute("SELECT #{key_col}, count FROM #{table}") { |r| h[r[0]] = coerce_int!(r[1], 1, "count") }
|
|
512
738
|
h
|
|
513
739
|
end
|
|
514
740
|
|
|
741
|
+
# rusqlite's storage class name for a value SQLite handed back untyped
|
|
742
|
+
# for an INTEGER column — matches the words rust/ raises for the same
|
|
743
|
+
# corrupted row so the two runtimes agree.
|
|
744
|
+
SQLITE_TYPE_NAMES = {
|
|
745
|
+
NilClass => "Null",
|
|
746
|
+
Float => "Real",
|
|
747
|
+
String => "Text",
|
|
748
|
+
}.freeze
|
|
749
|
+
|
|
750
|
+
# Every count/total column is declared INTEGER, but SQLite's affinity
|
|
751
|
+
# rules only convert a value on write when that can be done losslessly
|
|
752
|
+
# — a hand-edited (or otherwise corrupted) corpus can still leave a
|
|
753
|
+
# Text or Real value behind. Reads validate the type here instead of
|
|
754
|
+
# crashing downstream on Integer#+ / Array#sum / Integer#times.
|
|
755
|
+
def coerce_int!(value, index, name)
|
|
756
|
+
return value if value.is_a?(Integer)
|
|
757
|
+
|
|
758
|
+
type_name = SQLITE_TYPE_NAMES.fetch(value.class, "Blob")
|
|
759
|
+
raise CorpusError, "corpus #{@path}: Invalid column type #{type_name} at index: #{index}, name: #{name}"
|
|
760
|
+
end
|
|
761
|
+
|
|
515
762
|
def load_cluster(key)
|
|
763
|
+
# key isn't re-selected — it's already the query parameter (matches
|
|
764
|
+
# the Rust reader's column list, so a corrupted `count` reports the
|
|
765
|
+
# same column index in both runtimes).
|
|
516
766
|
row = @db.get_first_row(
|
|
517
|
-
"SELECT
|
|
767
|
+
"SELECT host, scheme, shape, count FROM clusters WHERE key = ?", [key],
|
|
518
768
|
)
|
|
519
769
|
return nil unless row
|
|
520
770
|
|
|
521
771
|
c = Cluster.new(
|
|
522
|
-
key:
|
|
772
|
+
key: key, host: row[0], scheme: row[1], shape: row[2],
|
|
523
773
|
max_values: @max_values_per_position,
|
|
524
774
|
)
|
|
525
|
-
c.instance_variable_set(:@count, row[
|
|
775
|
+
c.instance_variable_set(:@count, coerce_int!(row[3], 3, "count"))
|
|
526
776
|
|
|
527
777
|
examples = []
|
|
528
778
|
@db.execute(
|
|
@@ -535,9 +785,9 @@ module Iriq
|
|
|
535
785
|
"SELECT position, value, count FROM cluster_segments WHERE cluster_key = ? ORDER BY position",
|
|
536
786
|
[key],
|
|
537
787
|
) do |r|
|
|
538
|
-
pos = r[0]
|
|
788
|
+
pos = coerce_int!(r[0], 0, "position")
|
|
539
789
|
seg_counts[pos] ||= Hash.new(0)
|
|
540
|
-
seg_counts[pos][r[1]] = r[2]
|
|
790
|
+
seg_counts[pos][r[1]] = coerce_int!(r[2], 2, "count")
|
|
541
791
|
end
|
|
542
792
|
c.instance_variable_set(:@segment_counts, seg_counts)
|
|
543
793
|
|
|
@@ -550,20 +800,20 @@ module Iriq
|
|
|
550
800
|
# and type counts; only @total needs filling here. The followup
|
|
551
801
|
# SELECTs below populate value/type rows in place.
|
|
552
802
|
stats = PositionStats.new(max_values: @max_values_per_position)
|
|
553
|
-
stats.instance_variable_set(:@total, r[1])
|
|
803
|
+
stats.instance_variable_set(:@total, coerce_int!(r[1], 1, "total"))
|
|
554
804
|
params[r[0]] = stats
|
|
555
805
|
end
|
|
556
806
|
@db.execute(
|
|
557
807
|
"SELECT name, value, count FROM cluster_param_values WHERE cluster_key = ?", [key],
|
|
558
808
|
) do |r|
|
|
559
809
|
stats = params[r[0]] or next
|
|
560
|
-
stats.value_counts[r[1]] = r[2]
|
|
810
|
+
stats.value_counts[r[1]] = coerce_int!(r[2], 2, "count")
|
|
561
811
|
end
|
|
562
812
|
@db.execute(
|
|
563
813
|
"SELECT name, type, count FROM cluster_param_types WHERE cluster_key = ?", [key],
|
|
564
814
|
) do |r|
|
|
565
815
|
stats = params[r[0]] or next
|
|
566
|
-
stats.type_counts[r[1].to_sym] = r[2]
|
|
816
|
+
stats.type_counts[r[1].to_sym] = coerce_int!(r[2], 2, "count")
|
|
567
817
|
end
|
|
568
818
|
params.each_value { |stats| recompute_numeric!(stats) }
|
|
569
819
|
c.instance_variable_set(:@param_stats, params)
|
|
@@ -584,7 +834,7 @@ module Iriq
|
|
|
584
834
|
sum = 0.0
|
|
585
835
|
stats.value_counts.each do |value, n|
|
|
586
836
|
num = Float(value, exception: false)
|
|
587
|
-
next unless num
|
|
837
|
+
next unless num&.finite? # same rule as PositionStats#record_numeric
|
|
588
838
|
|
|
589
839
|
count += n
|
|
590
840
|
min = num if min.nil? || num < min
|
data/lib/iriq/storage.rb
CHANGED
|
@@ -13,6 +13,10 @@ module Iriq
|
|
|
13
13
|
module Storage
|
|
14
14
|
SQLITE_EXTS = %w[.db .sqlite .sqlite3].freeze
|
|
15
15
|
|
|
16
|
+
TEMP_SEQ_LOCK = Mutex.new
|
|
17
|
+
@temp_seq = 0
|
|
18
|
+
def self.next_temp_seq = TEMP_SEQ_LOCK.synchronize { @temp_seq += 1 }
|
|
19
|
+
|
|
16
20
|
module_function
|
|
17
21
|
|
|
18
22
|
# Opens (or creates) a storage at `path`, picking the backend by extension.
|
|
@@ -29,6 +33,20 @@ module Iriq
|
|
|
29
33
|
Json.open(path, classifier: classifier, max_values_per_position: max_values_per_position)
|
|
30
34
|
end
|
|
31
35
|
end
|
|
36
|
+
|
|
37
|
+
# Replace `path` atomically via a per-write temp file, PATH.<pid>.<n>.tmp
|
|
38
|
+
# (Rust's writer uses the same shape; --reset sweeps exactly it), + rename.
|
|
39
|
+
# A shared PATH.tmp let concurrent writers rename each other's file away
|
|
40
|
+
# (ENOENT). Last writer still wins: a JSON corpus is single-writer.
|
|
41
|
+
def write_atomically(path, contents)
|
|
42
|
+
tmp = "#{path}.#{Process.pid}.#{Storage.next_temp_seq}.tmp"
|
|
43
|
+
File.write(tmp, contents)
|
|
44
|
+
File.rename(tmp, path)
|
|
45
|
+
rescue SystemCallError => e
|
|
46
|
+
raise CorpusError, "corpus #{path}: #{Iriq.os_error_message(e)}"
|
|
47
|
+
ensure
|
|
48
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
49
|
+
end
|
|
32
50
|
end
|
|
33
51
|
end
|
|
34
52
|
|
data/lib/iriq/trace.rb
CHANGED
|
@@ -31,8 +31,9 @@ module Iriq
|
|
|
31
31
|
input: iri.canonical,
|
|
32
32
|
normalized: normalized,
|
|
33
33
|
scheme: iri.scheme,
|
|
34
|
-
host: iri.host,
|
|
35
34
|
}
|
|
35
|
+
# Fields that don't apply are omitted, not null (matches parse JSON).
|
|
36
|
+
out[:host] = iri.host if iri.host
|
|
36
37
|
out[:port] = iri.port if iri.port
|
|
37
38
|
|
|
38
39
|
if iri.urn?
|
|
@@ -84,25 +85,24 @@ module Iriq
|
|
|
84
85
|
payload: { type: entry[:type], variable: entry[:variable], hint: entry[:hint] },
|
|
85
86
|
)
|
|
86
87
|
|
|
88
|
+
# The canonical policy applies even when the value is already canonical
|
|
89
|
+
# (that's what makes the row print the value, not a placeholder); only
|
|
90
|
+
# the note is reserved for an actual rewrite.
|
|
87
91
|
if entry[:variable]
|
|
88
92
|
if entry[:type] == :date && (canon = SegmentClassifier.canonical_date(entry[:value]))
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)
|
|
96
|
-
end
|
|
93
|
+
records << Evidence.segment(
|
|
94
|
+
index: idx, value: entry[:value],
|
|
95
|
+
source: :policy,
|
|
96
|
+
payload: { rule: :canonical_date, before: entry[:value], after: canon },
|
|
97
|
+
notes: canon == entry[:value] ? [] : ["canonical date (#{entry[:value]} → #{canon})"],
|
|
98
|
+
)
|
|
97
99
|
elsif entry[:type] == :currency && (canon = SegmentClassifier.canonical_currency(entry[:value]))
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
)
|
|
105
|
-
end
|
|
100
|
+
records << Evidence.segment(
|
|
101
|
+
index: idx, value: entry[:value],
|
|
102
|
+
source: :policy,
|
|
103
|
+
payload: { rule: :canonical_currency, before: entry[:value], after: canon },
|
|
104
|
+
notes: canon == entry[:value] ? [] : ["currency upcase (#{entry[:value]} → #{canon})"],
|
|
105
|
+
)
|
|
106
106
|
else
|
|
107
107
|
extra = placeholder_decoration_evidence(entry, segments, idx, classifier, hints)
|
|
108
108
|
records.concat(extra)
|
|
@@ -162,23 +162,19 @@ module Iriq
|
|
|
162
162
|
)
|
|
163
163
|
|
|
164
164
|
if effective == :date && (canon = SegmentClassifier.canonical_date(value))
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
)
|
|
172
|
-
end
|
|
165
|
+
records << Evidence.segment(
|
|
166
|
+
index: name, value: value,
|
|
167
|
+
source: :policy,
|
|
168
|
+
payload: { rule: :canonical_date, before: value, after: canon },
|
|
169
|
+
notes: canon == value ? [] : ["canonical date (#{value} → #{canon})"],
|
|
170
|
+
)
|
|
173
171
|
elsif effective == :currency && (canon = SegmentClassifier.canonical_currency(value))
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)
|
|
181
|
-
end
|
|
172
|
+
records << Evidence.segment(
|
|
173
|
+
index: name, value: value,
|
|
174
|
+
source: :policy,
|
|
175
|
+
payload: { rule: :canonical_currency, before: value, after: canon },
|
|
176
|
+
notes: canon == value ? [] : ["currency upcase (#{value} → #{canon})"],
|
|
177
|
+
)
|
|
182
178
|
elsif effective == :ipv4 || effective == :ipv6
|
|
183
179
|
records << Evidence.segment(
|
|
184
180
|
index: name, value: value,
|
data/lib/iriq/version.rb
CHANGED
data/lib/iriq.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: iriq
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.35.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Pepper
|
|
@@ -110,6 +110,7 @@ files:
|
|
|
110
110
|
- lib/iriq/parser.rb
|
|
111
111
|
- lib/iriq/path_shape.rb
|
|
112
112
|
- lib/iriq/position.rb
|
|
113
|
+
- lib/iriq/position_evidence.rb
|
|
113
114
|
- lib/iriq/position_stats.rb
|
|
114
115
|
- lib/iriq/recognizer.rb
|
|
115
116
|
- lib/iriq/recognizer_proposal.rb
|