Skip to content
Closed
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
68 changes: 49 additions & 19 deletions posthog/management/commands/migrate_clickhouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@

MIGRATIONS_PACKAGE_NAME = "posthog.clickhouse.migrations"

# infi's default request timeout is 60 seconds. ON CLUSTER statements and slow
# migrations can run longer than that, so give the migration connection a
# generous timeout instead of failing the whole run.
CLICKHOUSE_MIGRATIONS_TIMEOUT = 600


class Command(BaseCommand):
help = "Migrate clickhouse"
Expand Down Expand Up @@ -53,16 +58,22 @@ def handle(self, *args, **options):
self.migrate(CLICKHOUSE_HTTP_URL, options)

def migrate(self, host, options):
# The pre-create depends on the migrations cluster topology, not on the
# MULTINODE_CLICKHOUSE flag. Read it once from system.clusters.
multi_node = self._migrations_cluster_is_multi_node(CLICKHOUSE_MIGRATIONS_CLUSTER)
# Infi only creates the DB in one node, but not the rest. Create it before running migrations.
self._create_database_if_not_exists(CLICKHOUSE_DATABASE, CLICKHOUSE_MIGRATIONS_CLUSTER)
self._create_migration_tracking_tables_if_not_exist(CLICKHOUSE_DATABASE, CLICKHOUSE_MIGRATIONS_CLUSTER)
self._create_database_if_not_exists(CLICKHOUSE_DATABASE, CLICKHOUSE_MIGRATIONS_CLUSTER, multi_node)
self._create_migration_tracking_tables_if_not_exist(
CLICKHOUSE_DATABASE, CLICKHOUSE_MIGRATIONS_CLUSTER, multi_node
)
database = Database(
CLICKHOUSE_DATABASE,
db_url=host,
username=CLICKHOUSE_USER,
password=CLICKHOUSE_PASSWORD,
cluster=CLICKHOUSE_MIGRATIONS_CLUSTER,
verify_ssl_cert=False,
timeout=CLICKHOUSE_MIGRATIONS_TIMEOUT,
randomize_replica_paths=settings.TEST or settings.E2E_TESTING,
# don't use the egress proxy, clickhouse is internal
trust_env=False,
Expand Down Expand Up @@ -120,33 +131,52 @@ def get_migrations(self, database, upto):
def get_applied_migrations(self, database) -> set[str]:
return database._get_applied_migrations(MIGRATIONS_PACKAGE_NAME, replicated=True)

def _create_database_if_not_exists(self, database: str, cluster: str):
# MULTINODE_CLICKHOUSE: infi.clickhouse_orm creates the Distributed
# migration-tracking table across the migrations cluster before the
# first migration runs, so the database has to exist on every node up
# front — otherwise the CREATE TABLE fans out to satellites that have
# no `posthog` database yet and fails with UNKNOWN_DATABASE.
if settings.TEST or settings.E2E_TESTING or settings.MULTINODE_CLICKHOUSE:
def _migrations_cluster_is_multi_node(self, cluster: str) -> bool:
# The hazard is the cluster topology, not MULTINODE_CLICKHOUSE. infi's
# replicated migrate() selects from the Distributed tracking table.
# That query fans out to every node in the migrations cluster. When the
# cluster has more than one node, the local tables must already exist on
# all of them, so we pre-create them. A single-node cluster needs no
# pre-create: infi's auto-create lands on the one node and the SELECT
# reads it back. An undefined cluster (no rows in system.clusters) also
# returns False and skips the pre-create, but it is not made safe here:
# infi still builds the tracking tables with `ON CLUSTER <cluster>` and
# ClickHouse fails loudly with CLUSTER_DOESNT_EXIST. That is acceptable
# — an undefined posthog_migrations cluster is an invalid configuration
# that also breaks host discovery elsewhere, so failing early is correct.
with default_client() as client:
[(node_count,)] = client.execute(
"SELECT count() FROM system.clusters WHERE cluster = %(cluster)s",
{"cluster": cluster},
Comment thread
posthog[bot] marked this conversation as resolved.
)
return node_count > 1

def _create_database_if_not_exists(self, database: str, cluster: str, multi_node: bool):
# infi.clickhouse_orm creates the Distributed migration-tracking table
# across the migrations cluster before the first migration runs, so the
# database has to exist on every node up front — otherwise the CREATE
# TABLE fans out to satellites that have no `posthog` database yet and
# fails with UNKNOWN_DATABASE.
if settings.TEST or settings.E2E_TESTING or multi_node:
with default_client() as client:
client.execute(
f"CREATE DATABASE IF NOT EXISTS {database} ON CLUSTER {cluster}",
)

def _create_migration_tracking_tables_if_not_exist(self, database: str, cluster: str):
# MULTINODE_CLICKHOUSE only: infi.clickhouse_orm's auto-create path
# issues `CREATE TABLE` without `ON CLUSTER`, so the underlying
# ReplicatedMergeTree only lands on the migrations host. With a real
# multi-node `posthog_migrations` cluster, the Distributed tracking
# table fans out to every shard and trips UNKNOWN_TABLE on satellites
# that never received the local replica. Pre-create both tables on
# the cluster so the very first SELECT in infi's migrate() succeeds
# and the auto-create branch never runs.
def _create_migration_tracking_tables_if_not_exist(self, database: str, cluster: str, multi_node: bool):
# infi.clickhouse_orm's auto-create path issues `CREATE TABLE` without
# `ON CLUSTER`, so the underlying ReplicatedMergeTree only lands on the
# migrations host. With a real multi-node `posthog_migrations` cluster,
# the Distributed tracking table fans out to every shard and trips
# UNKNOWN_TABLE on satellites that never received the local replica.
# Pre-create both tables on the cluster so the very first SELECT in
# infi's migrate() succeeds and the auto-create branch never runs.
Comment on lines +166 to +173

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct the auto-create explanation

consider

Why we think it's a valid issue
  • Checked: The infi.clickhouse_orm auto-create path in the installed venv, the two tracking models it creates, on_cluster(), the Database constructor, and the value of CLICKHOUSE_MIGRATIONS_CLUSTER.
  • Found: The auto-create path calls create_table(MigrationHistoryReplicated) and create_table(MigrationHistoryDistributed) (database.py:364-378). Both models subclass plain Model (migrations.py:289, migrations.py:309), so both use Model.create_table_sql, which renders on_cluster(db) into the statement (models.py:355). on_cluster() returns ON CLUSTER '<cluster>' whenever db.cluster is not None (utils.py:158-162).
  • Found: db.cluster is always set on this path. Database.__init__ stores it (database.py:109), migrate_clickhouse.py:71 passes cluster=CLICKHOUSE_MIGRATIONS_CLUSTER, and that setting defaults to the non-empty string "posthog_migrations" (posthog/settings/data_stores.py:342). It is never None. So infi's auto-create does emit ON CLUSTER, and the comment at posthog/management/commands/migrate_clickhouse.py:167-168 states the opposite.
  • Found: The same file already contradicts itself. Line 143 says "infi still builds the tracking tables with ON CLUSTER <cluster>" — the correct behavior, added by commit feb766a, which fixed this exact misconception in the neighboring comment. Lines 167-171 still carry the old, wrong version, including the UNKNOWN_TABLE-on-satellites failure chain that cannot happen once the DDL runs ON CLUSTER.
  • Impact: The only stated justification for _create_migration_tracking_tables_if_not_exist is false, and it disagrees with another comment 25 lines above it. A future maintainer reading the two comments cannot tell which one describes infi. That maintainer may delete the helper as unnecessary, or re-derive the guard from the wrong failure mode, in tooling that runs DDL against production clusters. The real reasons — the helper creates the tables through the native client before infi's HTTP request path reads the Distributed table, and it removes the failed-SELECT-then-create retry — are absent.
  • Impact: No behavior changes, so this stays at the reviewer's lowest priority. It is not a wording preference, however: the claim is objectively wrong and self-contradictory inside the added code, so it does not fall under the style exclusion.
Issue description

The earlier review thread confirmed that infi adds ON CLUSTER when db.cluster is set. This comment still says that auto-create omits it. Both tracking models inherit Model, whose create_table_sql calls on_cluster(db). The comment therefore gives the wrong reason for this pre-create path.

Suggested fix

Explain that this helper creates the cluster tables before infi reads the Distributed table. It also moves creation to the native client. Do not state that infi omits ON CLUSTER.

Prompt to fix with AI (copy-paste)
## Context
@posthog/management/commands/migrate_clickhouse.py#L166-173

<issue_description>
The earlier review thread confirmed that infi adds `ON CLUSTER` when `db.cluster` is set. This comment still says that auto-create omits it. Both tracking models inherit `Model`, whose `create_table_sql` calls `on_cluster(db)`. The comment therefore gives the wrong reason for this pre-create path.
</issue_description>

<issue_validation>
- **Checked:** The `infi.clickhouse_orm` auto-create path in the installed venv, the two tracking models it creates, `on_cluster()`, the `Database` constructor, and the value of `CLICKHOUSE_MIGRATIONS_CLUSTER`.
- **Found:** The auto-create path calls `create_table(MigrationHistoryReplicated)` and `create_table(MigrationHistoryDistributed)` (`database.py:364-378`). Both models subclass plain `Model` (`migrations.py:289`, `migrations.py:309`), so both use `Model.create_table_sql`, which renders `on_cluster(db)` into the statement (`models.py:355`). `on_cluster()` returns `ON CLUSTER '<cluster>'` whenever `db.cluster is not None` (`utils.py:158-162`).
- **Found:** `db.cluster` is always set on this path. `Database.__init__` stores it (`database.py:109`), `migrate_clickhouse.py:71` passes `cluster=CLICKHOUSE_MIGRATIONS_CLUSTER`, and that setting defaults to the non-empty string `"posthog_migrations"` (`posthog/settings/data_stores.py:342`). It is never `None`. So infi's auto-create does emit `ON CLUSTER`, and the comment at `posthog/management/commands/migrate_clickhouse.py:167-168` states the opposite.
- **Found:** The same file already contradicts itself. Line 143 says "infi still builds the tracking tables with `ON CLUSTER <cluster>`" — the correct behavior, added by commit feb766abb, which fixed this exact misconception in the neighboring comment. Lines 167-171 still carry the old, wrong version, including the `UNKNOWN_TABLE`-on-satellites failure chain that cannot happen once the DDL runs `ON CLUSTER`.
- **Impact:** The only stated justification for `_create_migration_tracking_tables_if_not_exist` is false, and it disagrees with another comment 25 lines above it. A future maintainer reading the two comments cannot tell which one describes infi. That maintainer may delete the helper as unnecessary, or re-derive the guard from the wrong failure mode, in tooling that runs DDL against production clusters. The real reasons — the helper creates the tables through the native client before infi's HTTP request path reads the Distributed table, and it removes the failed-SELECT-then-create retry — are absent.
- **Impact:** No behavior changes, so this stays at the reviewer's lowest priority. It is not a wording preference, however: the claim is objectively wrong and self-contradictory inside the added code, so it does not fall under the style exclusion.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Explain that this helper creates the cluster tables before infi reads the Distributed table. It also moves creation to the native client. Do not state that infi omits `ON CLUSTER`.
</potential_solution>

#
# Schema (`package_name String, module_name String, applied Date`) and
# the ZK path mirror `infi.clickhouse_orm.migrations.MigrationHistory`
# / `MigrationHistoryReplicated`. If `infi` ever changes those, this
# pre-create will silently diverge — keep the two in sync.
if not settings.MULTINODE_CLICKHOUSE:
if not multi_node:
return
with default_client() as client:
client.execute(
Expand Down
55 changes: 55 additions & 0 deletions posthog/management/commands/test/test_migrate_clickhouse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from unittest.mock import MagicMock, patch

from django.test import SimpleTestCase, override_settings

from parameterized import parameterized

from posthog.management.commands.migrate_clickhouse import CLICKHOUSE_MIGRATIONS_TIMEOUT, Command


def _fake_client(node_count: int) -> tuple[MagicMock, list[str]]:
executed: list[str] = []
client = MagicMock()

def execute(sql, *args, **kwargs):
executed.append(sql)
if "system.clusters" in sql:
return [(node_count,)]
return []

client.execute.side_effect = execute
context = MagicMock()
context.__enter__.return_value = client
return context, executed


class TestMigrateClickhouse(SimpleTestCase):
@parameterized.expand([("multi_node", 2, True), ("single_node", 1, False)])
@override_settings(MULTINODE_CLICKHOUSE=False)
def test_tracking_table_precreate_follows_cluster_topology(
self, _name: str, node_count: int, expect_precreate: bool
) -> None:
# The pre-create must key on the real cluster topology, not on the
# MULTINODE_CLICKHOUSE flag. Both cases run with the flag off.
context, executed = _fake_client(node_count)
with (
patch("posthog.management.commands.migrate_clickhouse.default_client", return_value=context),
patch("posthog.management.commands.migrate_clickhouse.Database"),
):
Command().migrate("http://clickhouse", {"plan": False, "check": False, "fake": False, "upto": 99999})

precreated = any(
"infi_clickhouse_orm_migrations_distributed" in sql and "ON CLUSTER" in sql for sql in executed
)
self.assertEqual(precreated, expect_precreate)

@override_settings(MULTINODE_CLICKHOUSE=False)
def test_migration_connection_uses_generous_timeout(self) -> None:
context, _ = _fake_client(node_count=1)
with (
patch("posthog.management.commands.migrate_clickhouse.default_client", return_value=context),
patch("posthog.management.commands.migrate_clickhouse.Database") as database,
):
Command().migrate("http://clickhouse", {"plan": False, "check": False, "fake": False, "upto": 99999})

self.assertEqual(database.call_args.kwargs["timeout"], CLICKHOUSE_MIGRATIONS_TIMEOUT)
Loading