diff --git a/sqlmesh/cli/main.py b/sqlmesh/cli/main.py index 608b6eefb2..5574a892cb 100644 --- a/sqlmesh/cli/main.py +++ b/sqlmesh/cli/main.py @@ -641,7 +641,7 @@ def run(ctx: click.Context, environment: t.Optional[str] = None, **kwargs: t.Any def invalidate(ctx: click.Context, environment: str, **kwargs: t.Any) -> None: """Invalidate the target environment, forcing its removal during the next run of the janitor process.""" context = ctx.obj - context.invalidate_environment(environment, **kwargs) + context.invalidate_environment(environment, must_exist=True, **kwargs) @cli.command("janitor") diff --git a/sqlmesh/core/context.py b/sqlmesh/core/context.py index d52b10e304..c3abff1d94 100644 --- a/sqlmesh/core/context.py +++ b/sqlmesh/core/context.py @@ -1870,15 +1870,24 @@ def apply( ) @python_api_analytics - def invalidate_environment(self, name: str, sync: bool = False) -> None: + def invalidate_environment( + self, name: str, sync: bool = False, must_exist: bool = False + ) -> None: """Invalidates the target environment by setting its expiration timestamp to now. Args: name: The name of the environment to invalidate. sync: If True, the call blocks until the environment is deleted. Otherwise, the environment will be deleted asynchronously by the janitor process. + must_exist: If True, raise if the environment doesn't exist instead of silently doing nothing. + Used by the user-facing entry points, where a mistyped name should be reported rather than + look like it succeeded. Internal callers such as + `GithubController.try_invalidate_pr_environment` rely on the default no-op behavior, since + a PR environment may never have been created. """ name = Environment.sanitize_name(name) + if must_exist and self.state_sync.get_environment(name) is None: + raise SQLMeshError(f"Environment '{name}' was not found.") self.state_sync.invalidate_environment(name) if sync: self._cleanup_environments(name=name) diff --git a/sqlmesh/magics.py b/sqlmesh/magics.py index 3a59fc4f7b..ed6a1b62de 100644 --- a/sqlmesh/magics.py +++ b/sqlmesh/magics.py @@ -983,7 +983,7 @@ def diff(self, context: Context, line: str) -> None: def invalidate(self, context: Context, line: str) -> None: """Invalidate the target environment, forcing its removal during the next run of the janitor process.""" args = parse_argstring(self.invalidate, line) - context.invalidate_environment(args.environment) + context.invalidate_environment(args.environment, must_exist=True) @magic_arguments() @argument( diff --git a/tests/core/test_context.py b/tests/core/test_context.py index 7db129027b..e41382b078 100644 --- a/tests/core/test_context.py +++ b/tests/core/test_context.py @@ -1928,6 +1928,40 @@ def test_invalidate_environment_no_sync_skips_cleanup(sushi_context, mocker: Moc state_sync_mock.delete_expired_environments.assert_not_called() +def test_invalidate_environment_nonexistent_raises(sushi_context, mocker: MockerFixture) -> None: + """Invalidating an environment that does not exist should error instead of + reporting success, so a mistyped name is caught rather than silently accepted.""" + state_sync_mock = mocker.patch.object( + type(sushi_context), "state_sync", new_callable=mocker.PropertyMock + ).return_value + state_sync_mock.get_environment.return_value = None + + with pytest.raises(SQLMeshError, match="Environment 'doesnotexist' was not found"): + sushi_context.invalidate_environment("doesnotexist", must_exist=True) + + state_sync_mock.invalidate_environment.assert_not_called() + + +def test_invalidate_environment_nonexistent_is_a_noop_by_default( + sushi_context, mocker: MockerFixture +) -> None: + """Without must_exist, invalidating a missing environment stays a no-op. + + Internal callers depend on this. `GithubController.try_invalidate_pr_environment` + invalidates the PR environment after a prod deploy, and that environment may never + have been created — a forward-only deploy, for instance. Raising there turns a + routine cleanup into a failed deploy. + """ + state_sync_mock = mocker.patch.object( + type(sushi_context), "state_sync", new_callable=mocker.PropertyMock + ).return_value + state_sync_mock.get_environment.return_value = None + + sushi_context.invalidate_environment("doesnotexist") + + state_sync_mock.invalidate_environment.assert_called_once_with("doesnotexist") + + @pytest.mark.slow def test_plan_default_end(sushi_context_pre_scheduling: Context): prod_plan_builder = sushi_context_pre_scheduling.plan_builder("prod")