blob: 22de4d43e357762273d716292627d424a7d1de16 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import asyncio
import json
import math
import os
import subprocess
import tempfile
import time
from datetime import date, datetime, timezone
from datetime import time as dt_time
from decimal import Decimal
from pathlib import Path
import pyarrow as pa
import pytest
import pytest_asyncio
from filelock import FileLock
import fluss
CLUSTER_NAME = "shared-test"
def _find_cli_binary():
env_bin = os.environ.get("FLUSS_TEST_CLUSTER_BIN")
if env_bin:
if os.path.isfile(env_bin):
return env_bin
raise FileNotFoundError(f"FLUSS_TEST_CLUSTER_BIN={env_bin!r} does not exist")
result = subprocess.run(
["cargo", "locate-project", "--workspace", "--message-format", "plain"],
capture_output=True,
text=True,
)
if result.returncode == 0:
root = Path(result.stdout.strip()).parent
for profile in ("debug", "release"):
bin_path = root / "target" / profile / "fluss-test-cluster"
if bin_path.is_file():
return str(bin_path)
raise FileNotFoundError(
"fluss-test-cluster not found. Run: cargo build -p fluss-test-cluster"
)
def _start_cluster():
lock = Path(tempfile.gettempdir()) / f"fluss-{CLUSTER_NAME}.lock"
with FileLock(lock):
cli = _find_cli_binary()
result = subprocess.run(
[cli, "start", "--sasl", "--name", CLUSTER_NAME],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"fluss-test-cluster start failed:\n{result.stderr}\n{result.stdout}"
)
prefix = "CLUSTER_JSON: "
for line in result.stdout.strip().split("\n"):
if line.startswith(prefix):
info = json.loads(line[len(prefix) :])
return info["bootstrap_servers"], info.get("sasl_bootstrap_servers")
raise RuntimeError(
f"No CLUSTER_JSON token in output:\n{result.stdout}\n{result.stderr}"
)
def _stop_cluster():
try:
cli = _find_cli_binary()
except FileNotFoundError:
return
subprocess.run([cli, "stop", "--name", CLUSTER_NAME], capture_output=True)
async def _connect(bootstrap_servers):
config = fluss.Config({"bootstrap.servers": bootstrap_servers})
start = time.time()
last_err = None
while time.time() - start < 60:
try:
conn = await fluss.FlussConnection.create(config)
admin = conn.get_admin()
nodes = await admin.get_server_nodes()
if any(n.server_type == "TabletServer" for n in nodes):
return conn
await conn.close()
last_err = RuntimeError("No TabletServer available yet")
except Exception as e:
last_err = e
await asyncio.sleep(1)
raise RuntimeError(f"Could not connect after 60s: {last_err}")
def pytest_unconfigure(config):
if os.environ.get("FLUSS_BOOTSTRAP_SERVERS"):
return
if hasattr(config, "workerinput"):
return
if os.environ.get("FLUSS_SKIP_CLUSTER_TEARDOWN"):
return
_stop_cluster()
@pytest.fixture(scope="session")
def fluss_cluster():
env = os.environ.get("FLUSS_BOOTSTRAP_SERVERS")
if env:
sasl_env = os.environ.get("FLUSS_SASL_BOOTSTRAP_SERVERS", env)
yield (env, sasl_env)
return
plaintext_addr, sasl_addr = _start_cluster()
yield (plaintext_addr, sasl_addr or plaintext_addr)
@pytest_asyncio.fixture(scope="session")
async def connection(fluss_cluster):
plaintext_addr, _sasl_addr = fluss_cluster
conn = await _connect(plaintext_addr)
yield conn
conn.close()
@pytest.fixture(scope="session")
def sasl_bootstrap_servers(fluss_cluster):
_plaintext_addr, sasl_addr = fluss_cluster
return sasl_addr
@pytest.fixture(scope="session")
def plaintext_bootstrap_servers(fluss_cluster):
plaintext_addr, _sasl_addr = fluss_cluster
return plaintext_addr
@pytest_asyncio.fixture(scope="session")
async def admin(connection):
return connection.get_admin()
@pytest_asyncio.fixture
async def wait_for_table_ready(admin):
"""
Fixture that returns a helper function to wait for a table or partition to be ready.
"""
async def _wait(table_path, timeout=15, partition_name=None):
start_time = time.monotonic()
while time.monotonic() - start_time < timeout:
try:
if partition_name:
await admin.list_partition_offsets(
table_path, partition_name, [0], fluss.OffsetSpec.earliest()
)
else:
await admin.list_offsets(
table_path, [0], fluss.OffsetSpec.earliest()
)
return
except (fluss.FlussError, Exception) as e:
# Catch "No leader found" or other errors that indicate the table/partition is still initializing
err_msg = str(e)
if any(
msg in err_msg
for msg in [
"No leader found",
"Table not ready",
"Metadata not ready",
"not leader or follower",
]
):
await asyncio.sleep(1)
continue
raise
raise TimeoutError(
f"Table/Partition {table_path} ({partition_name or 'standard'}) "
f"did not become ready within {timeout}s"
)
return _wait
# Complex-type (ARRAY/MAP/ROW) helpers shared by the KV and log
# all_complex_datatypes tests: schema sections plus the full/edge/null row matrix.
# Read-back shapes: ARRAY -> list, MAP -> list of (key, value) tuples, ROW -> dict.
def pa_row_seq_label() -> pa.DataType:
return pa.struct([("seq", pa.int32()), ("label", pa.string())])
def pa_row_deep() -> pa.DataType:
return pa.struct([("inner", pa.struct([("n", pa.int32())]))])
def pa_row_rich() -> pa.DataType:
return pa.struct(
[
("f_bool", pa.bool_()),
("f_int", pa.int32()),
("f_long", pa.int64()),
("f_float", pa.float32()),
("f_double", pa.float64()),
("f_str", pa.string()),
("f_bytes", pa.binary()),
("f_decimal", pa.decimal128(10, 2)),
("f_date", pa.date32()),
("f_time", pa.time32("ms")),
("f_ts_ntz", pa.timestamp("us")),
("f_ts_ltz", pa.timestamp("us", tz="UTC")),
("f_binary", pa.binary(4)),
("f_array_int", pa.list_(pa.int32())),
]
)
def array_basics_fields() -> list:
return [
pa.field("arr_int", pa.list_(pa.int32())),
pa.field("arr_string", pa.list_(pa.string())),
pa.field("arr_of_arr", pa.list_(pa.list_(pa.int32()))),
pa.field("arr_of_row", pa.list_(pa_row_seq_label())),
]
def row_basics_fields() -> list:
return [
pa.field("row_basic", pa_row_seq_label()),
pa.field("row_deep", pa_row_deep()),
pa.field("row_rich", pa_row_rich()),
]
def map_basics_fields() -> list:
return [
pa.field("map_string_int", pa.map_(pa.string(), pa.int32())),
pa.field("map_of_row", pa.map_(pa.string(), pa_row_seq_label())),
pa.field("map_of_map", pa.map_(pa.string(), pa.map_(pa.string(), pa.int32()))),
pa.field("map_of_array", pa.map_(pa.string(), pa.list_(pa.int32()))),
pa.field("array_of_map", pa.list_(pa.map_(pa.string(), pa.int32()))),
]
def array_rich_fields() -> list:
return [
pa.field("arr_bytes", pa.list_(pa.binary())),
pa.field("arr_date", pa.list_(pa.date32())),
pa.field("arr_time", pa.list_(pa.time32("ms"))),
pa.field("arr_ts", pa.list_(pa.timestamp("us"))),
pa.field("arr_ts_ltz", pa.list_(pa.timestamp("us", tz="UTC"))),
pa.field("arr_decimal", pa.list_(pa.decimal128(10, 2))),
pa.field("arr_decimal_big", pa.list_(pa.decimal128(22, 5))),
pa.field("arr_float", pa.list_(pa.float32())),
pa.field("arr_double", pa.list_(pa.float64())),
pa.field("arr_binary", pa.list_(pa.binary(4))),
]
def map_rich_fields() -> list:
return [
pa.field("map_bytes", pa.map_(pa.string(), pa.binary())),
pa.field("map_decimal", pa.map_(pa.string(), pa.decimal128(10, 2))),
pa.field("map_date", pa.map_(pa.string(), pa.date32())),
pa.field("map_time", pa.map_(pa.string(), pa.time32("ms"))),
pa.field("map_ts", pa.map_(pa.string(), pa.timestamp("us"))),
pa.field("map_ts_ltz", pa.map_(pa.string(), pa.timestamp("us", tz="UTC"))),
pa.field("map_float", pa.map_(pa.string(), pa.float32())),
pa.field("map_double", pa.map_(pa.string(), pa.float64())),
pa.field("map_bool", pa.map_(pa.string(), pa.bool_())),
pa.field("map_binary", pa.map_(pa.string(), pa.binary(4))),
pa.field("map_int_key", pa.map_(pa.int32(), pa.string())),
]
def complex_fields() -> list:
"""`id` + all complex sections, in section order."""
return (
[pa.field("id", pa.int32())]
+ array_basics_fields()
+ array_rich_fields()
+ row_basics_fields()
+ map_basics_fields()
+ map_rich_fields()
)
def complex_column_names() -> list:
"""All complex column names (everything except `id`)."""
return [f.name for f in complex_fields() if f.name != "id"]
def complex_schema(primary_keys=None) -> "fluss.Schema":
return fluss.Schema(pa.schema(complex_fields()), primary_keys=primary_keys)
_ROW_RICH_FULL = {
"f_bool": True,
"f_int": 100_000,
"f_long": 9_876_543_210,
"f_float": float("inf"),
"f_double": math.pi,
"f_str": "hello world",
"f_bytes": b"binary",
"f_decimal": Decimal("123.45"),
"f_date": date(2026, 1, 23),
"f_time": dt_time(10, 13, 47, 123000),
"f_ts_ntz": datetime(2026, 1, 23, 10, 13, 47, 123456),
"f_ts_ltz": datetime(2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc),
"f_binary": b"\x01\x02\x03\x04",
"f_array_int": [7, None, 11],
}
def complex_full_row(id_: int) -> dict:
"""Fully-populated row exercising every complex shape (incl. nesting)."""
return {
"id": id_,
"arr_int": [10, 20, 30],
"arr_string": ["hello", "world"],
"arr_of_arr": [[1, 2], [3, 4]],
"arr_of_row": [{"seq": 1, "label": "open"}, {"seq": 2, "label": "close"}],
"arr_bytes": [b"\x10\x20\x30", None],
"arr_date": [date(2026, 1, 23), None],
"arr_time": [dt_time(10, 13, 47, 123000), None],
"arr_ts": [datetime(2026, 1, 23, 10, 13, 47, 123456)],
"arr_ts_ltz": [datetime(2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc)],
"arr_decimal": [Decimal("123.45"), None],
"arr_decimal_big": [
Decimal("12345678901234567.12345"),
Decimal("-99999999999999999.99999"),
],
"arr_float": [float("nan"), float("inf"), float("-inf")],
"arr_double": [float("nan"), float("inf"), float("-inf")],
"arr_binary": [b"\xde\xad\xbe\xef", b"\x00\x01\x02\x03"],
"row_basic": {"seq": 42, "label": "hello"},
"row_deep": {"inner": {"n": 99}},
"row_rich": dict(_ROW_RICH_FULL),
"map_string_int": {"a": 1, "b": None, "c": 3},
"map_of_row": {
"e0": {"seq": 1, "label": "open"},
"e1": {"seq": 2, "label": "close"},
},
"map_of_map": {"g1": {"a": 1, "b": 2}, "g2": {"c": 3}},
"map_of_array": {"primes": [2, 3, 5], "squares": [1, 4]},
"array_of_map": [{"x": 1, "y": 2}, {"z": 9}],
"map_bytes": {"k": b"\x10\x20\x30"},
"map_decimal": {"p": Decimal("123.45")},
"map_date": {"d": date(2026, 1, 23)},
"map_time": {"t": dt_time(10, 13, 47, 123000)},
"map_ts": {"t": datetime(2026, 1, 23, 10, 13, 47, 123456)},
"map_ts_ltz": {
"t": datetime(2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc)
},
"map_float": {"nan": float("nan"), "inf": float("inf"), "ninf": float("-inf")},
"map_double": {"nan": float("nan"), "inf": float("inf"), "ninf": float("-inf")},
"map_bool": {"t": True, "f": False},
"map_binary": {"k": b"\x01\x02\x03\x04"},
"map_int_key": {1: "one", 2: "two"},
}
# Rich sections appear only in the full row; edge/null rows leave them NULL.
_RICH_COLUMNS = [f.name for f in array_rich_fields() + map_rich_fields()]
def complex_edge_row(id_: int) -> dict:
"""Edge cases: empty collections, null elements, null nested rows."""
return {
"id": id_,
"arr_int": [],
"arr_string": [None],
"arr_of_arr": [[5], None, []],
"arr_of_row": [{"seq": 7, "label": "x"}, None, {"seq": 8, "label": "y"}],
"row_basic": None,
"row_deep": None,
"row_rich": None,
"map_string_int": {},
"map_of_row": {},
"map_of_map": {},
"map_of_array": {},
"array_of_map": [],
**{name: None for name in _RICH_COLUMNS},
}
def complex_null_row(id_: int) -> dict:
"""Every complex column set to NULL."""
return {"id": id_, **{name: None for name in complex_column_names()}}
def _map(value) -> dict:
"""A read-back MAP is a list of (key, value) tuples; turn it into a dict for
order-independent comparison (test maps use unique scalar keys)."""
return dict(value)
def _assert_float_triplet(values) -> None:
"""Assert a 3-element sequence holds [NaN, +Inf, -Inf]."""
assert math.isnan(values[0])
assert math.isinf(values[1]) and values[1] > 0
assert math.isinf(values[2]) and values[2] < 0
def assert_complex_full(row: dict) -> None:
assert row["arr_int"] == [10, 20, 30]
assert row["arr_string"] == ["hello", "world"]
assert row["arr_of_arr"] == [[1, 2], [3, 4]]
assert row["arr_of_row"] == [
{"seq": 1, "label": "open"},
{"seq": 2, "label": "close"},
]
assert row["row_basic"] == {"seq": 42, "label": "hello"}
assert row["row_deep"] == {"inner": {"n": 99}}
rr = row["row_rich"]
assert rr["f_bool"] is True
assert rr["f_int"] == 100_000
assert rr["f_long"] == 9_876_543_210
assert math.isinf(rr["f_float"]) and rr["f_float"] > 0
assert math.isclose(rr["f_double"], math.pi, rel_tol=1e-15)
assert rr["f_str"] == "hello world"
assert rr["f_bytes"] == b"binary"
assert rr["f_decimal"] == Decimal("123.45")
assert rr["f_date"] == date(2026, 1, 23)
assert rr["f_time"] == dt_time(10, 13, 47, 123000)
assert rr["f_ts_ntz"] == datetime(2026, 1, 23, 10, 13, 47, 123456)
assert rr["f_ts_ltz"] == datetime(
2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc
)
assert rr["f_binary"] == b"\x01\x02\x03\x04"
assert rr["f_array_int"] == [7, None, 11]
assert _map(row["map_string_int"]) == {"a": 1, "b": None, "c": 3}
assert _map(row["map_of_row"]) == {
"e0": {"seq": 1, "label": "open"},
"e1": {"seq": 2, "label": "close"},
}
assert {k: _map(v) for k, v in row["map_of_map"]} == {
"g1": {"a": 1, "b": 2},
"g2": {"c": 3},
}
assert _map(row["map_of_array"]) == {"primes": [2, 3, 5], "squares": [1, 4]}
assert [_map(m) for m in row["array_of_map"]] == [{"x": 1, "y": 2}, {"z": 9}]
assert row["arr_bytes"] == [b"\x10\x20\x30", None]
assert row["arr_date"] == [date(2026, 1, 23), None]
assert row["arr_time"] == [dt_time(10, 13, 47, 123000), None]
assert row["arr_ts"] == [datetime(2026, 1, 23, 10, 13, 47, 123456)]
assert row["arr_ts_ltz"] == [
datetime(2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc)
]
assert row["arr_decimal"] == [Decimal("123.45"), None]
assert row["arr_decimal_big"] == [
Decimal("12345678901234567.12345"),
Decimal("-99999999999999999.99999"),
]
_assert_float_triplet(row["arr_float"])
_assert_float_triplet(row["arr_double"])
assert row["arr_binary"] == [b"\xde\xad\xbe\xef", b"\x00\x01\x02\x03"]
assert _map(row["map_bytes"]) == {"k": b"\x10\x20\x30"}
assert _map(row["map_decimal"]) == {"p": Decimal("123.45")}
assert _map(row["map_date"]) == {"d": date(2026, 1, 23)}
assert _map(row["map_time"]) == {"t": dt_time(10, 13, 47, 123000)}
assert _map(row["map_ts"]) == {"t": datetime(2026, 1, 23, 10, 13, 47, 123456)}
assert _map(row["map_ts_ltz"]) == {
"t": datetime(2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc)
}
mf = _map(row["map_float"])
_assert_float_triplet([mf["nan"], mf["inf"], mf["ninf"]])
md = _map(row["map_double"])
_assert_float_triplet([md["nan"], md["inf"], md["ninf"]])
assert _map(row["map_bool"]) == {"t": True, "f": False}
assert _map(row["map_binary"]) == {"k": b"\x01\x02\x03\x04"}
assert _map(row["map_int_key"]) == {1: "one", 2: "two"}
def assert_complex_edge(row: dict) -> None:
assert row["arr_int"] == []
assert row["arr_string"] == [None]
assert row["arr_of_arr"] == [[5], None, []]
assert row["arr_of_row"] == [
{"seq": 7, "label": "x"},
None,
{"seq": 8, "label": "y"},
]
assert row["row_basic"] is None
assert row["row_deep"] is None
assert row["row_rich"] is None
# empty maps read back as empty lists
assert row["map_string_int"] == []
assert row["map_of_row"] == []
assert row["map_of_map"] == []
assert row["map_of_array"] == []
assert row["array_of_map"] == []
for name in _RICH_COLUMNS:
assert row[name] is None, f"{name} should be null in the edge row"