Skip to content

Commit 0d499a3

Browse files
committed
Kill leftovers of pre-MVCC read conflicts
In the early days, before MVCC was introduced, ZODB used to raise ReadConflictError on access to object that was simultaneously changed by another client in concurrent transaction. However, as doc/articles/ZODB-overview.rst says Since Zope 2.8 ZODB has implemented **Multi Version Concurrency Control**. This means no more ReadConflictErrors, each transaction is guaranteed to be able to load any object as it was when the transaction begun. So today the only way to get a ReadConflictError should be 1) at commit time for an object that was requested to stay unchanged via checkCurrentSerialInTransaction, and 2) at plain access time, if a pack running simultaneously to current transaction, removes object revision that we try to load. The second point is a bit unfortunate, since when load discovers that object was deleted or not yet created, it is logically more clean to raise POSKeyError. However due to backward compatibility we still want to raise ReadConflictError in this case - please see comments added to MVCCAdapter for details. Anyway, let's remove leftovers of handling regular read-conflicts from pre-MVCC era: Adjust docstring of ReadConflictError to explicitly describe that this error can only happen at commit time for objects requested to be current, or at plain access if pack is running simultaneously under connection foot. There were also leftover code, comment and test bits in Connection, interfaces, testmvcc and testZODB, that are corrected/removed correspondingly. testZODB actually had ReadConflictTests that was completely deactivated: commit b0f992f ("Removed the mvcc option..."; 2007) moved read-conflict-on-access related tests out of ZODBTests, but did not activated moved parts at all, because as that commit says when MVCC is always on unconditionally, there is no on-access conflicts: Removed the mvcc option. Everybody wants mvcc and removing us lets us simplify the code a little. (We'll be able to simplify more when we stop supporting versions.) Today, if I try to manually activate that ReadConflictTests via @@ -637,6 +637,7 @@ def __init__(self, poisonedjar): def test_suite(): return unittest.TestSuite(( unittest.makeSuite(ZODBTests, 'check'), + unittest.makeSuite(ReadConflictTests, 'check'), )) if __name__ == "__main__": it fails in dumb way showing that this tests were unmaintained for ages: Error in test checkReadConflict (ZODB.tests.testZODB.ReadConflictTests) Traceback (most recent call last): File "/usr/lib/python2.7/unittest/case.py", line 320, in run self.setUp() File "/home/kirr/src/wendelin/z/ZODB/src/ZODB/tests/testZODB.py", line 451, in setUp ZODB.tests.utils.TestCase.setUp(self) AttributeError: 'module' object has no attribute 'utils' Since today ZODB always uses MVCC and there is no way to get ReadConflictError on concurrent plain read/write access, those tests should be also gone together with old pre-MVCC way of handling concurrency. /cc @jimfulton
1 parent 22df1fd commit 0d499a3

File tree

7 files changed

+44
-181
lines changed

7 files changed

+44
-181
lines changed

src/ZODB/Connection.py

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,8 @@ def before_instance(before):
148148
self._pre_cache = {}
149149

150150
# List of all objects (not oids) registered as modified by the
151-
# persistence machinery, or by add(), or whose access caused a
152-
# ReadConflictError (just to be able to clean them up from the
153-
# cache on abort with the other modified objects). All objects
154-
# of this list are either in _cache or in _added.
151+
# persistence machinery.
152+
# All objects of this list are either in _cache or in _added.
155153
self._registered_objects = [] # [object]
156154

157155
# ids and serials of objects for which readCurrent was called
@@ -182,15 +180,6 @@ def before_instance(before):
182180
# in the cache on abort and in other connections on finish.
183181
self._modified = [] # [oid]
184182

