376 lines
16 KiB
Python
376 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import concurrent.futures
|
|
import json
|
|
import pathlib
|
|
import secrets
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
# 在隔离容器和数据卷验证持久化、冲突、崩溃、磁盘满及备份恢复。
|
|
|
|
|
|
# 执行有超时的 Docker 命令并保留失败诊断。
|
|
def docker(*args, **kwargs):
|
|
try:
|
|
return subprocess.run(
|
|
["docker", *args], check=True, capture_output=True, timeout=60, **kwargs
|
|
).stdout
|
|
except subprocess.CalledProcessError as error:
|
|
diagnostic = error.stderr.decode(errors="replace") if isinstance(error.stderr, bytes) else error.stderr
|
|
raise RuntimeError(diagnostic) from error
|
|
|
|
|
|
class Client:
|
|
# 建立独立会话并验证协议及登录。
|
|
def __init__(self, port, password, user="root"):
|
|
self.socket = socket.create_connection(("127.0.0.1", port), timeout=15)
|
|
self.stream = self.socket.makefile("rwb")
|
|
try:
|
|
assert self.request(method="hello", protocol=1)["status"] == "hello"
|
|
assert self.request(method="login", user=user, password=password)["status"] == "ok"
|
|
except BaseException:
|
|
self.close()
|
|
raise
|
|
|
|
# 发送一条请求,不重试结果未知的写入。
|
|
def request(self, **request):
|
|
self.stream.write(json.dumps(request).encode() + b"\n")
|
|
self.stream.flush()
|
|
raw = self.stream.readline(4 * 1024 * 1024)
|
|
if not raw:
|
|
raise ConnectionError("server closed the connection")
|
|
return json.loads(raw)
|
|
|
|
# 执行 SQL,保留错误码以识别事务冲突。
|
|
def sql(self, sql):
|
|
reply = self.request(method="query", sql=sql)
|
|
if reply["status"] == "error":
|
|
raise QueryError(reply)
|
|
assert reply["status"] == "result", reply
|
|
return reply
|
|
|
|
# 关闭流及底层套接字。
|
|
def close(self):
|
|
try:
|
|
self.stream.close()
|
|
finally:
|
|
self.socket.close()
|
|
|
|
|
|
class QueryError(Exception):
|
|
# 保存服务端结构化错误。
|
|
def __init__(self, reply):
|
|
self.reply = reply
|
|
super().__init__(str(reply))
|
|
|
|
|
|
# 仅等待真实服务登录成功,不用固定启动延时。
|
|
def ready(container, password):
|
|
deadline = time.monotonic() + 45
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
port = int(docker("port", container, "7777/tcp", text=True).strip().rsplit(":", 1)[1])
|
|
client = Client(port, password)
|
|
client.close()
|
|
return port
|
|
except (OSError, ValueError, AssertionError, subprocess.CalledProcessError):
|
|
time.sleep(0.2)
|
|
raise TimeoutError(f"server did not become ready: {container}")
|
|
|
|
|
|
# 连接验收库。
|
|
def connect(port, password):
|
|
client = Client(port, password)
|
|
try:
|
|
client.sql("USE acceptance")
|
|
return client
|
|
except BaseException:
|
|
client.close()
|
|
raise
|
|
|
|
|
|
# 验证事务冲突不会丢失更新,失败后显式回滚。
|
|
def conflict(port, password):
|
|
first = connect(port, password)
|
|
second = connect(port, password)
|
|
try:
|
|
for client in (first, second):
|
|
client.sql("BEGIN")
|
|
client.sql("UPDATE counters SET value = value + 1 WHERE id = 1")
|
|
first.sql("COMMIT")
|
|
try:
|
|
second.sql("COMMIT")
|
|
except QueryError as error:
|
|
assert error.reply["code"] == "serialization_failure", error.reply
|
|
second.sql("ROLLBACK")
|
|
else:
|
|
raise AssertionError("conflicting update unexpectedly committed")
|
|
assert first.sql("SELECT value FROM counters WHERE id = 1")["rows"] == [[1]]
|
|
first.sql("UPDATE counters SET value = 0 WHERE id = 1")
|
|
finally:
|
|
first.close()
|
|
second.close()
|
|
|
|
|
|
# 在独立会话中持续提交关联写入并核对已确认提交。
|
|
def workload(port, password, seconds, stop, acknowledged, lock, worker):
|
|
client = connect(port, password)
|
|
deadline = time.monotonic() + seconds
|
|
sequence = 0
|
|
conflicts = 0
|
|
try:
|
|
while not stop.is_set() and time.monotonic() < deadline:
|
|
sequence += 1
|
|
identity = worker * 1_000_000 + sequence
|
|
client.sql("BEGIN")
|
|
client.sql(f"INSERT INTO events (id, actor) VALUES ({identity}, {worker})")
|
|
client.sql("UPDATE counters SET value = value + 1 WHERE id = 1")
|
|
try:
|
|
client.sql("COMMIT")
|
|
except QueryError as error:
|
|
if error.reply["code"] != "serialization_failure":
|
|
raise
|
|
conflicts += 1
|
|
client.sql("ROLLBACK")
|
|
continue
|
|
with lock:
|
|
acknowledged.add(identity)
|
|
except (OSError, ConnectionError, ValueError):
|
|
if not stop.is_set():
|
|
raise
|
|
finally:
|
|
try:
|
|
client.close()
|
|
except OSError:
|
|
if not stop.is_set():
|
|
raise
|
|
return conflicts
|
|
|
|
|
|
# 核对事务组完整性及已确认提交,允许断线提交结果未知。
|
|
def verify(port, password, acknowledged):
|
|
client = connect(port, password)
|
|
try:
|
|
rows = []
|
|
last = -1
|
|
while True:
|
|
page = client.sql(f"SELECT id FROM events WHERE id > {last} ORDER BY id LIMIT 500")
|
|
assert not page["truncated"], page
|
|
rows.extend(page["rows"])
|
|
if len(page["rows"]) < 500:
|
|
break
|
|
last = page["rows"][-1][0]
|
|
identities = {row[0] for row in rows}
|
|
assert len(identities) == len(rows), "duplicate committed event"
|
|
assert acknowledged <= identities, "acknowledged commits were lost"
|
|
assert client.sql("SELECT value FROM counters WHERE id = 1")["rows"] == [[len(rows)]]
|
|
return len(rows)
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
# 验证恢复后的账号和最小授权,不授予写权限。
|
|
def verify_reader(port, password):
|
|
client = Client(port, password, user="backup_reader")
|
|
try:
|
|
client.sql("USE acceptance")
|
|
client.sql("SELECT id FROM events")
|
|
reply = client.request(method="query", sql="DELETE FROM events")
|
|
assert reply["status"] == "error" and reply["code"] == "forbidden", reply
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
# 运行有资源上限的容器,并登记待清理名称。
|
|
def launch(image, name, volume, secret, containers):
|
|
containers.append(name)
|
|
docker(
|
|
"run", "-d", "--name", name, "--cpus", "1", "--memory", "512m",
|
|
"--memory-swap", "512m", "--pids-limit", "128",
|
|
"--tmpfs", "/fault:rw,size=16m,uid=10001,gid=10001,mode=0700",
|
|
"-p", "127.0.0.1::7777", "-v", f"{volume}:/var/lib/chusql",
|
|
"-v", f"{secret}:/run/secrets/admin_password:ro", image,
|
|
)
|
|
|
|
|
|
# 清理所有测试资源,缺失对象除外均明确报错。
|
|
def cleanup(containers, volumes):
|
|
errors = []
|
|
for kind, names in (("container", containers), ("volume", volumes)):
|
|
for name in reversed(names):
|
|
try:
|
|
docker(kind, "rm", *(["-f"] if kind == "container" else []), name)
|
|
except RuntimeError as error:
|
|
if f"No such {kind}" not in str(error):
|
|
errors.append(str(error))
|
|
if errors:
|
|
raise RuntimeError("test resource cleanup failed: " + "; ".join(errors))
|
|
|
|
|
|
# 注入真实 ENOSPC,验证提交后故障封闭与重放。
|
|
def disk_full(container, port, password):
|
|
directory = "/var/lib/chusql/databases/acceptance"
|
|
docker("exec", container, "sh", "-ec",
|
|
f"touch /fault/checkpoint; ln -s /fault/checkpoint {directory}/wal.checkpoint.pending")
|
|
filled = subprocess.run(
|
|
["docker", "exec", container, "sh", "-c", "LC_ALL=C dd if=/dev/zero of=/fault/fill bs=4096"],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
assert filled.returncode != 0 and "No space left on device" in filled.stderr, filled.stderr
|
|
client = connect(port, password)
|
|
try:
|
|
reply = client.request(method="query", sql="INSERT INTO faults (id) VALUES (1)")
|
|
assert reply["status"] == "error" and "space" in reply["message"].lower(), reply
|
|
blocked = client.request(method="query", sql="SELECT id FROM faults")
|
|
assert blocked["status"] == "error" and "recovery" in blocked["message"].lower(), blocked
|
|
docker("exec", container, "sh", "-ec", f"test -s {directory}/wal.log")
|
|
finally:
|
|
client.close()
|
|
docker("exec", container, "sh", "-ec",
|
|
f"rm -f /fault/fill {directory}/wal.checkpoint.pending")
|
|
docker("restart", "-t", "10", container)
|
|
port = ready(container, password)
|
|
client = connect(port, password)
|
|
try:
|
|
assert client.sql("SELECT id FROM faults")["rows"] == [[1]]
|
|
client.sql("INSERT INTO faults (id) VALUES (2)")
|
|
finally:
|
|
client.close()
|
|
return port
|
|
|
|
|
|
# 验收完整镜像,清理仅限本次创建的容器及卷。
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Validate an already built ChuSQL image in isolated containers.")
|
|
parser.add_argument("--image", default="chusql:local")
|
|
parser.add_argument("--seconds", type=int, default=60)
|
|
args = parser.parse_args()
|
|
if args.seconds < 5:
|
|
parser.error("--seconds must be at least 5")
|
|
name = "chusql-acceptance-" + secrets.token_hex(4)
|
|
containers = []
|
|
volumes = []
|
|
password = "Aa1!" + secrets.token_urlsafe(30)
|
|
reader_password = "Aa1!" + secrets.token_urlsafe(30)
|
|
acknowledged = set()
|
|
lock = threading.Lock()
|
|
stop = threading.Event()
|
|
with tempfile.TemporaryDirectory(prefix="chusql-acceptance-") as temporary:
|
|
secret = pathlib.Path(temporary) / "password"
|
|
secret.write_text(password + "\n")
|
|
secret.chmod(0o644)
|
|
try:
|
|
image = json.loads(docker("image", "inspect", args.image))[0]
|
|
assert image["Config"]["User"] == "10001:10001", image["Config"]["User"]
|
|
print(f"image={image['Id']} repo_digests={image['RepoDigests']}", flush=True)
|
|
for suffix in ("data", "restore"):
|
|
volume = name + "-" + suffix
|
|
docker("volume", "create", volume)
|
|
volumes.append(volume)
|
|
server = name + "-server"
|
|
launch(args.image, server, volumes[0], secret, containers)
|
|
port = ready(server, password)
|
|
client = Client(port, password)
|
|
try:
|
|
for sql in (
|
|
"CREATE DATABASE acceptance", "USE acceptance",
|
|
"CREATE TABLE counters (id INT PRIMARY KEY, value INT)",
|
|
"CREATE TABLE events (id INT PRIMARY KEY, actor INT)",
|
|
"CREATE TABLE faults (id INT PRIMARY KEY)",
|
|
"INSERT INTO counters (id, value) VALUES (1, 0)",
|
|
f"CREATE USER backup_reader IDENTIFIED BY '{reader_password}'",
|
|
"GRANT SELECT ON acceptance.events TO backup_reader",
|
|
):
|
|
client.sql(sql)
|
|
finally:
|
|
client.close()
|
|
conflict(port, password)
|
|
verify_reader(port, reader_password)
|
|
print("conflicting transactions and rollback: passed", flush=True)
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
|
|
tasks = [pool.submit(workload, port, password, args.seconds, stop, acknowledged, lock, i)
|
|
for i in range(1, 4)]
|
|
conflicts = sum(task.result(timeout=args.seconds + 30) for task in tasks)
|
|
assert len(acknowledged) >= 3, "insufficient successful load transactions"
|
|
count = verify(port, password, acknowledged)
|
|
print(f"load: {args.seconds}s, {count} committed transactions, {conflicts} conflicts", flush=True)
|
|
uncommitted = connect(port, password)
|
|
uncommitted.sql("BEGIN")
|
|
uncommitted.sql("INSERT INTO faults (id) VALUES (99)")
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
tasks = [pool.submit(workload, port, password, 30, stop, acknowledged, lock, i)
|
|
for i in (10, 11)]
|
|
deadline = time.monotonic() + 20
|
|
while time.monotonic() < deadline:
|
|
with lock:
|
|
progressed = len(acknowledged) >= count + 5
|
|
if progressed:
|
|
break
|
|
if any(task.done() for task in tasks):
|
|
for task in tasks:
|
|
if task.done():
|
|
task.result()
|
|
raise AssertionError("crash workload ended before progress")
|
|
time.sleep(0.02)
|
|
assert progressed, "crash workload made no progress"
|
|
stop.set()
|
|
docker("kill", "--signal", "KILL", server)
|
|
for task in tasks:
|
|
task.result(timeout=20)
|
|
uncommitted.close()
|
|
docker("start", server)
|
|
port = ready(server, password)
|
|
count = verify(port, password, acknowledged)
|
|
client = connect(port, password)
|
|
try:
|
|
assert client.sql("SELECT id FROM faults")["rows"] == [], "uncommitted data survived crash"
|
|
finally:
|
|
client.close()
|
|
print(f"SIGKILL recovery: {count} complete transactions, all acknowledged commits retained", flush=True)
|
|
port = disk_full(server, port, password)
|
|
verify(port, password, acknowledged)
|
|
print("ENOSPC after WAL commit, recovery fence and replay: passed", flush=True)
|
|
secret.write_text(secrets.token_urlsafe(30) + "\n")
|
|
docker("restart", "-t", "10", server)
|
|
port = ready(server, password)
|
|
verify(port, password, acknowledged)
|
|
print("idempotent bootstrap preserves existing password and data: passed", flush=True)
|
|
docker("stop", "-t", "10", server)
|
|
archive = docker("run", "--rm", "--network", "none", "--memory", "128m", "--cpus", "1",
|
|
"-v", f"{volumes[0]}:/var/lib/chusql:ro", "--entrypoint", "tar",
|
|
args.image, "-C", "/var/lib/chusql", "-czf", "-", ".")
|
|
docker("run", "--rm", "-i", "--network", "none", "--memory", "128m", "--cpus", "1",
|
|
"-v", f"{volumes[1]}:/var/lib/chusql", "--entrypoint", "tar", args.image,
|
|
"-C", "/var/lib/chusql", "-xzf", "-", input=archive)
|
|
restored = name + "-restored"
|
|
launch(args.image, restored, volumes[1], secret, containers)
|
|
port = ready(restored, password)
|
|
assert verify(port, password, acknowledged) == count
|
|
verify_reader(port, reader_password)
|
|
client = connect(port, password)
|
|
try:
|
|
assert client.sql("SELECT id FROM faults ORDER BY id")["rows"] == [[1], [2]]
|
|
finally:
|
|
client.close()
|
|
print("cold backup restores into an empty volume with original credentials: passed", flush=True)
|
|
for container in containers:
|
|
state = json.loads(docker("inspect", container))[0]
|
|
assert not state["State"]["OOMKilled"], container
|
|
assert state["HostConfig"]["Memory"] == 512 * 1024 * 1024
|
|
print("runtime acceptance: passed (1 CPU / 512 MiB per server)", flush=True)
|
|
except BaseException:
|
|
for container in containers:
|
|
result = subprocess.run(["docker", "logs", "--tail", "40", container], capture_output=True, text=True, timeout=15)
|
|
print(result.stdout, result.stderr, flush=True)
|
|
raise
|
|
finally:
|
|
cleanup(containers, volumes)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|