Skip to content

Commit a9cdf70

Browse files
committed
issue2551142 - Import of retired node ... unique constraint failure.
Title: Import of retired node with username after active node fails with unique constraint failure. More fixes needed for mysql and postgresql. mysql: add unique constraint for (keyvalue, __retired__) when creating class in the database. On schema change if class is changed, remove the unique constraint too. upgrade version of rdbms database from 5 to 6 to add constraint to all version 5 databases that were created as version 5 and didn't get the unique constraint. Make no changes on version 5 databases upgraded from version 4, the upgrade process to 5 added the constraint. Make no changes to other databases (sqlite, postgres) during upgrade from version 5 to 6. postgres: Handle the exception raised on unique constraint violation. The exception invalidates the database connection so it can't be used to recover from the exception. Added two new database methods: checkpoint_data - performs a db.commit under postgres does nothing on other backends restore_connection_on_error - does a db.rollback on postgres, does nothing on other backends with the rollback() done on the connection I can use the database connection to fixup the import that failed on the unique constraint. This makes postgres slower but without the commit after every imported object, the rollback will delete all the entries done up to this point. Trying to figure out how to make the caller do_import batch and recover from this failure is beyond me. Also dismissed having to process the export csv file before importing. Pushing that onto a user just seems wrong. Also since import/export isn't frequently done the lack of surprise on having a failing import and reduced load/frustration for the user seems worth it. Also the import can be run in verbose mode where it prints out a row as it is processed, so it may take a while, ut the user can get feedback. db_test-base.py: add test for upgrade from 5 to 6.
1 parent 5e19472 commit a9cdf70

File tree

4 files changed

+140
-5
lines changed

4 files changed

+140
-5
lines changed

roundup/backends/back_mysql.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,21 @@ def fix_version_2_tables(self):
395395
# Convert all String properties to TEXT
396396
self._convert_string_properties()
397397

398+
def fix_version_5_tables(self):
399+
# A bug caused the _<class>_key_retired_idx to be missing
400+
# unless the database was upgraded from version 4 to 5.
401+
# If it was created at version 5, the index is missing.
402+
# The user class is always present and has a key.
403+
# Check it for the index. If missing, add index to all
404+
# classes by rerunning self.fix_version_4_tables().
405+
406+
# if this fails abort. Probably means no user class
407+
# so we should't be doing anything.
408+
if not self.sql_index_exists("_user", "_user_key_retired_idx"):
409+
self.fix_version_4_tables()
410+
else:
411+
self.log_info('No changes needed.')
412+
398413
def __repr__(self):
399414
return '<myroundsql 0x%x>'%id(self)
400415

@@ -446,6 +461,10 @@ def create_class_table_indexes(self, spec):
446461
spec.classname, idx)
447462
self.sql(index_sql3)
448463

464+
# and the unique index for key / retired(id)
465+
self.add_class_key_required_unique_constraint(spec.classname,
466+
spec.key)
467+
449468
# TODO: create indexes on (selected?) Link property columns, as
450469
# they're more likely to be used for lookup
451470

@@ -530,6 +549,12 @@ def drop_class_table_key_index(self, cn, key):
530549
sql = 'drop index %s on %s'%(index_name, table_name)
531550
self.sql(sql)
532551

