Skip to content

Commit 709b891

Browse files
committed
issue2551142 - Import ... unique constraint failure.
Full title: Import of retired node with username after active node fails with unique constraint failure. Fix this in two ways: 1) sort export on keyname, retired status so that retired nodes for a given keyname are before the acive node in the export file. This stops generating a broken export. 2) handle importing a broken export by deactivating/fixing up/clearing the active record's unique index entry temporarily. Redo the import of the retired node and resetting the active record to active. The fixup changes the unique index (keyvalue, __retired__) from (keyvalue, 0) to (keyvalue, -1). Then it retries the failed import of a retired record with keyvalue. I use -1 in case something goes wrong, It makes the record stand out in the database allowing hand recovery if needed. Rather than using -1 I could just use the id of the record like a normal retirement does. If the retry of the import fails (raises exception), reset the active record from -1 back to 0 and raise the exception. If it succeeds, reset the active record from -1 back to 0 and continue the import process. Reset __retired__ from -1 to 0 on every import. I don't think the performance loss from resetting on every exception matters as there should be very few exceptions. Also this makes the code more understandable. There is no reason to leave the -1 value in place and do a bulk rest of -1 to 0 after the class csv file is loaded. Also if a fixup is needed it is logged at level info with the rest of the database logging. Also success of the fixup is logged. Fixup failure generates a propagated exception.
1 parent 6117dcd commit 709b891

File tree

4 files changed

+142
-24
lines changed

4 files changed

+142
-24
lines changed

CHANGES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ Fixed:
131131
item with duplicate key. Fix incorrect error message when using
132132
roundup-admin to restore a user when the username is aleady in use.
133133
(John Rouillard)
134+
- issue2551142 - Import of retired node with username after active
135+
node fails with unique constraint failure. (John Rouillard)
134136

135137
Features:
136138
- issue2550522 - Add 'filter' command to command-line

roundup/admin.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,8 +1319,23 @@ class colon_separated(csv.excel):
13191319
fields.append('is retired')
13201320
writer.writerow(fields)
13211321

1322-
# all nodes for this class
1323-
for nodeid in cl.getnodeids():
1322+
# If a node has a key, sort all nodes by key
1323+
# with retired nodes first. Retired nodes
1324+
# must occur before a non-retired node with
1325+
# the same key. Otherwise you get an
1326+
# IntegrityError: UNIQUE constraint failed:
1327+
# _class.__retired__, _<class>._<keyname>
1328+
# on imports to rdbms.
1329+
all_nodes = cl.getnodeids()
1330+
1331+
classkey = cl.getkey()
1332+
if classkey: # False sorts before True, so negate is_retired
1333+
keysort = lambda i: (cl.get(i, classkey),
1334+
not cl.is_retired(i))
1335+
all_nodes.sort(key=keysort)
1336+
# if there is no classkey no need to sort
1337+
1338+
for nodeid in all_nodes:
13241339
if self.verbose:
13251340
sys.stdout.write('\rExporting %s - %s' %
13261341
(classname, nodeid))

