@@ -405,6 +405,10 @@ def _re_encrypt_single_blob(self, blob: Blob, target_key_id: int) -> str:
405405 """
406406 Re-encrypt a single blob with the target key.
407407
408+ Uses select_for_update to prevent concurrent modifications.
409+ For object storage blobs, keeps old content in memory so S3 can
410+ be restored if the DB update fails.
411+
408412 Args:
409413 blob: The blob to re-encrypt
410414 target_key_id: The encryption key ID to use for re-encryption
@@ -426,24 +430,25 @@ def _re_encrypt_single_blob(self, blob: Blob, target_key_id: int) -> str:
426430 )
427431 return "success"
428432
429- # Get the current encrypted/unencrypted content
430433 if blob .storage_location == BlobStorageLocationChoices .POSTGRES :
431- if blob .raw_content is None :
432- self .stdout .write (
433- self .style .WARNING (
434- f" SKIP blob { blob .id } : no content in PostgreSQL"
435- )
436- )
437- return "skipped"
434+ with transaction .atomic ():
435+ blob = Blob .objects .select_for_update ().get (id = blob .id )
436+ if blob .encryption_key_id == target_key_id :
437+ return "skipped"
438438
439- # Decrypt with old key (or passthrough if key_id=0)
440- decrypted = self .service .decrypt (bytes (blob .raw_content ), old_key_id )
439+ old_key_id = blob .encryption_key_id
441440
442- # Re-encrypt with new key
443- encrypted , new_key_id = self .service .encrypt (decrypted )
441+ if blob .raw_content is None :
442+ self .stdout .write (
443+ self .style .WARNING (
444+ f" SKIP blob { blob .id } : no content in PostgreSQL"
445+ )
446+ )
447+ return "skipped"
448+
449+ decrypted = self .service .decrypt (bytes (blob .raw_content ), old_key_id )
450+ encrypted , new_key_id = self .service .encrypt (decrypted )
444451
445- # Update blob
446- with transaction .atomic ():
447452 blob .raw_content = encrypted
448453 blob .encryption_key_id = new_key_id
449454 blob .save (update_fields = ["raw_content" , "encryption_key_id" ])
@@ -463,31 +468,57 @@ def _re_encrypt_single_blob(self, blob: Blob, target_key_id: int) -> str:
463468 )
464469 return "skipped"
465470
466- # Download and decrypt
467471 storage_key = self .service .compute_storage_key (bytes (blob .sha256 ))
472+
473+ # Keep old content in memory so we can restore S3 if the
474+ # transaction fails after the S3 overwrite.
475+ old_encrypted = None
476+ s3_updated = False
477+
468478 try :
469- with self .service .storage .open (storage_key , "rb" ) as f :
470- encrypted_content = f .read ()
471- except FileNotFoundError :
472- self .stderr .write (
473- self .style .ERROR (
474- f" ERROR blob { blob .id } : not found in storage at { storage_key } "
475- )
476- )
477- return "error"
479+ with transaction .atomic ():
480+ blob = Blob .objects .select_for_update ().get (id = blob .id )
481+ if blob .encryption_key_id == target_key_id :
482+ return "skipped"
478483
479- decrypted = self . service . decrypt ( encrypted_content , old_key_id )
484+ old_key_id = blob . encryption_key_id
480485
481- # Re-encrypt with new key
482- encrypted , new_key_id = self .service .encrypt (decrypted )
486+ try :
487+ with self .service .storage .open (storage_key , "rb" ) as f :
488+ old_encrypted = f .read ()
489+ except FileNotFoundError :
490+ self .stderr .write (
491+ self .style .ERROR (
492+ f" ERROR blob { blob .id } : not found in storage at { storage_key } "
493+ )
494+ )
495+ return "error"
483496
484- # Upload new encrypted content (overwrites existing )
485- self .service .storage . save ( storage_key , ContentFile ( encrypted ) )
497+ decrypted = self . service . decrypt ( old_encrypted , old_key_id )
498+ encrypted , new_key_id = self .service .encrypt ( decrypted )
486499
487- # Update blob metadata
488- with transaction .atomic ():
489- blob .encryption_key_id = new_key_id
490- blob .save (update_fields = ["encryption_key_id" ])
500+ self .service .storage .save (storage_key , ContentFile (encrypted ))
501+ s3_updated = True
502+
503+ blob .encryption_key_id = new_key_id
504+ blob .save (update_fields = ["encryption_key_id" ])
505+ except Exception :
506+ # If S3 was overwritten but the transaction failed (DB error
507+ # or commit failure), restore the old S3 content to prevent
508+ # leaving the blob in a corrupted state.
509+ if s3_updated and old_encrypted is not None :
510+ try :
511+ self .service .storage .save (
512+ storage_key , ContentFile (old_encrypted )
513+ )
514+ except Exception as restore_err : # pylint: disable=broad-except
515+ self .stderr .write (
516+ self .style .ERROR (
517+ f" CRITICAL: failed to restore S3 content for "
518+ f"blob { blob .id } : { restore_err } "
519+ )
520+ )
521+ raise
491522
492523 self .stdout .write (
493524 f" Re-encrypted blob { blob .id } (OBJECT_STORAGE): "
0 commit comments