1313from django .db .models import CharField
1414from django .db .models import Q
1515from django .db .models import signals
16+ from django .db .utils import IntegrityError
1617from django .db .utils import OperationalError
1718from django .utils import timezone
1819from 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+
434475def _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
610630def _queue_into_buffer_v1 (transfersession ):
0 commit comments