Skip to content

Commit 5a9e53d

Browse files
ozer550bjester
andauthored
Fix silent deserialization failure with unique together constraint (#313)
* add regression tests to verify unique-constraint setup fails silently * converge per record save logic for both self-referential and non self-referential branch * Bump version --------- Co-authored-by: Blaine Jester <blainesworld@gmail.com>
1 parent 0d8fe8f commit 5a9e53d

4 files changed

Lines changed: 189 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
List of the most important changes for each release.
44

5+
## 0.8.10
6+
- Fixes silent failure during deserialization of records that fail unique constraints
7+
58
## 0.8.9
69
- Removes debugging print statements
710
- Adds flake8 linting

morango/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.8.9"
1+
__version__ = "0.8.10"

morango/sync/operations.py

Lines changed: 66 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from django.db.models import CharField
1414
from django.db.models import Q
1515
from django.db.models import signals
16+
from django.db.utils import IntegrityError
1617
from django.db.utils import OperationalError
1718
from django.utils import timezone
1819
from rest_framework.exceptions import ValidationError
@@ -431,14 +432,56 @@ def _validate_store_foreign_keys(from_model_name, fk_references):
431432
return exclude_pks, deleted_pks
432433

433434

435+
def _save_deserialized_record(store_model, app_model, model_name, excluded_list=None):
436+
"""
437+
Attempt to save one deserialized app model into the app table.
438+
439+
On success: clears dirty_bit and deserialization_error on the Store record.
440+
On failure: records the error on the Store record, keeps dirty_bit True,
441+
and logs a warning.
442+
443+
:returns: True if save succeeded, False otherwise
444+
"""
445+
446+
try:
447+
with transaction.atomic():
448+
with mute_signals(signals.pre_save, signals.post_save):
449+
app_model.save(update_dirty_bit_to=False)
450+
store_model.dirty_bit = False
451+
store_model.deserialization_error = ""
452+
store_model.save(
453+
update_fields=["dirty_bit", "deserialization_error"]
454+
)
455+
return True
456+
except (
457+
exceptions.ValidationError,
458+
exceptions.ObjectDoesNotExist,
459+
ValueError,
460+
IntegrityError,
461+
) as e:
462+
if excluded_list is not None:
463+
excluded_list.append(store_model.id)
464+
store_model.deserialization_error = str(e)
465+
store_model.save(update_fields=["deserialization_error"])
466+
logger.warning(
467+
"Failed to deserialize Store record %s for %s: %s",
468+
store_model.id,
469+
model_name,
470+
e,
471+
)
472+
return False
473+
474+
434475
def _deserialize_from_store(profile, skip_erroring=False, filter=None):
435476
"""
436477
Takes data from the store and integrates into the application.
437478
438479
ALGORITHM: On a per syncable model basis, we iterate through each class model and we go through 2 possible cases:
439480
440481
1. For class models that have a self referential foreign key, we iterate down the dependency tree deserializing model by model.
441-
2. On a per app model basis, we append the field values to a single list, and do a single bulk insert/replace query.
482+
2. For other models, we deserialize and validate each record, then save individually so that DB-level errors
483+
(e.g. unique constraint violations) are caught and recorded per-record rather than silently lost or crashing
484+
the entire deserialization.
442485
443486
If a model fails to deserialize/validate, we exclude it from being marked as clean in the store.
444487
"""
@@ -487,24 +530,25 @@ def _deserialize_from_store(profile, skip_erroring=False, filter=None):
487530
app_model, _ = store_model._deserialize_store_model(
488531
fk_cache, sync_filter=filter
489532
)
490-
if app_model:
491-
with mute_signals(signals.pre_save, signals.post_save):
492-
app_model.save(update_dirty_bit_to=False)
493-
# we update a store model after we have deserialized it to be able to mark it as a clean parent
494-
store_model.dirty_bit = False
495-
store_model.deserialization_error = ""
496-
store_model.save(
497-
update_fields=["dirty_bit", "deserialization_error"]
498-
)
499533
except (
500534
exceptions.ValidationError,
501535
exceptions.ObjectDoesNotExist,
502536
ValueError,
503537
) as e:
504538
excluded_list.append(store_model.id)
505-
# if the app model did not validate, we leave the store dirty bit set, but mark the error
506539
store_model.deserialization_error = str(e)
507540
store_model.save(update_fields=["deserialization_error"])
541+
continue
542+
if app_model:
543+
_save_deserialized_record(
544+
store_model, app_model, model.__name__, excluded_list
545+
)
546+
else:
547+
store_model.dirty_bit = False
548+
store_model.deserialization_error = ""
549+
store_model.save(
550+
update_fields=["dirty_bit", "deserialization_error"]
551+
)
508552

509553
# update lists with new clean parents and dirty children
510554
clean_parents = store_models.filter(dirty_bit=False).char_ids_list()
@@ -529,9 +573,8 @@ def _deserialize_from_store(profile, skip_erroring=False, filter=None):
529573
)
530574

