Repository: dagster-io/dagster
Pull request #29150: [BCOR-54] Add `FROM_ASSET_FAILURE` re-execution option
Author: login: OwenKephart
Created: 2025-04-09T18:03:02Z | Merged: 2025-04-22T16:28:40Z

## Summary & Motivation

This adds in a new `FROM_ASSET_FAILURE` re-execution option. Its functionality is quite simple, just:

- compute the set of unexecuted asset keys and asset check keys from the parent run
- create a new run request that is a subset of the original job targeting just those selected assets and checks

This causes a brand new job snapshot / execution plan to be created, allowing the asset job construction machinery to be re-invoked to satisfy the new request.

Currently, there is no do this from the UI.

## How I Tested These Changes

Added unit tests, also tested locally.

## Changelog

NOCHANGELOG



Diff:
diff --git a/python_modules/dagster/dagster/_core/execution/plan/resume_retry.py b/python_modules/dagster/dagster/_core/execution/plan/resume_retry.py
index 48afebca9a985..01cbc4cb7ee2b 100644
--- a/python_modules/dagster/dagster/_core/execution/plan/resume_retry.py
+++ b/python_modules/dagster/dagster/_core/execution/plan/resume_retry.py
@@ -4,3 +4,4 @@
 class ReexecutionStrategy(enum.Enum):
     ALL_STEPS = "ALL_STEPS"
     FROM_FAILURE = "FROM_FAILURE"
