Skip to content

Commit 33855a7

Browse files
author
Richard Jones
committed
Fix race condition for key properties in rdbms backends [SF#1876683]
1 parent effcf3e commit 33855a7

File tree

6 files changed

+127
-34
lines changed

6 files changed

+127
-34
lines changed

CHANGES.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
This file contains the changes to the Roundup system over time. The entries
22
are given with the most recent entry first.
33

4-
2007-??-?? 1.4.2
4+
2008-02-07 1.4.2
55
Feature:
66
- New config option in mail section: ignore_alternatives allows to
77
ignore alternatives besides the text/plain part used for the content
@@ -16,10 +16,10 @@ Feature:
1616
Fixed:
1717
- Searching date range by supplying just a date as the filter spec
1818
- Handle no time.tzset under Windows (sf #1825643)
19-
- Work around race condition in file storage during transaction commit
20-
(sf #1883580)
19+
- Fix race condition in file storage transaction commit (sf #1883580)
2120
- Make user utils JS work with firstname/lastname again (sf #1868323)
2221
- Fix ZRoundup to work with Zope 2.8.5 (sf #1806125)
22+
- Fix race condition for key properties in rdbms backends (sf #1876683)
2323

2424

2525
2007-11-09 1.4.1

doc/admin_guide.txt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Administration Guide
33
====================
44

5-
:Version: $Revision: 1.26 $
5+
:Version: $Revision: 1.27 $
66

77
.. contents::
88

@@ -209,6 +209,12 @@ take:
209209
4. Stop the tracker web and email frontends.
210210
5. Follow the steps in the `upgrading documentation`_ for the new version of
211211
the software in the copied.
212+
213+
Usually you will be asked to run `roundup_admin migrate` on your tracker
214+
before you allow users to start accessing the tracker.
215+
216+
It's safe to run this even if it's not required, so just get into the
217+
habit.
212218
6. You may test each of the admin tool, web interface and mail gateway using
213219
the new version of the software. To do this, invoke the scripts directly
214220
in the source directory with::

doc/upgrading.txt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,24 @@ steps.
1313

1414
.. contents::
1515

16+
Migrating from 1.4.x to 1.4.2
17+
=============================
18+
19+
You should run the "roundup-admin migrate" command for your tracker once
20+
you've installed the latest codebase.
21+
22+
Do this before you use the web, command-line or mail interface and before
23+
any users access the tracker.
24+
25+
This command will respond with either "Tracker updated" (if you've not
26+
previously run it on an RDBMS backend) or "No migration action required"
27+
(if you have run it, or have used another interface to the tracker,
28+
or are using anydbm).
29+
30+
It's safe to run this even if it's not required, so just get into the
31+
habit.
32+
33+
1634
Migrating from 1.3.3 to 1.4.0
1735
=============================
1836

@@ -25,12 +43,12 @@ update the messagesummary detector as follows::
2543
--- detectors/messagesummary.py 17 Apr 2003 03:26:38 -0000 1.1
2644
+++ detectors/messagesummary.py 3 Apr 2007 06:47:21 -0000 1.2
2745
@@ -8,7 +8,7 @@
28-
if newvalues.has_key('summary') or not newvalues.has_key('content'):
29-
return
46+
if newvalues.has_key('summary') or not newvalues.has_key('content'):
47+
return
3048

3149
- summary, content = parseContent(newvalues['content'], 1, 1)
3250
+ summary, content = parseContent(newvalues['content'], config=db.config)
33-
newvalues['summary'] = summary
51+
newvalues['summary'] = summary
3452

3553
In the latest version we have added some database indexes to the
3654
SQL-backends (mysql, postgresql, sqlite) for speeding up building the

roundup/admin.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
1717
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
1818
#
19-
# $Id: admin.py,v 1.109 2007-09-26 14:09:59 jpend Exp $
19+
# $Id: admin.py,v 1.110 2008-02-07 03:28:33 richard Exp $
2020

2121
'''Administration commands for maintaining Roundup trackers.
2222
'''
@@ -1333,6 +1333,33 @@ def do_security(self, args):
13331333
print _(' %(description)s (%(name)s)')%d
13341334
return 0
13351335

1336+
1337+
def do_migrate(self, args):
1338+
'''Usage: migrate
1339+
Update a tracker's database to be compatible with the Roundup
1340+
codebase.
1341+
1342+
You should run the "migrate" command for your tracker once you've
1343+
installed the latest codebase.
1344+
1345+
Do this before you use the web, command-line or mail interface and
1346+
before any users access the tracker.
1347+
1348+
This command will respond with either "Tracker updated" (if you've
1349+
not previously run it on an RDBMS backend) or "No migration action
1350+
required" (if you have run it, or have used another interface to the
1351+
tracker, or possibly because you are using anydbm).
1352+
1353+
It's safe to run this even if it's not required, so just get into
1354+
the habit.
1355+
'''
1356+
if getattr(self.db, 'db_version_updated'):
1357+
print _('Tracker updated')
1358+
self.db_uncommitted = True
1359+
else:
1360+
print _('No migration action required')
1361+
return 0
1362+
13361363
def run_command(self, args):
13371364
'''Run a single command
13381365
'''

roundup/backends/rdbms_common.py

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
1616
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
1717
#
18-
#$Id: rdbms_common.py,v 1.193 2007-10-25 07:26:11 richard Exp $
18+
#$Id: rdbms_common.py,v 1.194 2008-02-07 03:28:34 richard Exp $
1919
""" Relational database (SQL) backend common code.
2020
2121
Basics:
@@ -29,7 +29,7 @@
2929
- journals are stored adjunct to the per-class tables
3030
- table names and columns have "_" prepended so the names can't clash with
3131
restricted names (like "order")
32-
- retirement is determined by the __retired__ column being true
32+
- retirement is determined by the __retired__ column being > 0
3333
3434
Database-specific changes may generally be pushed out to the overridable
3535
sql_* methods, since everything else should be fairly generic. There's
@@ -42,6 +42,13 @@
4242
that maps to a table. If that information differs from the hyperdb schema,
4343
then we update it. We also store in the schema dict a version which
4444
allows us to upgrade the database schema when necessary. See upgrade_db().
45+
46+
To force a unqiueness constraint on the key properties we put the item
47+
id into the __retired__ column duing retirement (so it's 0 for "active"
48+
items) and place a unqiueness constraint on key + __retired__. This is
49+
particularly important for the users class where multiple users may
50+
try to have the same username, with potentially many retired users with
51+
the same name.
4552
"""
4653
__docformat__ = 'restructuredtext'
4754

@@ -239,7 +246,8 @@ def post_init(self):
239246

240247
# update this number when we need to make changes to the SQL structure
241248
# of the backen database
242-
current_db_version = 4
249+
current_db_version = 5
250+
db_version_updated = False
243251
def upgrade_db(self):
244252
""" Update the SQL database to reflect changes in the backend code.
245253
@@ -272,7 +280,11 @@ def upgrade_db(self):
272280
if version < 4:
273281
self.fix_version_3_tables()
274282

283+
if version < 5:
284+
self.fix_version_4_tables()
285+
275286
self.database_schema['version'] = self.current_db_version
287+
self.db_version_updated = True
276288
return 1
277289

278290
def fix_version_3_tables(self):
@@ -283,9 +295,21 @@ def fix_version_3_tables(self):
283295
self.sql('ALTER TABLE %ss ADD %s_value TEXT'%(name, name))
284296

285297
def fix_version_2_tables(self):
286-
"""Default (used by sqlite): NOOP"""
298+
# Default (used by sqlite): NOOP
287299
pass
288300

301+
def fix_version_4_tables(self):
302+
# note this is an explicit call now
303+
c = self.cursor
304+
for cn, klass in self.classes.items():
305+
c.execute('select id from _%s where __retired__<>0'%(cn,))
306+
for (id,) in c.fetchall():
307+
c.execute('update _%s set __retired__=%s where id=%s'%(cn,
308+
self.arg, self.arg), (id, id))
309+
310+
if klass.key:
311+
self.add_class_key_required_unique_constraint(cn, klass.key)
312+
289313
def _convert_journal_tables(self):
290314
"""Get current journal table contents, drop the table and re-create"""
291315
c = self.cursor
@@ -530,9 +554,18 @@ def create_class_table_indexes(self, spec):
530554
spec.classname, spec.key)
531555
self.sql(index_sql3)
532556

557+
# and the unique index for key / retired(id)
558+
self.add_class_key_required_unique_constraint(spec.classname,
559+
spec.key)
560+
533561
# TODO: create indexes on (selected?) Link property columns, as
534562
# they're more likely to be used for lookup
535563

564+
def add_class_key_required_unique_constraint(self, cn, key):
565+
sql = '''create unique index _%s_key_retired_idx
566+
on _%s(__retired__, _%s)'''%(cn, cn, key)
567+
self.sql(sql)
568+
536569
def drop_class_table_indexes(self, cn, key):
537570
# drop the old table indexes first
538571
l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
@@ -555,10 +588,15 @@ def create_class_table_key_index(self, cn, key):
555588
def drop_class_table_key_index(self, cn, key):
556589
table_name = '_%s'%cn
557590
index_name = '_%s_%s_idx'%(cn, key)
558-
if not self.sql_index_exists(table_name, index_name):
559-
return
560-
sql = 'drop index '+index_name
561-
self.sql(sql)
591+
if self.sql_index_exists(table_name, index_name):
592+
sql = 'drop index '+index_name
593+
self.sql(sql)
594+
595+
# and now the retired unique index too
596+
index_name = '_%s_key_retired_idx'%cn
597+
if self.sql_index_exists(table_name, index_name):
598+
sql = 'drop index _%s_key_retired_idx'%cn
599+
self.sql(sql)
562600

563601
def create_journal_table(self, spec):
564602
""" create the journal table for a class given the spec and
@@ -1760,7 +1798,7 @@ def retire(self, nodeid):
17601798
# conversion (hello, sqlite)
17611799
sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
17621800
self.db.arg, self.db.arg)
1763-
self.db.sql(sql, (1, nodeid))
1801+
self.db.sql(sql, (nodeid, nodeid))
17641802
if self.do_journal:
17651803
self.db.addjournal(self.classname, nodeid, ''"retired", None)
17661804

@@ -1802,7 +1840,7 @@ def is_retired(self, nodeid):
18021840
sql = 'select __retired__ from _%s where id=%s'%(self.classname,
18031841
self.db.arg)
18041842
self.db.sql(sql, (nodeid,))
1805-
return int(self.db.sql_fetchone()[0])
1843+
return int(self.db.sql_fetchone()[0]) > 0
18061844

18071845
def destroy(self, nodeid):
18081846
"""Destroy a node.
@@ -1880,9 +1918,9 @@ def lookup(self, keyvalue):
18801918

18811919
# use the arg to handle any odd database type conversion (hello,
18821920
# sqlite)
1883-
sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
1921+
sql = "select id from _%s where _%s=%s and __retired__=%s"%(
18841922
self.classname, self.key, self.db.arg, self.db.arg)
1885-
self.db.sql(sql, (keyvalue, 1))
1923+
self.db.sql(sql, (keyvalue, 0))
18861924

18871925
# see if there was a result that's not retired
18881926
row = self.db.sql_fetchone()
@@ -1947,8 +1985,8 @@ def find(self, **propspec):
19471985
s += '_%s in (%s)'%(prop, ','.join([a]*len(values)))
19481986
where.append('(' + s +')')
19491987
if where:
1950-
allvalues = (1, ) + allvalues
1951-
sql.append("""select id from _%s where __retired__ <> %s
1988+
allvalues = (0, ) + allvalues
1989+
sql.append("""select id from _%s where __retired__=%s
19521990
and %s"""%(self.classname, a, ' and '.join(where)))
19531991

19541992
# now multilinks
@@ -1957,15 +1995,15 @@ def find(self, **propspec):
19571995
continue
19581996
if not values:
19591997
continue
1960-
allvalues += (1, )
1998+
allvalues += (0, )
19611999
if type(values) is type(''):
19622000
allvalues += (values,)
19632001
s = a
19642002
else:
19652003
allvalues += tuple(values.keys())
19662004
s = ','.join([a]*len(values))
19672005
tn = '%s_%s'%(self.classname, prop)
1968-
sql.append("""select id from _%s, %s where __retired__ <> %s
2006+
sql.append("""select id from _%s, %s where __retired__=%s
19692007
and id = %s.nodeid and %s.linkid in (%s)"""%(self.classname,
19702008
tn, a, tn, tn, s))
19712009

@@ -1996,9 +2034,9 @@ def stringFind(self, **requirements):
19962034

19972035
# generate the where clause
19982036
s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
1999-
sql = 'select id from _%s where %s and __retired__<>%s'%(
2037+
sql = 'select id from _%s where %s and __retired__=%s'%(
20002038
self.classname, s, self.db.arg)
2001-
args.append(1)
2039+
args.append(0)
20022040
self.db.sql(sql, tuple(args))
20032041
# XXX numeric ids
20042042
l = [str(x[0]) for x in self.db.sql_fetchall()]
@@ -2017,12 +2055,13 @@ def getnodeids(self, retired=None):
20172055
"""
20182056
# flip the sense of the 'retired' flag if we don't want all of them
20192057
if retired is not None:
2058+
args = (0, )
20202059
if retired:
2021-
args = (0, )
2060+
compare = '>'
20222061
else:
2023-
args = (1, )
2024-
sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
2025-
self.db.arg)
2062+
compare = '='
2063+
sql = 'select id from _%s where __retired__%s%s'%(self.classname,
2064+
compare, self.db.arg)
20262065
else:
20272066
args = ()
20282067
sql = 'select id from _%s'%self.classname
@@ -2276,7 +2315,7 @@ def filter(self, search_matches, filterspec, sort=[], group=[]):
22762315
props = self.getprops()
22772316

22782317
# don't match retired nodes
2279-
where.append('_%s.__retired__ <> 1'%icn)
2318+
where.append('_%s.__retired__=0'%icn)
22802319

22812320
# add results of full text search
22822321
if search_matches is not None:
@@ -2341,7 +2380,7 @@ def filter_sql(self, sql):
23412380
The SQL select must include the item id as the first column.
23422381
23432382
This function DOES NOT filter out retired items, add on a where
2344-
clause "__retired__ <> 1" if you don't want retired nodes.
2383+
clause "__retired__=0" if you don't want retired nodes.
23452384
"""
23462385
if __debug__:
23472386
start_t = time.time()
@@ -2502,7 +2541,7 @@ def import_list(self, propnames, proplist):
25022541
# conversion (hello, sqlite)
25032542
sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
25042543
self.db.arg, self.db.arg)
2505-
self.db.sql(sql, (1, newid))
2544+
self.db.sql(sql, (newid, newid))
25062545
return newid
25072546

25082547
def export_journals(self):

test/db_test_base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
1616
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
1717
#
18-
# $Id: db_test_base.py,v 1.95 2007-12-23 01:52:07 richard Exp $
18+
# $Id: db_test_base.py,v 1.96 2008-02-07 03:28:34 richard Exp $
1919

2020
import unittest, os, shutil, errno, imp, sys, time, pprint, sets, base64, os.path
2121

@@ -2054,12 +2054,15 @@ def testCreation(self):
20542054

20552055
# check the basics of the schema and initial data set
20562056
l = db.priority.list()
2057+
l.sort()
20572058
ae(l, ['1', '2', '3', '4', '5'])
20582059
l = db.status.list()
2060+
l.sort()
20592061
ae(l, ['1', '2', '3', '4', '5', '6', '7', '8'])
20602062
l = db.keyword.list()
20612063
ae(l, [])
20622064
l = db.user.list()
2065+
l.sort()
20632066
ae(l, ['1', '2'])
20642067
l = db.msg.list()
20652068
ae(l, [])

0 commit comments

Comments
 (0)