Skip to content

Commit a52a129

Browse files
committed
Links: upsert iterable observation targets
MongoDB cannot expand an upserting $in filter into one cache document per target. Iterable observation links could therefore be missing from the cache and escape deletion cleanup. Use one exact-key UpdateOne upsert per distinct target in an unordered bulk write, and add real-MongoDB coverage for empty and partially populated caches.
1 parent eab2667 commit a52a129

2 files changed

Lines changed: 81 additions & 9 deletions

File tree

dp3/core/link_manager.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,24 @@ def add_observation_to_link_cache(
113113
def add_iterable_observation_to_link_cache(
114114
self, etype_to: str, post_validity: timedelta, eid: AnyEidT, dp: DataPointObservationsBase
115115
):
116-
linked_eids = [v.eid for v in dp.v]
117-
self.cache.update_many(
118-
{
119-
"to": {"$in": [f"{etype_to}#{eid_}" for eid_ in linked_eids]},
120-
"from": f"{dp.etype}#{eid}",
121-
"using_attr": f"{dp.etype}#{dp.attr}",
122-
},
123-
{"$max": {"ttl": dp.t2 + post_validity}},
124-
upsert=True,
116+
targets = {f"{etype_to}#{value.eid}" for value in dp.v}
117+
if not targets:
118+
return
119+
120+
link_key = {
121+
"from": f"{dp.etype}#{eid}",
122+
"using_attr": f"{dp.etype}#{dp.attr}",
123+
}
124+
self.cache.bulk_write(
125+
[
126+
UpdateOne(
127+
link_key | {"to": target},
128+
{"$max": {"ttl": dp.t2 + post_validity}},
129+
upsert=True,
130+
)
131+
for target in targets
132+
],
133+
ordered=False,
125134
)
126135

127136
def remove_link_cache_of_deleted(self, etype: str, eid: AnyEidT):
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import unittest
2+
from datetime import UTC, datetime, timedelta
3+
from types import SimpleNamespace
4+
5+
import common
6+
7+
from dp3.core.link_manager import LinkManager
8+
from dp3.database.config import MongoConfig
9+
from dp3.database.database import EntityDatabase
10+
from dp3.database.encodings import get_codec_options
11+
12+
13+
class IterableObservationLinks(unittest.TestCase):
14+
@classmethod
15+
def setUpClass(cls):
16+
db_config = MongoConfig.model_validate(common.CONFIG.get("database", {}))
17+
cls.client = EntityDatabase.connect(db_config)
18+
cls.client.admin.command("ping")
19+
cls.db = cls.client.get_database(db_config.db_name, codec_options=get_codec_options())
20+
cls.cache = cls.db["#cache#IterableObservationLinkRegression"]
21+
22+
cls.link_manager = LinkManager.__new__(LinkManager)
23+
cls.link_manager.cache = cls.cache
24+
25+
@classmethod
26+
def tearDownClass(cls):
27+
cls.cache.drop()
28+
cls.client.close()
29+
30+
def setUp(self):
31+
self.cache.delete_many({})
32+
33+
@staticmethod
34+
def _datapoint(*targets):
35+
return SimpleNamespace(
36+
etype="source",
37+
attr="observed_targets",
38+
v=[SimpleNamespace(eid=target) for target in targets],
39+
t2=datetime(2025, 1, 1, tzinfo=UTC),
40+
)
41+
42+
def _add_links(self, *targets):
43+
self.link_manager.add_iterable_observation_to_link_cache(
44+
"target", timedelta(hours=1), "source-1", self._datapoint(*targets)
45+
)
46+
47+
def _cached_links(self):
48+
return list(self.cache.find({}, {"_id": 0, "to": 1}))
49+
50+
def test_empty_cache_creates_one_link_per_target(self):
51+
self._add_links("a", "a", "b")
52+
53+
self.assertCountEqual(self._cached_links(), [{"to": "target#a"}, {"to": "target#b"}])
54+
55+
def test_existing_links_do_not_prevent_new_target_upserts(self):
56+
self._add_links("a")
57+
self._add_links("a", "b")
58+
59+
self.assertCountEqual(self._cached_links(), [{"to": "target#a"}, {"to": "target#b"}])
60+
61+
62+
if __name__ == "__main__":
63+
unittest.main()

0 commit comments

Comments
 (0)