I've loaded up mailman's test suite and one step at a time reduced the failing test to more granular steps, removing one at a time to continue to reproduce the error. It turns out there is a fundamental bug in the post_update feature that has existed for years and is only becoming apparent now that the feature reports on missed rows. issue is at https://bitbucket.org/zzzeek/sqlalchemy/issues/4187/post_update-fails-if-the-parent-object-is. hopefully fixed in a few hours and hopefully backports to 1.1.x though it's only critical that it gets to 1.2 since that's where the assertion was added.
On Sun, Jan 28, 2018 at 12:19 PM, Mike Bayer <mike...@zzzcomputing.com> wrote: > On Sat, Jan 27, 2018 at 11:54 PM, <raj.abhila...@gmail.com> wrote: >> Hi Mike, >> >> I am one of the other Mailman developers, just following up on behalf of >> Mark. >> >> On Sunday, 21 January 2018 06:51:29 UTC-8, Mike Bayer wrote: >>> >>> here are a few questions: >>> >>> 1. is mailman using the version_id mapper feature with these mappings ? >> >> >> No, Mailman isn't using version_id mapper feature. >> >>> >>> >>> 2. do mailman mappings use natural primary keys? in the above example >>> are the primary keys of objects changing? >>> >> No, the primary keys of the objects aren't changing. >> >> I tried to run the test with echo='debug' to produce SQL statements emitted >> and found something odd. I see that the UPDATE statement from >> post_update=True is being emitted *after* the DELETE statement, when trying >> to delete a record. This causes the UPDATE statement to error out due to >> non-existent row. This happens both in 1.1 and 1.2, although 1.1 passes it >> silently while 1.2 errors out with the stack trace that Mark presented >> above. >> >> Here is the relevant SQL statements: >> >> User.id == 2 is being accessed after it has been deleted. >> >> 1.1.15: >> >> >> 018-01-27 20:32:54,580 INFO sqlalchemy.engine.base.Engine DELETE FROM user >> WHERE user.id = ? >> 2018-01-27 20:32:54,580 INFO sqlalchemy.engine.base.Engine (2,) >> 2018-01-27 20:32:54,581 INFO sqlalchemy.engine.base.Engine UPDATE user SET >> _preferred_address_id=? WHERE user.id = ? >> 2018-01-27 20:32:54,581 INFO sqlalchemy.engine.base.Engine (None, 2) >> 2018-01-27 20:32:54,582 INFO sqlalchemy.engine.base.Engine DELETE FROM >> preferences WHERE preferences.id = ? >> 2018-01-27 20:32:54,582 INFO sqlalchemy.engine.base.Engine (4,) >> 2018-01-27 20:32:54,583 INFO sqlalchemy.engine.base.Engine SELECT >> user.password AS user_password, user.id AS user_id, user.display_name AS >> user_display_name, user._user_id AS user__user_id, user._created_on AS >> user__created_on, user.is_server_owner AS user_is_server_owner, >> user._preferred_address_id AS user__preferred_address_id, >> user.preferences_id AS user_preferences_id >> FROM user ORDER BY user.id >> 2018-01-27 20:32:54,584 INFO sqlalchemy.engine.base.Engine () >> >> 1.2.2: >> >> >> 2018-01-27 20:26:58,981 INFO sqlalchemy.engine.base.Engine DELETE FROM user >> WHERE user.id = ? >> 2018-01-27 20:26:58,982 INFO sqlalchemy.engine.base.Engine (2,) >> 2018-01-27 20:26:58,982 INFO sqlalchemy.engine.base.Engine UPDATE user SET >> _preferred_address_id=? WHERE user.id = ? >> 2018-01-27 20:26:58,983 INFO sqlalchemy.engine.base.Engine (None, 2) >> 2018-01-27 20:26:58,983 INFO sqlalchemy.engine.base.Engine ROLLBACK >> sqlalchemy.orm.exc.StaleDataError: UPDATE statement on table 'user' expected >> to update 1 row(s); 0 were matched. >> >> >> >> This[1] is the 'user' model definition and the foreign key that should be >> dis-connected before the DELETE, as is my understanding at least. > > Well, i just recreated your User/ Address/ Preferences model and tried > every combination of deleting / removing Address, Preference, and User > objects / attributes using a loop with flags iterating through 1024 > different permutations, and I can't reproduce that condition, the post > update always does the right thing. From what we see, we know that > the User object is definitely subject to a session.delete(), but as > far as other objects, it's not clear yet. > > So, need to know a lot more detail: > > 1. what is the *full* SQL output of every row being altered within this test > > 2. are you manipulating the integer foreign key attributes of objects? > E.g. user.preferred_address_id = None? Are you manipulating > relationship-bound attributes? e.g. user.preferred_address = None ? > are the two approaches being combined? > > 3. can you place a session.flush() right before the Query in question > so that the flush simply proceeds > > 4. right before this flush and/or Query proceeds, what are the > contents of: session.deleted, session.dirty, session.new ? > > 5. are relationships being reassigned? like > user1.addresses.append(user2.address[0]) ? > > Alternatively: > > Below is a sample of the relevant bits of the model in isolation, with > a delete of a User and Preferences object. if you could make this > test show the problem, that would be the most effective way to > immediately show what's happening. > > from sqlalchemy import * > from sqlalchemy.orm import * > from sqlalchemy.ext.declarative import declarative_base > > Base = declarative_base() > > > class User(Base): > __tablename__ = 'user' > id = Column(Integer, primary_key=True) > favorite_address_id = Column(ForeignKey('address.id')) > addresses = relationship("Address", backref="user", > primaryjoin=lambda: User.id == Address.user_id) > favorite_address = relationship( > "Address", post_update=True, > primaryjoin=lambda: User.favorite_address_id == Address.id) > > preferences_id = Column(Integer, ForeignKey('preferences.id'), index=True) > preferences = relationship( > 'Preferences', backref=backref('user', uselist=False)) > > > class Preferences(Base): > __tablename__ = 'preferences' > id = Column(Integer, primary_key=True) > > > class Address(Base): > __tablename__ = 'address' > id = Column(Integer, primary_key=True) > user_id = Column(ForeignKey('user.id')) > > e = create_engine("sqlite://", echo=True) > s = Session(e) > > > Base.metadata.create_all(e) > p1 = Preferences() > a1, a2, a3 = Address(), Address(), Address() > u1 = User(addresses=[a1, a2, a3], favorite_address=a2, preferences=p1) > s.add(u1) > s.commit() > > s.delete(p1) > s.delete(u1) > s.commit() > > > > > > > >> >> [1]: >> https://gitlab.com/mailman/mailman/blob/master/src/mailman/model/user.py#L68 >> >> Please let me know if you need any more information about this. >> >> thanks, >> Abhilash >> >> >> >>> >>> >>> >>> On Sun, Jan 21, 2018 at 8:24 AM, Mike Bayer <mik...@zzzcomputing.com> >>> wrote: >>> > On Sat, Jan 20, 2018 at 8:19 PM, Mark Sapiro <ma...@msapiro.net> wrote: >>> >> Gnu Mailman 3 uses SQL Alchemy to access SQL databases. We have an >>> >> issue >>> >> with one of or unit tests which passes with SQLAlchemy <= 1.1.15 but >>> >> which >>> >> fails with SQLAlchemy >= 1.2. The failure is independent of the backend >>> >> (sglite, mysql or pgsql). Here's a traceback from the failure. >>> >> >>> >> Traceback (most recent call last): >>> >> File "/builds/mailman/mailman/src/mailman/model/tests/test_user.py", >>> >> line >>> >> 184, in test_absorb_memberships >>> >> all_users = list(self._manager.users) >>> >> File "/builds/mailman/mailman/src/mailman/model/usermanager.py", line >>> >> 102, >>> >> in users >>> >> yield from store.query(User).order_by(User.id).all() >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/orm/query.py", >>> >> line 2726, in all >>> >> return list(self) >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/orm/query.py", >>> >> line 2877, in __iter__ >>> >> self.session._autoflush() >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/orm/session.py", >>> >> line 1434, in _autoflush >>> >> self.flush() >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/orm/session.py", >>> >> line 2243, in flush >>> >> self._flush(objects) >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/orm/session.py", >>> >> line 2369, in _flush >>> >> transaction.rollback(_capture_exception=True) >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/util/langhelpers.py", >>> >> line 66, in __exit__ >>> >> compat.reraise(exc_type, exc_value, exc_tb) >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/util/compat.py", >>> >> line 187, in reraise >>> >> raise value >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/orm/session.py", >>> >> line 2333, in _flush >>> >> flush_context.execute() >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/orm/unitofwork.py", >>> >> line 391, in execute >>> >> rec.execute(self) >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/orm/unitofwork.py", >>> >> line 542, in execute >>> >> persistence.post_update(self.mapper, states, uow, cols) >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/orm/persistence.py", >>> >> line 234, in post_update >>> >> mapper, table, update) >>> >> File >>> >> >>> >> "/builds/mailman/mailman/.tox/py36-nocov/lib/python3.6/site-packages/sqlalchemy/orm/persistence.py", >>> >> line 982, in _emit_post_update_statements >>> >> (table.description, len(records), rows)) >>> >> sqlalchemy.orm.exc.StaleDataError: UPDATE statement on table 'user' >>> >> expected >>> >> to update 1 row(s); 0 were matched. >>> > >>> > >>> > so StaleDataError is thrown when the ORM seeks to update or delete a >>> > row based on primary key, and the row which that statement would match >>> > is no longer present. This is essentially an assertion that the state >>> > of the database is what the ORM expects based on its internal state. >>> > The "stale" name refers to the typical case that some other >>> > transaction has changed the state of the database while our >>> > transaction is proceeding, however it is often the case that some >>> > combination of how the mappings are set up combined with what the >>> > application is doing causes the ORM to not have a row available which >>> > should. >>> > >>> > In this case, the operation is specifically in relationship to the >>> > usage of the "post_update" feature of mappings >>> > >>> > (http://docs.sqlalchemy.org/en/latest/orm/relationship_persistence.html#post-update) >>> > , since we can see that in the stack trace. The difference between >>> > 1.1 and 1.2 is that the StaleDataError assertions have been added to >>> > the post update system: >>> > >>> > http://docs.sqlalchemy.org/en/latest/changelog/migration_12.html#post-update-integrates-with-orm-versioning >>> > which is almost definitely part of the reason you get an error in 1.2 >>> > and not in 1.1. >>> > >>> > What remains to be understood is whether your test produced an UPDATE >>> > that didn't match a row in any case, and in 1.2, we just happen to be >>> > raising an error for it, whereas under 1.1 this error condition >>> > silently passed. Or, if the change in 1.2 itself has a regression and >>> > the steps / SQL emitted are erroneously different. >>> > >>> > Things that would help us to see would be: 1. a look at the models >>> > in question and how post_update is being set up and 2. when the >>> > test runs, grab the full SQL output, using something like echo='debug' >>> > on create_engine() or logging the "sqlalchemy.engine" logger. We >>> > want to look at the exact sequence of SQL emitted both for the 1.1 >>> > series and the 1.2 series, to see what the nature of the UPDATE >>> > against a missing row is. If it looks like things work exactly the >>> > same in 1.1 then that means your post_update has been missing this row >>> > the whole time. >>> > >>> > This is just the top level information, I haven't looked at your >>> > actual code yet just the stack trace. I'm not available today but >>> > later in the week if you can produce self-contained reproducing >>> > examples then I can further examine if we have a regression on the >>> > SQLAlchemy side, or if this is expected behavior. >>> > >>> > >>> > >>> > >>> > >>> > >>> >> >>> >> >>> >> The test that fails sets up 3 mailing lists and subscribes 2 users >>> >> (Anne and >>> >> Bart) to some or all of the lists with different roles. Then user Anne >>> >> "absorbs" user Bart so user Bart is deleted and user Anne assumes all >>> >> Bart's >>> >> memberships. The actual code in the test may not be intelligible >>> >> without >>> >> more Mailman knowledge, but it is: >>> >> def test_absorb_memberships(self): >>> >> # When a user is absorbed, all of their user-subscribed >>> >> memberships >>> >> # are relinked to the absorbing user. >>> >> mlist2 = create_list('te...@example.com') >>> >> mlist3 = create_list('te...@example.com') >>> >> with transaction(): >>> >> # This has to happen in a transaction so that both the user >>> >> and >>> >> # the preferences objects get valid ids. >>> >> bart = self._manager.create_user('b...@example.com', 'Bart >>> >> Person') >>> >> set_preferred(bart) >>> >> # Subscribe both users to self._mlist. >>> >> self._mlist.subscribe(self._anne, MemberRole.member) >>> >> self._mlist.subscribe(bart, MemberRole.moderator) >>> >> # Subscribe only Bart to mlist2. >>> >> mlist2.subscribe(bart, MemberRole.owner) >>> >> # Subscribe only Bart's address to mlist3. >>> >> mlist3.subscribe(bart.preferred_address, MemberRole.moderator) >>> >> # There are now 4 memberships, one with Anne two with Bart's >>> >> user >>> >> and >>> >> # one with Bart's address. >>> >> all_members = list(self._manager.members) >>> >> self.assertEqual(len(all_members), 4, all_members) >>> >> # Do the absorption. >>> >> self._anne.absorb(bart) >>> >> # The Bart user has been deleted, leaving only the Anne user in >>> >> the >>> >> # user manager. >>> >> all_users = list(self._manager.users) >>> >> self.assertEqual(len(all_users), 1) >>> >> self.assertEqual(all_users[0], self._anne) >>> >> # There are no leftover memberships for user Bart. Anne owns >>> >> all >>> >> the >>> >> # memberships. >>> >> all_members = list(self._manager.members) >>> >> self.assertEqual(len(all_members), 4, all_members) >>> >> self.assertEqual(self._anne.memberships.member_count, 4) >>> >> memberships = {(member.list_id, member.role): member >>> >> for member in self._anne.memberships.members} >>> >> # Note that Anne is now both a member and moderator of the test >>> >> list. >>> >> self.assertEqual(set(memberships), set([ >>> >> ('test.example.com', MemberRole.member), >>> >> ('test.example.com', MemberRole.moderator), >>> >> ('test2.example.com', MemberRole.owner), >>> >> ('test3.example.com', MemberRole.moderator), >>> >> ])) >>> >> # Both of Bart's previous user subscriptions are now >>> >> transferred to >>> >> # the Anne user. >>> >> self.assertEqual( >>> >> memberships[('test.example.com', >>> >> MemberRole.moderator)].address, >>> >> self._anne.preferred_address) >>> >> self.assertEqual( >>> >> memberships[('test2.example.com', >>> >> MemberRole.owner)].address, >>> >> self._anne.preferred_address) >>> >> # Bart's address was subscribed; it must not have been changed. >>> >> Of >>> >> # course, Anne now controls ba...@example.com. >>> >> key = ('test3.example.com', MemberRole.moderator) >>> >> self.assertEqual(memberships[key].address.email, >>> >> 'ba...@example.com') >>> >> self.assertEqual(self._manager.get_user('ba...@example.com'), >>> >> self._anne) >>> >> >>> >> >>> >> >>> >> The start of the traceback is the statement >>> >> # The Bart user has been deleted, leaving only the Anne user in >>> >> the >>> >> # user manager. >>> >> all_users = list(self._manager.users) >>> >> Versions older than 1.2 do not throw the >>> >> sqlalchemy.orm.exc.StaleDataError >>> >> exception at this point. >>> >> >>> >> If I attempt to fix the test by doing the `self._anne.absorb(bart)` in >>> >> a >>> >> transaction that gets committed on completion, the same exception is >>> >> thrown >>> >> on the commit. >>> >> >>> >> Does anyone have any ideas about this? >>> >> >>> >> -- >>> >> SQLAlchemy - >>> >> The Python SQL Toolkit and Object Relational Mapper >>> >> >>> >> http://www.sqlalchemy.org/ >>> >> >>> >> To post example code, please provide an MCVE: Minimal, Complete, and >>> >> Verifiable Example. See http://stackoverflow.com/help/mcve for a full >>> >> description. >>> >> --- >>> >> You received this message because you are subscribed to the Google >>> >> Groups >>> >> "sqlalchemy" group. >>> >> To unsubscribe from this group and stop receiving emails from it, send >>> >> an >>> >> email to sqlalchemy+...@googlegroups.com. >>> >> To post to this group, send email to sqlal...@googlegroups.com. >>> >> Visit this group at https://groups.google.com/group/sqlalchemy. >>> >> For more options, visit https://groups.google.com/d/optout. >> >> -- >> SQLAlchemy - >> The Python SQL Toolkit and Object Relational Mapper >> >> http://www.sqlalchemy.org/ >> >> To post example code, please provide an MCVE: Minimal, Complete, and >> Verifiable Example. See http://stackoverflow.com/help/mcve for a full >> description. >> --- >> You received this message because you are subscribed to the Google Groups >> "sqlalchemy" group. >> To unsubscribe from this group and stop receiving emails from it, send an >> email to sqlalchemy+unsubscr...@googlegroups.com. >> To post to this group, send email to sqlalchemy@googlegroups.com. >> Visit this group at https://groups.google.com/group/sqlalchemy. >> For more options, visit https://groups.google.com/d/optout. -- SQLAlchemy - The Python SQL Toolkit and Object Relational Mapper http://www.sqlalchemy.org/ To post example code, please provide an MCVE: Minimal, Complete, and Verifiable Example. See http://stackoverflow.com/help/mcve for a full description. --- You received this message because you are subscribed to the Google Groups "sqlalchemy" group. To unsubscribe from this group and stop receiving emails from it, send an email to sqlalchemy+unsubscr...@googlegroups.com. To post to this group, send email to sqlalchemy@googlegroups.com. Visit this group at https://groups.google.com/group/sqlalchemy. For more options, visit https://groups.google.com/d/optout.