Skip to content

Commit c314b6b

Browse files
Use passive_delete to handle LabelItem cascade (#1077)
1 parent 445b402 commit c314b6b

File tree

2 files changed

+81
-6
lines changed

2 files changed

+81
-6
lines changed

inbox/mailsync/backends/gmail.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
2121
"""
2222

23+
import itertools
2324
from collections import OrderedDict
2425
from datetime import datetime, timedelta
2526
from threading import Semaphore
@@ -173,7 +174,7 @@ def initial_sync_impl(self, crispin_client: "CrispinClient") -> None:
173174
# schedule change_poller to die
174175
change_poller.kill()
175176

176-
def resync_uids_impl(self) -> None:
177+
def resync_uids_impl(self, chunk_size: int = 1000) -> None:
177178
with session_scope(self.namespace_id) as db_session:
178179
imap_folder_info_entry = (
179180
db_session.query(ImapFolderInfo)
@@ -188,6 +189,7 @@ def resync_uids_impl(self) -> None:
188189
self.folder_name, lambda *args: True
189190
)
190191
uidvalidity = crispin_client.selected_uidvalidity
192+
remote_uidnext = crispin_client.selected_uidnext
191193
if uidvalidity <= imap_folder_info_entry.uidvalidity:
192194
# if the remote UIDVALIDITY is less than or equal to -
193195
# from my (siro) understanding it should not be less than -
@@ -221,8 +223,7 @@ def resync_uids_impl(self) -> None:
221223
"FORCE INDEX (ix_imapuid_account_id_folder_id_msg_uid_desc)",
222224
)
223225
)
224-
225-
chunk_size = 1000
226+
imap_uids_to_delete = []
226227
for entry in imap_uid_entries.yield_per(chunk_size):
227228
if entry.message.g_msgid in mapping:
228229
log.debug(
@@ -234,14 +235,22 @@ def resync_uids_impl(self) -> None:
234235
)
235236
entry.msg_uid = mapping[entry.message.g_msgid]
236237
else:
237-
db_session.delete(entry)
238+
imap_uids_to_delete.append(entry.msg_uid)
238239
log.debug(
239240
"UIDVALIDITY from {} to {}".format( # noqa: G001
240241
imap_folder_info_entry.uidvalidity, uidvalidity
241242
)
242243
)
244+
for uid_batch in itertools.batched(
245+
imap_uids_to_delete, chunk_size
246+
):
247+
for uid_to_delete in db_session.query(ImapUid).filter(
248+
ImapUid.msg_uid.in_(uid_batch)
249+
):
250+
db_session.delete(uid_to_delete)
243251
imap_folder_info_entry.uidvalidity = uidvalidity
244252
imap_folder_info_entry.highestmodseq = None
253+
imap_folder_info_entry.uidnext = remote_uidnext
245254
db_session.commit()
246255

247256
def __deduplicate_message_object_creation( # type: ignore[no-untyped-def]
@@ -470,15 +479,15 @@ def g_msgids(namespace_id, session, in_): # type: ignore[no-untyped-def] # noq
470479
.filter(Message.namespace_id == namespace_id)
471480
.all()
472481
)
473-
return sorted(g_msgid for g_msgid, in query if g_msgid in in_)
482+
return sorted(g_msgid for (g_msgid,) in query if g_msgid in in_)
474483
# But in the normal case that in_ only has a few elements, it's way better
475484
# to not fetch a bunch of values from MySQL only to return a few of them.
476485
query = (
477486
session.query(Message.g_msgid)
478487
.filter(Message.namespace_id == namespace_id, Message.g_msgid.in_(in_))
479488
.all()
480489
)
481-
return {g_msgid for g_msgid, in query}
490+
return {g_msgid for (g_msgid,) in query}
482491

483492

484493
class GmailSyncMonitor(ImapSyncMonitor):

tests/imap/test_folder_sync.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,69 @@ def test_imap_message_deduplication(
428428
.count()
429429
== 1
430430
)
431+
432+
433+
def test_gmail_handle_uidinvalid(
434+
db, default_account, all_mail_folder, mock_imapclient, label
435+
) -> None:
436+
"""
437+
Tests when a GMail account's UID validity changes. In this case we attempt
438+
to resync existing messages by their google message ID. If this fails, we
439+
delete the message and trust that we'll sync it back in if it still exists.
440+
441+
When deleting these messages, during the `yield_chunk`, it was possible for
442+
a `sqlalchemy.exc.ProgrammingError: (MySQLdb.ProgrammingError)
443+
(2014, "Commands out of sync; you can't run this command now")` to be raised.
444+
445+
This error is raised when an association attempts a cascade delete from
446+
within the ORM. Adding label associations triggers the error. To resolve
447+
this, we can use `passive_deletes=True` in the `relationship` call.
448+
"""
449+
uid_dict = uids.example()
450+
mock_imapclient.add_folder_data(all_mail_folder.name, uid_dict)
451+
mock_imapclient.list_folders = lambda: [
452+
((b"\\All", b"\\HasNoChildren"), b"/", "[Gmail]/All Mail")
453+
]
454+
mock_imapclient.idle = lambda: None
455+
mock_imapclient.idle_check = raise_imap_error
456+
457+
folder_sync_engine = GmailFolderSyncEngine(
458+
default_account.id,
459+
default_account.namespace.id,
460+
all_mail_folder.name,
461+
default_account.email_address,
462+
"gmail",
463+
BoundedSemaphore(1),
464+
)
465+
folder_sync_engine.initial_sync()
466+
467+
# We need to add labels to our imap uids so that the cascade delete on
468+
# LabelItems is required. This would trigger a deletion during the yield_chunk
469+
# query if any ORM level cascades are triggered.
470+
imap_uids = (
471+
db.session.query(ImapUid)
472+
.filter(ImapUid.folder_id == all_mail_folder.id)
473+
.limit(5)
474+
.all()
475+
)
476+
477+
assert len(imap_uids) == 5
478+
for uid in imap_uids:
479+
uid.labels.add(label)
480+
481+
db.session.commit()
482+
483+
mock_imapclient.uidvalidity = 2
484+
with pytest.raises(UidInvalid):
485+
folder_sync_engine.poll_impl()
486+
487+
# chunk_size must be set small (default is 1000) so that we actually have
488+
# more than one chunk, keeping the query open.
489+
folder_sync_engine.resync_uids_impl(chunk_size=1)
490+
491+
assert (
492+
db.session.query(ImapUid)
493+
.filter(ImapUid.folder_id == all_mail_folder.id)
494+
.all()
495+
== []
496+
)

0 commit comments

Comments
 (0)