Files
Michael KolupaevandClaude Opus 4.8 8c5f908b80 Merge remote-tracking branch 'origin/master' into unrocks
Resolve conflicts from the Keeper TTL garbage-collector feature
(#100397, https://github.com/ClickHouse/ClickHouse/pull/100397), the only
PR that conflicted with the Keeper RocksDB storage removal.

Resolution:
- Keep this branch's de-templated, RocksDB-free `KeeperStorage` /
  `KeeperStateMachine` while preserving the new TTL feature: `ttl` parameter
  on `createNode`, `ttl_paths` / `committed_ttl_nodes` bookkeeping in
  `removeNode`, `collectExpiredTTLPaths`, `containsTTLPath`, and
  `getExpiredTTLPathsForGarbageCollector` (now concrete methods, the
  `TSA_NO_THREAD_SAFETY_ANALYSIS` annotations retained).
- `processWatches` for `Remove` now iterates `deltas` (no longer ignored).
- Keep `RocksDBContainer.h` deleted (modify/delete conflict).
- Port the new TTL unit tests in `gtest_coordination_storage.cpp` and
  `gtest_coordination_snapshot.cpp` to the single memory-storage fixture:
  drop the `setRocksDBDirectory` / `ChangelogDirTest rocks` setup and the
  `KeeperSnapshotManager<Storage>` / `KeeperStorageSnapshot<Storage>` template
  arguments, matching this branch's conventions.

All touched Coordination translation units pass `-fsyntax-only` against the
merged headers.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-24 19:27:44 -07:00

445 lines
19 KiB
Python

import os
import re
import threading
import time as _time
from contextlib import contextmanager
from keeper.faults.registry import register_fault
from keeper.framework.core.settings import DEFAULT_FAULT_DURATION_S
from keeper.framework.core.util import (
for_each_target,
resolve_targets,
sh,
sh_root,
sh_root_strict,
ts_ms,
)
def _ram_block_device(node):
return sh(node, "ls -1 /dev/ram* 2>/dev/null | head -n1 || true")["out"].strip()
def _detach_loop_for_image(node, img):
sh_root(
node,
f'LOOP=$(losetup -j {img} | cut -d: -f1); [ -n "$LOOP" ] && losetup -d $LOOP || true',
timeout=60,
)
def _loop_device_for_image(node, img, size_mb):
sh_root_strict(node, f"rm -f {img} || true", timeout=30)
sh_root_strict(
node, f"dd if=/dev/zero of={img} bs=1M count={int(size_mb)}", timeout=120
)
_detach_loop_for_image(node, img)
return sh_root_strict(node, f"losetup -f --show {img}", timeout=60)["out"].strip()
def _safe_dm_token(s):
if not s:
return ""
s = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(s))
return s.strip("_")
def _dm_name(prefix, node):
suffix = getattr(node, "name", "node")
run = _safe_dm_token(os.environ.get("KEEPER_CLUSTER_NAME", ""))
if run:
return f"{prefix}_{run[:32]}_{suffix}"
return f"{prefix}_{suffix}"
def _dm_img(prefix, node):
suffix = getattr(node, "name", "node")
run = _safe_dm_token(os.environ.get("KEEPER_CLUSTER_NAME", ""))
if run:
return f"/var/lib/keeper_{prefix}_{run[:32]}_{suffix}.img"
return f"/var/lib/keeper_{prefix}_{suffix}.img"
def _kill_clickhouse(node, signal="STOP"):
"""Send signal to clickhouse processes."""
try:
sh(
node,
f'PIDS=$(pidof clickhouse clickhouse-server 2>/dev/null || true); [ -n "$PIDS" ] && kill -{signal} $PIDS || true',
timeout=30,
)
except Exception:
pass
def _restart_clickhouse(node):
"""Restart clickhouse process after it was killed. No-op for non-ClickHouse nodes."""
if not hasattr(node, "start_clickhouse"):
return
try:
node.start_clickhouse(start_wait_sec=90)
except Exception as e:
print(f"[dm_delay] _restart_clickhouse failed: {e}")
def _wait_dm_removed(node, dm_name, max_iter=80):
"""Wait for dm device to be removed."""
sh_root_strict(
node,
f"i=0; while dmsetup info {dm_name} >/dev/null 2>&1 && [ $i -lt {max_iter} ]; do sleep 0.1; i=$((i+1)); done; true",
timeout=30,
)
def _check_dm_exists(node, dm_name):
"""Check if dm device exists."""
try:
r = sh_root(node, f"dmsetup info {dm_name} >/dev/null 2>&1; echo $?", timeout=10)
if not str((r or {}).get("out", "")).strip().endswith("0"):
return False
r2 = sh_root(node, f"test -e /dev/mapper/{dm_name}; echo $?", timeout=10)
return str((r2 or {}).get("out", "")).strip().endswith("0")
except Exception:
return False
def _coord_mounted(node):
"""Check if /var/lib/clickhouse/coordination is still bind-mounted."""
try:
r = sh_root(node, "mountpoint -q /var/lib/clickhouse/coordination; echo $?", timeout=10)
return str((r or {}).get("out", "")).strip().endswith("0")
except Exception:
return False
@contextmanager
def dm_delay(node, ms=3):
# Declare before try so finally can always reference them safely.
dm_name = _dm_name("kdelay", node)
img = _dm_img("delay", node)
backend_dev = None
ram = None
sz = None
try:
# Prefer RAM block device if present inside container
ram = _ram_block_device(node)
backend_dev = ram if ram else _loop_device_for_image(node, img, 1024)
if not backend_dev:
raise AssertionError("dm_delay: cannot create loop device")
sz = sh_root_strict(node, f"blockdev --getsz {backend_dev}", timeout=30)["out"].strip()
# --- PRE-STOP: format and create dm device before stopping keeper ---
#
# We format the loop device (not the dm device with delay) because formatting
# through a delayed dm device takes O(num_blocks * delay_ms) time — with 3ms
# delay and 256 MB / 4 KB blocks that is ~200 seconds, far exceeding the 30s
# session_timeout_ms and causing all bench sessions to expire during setup.
if not ram:
sh_root_strict(node, f"wipefs -a {backend_dev} || true", timeout=60)
sh_root_strict(
node,
f"mkfs.ext4 -E nodiscard,lazy_itable_init=0,lazy_journal_init=0 -F {backend_dev}",
timeout=120,
)
sh_root_strict(
node,
"sync || true; (command -v udevadm >/dev/null 2>&1 && udevadm settle) || true",
timeout=60,
)
# Remove any stale dm device from a previous fault (before STOP; coordination
# is still mounted to original dir, so we only need to umount /mnt/{dm_name}).
sh_root_strict(
node,
f"umount /mnt/{dm_name} 2>/dev/null || true; dmsetup remove --retry -f {dm_name} || true",
timeout=90,
)
_wait_dm_removed(node, dm_name, 80)
if _check_dm_exists(node, dm_name):
sh_root_strict(node, f"dmsetup remove -f {dm_name} || true", timeout=60)
_wait_dm_removed(node, dm_name, 80)
if _check_dm_exists(node, dm_name):
raise AssertionError("dm_delay: dm device still exists after remove")
# NOTE: The dm device is created INSIDE the STOP window (below), not here.
#
# Reason: dmsetup create holds the backend loop device open. If we created
# the dm device here (PRE-STOP) and then tried to mount the loop device
# directly in the STOP window (for the fast coordination copy), Linux would
# refuse with EBUSY because the dm device already has the loop device open.
# Creating the dm device after the fast copy avoids this conflict.
# --- KILL window: kill keeper, set up dm, restart with fresh fds ---
#
# We use SIGKILL (not SIGSTOP/SIGCONT) to avoid the fd-split problem:
# with SIGSTOP+SIGCONT the process resumes with existing open fds pointing
# at the original docker overlay filesystem while new path-opens go to the
# dm ext4. After restart, Changelog detects the gap between the old fd-
# written log entries (up to index N) and the new path-opened entries
# (starting at N+100000+) and throws CORRUPTED_DATA.
# SIGKILL guarantees the process has no open fds, so after restart every
# write goes exclusively through dm ext4.
_kill_clickhouse(node, "KILL")
# Wait for the process to exit and release all fds before touching mounts.
sh(
node,
"for i in $(seq 40); do pidof clickhouse clickhouse-server 2>/dev/null || break; sleep 0.5; done; true",
timeout=25,
)
try:
sh_root_strict(node, "umount /var/lib/clickhouse/coordination || true", timeout=60)
sh_root_strict(node, f"mkdir -p /var/lib/clickhouse/coordination /mnt/{dm_name} || true", timeout=30)
if not ram:
# Mount the loop device DIRECTLY (bypassing dm delay) to copy data fast.
# The dm device does not exist yet so the loop device is free to mount.
sh_root_strict(
node,
f"mount -t ext4 -o rw,nodelalloc,data=writeback,noatime {backend_dev} /mnt/{dm_name}",
timeout=60,
)
sh_root(
node,
f"cp -a /var/lib/clickhouse/coordination/. /mnt/{dm_name}/ 2>/dev/null || true; sync || true",
timeout=180,
)
# Fix ext4 root ownership: mkfs.ext4 creates root:root root dir, but
# the bind mount maps this root dir to /var/lib/clickhouse/coordination.
# If the keeper process (non-root user) tries to create STATE_COPY_LOCK
# or other files directly in coordination/, it gets Permission denied.
# Copy ownership+mode from the original coordination dir to the ext4 root.
sh_root(
node,
(
f"_coord_owner=$(stat -c '%u:%g' /var/lib/clickhouse/coordination 2>/dev/null || echo ''); "
f"_coord_mode=$(stat -c '%a' /var/lib/clickhouse/coordination 2>/dev/null || echo ''); "
f"[ -n \"$_coord_owner\" ] && chown \"$_coord_owner\" /mnt/{dm_name}/ 2>/dev/null || true; "
f"[ -n \"$_coord_mode\" ] && chmod \"$_coord_mode\" /mnt/{dm_name}/ 2>/dev/null || true"
),
timeout=30,
)
sh_root_strict(node, f"umount /mnt/{dm_name}", timeout=60)
# Create the delay dm device (fast — just writes a kernel table entry).
try:
sh_root_strict(
node,
f"dmsetup create {dm_name} --table '0 {sz} delay {backend_dev} 0 {ms} {backend_dev} 0 {ms}'",
timeout=60,
)
except Exception as e:
raise AssertionError(f"dm_delay: dmsetup create failed: {e}") from e
sh_root_strict(node, f"dmsetup mknodes {dm_name} || dmsetup mknodes", timeout=60)
sh(
node,
f"i=0; while [ ! -e /dev/mapper/{dm_name} ] && [ $i -lt 50 ]; do sleep 0.1; i=$((i+1)); done; [ -e /dev/mapper/{dm_name} ] && echo ok || (dmsetup info {dm_name} && false)",
timeout=30,
)
if ram:
# RAM devices are already formatted; format via dm (RAM is instant anyway).
sh_root_strict(node, f"wipefs -a /dev/mapper/{dm_name} || true", timeout=60)
sh_root_strict(
node,
f"mkfs.ext4 -E nodiscard,lazy_itable_init=0,lazy_journal_init=0 -F /dev/mapper/{dm_name}",
timeout=120,
)
sh_root_strict(node, "sync || true", timeout=60)
# Mount the dm device (with delay) and bind it to coordination.
sh_root_strict(
node,
f"mount -t ext4 -o rw,nodelalloc,data=writeback,noatime /dev/mapper/{dm_name} /mnt/{dm_name}",
timeout=60,
)
sh_root_strict(
node,
f"mount --bind /mnt/{dm_name} /var/lib/clickhouse/coordination",
timeout=30,
)
# Restart keeper with fresh fds pointing entirely at dm ext4.
# All coordination I/O now goes through the delayed device.
try:
node.start_clickhouse(start_wait_sec=90)
except Exception as e:
raise AssertionError(f"dm_delay: keeper failed to restart after setup: {e}") from e
except Exception as e:
raise AssertionError(f"dm_delay: setup failed: {e}") from e
yield
finally:
# --- CLEANUP: SIGKILL instead of SIGSTOP to avoid stale fd EIO ---
#
# SIGSTOP + dmsetup remove -f leaves NuRaft file descriptors
# pointing at the removed block device. When the process resumes via SIGCONT
# those stale fds return EIO, crashing the keeper and expiring all sessions.
# SIGKILL causes the kernel to close all fds immediately, so the dm device
# can be cleanly dismantled before restarting keeper with a fresh fd table.
if backend_dev is not None:
_kill_clickhouse(node, "KILL")
# Wait for the process to actually exit so the kernel releases all dm-backed fds.
sh(
node,
"for i in $(seq 40); do pidof clickhouse clickhouse-server 2>/dev/null || break; sleep 0.5; done; true",
timeout=25,
)
# Replace dm delay table with a no-delay linear mapping before flushing.
# Without this, sync+umount must push every dirty page through the 3ms
# delay target, turning ~30s of buffered writes into ~90s of flush time
# and blowing the for_each_target deadline. A linear reload is instant
# (just a kernel table swap) so sync and umount complete in <5s.
if sz is not None and backend_dev is not None:
sh_root(
node,
(
f"dmsetup suspend {dm_name} 2>/dev/null || true; "
f"dmsetup load {dm_name} --table '0 {sz} linear {backend_dev} 0' 2>/dev/null || true; "
f"dmsetup resume {dm_name} 2>/dev/null || true"
),
timeout=30,
)
sh_root(
node,
(
f"sync || true; "
f"umount /var/lib/clickhouse/coordination 2>/dev/null || umount -l /var/lib/clickhouse/coordination 2>/dev/null || true; "
f"mountpoint -q /var/lib/clickhouse/coordination && umount -l /var/lib/clickhouse/coordination 2>/dev/null || true; "
f"umount /mnt/{dm_name} 2>/dev/null || umount -l /mnt/{dm_name} 2>/dev/null || true; "
f"dmsetup remove --retry -f {dm_name} || true"
),
timeout=90,
)
_wait_dm_removed(node, dm_name, 40)
if _check_dm_exists(node, dm_name):
raise AssertionError(
f"dm_delay: cleanup failed: dm device {dm_name} still exists on {node.name} after removal attempt"
)
# Copy coordination data back by mounting the loop device directly (no dm delay).
# This is fast even for large coordination directories.
#
# Notes:
# - Mount rw (not ro): ext4 journal recovery requires a read-write mount after
# an unclean shutdown. A read-only mount fails silently (|| true) when the
# journal hasn't been replayed, resulting in an empty /mnt/{dm_name} and an
# empty coordination directory that prevents keeper from starting.
# - Clear destination first: cp -a does not delete stale files. Leftover
# changelog/snapshot files from before the fault confuse keeper on restart.
if backend_dev and not ram:
sh_root_strict(node, f"mkdir -p /var/lib/clickhouse/coordination /mnt/{dm_name}", timeout=10)
# Strict mount: a silently-failed mount means cp reads an empty
# mountpoint and restores an empty coordination directory, causing
# keeper to fail to start without any visible error.
sh_root_strict(node, f"mount -t ext4 -o rw {backend_dev} /mnt/{dm_name}", timeout=30)
r_ls = sh_root(node, f"ls -la /mnt/{dm_name}/ 2>&1", timeout=10)
print(f"[dm_delay][copyback][{node.name}] loop files: {(r_ls or {}).get('out', '')!r}")
# Clear stale pre-fault files, then copy back — both strict so silent
# failure cannot silently leave an empty coordination directory.
sh_root_strict(node, "find /var/lib/clickhouse/coordination -mindepth 1 -delete", timeout=30)
sh_root_strict(node, f"cp -a /mnt/{dm_name}/. /var/lib/clickhouse/coordination/", timeout=60)
r_coord = sh_root(node, "ls -la /var/lib/clickhouse/coordination/ 2>&1", timeout=10)
print(f"[dm_delay][copyback][{node.name}] coord files: {(r_coord or {}).get('out', '')!r}")
sh_root(node, f"sync || true; umount /mnt/{dm_name} 2>/dev/null || true", timeout=30)
# Restore coordination dir ownership from parent (/var/lib/clickhouse)
# in case it was recreated as root:root during cleanup teardown.
sh_root(
node,
"chown $(stat -c '%u:%g' /var/lib/clickhouse 2>/dev/null || echo '0:0') "
" /var/lib/clickhouse/coordination 2>/dev/null || true",
timeout=10,
)
else:
sh_root(node, "mkdir -p /var/lib/clickhouse/coordination || true", timeout=10)
if backend_dev is not None:
_restart_clickhouse(node)
_detach_loop_for_image(node, img)
sh_root(node, f"rm -f {img} || true", timeout=30)
def _create_emit_fn(kind, ctx):
"""Create an emit function for fault events."""
lock = threading.Lock()
def _emit(node, phase, extra=None):
try:
ev = {
"ts": ts_ms(),
"kind": kind,
"node": str(getattr(node, "name", "")),
"phase": str(phase),
}
if isinstance(extra, dict) and extra:
ev.update(extra)
with lock:
(ctx.setdefault("fault_events", [])).append(ev)
except Exception:
pass
return _emit
@register_fault("dm_delay")
def _f_dm_delay(ctx, nodes, leader, step):
dur = int(step.get("duration_s", DEFAULT_FAULT_DURATION_S))
ms = step.get("ms", 3)
_emit = _create_emit_fn("dm_delay", ctx)
def _run_one(t):
dm_name = _dm_name("kdelay", t)
t0 = _time.time()
_emit(t, "start", {"duration_s": int(dur), "ms": ms})
with dm_delay(t, ms):
_emit(t, "apply_ok")
if dur >= 4:
early = min(2.0, float(dur) / 4.0)
_time.sleep(max(0.0, early))
if not _check_dm_exists(t, dm_name):
_emit(t, "active_verify_failed", {"when": "early"})
raise AssertionError("dm_delay active verify failed (early)")
_emit(t, "active_ok", {"when": "early"})
remaining = max(0.0, float(dur) - early)
late = min(2.0, max(0.0, remaining / 2.0))
_time.sleep(max(0.0, remaining - late))
if not _check_dm_exists(t, dm_name):
_emit(t, "active_verify_failed", {"when": "late"})
raise AssertionError("dm_delay active verify failed (late)")
_emit(t, "active_ok", {"when": "late"})
_time.sleep(max(0.0, late))
else:
_time.sleep(dur)
if _check_dm_exists(t, dm_name):
_emit(t, "cleanup_verify_failed")
raise AssertionError("dm_delay cleanup verify failed")
if _coord_mounted(t):
_emit(t, "cleanup_verify_failed", {"reason": "coordination_still_mounted"})
raise AssertionError("dm_delay cleanup verify failed: coordination still mounted")
_emit(t, "cleanup_ok", {"elapsed_s": _time.time() - t0})
# dm_delay cleanup (kill keeper + flush + copy-back + restart) takes much
# longer than the default 60s parallel slack. Set a large slack so the
# for_each_target deadline covers the full cleanup+restart cycle.
step = {**step, "target_parallel_slack_s": step.get("target_parallel_slack_s", 300.0)}
for_each_target(step, nodes, leader, _run_one)
try:
targets = resolve_targets(step.get("on", "leader"), nodes, leader)
except Exception:
targets = []
evs = (ctx or {}).get("fault_events") or []
cleaned = {
e.get("node")
for e in evs
if e.get("kind") == "dm_delay" and e.get("phase") == "cleanup_ok"
}
expected = {str(getattr(t, "name", "")) for t in (targets or []) if str(getattr(t, "name", ""))}
missing = sorted([n for n in expected if n and n not in cleaned])
if missing:
raise AssertionError(
"dm_delay did not complete cleanup for targets: " + ", ".join(missing)
)