Skip to content
6 changes: 6 additions & 0 deletions posthog/clickhouse/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,12 @@ def find_existing_mutations(
# value containing the heredoc delimiter would close it early and the rest would parse as
# SQL. Mutation parameters carry third-party strings (a person's distinct_id), so that is
# reachable input, and the injection is silent because the surrounding array keeps its length.
# Render with this connection's context so datetimes are converted to the server timezone exactly
# as `client.execute` does when the mutation is submitted. A fresh pooled client has not connected
# yet and has no `server_info`, so connect first. Use a query rather than `force_connect()`, which
# leaves the connection marked mid-query and makes the next execute raise PartiallyConsumedQueryError.
if client.connection.context.server_info is None:
client.execute("SELECT 1")
rendered_commands = [
client.substitute_params(f"{alter_prefix}{cmd}", self.parameters, client.connection.context)
for cmd in command_list
Expand Down
30 changes: 29 additions & 1 deletion posthog/clickhouse/test/test_cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import uuid
from collections import defaultdict
from collections.abc import Callable, Iterator, Mapping
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta

import pytest
from posthog.test.base import materialized
Expand Down Expand Up @@ -426,6 +426,34 @@ def test_find_existing_mutations_handles_delimiter_shaped_parameter_value(cluste
)


def test_find_existing_mutations_renders_tz_aware_datetimes_before_connecting() -> None:
"""Regression test: commands were rendered with `client.connection.context` before the client had
connected, so `server_info` was None and a tz-aware datetime parameter (e.g. an event removal time
range) raised `'NoneType' object has no attribute 'get_timezone'`. The lookup must also render in the
server timezone, as submission does, or a non-UTC server would never match its stored mutation.
"""
runner = LightweightDeleteMutationRunner(
table=EVENTS_DATA_TABLE(),
predicate="team_id = %(team_id)s AND timestamp >= %(start_time)s",
parameters={"team_id": 1, "start_time": datetime(2026, 1, 1, tzinfo=UTC)},
)
client = Client("unconnected-host")
assert client.connection.context.server_info is None

def fake_execute(query: str, params: dict | None = None, **kwargs) -> list[tuple]:
if query == "SELECT 1": # connecting populates server_info, here a non-UTC server
client.connection.context.server_info = Mock(get_timezone=Mock(return_value="America/New_York"))
return [(1,)]
return [(None,)]

with patch.object(client, "execute", side_effect=fake_execute) as execute:
assert runner.find_existing_mutations(client) == {}

connect_call, lookup_call = execute.call_args_list
assert connect_call.args == ("SELECT 1",)
assert "'2025-12-31 19:00:00'" in lookup_call.args[1]["__command_0"]


def test_alter_mutation_multiple_commands(cluster: ClickhouseCluster) -> None:
table = EVENTS_DATA_TABLE()
count = 100
Expand Down
Loading