Files
ClickHouse/tests/protocol_client.py
Alexey MilovidovandClaude Opus 5.5 a6a73f4516 Address review: disable http_write_exception_in_output_format in the HTTP runner of protocol_client.py
With `http_write_exception_in_output_format = 1`, an exception after the HTTP
response has started is written in the output format (e.g. an `exception`
object of `JSONEachRowWithProgress`) in a complete response with status 200,
without the tagged `__exception__` block, so `HTTPClient` took it for data.
Pass `http_write_exception_in_output_format = 0` with every request, so the
exception always comes as the tagged block, and do not run the tests that set
the setting themselves over HTTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:25:11 +00:00

1189 lines
47 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run the queries of a `.sql` test, read from stdin, over a protocol other than the native one.
`clickhouse-test --protocols` uses it to run every `.sql` test over a random protocol, so that the
same randomized and fuzzed workload that the Stress test sends over the native protocol reaches the
other protocol handlers too. The output is not expected to match the `.reference` files: every
protocol formats results its own way. What matters is how the server behaves.
Like `clickhouse-client` in a `.sql` test, it runs the queries one by one in one session and stops
at the first error, unless the query carries a `-- { serverError ... }`, `-- { clientError ... }` or
`-- { error ... }` hint with the code of the error. It also stops when a query with such a hint
succeeds. The `ssh` and `webterminal` protocols run the embedded client inside the server, which parses
the queries and applies the hints itself.
The non-standard Python modules (`grpc`, `pyarrow`) are imported only by the protocols that need them.
"""
import argparse
import base64
import hashlib
import http.client
import json
import os
import re
import shutil
import signal
import socket
import ssl
import struct
import subprocess
import sys
import tempfile
import urllib.parse
import uuid
PROTOCOLS = [
"http",
"https",
"mysql",
"postgresql",
"grpc",
"arrowflight",
"ssh",
"webterminal",
]
# The server-side end of a query is governed by the test timeout of `clickhouse-test`, which kills
# this process. These timeouts only bound waiting on a peer that went silent.
SOCKET_TIMEOUT = 600
# `ssh` passes the whole test as the command of the channel, which travels in one `argv` element of
# the `ssh` client; Linux limits one element to 128 KiB (`MAX_ARG_STRLEN`).
SSH_MAX_QUERY_SIZE = 100 * 1024
# A test of at most this size leaves enough room in `SSH_MAX_QUERY_SIZE` for the setup queries.
SSH_MAX_TEST_SIZE = 90 * 1024
# The protocols that carry arbitrary bytes of a query: a NUL byte or invalid UTF-8 in it. The others
# need a valid string: gRPC and Arrow Flight send it in a protobuf `string`, PostgreSQL terminates it
# with a NUL byte, SSH passes it in an `argv` element, and the web terminal pastes it into a line editor.
BINARY_PROTOCOLS = {"http", "https", "mysql"}
class QueryError(Exception):
def __init__(self, message, code=None):
super().__init__(message)
# The ClickHouse error code, if the protocol reports it; otherwise it is taken from the
# `Code: N.` prefix of the message, if there is one.
if code is None and (match := CODE_RE.search(message)):
code = int(match.group(1))
self.code = code
CODE_RE = re.compile(r"\bCode: (\d+)\.")
def env_port(name, default):
return int(os.environ.get(name) or default)
def sql_literal(value):
"""Quote the value of a setting or a query parameter for `SET name = value`. Like
`clickhouse-client` does with a value from its command line, the server parses the string."""
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
def parse_key_values(items):
result = []
for item in items:
key, sep, value = item.partition("=")
if not sep:
raise ValueError(f"Expected name=value, got {item!r}")
result.append((key, value))
return result
# Splitting the test into queries.
#
# This mirrors what `ClientBase` does in a multi-query: the queries are separated by top-level
# semicolons, while string literals, quoted identifiers, comments and heredocs may contain anything.
# The data of `INSERT ... FORMAT <not Values>` lasts until an empty line, as in `ClientBase`.
HINT_RE = re.compile(r"\{\s*(serverError|clientError|error)\b")
# The list of the error codes or names of the hint, separated by commas, as `TestHint` parses it.
ERROR_HINT_RE = re.compile(r"\{\s*(?:serverError|clientError|error)\b([^}]*)\}")
# `ParserInsertQuery` also accepts a `WITH` clause before `INSERT`.
INSERT_RE = re.compile(r"\s*(EXPLAIN|INSERT|WITH)\b", re.IGNORECASE)
FORMAT_RE = re.compile(r"FORMAT\s+(\w+)\b", re.IGNORECASE)
HEREDOC_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)?\$")
def skip_quoted(text, pos, quote):
"""Return the position after the literal that starts at `pos` with `quote`."""
pos += 1
while pos < len(text):
char = text[pos]
if char == "\\":
pos += 2
continue
if char == quote:
if pos + 1 < len(text) and text[pos + 1] == quote:
pos += 2
continue
return pos + 1
pos += 1
return pos
def skip_block_comment(text, pos):
depth = 0
while pos < len(text):
if text.startswith("/*", pos):
depth += 1
pos += 2
elif text.startswith("*/", pos):
depth -= 1
pos += 2
if depth == 0:
return pos
else:
pos += 1
return pos
def is_line_comment(text, pos):
if text.startswith("--", pos):
return True
# `#` starts a comment only when followed by a space or `!`, as in the ClickHouse lexer.
return text[pos] == "#" and (pos + 1 == len(text) or text[pos + 1] in " !")
def split_queries(text):
"""Split the text into `(query, hint)` pairs. The hint is the comment that follows the query on
the line of its terminating semicolon, as in `ClientBase`."""
queries = []
begin = 0
pos = 0
has_tokens = False
while pos < len(text):
char = text[pos]
if char in "'\"`":
pos = skip_quoted(text, pos, char)
has_tokens = True
elif text.startswith("/*", pos):
pos = skip_block_comment(text, pos)
elif is_line_comment(text, pos):
end = text.find("\n", pos)
pos = len(text) if end == -1 else end
elif char == "$" and (heredoc := HEREDOC_RE.match(text, pos)):
end = text.find(heredoc.group(0), heredoc.end())
pos = len(text) if end == -1 else end + len(heredoc.group(0))
has_tokens = True
elif char == ";":
pos = finish_query(text, begin, pos, queries, has_tokens)
begin = pos
has_tokens = False
elif (insert_format := match_insert_format(text, begin, pos)) is not None:
# The data of an `INSERT ... FORMAT <not Values>` starts right after the format name, on
# the same line or the next ones, and ends at an empty line, regardless of semicolons in it.
end = text.find("\n\n", insert_format)
end = len(text) if end == -1 else end
# Only the SQL before the data is trimmed: whitespace at the end of the data can be a
# field separator or a part of a value.
queries.append((text[begin:end].lstrip(), ""))
begin = pos = end
has_tokens = False
else:
if not char.isspace():
has_tokens = True
pos += 1
if has_tokens:
queries.append((text[begin:].strip(), ""))
return queries
def match_insert_format(text, begin, pos):
"""If the `FORMAT <name>` of an `INSERT` with inline data in a format other than `Values` starts at
`pos`, return the position after the format name, like `ParserInsertQuery`, otherwise `None`."""
if pos > 0 and (text[pos - 1].isalnum() or text[pos - 1] == "_"):
return None
insert_format = FORMAT_RE.match(text, pos)
if not insert_format or insert_format.group(1) == "Values":
return None
# A `;` right after the format name is a syntax error the server reports.
if text.startswith(";", insert_format.end()):
return None
statement = text[begin:pos]
if not INSERT_RE.match(strip_comments(statement)):
return None
# Only the format of an `INSERT` (possibly explained) starts the data, not the format of a nested
# query, like in `EXPLAIN AST (SELECT 1 FORMAT TSV)` or `EXPLAIN SELECT 1 FORMAT TSV`.
words = set(top_level_words(statement))
if "INSERT" not in words or not is_top_level_end(statement):
return None
# The format of `WITH ... SELECT ... FORMAT TSV` does not start any data.
if next(top_level_words(statement), None) == "WITH" and returns_result(statement):
return None
# The rows of `INSERT ... FROM INFILE` and of `INSERT ... SELECT` do not follow the format name,
# unless the `SELECT` reads them with the `input` table function.
if "INFILE" in words or ("SELECT" in words and not reads_input(statement)):
return None
return insert_format.end()
def finish_query(text, begin, pos, queries, has_tokens):
"""Record the query that ends with the semicolon at `pos`, return where the next one begins."""
query = text[begin:pos].strip()
end = pos + 1
hint_end = trailing_hint_end(text, end)
if hint_end is not None and HINT_RE.search(text, end, hint_end):
hint = text[end:hint_end]
end = hint_end
else:
hint = ""
if has_tokens:
queries.append((query, hint))
return end
def trailing_hint_end(text, pos):
"""Like `ClientBase::adjustQueryEnd`: the rest of the line after the semicolon, together with
more semicolons, belongs to the query if no next query starts on this line. Return its end, or
`None` if the next query starts on this line, like in `SELECT 1; SELECT throwIf(1); -- { serverError
FUNCTION_THROW_IF_VALUE_IS_NON_ZERO }`, where the hint belongs to the second query."""
newline = text.find("\n", pos)
line_end = len(text) if newline == -1 else newline
while pos < len(text):
if text[pos].isspace() or text[pos] == ";":
pos += 1
elif text.startswith("/*", pos):
comment_end = skip_block_comment(text, pos)
# A comment that continues on the next lines belongs to the query as a whole.
if pos < line_end < comment_end:
line_end = comment_end
pos = comment_end
elif is_line_comment(text, pos):
newline = text.find("\n", pos)
pos = len(text) if newline == -1 else newline
else:
break
return line_end if pos >= line_end else None
def is_top_level_end(statement):
"""Whether the end of the statement is outside of parentheses."""
query = strip_comments(statement)
depth = 0
pos = 0
while pos < len(query):
char = query[pos]
if char in "'\"`":
pos = skip_quoted(query, pos, char)
continue
if char == "(":
depth += 1
elif char == ")":
depth -= 1
pos += 1
return depth == 0
def strip_comments(query):
"""Remove the comments from the query; the line structure of the remaining text is kept."""
result = []
pos = 0
while pos < len(query):
char = query[pos]
if char in "'\"`":
end = skip_quoted(query, pos, char)
result.append(query[pos:end])
pos = end
elif query.startswith("/*", pos):
pos = skip_block_comment(query, pos)
result.append(" ")
elif is_line_comment(query, pos):
end = query.find("\n", pos)
pos = len(query) if end == -1 else end
else:
result.append(char)
pos += 1
return "".join(result).strip()
def iter_comments(query):
"""Yield the comments of the query, each with whether it precedes the first token."""
leading = True
pos = 0
while pos < len(query):
char = query[pos]
if char in "'\"`":
pos = skip_quoted(query, pos, char)
leading = False
elif query.startswith("/*", pos):
end = skip_block_comment(query, pos)
yield query[pos:end], leading
pos = end
elif is_line_comment(query, pos):
end = query.find("\n", pos)
end = len(query) if end == -1 else end
yield query[pos:end], leading
pos = end
else:
if not char.isspace():
leading = False
pos += 1
def expected_errors(query, hint):
"""The error codes and names that the hint of the query expects, or `None` if the query is
expected to succeed. Like `TestHint`, it takes the hints from the comments in the query and
after it, except the leading comments, and the last hint wins.
The protocols have no client side that could fail by itself, so a `clientError` hint is
satisfied by a server error with one of its codes, the same as `serverError`: what the native
client rejects before sending, like a syntax error, the server rejects with the same code."""
comments = list(iter_comments(query)) + [(comment, False) for comment, _ in iter_comments(hint)]
expected = None
for comment, leading in comments:
if leading:
continue
if match := ERROR_HINT_RE.search(comment):
expected = [item.strip() for item in match.group(1).split(",") if item.strip()]
return expected
class ErrorCodes:
"""The codes of the errors by their names, from `system.errors` of the server, loaded on the
first use. It is queried over HTTP regardless of the protocol of the test."""
def __init__(self, host):
self.host = host
self.codes = None
def resolve(self, item):
if item.isdigit():
return int(item)
if self.codes is None:
connection = http.client.HTTPConnection(
self.host, env_port("CLICKHOUSE_PORT_HTTP", 8123), timeout=SOCKET_TIMEOUT
)
query = "SELECT name, code FROM system.errors SETTINGS system_events_show_zero_values = 1"
connection.request("POST", "/", body=query.encode())
response = connection.getresponse()
data = response.read().decode()
connection.close()
if response.status != 200:
raise QueryError(f"Cannot load the error codes from system.errors: {data}")
self.codes = {name: int(code) for name, code in (line.split("\t") for line in data.splitlines())}
if item not in self.codes:
raise QueryError(f"Unknown error name {item!r} in the test hint")
return self.codes[item]
def check_error(client, error, expected, error_codes):
"""Return the description of the mismatch of the error with the hint, or `None` if it matches."""
codes = [error_codes.resolve(item) for item in expected]
if error.code is None and not client.reports_error_codes:
# Nothing to compare with: the protocol did not report the code of this error.
return None
if error.code is None:
return f"Expected an error with the code in {expected}, got an error without a code: {error}"
if error.code not in codes:
return f"Expected an error with the code in {expected}, got the code {error.code}: {error}"
return None
RESULT_KEYWORDS = {"SELECT", "WITH", "SHOW", "DESC", "DESCRIBE", "EXPLAIN", "EXISTS", "CHECK"}
WORD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
def top_level_words(query, any_depth=False):
"""Yield the upper-cased words of the query outside of literals, comments and, unless
`any_depth`, parentheses."""
query = strip_comments(query)
depth = 0
pos = 0
while pos < len(query):
char = query[pos]
if char in "'\"`":
pos = skip_quoted(query, pos, char)
elif char == "(":
depth += 1
pos += 1
elif char == ")":
depth -= 1
pos += 1
elif word := WORD_RE.match(query, pos):
if depth == 0 or any_depth:
yield word.group(0).upper()
pos = word.end()
else:
pos += 1
INPUT_FUNCTION_RE = re.compile(r"\binput\s*\(", re.IGNORECASE)
def reads_input(statement):
"""Whether the statement calls the `input` table function, which reads the inline data."""
if "INPUT" not in top_level_words(statement, any_depth=True):
return False
# Literals and quoted identifiers become empty, so that only an actual call matches.
text = strip_comments(statement)
unquoted = []
pos = 0
while pos < len(text):
char = text[pos]
if char in "'\"`":
end = skip_quoted(text, pos, char)
unquoted.append(char + char)
pos = end
else:
unquoted.append(char)
pos += 1
return INPUT_FUNCTION_RE.search("".join(unquoted)) is not None
def returns_result(query):
words = top_level_words(query)
first = next(words, None)
if first == "WITH":
# `WITH ... SELECT` returns a result, while `WITH ... INSERT` does not.
return next((word for word in words if word in ("SELECT", "INSERT")), "SELECT") == "SELECT"
return first in RESULT_KEYWORDS
def format_value(value):
if value is None:
return "\\N"
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
return str(value).replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n")
def format_rows(rows):
return "".join("\t".join(format_value(value) for value in row) + "\n" for row in rows)
class QueryClient:
"""A protocol on which `clickhouse-test` runs the queries one by one."""
# Whether the settings and the query parameters are passed with the protocol rather than with `SET`.
native_settings = False
native_params = False
# Whether the protocol reports the code of every error, so that it can be checked against the
# test hint. An error without a code from such a protocol does not satisfy any hint.
reports_error_codes = True
def __init__(self, args):
self.host = args.host
self.user = args.user
self.database = args.database
self.settings = args.settings
self.params = args.params
def setup_queries(self):
return setup_queries(
[] if self.native_settings else self.settings, [] if self.native_params else self.params
)
def connect(self):
pass
def execute(self, query):
"""Run the query, return its result as bytes or raise `QueryError`."""
raise NotImplementedError
def close(self):
pass
def setup_queries(settings, params):
# A changed `compatibility` resets the defaults of the settings that were not changed
# explicitly, so it goes first, not to undo the others.
ordered = sorted(settings, key=lambda kv: kv[0] != "compatibility")
queries = [f"SET {name} = {sql_literal(value)}" for name, value in ordered]
queries += [f"SET param_{name} = {sql_literal(value)}" for name, value in params]
return queries
class HTTPClient(QueryClient):
# The settings and the database of a request override those of the session, so they are set once
# with `SET` and `USE`, and the `SET` and `USE` of the test persist, as with `clickhouse-client`.
# The query parameters are passed with every query, as `clickhouse-client` does.
native_params = True
def __init__(self, args, secure):
super().__init__(args)
self.secure = secure
self.port = (
env_port("CLICKHOUSE_PORT_HTTPS", 8443) if secure else env_port("CLICKHOUSE_PORT_HTTP", 8123)
)
self.session_id = str(uuid.uuid4())
self.connection = None
def setup_queries(self):
return [f"USE `{self.database}`"] + super().setup_queries()
def connect(self):
if self.secure:
self.connection = http.client.HTTPSConnection(
self.host, self.port, timeout=SOCKET_TIMEOUT, context=ssl._create_unverified_context()
)
else:
self.connection = http.client.HTTPConnection(self.host, self.port, timeout=SOCKET_TIMEOUT)
def execute(self, query):
params = [("session_id", self.session_id)]
# With `http_write_exception_in_output_format`, an exception after the response has started is
# written in the output format, e.g. as an `exception` object of `JSONEachRowWithProgress`, in a
# complete response with status 200. Disable it, so that such an exception always comes as the
# tagged block checked below. `supports` excludes the tests that set it themselves.
params += [("http_write_exception_in_output_format", "0")]
params += [(f"param_{name}", value) for name, value in self.params]
url = "/?" + urllib.parse.urlencode(params)
body = query.encode("utf-8", errors="surrogateescape")
incomplete = False
try:
self.connection.request("POST", url, body=body, headers={"X-ClickHouse-User": self.user})
response = self.connection.getresponse()
try:
data = response.read()
except http.client.IncompleteRead as e:
# The server may break the response after an exception in the middle of it.
data = e.partial
incomplete = True
self.connection.close()
except (http.client.HTTPException, OSError):
# Do not leave a connection broken in the middle of a request for the next query.
self.connection.close()
raise
code = response.getheader("X-ClickHouse-Exception-Code")
if response.status != 200 or code:
raise QueryError(data.decode("utf-8", errors="replace"), int(code) if code else None)
# An exception after the response has started (with `http_wait_end_of_query = 0` or a small
# `http_response_buffer_size`) is appended to the data as a block tagged with the tag from the
# headers (`WriteBufferFromHTTPServerResponse::cancelWithException`).
tag = response.getheader("X-ClickHouse-Exception-Tag")
if tag and (message := self.tagged_exception(data, tag.encode())) is not None:
raise QueryError(message)
if incomplete:
raise QueryError(f"Incomplete HTTP response without an exception: {data[-1024:]!r}")
return data
@staticmethod
def tagged_exception(data, tag):
"""Return the message of the exception block at the end of the data, if there is one:
`__exception__\\r\\n<tag>\\r\\n<message>\\n<message size> <tag>\\r\\n__exception__\\r\\n`."""
header = b"__exception__\r\n" + tag + b"\r\n"
trailer = b" " + tag + b"\r\n__exception__\r\n"
if not data.endswith(trailer):
return None
start = data.rfind(header)
if start == -1:
raise QueryError(f"Malformed exception block in the HTTP response: {data[-1024:]!r}")
message, _, size = data[start + len(header) : -len(trailer)].rpartition(b"\n")
message += b"\n"
if not size.isdigit() or int(size) != len(message):
raise QueryError(f"Malformed exception block in the HTTP response: {data[-1024:]!r}")
return message.decode("utf-8", errors="replace")
class MySQLClient(QueryClient):
"""A minimal client of the MySQL text protocol."""
CLIENT_LONG_PASSWORD = 0x1
CLIENT_CONNECT_WITH_DB = 0x8
CLIENT_PROTOCOL_41 = 0x200
CLIENT_TRANSACTIONS = 0x2000
CLIENT_SECURE_CONNECTION = 0x8000
CLIENT_PLUGIN_AUTH = 0x80000
MAX_PACKET = 0xFFFFFF
def connect(self):
self.sock = socket.create_connection(
(self.host, env_port("CLICKHOUSE_PORT_MYSQL", 9004)), timeout=SOCKET_TIMEOUT
)
self.sequence = 0
handshake = self.read_packet()
if handshake[0] == 0xFF:
raise self.error(handshake)
capabilities = (
self.CLIENT_LONG_PASSWORD
| self.CLIENT_CONNECT_WITH_DB
| self.CLIENT_PROTOCOL_41
| self.CLIENT_TRANSACTIONS
| self.CLIENT_SECURE_CONNECTION
| self.CLIENT_PLUGIN_AUTH
)
# The scramble of the handshake is not needed: `mysql_native_password` of the empty password
# of the test user is empty.
auth_response = b""
response = struct.pack("<IIB23x", capabilities, self.MAX_PACKET, 33)
response += self.user.encode() + b"\0"
response += bytes([len(auth_response)]) + auth_response
response += self.database.encode() + b"\0"
response += b"mysql_native_password\0"
self.write_packet(response)
while True:
packet = self.read_packet()
if packet[0] == 0x00:
return
if packet[0] == 0xFF:
raise self.error(packet)
if packet[0] == 0xFE:
# Auth switch request: the response for the empty password is empty again.
self.write_packet(auth_response)
continue
raise QueryError(f"Unexpected MySQL packet during authentication: {packet[:16]!r}")
def read_exact(self, size):
data = bytearray()
while len(data) < size:
chunk = self.sock.recv(size - len(data))
if not chunk:
raise QueryError("MySQL server closed the connection")
data += chunk
return bytes(data)
def read_packet(self):
payload = b""
while True:
header = self.read_exact(4)
length = int.from_bytes(header[:3], "little")
self.sequence = (header[3] + 1) % 256
payload += self.read_exact(length)
if length < self.MAX_PACKET:
return payload
def write_packet(self, payload):
while True:
chunk, payload = payload[: self.MAX_PACKET], payload[self.MAX_PACKET :]
self.sock.sendall(len(chunk).to_bytes(3, "little") + bytes([self.sequence]) + chunk)
self.sequence = (self.sequence + 1) % 256
if len(chunk) < self.MAX_PACKET:
return
@staticmethod
def read_length(packet, pos):
first = packet[pos]
if first < 0xFB:
return first, pos + 1
if first == 0xFB:
return None, pos + 1
size = {0xFC: 2, 0xFD: 3, 0xFE: 8}[first]
return int.from_bytes(packet[pos + 1 : pos + 1 + size], "little"), pos + 1 + size
@staticmethod
def error(packet):
# ClickHouse sends its own error code as the MySQL error code.
code = int.from_bytes(packet[1:3], "little")
message = packet[3:]
if message[:1] == b"#":
message = message[6:]
return QueryError(f"MySQL error {code}: {message.decode('utf-8', errors='replace')}", code)
@staticmethod
def is_eof(packet):
return packet[0] == 0xFE and len(packet) < 9
def execute(self, query):
self.sequence = 0
self.write_packet(b"\x03" + query.encode("utf-8", errors="surrogateescape"))
packet = self.read_packet()
if packet[0] == 0x00:
return b""
if packet[0] == 0xFF:
raise self.error(packet)
column_count, _ = self.read_length(packet, 0)
for _ in range(column_count):
self.read_packet()
if not self.is_eof(packet := self.read_packet()):
raise QueryError(f"Expected EOF after the MySQL column definitions, got {packet[:16]!r}")
rows = []
while True:
packet = self.read_packet()
if packet[0] == 0xFF:
raise self.error(packet)
if self.is_eof(packet):
return format_rows(rows).encode()
row = []
pos = 0
while pos < len(packet):
length, pos = self.read_length(packet, pos)
if length is None:
row.append(None)
else:
row.append(packet[pos : pos + length])
pos += length
rows.append(row)
def close(self):
try:
self.sequence = 0
self.write_packet(b"\x01") # COM_QUIT
except OSError:
pass
self.sock.close()
class PostgreSQLClient(QueryClient):
"""A minimal client of the PostgreSQL simple query protocol."""
# The `ErrorResponse` of a failed query carries a generic SQLSTATE and the text of the exception
# without its code (`PostgreSQLHandler::sendErrorResponseOrRethrow`), so the codes are compared
# only for the errors that have the `Code: N.` prefix in their text.
reports_error_codes = False
def connect(self):
self.sock = socket.create_connection(
(self.host, env_port("CLICKHOUSE_PORT_POSTGRESQL", 9005)), timeout=SOCKET_TIMEOUT
)
parameters = b"user\0" + self.user.encode() + b"\0database\0" + self.database.encode() + b"\0\0"
self.sock.sendall(struct.pack("!II", 8 + len(parameters), 196608) + parameters)
while True:
kind, body = self.read_message()
if kind == b"R":
code = struct.unpack("!I", body[:4])[0]
if code == 3: # Cleartext password; the password of the test user is empty.
self.send_message(b"p", b"\0")
elif code != 0:
raise QueryError(f"Unsupported PostgreSQL authentication request {code}")
elif kind == b"E":
raise QueryError(self.error_message(body))
elif kind == b"Z":
return
def read_exact(self, size):
data = bytearray()
while len(data) < size:
chunk = self.sock.recv(size - len(data))
if not chunk:
raise QueryError("PostgreSQL server closed the connection")
data += chunk
return bytes(data)
def read_message(self):
header = self.read_exact(5)
length = struct.unpack("!I", header[1:])[0]
return header[:1], self.read_exact(length - 4)
def send_message(self, kind, body):
self.sock.sendall(kind + struct.pack("!I", len(body) + 4) + body)
@staticmethod
def error_message(body):
fields = {}
for field in body.split(b"\0"):
if field:
fields[field[:1]] = field[1:].decode("utf-8", errors="replace")
return f"PostgreSQL error {fields.get(b'C', '')}: {fields.get(b'M', '')}"
def execute(self, query):
self.send_message(b"Q", query.encode("utf-8", errors="surrogateescape") + b"\0")
rows = []
error = None
while True:
kind, body = self.read_message()
if kind == b"D":
count = struct.unpack("!H", body[:2])[0]
pos = 2
row = []
for _ in range(count):
length = struct.unpack("!i", body[pos : pos + 4])[0]
pos += 4
if length < 0:
row.append(None)
else:
row.append(body[pos : pos + length])
pos += length
rows.append(row)
elif kind == b"E":
error = self.error_message(body)
elif kind == b"Z":
if error is not None:
raise QueryError(error)
return format_rows(rows).encode()
def close(self):
try:
self.send_message(b"X", b"")
except OSError:
pass
self.sock.close()
class GRPCClient(QueryClient):
# As with HTTP, the settings and the database of a request override those of the session.
def setup_queries(self):
return [f"USE `{self.database}`"] + super().setup_queries()
def connect(self):
sys.path.append(
os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "utils", "grpc-client", "pb2")
)
import clickhouse_grpc_pb2 # pylint: disable=import-outside-toplevel
import clickhouse_grpc_pb2_grpc # pylint: disable=import-outside-toplevel
import grpc # pylint: disable=import-outside-toplevel
self.pb2 = clickhouse_grpc_pb2
self.channel = grpc.insecure_channel(
f"{self.host}:{env_port('CLICKHOUSE_PORT_GRPC', 9100)}",
options=[("grpc.max_receive_message_length", -1), ("grpc.max_send_message_length", -1)],
)
self.stub = clickhouse_grpc_pb2_grpc.ClickHouseStub(self.channel)
self.session_id = str(uuid.uuid4())
def execute(self, query):
result = self.stub.ExecuteQuery(
self.pb2.QueryInfo(
query=query,
session_id=self.session_id,
user_name=self.user,
output_format="TabSeparated",
),
timeout=SOCKET_TIMEOUT,
)
if result.HasField("exception"):
raise QueryError(
f"Code: {result.exception.code}. {result.exception.display_text}", result.exception.code
)
return result.output
def close(self):
self.channel.close()
class ArrowFlightClient(QueryClient):
def connect(self):
import pyarrow # pylint: disable=import-outside-toplevel
from pyarrow import flight # pylint: disable=import-outside-toplevel
self.pyarrow = pyarrow
self.flight = flight
self.client = flight.FlightClient(
f"grpc://{self.host}:{env_port('CLICKHOUSE_PORT_ARROWFLIGHT', 9090)}"
)
token = self.client.authenticate_basic_token(self.user, "")
self.options = flight.FlightCallOptions(
headers=[token, (b"x-clickhouse-session-id", str(uuid.uuid4()).encode())],
timeout=SOCKET_TIMEOUT,
)
# The session starts in the default database.
self.execute(f"USE `{self.database}`")
def execute(self, query):
try:
if returns_result(query):
table = self.client.do_get(self.flight.Ticket(query.encode()), self.options).read_all()
columns = [column.to_pylist() for column in table.columns]
return format_rows(zip(*columns)).encode()
writer, _ = self.client.do_put(
self.flight.FlightDescriptor.for_command(query), self.pyarrow.schema([]), self.options
)
writer.close()
return b""
except self.pyarrow.ArrowException as e:
raise QueryError(str(e)) from e
def close(self):
self.client.close()
def is_binary(data):
"""Whether the test contains bytes that only `BINARY_PROTOCOLS` can carry."""
if b"\0" in data:
return True
try:
data.decode("utf-8")
except UnicodeDecodeError:
return True
return False
# A value of the `dialect` setting, e.g. in `SET dialect = 'kusto'`.
DIALECT_RE = re.compile(r"""\bdialect\s*=\s*['"`]?(\w+)""", re.IGNORECASE)
def uses_other_dialect(text):
"""Whether the test switches to a dialect other than `clickhouse`. `split_queries` knows only the
statement boundaries of SQL: e.g. a `let` binding of KQL belongs to the following tabular expression,
so splitting `let F = (a:long) { a }; print F(5);` at the semicolon breaks the test."""
return any(match.group(1).lower() != "clickhouse" for match in DIALECT_RE.finditer(text))
def supports(protocol, data):
"""Whether the test with the contents `data` can run over the protocol without changing its queries.
`clickhouse-test` chooses the protocol of a test among the ones that support it."""
if is_binary(data) and protocol not in BINARY_PROTOCOLS:
return False
# The same byte-preserving decoding as in `main`: `BINARY_PROTOCOLS` carry invalid UTF-8 as is.
text = data.decode("utf-8", errors="surrogateescape")
# Only `ssh` passes the whole test to `ClientBase`, which splits it according to the dialect.
if protocol != "ssh" and uses_other_dialect(text):
return False
if protocol == "ssh":
return len(data) <= SSH_MAX_TEST_SIZE
if protocol in ("http", "https") and "http_write_exception_in_output_format" in text:
# `HTTPClient` cannot tell an exception written in the output format from the data.
return False
if protocol == "webterminal":
# The line editor removes the whitespace at the end of the pasted text, which can be a field
# separator or a part of a value at the end of the inline data of `INSERT ... FORMAT`.
return all(query == query.rstrip() for query, _ in split_queries(text))
return True
def prepend_setup(text, setup):
return "".join(query + ";\n" for query in setup) + text
def run_ssh(args, text):
"""Pass the whole test to the embedded client as the command of an SSH channel, like
`ssh user@host "SELECT 1"`. The embedded client splits it into queries and applies the hints."""
text = prepend_setup(text, [f"USE `{args.database}`"] + setup_queries(args.settings, args.params))
if len(text.encode("utf-8", errors="surrogateescape")) > SSH_MAX_QUERY_SIZE:
raise QueryError(f"The test is larger than {SSH_MAX_QUERY_SIZE} bytes, it cannot be run over SSH")
# OpenSSH refuses a private key that is readable by others, and git checks it out as 644.
key_dir = tempfile.mkdtemp(prefix="clickhouse-test-ssh-")
try:
key = os.path.join(key_dir, "key")
shutil.copyfile(args.ssh_key, key)
os.chmod(key, 0o600)
command = [
"ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "BatchMode=yes",
"-o", "LogLevel=ERROR",
"-o", "ConnectTimeout=60",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=20",
"-i", key,
"-p", str(env_port("CLICKHOUSE_PORT_SSH", 9022)),
f"{args.ssh_user}@{args.host}",
text,
]
# The random disruptor of the Stress test terminates this process to disconnect the client in
# the middle of a query. The connection belongs to the `ssh` child, so terminate it as well.
previous_handler = signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(128 + signum))
try:
process = subprocess.Popen(command, stdin=subprocess.DEVNULL)
try:
return process.wait()
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
finally:
signal.signal(signal.SIGTERM, previous_handler)
finally:
shutil.rmtree(key_dir)
class WebTerminal:
"""Pastes the queries into the interactive embedded client behind `/webterminal` one by one,
and waits for the prompt after every query, like a person at the browser would."""
PROMPT = b":) "
# Replxx enables the bracketed paste mode each time it starts reading a new line, right before it
# prints the prompt. It distinguishes the prompt from `:) ` in the echoed queries and their results.
PROMPT_START = b"\x1b[?2004h"
# A bracketed paste keeps the text as is, line breaks included, and the line break after it
# submits the whole text. The client splits it into queries and applies the test hints itself.
PASTE_START = b"\x1b[200~"
PASTE_END = b"\x1b[201~"
# What the interactive client prints, instead of stopping, on an error that no hint expects
# (`ClientEmbedded::processError`) and on an error or its absence that does not match the hint
# (`ClientBase::executeMultiQuery`).
ERROR_RE = re.compile(
rb"Received exception\r?\n|(Expected |The query succeeded but )[^\r\n]*\berrors?\b[^\r\n]*\(query: "
)
def __init__(self, args):
self.host = args.host
self.port = env_port("CLICKHOUSE_PORT_HTTP", 8123)
self.user = args.user
def connect(self):
self.sock = socket.create_connection((self.host, self.port), timeout=SOCKET_TIMEOUT)
key = base64.b64encode(os.urandom(16)).decode()
host = f"{self.host}:{self.port}"
request = (
f"GET /webterminal HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nOrigin: http://{host}\r\n\r\n"
)
self.sock.sendall(request.encode())
response = b""
while b"\r\n\r\n" not in response:
chunk = self.sock.recv(4096)
if not chunk:
raise QueryError("The server closed the connection during the WebSocket handshake")
response += chunk
head, self.pending = response.split(b"\r\n\r\n", 1)
if not head.startswith(b"HTTP/1.1 101"):
raise QueryError(f"WebSocket handshake failed: {head.decode(errors='replace')}")
expected = base64.b64encode(
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
)
if b"Sec-WebSocket-Accept: " + expected not in head:
raise QueryError("WebSocket handshake returned a wrong Sec-WebSocket-Accept")
self.send_frame(0x1, json.dumps({"type": "auth", "user": self.user, "password": ""}).encode())
# A wide terminal, so that the client does not wrap and redraw long queries.
self.send_frame(0x1, json.dumps({"type": "resize", "cols": 500, "rows": 50}).encode())
self.wait_for_prompt()
def send_frame(self, opcode, payload):
header = bytearray([0x80 | opcode])
if len(payload) < 126:
header.append(0x80 | len(payload))
elif len(payload) < 65536:
header.append(0x80 | 126)
header += struct.pack("!H", len(payload))
else:
header.append(0x80 | 127)
header += struct.pack("!Q", len(payload))
mask = os.urandom(4)
header += mask
self.sock.sendall(bytes(header) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def recv_exact(self, size):
while len(self.pending) < size:
chunk = self.sock.recv(65536)
if not chunk:
raise EOFError
self.pending += chunk
data, self.pending = self.pending[:size], self.pending[size:]
return data
def read_frame(self):
first, second = self.recv_exact(2)
length = second & 0x7F
if length == 126:
length = struct.unpack("!H", self.recv_exact(2))[0]
elif length == 127:
length = struct.unpack("!Q", self.recv_exact(8))[0]
return first & 0x0F, self.recv_exact(length)
def read_output(self):
"""Read the next chunk of the terminal output, return `None` when the session is over."""
try:
opcode, payload = self.read_frame()
except EOFError:
return None
if opcode == 0x8:
if len(payload) >= 2 and struct.unpack("!H", payload[:2])[0] != 1000:
sys.stderr.write(f"Web terminal closed: {payload[2:].decode(errors='replace')}\n")
return None
if opcode == 0x9:
self.send_frame(0xA, payload)
return b""
sys.stdout.buffer.write(payload)
return payload
def wait_for_prompt(self):
"""Read the output until the next prompt, return the output before the prompt."""
output = bytearray()
while True:
start = output.rfind(self.PROMPT_START)
# Nothing follows the prompt until the next input.
if start != -1 and output.endswith(self.PROMPT) and b"\n" not in output[start:]:
return output[:start]
data = self.read_output()
if data is None:
raise QueryError("The web terminal session ended before the prompt")
output += data
def run(self, queries):
"""Run the queries, return whether all of them succeeded or failed as their hints expect."""
for query in queries:
text = query.encode("utf-8", errors="surrogateescape")
self.send_frame(0x2, self.PASTE_START + text + self.PASTE_END + b"\r")
if self.ERROR_RE.search(self.wait_for_prompt()):
self.sock.close()
return False
self.send_frame(0x2, b"exit\r")
output = bytearray()
while (data := self.read_output()) is not None:
output += data
self.sock.close()
return not self.ERROR_RE.search(output)
def run_webterminal(args, text):
terminal = WebTerminal(args)
terminal.connect()
setup = [f"USE `{args.database}`"] + setup_queries(args.settings, args.params)
# A query goes with its hint after the semicolon. A query without a hint goes without the
# semicolon, because the data of `INSERT ... FORMAT` would take it as a part of the data.
queries = [f"{query}; {hint}" if hint else query for query, hint in split_queries(text)]
return 0 if terminal.run(setup + queries) else 1
def run_queries(client, text, error_codes):
client.connect()
try:
for query in client.setup_queries():
client.execute(query)
for query, hint in split_queries(text):
expected = expected_errors(query, hint)
try:
output = client.execute(query)
except QueryError as e:
if expected is None:
sys.stderr.write(f"Error on query {query!r}:\n{e}\n")
return 1
if mismatch := check_error(client, e, expected, error_codes):
sys.stderr.write(f"{mismatch}\nQuery: {query!r}\n")
return 1
continue
sys.stdout.buffer.write(output)
if expected is not None:
sys.stderr.write(f"Expected an error, but the query succeeded: {query!r}\n")
return 1
return 0
finally:
client.close()
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--protocol", required=True, choices=PROTOCOLS)
parser.add_argument("--host", default=os.environ.get("CLICKHOUSE_HOST", "localhost"))
parser.add_argument("--user", default="default")
parser.add_argument("--database", default="default")
parser.add_argument("--setting", dest="settings", action="append", default=[], help="name=value")
parser.add_argument("--param", dest="params", action="append", default=[], help="name=value")
parser.add_argument("--ssh-user", help="The user with the key authentication for the ssh protocol")
parser.add_argument(
"--ssh-key",
default=os.path.join(os.path.dirname(os.path.realpath(__file__)), "config", "ssh_user_ed25519_key"),
)
args = parser.parse_args()
args.settings = parse_key_values(args.settings)
args.params = parse_key_values(args.params)
text = sys.stdin.buffer.read().decode("utf-8", errors="surrogateescape")
try:
if args.protocol == "ssh":
if not args.ssh_user:
parser.error("--ssh-user is required for the ssh protocol")
return run_ssh(args, text)
if args.protocol == "webterminal":
return run_webterminal(args, text)
client = {
"http": lambda: HTTPClient(args, secure=False),
"https": lambda: HTTPClient(args, secure=True),
"mysql": lambda: MySQLClient(args),
"postgresql": lambda: PostgreSQLClient(args),
"grpc": lambda: GRPCClient(args),
"arrowflight": lambda: ArrowFlightClient(args),
}[args.protocol]()
return run_queries(client, text, ErrorCodes(args.host))
except QueryError as e:
sys.stderr.write(f"{e}\n")
return 1
if __name__ == "__main__":
sys.exit(main())