odba 1.2.1 → 1.2.2

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: ea8626e332e7e9650d0221804e2cc6b6767e0fdedbabb507faaa2ad104b4199e
4
- data.tar.gz: '06848ad3f7def177deec9c62385e1b63c56fcbf3e6d161f3a73e60a70a0c92a9'
3
+ metadata.gz: e275634934d76a06600ef71830600a6a6cd1e03c3f59069aa94f998f25d9a973
4
+ data.tar.gz: cf0ae8760286cafa23e4d1c1c37e304aa8a548daa8438c00e58c1f2492691743
5
5
  SHA512:
6
- metadata.gz: 2d55bb2c6b46d92bd89da28489544b8fc68e8c4fd16a94f3deefef5970376fa2a607c9c9c811506c6f66147f02fcc9981148baf89acd54ae528c51c2b0a7dda6
7
- data.tar.gz: 2337e2c7910c89653bdb6ddb90150aa38794096f1c5110bd94ae0f4bd54f84006d6ae8cf4bb08bc467e1d0c78d5d28cbfd5e7788611cf75bb83739432d8f3a75
6
+ metadata.gz: 9d7005efee025dddcf75ae2e80b50266482a322916b59a7bd25fa339149a5df762e87248d4351e80acf819550ab2738d2f3cb3d04046c759bddfb48418221b69
7
+ data.tar.gz: cd31af260988120f4ab9c4b73126896f8c0ce43d68b9b4da24ffd1e968fc8fd0c3564fec466ba5f1d2ecd36c5605afe2b3b124bcd5ef2eb990b7535bc29e3751
data/History.md CHANGED
@@ -1,3 +1,29 @@
1
+ ## 1.2.2 / 31.08.2026
2
+
3
+ Two processes on one database handed out the same odba_id. Whichever wrote
4
+ last overwrote the other's row in `object`, and every reference to the lost
5
+ object then resolved to a foreign one - an Array where a domain object
6
+ belonged, or the reverse. The referring instance variable stays correct and
7
+ points at the right number; a different object simply sits under it, so
8
+ searching the application for the offending assignment finds nothing.
9
+
10
+ * `Storage#next_id` takes the id from a Postgres sequence, `odba_id_seq`,
11
+ which `#setup` creates. It used to be `@next_id += 1` under a mutex, with
12
+ @next_id seeded once per process from the highest odba_id in the table -
13
+ sound for one process, wrong for every deployment running a web worker and
14
+ an import job against the same database. Measured with two processes side
15
+ by side, both answered `[61935067, 61935068, 61935069]`. Stores without the
16
+ sequence keep the old behaviour.
17
+ * The sequence starts at `MAX(odba_id)` plus `ID_SEQUENCE_GAP`, not at 1: a
18
+ plain `CREATE SEQUENCE` would re-issue ids that already exist, and the gap
19
+ covers ids that processes still on the old counter hold but have not
20
+ written yet.
21
+ * `Cache#next_id` no longer swallows `OdbaDuplicateIdError`. The guard was
22
+ there all along - a peer raises it when the id is taken and the method
23
+ retries - but `rescue` without a class caught it too, so the retry could
24
+ never run. Only `DRb::DRbError` is caught now, which is what the line was
25
+ for: an unreachable peer must not stop the allocation.
26
+
1
27
  ## 1.2.1 / 21.08.2026
2
28
 
3
29
  No library changes: lib/ is identical to 1.2.0. This release only ships a test
data/lib/odba/cache.rb CHANGED
@@ -441,8 +441,14 @@ module ODBA
441
441
  end
442
442
  @peers.each do |peer|
443
443
  peer.reserve_next_id id
444
- rescue
445
- DRb::DRbError
444
+ rescue DRb::DRbError
445
+ # A peer we cannot reach must not stop the allocation. Note the
446
+ # explicit class: a bare rescue here also swallowed the
447
+ # OdbaDuplicateIdError a peer raises when the id is already taken,
448
+ # so the retry below could never run and both processes kept the
449
+ # same id. Whichever wrote last overwrote the other's row in
450
+ # `object`, and every reference to it then resolved to a foreign
451
+ # object.
446
452
  end
447
453
  id
448
454
  rescue OdbaDuplicateIdError
data/lib/odba/storage.rb CHANGED
@@ -9,8 +9,17 @@ require "dbi"
9
9
  module ODBA
10
10
  class Storage # :nodoc: all
11
11
  include Singleton
