Skip to content

Commit 293dfaa

Browse files
committed
- issue2551223 - fix timestamp truncation in mysql and postgresql
The data types used to represent timestamps in pg and mysql for ephemeral tables: sessions and otks don't have enough signifcant digits to work. As a result the timestamps are rounduped (up/down) rsuling in the stored timestamp being 2 minutes (pg) or 2-3 hours(mysql) off from what it should be. Modify db schema to use a numeric type that preserves more significant figures. Implement schema upgrade. Document need for upgrade in upgrading.txt. Write tests for schema upgrade. Implement test for updateTimestamp method on BasicDatabase that showed this issue in the first place. Write overrides for test for anydbm/memorydb which store timestamp properly or not at all.
1 parent cb573a8 commit 293dfaa

File tree

11 files changed

+333
-10
lines changed

11 files changed

+333
-10
lines changed

CHANGES.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ Fixed:
2222
sqlite. New databases are created for session data (db-session)
2323
and one time key data (db-otk). The data is ephemeral so no need to
2424
migrate.
25+
- issue2551223 - Timestamps are truncated in mysql and postgresql for
26+
session and otk database tables. Modify db schema to use a numeric
27+
type that preserves more significant figures. See upgrading.txt for
28+
required steps.
2529

2630
Features:
2731

doc/upgrading.txt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,38 @@ Contents:
3535
Migrating from 2.2.0 to 2.3.0
3636
=============================
3737

38+
Rdbms version change from 7 to 8 (required)
39+
-------------------------------------------
40+
41+
This release includes a change that requires updates to the
42+
database schema.
43+
44+
Sessions and one time key (otks) tables in the Mysql and
45+
PostgreSQL database use a numeric type that
46+
truncates/rounds expiration timestamps. This results in
47+
entries being purged early or late (depending on whether
48+
it rounds up or down). The discrepancy is a couple of
49+
days for Mysql or a couple of minutes for PostgreSQL.
50+
51+
Session keys stay for a week or more and CSRF keys are
52+
two weeks by default. As a result, this isn't usually a
53+
visible issue. This migration updates the numeric types
54+
to ones that supports more significant figures.
55+
56+
You should backup your instance and run the
57+
``roundup-admin -i <tracker_home> migrate``
58+
command for all your trackers once you've
59+
installed the latest code base.
60+
61+
Do this before you use the web, command-line or mail
62+
interface and before any users access the tracker.
63+
64+
If successful, this command will respond with either
65+
"Tracker updated" (if you've not previously run it on an
66+
RDBMS backend) or "No migration action required" (if you
67+
have run it, or have used another interface to the tracker,
68+
or are using anydbm).
69+
3870
Session/OTK data storage for SQLite backend changed
3971
---------------------------------------------------
4072

roundup/backends/back_mysql.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,13 @@ def save_dbschema(self):
219219
def create_version_2_tables(self):
220220
# OTK store
221221
self.sql('''CREATE TABLE otks (otk_key VARCHAR(255),
222-
otk_value TEXT, otk_time FLOAT(20))
222+
otk_value TEXT, otk_time DOUBLE)
223223
ENGINE=%s'''%self.mysql_backend)
224224
self.sql('CREATE INDEX otks_key_idx ON otks(otk_key)')
225225

226226
# Sessions store
227227
self.sql('''CREATE TABLE sessions (session_key VARCHAR(255),
228-
session_time FLOAT(20), session_value TEXT)
228+
session_time DOUBLE, session_value TEXT)
229229
ENGINE=%s'''%self.mysql_backend)
230230
self.sql('''CREATE INDEX sessions_key_idx ON
231231
sessions(session_key)''')
@@ -421,6 +421,13 @@ def fix_version_6_tables(self):
421421
# column length and maxlength.
422422
c.execute(sql, (self.indexer.maxlength + 5,))
423423

424+
def fix_version_7_tables(self):
425+
# Modify type for session.session_time/otk.otk_time column.
426+
sql = "alter table sessions modify session_time double"
427+
self.sql(sql)
428+
sql = "alter table otks modify otk_time double"
429+
self.sql(sql)
430+
424431
def __repr__(self):
425432
return '<myroundsql 0x%x>'%id(self)
426433

