Skip to content

Commit 29ce695

Browse files
Merge pull request #1499 from datajoint/fix/1493-lazy-upstream
fix(#1493): build self.upstream lazily; stop reloading deps per populate key
2 parents 1e1e0b3 + d023c81 commit 29ce695

3 files changed

Lines changed: 42 additions & 17 deletions

File tree

src/datajoint/autopopulate.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ class AutoPopulate:
9898
_key_source = None
9999
_allow_insert = False
100100
_jobs = None
101-
_upstream = None # set per-make() by _populate_one; see `upstream` property below
101+
_upstream = None # memoized upstream trace for the current make(); built lazily on first use
102+
_upstream_key = None # current make() key; not None iff executing inside make()
102103

103104
@property
104105
def upstream(self):
@@ -132,12 +133,19 @@ def make(self, key):
132133
traces = self.upstream[ExtractTraces].to_arrays("trace")
133134
self.insert1({**key, "summary": compute(traces, date)})
134135
"""
135-
if self._upstream is None:
136+
if self._upstream_key is None:
136137
raise DataJointError(
137138
"self.upstream is only available inside make(). "
138139
"Outside make(), construct a trace explicitly: "
139140
"dj.Diagram.trace(self & key)."
140141
)
142+
if self._upstream is None:
143+
# Build the trace lazily on first access and memoize it for this
144+
# make() call, so make() calls that never read self.upstream pay
145+
# nothing. See #1493.
146+
from .diagram import Diagram
147+
148+
self._upstream = Diagram.trace(self & self._upstream_key)
141149
return self._upstream
142150

143151
class _JobsDescriptor:
@@ -665,12 +673,11 @@ def _populate1(
665673
logger.jobs(f"Making {key} -> {self.full_table_name}")
666674
self.__class__._allow_insert = True
667675

668-
# Pre-construct the upstream view for this make() call. Lazy — only
669-
# `dj.Diagram.trace(self & key)` runs here (graph copy); the
670-
# expensive SQL fetch fires when the user accesses self.upstream[T].
671-
from .diagram import Diagram
672-
673-
self._upstream = Diagram.trace(self & dict(key))
676+
# Record the key for a lazily-constructed upstream view. The trace is
677+
# built on first `self.upstream` access (see the `upstream` property),
678+
# so make() calls that never read upstream pay nothing. See #1493.
679+
self._upstream_key = dict(key)
680+
self._upstream = None
674681

675682
try:
676683
if not is_generator:
@@ -733,6 +740,7 @@ def _populate1(
733740
# access raises a clear error rather than silently using a
734741
# stale trace from the previous make() call.
735742
self._upstream = None
743+
self._upstream_key = None
736744

737745
def progress(self, *restrictions: Any, display: bool = False) -> tuple[int, int]:
738746
"""

src/datajoint/dependencies.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,13 @@ def __init__(self, connection=None) -> None:
132132
self._conn = connection
133133
self._node_alias_count = itertools.count()
134134
self._loaded = False
135+
self._loaded_schemas = set() # schema names currently represented in the graph
135136
super().__init__(self)
136137

137138
def clear(self) -> None:
138139
"""Clear the graph and reset loaded state."""
139140
self._loaded = False
141+
self._loaded_schemas = set()
140142
self._node_alias_count = itertools.count() # reset alias IDs for consistency
141143
super().clear()
142144

@@ -165,6 +167,7 @@ def load(self, force: bool = True, schema_names: set[str] | None = None) -> None
165167

166168
# Build schema list for IN clause
167169
names = schema_names if schema_names is not None else set(self._conn.schemas)
170+
self._loaded_schemas = set(names)
168171
if not names:
169172
self._loaded = True
170173
return
@@ -257,6 +260,11 @@ def load_all_downstream(self) -> None:
257260
break
258261
known_schemas |= new_schemas
259262

263+
# Skip the expensive rebuild when the graph already contains every needed
264+
# schema, so repeated calls within one operation don't reload the whole
265+
# dependency tree. See #1493.
266+
if self._loaded and known_schemas <= self._loaded_schemas:
267+
return
260268
self.load(force=True, schema_names=known_schemas)
261269

262270
def load_all_upstream(self) -> None:
@@ -286,6 +294,11 @@ def load_all_upstream(self) -> None:
286294
break
287295
known_schemas |= new_schemas
288296

297+
# Skip the expensive rebuild when the graph already contains every needed
298+
# schema, so repeated Diagram.trace() calls within one populate() don't
299+
# reload the whole dependency tree per key. See #1493.
300+
if self._loaded and known_schemas <= self._loaded_schemas:
301+
return
289302
self.load(force=True, schema_names=known_schemas)
290303

291304
def topo_sort(self) -> list[str]:

tests/integration/test_autopopulate.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -492,14 +492,18 @@ class Derived(dj.Computed):
492492

493493
def make(self, key):
494494
captured.append(self) # the actual populate instance
495-
assert self._upstream is not None # set for the duration of make()
495+
# Lazy: the trace is NOT built here because this make() never reads
496+
# self.upstream — but we ARE inside make() (the key is recorded).
497+
assert self._upstream is None
498+
assert self._upstream_key is not None
496499
self.insert1({**key, "val": 0})
497500

498501
Derived.populate()
499502
assert captured, "make() did not run"
500503
inst = captured[0]
501-
# The finally block must have cleared _upstream on this very instance.
502-
assert inst._upstream is None
504+
# The finally block must have cleared the make() key on this very instance —
505+
# that is what makes self.upstream raise afterward.
506+
assert inst._upstream_key is None
503507
with pytest.raises(DataJointError, match="only available inside make"):
504508
inst.upstream
505509

@@ -529,15 +533,15 @@ class Boom(dj.Computed):
529533

530534
def make(self, key):
531535
captured.append(self)
532-
assert self._upstream is not None
536+
assert self._upstream_key is not None # inside make()
533537
raise RuntimeError("make failed on purpose")
534538

535539
with pytest.raises(RuntimeError, match="make failed on purpose"):
536540
Boom.populate(suppress_errors=False)
537541
assert captured, "make() did not run"
538542
inst = captured[0]
539543
# Cleared by the finally block even though make() raised.
540-
assert inst._upstream is None
544+
assert inst._upstream_key is None
541545
with pytest.raises(DataJointError, match="only available inside make"):
542546
inst.upstream
543547

@@ -557,7 +561,7 @@ class Source(dj.Lookup):
557561
"""
558562
contents = [(1, 100), (2, 200)]
559563

560-
seen = [] # (source_id, phase, id(self._upstream))
564+
seen = [] # (source_id, phase, id(self.upstream))
561565

562566
@schema
563567
class TriComputed(dj.Computed):
@@ -568,15 +572,15 @@ class TriComputed(dj.Computed):
568572
"""
569573

570574
def make_fetch(self, key):
571-
seen.append((key["source_id"], "fetch", id(self._upstream)))
575+
seen.append((key["source_id"], "fetch", id(self.upstream)))
572576
return (self.upstream[Source].fetch1("value"),)
573577

574578
def make_compute(self, key, value):
575-
seen.append((key["source_id"], "compute", id(self._upstream)))
579+
seen.append((key["source_id"], "compute", id(self.upstream)))
576580
return (value * 2,)
577581

578582
def make_insert(self, key, doubled):
579-
seen.append((key["source_id"], "insert", id(self._upstream)))
583+
seen.append((key["source_id"], "insert", id(self.upstream)))
580584
self.insert1({**key, "result": doubled})
581585

582586
TriComputed.populate()

0 commit comments

Comments
 (0)