12
- attr_writer :dbi
12
+
13
+ # Not attr_writer: whether the store has an id sequence is memoized, and
14
+ # that answer belongs to the connection it was asked on.
15
+ def dbi=(dbi)
16
+ @id_sequence = nil
17
+ @dbi = dbi
18
+ end
13
19
  BULK_FETCH_STEP = 2500
20
+ # Distance between the highest id in use and the first the sequence
21
+ # hands out; see the odba_id_seq entry in TABLES.
22
+ ID_SEQUENCE_GAP = 100_000
14
23
  TABLES = [
15
24
  # in table 'object', the isolated dumps of all objects are stored
16
25
  ["object", <<~SQL],
@@ -37,12 +46,31 @@ module ODBA
37
46
  CREATE INDEX IF NOT EXISTS target_id_index ON object_connection(target_id);
38
47
  SQL
39
48
  # helper table 'collection'
40
- ["collection", <<~SQL]
49
+ ["collection", <<~SQL],
41
50
  CREATE TABLE IF NOT EXISTS collection (
42
51
  odba_id integer NOT NULL, key text, value text,
43
52
  PRIMARY KEY(odba_id, key)
44
53
  );
45
54
  SQL
55
+ # The odba_id comes from this sequence, so that several processes on
56
+ # one database cannot hand out the same one. See #next_id.
57
+ #
58
+ # The start value is computed and must be: a plain CREATE SEQUENCE
59
+ # starts at 1 and would re-issue ids that already exist. The gap on
60
+ # top of MAX(odba_id) covers ids that processes still running with the
61
+ # old in-memory counter hold but have not written yet. Skipped numbers
62
+ # cost nothing - the odba_id is a surrogate key and carries no meaning.
63
+ ["odba_id_seq", <<~SQL]
64
+ DO $$
65
+ BEGIN
66
+ IF NOT EXISTS (SELECT 1 FROM pg_class
67
+ WHERE relkind = 'S' AND relname = 'odba_id_seq') THEN
68
+ EXECUTE format('CREATE SEQUENCE odba_id_seq START WITH %s',
69
+ (SELECT COALESCE(MAX(odba_id), 0) + #{ID_SEQUENCE_GAP}
70
+ FROM object));
71
+ END IF;
72
+ END $$;
73
+ SQL
46
74
  ]
47
75
  def initialize
48
76
  @id_mutex = Mutex.new
@@ -425,13 +453,43 @@ module ODBA
425
453
  end
426
454
  end
427
455
 
456
+ # The id is allocated by the database, not by a counter in this process.
457
+ #
458
+ # It used to be `@next_id += 1` under this mutex, with @next_id seeded
459
+ # once per process from the highest odba_id in the table. That is sound
460
+ # for a single process and wrong for every deployment that runs more
461
+ # than one - web workers and import jobs on the same database each kept
462
+ # their own counter and handed out the same numbers, so one silently
463
+ # overwrote the other's row in `object`.
464
+ #
465
+ # Falls back to the old behaviour where no sequence exists, so a store
466
+ # that was never through #setup keeps working; #setup creates it.
428
467
  def next_id
429
- @id_mutex.synchronize do
430
- ensure_next_id_set
431
- @next_id += 1
468
+ if id_sequence?
469
+ dbi.select_one("SELECT nextval('odba_id_seq')").first.to_i.tap { |id|
470
+ # max_id and reserve_next_id read @next_id, so keep it in step.
471
+ # Never backwards: a peer may already stand higher.
472
+ @id_mutex.synchronize {
473
+ @next_id = id if @next_id.nil? || @next_id < id
474
+ }
475
+ }
476
+ else
477
+ @id_mutex.synchronize do
478
+ ensure_next_id_set
479
+ @next_id += 1
480
+ end
432
481
  end
433
482
  end
434
483
 
484
+ def id_sequence?
485
+ return @id_sequence unless @id_sequence.nil?
486
+ @id_sequence = dbi.select_one(
487
+ "SELECT 1 FROM pg_class WHERE relkind = 'S' AND relname = 'odba_id_seq'"
488
+ ) ? true : false
489
+ rescue
490
+ @id_sequence = false
491
+ end
492
+
435
493
  def update_max_id(id)
436
494
  @id_mutex.synchronize do
437
495
  @next_id = id
data/lib/odba/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  class Odba
4
- VERSION = '1.2.1'
4
+ VERSION = '1.2.2'
5
5
  end
data/test/test_cache.rb CHANGED
@@ -19,6 +19,46 @@ module ODBA
19
19
  public :load_object
