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.
@@ -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
- # busy_timeout MUST come first: other PRAGMAs (journal_mode in
140
- # particular) can themselves block on the write lock under
141
- # concurrent open, and without busy_timeout set they fail
142
- # immediately with SQLITE_BUSY.
143
- @db.execute("PRAGMA busy_timeout = 30000")
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 = false
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
- @db.execute("INSERT INTO meta (key, value) VALUES ('schema_version', ?)", SCHEMA_VERSION.to_s)
155
- @db.execute("INSERT INTO meta (key, value) VALUES ('max_values_per_position', ?)",
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
- @db.transaction
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
- card = @db.get_first_value(
262
- "SELECT COUNT(*) FROM position_values WHERE host = ? AND scope = ? AND locator = ?",
263
- [host, scope, locator],
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
- if examples_count < Cluster::MAX_EXAMPLES
288
- @db.execute(<<~SQL, [key, examples_count, identifier.canonical])
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 key, host, scheme, shape, count FROM clusters WHERE key = ?", [key],
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: row[0], host: row[1], scheme: row[2], shape: row[3],
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[4])
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