185-
# We intend to prevent committing a transaction in which
186-
# ReadConflictError occurs. _conflicts is the set of oids that
187-
# experienced ReadConflictError. Any time we raise ReadConflictError,
188-
# the oid should be added to this set, and we should be sure that the
189-
# object is registered. Because it's registered, Connection.commit()
190-
# will raise ReadConflictError again (because the oid is in
191-
# _conflicts).
192-
self._conflicts = {}
193-
194183
# To support importFile(), implemented in the ExportImport base
195184
# class, we need to run _importDuringCommit() from our commit()
196185
# method. If _import is not None, it is a two-tuple of arguments
@@ -460,7 +449,6 @@ def _abort(self):
460449

461450
def _tpc_cleanup(self):
462451
"""Performs cleanup operations to support tpc_finish and tpc_abort."""
463-
self._conflicts.clear()
464452
self._needs_to_join = True
465453
self._registered_objects = []
466454
self._creating.clear()
@@ -528,8 +516,6 @@ def _commit(self, transaction):
528516
for obj in self._registered_objects:
529517
oid = obj._p_oid
530518
assert oid
531-
if oid in self._conflicts:
532-
raise ReadConflictError(object=obj)
533519

534520
if obj._p_jar is not self:
535521
raise InvalidObjectReference(obj, obj._p_jar)

src/ZODB/POSException.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,17 @@ def get_serials(self):
144144
return self.serials
145145

146146
class ReadConflictError(ConflictError):
147-
"""Conflict detected when object was loaded.
147+
"""Conflict detected when object was requested to stay unchanged.
148148
149-
An attempt was made to read an object that has changed in another
150-
transaction (eg. another thread or process).
149+
An object was requested to stay not modified via
150+
checkCurrentSerialInTransaction, and at commit time was found to be
151+
changed by another transaction (eg. another thread or process).
152+
153+
Note: for backward compatibility ReadConflictError is also raised on
154+
plain object access if
155+
156+
- object is found to be removed, and
157+
- there is possibility that database pack was running simultaneously.
151158
"""
152159
def __init__(self, message=None, object=None, serials=None, **kw):
153160
if message is None:

src/ZODB/interfaces.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,6 @@ class IConnection(Interface):
7878
Two options affect consistency. By default, the mvcc and synch
7979
options are enabled by default.
8080
81-
If you pass mvcc=False to db.open(), the Connection will never read
82-
non-current revisions of an object. Instead it will raise a
83-
ReadConflictError to indicate that the current revision is
84-
unavailable because it was written after the current transaction
85-
began.
86-
8781
The logic for handling modifications assumes that the thread that
8882
opened a Connection (called db.open()) is the thread that will use
8983
the Connection. If this is not true, you should pass synch=False

src/ZODB/mvccadapter.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# -*- coding: utf-8 -*-
12
"""Adapt IStorage objects to IMVCCStorage
23
34
This is a largely internal implementation of ZODB, especially DB and
@@ -9,7 +10,7 @@
910
import zope.interface
1011

1112
from . import interfaces, serialize, POSException
12-
from .utils import p64, u64, Lock
13+
from .utils import p64, u64, Lock, oid_repr, tid_repr
1314

1415
class Base(object):
1516

@@ -152,7 +153,31 @@ def load(self, oid):
152153
assert self._start is not None
153154
r = self._storage.loadBefore(oid, self._start)
154155
if r is None:
155-
raise POSException.ReadConflictError(repr(oid))
156+
# object was deleted or not-yet-created.
157+
# raise ReadConflictError - not - POSKeyError due to backward
158+
# compatibility: a pack(t+δ) could be running simultaneously to our
159+
# transaction that observes database as of t state. Such pack,
160+
# because it packs the storage from a "future-to-us" point of view,
161+
# can remove object revisions that we can try to load, for example:
162+
#
163+
# txn1 <-- t
164+
# obj.revA
165+
#
166+
# txn2 <-- t+δ
167+
# obj.revB
168+
#
169+
# for such case we want user transaction to be restarted - not
170+
# failed - by raising ConflictError subclass.
171+
#
172+
# XXX we don't detect for pack to be actually running - just assume
173+
# the worst. It would be good if storage could provide information
174+
# whether pack is/was actually running and its details, take that
175+
# into account, and raise ReadConflictError only in the presence of
176+
# database being simultaneously updated from back of its log.
177+
raise POSException.ReadConflictError(
178+
"load %s @%s: object deleted, likely by simultaneous pack" %
179+
(oid_repr(oid), tid_repr(p64(u64(self._start)-1))))
180+
156181
return r[:2]
157182