roundup/backends/back_postgresql.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,12 @@ def restore_connection_on_error(self, savepoint="importing"):
234234
def create_version_2_tables(self):
235235
# OTK store
236236
self.sql('''CREATE TABLE otks (otk_key VARCHAR(255),
237-
otk_value TEXT, otk_time REAL)''')
237+
otk_value TEXT, otk_time float)''')
238238
self.sql('CREATE INDEX otks_key_idx ON otks(otk_key)')
239239

240240
# Sessions store
241241
self.sql('''CREATE TABLE sessions (
242-
session_key VARCHAR(255), session_time REAL,
242+
session_key VARCHAR(255), session_time float,
243243
session_value TEXT)''')
244244
self.sql('''CREATE INDEX sessions_key_idx ON
245245
sessions(session_key)''')
@@ -296,6 +296,14 @@ def fix_version_6_tables(self):
296296

297297
self._add_fts_table()
298298

299+
def fix_version_7_tables(self):
300+
# Modify type for session.session_time/otk.otk_time column.
301+
# float is double precision 15 signifcant digits
302+
sql = 'alter table sessions alter column session_time type float'
303+
self.sql(sql)
304+
sql = 'alter table otks alter column otk_time type float'
305+
self.sql(sql)
306+
299307
def add_actor_column(self):
300308
# update existing tables to have the new actor column
301309
tables = self.database_schema['tables']

roundup/backends/rdbms_common.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ def post_init(self):
331331

332332
# update this number when we need to make changes to the SQL structure
333333
# of the backend database
334-
current_db_version = 7
334+
current_db_version = 8
335335
db_version_updated = False
336336

337337
def upgrade_db(self):
@@ -381,6 +381,10 @@ def upgrade_db(self):
381381
self.log_info('upgrade to version 7')
382382
self.fix_version_6_tables()
383383

384+
if version < 8:
385+
self.log_info('upgrade to version 8')
386+
self.fix_version_7_tables()
387+
384388
self.database_schema['version'] = self.current_db_version
385389
self.db_version_updated = True
386390
return 1
@@ -422,6 +426,12 @@ def fix_version_6_tables(self):
422426
# You would think ALTER commands would be the same but nooo.
423427
pass
424428

429+
def fix_version_7_tables(self):
430+
# Default (used by sqlite): NOOP
431+
# Each backend mysql, postgres overrides this
432+
# You would think ALTER commands would be the same but nooo.
433+
pass
434+
425435
def _convert_journal_tables(self):
426436
"""Get current journal table contents, drop the table and re-create"""
427437
c = self.cursor

test/session_common.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,32 @@ def testUpdateSession(self):
5959
self.sessions.set('random_key', text='nope')
6060
self.assertEqual(self.sessions.get('random_key', 'text'), 'nope')
6161

62+
# overridden in dbm and memory backends
63+
def testUpdateTimestamp(self):
64+
def get_ts_via_sql(self):
65+
sql = '''select %(name)s_time from %(name)ss
66+
where %(name)s_key = '%(session)s';'''% \
67+
{'name': self.sessions.name,
68+
'session': 'random_session'}
69+
70+
self.sessions.cursor.execute(sql)
71+
db_tstamp = self.sessions.cursor.fetchone()
72+
return db_tstamp
73+
74+
# make sure timestamp is older than one minute so update will apply
75+
timestamp = time.time() - 62
76+
self.sessions.set('random_session', text='hello, world!',
77+
__timestamp=timestamp)
78+
79+
self.sessions.updateTimestamp('random_session')
80+
# this doesn't work as the rdbms backends have a
81+
# session_time, otk_time column and the timestamp in the
82+
# session marshalled payload isn't updated. The dbm
83+
# backend does update the __timestamp value so it works
84+
# for dbm.
85+
#self.assertNotEqual (self.sessions.get('random_session',
86+
# '__timestamp'),
87+
# timestamp)
88+
89+
# use 61 to allow a fudge factor
90+
self.assertGreater(get_ts_via_sql(self)[0] - timestamp, 61)

test/test_anydbm.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ class anydbmHTMLItemTest(HTMLItemTest, unittest.TestCase):
5555
class anydbmSessionTest(anydbmOpener, SessionTest, unittest.TestCase):
5656
s2b = lambda x,y: strings.s2b(y)
5757

58+
# this only works for dbm. other backends don't change the __timestamp
59+
# value, they have a separate column for the timestamp so they can
60+
# update it with SQL.
61+
def testUpdateTimestamp(self):
62+
# make sure timestamp is older than one minute so update will work
63+
timestamp = time.time() - 62
64+
self.sessions.set('random_session', text='hello, world!',
65+
__timestamp=timestamp)
66+
self.sessions.updateTimestamp('random_session')
67+
self.assertNotEqual (self.sessions.get('random_session',
68+
'__timestamp'),
69+
timestamp)
70+
5871
class anydbmSpecialActionTestCase(anydbmOpener, SpecialActionTest,
5972
unittest.TestCase):
6073
backend = 'anydbm'

test/test_memorydb.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,9 @@ def setUp(self):
6363
setupSchema(self.db, 1, self.module)
6464
self.sessions = self.db.sessions
6565

66+
# doesn't work for memory as it uses a mock for session db.
67+
def testUpdateTimestamp(self):
68+
pass
69+
6670
# vim: set filetype=python ts=4 sw=4 et si
6771

test/test_mysql.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,19 @@ def testUpgrade_6_to_7(self):
7171
self.db.issue.create(title="flebble frooz")
7272
self.db.commit()
7373

74-
if self.db.database_schema['version'] != 7:
74+
if self.db.database_schema['version'] > 7:
75+
# make testUpgrades run the downgrade code only.
76+
if hasattr(self, "downgrade_only"):
77+
# we are being called by an earlier test
78+
self.testUpgrade_7_to_8()
79+
self.assertEqual(self.db.database_schema['version'], 7)
80+
else:
81+
# we are being called directly
82+
self.downgrade_only = True
83+
self.testUpgrade_7_to_8()
84+
self.assertEqual(self.db.database_schema['version'], 7)
85+
del(self.downgrade_only)
86+
elif self.db.database_schema['version'] != 7:
7587
self.skipTest("This test only runs for database version 7")
7688

7789

@@ -111,6 +123,65 @@ def testUpgrade_6_to_7(self):
111123
self.assertEqual(self.db.database_schema['version'],
112124
self.db.current_db_version)
113125

126+
def testUpgrade_7_to_8(self):
127+
# load the database
128+
self.db.issue.create(title="flebble frooz")
129+
self.db.commit()
130+
131+
if self.db.database_schema['version'] != 8:
132+
self.skipTest("This test only runs for database version 8")
133+
134+
# change otk and session db's _time value to their original types
135+
sql = "alter table sessions modify session_time float;"
136+
self.db.sql(sql)
137+
sql = "alter table otks modify otk_time float;"
138+
self.db.sql(sql)
139+
140+
# verify they truncate long ints.
141+
test_double = 1658718284.7616878
142+
for tablename in ['otk', 'session']:
143+
self.db.sql(
144+
'insert %(name)ss(%(name)s_key, %(name)s_time, %(name)s_value) '
145+
'values("foo", %(double)s, "value");'%{'name': tablename,
146+
'double': test_double}
147+
)
148+
149+
self.db.cursor.execute('select %(name)s_time from %(name)ss '
150+
'where %(name)s_key = "foo"'%{'name': tablename})
151+
152+
self.assertNotAlmostEqual(self.db.cursor.fetchone()[0],
153+
test_double, -1)
154+
155+
# cleanup or else the inserts after the upgrade will not
156+
# work.
157+
self.db.sql("delete from %(name)ss where %(name)s_key='foo'"%{
158+
'name': tablename} )
159+
160+
self.db.database_schema['version'] = 7
161+
162+
if hasattr(self,"downgrade_only"):
163+
return
164+
165+
# test upgrade
166+
self.db.post_init()
167+
168+
# verify they keep all signifcant digits before the decimal point
169+
for tablename in ['otk', 'session']:
170+
self.db.sql(
171+
'insert %(name)ss(%(name)s_key, %(name)s_time, %(name)s_value) '
172+
'values("foo", %(double)s, "value");'%{'name': tablename,
173+
'double': test_double}
174+
)
175+
176+
self.db.cursor.execute('select %(name)s_time from %(name)ss '
177+
'where %(name)s_key = "foo"'%{'name': tablename})
178+
179+
# -1 compares just the integer part. No fractions.
180+
self.assertAlmostEqual(self.db.cursor.fetchone()[0],
181+
test_double, -1)
182+
183+
self.assertEqual(self.db.database_schema['version'], 8)
184+
114185
@skip_mysql
115186
class mysqlROTest(mysqlOpener, ROTest, unittest.TestCase):
116187
def setUp(self):

test/test_postgresql.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,19 @@ def testUpgrade_6_to_7(self):
8484
self.db.issue.create(title="flebble frooz")
8585
self.db.commit()
8686

87-
if self.db.database_schema['version'] != 7:
88-
# consider calling next testUpgrade script to roll back
89-
# schema to version 7.
87+
if self.db.database_schema['version'] > 7:
88+
# make testUpgrades run the downgrade code only.
89+
if hasattr(self, "downgrade_only"):
90+
# we are being called by an earlier test
91+
self.testUpgrade_7_to_8()
92+
self.assertEqual(self.db.database_schema['version'], 7)
93+
else:
94+
# we are being called directly
95+
self.downgrade_only = True
96+
self.testUpgrade_7_to_8()
97+
self.assertEqual(self.db.database_schema['version'], 7)
98+
del(self.downgrade_only)
99+
elif self.db.database_schema['version'] != 7:
90100
self.skipTest("This test only runs for database version 7")
91101

92102
# remove __fts table/index; shrink length of __words._words
@@ -140,6 +150,65 @@ def testUpgrade_6_to_7(self):
140150
self.assertEqual(self.db.database_schema['version'],
141151
self.db.current_db_version)
142152

153+
def testUpgrade_7_to_8(self):
154+
""" change _time fields in BasicDatabases to double """
155+
# load the database
156+
self.db.issue.create(title="flebble frooz")
157+
self.db.commit()
158+
159+
if self.db.database_schema['version'] != 8:
160+
self.skipTest("This test only runs for database version 8")
161+
162+
# change otk and session db's _time value to their original types
163+
sql = "alter table sessions alter column session_time type REAL;"
164+
self.db.sql(sql)
165+
sql = "alter table otks alter column otk_time type REAL;"
166+
self.db.sql(sql)
167+
168+
# verify they truncate long ints.
169+
test_double = 1658718284.7616878
170+
for tablename in ['otk', 'session']:
171+
self.db.sql(
172+
'insert into %(name)ss(%(name)s_key, %(name)s_time, %(name)s_value) '
173+
"values ('foo', %(double)s, 'value');"%{'name': tablename,
174+
'double': test_double}
175+
)
176+
177+
self.db.cursor.execute('select %(name)s_time from %(name)ss '
178+
"where %(name)s_key = 'foo'"%{'name': tablename})
179+
180+
self.assertNotAlmostEqual(self.db.cursor.fetchone()[0],
181+
test_double, -1)
182+
183+
# cleanup or else the inserts after the upgrade will not
184+
# work.
185+
self.db.sql("delete from %(name)ss where %(name)s_key='foo'"%{
186+
'name': tablename} )
187+
188+
self.db.database_schema['version'] = 7
189+
190+
if hasattr(self,"downgrade_only"):
191+
return
192+
193+
# test upgrade altering row
194+
self.db.post_init()
195+
196+
# verify they keep all signifcant digits before the decimal point
197+
for tablename in ['otk', 'session']:
198+
self.db.sql(
199+
'insert into %(name)ss(%(name)s_key, %(name)s_time, %(name)s_value) '
200+
"values ('foo', %(double)s, 'value');"%{'name': tablename,
201+
'double': test_double}
202+
)
203+
204+
self.db.cursor.execute('select %(name)s_time from %(name)ss '
205+
"where %(name)s_key = 'foo'"%{'name': tablename})
206+
207+
self.assertAlmostEqual(self.db.cursor.fetchone()[0],
208+
test_double, -1)
209+
210+
self.assertEqual(self.db.database_schema['version'], 8)
211+
143212
@skip_postgresql
144213
class postgresqlROTest(postgresqlOpener, ROTest, unittest.TestCase):
145214
def setUp(self):

0 commit comments

Comments
 (0)