Skip to content

Commit b8496b1

Browse files
committed
Snapshots: handle oversized bulk update commands
Preflight snapshot update sizes so individually valid snapshots do not exceed MongoDB's BSON command limit when repeated in an update document. Split command-level oversized bulk failures to isolate offending updates and migrate those entities to oversized storage, including errors without per-write details. Keep oversized-state caching and latest-marker selection consistent across migration paths. Stop retrying partially processed worker cursors from the beginning, avoiding duplicate snapshots and hook side effects while withholding worker completion after failures. Add regression coverage for command-level and indexed bulk failures, cache consistency, marker ordering, and incomplete worker runs.
1 parent a52a129 commit b8496b1

5 files changed

Lines changed: 510 additions & 80 deletions

File tree

dp3/database/encodings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from dp3.common.mac_address import MACAddress
99

1010
BSON_OBJECT_TOO_LARGE = 10334
11+
BSON_MAX_SIZE = 16 * 1024 * 1024
1112

1213
BSON_IPV4_SUBTYPE = USER_DEFINED_SUBTYPE + 1
1314
BSON_IPV6_SUBTYPE = BSON_IPV4_SUBTYPE + 1

dp3/database/snapshots.py

Lines changed: 124 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Any
1010

1111
import pymongo
12-
from bson import Binary
12+
from bson import BSON, Binary
1313
from pymongo import UpdateMany, UpdateOne
1414
from pymongo.collection import Collection
1515
from pymongo.command_cursor import CommandCursor
@@ -23,7 +23,7 @@
2323
from dp3.common.mac_address import MACAddress
2424
from dp3.common.utils import bytes2int, int2bytes
2525
from 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
2727
from dp3.database.exceptions import SnapshotCollectionError
2828
from 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

dp3/snapshots/snapshooter.py

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@
5353
from dp3.task_processing.task_queue import TaskQueueReader, TaskQueueWriter
5454

5555
DB_SEND_CHUNK = 100
56-
RETRY_COUNT = 3
5756

5857

5958
class SnapShooterConfig(BaseModel):
@@ -345,7 +344,7 @@ def get_linked_entities(self, time: datetime, cached_linked_entities: list[tuple
345344
for entity in have_component:
346345
component = entity_to_component[entity]
347346
linked_entities.update(component)
348-
entity_to_component.update({entity: linked_entities for entity in linked_entities})
347+
entity_to_component.update(dict.fromkeys(linked_entities, linked_entities))
349348

350349
# Make a list of unique components
351350
visited_entities.clear()
@@ -401,27 +400,27 @@ def make_snapshots_by_hash(self, task: Snapshot):
401400
self.log.debug("Creating snapshots for worker portion by hash.")
402401
have_links = set(task.entities)
403402
entity_cnt = 0
403+
all_succeeded = True
404404
for etype in self.snapshot_entities:
405-
records_cursor = self.db.get_worker_master_records(
406-
self.worker_index, self.worker_cnt, etype, no_cursor_timeout=True
407-
)
408-
for attempt in range(RETRY_COUNT):
409-
try:
410-
entity_cnt += self.make_linkless_snapshots(
411-
etype, records_cursor, task.time, have_links
412-
)
413-
except Exception as err:
414-
self.log.exception("Uncaught exception while creating snapshots: %s", err)
415-
if attempt < RETRY_COUNT - 1:
416-
self.log.info("Retrying snapshot creation for '%s' due to errors.", etype)
417-
continue
418-
finally:
419-
records_cursor.close()
420-
break
421-
else:
422-
self.log.error(
423-
"Failed to create snapshots for '%s' after %s attempts.", etype, attempt + 1
405+
records_cursor = None
406+
try:
407+
records_cursor = self.db.get_worker_master_records(
408+
self.worker_index, self.worker_cnt, etype, no_cursor_timeout=True
424409
)
410+
entity_cnt += self.make_linkless_snapshots(
411+
etype, records_cursor, task.time, have_links
412+
)
413+
except Exception as err:
414+
all_succeeded = False
415+
self.log.exception("Uncaught exception while creating snapshots: %s", err)
416+
finally:
417+
if records_cursor is not None:
418+
records_cursor.close()
419+
420+
if not all_succeeded:
421+
self.log.error("Worker snapshot creation incomplete; not reporting completion.")
422+
return
423+
425424
self.db.update_metadata(
426425
task.time,
427426
metadata={},

0 commit comments

Comments
 (0)