Hi,

Apologies for the slightly lengthy email.

I'm working on getting ticket #2705 (Add optional FOR UPDATE clause to 
QuerySets) into shape, primarily by adding tests to the patch. I'm writing a 
test for a backend's FOR UPDATE NOWAIT, and have come across some interesting 
behaviour where DatabaseError appears to be swallowed when converting a 
queryset to a list. This doesn't happen when simply iterating over a queryset.

My gut feeling is that this boils down to this vastly simplified demonstration 
of how list() works:

>>> class Foo(object):
...   def __len__(self):
...     print 'len called'
...     raise ValueError
...   def __iter__(self):
...     return iter([1,2,3])
... 
>>> a = Foo()
>>> list(a)
len called
[1, 2, 3]

Here, you can see that when converting to a list, Python calls __len__, and if 
that raises an exception, discards it and goes on to call __iter__.

So - my hypothesis (unproved, as I could benefit from someone with deeper ORM 
knowledge) is that the call to list() in my original test case calls 
QuerySet.__len__(), which ends up raising a DatabaseError (caused by an 
underlying database lock, the behaviour I'm actually testing for). Python's 
subsequent call to QuerySet.__iter__() succeeds, but ends up returning an empty 
iterator due to some pre-existing state *handwaving here*.

It's the handwaving bit I'm not sure about :). Does that hypothesis sound 
plausible? It seems to be borne out by the snippet below, where I've removed 
the underlying table:

>>> from piston.models import Token
>>> list(Token.objects.all())
[]
>>> Token.objects.create()
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File 
"/Users/dan/virtual/ism/django/parts/django/django/db/models/manager.py", line 
138, in create
    return self.get_query_set().create(**kwargs)
  File "/Users/dan/virtual/ism/django/parts/django/django/db/models/query.py", 
line 352, in create
    obj.save(force_insert=True, using=self.db)
  File "/Users/dan/virtual/ism/django/parts/django/django/db/models/base.py", 
line 434, in save
    self.save_base(using=using, force_insert=force_insert, 
force_update=force_update)
  File "/Users/dan/virtual/ism/django/parts/django/django/db/models/base.py", 
line 527, in save_base
    result = manager._insert(values, return_id=update_pk, using=using)
  File 
"/Users/dan/virtual/ism/django/parts/django/django/db/models/manager.py", line 
198, in _insert
    return insert_query(self.model, values, **kwargs)
  File "/Users/dan/virtual/ism/django/parts/django/django/db/models/query.py", 
line 1492, in insert_query
    return query.get_compiler(using=using).execute_sql(return_id)
  File 
"/Users/dan/virtual/ism/django/parts/django/django/db/models/sql/compiler.py", 
line 787, in execute_sql
    cursor = super(SQLInsertCompiler, self).execute_sql(None)
  File 
"/Users/dan/virtual/ism/django/parts/django/django/db/models/sql/compiler.py", 
line 731, in execute_sql
    cursor.execute(sql, params)
  File "/Users/dan/virtual/ism/django/parts/django/django/db/backends/util.py", 
line 15, in execute
    return self.cursor.execute(sql, params)
  File 
"/Users/dan/virtual/ism/django/parts/django/django/db/backends/mysql/base.py", 
line 86, in execute
    return self.cursor.execute(query, args)
  File 
"/Users/dan/.buildout/eggs/MySQL_python-1.2.3-py2.6-macosx-10.6-universal.egg/MySQLdb/cursors.py",
 line 174, in execute
    self.errorhandler(self, exc, value)
  File 
"/Users/dan/.buildout/eggs/MySQL_python-1.2.3-py2.6-macosx-10.6-universal.egg/MySQLdb/connections.py",
 line 36, in defaulterrorhandler
    raise errorclass, errorvalue
DatabaseError: (1146, "Table 'ismdj.piston_token' doesn't exist")

... and of course:

>>> for token in Token.objects.all(): 
...   print token
... 
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/Users/dan/virtual/ism/django/parts/django/django/db/models/query.py", 
line 106, in _result_iter
    self._fill_cache()
  File "/Users/dan/virtual/ism/django/parts/django/django/db/models/query.py", 
line 773, in _fill_cache
    self._result_cache.append(self._iter.next())
  File "/Users/dan/virtual/ism/django/parts/django/django/db/models/query.py", 
