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 +4 -4
- data/History.md +26 -0
- data/lib/odba/cache.rb +8 -2
- data/lib/odba/storage.rb +63 -5
- data/lib/odba/version.rb +1 -1
- data/test/test_cache.rb +40 -0
- data/test/test_storage.rb +29 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e275634934d76a06600ef71830600a6a6cd1e03c3f59069aa94f998f25d9a973
|
|
4
|
+
data.tar.gz: cf0ae8760286cafa23e4d1c1c37e304aa8a548daa8438c00e58c1f2492691743
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
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
|