+    FROM_ASSET_FAILURE = "FROM_ASSET_FAILURE"
diff --git a/python_modules/dagster/dagster/_core/instance/__init__.py b/python_modules/dagster/dagster/_core/instance/__init__.py
index 5428ccb1fbf8d..2cbace4de8d93 100644
--- a/python_modules/dagster/dagster/_core/instance/__init__.py
+++ b/python_modules/dagster/dagster/_core/instance/__init__.py
@@ -71,6 +71,7 @@
 from dagster._core.storage.tags import (
     ASSET_PARTITION_RANGE_END_TAG,
     ASSET_PARTITION_RANGE_START_TAG,
+    ASSET_RESUME_RETRY_TAG,
     BACKFILL_ID_TAG,
     BACKFILL_TAGS,
     PARENT_RUN_ID_TAG,
@@ -339,7 +340,11 @@ def get_dynamic_partitions(self, partitions_def_name: str) -> Sequence[str]: ...
 
     @abstractmethod
     def get_paginated_dynamic_partitions(
-        self, partitions_def_name: str, limit: int, ascending: bool, cursor: Optional[str] = None
+        self,
+        partitions_def_name: str,
+        limit: int,
+        ascending: bool,
+        cursor: Optional[str] = None,
     ) -> PaginatedResults[str]: ...
 
     @abstractmethod
@@ -446,7 +451,8 @@ def __init__(
             self._compute_log_manager.register_instance(self)
         else:
             check.invariant(
-                ref, "Compute log manager must be provided if instance is not from a ref"
+                ref,
+                "Compute log manager must be provided if instance is not from a ref",
             )
             self._compute_log_manager = None
 
@@ -698,7 +704,9 @@ def _info(self, component: object) -> Union[str, Mapping[Any, Any]]:
 
     def _info_str_for_component(self, component_name: str, component: object) -> str:
         return yaml.dump(
-            {component_name: self._info(component)}, default_flow_style=False, sort_keys=False
+            {component_name: self._info(component)},
+            default_flow_style=False,
+            sort_keys=False,
         )
 
     def info_dict(self) -> Mapping[str, object]:
@@ -727,7 +735,9 @@ def info_str(self) -> str:
         return yaml.dump(self.info_dict(), default_flow_style=False, sort_keys=False)
 
     def schema_str(self) -> str:
-        def _schema_dict(alembic_version: "AlembicVersion") -> Optional[Mapping[str, object]]:
+        def _schema_dict(
+            alembic_version: "AlembicVersion",
+        ) -> Optional[Mapping[str, object]]:
             if not alembic_version:
                 return None
             db_revision, head_revision = alembic_version
@@ -821,11 +831,13 @@ def run_launcher(self) -> "RunLauncher":
     def compute_log_manager(self) -> "ComputeLogManager":
         if not self._compute_log_manager:
             check.invariant(
-                self._ref, "Compute log manager not provided, and no instance ref available"
+                self._ref,
+                "Compute log manager not provided, and no instance ref available",
             )
             compute_log_manager = cast("InstanceRef", self._ref).compute_log_manager
             check.invariant(
-                compute_log_manager, "Compute log manager not configured in instance ref"
+                compute_log_manager,
+                "Compute log manager not configured in instance ref",
             )
             self._compute_log_manager = cast("ComputeLogManager", compute_log_manager)
             self._compute_log_manager.register_instance(self)
@@ -1536,7 +1548,7 @@ def create_run(
         job_code_origin: Optional[JobPythonOrigin],
         asset_graph: Optional["BaseAssetGraph"],
     ) -> DagsterRun:
-        from dagster._core.definitions.asset_check_spec import AssetCheckKey
+        from dagster._core.definitions.asset_key import AssetCheckKey
         from dagster._core.remote_representation.origin import RemoteJobOrigin
         from dagster._core.snap import ExecutionPlanSnapshot, JobSnap
         from dagster._utils.tags import normalize_tags
@@ -1673,6 +1685,51 @@ def create_run(
 
         return dagster_run
 
+    def _get_skipped_entity_keys(
+        self, run_id: str
+    ) -> tuple[AbstractSet["AssetKey"], AbstractSet["AssetCheckKey"]]:
+        """For a given run_id, return the set of asset keys and asset check keys that were planned but
+        not executed.
+        """
+        from dagster._core.events import (
+            AssetCheckEvaluation,
+            AssetCheckEvaluationPlanned,
+            AssetMaterializationPlannedData,
+            DagsterEventType,
+            StepMaterializationData,
+        )
+
+        logs = self.all_logs(
+            run_id=run_id,
+            of_type={
+                DagsterEventType.ASSET_MATERIALIZATION_PLANNED,
+                DagsterEventType.ASSET_MATERIALIZATION,
+                DagsterEventType.ASSET_CHECK_EVALUATION_PLANNED,
+                DagsterEventType.ASSET_CHECK_EVALUATION,
+            },
+        )
+        planned_asset_keys: set[AssetKey] = set()
+        executed_asset_keys: set[AssetKey] = set()
+        planned_asset_check_keys: set[AssetCheckKey] = set()
+        executed_asset_check_keys: set[AssetCheckKey] = set()
+        for log in logs:
+            event_data = log.dagster_event.event_specific_data if log.dagster_event else None
+            if isinstance(event_data, AssetMaterializationPlannedData):
+                planned_asset_keys.add(event_data.asset_key)
+            elif isinstance(event_data, StepMaterializationData):
+                executed_asset_keys.add(event_data.materialization.asset_key)
+            elif isinstance(event_data, AssetCheckEvaluationPlanned):
+                planned_asset_check_keys.add(event_data.asset_check_key)
+            elif isinstance(event_data, AssetCheckEvaluation):
+                executed_asset_check_keys.add(event_data.asset_check_key)
+
+        return (
+            # skipped assets
+            planned_asset_keys - executed_asset_keys,
+            # skipped checks
+            planned_asset_check_keys - executed_asset_check_keys,
+        )
+
     def create_reexecuted_run(
         self,
         *,
@@ -1741,6 +1798,19 @@ def create_reexecuted_run(
                 parent_run=parent_run,
             )
             tags[RESUME_RETRY_TAG] = "true"
+        elif strategy == ReexecutionStrategy.FROM_ASSET_FAILURE:
+            skipped_asset_keys, skipped_asset_check_keys = self._get_skipped_entity_keys(
+                parent_run_id
+            )
+            remote_job = code_location.get_job(
+                remote_job.get_subset_selector(
+                    asset_selection=skipped_asset_keys,
+                    asset_check_selection=skipped_asset_check_keys,
+                )
+            )
+            step_keys_to_execute = None
+            known_state = None
+            tags[ASSET_RESUME_RETRY_TAG] = "true"
         elif strategy == ReexecutionStrategy.ALL_STEPS:
             step_keys_to_execute = None
             known_state = None
@@ -1769,8 +1839,8 @@ def create_reexecuted_run(
             execution_plan_snapshot=remote_execution_plan.execution_plan_snapshot,
             parent_job_snapshot=remote_job.parent_job_snapshot,
             op_selection=parent_run.op_selection,
-            asset_selection=parent_run.asset_selection,
-            asset_check_selection=parent_run.asset_check_selection,
+            asset_selection=remote_job.asset_selection,
+            asset_check_selection=remote_job.asset_check_selection,
             remote_job_origin=remote_job.get_remote_origin(),
             job_code_origin=remote_job.get_python_origin(),
             asset_graph=code_location.get_repository(
@@ -2181,7 +2251,9 @@ def fetch_planned_materializations(
             EventRecordsFilter(DagsterEventType.ASSET_MATERIALIZATION_PLANNED, records_filter)
             if isinstance(records_filter, AssetKey)
             else records_filter.to_event_records_filter(
-                DagsterEventType.ASSET_MATERIALIZATION_PLANNED, cursor=cursor, ascending=ascending
+                DagsterEventType.ASSET_MATERIALIZATION_PLANNED,
+                cursor=cursor,
+                ascending=ascending,
             )
         )
         records = self._event_storage.get_event_records(
@@ -2405,7 +2477,11 @@ def get_dynamic_partitions(self, partitions_def_name: str) -> Sequence[str]:
 
     @traced
     def get_paginated_dynamic_partitions(
-        self, partitions_def_name: str, limit: int, ascending: bool, cursor: Optional[str] = None
+        self,
+        partitions_def_name: str,
+        limit: int,
+        ascending: bool,
+        cursor: Optional[str] = None,
     ) -> PaginatedResults[str]:
         """Get a paginatable subset of partition keys for the specified :py:class:`DynamicPartitionsDefinition`.
 
@@ -2420,7 +2496,10 @@ def get_paginated_dynamic_partitions(
         check.bool_param(ascending, "ascending")
         check.opt_str_param(cursor, "cursor")
         return self._event_storage.get_paginated_dynamic_partitions(
-            partitions_def_name=partitions_def_name, limit=limit, ascending=ascending, cursor=cursor
+            partitions_def_name=partitions_def_name,
+            limit=limit,
+            ascending=ascending,
+            cursor=cursor,
         )
 
     @public
@@ -3090,7 +3169,10 @@ def all_instigator_state(
         if not self._schedule_storage:
             check.failed("Schedule storage not available")
         return self._schedule_storage.all_instigator_state(
-            repository_origin_id, repository_selector_id, instigator_type, instigator_statuses
+            repository_origin_id,
+            repository_selector_id,
+            instigator_type,
+            instigator_statuses,
         )
 
     @traced
@@ -3147,7 +3229,12 @@ def get_ticks(
         statuses: Optional[Sequence["TickStatus"]] = None,
     ) -> Sequence["InstigatorTick"]:
         return self._schedule_storage.get_ticks(  # type: ignore  # (possible none)
-            origin_id, selector_id, before=before, after=after, limit=limit, statuses=statuses
+            origin_id,
+            selector_id,
+            before=before,
+            after=after,
+            limit=limit,
+            statuses=statuses,
         )
 
     def create_tick(self, tick_data: "TickData") -> "InstigatorTick":
@@ -3238,7 +3325,9 @@ def get_daemon_statuses(
 
         check.opt_sequence_param(daemon_types, "daemon_types", of_type=str)
         return get_daemon_statuses(
-            self, daemon_types=daemon_types or self.get_required_daemon_types(), ignore_errors=True
+            self,
+            daemon_types=daemon_types or self.get_required_daemon_types(),
+            ignore_errors=True,
         )
 
     @property
diff --git a/python_modules/dagster/dagster/_core/remote_representation/external.py b/python_modules/dagster/dagster/_core/remote_representation/external.py
index 57e6f37459f52..27b3adb65065b 100644
--- a/python_modules/dagster/dagster/_core/remote_representation/external.py
+++ b/python_modules/dagster/dagster/_core/remote_representation/external.py
@@ -1,5 +1,5 @@
 from collections import defaultdict
-from collections.abc import Iterable, Iterator, Mapping, Sequence
+from collections.abc import Iterable, Iterator, Mapping, Sequence, Set
 from datetime import datetime
 from functools import cached_property
 from threading import RLock
@@ -21,6 +21,7 @@
 from dagster._core.definitions.schedule_definition import DefaultScheduleStatus
 from dagster._core.definitions.selector import (
     InstigatorSelector,
+    JobSubsetSelector,
     RepositorySelector,
     ScheduleSelector,
     SensorSelector,
@@ -658,6 +659,19 @@ def get_remote_origin(self) -> RemoteJobOrigin:
     def get_remote_origin_id(self) -> str:
         return self.get_remote_origin().get_id()
 
+    def get_subset_selector(
+        self, asset_selection: Set[AssetKey], asset_check_selection: Set[AssetCheckKey]
+    ) -> JobSubsetSelector:
+        """Returns a JobSubsetSelector to select a subset of this RemoteJob."""
+        return JobSubsetSelector(
+            location_name=self.handle.location_name,
+            repository_name=self.handle.repository_name,
+            job_name=self.name,
+            op_selection=self.op_selection,
+            asset_selection=asset_selection,
+            asset_check_selection=asset_check_selection,
+        )
+
 
 class RemoteExecutionPlan:
     """RemoteExecutionPlan is a object that represents an execution plan that
diff --git a/python_modules/dagster/dagster/_core/storage/tags.py b/python_modules/dagster/dagster/_core/storage/tags.py
index 60b1812bad489..e7b508ba616c4 100644
--- a/python_modules/dagster/dagster/_core/storage/tags.py
+++ b/python_modules/dagster/dagster/_core/storage/tags.py
@@ -39,6 +39,8 @@
 
 RESUME_RETRY_TAG = f"{SYSTEM_TAG_PREFIX}is_resume_retry"
 
+ASSET_RESUME_RETRY_TAG = f"{SYSTEM_TAG_PREFIX}is_asset_resume_retry"
+
 STEP_SELECTION_TAG = f"{SYSTEM_TAG_PREFIX}step_selection"
 
 OP_SELECTION_TAG = f"{SYSTEM_TAG_PREFIX}solid_selection"
diff --git a/python_modules/dagster/dagster_tests/core_tests/instance_tests/test_instance_reexecution.py b/python_modules/dagster/dagster_tests/core_tests/instance_tests/test_instance_reexecution.py
index 70314ebc21ab7..4e68453701bcf 100644
--- a/python_modules/dagster/dagster_tests/core_tests/instance_tests/test_instance_reexecution.py
+++ b/python_modules/dagster/dagster_tests/core_tests/instance_tests/test_instance_reexecution.py
@@ -1,11 +1,12 @@
 import os
 
+import dagster as dg
 import pytest
-from dagster import DagsterInstance, job, op, reconstructable, repository
+from dagster._core.events import DagsterEventType
 from dagster._core.execution.api import execute_job
 from dagster._core.execution.plan.resume_retry import ReexecutionStrategy
 from dagster._core.storage.dagster_run import DagsterRunStatus
-from dagster._core.storage.tags import RESUME_RETRY_TAG, SYSTEM_TAG_PREFIX
+from dagster._core.storage.tags import ASSET_RESUME_RETRY_TAG, RESUME_RETRY_TAG, SYSTEM_TAG_PREFIX
 from dagster._core.test_utils import (
     environ,
     instance_for_test,
@@ -19,12 +20,12 @@
 CONDITIONAL_FAIL_ENV = "DAGSTER_CONDIIONAL_FAIL"
 
 
-@op
+@dg.op
 def before_failure():
     return "hello"
 
 
-@op
+@dg.op
 def conditional_fail(_, input_value):
     if os.environ.get(CONDITIONAL_FAIL_ENV):
         raise Exception("env set, failing!")
@@ -32,19 +33,89 @@ def conditional_fail(_, input_value):
     return input_value
 
 
-@op
+@dg.op
 def after_failure(_, input_value):
     return input_value
 
 
-@job(tags={"foo": "bar"})
+@dg.job(tags={"foo": "bar"})
 def conditional_fail_job():
     after_failure(conditional_fail(before_failure()))
 
 
-@repository
-def repo():
-    return [conditional_fail_job]
+@dg.multi_asset(
+    specs=[
+        dg.AssetSpec("a", skippable=True),
+        dg.AssetSpec("c", deps="b", skippable=True),
+        dg.AssetSpec("d", deps=["a", "b"], skippable=True),
+    ],
+    can_subset=True,
+)
+def acd(context: dg.AssetExecutionContext):
+    fail_output = os.environ.get(CONDITIONAL_FAIL_ENV)
+    for selected in sorted(context.selected_output_names):
+        if selected == fail_output:
+            raise Exception("env set, failing!")
+        yield dg.Output(None, selected)
+
+
+@dg.asset(deps=["a"])
+def b() -> None: ...
+
+
+@dg.multi_asset(
+    specs=[
+        dg.AssetSpec("a_checked", skippable=True),
+        dg.AssetSpec("c_checked", deps="b_checked", skippable=True),
+        dg.AssetSpec("d_checked", deps=["a_checked", "b_checked"], skippable=True),
+    ],
+    check_specs=[
+        dg.AssetCheckSpec("good", asset="a_checked"),
+        dg.AssetCheckSpec("good", asset="c_checked"),
+        dg.AssetCheckSpec("good", asset="d_checked"),
+    ],
+    can_subset=True,
+)
+def acd_checked(context: dg.AssetExecutionContext):
+    context.log.info(f"{list(sorted(context.selected_output_names))}")
+    fail_output = os.environ.get(CONDITIONAL_FAIL_ENV)
+    for selected in sorted(context.selected_output_names):
+        if selected == fail_output:
+            raise Exception("env set, failing!")
+        if selected.endswith("good"):
+            yield dg.AssetCheckResult(asset_key=selected[:-5], check_name="good", passed=True)
+        else:
+            yield dg.Output(None, selected)
+
+
+@dg.asset(deps=["a_checked"])
+def b_checked() -> None: ...
+
+
+@dg.asset_check(name="good", asset="b_checked")
+def b_checked_good() -> dg.AssetCheckResult:
+    return dg.AssetCheckResult(passed=True)
+
+
+defs = dg.Definitions(
+    assets=[acd, b, acd_checked, b_checked, b_checked_good],
+    jobs=[
+        conditional_fail_job,
+        dg.define_asset_job(name="multi_asset_fail_job", selection=[acd, b]),
+        dg.define_asset_job(
+            name="multi_asset_with_checks_fail_job",
+            selection=[acd_checked, b_checked, b_checked_good],
+        ),
+    ],
+)
+
+
+def multi_asset_fail_job():
+    return defs.get_job_def("multi_asset_fail_job")
+
+
+def multi_asset_with_checks_fail_job():
+    return defs.get_job_def("multi_asset_with_checks_fail_job")
 
 
 @pytest.fixture(name="instance", scope="module")
@@ -74,7 +145,7 @@ def code_location_fixture(workspace):
 
 @pytest.fixture(name="remote_job", scope="module")
 def remote_job_fixture(code_location):
-    return code_location.get_repository("repo").get_full_job("conditional_fail_job")
+    return code_location.get_repository("__repository__").get_full_job("conditional_fail_job")
 
 
 @pytest.fixture(name="failed_run", scope="module")
@@ -82,7 +153,7 @@ def failed_run_fixture(instance):
     # trigger failure in the conditionally_fail op
     with environ({CONDITIONAL_FAIL_ENV: "1"}):
         result = execute_job(
-            reconstructable(conditional_fail_job),
+            dg.reconstructable(conditional_fail_job),
             instance=instance,
             tags={"fizz": "buzz", "foo": "not bar!", f"{SYSTEM_TAG_PREFIX}run_metrics": "true"},
         )
@@ -93,11 +164,7 @@ def failed_run_fixture(instance):
 
 
 def test_create_reexecuted_run_from_failure(
-    instance: DagsterInstance,
-    workspace,
-    code_location,
-    remote_job,
-    failed_run,
+    instance: dg.DagsterInstance, workspace, code_location, remote_job, failed_run
 ):
     run = instance.create_reexecuted_run(
         parent_run=failed_run,
@@ -118,7 +185,7 @@ def test_create_reexecuted_run_from_failure(
 
 
 def test_create_reexecuted_run_from_failure_tags(
-    instance: DagsterInstance,
+    instance: dg.DagsterInstance,
     code_location,
     remote_job,
     failed_run,
@@ -159,7 +226,7 @@ def test_create_reexecuted_run_from_failure_tags(
 
 
 def test_create_reexecuted_run_all_steps(
-    instance: DagsterInstance, workspace, code_location, remote_job, failed_run
+    instance: dg.DagsterInstance, workspace, code_location, remote_job, failed_run
 ):
     run = instance.create_reexecuted_run(
         parent_run=failed_run,
@@ -177,3 +244,100 @@ def test_create_reexecuted_run_all_steps(
     assert step_succeeded(instance, run, "before_failure")
     assert step_succeeded(instance, run, "conditional_fail")
     assert step_succeeded(instance, run, "after_failure")
+
+
+def _get_materialized_keys(instance: dg.DagsterInstance, run_id: str) -> set[dg.AssetKey]:
+    result = instance.get_records_for_run(
+        run_id=run_id, of_type=DagsterEventType.ASSET_MATERIALIZATION, limit=1000
+    )
+    keys = set()
+    for record in result.records:
+        assert record.asset_materialization
+        keys.add(record.asset_materialization.asset_key)
+    return keys
+
+
+def _get_checked_keys(instance: dg.DagsterInstance, run_id: str) -> set[dg.AssetCheckKey]:
+    result = instance.get_records_for_run(
+        run_id=run_id, of_type=DagsterEventType.ASSET_CHECK_EVALUATION, limit=1000
+    )
+    keys = set()
+    for record in result.records:
+        dagster_event = record.event_log_entry.dagster_event
+        assert dagster_event
+        assert isinstance(dagster_event.event_specific_data, dg.AssetCheckEvaluation)
+        keys.add(dagster_event.event_specific_data.asset_check_key)
+    return keys
+
+
+def test_create_reexecuted_run_from_multi_asset_failure(
+    instance: dg.DagsterInstance, workspace, code_location
+):
+    remote_job = code_location.get_repository("__repository__").get_full_job("multi_asset_fail_job")
+    with environ({CONDITIONAL_FAIL_ENV: "c"}):
+        result = execute_job(dg.reconstructable(multi_asset_fail_job), instance=instance)
+        assert not result.success
+        failed_run = instance.get_run_by_id(result.run_id)
+        assert failed_run
+
+    assert _get_materialized_keys(instance, failed_run.run_id) == {
+        dg.AssetKey("a"),
+        dg.AssetKey("b"),
+    }
+    run = instance.create_reexecuted_run(
+        parent_run=failed_run,
+        code_location=code_location,
+        remote_job=remote_job,
+        strategy=ReexecutionStrategy.FROM_ASSET_FAILURE,
+    )
+
+    assert run.tags[ASSET_RESUME_RETRY_TAG] == "true"
+    instance.launch_run(run.run_id, workspace)
+    run = poll_for_finished_run(instance, run.run_id)
+
+    assert run.status == DagsterRunStatus.SUCCESS
+    assert _get_materialized_keys(instance, run.run_id) == {dg.AssetKey("c"), dg.AssetKey("d")}
+
+
+def test_create_reexecuted_run_from_multi_asset_check_failure(
+    instance: dg.DagsterInstance, workspace, code_location
+):
+    remote_job = code_location.get_repository("__repository__").get_full_job(
+        "multi_asset_with_checks_fail_job"
+    )
+    with environ({CONDITIONAL_FAIL_ENV: "c_checked"}):
+        result = execute_job(
+            dg.reconstructable(multi_asset_with_checks_fail_job), instance=instance
+        )
+        assert not result.success
+        failed_run = instance.get_run_by_id(result.run_id)
+        assert failed_run
+
+    assert _get_materialized_keys(instance, failed_run.run_id) == {
+        dg.AssetKey("a_checked"),
+    }
+    assert _get_checked_keys(instance, failed_run.run_id) == {
+        dg.AssetCheckKey(dg.AssetKey("a_checked"), "good"),
+    }
+    run = instance.create_reexecuted_run(
+        parent_run=failed_run,
+        code_location=code_location,
+        remote_job=remote_job,
+        strategy=ReexecutionStrategy.FROM_ASSET_FAILURE,
+    )
+
+    assert run.tags[ASSET_RESUME_RETRY_TAG] == "true"
+    instance.launch_run(run.run_id, workspace)
+    run = poll_for_finished_run(instance, run.run_id)
+
+    assert run.status == DagsterRunStatus.SUCCESS
+    assert _get_materialized_keys(instance, run.run_id) == {
+        dg.AssetKey("b_checked"),
+        dg.AssetKey("c_checked"),
+        dg.AssetKey("d_checked"),
+    }
+    assert _get_checked_keys(instance, run.run_id) == {
+        dg.AssetCheckKey(dg.AssetKey("b_checked"), "good"),
+        dg.AssetCheckKey(dg.AssetKey("c_checked"), "good"),
+        dg.AssetCheckKey(dg.AssetKey("d_checked"), "good"),
+    }


Reviews:
user: login: dpeng817
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T18:12:06Z
user: login: dpeng817
body: Seems good - one comment regarding handling awkward "snapshot is different than current code location". We should follow up with UI implications, and then add changelog
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T18:15:05Z
user: login: alangenfeld
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T21:52:00Z
user: login: OwenKephart
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T23:20:57Z
user: login: OwenKephart
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T23:23:14Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T16:27:00Z
user: login: gibsondan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T17:31:36Z

Review comments:
diff_hunk: @@ -1734,6 +1779,19 @@ def create_reexecuted_run(
                 parent_run=parent_run,
             )
             tags[RESUME_RETRY_TAG] = "true"
+        elif strategy == ReexecutionStrategy.FROM_ASSET_FAILURE:
+            skipped_asset_keys, skipped_asset_check_keys = self._get_skipped_entity_keys(
+                parent_run_id
+            )
+            remote_job = code_location.get_job(
path: python_modules/dagster/dagster/_core/instance/__init__.py
user: login: dpeng817
body: doesn't seem like we do a good job of handling this in general; but should we be gracefully failing if this thing doesn't exist anymore? It might not right, if the job has been removed from code location, or if the set of asset keys has changed?
created_at: 2025-04-18T18:12:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1734,6 +1779,19 @@ def create_reexecuted_run(
                 parent_run=parent_run,
             )
             tags[RESUME_RETRY_TAG] = "true"
+        elif strategy == ReexecutionStrategy.FROM_ASSET_FAILURE:
+            skipped_asset_keys, skipped_asset_check_keys = self._get_skipped_entity_keys(
+                parent_run_id
+            )
+            remote_job = code_location.get_job(
+                remote_job.get_subset_selector(
+                    asset_selection=skipped_asset_keys,
+                    asset_check_selection=skipped_asset_check_keys,
+                )
+            )
+            step_keys_to_execute = None
+            known_state = None
+            tags[RESUME_RETRY_TAG] = "true"
path: python_modules/dagster/dagster/_core/instance/__init__.py
user: login: alangenfeld
body: not sure if we want to set the exact same tag in this slightly different behavior path 
created_at: 2025-04-21T21:51:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1734,6 +1779,19 @@ def create_reexecuted_run(
                 parent_run=parent_run,
             )
             tags[RESUME_RETRY_TAG] = "true"
+        elif strategy == ReexecutionStrategy.FROM_ASSET_FAILURE:
+            skipped_asset_keys, skipped_asset_check_keys = self._get_skipped_entity_keys(
+                parent_run_id
+            )
+            remote_job = code_location.get_job(
path: python_modules/dagster/dagster/_core/instance/__init__.py
user: login: OwenKephart
body: my thinking is that the other retry from failure codepath would also fail if the underlying job changed -- a hard error doesn't seem too bad? although I'll admit to not really knowing how that manifests in the UI
created_at: 2025-04-21T23:20:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1734,6 +1779,19 @@ def create_reexecuted_run(
                 parent_run=parent_run,
             )
             tags[RESUME_RETRY_TAG] = "true"
+        elif strategy == ReexecutionStrategy.FROM_ASSET_FAILURE:
+            skipped_asset_keys, skipped_asset_check_keys = self._get_skipped_entity_keys(
+                parent_run_id
+            )
+            remote_job = code_location.get_job(
+                remote_job.get_subset_selector(
+                    asset_selection=skipped_asset_keys,
+                    asset_check_selection=skipped_asset_check_keys,
+                )
+            )
+            step_keys_to_execute = None
+            known_state = None
+            tags[RESUME_RETRY_TAG] = "true"
path: python_modules/dagster/dagster/_core/instance/__init__.py
user: login: OwenKephart
body: yeah looking at this more closely it seems wise to distinguish between these cases -- we use this in the UI to determine if we should default to re-executing from step failure which seems like not what we want here
created_at: 2025-04-21T23:23:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1536,7 +1548,7 @@ def create_run(
         job_code_origin: Optional[JobPythonOrigin],
         asset_graph: Optional["BaseAssetGraph"],
     ) -> DagsterRun:
-        from dagster._core.definitions.asset_check_spec import AssetCheckKey
+        from dagster._core.definitions.asset_key import AssetCheckKey
path: python_modules/dagster/dagster/_core/instance/__init__.py
user: login: graphite-app[bot]
body: The import statement is incorrect. `AssetCheckKey` should be imported from `dagster._core.definitions.asset_check_spec` instead of `dagster._core.definitions.asset_key`. This is consistent with other imports in the codebase, such as the one on line 8 of `asset_job.py`.
```suggestion
        from dagster._core.definitions.asset_check_spec import AssetCheckKey
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-22T16:26:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1673,6 +1685,51 @@ def create_run(
 
         return dagster_run
 
+    def _get_skipped_entity_keys(
+        self, run_id: str
+    ) -> tuple[AbstractSet["AssetKey"], AbstractSet["AssetCheckKey"]]:
+        """For a given run_id, return the set of asset keys and asset check keys that were planned but
+        not executed.
+        """
+        from dagster._core.events import (
+            AssetCheckEvaluation,
+            AssetCheckEvaluationPlanned,
+            AssetMaterializationPlannedData,
+            DagsterEventType,
+            StepMaterializationData,
+        )
+
+        logs = self.all_logs(
+            run_id=run_id,
+            of_type={
+                DagsterEventType.ASSET_MATERIALIZATION_PLANNED,
+                DagsterEventType.ASSET_MATERIALIZATION,
+                DagsterEventType.ASSET_CHECK_EVALUATION_PLANNED,
+                DagsterEventType.ASSET_CHECK_EVALUATION,
+            },
+        )
path: python_modules/dagster/dagster/_core/instance/__init__.py
user: login: gibsondan
body: I think there are ways of fetching this that are more efficient than doing an event log scan

The planned assets and planned asset check keys can be loaded from the execution plan snapshot

The set of materialized assets can be fetched more efficiently if you only look for ASSET_MATERIALIZATION in the all logs call

ASSET_CHECK_EVALUATION is the tricky one, that one probably does require an event log scan (but we should fix that...)

created_at: 2025-04-22T17:31:36Z
author_association: MEMBER

PR comments:
user: login: OwenKephart
created_at: 2025-04-09T18:03:20Z
body: * **#29152** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/29152?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#29150** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/29150?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/29150?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `master`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: OwenKephart
created_at: 2025-04-22T16:28:18Z
body: ### Merge activity

* **Apr 22, 12:28 PM EDT**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/dagster-io/dagster/29150).
* **Apr 22, 12:28 PM EDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/dagster-io/dagster/29150).

author_association: CONTRIBUTOR