99from typing import Any
1010
1111import pymongo
12- from bson import Binary
12+ from bson import BSON , Binary
1313from pymongo import UpdateMany , UpdateOne
1414from pymongo .collection import Collection
1515from pymongo .command_cursor import CommandCursor
2323from dp3 .common .mac_address import MACAddress
2424from dp3 .common .utils import bytes2int , int2bytes
2525from dp3 .database .config import MongoConfig
26- from dp3 .database .encodings import BSON_OBJECT_TOO_LARGE , get_codec_options
26+ from dp3 .database .encodings import BSON_MAX_SIZE , BSON_OBJECT_TOO_LARGE , get_codec_options
2727from dp3 .database .exceptions import SnapshotCollectionError
2828from dp3 .database .magic import search_and_replace
2929
@@ -55,6 +55,7 @@ def __init__(
5555 self ._oversized_snapshot_eids = set ()
5656 self ._snapshot_bucket_size = db_config .storage .snapshot_bucket_size
5757 self ._bucket_delta = self ._get_snapshot_bucket_delta (snapshots_config )
58+ self ._server_max_bson_size : int | None = None
5859
5960 def _get_snapshot_bucket_delta (self , config ) -> timedelta :
6061 """Returns how long it takes to fill a snapshot bucket.
@@ -100,6 +101,12 @@ def _get_snapshot_bucket_delta(self, config) -> timedelta:
100101
101102 return timedelta (seconds = bucket_delta * self ._snapshot_bucket_size )
102103
104+ def _max_bson_size (self ) -> int :
105+ if self ._server_max_bson_size is None :
106+ hello = self ._db .command ("hello" )
107+ self ._server_max_bson_size = hello .get ("maxBsonObjectSize" , BSON_MAX_SIZE )
108+ return min (BSON_MAX_SIZE , self ._server_max_bson_size )
109+
103110 def _col (self , ** kwargs ) -> Collection :
104111 """Returns entity snapshots collection.
105112
@@ -403,6 +410,8 @@ def _migrate_to_oversized(self, eid: AnyEidT, snapshot: dict):
403410 move_to_oversized .extend (doc .get ("history" , []))
404411 last_id = doc ["_id" ]
405412 move_to_oversized .insert (0 , snapshot )
413+ if last_id is None :
414+ last_id = self ._bucket_id (eid , snapshot ["_time_created" ])
406415
407416 os_col .insert_many (move_to_oversized )
408417 snapshot_col .update_one (
@@ -413,15 +422,20 @@ def _migrate_to_oversized(self, eid: AnyEidT, snapshot: dict):
413422 "last" : snapshot ,
414423 "_time_created" : snapshot ["_time_created" ],
415424 "count" : 0 ,
425+ "latest" : True ,
416426 },
417427 "$unset" : {"history" : "" },
418428 },
429+ upsert = True ,
419430 )
420431 snapshot_col .delete_many (self ._filter_from_eid (eid ) | {"oversized" : False })
421432 except Exception as e :
433+ self ._invalidate_snapshot_state ({eid })
422434 raise SnapshotCollectionError (
423435 f"Update of snapshot { eid } failed: { e } , { snapshot } "
424436 ) from e
437+ else :
438+ self ._cache_snapshot_state (set (), {eid })
425439
426440 def save_one (self , snapshot : dict , ctime : datetime ):
427441 """Saves snapshot to specified entity of current master document.
@@ -469,7 +483,6 @@ def save_one(self, snapshot: dict, ctime: datetime):
469483 # The snapshot is too large, move it to oversized snapshots
470484 self .log .info (f"Snapshot of { eid } is too large: { e } , marking as oversized." )
471485 self ._migrate_to_oversized (eid , snapshot )
472- self ._cache_snapshot_state (set (), normal )
473486 except Exception as e :
474487 raise SnapshotCollectionError (
475488 f"Insert of snapshot { eid } failed: { e } , { snapshot } "
@@ -521,6 +534,72 @@ def _invalidate_snapshot_state(self, eids: Iterable[AnyEidT]):
521534 self ._normal_snapshot_eids .difference_update (eids )
522535 self ._oversized_snapshot_eids .difference_update (eids )
523536
537+ def _migrate_snapshot_batch (
538+ self , eid_snapshots : list [dict ], oversized_inserts : list [dict ]
539+ ) -> None :
540+ """Move snapshots for one EID to oversized storage, preserving input order."""
541+ eid = eid_snapshots [0 ]["eid" ]
542+ self ._migrate_to_oversized (eid , eid_snapshots [- 1 ])
543+ oversized_inserts .extend (eid_snapshots [:- 1 ])
544+
545+ def _write_snapshot_upserts (
546+ self ,
547+ snapshot_col : Collection ,
548+ upserts : list [UpdateOne ],
549+ update_originals : list [list [dict ]],
550+ oversized_inserts : list [dict ],
551+ ) -> tuple [int , list [Any ]]:
552+ """Write normal snapshot updates and isolate command-level oversized updates."""
553+ try :
554+ result = snapshot_col .bulk_write (upserts , ordered = False )
555+ except (BulkWriteError , OperationFailure ) as error :
556+ details = error .details or {}
557+ write_errors = details .get ("writeErrors" , [])
558+
559+ if not write_errors :
560+ if error .code != BSON_OBJECT_TOO_LARGE :
561+ raise
562+ if len (upserts ) == 1 :
563+ self ._migrate_snapshot_batch (update_originals [0 ], oversized_inserts )
564+ return 1 , []
565+
566+ midpoint = len (upserts ) // 2
567+ left_count , left_ids = self ._write_snapshot_upserts (
568+ snapshot_col ,
569+ upserts [:midpoint ],
570+ update_originals [:midpoint ],
571+ oversized_inserts ,
572+ )
573+ right_count , right_ids = self ._write_snapshot_upserts (
574+ snapshot_col ,
575+ upserts [midpoint :],
576+ update_originals [midpoint :],
577+ oversized_inserts ,
578+ )
579+ return left_count + right_count , left_ids + right_ids
580+
581+ oversized_indexes = [
582+ write_error ["index" ]
583+ for write_error in write_errors
584+ if write_error ["code" ] == BSON_OBJECT_TOO_LARGE
585+ ]
586+ for index in oversized_indexes :
587+ self ._migrate_snapshot_batch (update_originals [index ], oversized_inserts )
588+
589+ if any (write_error ["code" ] != BSON_OBJECT_TOO_LARGE for write_error in write_errors ):
590+ raise
591+
592+ upserted_ids = [item ["_id" ] for item in details .get ("upserted" , [])]
593+ processed_count = (
594+ details .get ("nModified" , 0 ) + details .get ("nUpserted" , 0 ) + len (oversized_indexes )
595+ )
596+ return processed_count , upserted_ids
597+
598+ return (
599+ result .modified_count + result .upserted_count ,
600+ list (result .upserted_ids .values ()),
601+ )
602+
524603 def save_many (self , snapshots : list [dict ], ctime : datetime ):
525604 """
526605 Saves a list of snapshots of current master documents.
@@ -555,24 +634,32 @@ def save_many(self, snapshots: list[dict], ctime: datetime):
555634
556635 # A normal snapshot, shift the last snapshot to history and update last
557636 for eid in normal :
558- upserts .append (
559- UpdateOne (
560- self ._filter_from_eid (eid ) | {"count" : {"$lt" : self ._snapshot_bucket_size }},
561- {
562- "$set" : {"last" : snapshots_by_eid [eid ][- 1 ]},
563- "$push" : {"history" : {"$each" : snapshots_by_eid [eid ], "$position" : 0 }},
564- "$inc" : {"count" : len (snapshots_by_eid [eid ])},
565- "$setOnInsert" : {
566- "_id" : self ._bucket_id (eid , ctime ),
567- "_time_created" : ctime ,
568- "oversized" : False ,
569- "latest" : True ,
570- },
571- },
572- upsert = True ,
637+ eid_snapshots = snapshots_by_eid [eid ]
638+ query = self ._filter_from_eid (eid ) | {"count" : {"$lt" : self ._snapshot_bucket_size }}
639+ update = {
640+ "$set" : {"last" : eid_snapshots [- 1 ]},
641+ "$push" : {"history" : {"$each" : eid_snapshots , "$position" : 0 }},
642+ "$inc" : {"count" : len (eid_snapshots )},
643+ "$setOnInsert" : {
644+ "_id" : self ._bucket_id (eid , ctime ),
645+ "_time_created" : ctime ,
646+ "oversized" : False ,
647+ "latest" : True ,
648+ },
649+ }
650+ update_statement = {"q" : query , "u" : update , "multi" : False , "upsert" : True }
651+ update_size = len (BSON .encode (update_statement , codec_options = self ._db .codec_options ))
652+ if update_size > self ._max_bson_size ():
653+ self .log .info (
654+ "Snapshot update for %s is too large (%d bytes), marking as oversized." ,
655+ eid ,
656+ update_size ,
573657 )
574- )
575- update_originals .append (snapshots_by_eid [eid ])
658+ self ._migrate_snapshot_batch (eid_snapshots , oversized_inserts )
659+ continue
660+
661+ upserts .append (UpdateOne (query , update , upsert = True ))
662+ update_originals .append (eid_snapshots )
576663
577664 # Snapshot is already marked as oversized
578665 for eid in oversized :
@@ -589,56 +676,37 @@ def save_many(self, snapshots: list[dict], ctime: datetime):
589676 )
590677 )
591678
592- new_oversized = set ()
593-
594679 if upserts :
595680 try :
596- res = snapshot_col .bulk_write (upserts , ordered = False )
681+ processed_count , upserted_ids = self ._write_snapshot_upserts (
682+ snapshot_col , upserts , update_originals , oversized_inserts
683+ )
597684
598685 # Unset latest snapshots if new snapshots were inserted
599- if res .upserted_count > 0 :
600- unset_latest_updates = []
601- for upsert_id in res .upserted_ids .values ():
602- unset_latest_updates .append (
603- UpdateMany (
604- self ._filter_from_bid (upsert_id )
605- | {"latest" : True , "count" : self ._snapshot_bucket_size },
606- {"$unset" : {"latest" : 1 }},
607- )
686+ if upserted_ids :
687+ unset_latest_updates = [
688+ UpdateMany (
689+ self ._filter_from_bid (upsert_id )
690+ | {"latest" : True , "count" : self ._snapshot_bucket_size },
691+ {"$unset" : {"latest" : 1 }},
608692 )
693+ for upsert_id in upserted_ids
694+ ]
609695 up_res = snapshot_col .bulk_write (unset_latest_updates )
610- if up_res .modified_count != res . upserted_count :
696+ if up_res .modified_count != len ( upserted_ids ) :
611697 self .log .info (
612698 "Upserted the first snapshot for %d entities." ,
613- res . upserted_count - up_res .modified_count ,
699+ len ( upserted_ids ) - up_res .modified_count ,
614700 )
615701
616- if res . modified_count + res . upserted_count != len (upserts ):
702+ if processed_count != len (upserts ):
617703 self .log .error (
618704 "Some snapshots were not updated, %s != %s" ,
619- res . modified_count + res . upserted_count ,
705+ processed_count ,
620706 len (upserts ),
621707 )
622- except (BulkWriteError , OperationFailure ) as e :
623- self .log .info ("Update of snapshots failed, will retry with oversize." )
624- failed_indexes = [
625- err ["index" ]
626- for err in e .details ["writeErrors" ]
627- if err ["code" ] == BSON_OBJECT_TOO_LARGE
628- ]
629- failed_snapshots = (update_originals [i ] for i in failed_indexes )
630- for eid_snapshots in failed_snapshots :
631- eid = eid_snapshots [0 ]["eid" ]
632- failed_snapshots = sorted (
633- eid_snapshots , key = lambda s : s ["_time_created" ], reverse = True
634- )
635- self ._migrate_to_oversized (eid , failed_snapshots [0 ])
636- oversized_inserts .extend (failed_snapshots [1 :])
637- new_oversized .add (eid )
638-
639- if any (err ["code" ] != BSON_OBJECT_TOO_LARGE for err in e .details ["writeErrors" ]):
640- # Some other error occurred
641- raise e
708+ except (BulkWriteError , OperationFailure , SnapshotCollectionError ):
709+ raise
642710 except Exception as e :
643711 raise SnapshotCollectionError (f"Upsert of snapshots failed: { str (e )[:2048 ]} " ) from e
644712
@@ -651,9 +719,6 @@ def save_many(self, snapshots: list[dict], ctime: datetime):
651719 except Exception as e :
652720 raise SnapshotCollectionError (f"Insert of snapshots failed: { str (e )[:2048 ]} " ) from e
653721
654- # Cache the new state
655- self ._cache_snapshot_state (set (), new_oversized )
656-
657722 def delete_old (self , t_old : datetime ) -> int :
658723 """Delete old snapshots.
659724
0 commit comments