Skip to content

Commit fd0f23a

Browse files
fix(#1454): in-memory check + auto-heal of missing ~lineage rows (#1467)
Stale rows in the ~lineage table caused spurious "different lineages" errors during populate() on FK-inherited primary keys. The load-bearing failure mode was lineage missing entirely (the demo failure: None vs ...), not stale-but-non-None values. Approach: detect the failure symptom in memory at @Schema decoration time. When the heading is constructed for an already-declared table, its lineage values are loaded from ~lineage in a single SELECT. Scanning those in-memory values for PK attributes with lineage=None costs nothing extra. Healthy schemas pay zero additional DB queries on re-decoration; the refresh only fires when the symptom is detectable in memory. Changes: 1. Table._refresh_lineage(context) — parses current definition via the existing declare() machinery (in-memory parse only; no DDL execution), then calls _populate_lineage() to delete-then-insert the table's rows. Errors logged and swallowed so a stale row is preferable to a failed schema activation. 2. schemas.py:_decorate_table guards the refresh on the in-memory check: only when any PK attribute's heading lineage is None. Healthy schemas skip the refresh entirely; missing-row schemas auto-heal. 3. Improved error message in condition.assert_join_compatibility: when one side's lineage is None, surface a tailored hint pointing at schema.rebuild_lineage() instead of the generic "different lineages" message. The original message stands when both lineages are present but differ. Documented limitation: stale-but-non-None entries (e.g. DJ version skew that wrote lineage in a different string format) are NOT auto-detected. The tailored error message + dj.migrate.rebuild_lineage(schema) cover that case as an explicit repair step. Tests in tests/integration/test_semantic_matching.py::TestLineageRefreshOnDecoration: - test_redecorate_restores_missing_lineage — primary auto-heal path - test_redecorate_heals_partial_lineage — mixed state (some stale, some missing) triggers on the missing rows and fixes both - test_redecorate_skips_when_lineage_healthy — intercept ~lineage writes and verify zero DELETE/INSERT on healthy decoration - test_stale_non_none_lineage_not_auto_refreshed — documents the limitation; manual rebuild_lineage fixes it - test_missing_lineage_error_points_to_rebuild — verifies the new error Slated for DataJoint 2.3.
1 parent 097d8c4 commit fd0f23a

4 files changed

Lines changed: 218 additions & 0 deletions

File tree

src/datajoint/condition.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,20 @@ def assert_join_compatibility(
268268
lineage2 = expr2.heading[name].lineage
269269
# Semantic match requires both lineages to be non-None and equal
270270
if lineage1 is None or lineage2 is None or lineage1 != lineage2:
271+
if lineage1 is None or lineage2 is None:
272+
# Missing lineage usually means stale ~lineage rows that survived
273+
# an upgrade or a partial declare. Decoration in 2.3+ refreshes
274+
# lineage automatically, so this typically indicates a schema
275+
# that has not been re-decorated since the upgrade.
276+
raise DataJointError(
277+
f"Cannot join on attribute `{name}`: lineage missing on "
278+
f"one side ({lineage1} vs {lineage2}). This usually "
279+
f"indicates a stale `~lineage` entry from an older "
280+
f"DataJoint version or an incomplete declare. Run "
281+
f"`schema.rebuild_lineage()` to recompute lineage from "
282+
f"current FK definitions. If the lineages are genuinely "
283+
f"different, use `.proj()` to rename one of the attributes."
284+
)
271285
raise DataJointError(
272286
f"Cannot join on attribute `{name}`: "
273287
f"different lineages ({lineage1} vs {lineage2}). "

src/datajoint/schemas.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,23 @@ def _decorate_table(self, table_class: type, context: dict[str, Any], assert_dec
303303
if not is_declared and not assert_declared and create_tables:
304304
instance.declare(context)
305305
self.connection.dependencies.clear()
306+
elif is_declared and create_tables:
307+
# Table already exists — declare() didn't run, so _populate_lineage
308+
# didn't either. Scan the already-loaded heading for the symptom
309+
# of stale/missing lineage rows (#1454): any PK attribute with
310+
# lineage=None indicates the ~lineage table is missing rows for
311+
# this table. Only then trigger a refresh — no extra DB queries
312+
# on healthy schemas, automatic repair when the bug is present.
313+
#
314+
# Note: stale-but-non-None rows (DJ version skew that wrote a
315+
# different string format) are not auto-detected here; users hit
316+
# the tailored "rebuild_lineage" error message on first join.
317+
try:
318+
pk_lineages = [instance.heading[attr].lineage for attr in instance.primary_key]
319+
except Exception:
320+
pk_lineages = []
321+
if pk_lineages and any(lineage is None for lineage in pk_lineages):
322+
instance._refresh_lineage(context)
306323
is_declared = is_declared or instance.is_declared
307324

308325
# add table definition to the doc string

src/datajoint/table.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,43 @@ def _populate_lineage(self, primary_key, fk_attribute_map):
262262
if entries:
263263
insert_lineages(self.connection, self.database, entries)
264264

265+
def _refresh_lineage(self, context=None):
266+
"""
267+
Re-derive ``~lineage`` rows from the current definition and overwrite them.
268+
269+
Called by ``@schema`` decoration on every pass — including when the table
270+
is already declared — so that stale rows from earlier DataJoint versions
271+
or partial declares do not survive a redeclare. The actual deletion +
272+
re-insertion happens in ``_populate_lineage``; this method just parses
273+
the definition to obtain ``primary_key`` and ``fk_attribute_map`` without
274+
executing any DDL.
275+
276+
Errors during refresh (e.g. missing write permission on ``~lineage``) are
277+
logged and swallowed; a stale row is preferable to a failed import.
278+
"""
279+
try:
280+
(
281+
_,
282+
_,
283+
primary_key,
284+
fk_attribute_map,
285+
_,
286+
_,
287+
) = declare(
288+
self.full_table_name,
289+
self.definition,
290+
context,
291+
self.connection.adapter,
292+
config=self.connection._config,
293+
)
294+
self._populate_lineage(primary_key, fk_attribute_map)
295+
except Exception as exc: # noqa: BLE001 — defensive; see docstring
296+
logger.warning(
297+
f"Could not refresh lineage for {self.full_table_name}: {exc}. "
298+
"If you encounter `different lineages` errors, run "
299+
"`schema.rebuild_lineage()` to rebuild from current FK definitions."
300+
)
301+
265302
def alter(self, prompt=True, context=None):
266303
"""
267304
Alter the table definition from self.definition

tests/integration/test_semantic_matching.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,153 @@ def test_rebuild_lineage_populates_table(self, schema_semantic):
340340
# Check that lineages were populated for Student table
341341
lineages = get_table_lineages(schema_semantic.connection, schema_semantic.database, "student")
342342
assert "student_id" in lineages
343+
344+
345+
class TestLineageRefreshOnDecoration:
346+
"""Tests for #1454: @schema decoration auto-heals missing ~lineage entries.
347+
348+
Contract: when an already-declared table's heading reports any PK attribute
349+
with lineage=None, decoration triggers a refresh. The check is in-memory
350+
against the heading's already-loaded lineage values — no extra DB queries
351+
on healthy schemas. Stale-but-non-None entries (e.g. DJ version skew) are
352+
NOT auto-healed and require manual rebuild_lineage().
353+
"""
354+
355+
def test_redecorate_restores_missing_lineage(self, schema_semantic):
356+
"""
357+
Delete a table's ~lineage rows entirely, then re-decorate — rows are
358+
recreated. Primary auto-heal path: PK lineage=None triggers refresh.
359+
"""
360+
from datajoint.lineage import get_lineage, delete_table_lineages
361+
from datajoint.heading import Heading
362+
363+
delete_table_lineages(schema_semantic.connection, schema_semantic.database, "trial")
364+
# Force heading reload so the deleted state is reflected in memory
365+
old_heading = Trial._heading
366+
Trial._heading = Heading(table_info=old_heading.table_info)
367+
assert get_lineage(schema_semantic.connection, schema_semantic.database, "trial", "session_id") is None
368+
369+
schema_semantic(Trial)
370+
371+
refreshed = get_lineage(schema_semantic.connection, schema_semantic.database, "trial", "session_id")
372+
assert refreshed is not None and "session" in refreshed.lower()
373+
374+
def test_redecorate_heals_partial_lineage(self, schema_semantic):
375+
"""
376+
Mixed state: one row stale (non-None bogus), another missing. The in-memory
377+
check fires on the missing row and the refresh fixes both.
378+
"""
379+
from datajoint.lineage import get_lineage, delete_table_lineages, insert_lineages
380+
from datajoint.heading import Heading
381+
382+
correct_student = get_lineage(schema_semantic.connection, schema_semantic.database, "enrollment", "student_id")
383+
assert correct_student is not None
384+
385+
# Wipe both rows, then re-insert ONLY student_id with a stale value.
386+
# course_id is now missing → triggers auto-heal of all enrollment rows.
387+
delete_table_lineages(schema_semantic.connection, schema_semantic.database, "enrollment")
388+
insert_lineages(
389+
schema_semantic.connection,
390+
schema_semantic.database,
391+
[("enrollment", "student_id", "stale_schema.stale_table.stale_attr")],
392+
)
393+
old_heading = Enrollment._heading
394+
Enrollment._heading = Heading(table_info=old_heading.table_info)
395+
396+
schema_semantic(Enrollment)
397+
398+
assert get_lineage(schema_semantic.connection, schema_semantic.database, "enrollment", "student_id") == correct_student
399+
course_lineage = get_lineage(schema_semantic.connection, schema_semantic.database, "enrollment", "course_id")
400+
assert course_lineage is not None and "course" in course_lineage.lower()
401+
402+
def test_redecorate_skips_when_lineage_healthy(self, schema_semantic):
403+
"""
404+
Healthy schema: re-decoration must issue no DELETE/INSERT against ~lineage.
405+
Verifies the zero-cost path — the in-memory check skips the refresh.
406+
"""
407+
from datajoint.lineage import get_table_lineages
408+
409+
# Pre-condition: healthy lineage state
410+
assert get_table_lineages(schema_semantic.connection, schema_semantic.database, "trial")
411+
412+
# Intercept any ~lineage write
413+
connection = schema_semantic.connection
414+
original_query = connection.query
415+
write_calls = []
416+
417+
def counting_query(sql, *args, **kwargs):
418+
if "lineage" in sql.lower() and any(tok in sql.lower() for tok in ("delete", "insert")):
419+
write_calls.append(sql)
420+
return original_query(sql, *args, **kwargs)
421+
422+
connection.query = counting_query
423+
try:
424+
schema_semantic(Trial)
425+
finally:
426+
connection.query = original_query
427+
428+
assert not write_calls, (
429+
f"Healthy schema decoration must not write to ~lineage; " f"observed {len(write_calls)} write(s): {write_calls}"
430+
)
431+
432+
def test_stale_non_none_lineage_not_auto_refreshed(self, schema_semantic):
433+
"""
434+
Stale-but-non-None lineage values are NOT auto-healed. Users with this
435+
case must call dj.migrate.rebuild_lineage(schema) or schema.rebuild_lineage().
436+
Documents the limitation explicitly.
437+
"""
438+
from datajoint.lineage import (
439+
get_lineage,
440+
delete_table_lineages,
441+
insert_lineages,
442+
get_table_lineages,
443+
)
444+
from datajoint.heading import Heading
445+
446+
# Replace ALL trial rows with non-None stale values — no None state.
447+
original = get_table_lineages(schema_semantic.connection, schema_semantic.database, "trial")
448+
delete_table_lineages(schema_semantic.connection, schema_semantic.database, "trial")
449+
stale_entries = [("trial", attr, f"stale_schema.stale.{attr}") for attr in original]
450+
insert_lineages(schema_semantic.connection, schema_semantic.database, stale_entries)
451+
old_heading = Trial._heading
452+
Trial._heading = Heading(table_info=old_heading.table_info)
453+
454+
try:
455+
schema_semantic(Trial)
456+
still_stale = get_lineage(schema_semantic.connection, schema_semantic.database, "trial", "session_id")
457+
assert still_stale == "stale_schema.stale.session_id", (
458+
f"Expected stale value to persist (no auto-heal for non-None stale); " f"got {still_stale!r}"
459+
)
460+
461+
# Manual rebuild fixes it
462+
schema_semantic.rebuild_lineage()
463+
fixed = get_lineage(schema_semantic.connection, schema_semantic.database, "trial", "session_id")
464+
assert fixed is not None and fixed != "stale_schema.stale.session_id"
465+
finally:
466+
schema_semantic.rebuild_lineage()
467+
Trial._heading = Heading(table_info=old_heading.table_info)
468+
469+
def test_missing_lineage_error_points_to_rebuild(self, schema_semantic):
470+
"""
471+
When a join fails because one side has None lineage, the error must
472+
point the user at `schema.rebuild_lineage()`.
473+
"""
474+
from datajoint.lineage import delete_table_lineages
475+
from datajoint.heading import Heading
476+
477+
# Wipe enrollment.student_id lineage by deleting the row, then force the
478+
# class-level heading to reload from DB so it reflects the missing row.
479+
delete_table_lineages(schema_semantic.connection, schema_semantic.database, "enrollment")
480+
old_heading = Enrollment._heading
481+
Enrollment._heading = Heading(table_info=old_heading.table_info)
482+
try:
483+
assert Enrollment().heading["student_id"].lineage is None
484+
485+
with pytest.raises(DataJointError) as exc_info:
486+
Student() * Enrollment()
487+
assert "rebuild_lineage" in str(exc_info.value), f"Error must mention rebuild_lineage(); got: {exc_info.value}"
488+
assert "stale" in str(exc_info.value).lower() or "missing" in str(exc_info.value).lower()
489+
finally:
490+
# Restore lineage so subsequent tests see clean state
491+
schema_semantic.rebuild_lineage()
492+
Enrollment._heading = Heading(table_info=old_heading.table_info)

0 commit comments

Comments
 (0)