From b798b2ed6c35a1fbff297b718b50389311bf25e4 Mon Sep 17 00:00:00 2001 From: Nishchay Mahor Date: Sat, 4 Jul 2026 18:15:15 -0700 Subject: [PATCH 1/3] fix: error when invalidating a nonexistent environment sqlmesh invalidate ENVIRONMENT reported success even when the environment did not exist, so a mistyped name looked like it worked. Check the environment exists first and raise a clear error (nonzero exit) otherwise. Fixes #5621 Signed-off-by: Nishchay Mahor --- sqlmesh/core/context.py | 2 ++ tests/core/test_context.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/sqlmesh/core/context.py b/sqlmesh/core/context.py index d52b10e304..be031533de 100644 --- a/sqlmesh/core/context.py +++ b/sqlmesh/core/context.py @@ -1879,6 +1879,8 @@ def invalidate_environment(self, name: str, sync: bool = False) -> None: be deleted asynchronously by the janitor process. """ name = Environment.sanitize_name(name) + if self.state_sync.get_environment(name) is None: + raise SQLMeshError(f"Environment '{name}' does not exist.") self.state_sync.invalidate_environment(name) if sync: self._cleanup_environments(name=name) diff --git a/tests/core/test_context.py b/tests/core/test_context.py index 7db129027b..0646216026 100644 --- a/tests/core/test_context.py +++ b/tests/core/test_context.py @@ -1928,6 +1928,20 @@ 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' does not exist"): + sushi_context.invalidate_environment("doesnotexist") + + state_sync_mock.invalidate_environment.assert_not_called() + + @pytest.mark.slow def test_plan_default_end(sushi_context_pre_scheduling: Context): prod_plan_builder = sushi_context_pre_scheduling.plan_builder("prod") From 23bc02e09d8a4c800b29b098d37d083365a967ea Mon Sep 17 00:00:00 2001 From: Nishchay Mahor Date: Sun, 19 Jul 2026 23:04:19 -0700 Subject: [PATCH 2/3] fix: use 'was not found' wording to match convention Signed-off-by: Nishchay Mahor --- sqlmesh/core/context.py | 2 +- tests/core/test_context.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sqlmesh/core/context.py b/sqlmesh/core/context.py index be031533de..b64fc5e928 100644 --- a/sqlmesh/core/context.py +++ b/sqlmesh/core/context.py @@ -1880,7 +1880,7 @@ def invalidate_environment(self, name: str, sync: bool = False) -> None: """ name = Environment.sanitize_name(name) if self.state_sync.get_environment(name) is None: - raise SQLMeshError(f"Environment '{name}' does not exist.") + raise SQLMeshError(f"Environment '{name}' was not found.") self.state_sync.invalidate_environment(name) if sync: self._cleanup_environments(name=name) diff --git a/tests/core/test_context.py b/tests/core/test_context.py index 0646216026..2eadf7c196 100644 --- a/tests/core/test_context.py +++ b/tests/core/test_context.py @@ -1936,7 +1936,7 @@ def test_invalidate_environment_nonexistent_raises(sushi_context, mocker: Mocker ).return_value state_sync_mock.get_environment.return_value = None - with pytest.raises(SQLMeshError, match="Environment 'doesnotexist' does not exist"): + with pytest.raises(SQLMeshError, match="Environment 'doesnotexist' was not found"): sushi_context.invalidate_environment("doesnotexist") state_sync_mock.invalidate_environment.assert_not_called() From f082aeabbed9386a2f9835b6bea61266105b36d8 Mon Sep 17 00:00:00 2001 From: Nishchay Mahor Date: Sun, 16 Aug 2026 17:04:21 -0700 Subject: [PATCH 3/3] Gate the existence check behind must_exist for user-facing callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existence check was applied unconditionally in GenericContext.invalidate_environment, which broke GithubController.try_invalidate_pr_environment. That method invalidates the PR environment after a prod deploy, and the environment may never have been created — a forward-only deploy being one case — so it relies on a missing environment being a silent no-op. Raising there turned a routine cleanup into a failed deploy, which is what test_deploy_prod_forward_only caught. Move the check behind must_exist=False and have the two user-facing entry points, the invalidate CLI command and the %invalidate magic, opt in. The reported behavior is unchanged; library callers keep the lenient path. Adds a regression test pinning the no-op default. Signed-off-by: Nishchay Mahor --- sqlmesh/cli/main.py | 2 +- sqlmesh/core/context.py | 11 +++++++++-- sqlmesh/magics.py | 2 +- tests/core/test_context.py | 22 +++++++++++++++++++++- 4 files changed, 32 insertions(+), 5 deletions(-) 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 b64fc5e928..c3abff1d94 100644 --- a/sqlmesh/core/context.py +++ b/sqlmesh/core/context.py @@ -1870,16 +1870,23 @@ 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 self.state_sync.get_environment(name) is None: + 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: 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 2eadf7c196..e41382b078 100644 --- a/tests/core/test_context.py +++ b/tests/core/test_context.py @@ -1937,11 +1937,31 @@ def test_invalidate_environment_nonexistent_raises(sushi_context, mocker: Mocker state_sync_mock.get_environment.return_value = None with pytest.raises(SQLMeshError, match="Environment 'doesnotexist' was not found"): - sushi_context.invalidate_environment("doesnotexist") + 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")