Files
Groene AIandClaude Opus 4.8 aae86e3320 Fix flaky test_keeper_session_loss_during_coordinated_refresh
The test puts replica B (node2) on a fake clock so it starts a coordinated
refresh, makes node2 lose its keeper session so its 'running' ephemeral
expires, then checks replica A (node1) does not start a duplicate refresh for
the same timeslot (the guarantee added in #104051).

It was flaky (8 PR failures / 30d, 0 on master across 4036 master runs). Two
issues, both in the test's timing:

1. node1's crash-detection grace deadline is computed from currentTime(),
   which returns the frozen fake clock once SET FAKE TIME is applied. node1
   enters the missing-'running'-ephemeral grace path purely from watching
   node2's znodes (the check runs before the stop_requested check, so STOP
   VIEW does not gate it). The old test dropped node2's keeper connection and
   only THEN set node1's fake clock. On a loaded runner node2's ephemeral
   could expire and node1 could record running_znode_missing_since on the REAL
   clock before that SET FAKE TIME; the subsequent jump to a fake time far in
   the future then put the grace deadline in the past, so node1 immediately
   declared node2 crashed, cleared the lock and ran a duplicate refresh.

2. node2's reconnect was timed with fixed sleeps relative to the partition,
   not to when node1 actually started its grace period, leaving a load-
   dependent margin before node1's ~6.25s deadline.

Fix the test (the behavior under a genuinely long outage is by design):

- Set node1's fake clock and START its view BEFORE partitioning node2, so
  node1 always records grace-start on the frozen fake clock and its deadline
  never arrives while node2 is briefly disconnected.
- Cut only node2 off from keeper (PartitionManager) instead of restarting the
  whole keeper cluster, so keeper stays healthy for node1 and node2 reconnects
  promptly.
- Chain node2's restore to node1's "no corresponding ephemeral znode" log line
  instead of a fixed sleep, so node2 reconnects ~1s after node1 starts the
  grace period regardless of runner load.
- Use query_with_retry for SYSTEM SYNC REPLICA to tolerate a replica being
  briefly read-only right after re-establishing its keeper session.

node1 still enters the grace-period wait while node2's ephemeral is missing,
so the test still fails if the #104051 coordination fix is reverted. Verified
locally: 14/14 runs pass with the change; a forcing variant that deliberately
uses the old ordering deterministically reproduces node1 clearing the lock and
refreshing, confirming the root cause.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-13 20:48:45 +00:00

190 lines
8.1 KiB
Python

