Skip to content

fix(migrations): scope leaf/root nodes to same-app edges - #2307

Open
inchang-ing wants to merge 9 commits into
tortoise:developfrom
inchang-ing:fix/migration-graph-app-scoped-leaf-root
Open

inchang-ing wants to merge 9 commits into
tortoise:developfrom
inchang-ing:fix/migration-graph-app-scoped-leaf-root

Conversation

@inchang-ing

Copy link
Copy Markdown

Description

MigrationGraph.leaf_nodes(app_label) and root_nodes(app_label) filtered the candidate nodes by app label but still required a node to have no children (or no parents) at all, including those in other apps. An app whose newest migration is depended on from another app therefore had no leaf, and an app whose first migration depends on another app had no root.

With an app label, only same-app edges decide whether that app has a root or a leaf now. The global call (leaf_nodes()/root_nodes() with no argument, used for the full plan) keeps the original any-app behaviour.

Motivation and Context

Fixes #2305.

  • migrate/plan for A.__latest__ (and bare A) planned nothing: executor.py looks up graph.leaf_nodes(target.app_label) and got an empty list.
  • B.__first__ rolled nothing back for the same reason with graph.root_nodes(target.app_label).
  • loader.py resolves __first__/__latest__ dependencies through the same helpers, so a dependency such as ("A", "__latest__") could raise the misleading Dependency on app with no migrations for an app that does have migrations.

How Has This Been Tested?

  • New unit tests in tests/migrations/test_migration_graph.py for the reported graph (a.0002 depended on by b.0001): leaf_nodes("a") == [a.0002_second], root_nodes("b") == [b.0001_initial], plus the unchanged global results.
  • The app-scoped assertions fail on develop before the change and pass after it.
  • pytest tests/migrations/ -> 311 passed, 6 skipped.

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have added the changelog accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

AI disclosure: prepared with the help of an AI coding assistant. I reviewed the change, reproduced the bug, and ran the tests locally.

@codspeed

codspeed Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 21 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 24 untouched benchmarks


Comparing inchang-ing:fix/migration-graph-app-scoped-leaf-root (a677e7e) with develop (15330b9)

Open in CodSpeed

@inchang-ing

Copy link
Copy Markdown
Author

CI is now fully green — check plus test-sqlite/test-postgres/test-mysql/test-mssql across Python 3.10–3.14, plus coverage and CodSpeed, all SUCCESS (runs triggered 2026-10-11). The fix scopes leaf/root migration nodes to same-app edges. Ready for review/merge when you have a chance. Thanks!

@inchang-ing
inchang-ing force-pushed the fix/migration-graph-app-scoped-leaf-root branch from a30b980 to 4115df0 Compare October 11, 2026 17:18
return sorted(nodes)

@staticmethod
def _has_related_node(related: set[Node], app_label: str | None) -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about:

    @staticmethod
    def _has_related_node(related: set[Node], app_label: str | None) -> bool:
        # For a single-app request only same-app edges decide whether that app has a root or
        # a leaf: an app whose newest migration is depended on from another app, or whose
        # first migration depends on another app, still has its own target.
        return any(node.key.app_label == app_label for node in related)

    def _filter_boundary_keys(
        self, attr: Literal["parents", "children"], app_label: str | None
    ) -> list[MigrationKey]:
        nodes = self.node_map.values()
        if app_label is None:
            return sorted([node.key for node in nodes if not getattr(node, attr)])
        keys = [
            node.key
            for node in nodes
            if node.key.app_label == app_label
            and not self._has_related_node(node.parents, app_label)
        ]
        return sorted(keys)

    def root_nodes(self, app_label: str | None = None) -> list[MigrationKey]:
        return self._filter_boundary_keys("parents", app_label)

    def leaf_nodes(self, app_label: str | None = None) -> list[MigrationKey]:
        return self._filter_boundary_keys("children", app_label)

@inchang-ing inchang-ing Oct 16, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — adopted. _filter_boundary_keys now takes the attr and handles the global case once up front, and _has_related_node is a plain same-app check without the app_label is None branch. One correction to the sketch: it hardcoded node.parents, so leaf_nodes() would have kept using parents; it uses getattr(node, attr) now. Rebased onto current develop while at it (the changelog was the only conflict).

MigrationGraph.leaf_nodes(app_label) and root_nodes(app_label) filtered the
candidate nodes by app label but still required a node to have no children (or
no parents) at all, across every app. An app whose newest migration was depended
on from another app therefore had no leaf, and an app whose first migration
depended on another app had no root: `migrate`/`plan` for `A.__latest__` planned
nothing and `B.__first__` rolled nothing back.

Only same-app edges decide whether a single-app request has a root or a leaf now;
the global call keeps the original any-app behaviour.

Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
@inchang-ing
inchang-ing force-pushed the fix/migration-graph-app-scoped-leaf-root branch from 1f9e626 to 736def4 Compare October 22, 2026 12:43
Comment thread CHANGELOG.rst Outdated
- Migrations now honor ``db_constraint=False`` on ``ForeignKeyField``/``ManyToManyField`` and emit the column (and through table) without a ``FOREIGN KEY`` constraint, matching the runtime schema generator. (#2226)
- Fix ``field__not=None`` filter returning NULL rows instead of non-NULL rows. (#2353)
- ``Case``/``When`` and aggregate ``_filter`` expressions now keep the joins for related fields they reference, so e.g. ``When(tournament__name="a", then=1)`` no longer fails with a missing table. (#2354)
- ``MigrationGraph.leaf_nodes()``/``root_nodes()`` with an app label no longer treat an edge from another app as making that app leafless or rootless. ``migrate``/``plan`` for ``app.__latest__``/``app.__first__`` planned nothing (or rolled nothing back) when the app's newest migration was depended on from another app, or its first migration depended on another app. (#2305)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use #2307 instead of #2305

Comment thread tortoise/migrations/graph.py Outdated
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Literal

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typing is part of the standard library and is already imported on line 5, so there is no need to put it under TYPE_CHECKING.

@inchang-ing

Copy link
Copy Markdown
Author

Both points are addressed in 3502453: the changelog entry now references #2307, and Literal is imported together with TYPE_CHECKING at module level instead of a TYPE_CHECKING-local import.

pytest tests/migrations/test_migration_graph.py (2 passed) and ruff check pass locally.

@inchang-ing
inchang-ing force-pushed the fix/migration-graph-app-scoped-leaf-root branch from 596f186 to 1e88310 Compare October 25, 2026 05:10
Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

leaf_nodes(app_label)/root_nodes(app_label) count cross-app edges, so migrating to app.__latest__ can plan nothing

2 participants