158183
def prefetch(self, oids):

src/ZODB/tests/testZODB.py

Lines changed: 0 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
##############################################################################
1414
from persistent import Persistent
1515
from persistent.mapping import PersistentMapping
16-
from ZODB.POSException import ReadConflictError
1716
from ZODB.POSException import TransactionFailedError
1817

1918
import doctest
@@ -445,156 +444,6 @@ def checkMultipleUndoInOneTransaction(self):
445444
transaction.abort()
446445
conn.close()
447446

448-
class ReadConflictTests(ZODB.tests.util.TestCase):
449-
450-
def setUp(self):
451-
ZODB.tests.utils.TestCase.setUp(self)
452-
self._storage = ZODB.MappingStorage.MappingStorage()
453-
454-
def readConflict(self, shouldFail=True):
455-
# Two transactions run concurrently. Each reads some object,
456-
# then one commits and the other tries to read an object
457-
# modified by the first. This read should fail with a conflict
458-
# error because the object state read is not necessarily
459-
# consistent with the objects read earlier in the transaction.
460-
461-
tm1 = transaction.TransactionManager()
462-
conn = self._db.open(transaction_manager=tm1)
463-
r1 = conn.root()
464-
r1["p"] = self.obj
465-
self.obj.child1 = P()
466-
tm1.get().commit()
467-
468-
# start a new transaction with a new connection
469-
tm2 = transaction.TransactionManager()
470-
cn2 = self._db.open(transaction_manager=tm2)
471-
# start a new transaction with the other connection
472-
r2 = cn2.root()
473-
474-
self.assertEqual(r1._p_serial, r2._p_serial)
475-
476-
self.obj.child2 = P()
477-
tm1.get().commit()
478-
479-
# resume the transaction using cn2
480-
obj = r2["p"]
481-
# An attempt to access obj should fail, because r2 was read
482-
# earlier in the transaction and obj was modified by the othe
483-
# transaction.
484-
if shouldFail:
485-
self.assertRaises(ReadConflictError, lambda: obj.child1)
486-
# And since ReadConflictError was raised, attempting to commit
487-
# the transaction should re-raise it. checkNotIndependent()
488-
# failed this part of the test for a long time.
489-
self.assertRaises(ReadConflictError, tm2.get().commit)
490-
491-
# And since that commit failed, trying to commit again should
492-
# fail again.
493-
self.assertRaises(TransactionFailedError, tm2.get().commit)
494-
# And again.
495-
self.assertRaises(TransactionFailedError, tm2.get().commit)
496-
# Etc.
497-
self.assertRaises(TransactionFailedError, tm2.get().commit)
498-
499-
else:
500-
# make sure that accessing the object succeeds
501-
obj.child1
502-
tm2.get().abort()
503-
504-
505-
def checkReadConflict(self):
506-
self.obj = P()
507-
self.readConflict()
508-
509-
def checkReadConflictIgnored(self):
510-
# Test that an application that catches a read conflict and
511-
# continues can not commit the transaction later.
512-
root = self._db.open().root()
513-
root["real_data"] = real_data = PersistentMapping()
514-
root["index"] = index = PersistentMapping()
515-
516-
real_data["a"] = PersistentMapping({"indexed_value": 0})
517-
real_data["b"] = PersistentMapping({"indexed_value": 1})
518-
index[1] = PersistentMapping({"b": 1})
519-
index[0] = PersistentMapping({"a": 1})
520-
transaction.commit()
521-
522-
# load some objects from one connection
523-
tm = transaction.TransactionManager()
524-
cn2 = self._db.open(transaction_manager=tm)
525-
r2 = cn2.root()
526-
real_data2 = r2["real_data"]
527-
index2 = r2["index"]
528-
529-
real_data["b"]["indexed_value"] = 0
530-
del index[1]["b"]
531-
index[0]["b"] = 1
532-
transaction.commit()
533-
534-
del real_data2["a"]
535-
try:
536-
del index2[0]["a"]
537-
except ReadConflictError:
538-
# This is the crux of the text. Ignore the error.
539-
pass
540-
else:
541-
self.fail("No conflict occurred")
542-
543-
# real_data2 still ready to commit
544-
self.assertTrue(real_data2._p_changed)
545-
546-
# index2 values not ready to commit
547-
self.assertTrue(not index2._p_changed)
548-
self.assertTrue(not index2[0]._p_changed)
549-
self.assertTrue(not index2[1]._p_changed)
550-
551-
self.assertRaises(ReadConflictError, tm.get().commit)
552-
self.assertRaises(TransactionFailedError, tm.get().commit)
553-
tm.get().abort()
554-
555-
def checkReadConflictErrorClearedDuringAbort(self):
556-
# When a transaction is aborted, the "memory" of which
557-
# objects were the cause of a ReadConflictError during
558-
# that transaction should be cleared.
559-
root = self._db.open().root()
560-
data = PersistentMapping({'d': 1})
561-
root["data"] = data
562-
transaction.commit()
563-
564-
# Provoke a ReadConflictError.
565-
tm2 = transaction.TransactionManager()
566-
cn2 = self._db.open(transaction_manager=tm2)
567-
r2 = cn2.root()
568-
data2 = r2["data"]
569-
570-
data['d'] = 2
571-
transaction.commit()
572-
573-
try:
574-
data2['d'] = 3
575-
except ReadConflictError:
576-
pass
577-
else:
578-
self.fail("No conflict occurred")
579-
580-
# Explicitly abort cn2's transaction.
581-
tm2.get().abort()
582-
583-
# cn2 should retain no memory of the read conflict after an abort(),
584-
# but 3.2.3 had a bug wherein it did.
585-
data_conflicts = data._p_jar._conflicts
586-
data2_conflicts = data2._p_jar._conflicts
587-
self.assertFalse(data_conflicts)
588-
self.assertFalse(data2_conflicts) # this used to fail
589-
590-
# And because of that, we still couldn't commit a change to data2['d']
591-
# in the new transaction.
592-
cn2.sync() # process the invalidation for data2['d']
593-
data2['d'] = 3
594-
tm2.get().commit() # 3.2.3 used to raise ReadConflictError
595-
596-
cn2.close()
597-
598447
class PoisonedError(Exception):
599448
pass
600449

src/ZODB/tests/testmvcc.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,8 @@
2525
always see a consistent view of the database while it is executing.
2626
If transaction A is running, has already read an object O1, and a
2727
different transaction B modifies object O2, then transaction A can no
28-
longer read the current revision of O2. It must either read the
29-
version of O2 that is consistent with O1 or raise a ReadConflictError.
30-
When MVCC is in use, A will do the former.
28+
longer read the current revision of O2. It must read the
29+
version of O2 that is consistent with O1.
3130
3231
This note includes doctests that explain how MVCC is implemented (and
3332
test that the implementation is correct). The tests use a

src/ZODB/transact.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ def g(*args, **kwargs):
4343
try:
4444
r = f(*args, **kwargs)
4545
except ReadConflictError as msg:
46+
# the only way ReadConflictError can happen here is due to
47+
# simultaneous pack removing objects revision that f could try
48+
# to load.
4649
transaction.abort()
4750
if not n:
4851
raise

0 commit comments

Comments
 (0)