import time
from datetime import datetime, timedelta
import pytest
from helpers.cluster import ClickHouseCluster
from helpers.network import PartitionManager
cluster = ClickHouseCluster(__file__, zookeeper_config_path="configs/zookeeper.xml")
node1 = cluster.add_instance(
"node1",
main_configs=["configs/remote_servers.xml"],
user_configs=["configs/settings.xml"],
with_zookeeper=True,
keeper_required_feature_flags=["multi_read", "create_if_not_exists"],
macros={"shard": 1, "replica": 1},
)
node2 = cluster.add_instance(
"node2",
main_configs=["configs/remote_servers.xml"],
user_configs=["configs/settings.xml"],
with_zookeeper=True,
keeper_required_feature_flags=["multi_read", "create_if_not_exists"],
macros={"shard": 1, "replica": 2},
)
@pytest.fixture(scope="module", autouse=True)
def started_cluster():
try:
cluster.start()
yield cluster
finally:
cluster.shutdown()
def view_state(table):
"""Per-replica `system.view_refreshes` snapshot, for failure messages."""
rows = []
for n in [node1, node2]:
info = n.query(
"SELECT status, exception, retry, last_refresh_replica, "
"toInt64(last_refresh_time) AS last, toInt64(next_refresh_time) AS next, "
"toInt64(last_success_time) AS succ "
f"FROM system.view_refreshes WHERE view = '{table}' FORMAT Vertical"
).strip()
rows.append(f"--- {n.name} ---\n{info}")
return "\n".join(rows)
def wait_for_status(node, table, expected, timeout=60):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
last = node.query(
f"SELECT status FROM system.view_refreshes WHERE view = '{table}'"
).strip()
if last == expected:
return
time.sleep(0.2)
raise AssertionError(
f"{node.name}: view '{table}' did not reach status '{expected}' within {timeout}s "
f"(last: '{last}')\n{view_state(table)}"
)
def test_keeper_session_loss_during_coordinated_refresh(started_cluster):
"""
Regression test: when a coordinated RMV refresh is running on replica B and B
loses its keeper session (long enough for the 'running' ephemeral to expire),
replica A must NOT start a duplicate refresh for the same timeslot.
"""
if node1.is_built_with_sanitizer():
# SELECT below balloons under sanitizers → flaky timing.
pytest.skip("Disabled for sanitizers (timing-sensitive)")
# Replicated database is the only flavor that supports coordinated RMVs.
node1.query("DROP DATABASE IF EXISTS re ON CLUSTER default SYNC")
node1.query(
"CREATE DATABASE re ON CLUSTER default "
"ENGINE = Replicated('/clickhouse/databases/re', '{shard}', '{replica}')"
)
# Separate target table so we can SYSTEM SYNC REPLICA on it deterministically
# before counting rows.
node1.query(
"CREATE TABLE re.tgt (t DateTime, i UInt64) "
"ENGINE = ReplicatedMergeTree() ORDER BY tuple()"
)
# APPEND-mode coordinated RMV. The SELECT runs ~30s (sleepEachRow with
# max_block_size=1), giving a wide window to kill keeper mid-refresh.
# EVERY 1 YEAR puts the natural schedule far from any clock boundary, so
# neither replica spontaneously refreshes during setup. EMPTY skips the
# initial refresh at CREATE time.
node1.query(
"""
CREATE MATERIALIZED VIEW re.a
REFRESH EVERY 1 YEAR APPEND
TO re.tgt
EMPTY
AS SELECT now() AS t, sum(sleepEachRow(1) + 1) AS i
FROM numbers(30) SETTINGS max_block_size = 1
"""
)
wait_for_status(node1, "a", "Scheduled")
wait_for_status(node2, "a", "Scheduled")
# Stop the view on node1 so node2 deterministically wins the race for the
# 'running' ephemeral znode of the next refresh.
node1.query("SYSTEM STOP VIEW re.a")
next_refresh = node2.query(
"SELECT next_refresh_time FROM system.view_refreshes WHERE view = 'a'"
).strip()
assert next_refresh, "Expected node2 to have a scheduled next refresh"
next_refresh_dt = datetime.strptime(next_refresh, "%Y-%m-%d %H:%M:%S")
# node1's wake time must land in the same yearly timeslot as node2's,
# otherwise determineNextRefreshTime would advance to next year and APPEND
# for a different timeslot, masking the bug. 5 minutes leaves room for
# retry backoff to grow without crossing the year boundary.
node1_fake = (next_refresh_dt + timedelta(minutes=5)).strftime("%Y-%m-%d %H:%M:%S")
# Fake clock is per-replica, so this only triggers node2's refresh.
node2.query(f"SYSTEM TEST VIEW re.a SET FAKE TIME '{next_refresh}'")
wait_for_status(node2, "a", "Running", timeout=30)
wait_for_status(node1, "a", "RunningOnAnotherReplica", timeout=30)
# Put node1 on the fake clock for node2's timeslot and START its view BEFORE
# touching keeper. node1's 'running' ephemeral check in RefreshTask runs
# before the stop_requested check, so node1 is already coordinating
# (RunningOnAnotherReplica) regardless of START/STOP; what matters is that
# node1 is now on the fake clock while node2's ephemeral is still present, so
# it has not yet entered the missing-znode grace path.
#
# This ordering is the crux of the fix: node1's grace deadline is computed
# from currentTime(), which returns the frozen fake clock once set. If node1
# recorded running_znode_missing_since while still on the real clock (i.e. if
# the ephemeral disappeared before this SET FAKE TIME) and we then jumped its
# clock forward, the real-clock deadline would already be in the past and
# node1 would clear the lock immediately, producing the duplicate this test
# only wants to see when the coordination fix is absent. Setting the fake
# clock first makes node1 record grace-start on the frozen clock instead.
node1.query(f"SYSTEM TEST VIEW re.a SET FAKE TIME '{node1_fake}'")
node1.query("SYSTEM START VIEW re.a")
wait_for_status(node1, "a", "RunningOnAnotherReplica", timeout=30)
# Cut only node2 off from keeper instead of restarting the whole keeper
# cluster. Keeper stays healthy for node1, so node1's session never expires
# and its watches keep firing. node2's session expires
# (>session_timeout_ms=5000, see zookeeper.xml) so its 'running' ephemeral
# disappears, but its SELECT keeps running because query execution is
# independent of keeper -- the bug's trigger condition.
with PartitionManager() as pm:
pm.drop_instance_zk_connections(node2)
# Wait until node1 actually notices the missing 'running' ephemeral and
# enters its crash-detection grace period, then restore node2 at once.
# Chaining the restore to this log line (instead of a fixed sleep)
# removes the load-dependent race: node2 reconnects ~1s after node1
# starts the grace period, always well within the ~6.25s window,
# regardless of how loaded the runner is. With the fix node1 waits and
# sees node2 re-register; without it node1 has no grace and APPENDs a
# duplicate row.
node1.wait_for_log_line("no corresponding ephemeral znode", timeout=60)
pm.restore_instance_zk_connections(node2)
# Wait for the dust to settle: node2's refresh completes (~30s SELECT +
# post-keeper INSERT) and propagates to keeper, then both replicas idle.
wait_for_status(node1, "a", "Scheduled", timeout=120)
wait_for_status(node2, "a", "Scheduled", timeout=120)
# Pull both replicas' parts before counting -- otherwise the count races
# against ReplicatedMergeTree replication. Retry: a replica may still be
# transiently read-only right after re-establishing its keeper session.
node1.query_with_retry("SYSTEM SYNC REPLICA re.tgt")
node2.query_with_retry("SYSTEM SYNC REPLICA re.tgt")
rows = node1.query("SELECT toInt64(t), i, _part FROM re.tgt ORDER BY t")
final_count = int(node1.query("SELECT count() FROM re.tgt").strip())
assert final_count == 1, (
f"Expected 1 row APPENDed for the timeslot, got {final_count}. "
f"Rows:\n{rows}"
)