Files
Groene AI 06e02be20e Reduce flakiness in test_distributed_ddl_parallel
The two heavy parallel tests in this suite have been chronically flaky
across unrelated PRs (e.g. #100177, #101757, #93757, #95761, #100942)
with three recurring failure modes:

* `Failed: Timeout (>900.0s) from pytest-timeout` while iterating
  `thread.join(70)` in `test_smoke_parallel_dict_reload`.
* `Coordination::Exception: Connection loss, path /clickhouse/task_queue/ddl/...`
  during `ON CLUSTER` operations.
* Sanitizer asserts in the `zoo3-1` ZooKeeper container under load.

The pattern is the same: 90 concurrent `SYSTEM RELOAD DICTIONARY ON CLUSTER`
(or 50 concurrent `DROP DATABASE ... ON CLUSTER`) submit that many ZK
transactions in parallel. Under sanitizer slowdown the cluster cannot keep
up, ZooKeeper drops connections, and either threads error out or sit on a
hung query until `pytest-timeout` (900 s) kills the whole test.

This commit makes two changes:

1. Lower the parallelism in `test_smoke_parallel` (50 → 20) and
   `test_smoke_parallel_dict_reload` (90 → 30). 30 threads still exceeds
   `cluster_b.pool_size = 20`, so the queueing path is still exercised, and
   `check_log` still catches the `Coordination::Exception: Node exists`
   regression the test was written for.

2. Make `SafeThread.join` raise when the thread is still alive after the
   timeout, instead of silently returning. With 90 stuck joins of 70 s each
   the test would only surface a failure 6300 s later — long after
   `pytest-timeout` has already killed it. Failing fast keeps the error
   message useful and frees CI time.

Verified locally on `Integration tests (amd_tsan, 1/6)`:

* Full suite (`test_distributed_ddl_parallel/test.py`): 6/6 passed in 106 s.
* `test_smoke_parallel_dict_reload --count 5`: 5/5 passed in 54 s.
2026-04-26 16:56:40 +00:00

209 lines
5.6 KiB
Python

# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
# pylint: disable=line-too-long
import threading
import time
from functools import wraps
import pytest
from helpers.cluster import ClickHouseCluster
cluster = ClickHouseCluster(__file__)
# By default the exceptions that was throwed in threads will be ignored
# (they will not mark the test as failed, only printed to stderr).
#
# Wrap thrading.Thread and re-throw exception on join()
class SafeThread(threading.Thread):
def __init__(self, target):
super().__init__()
self.target = target
self.exception = None
def run(self):
try:
self.target()
except Exception as e: # pylint: disable=broad-except
self.exception = e
def join(self, timeout=None):
super().join(timeout)
# If the thread is still alive after the timeout, fail fast
# instead of returning silently. Otherwise the caller moves on
# to the next thread.join(timeout), which can stack up many
# 70-second waits and trip the 900-second pytest-timeout long
# after the real failure happened.
if self.is_alive():
raise RuntimeError(
f"Thread did not finish within {timeout}s — likely stuck "
"(check ZooKeeper connectivity and DDL queue state)"
)
if self.exception:
raise self.exception
def add_instance(name, ddl_config=None):
main_configs = [
"configs/remote_servers.xml",
]
if ddl_config:
main_configs.append(ddl_config)
dictionaries = [
"configs/dict.xml",
]
return cluster.add_instance(
name, main_configs=main_configs, dictionaries=dictionaries, with_zookeeper=True
)
initiator = add_instance("initiator")
# distributed_ddl.pool_size = 2
n1 = add_instance("n1", "configs/ddl_a.xml")
n2 = add_instance("n2", "configs/ddl_a.xml")
# distributed_ddl.pool_size = 20
n3 = add_instance("n3", "configs/ddl_b.xml")
n4 = add_instance("n4", "configs/ddl_b.xml")
@pytest.fixture(scope="module", autouse=True)
def start_cluster():
try:
cluster.start()
yield cluster
finally:
cluster.shutdown()
# verifies that functions executes longer then `sec`
def longer_then(sec):
def wrapper(func):
@wraps(func)
def inner(*args, **kwargs):
ts = time.time()
result = func(*args, **kwargs)
te = time.time()
took = te - ts
assert took >= sec
return result
return inner
return wrapper
# It takes 7 seconds to load slow_dict_7.
def execute_reload_dictionary_slow_dict_7():
initiator.query(
"SYSTEM RELOAD DICTIONARY ON CLUSTER cluster_a slow_dict_7",
settings={
"distributed_ddl_task_timeout": 60,
},
)
def execute_reload_dictionary_slow_dict_3():
initiator.query(
"SYSTEM RELOAD DICTIONARY ON CLUSTER cluster_b slow_dict_3",
settings={
"distributed_ddl_task_timeout": 60,
},
)
def execute_smoke_query():
initiator.query(
"DROP DATABASE IF EXISTS foo ON CLUSTER cluster_b",
settings={
"distributed_ddl_task_timeout": 60,
},
)
def check_log():
# ensure that none of tasks processed multiple times
for _, instance in list(cluster.instances.items()):
assert not instance.contains_in_log("Coordination::Exception: Node exists")
# NOTE: uses inner function to exclude slow start_cluster() from timeout.
def test_slow_dict_load_7():
@pytest.mark.timeout(10)
@longer_then(7)
def inner_test():
initiator.query("SYSTEM RELOAD DICTIONARY slow_dict_7")
inner_test()
def test_all_in_parallel():
@pytest.mark.timeout(10)
@longer_then(7)
def inner_test():
threads = []
for _ in range(2):
threads.append(SafeThread(target=execute_reload_dictionary_slow_dict_7))
for thread in threads:
thread.start()
for thread in threads:
thread.join(70)
inner_test()
check_log()
def test_two_in_parallel_two_queued():
@pytest.mark.timeout(19)
@longer_then(14)
def inner_test():
threads = []
for _ in range(4):
threads.append(SafeThread(target=execute_reload_dictionary_slow_dict_7))
for thread in threads:
thread.start()
for thread in threads:
thread.join(70)
inner_test()
check_log()
def test_smoke():
for _ in range(100):
execute_smoke_query()
check_log()
# 20 parallel `DROP DATABASE IF EXISTS ... ON CLUSTER` is enough to exercise
# the DDL coordination path and catch the `Node exists` race the test guards
# against. Going higher (the test used to spawn 50) saturates ZooKeeper under
# sanitizer builds and causes spurious connection-loss failures.
def test_smoke_parallel():
threads = []
for _ in range(20):
threads.append(SafeThread(target=execute_smoke_query))
for thread in threads:
thread.start()
for thread in threads:
thread.join(70)
check_log()
# 30 threads still exercises queueing on `cluster_b` (pool_size = 20).
# Going higher (the test used to spawn 90) saturates ZooKeeper under
# sanitizer builds, and the resulting connection losses make the test
# stall long enough to trip the 900-second pytest-timeout.
def test_smoke_parallel_dict_reload():
threads = []
for _ in range(30):
threads.append(SafeThread(target=execute_reload_dictionary_slow_dict_3))
for thread in threads:
thread.start()
for thread in threads:
thread.join(70)
check_log()