roundup/backends/rdbms_common.py

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3113,6 +3113,9 @@ def import_list(self, propnames, proplist):
31133113
31143114
Return the nodeid of the node imported.
31153115
"""
3116+
3117+
logger = logging.getLogger('roundup.hyperdb.backend')
3118+
31163119
if self.db.journaltag is None:
31173120
raise DatabaseError(_('Database open read-only'))
31183121
properties = self.getprops()
@@ -3168,19 +3171,57 @@ def import_list(self, propnames, proplist):
31683171
if newid is None:
31693172
newid = self.db.newid(self.classname)
31703173

3174+
activeid = None
3175+
has_node = False
3176+
3177+
# use the arg for __retired__ to cope with any odd database type
3178+
# conversion (hello, sqlite)
3179+
retired_sql = 'update _%s set __retired__=%s where id=%s'%(
3180+
self.classname, self.db.arg, self.db.arg)
3181+
31713182
# insert new node or update existing?
3172-
if not self.hasnode(newid):
3173-
self.db.addnode(self.classname, newid, d) # insert
3174-
else:
3175-
self.db.setnode(self.classname, newid, d) # update
3183+
# if integrity error raised try to recover
3184+
try:
3185+
has_node = self.hasnode(newid)
3186+
if not has_node:
3187+
self.db.addnode(self.classname, newid, d) # insert
3188+
else:
3189+
self.db.setnode(self.classname, newid, d) # update
3190+
# Blech, different db's return different exceptions
3191+
# so I can't list them here as some might not be defined
3192+
# on a given system. So capture all exceptions from the
3193+
# code above and try to correct it. If it's correctable its
3194+
# some form of Uniqueness Failure/Integrity Error otherwise
3195+
# undo the fixup and pass on the error.
3196+
except Exception as e:
3197+
logger.info('Attempting to handle import exception '
3198+
'for id %s: %s' % (newid,e))
3199+
3200+
keyname = self.db.user.getkey()
3201+
if has_node or not keyname: # Not an integrity error
3202+
raise
3203+
activeid = self.db.user.lookup(d[keyname])
3204+
self.db.sql(retired_sql, (-1, activeid)) # clear the active node
3205+
# this can only happen on an addnode, so retry
3206+
try:
3207+
# if this raises an error, let it propagate upward
3208+
self.db.addnode(self.classname, newid, d) # insert
3209+
except Exception:
3210+
# undo the database change
3211+
self.db.sql(retired_sql, (0, activeid)) # clear the active node
3212+
raise # propagate
3213+
logger.info('Successfully handled import exception '
3214+
'for id %s which conflicted with %s' % (
3215+
newid, activeid))
31763216

31773217
# retire?
31783218
if retire:
3179-
# use the arg for __retired__ to cope with any odd database type
3180-
# conversion (hello, sqlite)
3181-
sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
3182-
self.db.arg, self.db.arg)
3183-
self.db.sql(sql, (newid, newid))
3219+
self.db.sql(retired_sql, (newid, newid))
3220+
3221+
if activeid:
3222+
# unretire the active node
3223+
self.db.sql(retired_sql, ('0', activeid))
3224+
31843225
return newid
31853226

31863227
def export_journals(self):

test/db_test_base.py

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ def filteringSetupTransitiveSearch(self, classname='issue'):
235235

236236
class DBTest(commonDBTest):
237237

238+
@pytest.fixture(autouse=True)
239+
def inject_fixtures(self, caplog):
240+
self._caplog = caplog
241+
238242
def testRefresh(self):
239243
self.db.refresh_database()
240244

@@ -2808,6 +2812,27 @@ def testImportExport(self):
28082812
self.db.user.retire('3')
28092813
self.db.issue.retire('2')
28102814

2815+
# Key fields must be unique. There can only be one unretired
2816+
# object with a given key value. When importing verify that
2817+
# having the unretired object with a key value before a retired
2818+
# object with the same key value is handled properly.
2819+
2820+
# Since the order of the exported objects is not consistant
2821+
# across backends, we sort the objects by id (as an int) and
2822+
# make sure that the active (non-retired) entry sorts before
2823+
# the retired entry.
2824+
active_dupe_id = self.db.user.create(username="duplicate",
2825+
roles='User',
2826+
password=password.Password('sekrit'), address='[email protected]')
2827+
self.db.user.retire(active_dupe_id) # allow us to create second dupe
2828+
2829+
retired_dupe_id = self.db.user.create(username="duplicate",
2830+
roles='User',
2831+
password=password.Password('sekrit'), address='[email protected]')
2832+
self.db.user.retire(retired_dupe_id)
2833+
self.db.user.restore(active_dupe_id) # unretire lower numbered id
2834+
self.db.commit()
2835+
28112836
# grab snapshot of the current database
28122837
orig = {}
28132838
origj = {}
@@ -2825,29 +2850,64 @@ def testImportExport(self):
28252850
# grab the export
28262851
export = {}
28272852
journals = {}
2853+
active_dupe_id_first = -1 # -1 unknown, False or True
28282854
for cn,klass in self.db.classes.items():
28292855
names = klass.export_propnames()
28302856
cl = export[cn] = [names+['is retired']]
2831-
for id in klass.getnodeids():
2857+
classname = klass.classname
2858+
nodeids = klass.getnodeids()
2859+
# sort to enforce retired/unretired order
2860+
nodeids.sort(key=int)
2861+
for id in nodeids:
2862+
if (classname == 'user' and
2863+
id == retired_dupe_id and
2864+
active_dupe_id_first == -1):
2865+
active_dupe_id_first = False
2866+
if (classname == 'user' and
2867+
id == active_dupe_id and
2868+
active_dupe_id_first == -1):
2869+
active_dupe_id_first = True
28322870
cl.append(klass.export_list(names, id))
28332871
if hasattr(klass, 'export_files'):
28342872
klass.export_files('_test_export', id)
28352873
journals[cn] = klass.export_journals()
28362874

28372875
self.nukeAndCreate()
28382876

2877+
if not active_dupe_id_first:
2878+
# verify that the test is configured properly to
2879+
# trigger the exception code to handle uniqueness
2880+
# failure.
2881+
self.fail("Setup failure: active user id not first.")
2882+
28392883
# import
2840-
for cn, items in export.items():
2841-
klass = self.db.classes[cn]
2842-
names = items[0]
2843-
maxid = 1
2844-
for itemprops in items[1:]:
2845-
id = int(klass.import_list(names, itemprops))
2846-
if hasattr(klass, 'import_files'):
2847-
klass.import_files('_test_export', str(id))
2848-
maxid = max(maxid, id)
2849-
self.db.setid(cn, str(maxid+1))
2850-
klass.import_journals(journals[cn])
2884+
with self._caplog.at_level(logging.INFO,
2885+
logger="roundup.hyperdb.backend"):
2886+
# not supported in python2, so use caplog rather than len(log)
2887+
# X in log[0] ...
2888+
# with self.assertLogs('roundup.hyperdb.backend',
2889+
# level="INFO") as log:
2890+
for cn, items in export.items():
2891+
klass = self.db.classes[cn]
2892+
names = items[0]
2893+
maxid = 1
2894+
for itemprops in items[1:]:
2895+
id = int(klass.import_list(names, itemprops))
2896+
if hasattr(klass, 'import_files'):
2897+
klass.import_files('_test_export', str(id))
2898+
maxid = max(maxid, id)
2899+
self.db.setid(cn, str(maxid+1))
2900+
klass.import_journals(journals[cn])
2901+
2902+
if self.db.dbtype != 'anydbm':
2903+
# no logs or fixup needed under anydbm
2904+
self.assertEqual(2, len(self._caplog.record_tuples))
2905+
self.assertIn('Attempting to handle import exception for id 7:',
2906+
self._caplog.record_tuples[0][2])
2907+
self.assertIn('Successfully handled import exception for id 7 '
2908+
'which conflicted with 6',
2909+
self._caplog.record_tuples[1][2])
2910+
28512911
# This is needed, otherwise journals won't be there for anydbm
28522912
self.db.commit()
28532913
finally:
@@ -2895,7 +2955,7 @@ def testImportExport(self):
28952955
# make sure id counters are set correctly
28962956
maxid = max([int(id) for id in self.db.user.list()])
28972957
newid = int(self.db.user.create(username='testing'))
2898-
assert newid > maxid
2958+
self.assertGreater(newid, maxid)
28992959

29002960
# test import/export via admin interface
29012961
def testAdminImportExport(self):

0 commit comments

Comments
 (0)