iriq 0.30.2 → 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 +125 -0
- data/README.md +254 -92
- data/completions/_iriq +2 -0
- data/completions/iriq.bash +1 -1
- data/iriq.gemspec +1 -1
- data/lib/iriq/cli.rb +241 -70
- data/lib/iriq/cluster.rb +76 -24
- data/lib/iriq/clusterer.rb +0 -11
- data/lib/iriq/corpus.rb +236 -119
- data/lib/iriq/errors.rb +9 -0
- data/lib/iriq/identifier.rb +1 -1
- data/lib/iriq/normalizer.rb +21 -18
- data/lib/iriq/observation.rb +11 -6
- data/lib/iriq/parser.rb +5 -1
- data/lib/iriq/position_evidence.rb +31 -0
- data/lib/iriq/position_stats.rb +6 -4
- data/lib/iriq/recognizer.rb +1 -1
- data/lib/iriq/recognizer_proposal.rb +15 -3
- data/lib/iriq/reducer.rb +1 -2
- data/lib/iriq/segment_classifier.rb +17 -6
- data/lib/iriq/specificity.rb +3 -3
- data/lib/iriq/storage/json.rb +21 -7
- data/lib/iriq/storage/memory.rb +52 -10
- data/lib/iriq/storage/sqlite.rb +351 -44
- 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 +3 -8
- data/CLAUDE.md +0 -208
- data/Gemfile +0 -3
- data/Gemfile.lock +0 -103
- data/Makefile +0 -113
- data/docs/ARCHITECTURE.md +0 -223
- data/docs/ROADMAP.md +0 -190
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,23 +169,62 @@ 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
|
-
|
|
144
|
-
@db.execute("PRAGMA journal_mode = WAL")
|
|
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
|
|
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?
|
|
154
|
-
|
|
155
|
-
|
|
223
|
+
# OR IGNORE: two processes can race to initialize a fresh corpus
|
|
224
|
+
# concurrently — both read schema_version as nil, and the loser's
|
|
225
|
+
# INSERT must not blow up on the PRIMARY KEY.
|
|
226
|
+
@db.execute("INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', ?)", SCHEMA_VERSION.to_s)
|
|
227
|
+
@db.execute("INSERT OR IGNORE INTO meta (key, value) VALUES ('max_values_per_position', ?)",
|
|
156
228
|
@max_values_per_position.to_s)
|
|
157
229
|
else
|
|
158
230
|
@max_values_per_position = (@db.get_first_value(
|
|
@@ -167,32 +239,119 @@ module Iriq
|
|
|
167
239
|
# no-ops — the outer batch wraps everything in one txn for speed.
|
|
168
240
|
return yield(self) if @in_batch
|
|
169
241
|
|
|
170
|
-
|
|
171
|
-
yield self
|
|
172
|
-
@db.commit
|
|
173
|
-
rescue
|
|
174
|
-
@db.rollback rescue nil
|
|
175
|
-
raise
|
|
242
|
+
write_transaction { yield self }
|
|
176
243
|
end
|
|
177
244
|
|
|
178
245
|
# Wrap many observations in a single transaction. Cuts SQLite write
|
|
179
|
-
# 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.
|
|
180
248
|
def batch
|
|
181
|
-
return yield if @in_batch
|
|
249
|
+
return yield(false) if @in_batch
|
|
182
250
|
|
|
183
251
|
@in_batch = true
|
|
184
|
-
@db.transaction
|
|
185
252
|
begin
|
|
186
|
-
yield
|
|
187
|
-
@db.commit
|
|
188
|
-
rescue
|
|
189
|
-
@db.rollback rescue nil
|
|
190
|
-
raise
|
|
253
|
+
write_transaction { |changed| yield changed }
|
|
191
254
|
ensure
|
|
192
255
|
@in_batch = false
|
|
193
256
|
end
|
|
194
257
|
end
|
|
195
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
|
+
|
|
196
355
|
# Saving is automatic — incremental UPSERTs hit disk on commit. flush
|
|
197
356
|
# makes that explicit; close releases the connection.
|
|
198
357
|
def flush; end
|
|
@@ -258,16 +417,18 @@ module Iriq
|
|
|
258
417
|
WHERE host = ? AND scope = ? AND locator = ? AND value = ?
|
|
259
418
|
SQL
|
|
260
419
|
if @db.changes.zero?
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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,
|
|
264
423
|
)
|
|
265
424
|
if card < @max_values_per_position
|
|
266
425
|
@db.execute(
|
|
267
426
|
"INSERT INTO position_values (host, scope, locator, value, count) VALUES (?, ?, ?, ?, 1)",
|
|
268
427
|
[host, scope, locator, value],
|
|
269
428
|
)
|
|
429
|
+
card += 1
|
|
270
430
|
end
|
|
431
|
+
@value_counts[where] = card if counts_exact?
|
|
271
432
|
end
|
|
272
433
|
end
|
|
273
434
|
|
|
@@ -280,12 +441,18 @@ module Iriq
|
|
|
280
441
|
ON CONFLICT(key) DO UPDATE SET count = count + 1
|
|
281
442
|
SQL
|
|
282
443
|
|
|
283
|
-
# Examples — capped at Cluster::MAX_EXAMPLES
|
|
444
|
+
# Examples — capped at Cluster::MAX_EXAMPLES, deduped by canonical
|
|
445
|
+
# (mirrors Cluster#add so the SQLite view matches the in-memory one).
|
|
446
|
+
canonical = identifier.canonical
|
|
284
447
|
examples_count = @db.get_first_value(
|
|
285
448
|
"SELECT COUNT(*) FROM cluster_examples WHERE cluster_key = ?", [key],
|
|
286
449
|
)
|
|
287
|
-
|
|
288
|
-
|
|
450
|
+
already_present = @db.get_first_value(
|
|
451
|
+
"SELECT 1 FROM cluster_examples WHERE cluster_key = ? AND canonical = ?",
|
|
452
|
+
[key, canonical],
|
|
453
|
+
)
|
|
454
|
+
if examples_count < Cluster::MAX_EXAMPLES && already_present.nil?
|
|
455
|
+
@db.execute(<<~SQL, [key, examples_count, canonical])
|
|
289
456
|
INSERT INTO cluster_examples (cluster_key, position, canonical)
|
|
290
457
|
VALUES (?, ?, ?)
|
|
291
458
|
SQL
|
|
@@ -332,8 +499,6 @@ module Iriq
|
|
|
332
499
|
end
|
|
333
500
|
end
|
|
334
501
|
end
|
|
335
|
-
|
|
336
|
-
load_cluster(key)
|
|
337
502
|
end
|
|
338
503
|
|
|
339
504
|
# Append a canonical IRI to the source-IRI log. Inside the same
|
|
@@ -349,6 +514,18 @@ module Iriq
|
|
|
349
514
|
end
|
|
350
515
|
end
|
|
351
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
|
+
|
|
352
529
|
def observed_iri_count
|
|
353
530
|
@db.get_first_value("SELECT COUNT(*) FROM observed_iris") || 0
|
|
354
531
|
end
|
|
@@ -375,6 +552,7 @@ module Iriq
|
|
|
375
552
|
# Drop every materialized view without touching the source-IRI log.
|
|
376
553
|
# Corpus#reinfer calls this before replaying the log.
|
|
377
554
|
def clear_materialized_views
|
|
555
|
+
@value_counts.clear
|
|
378
556
|
@db.execute_batch(<<~SQL)
|
|
379
557
|
DELETE FROM host_counts;
|
|
380
558
|
DELETE FROM path_length_counts;
|
|
@@ -400,7 +578,7 @@ module Iriq
|
|
|
400
578
|
|
|
401
579
|
def path_length_counts
|
|
402
580
|
h = Hash.new(0)
|
|
403
|
-
@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") }
|
|
404
582
|
h
|
|
405
583
|
end
|
|
406
584
|
|
|
@@ -423,22 +601,23 @@ module Iriq
|
|
|
423
601
|
return nil if total.nil?
|
|
424
602
|
|
|
425
603
|
stats = PositionStats.new(max_values: @max_values_per_position)
|
|
426
|
-
stats.instance_variable_set(:@total, total)
|
|
604
|
+
stats.instance_variable_set(:@total, coerce_int!(total, 0, "total"))
|
|
427
605
|
|
|
428
606
|
vc = Hash.new(0)
|
|
429
607
|
@db.execute(
|
|
430
608
|
"SELECT value, count FROM position_values WHERE host = ? AND scope = ? AND locator = ?",
|
|
431
609
|
[host, scope, locator],
|
|
432
|
-
) { |r| vc[r[0]] = r[1] }
|
|
610
|
+
) { |r| vc[r[0]] = coerce_int!(r[1], 1, "count") }
|
|
433
611
|
stats.instance_variable_set(:@value_counts, vc)
|
|
434
612
|
|
|
435
613
|
tc = Hash.new(0)
|
|
436
614
|
@db.execute(
|
|
437
615
|
"SELECT type, count FROM position_types WHERE host = ? AND scope = ? AND locator = ?",
|
|
438
616
|
[host, scope, locator],
|
|
439
|
-
) { |r| tc[r[0].to_sym] = r[1] }
|
|
617
|
+
) { |r| tc[r[0].to_sym] = coerce_int!(r[1], 1, "count") }
|
|
440
618
|
stats.instance_variable_set(:@type_counts, tc)
|
|
441
619
|
|
|
620
|
+
recompute_numeric!(stats)
|
|
442
621
|
stats
|
|
443
622
|
end
|
|
444
623
|
|
|
@@ -469,8 +648,83 @@ module Iriq
|
|
|
469
648
|
load_cluster(key)
|
|
470
649
|
end
|
|
471
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
|
+
|
|
472
698
|
private
|
|
473
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
|
+
|
|
710
|
+
# Converting a rollback-mode database to WAL takes an exclusive lock,
|
|
711
|
+
# and SQLite does NOT consult the busy handler for that lock — so
|
|
712
|
+
# concurrent first-opens of a fresh corpus can fail with SQLITE_BUSY
|
|
713
|
+
# even with busy_timeout set. WAL is a persistent database property:
|
|
714
|
+
# retry briefly — either this connection wins the conversion or
|
|
715
|
+
# another process already converted the file.
|
|
716
|
+
def enable_wal!
|
|
717
|
+
deadline = monotonic_now + LOCK_WAIT
|
|
718
|
+
begin
|
|
719
|
+
@db.execute("PRAGMA journal_mode = WAL")
|
|
720
|
+
rescue SQLite3::BusyException
|
|
721
|
+
raise if monotonic_now > deadline
|
|
722
|
+
|
|
723
|
+
sleep 0.01
|
|
724
|
+
retry
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
474
728
|
def upsert_shape(table, shape)
|
|
475
729
|
@db.execute(<<~SQL, shape)
|
|
476
730
|
INSERT INTO #{table} (shape, count) VALUES (?, 1)
|
|
@@ -480,21 +734,45 @@ module Iriq
|
|
|
480
734
|
|
|
481
735
|
def rows_to_count_hash(table, key_col)
|
|
482
736
|
h = Hash.new(0)
|
|
483
|
-
@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") }
|
|
484
738
|
h
|
|
485
739
|
end
|
|
486
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
|
+
|
|
487
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).
|
|
488
766
|
row = @db.get_first_row(
|
|
489
|
-
"SELECT
|
|
767
|
+
"SELECT host, scheme, shape, count FROM clusters WHERE key = ?", [key],
|
|
490
768
|
)
|
|
491
769
|
return nil unless row
|
|
492
770
|
|
|
493
771
|
c = Cluster.new(
|
|
494
|
-
key:
|
|
772
|
+
key: key, host: row[0], scheme: row[1], shape: row[2],
|
|
495
773
|
max_values: @max_values_per_position,
|
|
496
774
|
)
|
|
497
|
-
c.instance_variable_set(:@count, row[
|
|
775
|
+
c.instance_variable_set(:@count, coerce_int!(row[3], 3, "count"))
|
|
498
776
|
|
|
499
777
|
examples = []
|
|
500
778
|
@db.execute(
|
|
@@ -507,9 +785,9 @@ module Iriq
|
|
|
507
785
|
"SELECT position, value, count FROM cluster_segments WHERE cluster_key = ? ORDER BY position",
|
|
508
786
|
[key],
|
|
509
787
|
) do |r|
|
|
510
|
-
pos = r[0]
|
|
788
|
+
pos = coerce_int!(r[0], 0, "position")
|
|
511
789
|
seg_counts[pos] ||= Hash.new(0)
|
|
512
|
-
seg_counts[pos][r[1]] = r[2]
|
|
790
|
+
seg_counts[pos][r[1]] = coerce_int!(r[2], 2, "count")
|
|
513
791
|
end
|
|
514
792
|
c.instance_variable_set(:@segment_counts, seg_counts)
|
|
515
793
|
|
|
@@ -522,25 +800,54 @@ module Iriq
|
|
|
522
800
|
# and type counts; only @total needs filling here. The followup
|
|
523
801
|
# SELECTs below populate value/type rows in place.
|
|
524
802
|
stats = PositionStats.new(max_values: @max_values_per_position)
|
|
525
|
-
stats.instance_variable_set(:@total, r[1])
|
|
803
|
+
stats.instance_variable_set(:@total, coerce_int!(r[1], 1, "total"))
|
|
526
804
|
params[r[0]] = stats
|
|
527
805
|
end
|
|
528
806
|
@db.execute(
|
|
529
807
|
"SELECT name, value, count FROM cluster_param_values WHERE cluster_key = ?", [key],
|
|
530
808
|
) do |r|
|
|
531
809
|
stats = params[r[0]] or next
|
|
532
|
-
stats.value_counts[r[1]] = r[2]
|
|
810
|
+
stats.value_counts[r[1]] = coerce_int!(r[2], 2, "count")
|
|
533
811
|
end
|
|
534
812
|
@db.execute(
|
|
535
813
|
"SELECT name, type, count FROM cluster_param_types WHERE cluster_key = ?", [key],
|
|
536
814
|
) do |r|
|
|
537
815
|
stats = params[r[0]] or next
|
|
538
|
-
stats.type_counts[r[1].to_sym] = r[2]
|
|
816
|
+
stats.type_counts[r[1].to_sym] = coerce_int!(r[2], 2, "count")
|
|
539
817
|
end
|
|
818
|
+
params.each_value { |stats| recompute_numeric!(stats) }
|
|
540
819
|
c.instance_variable_set(:@param_stats, params)
|
|
541
820
|
|
|
542
821
|
c
|
|
543
822
|
end
|
|
823
|
+
|
|
824
|
+
# The rolling numeric aggregates (count/min/max/sum) aren't stored in
|
|
825
|
+
# the schema — rebuild them from the tracked value counts, mirroring
|
|
826
|
+
# the Rust backend: only positions with integer/float observations
|
|
827
|
+
# qualify, and cap-trimmed values are lost.
|
|
828
|
+
def recompute_numeric!(stats)
|
|
829
|
+
return if (stats.type_counts[:integer] + stats.type_counts[:float]).zero?
|
|
830
|
+
|
|
831
|
+
count = 0
|
|
832
|
+
min = nil
|
|
833
|
+
max = nil
|
|
834
|
+
sum = 0.0
|
|
835
|
+
stats.value_counts.each do |value, n|
|
|
836
|
+
num = Float(value, exception: false)
|
|
837
|
+
next unless num&.finite? # same rule as PositionStats#record_numeric
|
|
838
|
+
|
|
839
|
+
count += n
|
|
840
|
+
min = num if min.nil? || num < min
|
|
841
|
+
max = num if max.nil? || num > max
|
|
842
|
+
n.times { sum += num }
|
|
843
|
+
end
|
|
844
|
+
return if count.zero?
|
|
845
|
+
|
|
846
|
+
stats.instance_variable_set(:@numeric_count, count)
|
|
847
|
+
stats.instance_variable_set(:@numeric_min, min)
|
|
848
|
+
stats.instance_variable_set(:@numeric_max, max)
|
|
849
|
+
stats.instance_variable_set(:@numeric_sum, sum)
|
|
850
|
+
end
|
|
544
851
|
end
|
|
545
852
|
end
|
|
546
853
|
end
|
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
|
|