line 269, in iterator
    for row in compiler.results_iter():
  File 
"/Users/dan/virtual/ism/django/parts/django/django/db/models/sql/compiler.py", 
line 676, in results_iter
    for rows in self.execute_sql(MULTI):
  File 
"/Users/dan/virtual/ism/django/parts/django/django/db/models/sql/compiler.py", 
line 731, in execute_sql
    cursor.execute(sql, params)
  File "/Users/dan/virtual/ism/django/parts/django/django/db/backends/util.py", 
line 15, in execute
    return self.cursor.execute(sql, params)
  File 
"/Users/dan/virtual/ism/django/parts/django/django/db/backends/mysql/base.py", 
line 86, in execute
    return self.cursor.execute(query, args)
  File 
"/Users/dan/.buildout/eggs/MySQL_python-1.2.3-py2.6-macosx-10.6-universal.egg/MySQLdb/cursors.py",
 line 174, in execute
    self.errorhandler(self, exc, value)
  File 
"/Users/dan/.buildout/eggs/MySQL_python-1.2.3-py2.6-macosx-10.6-universal.egg/MySQLdb/connections.py",
 line 36, in defaulterrorhandler
    raise errorclass, errorvalue
DatabaseError: (1146, "Table 'ismdj.piston_token' doesn't exist")

Is this expected behaviour, where an exception is only raised when models are 
iterated over?

For reference, here's a rough, slightly abbreviated version of the test code I 
have so far for the patch I'm working on. The interesting code is marked with a 
# XXX, but the context may be useful (I'm trying to look for a DatabaseError 
that will be returned by the database due to being unable to get a lock):

from models import Person

class SelectForUpdateTests(TransactionTestCase):

    def setUp(self):
        connection._rollback()
        connection._enter_transaction_management(True)
        self.new_connections = ConnectionHandler(settings.DATABASES)
        self.person = Person.objects.create(name='Reinhardt')

    def start_blocking_transaction(self):
        self.new_connection = self.new_connections[DEFAULT_DB_ALIAS]
        self.new_connection._enter_transaction_management(True)
        self.cursor = self.new_connection.cursor()
        sql = 'SELECT * FROM %(db_table)s %(for_update)s;' % {
            'db_table': Person._meta.db_table,
            'for_update': self.new_connection.ops.for_update_sql(),
            }
        self.cursor.execute(sql, ())
        result = self.cursor.fetchone()

    def end_blocking_transaction(self):
        self.new_connection._rollback()
        self.new_connection.close()
        self.new_connection._leave_transaction_management(True)

    def get_exception(self):
        from django.db.models.sql.query import LockNotAvailable
        return LockNotAvailable

    def run_select_for_update(self, status, nowait=False):
        status.append('started')
        try:
            connection._rollback()
            people = Person.objects.all().select_for_update(nowait=nowait)
            # for person in people:
            #     person.name = 'Fred'
            #     person.save()
            # XXX
            people = list(people)
            connection._commit()
            status.append('finished')
        except Exception, e:
            status.append(e)

    @skipUnlessDBFeature('has_select_for_update_nowait')
    def test_nowait_raises_error_on_block(self):
        """
        If nowait is specified, we expect an error to be raised rather
        than blocking.
        """
        self.start_blocking_transaction()
        status = []
        thread = threading.Thread(
            target=self.run_select_for_update,
            args=(status,),
            kwargs={'nowait': True},
        )

        thread.start()

        # We should find the thread threw an exception
        time.sleep(1)
        self.end_blocking_transaction()
        thread.join()
        self.assertTrue(isinstance(status[-1], self.get_exception()))

The interesting part is the code marked with # XXX. As it stands, that line 
produces a simple empty list. Commenting that and uncommenting the 'for person 
in people' stanza causes a DatabaseError to be (correctly) raised.

This is a problem for this patch, as it's difficult to tell the difference 
between no records returned, and the database being unable to get a row lock in 
the case for FOR UPDATE NOWAIT.

Cheers,
Dan

--
Dan Fairs | [email protected] | www.fezconsulting.com


-- 
You received this message because you are subscribed to the Google Groups 
"Django developers" group.
To post to this group, send email to [email protected].
To unsubscribe from this group, send email to 
[email protected].
For more options, visit this group at 
http://groups.google.com/group/django-developers?hl=en.

Reply via email to