20
20
  end
21
21
 
22
+ # The peer conflict must reach the retry. Until 1.2.2 the loop read
23
+ # `peer.reserve_next_id id rescue DRb::DRbError` - a rescue without a
24
+ # class, which caught the OdbaDuplicateIdError a peer raises when the id
25
+ # is taken. Both processes then kept the same id and one overwrote the
26
+ # other's row in `object`.
27
+ class TestCacheNextId < Test::Unit::TestCase
28
+ include FlexMock::TestCase
29
+
30
+ def setup
31
+ @cache = ODBA::Cache.instance
32
+ @cache.instance_variable_set(:@file_lock, false)
33
+ @storage = flexmock("storage")
34
+ ODBA.storage = @storage
35
+ end
36
+
37
+ def test_a_peer_conflict_leads_to_a_new_id
38
+ @storage.should_receive(:next_id).and_return(100, 101)
39
+ seen = []
40
+ peer = flexmock("peer")
41
+ peer.should_receive(:reserve_next_id).and_return { |id|
42
+ seen << id
43
+ raise ODBA::OdbaDuplicateIdError, "taken" if seen.size == 1
44
+ true
45
+ }
46
+ @cache.instance_variable_set(:@peers, [peer])
47
+ assert_equal(101, @cache.next_id)
48
+ assert_equal([100, 101], seen)
49
+ end
50
+
51
+ # A peer we cannot reach must not stop the allocation - that is what the
52
+ # line was for, and it stays.
53
+ def test_an_unreachable_peer_does_not_stop_the_allocation
54
+ @storage.should_receive(:next_id).and_return(100)
55
+ peer = flexmock("peer")
56
+ peer.should_receive(:reserve_next_id).and_raise(DRb::DRbError, "gone")
57
+ @cache.instance_variable_set(:@peers, [peer])
58
+ assert_equal(100, @cache.next_id)
59
+ end
60
+ end
61
+
22
62
  class TestCache < Test::Unit::TestCase
23
63
  include FlexMock::TestCase
24
64
  class ODBAContainerInCache
data/test/test_storage.rb CHANGED
@@ -15,6 +15,7 @@ module ODBA
15
15
  @storage = ODBA::Storage.instance
16
16
  @dbi = flexmock("DBI")
17
17
  @storage.dbi = @dbi
18
+ @storage.instance_variable_set(:@id_sequence, nil)
18
19
  end
19
20
 
20
21
  def teardown
@@ -106,12 +107,40 @@ module ODBA
106
107
  @storage.create_index("index_name")
107
108
  end
108
109
 
110
+ # Without a sequence the old counter still runs, so a store that was
111
+ # never through #setup keeps working.
109
112
  def test_next_id
113
+ @dbi.should_receive(:select_one).and_return(nil)
110
114
  @storage.next_id = 1
111
115
  assert_equal(2, @storage.next_id)
112
116
  assert_equal(3, @storage.next_id)
113
117
  end
114
118
 
119
+ # The point of 1.2.2: the id comes from the database, so two processes
120
+ # on one store cannot be handed the same one.
121
+ def test_next_id__from_the_sequence
122
+ @dbi.should_receive(:select_one)
123
+ .with("SELECT 1 FROM pg_class WHERE relkind = 'S' AND relname = 'odba_id_seq'")
124
+ .once.and_return([1])
125
+ @dbi.should_receive(:select_one)
126
+ .with("SELECT nextval('odba_id_seq')").twice.and_return([4711], [4712])
127
+ assert_equal(4711, @storage.next_id)
128
+ assert_equal(4712, @storage.next_id)
129
+ end
130
+
131
+ # max_id and reserve_next_id read @next_id, so it has to follow the
132
+ # sequence - and never move backwards, a peer may stand higher already.
133
+ def test_next_id__keeps_the_local_counter_in_step
134
+ @dbi.should_receive(:select_one)
135
+ .with(/pg_class/).and_return([1])
136
+ @dbi.should_receive(:select_one).with(/nextval/).and_return([90], [5])
137
+ @storage.next_id = 10
138
+ @storage.next_id
139
+ assert_equal(90, @storage.instance_variable_get(:@next_id))
140
+ @storage.next_id
141
+ assert_equal(90, @storage.instance_variable_get(:@next_id))
142
+ end
143
+
115
144
  def test_store__1
116
145
  dbi = flexmock("dbi")
117
146
  @storage.dbi = dbi
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: odba
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama, Zeno R.R. Davatz