531575
else:
532-
# collect all initially valid app models
576+
# collect all initially valid app models and validate their FKs
533577
app_models = []
534-
fields = model._meta.fields
535578
for store_model in store_models.filter(dirty_bit=True):
536579
try:
537580
(
@@ -541,7 +584,7 @@ def _deserialize_from_store(profile, skip_erroring=False, filter=None):
541584
fk_cache, defer_fks=True, sync_filter=filter,
542585
)
543586
if app_model:
544-
app_models.append(app_model)
587+
app_models.append((store_model, app_model))
545588
for fk_model, fk_refs in model_deferred_fks.items():
546589
# validate that the FK references aren't to anything already in the
547590
# excluded list, which should only contain models which failed to
@@ -571,40 +614,17 @@ def _deserialize_from_store(profile, skip_erroring=False, filter=None):
571614
excluded_list.extend(model_excluded_pks)
572615
deleted_list.extend(model_deleted_pks)
573616

574-
# array for holding db values from the fields of each model for this class
575-
db_values = []
576-
for app_model in app_models:
617+
# save each app model individually so we can catch per-record
618+
# DB-level errors (e.g. unique constraint violations)
619+
for store_model, app_model in app_models:
577620
if (
578-
app_model.pk not in excluded_list
579-
and app_model.pk not in deleted_list
621+
app_model.pk in excluded_list
622+
or app_model.pk in deleted_list
580623
):
581-
# handle any errors that might come from `get_db_prep_value`
582-
try:
583-
new_db_values = []
584-
for f in fields:
585-
value = getattr(app_model, f.attname)
586-
db_value = f.get_db_prep_value(value, connection)
587-
new_db_values.append(db_value)
588-
db_values += new_db_values
589-
except ValueError as e:
590-
excluded_list.append(app_model.pk)
591-
store_model = store_models.get(pk=app_model.pk)
592-
store_model.deserialization_error = str(e)
593-
store_model.save(update_fields=["deserialization_error"])
594-
595-
if db_values:
596-
with connection.cursor() as cursor:
597-
DBBackend._bulk_full_record_upsert(
598-
cursor,
599-
model._meta.db_table,
600-
fields,
601-
db_values,
602-
)
603-
604-
# clear dirty bit for all store records for this model/profile except for rows that did not validate
605-
store_models.exclude(id__in=excluded_list).filter(
606-
dirty_bit=True
607-
).update(dirty_bit=False)
624+
continue
625+
_save_deserialized_record(
626+
store_model, app_model, model.__name__
627+
)
608628

609629

610630
def _queue_into_buffer_v1(transfersession):

tests/testapp/tests/sync/test_controller.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,125 @@ def test_filtered_deserialization(self):
535535
self.assertFalse(MyUser.objects.filter(username="changed2").exists())
536536

537537