552+
# and now the retired unique index too
553+
index_name = '_%s_key_retired_idx' % cn
554+
if self.sql_index_exists(table_name, index_name):
555+
sql = 'drop index %s on _%s'%(index_name, cn)
556+
self.sql(sql)
557+
533558
# old-skool id generation
534559
def newid(self, classname):
535560
''' Generate a new id for the given class

roundup/backends/back_postgresql.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,22 @@ def open_connection(self):
200200
# the necessary tables (in a parallel connection!)
201201
self.commit()
202202

203+
def checkpoint_data(self):
204+
"""Commit the state of the database. Allows recovery/retry
205+
of operation in exception handler because postgres
206+
requires a rollback in case of error generating exception
207+
"""
208+
self.commit()
209+
210+
def restore_connection_on_error(self):
211+
"""Postgres leaves a cursor in an unusable state after
212+
an error. Rollback the transaction to recover and
213+
permit a retry of the failed statement. Used with
214+
checkpoint_data to handle uniqueness conflict in
215+
import_table()
216+
"""
217+
self.rollback()
218+
203219
def create_version_2_tables(self):
204220
# OTK store
205221
self.sql('''CREATE TABLE otks (otk_key VARCHAR(255),

roundup/backends/rdbms_common.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ def post_init(self):
330330

331331
# update this number when we need to make changes to the SQL structure
332332
# of the backen database
333-
current_db_version = 5
333+
current_db_version = 6
334334
db_version_updated = False
335335

336336
def upgrade_db(self):
@@ -372,6 +372,10 @@ def upgrade_db(self):
372372
self.log_info('upgrade to version 5')
373373
self.fix_version_4_tables()
374374

375+
if version < 6:
376+
self.log_info('upgrade to version 6')
377+
self.fix_version_5_tables()
378+
375379
self.database_schema['version'] = self.current_db_version
376380
self.db_version_updated = True
377381
return 1
@@ -399,6 +403,14 @@ def fix_version_4_tables(self):
399403
if klass.key:
400404
self.add_class_key_required_unique_constraint(cn, klass.key)
401405

406+
def fix_version_5_tables(self):
407+
# Default (used by sqlite, postgres): NOOP
408+
# mysql overrides this because it is missing
409+
# _<class>_key_retired_idx index used to make
410+
# sure that the key is unique if it was created
411+
# as version 5.
412+
pass
413+
402414
def _convert_journal_tables(self):
403415
"""Get current journal table contents, drop the table and re-create"""
404416
c = self.cursor
@@ -467,6 +479,21 @@ def reindex(self, classname=None, show_progress=False):
467479
klass.index(nodeid)
468480
self.indexer.save_index()
469481

482+
def checkpoint_data(self):
483+
"""Call if you need to commit the state of the database
484+
so you can try to fix the error rather than rolling back
485+
486+
Needed for postgres when importing data.
487+
"""
488+
pass
489+
490+
def restore_connection_on_error(self):
491+
"""on a database error/exception recover the db connection
492+
if left in an unusable state (e.g. postgres requires
493+
a rollback).
494+
"""
495+
pass
496+
470497
# Used here in the generic backend to determine if the database
471498
# supports 'DOUBLE PRECISION' for floating point numbers.
472499
implements_double_precision = True
@@ -3187,19 +3214,21 @@ def import_list(self, propnames, proplist):
31873214
self.db.addnode(self.classname, newid, d) # insert
31883215
else:
31893216
self.db.setnode(self.classname, newid, d) # update
3217+
self.db.checkpoint_data()
31903218
# Blech, different db's return different exceptions
31913219
# so I can't list them here as some might not be defined
31923220
# on a given system. So capture all exceptions from the
31933221
# code above and try to correct it. If it's correctable its
31943222
# some form of Uniqueness Failure/Integrity Error otherwise
31953223
# undo the fixup and pass on the error.
3196-
except Exception as e:
3224+
except Exception as e: # nosec
31973225
logger.info('Attempting to handle import exception '
31983226
'for id %s: %s' % (newid,e))
31993227

32003228
keyname = self.db.user.getkey()
32013229
if has_node or not keyname: # Not an integrity error
32023230
raise
3231+
self.db.restore_connection_on_error()
32033232
activeid = self.db.user.lookup(d[keyname])
32043233
self.db.sql(retired_sql, (-1, activeid)) # clear the active node
32053234
# this can only happen on an addnode, so retry

test/db_test_base.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,59 @@ def inject_fixtures(self, caplog):
242242
def testRefresh(self):
243243
self.db.refresh_database()
244244

245+
246+
def testUpgrade_5_to_6(self):
247+
248+
if(self.db.dbtype in ['anydbm', 'memorydb']):
249+
self.skipTest('No schema upgrade needed on non rdbms backends')
250+
251+
# load the database
252+
self.db.issue.create(title="flebble frooz")
253+
self.db.commit()
254+
255+
self.assertEqual(self.db.database_schema['version'], 6,
256+
"This test only runs for database version 6")
257+
self.db.database_schema['version'] = 5
258+
if self.db.dbtype == 'mysql':
259+
# version 6 has 5 indexes
260+
self.db.sql('show indexes from _user;')
261+
self.assertEqual(5,len(self.db.cursor.fetchall()),
262+
"Database created with wrong number of indexes")
263+
264+
self.drop_key_retired_idx()
265+
266+
# after dropping (key.__retired__) composite index we have
267+
# 3 index entries
268+
self.db.sql('show indexes from _user;')
269+
self.assertEqual(3,len(self.db.cursor.fetchall()))
270+
271+
# test upgrade adding index
272+
self.db.post_init()
273+
274+
# they're back
275+
self.db.sql('show indexes from _user;')
276+
self.assertEqual(5,len(self.db.cursor.fetchall()))
277+
278+
# test a database already upgraded from 4 to 5
279+
# so it has the index to enforce key uniqueness
280+
self.db.database_schema['version'] = 5
281+
self.db.post_init()
282+
283+
# they're still here.
284+
self.db.sql('show indexes from _user;')
285+
self.assertEqual(5,len(self.db.cursor.fetchall()))
286+
else:
287+
# this should be a no-op
288+
# test upgrade
289+
self.db.post_init()
290+
291+
def drop_key_retired_idx(self):
292+
c = self.db.cursor
293+
for cn, klass in self.db.classes.items():
294+
if klass.key:
295+
sql = '''drop index _%s_key_retired_idx on _%s''' % (cn, cn)
296+
self.db.sql(sql)
297+
245298
#
246299
# automatic properties (well, the two easy ones anyway)
247300
#
@@ -2901,12 +2954,24 @@ def testImportExport(self):
29012954

29022955
if self.db.dbtype not in ['anydbm', 'memorydb']:
29032956
# no logs or fixup needed under anydbm
2904-
self.assertEqual(2, len(self._caplog.record_tuples))
2957+
# postgres requires commits and rollbacks
2958+
# as part of error recovery, so we get commit
2959+
# logging that we need to account for
2960+
if self.db.dbtype == 'postgres':
2961+
log_count=24
2962+
handle_msg_location=16
2963+
# add two since rollback is logged
2964+
success_msg_location = handle_msg_location+2
2965+
else:
2966+
log_count=2
2967+
handle_msg_location=0
2968+
success_msg_location = handle_msg_location+1
2969+
self.assertEqual(log_count, len(self._caplog.record_tuples))
29052970
self.assertIn('Attempting to handle import exception for id 7:',
2906-
self._caplog.record_tuples[0][2])
2971+
self._caplog.record_tuples[handle_msg_location][2])
29072972
self.assertIn('Successfully handled import exception for id 7 '
29082973
'which conflicted with 6',
2909-
self._caplog.record_tuples[1][2])
2974+
self._caplog.record_tuples[success_msg_location][2])
29102975

29112976
# This is needed, otherwise journals won't be there for anydbm
29122977
self.db.commit()

0 commit comments

Comments
 (0)