Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sqlmesh/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
11 changes: 10 additions & 1 deletion sqlmesh/core/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion sqlmesh/magics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
34 changes: 34 additions & 0 deletions tests/core/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down