538+
class UniqueConstraintDeserializationTestCase(TestCase):
539+
"""
540+
Regression tests for silent deserialization failure when two Store records
541+
have different Morango IDs but violate a unique constraint on the app model table.
542+
543+
On SQLite, ``REPLACE INTO`` silently deletes the conflicting row and inserts the
544+
new one, so no IntegrityError is raised. On PostgreSQL, the ``INSERT`` without an
545+
``ON CONFLICT`` clause for non-PK constraints would raise IntegrityError.
546+
547+
In both cases, the expected behavior is:
548+
- The conflicting Store record should have ``deserialization_error`` populated
549+
- Its ``dirty_bit`` should remain True
550+
- A warning should be logged
551+
"""
552+
553+
def setUp(self):
554+
InstanceIDModel.get_or_create_current_instance()
555+
self.mc = MorangoProfileController("facilitydata")
556+
557+
def _create_conflicting_user_store_records(self):
558+
"""Create two Store records for MyUser with different IDs but the same unique username."""
559+
self.user1_id = uuid.uuid4().hex
560+
self.user2_id = uuid.uuid4().hex
561+
562+
user1 = MyUser(id=self.user1_id, username="duplicate", password="password")
563+
user2 = MyUser(id=self.user2_id, username="duplicate", password="password")
564+
565+
self.store1 = StoreModelFacilityFactory(
566+
id=self.user1_id,
567+
serialized=json.dumps(user1.serialize()),
568+
model_name="user",
569+
)
570+
self.store2 = StoreModelFacilityFactory(
571+
id=self.user2_id,
572+
serialized=json.dumps(user2.serialize()),
573+
model_name="user",
574+
)
575+
576+
def test_unique_violation_sets_deserialization_error(self):
577+
"""At least one conflicting record should have deserialization_error set."""
578+
self._create_conflicting_user_store_records()
579+
580+
self.mc.deserialize_from_store()
581+
582+
# At most one app model should exist due to the unique constraint
583+
self.assertLessEqual(MyUser.objects.filter(username="duplicate").count(), 1)
584+
585+
# Both Store records should still exist
586+
self.assertEqual(
587+
Store.objects.filter(id__in=[self.user1_id, self.user2_id]).count(), 2
588+
)
589+
590+
# At least one Store record should have deserialization_error set
591+
errored_stores = Store.objects.filter(
592+
id__in=[self.user1_id, self.user2_id],
593+
).exclude(deserialization_error="")
594+
self.assertGreater(
595+
errored_stores.count(),
596+
0,
597+
"Expected at least one Store record to have deserialization_error "
598+
"set for unique constraint violation, but none did.",
599+
)
600+
601+
def test_unique_violation_leaves_dirty_bit(self):
602+
"""The conflicting record's dirty_bit should remain True."""
603+
self._create_conflicting_user_store_records()
604+
605+
self.mc.deserialize_from_store()
606+
607+
# At least one Store record should still have dirty_bit=True
608+
dirty_stores = Store.objects.filter(
609+
id__in=[self.user1_id, self.user2_id],
610+
dirty_bit=True,
611+
)
612+
self.assertGreater(
613+
dirty_stores.count(),
614+
0,
615+
"Expected at least one Store record to still have dirty_bit=True "
616+
"for a unique constraint violation, but all were cleared.",
617+
)
618+
619+
def test_unique_violation_logs_warning(self):
620+
"""A warning should be logged when deserialization fails due to a unique constraint."""
621+
self._create_conflicting_user_store_records()
622+
623+
with self.assertLogs("morango.sync.operations", level="WARNING") as cm:
624+
self.mc.deserialize_from_store()
625+
626+
unique_warnings = [
627+
msg for msg in cm.output if "unique" in msg.lower() or "integrity" in msg.lower()
628+
]
629+
self.assertGreater(
630+
len(unique_warnings),
631+
0,
632+
"Expected a warning log about unique constraint violation during "
633+
"deserialization, but none was found. Logs: {}".format(cm.output),
634+
)
635+
636+
def test_non_conflicting_records_still_deserialize(self):
637+
"""Records that don't conflict should still be deserialized even when others conflict."""
638+
self._create_conflicting_user_store_records()
639+
640+
ok_user_id = uuid.uuid4().hex
641+
ok_user = MyUser(id=ok_user_id, username="noconflict", password="password")
642+
StoreModelFacilityFactory(
643+
id=ok_user_id,
644+
serialized=json.dumps(ok_user.serialize()),
645+
model_name="user",
646+
)
647+
648+
self.mc.deserialize_from_store()
649+
650+
self.assertTrue(MyUser.objects.filter(id=ok_user_id, username="noconflict").exists())
651+
652+
ok_store = Store.objects.get(id=ok_user_id)
653+
self.assertFalse(ok_store.dirty_bit)
654+
self.assertEqual(ok_store.deserialization_error, "")
655+
656+
538657
class SelfReferentialFKDeserializationTestCase(TestCase):
539658
def setUp(self):
540659
(self.current_id, _) = InstanceIDModel.get_or_create_current_instance()

0 commit comments

Comments
 (0)