blob: b6bee545a39d255d4a01d0bbfdbdfe3d3980aaac [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.
"""Integration tests for log (append-only) table operations.
Mirrors the Rust integration tests in crates/fluss/tests/integration/log_table.rs.
"""
import asyncio
import time
import pyarrow as pa
import pytest
from conftest import (
assert_complex_edge,
assert_complex_full,
complex_column_names,
complex_edge_row,
complex_full_row,
complex_null_row,
complex_schema,
)
import fluss
async def test_append_and_scan(connection, admin):
"""Test appending record batches and scanning with a record-based scanner."""
table_path = fluss.TablePath("fluss", "py_test_append_and_scan")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("c1", pa.int32()), pa.field("c2", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema, bucket_count=3, bucket_keys=["c1"])
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
batch1 = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3], type=pa.int32()), pa.array(["a1", "a2", "a3"])],
schema=pa.schema([pa.field("c1", pa.int32()), pa.field("c2", pa.string())]),
)
append_writer.write_arrow_batch(batch1)
batch2 = pa.RecordBatch.from_arrays(
[pa.array([4, 5, 6], type=pa.int32()), pa.array(["a4", "a5", "a6"])],
schema=pa.schema([pa.field("c1", pa.int32()), pa.field("c2", pa.string())]),
)
append_writer.write_arrow_batch(batch2)
await append_writer.flush()
# Scan with record-based scanner
scanner = await table.new_scan().create_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
records = await _poll_records(scanner, expected_count=6)
assert len(records) == 6, f"Expected 6 records, got {len(records)}"
records.sort(key=lambda r: r.row["c1"])
expected_c1 = [1, 2, 3, 4, 5, 6]
expected_c2 = ["a1", "a2", "a3", "a4", "a5", "a6"]
for i, record in enumerate(records):
assert record.row["c1"] == expected_c1[i], f"c1 mismatch at row {i}"
assert record.row["c2"] == expected_c2[i], f"c2 mismatch at row {i}"
# Test unsubscribe
scanner.unsubscribe(bucket_id=0)
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_append_dict_rows(connection, admin):
"""Test appending rows as dicts and scanning."""
table_path = fluss.TablePath("fluss", "py_test_append_dict_rows")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
# Append using dicts
append_writer.append({"id": 1, "name": "Alice"})
append_writer.append({"id": 2, "name": "Bob"})
# Append using lists
append_writer.append([3, "Charlie"])
await append_writer.flush()
scanner = await table.new_scan().create_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
records = await _poll_records(scanner, expected_count=3)
assert len(records) == 3
rows = sorted([r.row for r in records], key=lambda r: r["id"])
assert rows[0] == {"id": 1, "name": "Alice"}
assert rows[1] == {"id": 2, "name": "Bob"}
assert rows[2] == {"id": 3, "name": "Charlie"}
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_list_offsets(connection, admin, wait_for_table_ready):
"""Test listing earliest, latest, and timestamp-based offsets."""
table_path = fluss.TablePath("fluss", "py_test_list_offsets")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
await wait_for_table_ready(table_path)
# Earliest offset should be 0 for empty table
earliest = await admin.list_offsets(
table_path, bucket_ids=[0], offset_spec=fluss.OffsetSpec.earliest()
)
assert earliest[0] == 0
# Latest offset should be 0 for empty table
latest = await admin.list_offsets(
table_path, bucket_ids=[0], offset_spec=fluss.OffsetSpec.latest()
)
assert latest[0] == 0
before_append_ms = int(time.time() * 1000)
# Append some records
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
batch = pa.RecordBatch.from_arrays(
[
pa.array([1, 2, 3], type=pa.int32()),
pa.array(["alice", "bob", "charlie"]),
],
schema=pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())]),
)
append_writer.write_arrow_batch(batch)
await append_writer.flush()
await asyncio.sleep(1)
after_append_ms = int(time.time() * 1000)
# Latest offset should be 3 after appending 3 records
latest_after = await admin.list_offsets(
table_path, bucket_ids=[0], offset_spec=fluss.OffsetSpec.latest()
)
assert latest_after[0] == 3
# Earliest offset should still be 0
earliest_after = await admin.list_offsets(
table_path, bucket_ids=[0], offset_spec=fluss.OffsetSpec.earliest()
)
assert earliest_after[0] == 0
# Timestamp before append should resolve to offset 0
ts_before = await admin.list_offsets(
table_path,
bucket_ids=[0],
offset_spec=fluss.OffsetSpec.timestamp(before_append_ms),
)
assert ts_before[0] == 0
# Intentional sleep to avoid race condition FlussError(code=38) The timestamp is invalid
await asyncio.sleep(1)
# Timestamp after append should resolve to offset 3
ts_after = await admin.list_offsets(
table_path,
bucket_ids=[0],
offset_spec=fluss.OffsetSpec.timestamp(after_append_ms),
)
assert ts_after[0] == 3
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_project(connection, admin):
"""Test column projection by name and by index."""
table_path = fluss.TablePath("fluss", "py_test_project")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema(
[
pa.field("col_a", pa.int32()),
pa.field("col_b", pa.string()),
pa.field("col_c", pa.int32()),
]
)
)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
batch = pa.RecordBatch.from_arrays(
[
pa.array([1, 2, 3], type=pa.int32()),
pa.array(["x", "y", "z"]),
pa.array([10, 20, 30], type=pa.int32()),
],
schema=pa.schema(
[
pa.field("col_a", pa.int32()),
pa.field("col_b", pa.string()),
pa.field("col_c", pa.int32()),
]
),
)
append_writer.write_arrow_batch(batch)
await append_writer.flush()
# Test project_by_name: select col_b and col_c only
scan = table.new_scan().project_by_name(["col_b", "col_c"])
scanner = await scan.create_log_scanner()
scanner.subscribe_buckets({0: 0})
records = await _poll_records(scanner, expected_count=3)
assert len(records) == 3
records.sort(key=lambda r: r.row["col_c"])
expected_col_b = ["x", "y", "z"]
expected_col_c = [10, 20, 30]
for i, record in enumerate(records):
assert record.row["col_b"] == expected_col_b[i]
assert record.row["col_c"] == expected_col_c[i]
# col_a should not be present in projected results
assert "col_a" not in record.row
# Test project by indices [1, 0] -> (col_b, col_a)
scanner2 = await table.new_scan().project([1, 0]).create_log_scanner()
scanner2.subscribe_buckets({0: 0})
records2 = await _poll_records(scanner2, expected_count=3)
assert len(records2) == 3
records2.sort(key=lambda r: r.row["col_a"])
for i, record in enumerate(records2):
assert record.row["col_b"] == expected_col_b[i]
assert record.row["col_a"] == [1, 2, 3][i]
assert "col_c" not in record.row
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_project_compound_types(connection, admin):
"""Projection selects and reorders ROW/MAP/ARRAY columns and drops others
(mirrors the Rust projection_with_compound_types IT)."""
table_path = fluss.TablePath("fluss", "py_test_project_compound")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema(
[
pa.field("id", pa.int32()),
pa.field(
"nested", pa.struct([("seq", pa.int32()), ("label", pa.string())])
),
pa.field("attrs", pa.map_(pa.string(), pa.int32())),
pa.field("tags", pa.list_(pa.string())),
pa.field("extra", pa.string()),
]
)
)
await admin.create_table(
table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
)
table = await connection.get_table(table_path)
aw = table.new_append().create_writer()
aw.append(
{
"id": 7,
"nested": {"seq": 42, "label": "hello"},
"attrs": {"x": 1, "y": 2},
"tags": ["alpha", "beta"],
"extra": "ignore-me",
}
)
await aw.flush()
# Reorder and drop `extra`.
scan = table.new_scan().project_by_name(["nested", "attrs", "tags", "id"])
scanner = await scan.create_log_scanner()
scanner.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
records = await _poll_records(scanner, expected_count=1)
assert len(records) == 1
row = records[0].row
assert row["nested"] == {"seq": 42, "label": "hello"}
assert dict(row["attrs"]) == {"x": 1, "y": 2}
assert row["tags"] == ["alpha", "beta"]
assert row["id"] == 7
assert "extra" not in row
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_poll_batches(connection, admin, wait_for_table_ready):
"""Test batch-based scanning with poll_arrow and poll_record_batch."""
table_path = fluss.TablePath("fluss", "py_test_poll_batches")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
await wait_for_table_ready(table_path)
table = await connection.get_table(table_path)
scanner = await table.new_scan().create_record_batch_log_scanner()
scanner.subscribe(bucket_id=0, start_offset=0)
# Empty table should return empty result
result = await scanner.poll_arrow(500)
assert result.num_rows == 0
writer = table.new_append().create_writer()
pa_schema = pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[pa.array([1, 2], type=pa.int32()), pa.array(["a", "b"])],
schema=pa_schema,
)
)
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[pa.array([3, 4], type=pa.int32()), pa.array(["c", "d"])],
schema=pa_schema,
)
)
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[pa.array([5, 6], type=pa.int32()), pa.array(["e", "f"])],
schema=pa_schema,
)
)
await writer.flush()
# Poll until we get all 6 records
all_ids = await _poll_arrow_ids(scanner, expected_count=6)
assert all_ids == [1, 2, 3, 4, 5, 6]
# Append more and verify offset continuation (no duplicates)
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[pa.array([7, 8], type=pa.int32()), pa.array(["g", "h"])],
schema=pa_schema,
)
)
await writer.flush()
new_ids = await _poll_arrow_ids(scanner, expected_count=2)
assert new_ids == [7, 8]
# Subscribe from mid-offset should truncate (skip earlier records)
trunc_scanner = await table.new_scan().create_record_batch_log_scanner()
trunc_scanner.subscribe(bucket_id=0, start_offset=3)
trunc_ids = await _poll_arrow_ids(trunc_scanner, expected_count=5)
assert trunc_ids == [4, 5, 6, 7, 8]
# Projection with batch scanner
proj_scanner = (
await table.new_scan().project_by_name(["id"]).create_record_batch_log_scanner()
)
proj_scanner.subscribe(bucket_id=0, start_offset=0)
batches = await proj_scanner.poll_record_batch(10000)
assert len(batches) > 0
assert batches[0].batch.num_columns == 1
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_to_arrow_and_to_pandas(connection, admin):
"""Test to_arrow() and to_pandas() convenience methods."""
table_path = fluss.TablePath("fluss", "py_test_to_arrow_pandas")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
pa_schema = pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3], type=pa.int32()), pa.array(["a", "b", "c"])],
schema=pa_schema,
)
)
await writer.flush()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
# to_arrow()
scanner = await table.new_scan().create_record_batch_log_scanner()
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
arrow_table = await scanner.to_arrow()
assert arrow_table.num_rows == 3
assert arrow_table.schema.names == ["id", "name"]
# to_pandas()
scanner2 = await table.new_scan().create_record_batch_log_scanner()
scanner2.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
df = await scanner2.to_pandas()
assert len(df) == 3
assert list(df.columns) == ["id", "name"]
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_to_arrow_batch_reader(connection, admin):
"""Test to_arrow_batch_reader() returns a lazy PyArrow RecordBatchReader."""
table_path = fluss.TablePath("fluss", "py_test_to_arrow_batch_reader")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
pa_schema = pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[pa.array([10, 20, 30], type=pa.int32()), pa.array(["x", "y", "z"])],
schema=pa_schema,
)
)
await writer.flush()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner = await table.new_scan().create_record_batch_log_scanner()
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
# to_arrow_batch_reader() is a blocking/sync API; run in a thread to
# avoid starving the asyncio event loop (see docstring warning).
def _read_all():
reader = scanner.to_arrow_batch_reader()
assert isinstance(reader, pa.RecordBatchReader)
assert reader.schema == pa_schema
batches = list(reader)
total_rows = sum(b.num_rows for b in batches)
assert total_rows == 3
result_table = pa.Table.from_batches(batches, schema=pa_schema)
assert result_table.column("id").to_pylist() == [10, 20, 30]
assert result_table.column("name").to_pylist() == ["x", "y", "z"]
await asyncio.to_thread(_read_all)
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_to_arrow_batch_reader_drop_and_guard(connection, admin):
"""Test reader-active guard and Drop cleanup on mid-iteration drop."""
table_path = fluss.TablePath("fluss", "py_test_batch_reader_drop_guard")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
pa_schema = pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
# Write multiple separate flushes so the server stores multiple log
# batches per bucket. This makes it likely that the reader's first poll
# only drains a subset, leaving real work for the Drop cleanup loop.
num_flushes = 10
rows_per_flush = 200
total_rows = num_flushes * rows_per_flush
for f in range(num_flushes):
start = f * rows_per_flush
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[
pa.array(
list(range(start, start + rows_per_flush)), type=pa.int32()
),
pa.array(
[f"row_{i}" for i in range(start, start + rows_per_flush)]
),
],
schema=pa_schema,
)
)
await writer.flush()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner = await table.new_scan().create_record_batch_log_scanner()
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
# to_arrow_batch_reader() is a blocking/sync API; run all blocking
# interactions in a thread to avoid starving the asyncio event loop.
def _test_guard_and_drop():
# --- Guard blocks subscribe / unsubscribe while reader is active ---
reader = scanner.to_arrow_batch_reader()
with pytest.raises(fluss.FlussError, match="RecordBatchLogReader is active"):
scanner.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
with pytest.raises(fluss.FlussError, match="RecordBatchLogReader is active"):
scanner.unsubscribe(0)
# --- Drop mid-iteration: read one batch, then discard ---
first_batch = next(reader)
assert first_batch.num_rows > 0
del reader
# --- Drop unsubscribed leftover buckets: creating a reader without
# re-subscribing must fail with "No buckets subscribed" ---
with pytest.raises(fluss.FlussError, match="No buckets subscribed"):
scanner.to_arrow_batch_reader()
# --- Guard cleared after drop: scanner is reusable from a fresh subscribe ---
scanner.subscribe_buckets(
{i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
)
reader2 = scanner.to_arrow_batch_reader()
batches = list(reader2)
assert sum(b.num_rows for b in batches) == total_rows
await asyncio.to_thread(_test_guard_and_drop)
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_partitioned_table_append_scan(connection, admin, wait_for_table_ready):
"""Test append and scan on a partitioned log table."""
table_path = fluss.TablePath("fluss", "py_test_partitioned_log_append")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema(
[
pa.field("id", pa.int32()),
pa.field("region", pa.string()),
pa.field("value", pa.int64()),
]
)
)
table_descriptor = fluss.TableDescriptor(
schema,
partition_keys=["region"],
)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
# Create partitions
for region in ["US", "EU"]:
await admin.create_partition(
table_path, {"region": region}, ignore_if_exists=True
)
await wait_for_table_ready(table_path, partition_name=region)
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
# Append rows
test_data = [
(1, "US", 100),
(2, "US", 200),
(3, "EU", 300),
(4, "EU", 400),
]
for id_, region, value in test_data:
append_writer.append({"id": id_, "region": region, "value": value})
await append_writer.flush()
# Append arrow batches per partition
pa_schema = pa.schema(
[
pa.field("id", pa.int32()),
pa.field("region", pa.string()),
pa.field("value", pa.int64()),
]
)
us_batch = pa.RecordBatch.from_arrays(
[
pa.array([5, 6], type=pa.int32()),
pa.array(["US", "US"]),
pa.array([500, 600], type=pa.int64()),
],
schema=pa_schema,
)
append_writer.write_arrow_batch(us_batch)
eu_batch = pa.RecordBatch.from_arrays(
[
pa.array([7, 8], type=pa.int32()),
pa.array(["EU", "EU"]),
pa.array([700, 800], type=pa.int64()),
],
schema=pa_schema,
)
append_writer.write_arrow_batch(eu_batch)
await append_writer.flush()
# Verify partition offsets
us_offsets = await admin.list_partition_offsets(
table_path,
partition_name="US",
bucket_ids=[0],
offset_spec=fluss.OffsetSpec.latest(),
)
assert us_offsets[0] == 4, "US partition should have 4 records"
eu_offsets = await admin.list_partition_offsets(
table_path,
partition_name="EU",
bucket_ids=[0],
offset_spec=fluss.OffsetSpec.latest(),
)
assert eu_offsets[0] == 4, "EU partition should have 4 records"
# Scan all partitions
scanner = await table.new_scan().create_log_scanner()
partition_infos = await admin.list_partition_infos(table_path)
for p in partition_infos:
scanner.subscribe_partition(
partition_id=p.partition_id, bucket_id=0, start_offset=0
)
expected = [
(1, "US", 100),
(2, "US", 200),
(3, "EU", 300),
(4, "EU", 400),
(5, "US", 500),
(6, "US", 600),
(7, "EU", 700),
(8, "EU", 800),
]
# Poll and verify per-bucket grouping
all_records = []
deadline = time.monotonic() + 10
while len(all_records) < 8 and time.monotonic() < deadline:
scan_records = await scanner.poll(5000)
for bucket, bucket_records in scan_records.items():
assert bucket.partition_id is not None, (
"Partitioned table should have partition_id"
)
# All records in a bucket should belong to the same partition
regions = {r.row["region"] for r in bucket_records}
assert len(regions) == 1, f"Bucket has mixed regions: {regions}"
all_records.extend(bucket_records)
assert len(all_records) == 8
collected = sorted(
[(r.row["id"], r.row["region"], r.row["value"]) for r in all_records],
key=lambda x: x[0],
)
assert collected == expected
# Test unsubscribe_partition: unsubscribe from EU, only US data should remain
unsub_scanner = await table.new_scan().create_log_scanner()
eu_partition_id = next(
p.partition_id for p in partition_infos if p.partition_name == "EU"
)
for p in partition_infos:
unsub_scanner.subscribe_partition(p.partition_id, 0, 0)
unsub_scanner.unsubscribe_partition(eu_partition_id, 0)
remaining = await _poll_records(unsub_scanner, expected_count=4, timeout_s=5)
assert len(remaining) == 4
assert all(r.row["region"] == "US" for r in remaining)
# Test subscribe_partition_buckets (batch subscribe)
batch_scanner = await table.new_scan().create_log_scanner()
partition_bucket_offsets = {
(p.partition_id, 0): fluss.EARLIEST_OFFSET for p in partition_infos
}
batch_scanner.subscribe_partition_buckets(partition_bucket_offsets)
batch_records = await _poll_records(batch_scanner, expected_count=8)
assert len(batch_records) == 8
batch_collected = sorted(
[(r.row["id"], r.row["region"], r.row["value"]) for r in batch_records],
key=lambda x: x[0],
)
assert batch_collected == expected
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_write_arrow(connection, admin):
"""Test writing a full PyArrow Table via write_arrow()."""
table_path = fluss.TablePath("fluss", "py_test_write_arrow")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
pa_schema = pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
arrow_table = pa.table(
{
"id": pa.array([1, 2, 3, 4, 5], type=pa.int32()),
"name": pa.array(["alice", "bob", "charlie", "dave", "eve"]),
},
schema=pa_schema,
)
writer.write_arrow(arrow_table)
await writer.flush()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner = await table.new_scan().create_record_batch_log_scanner()
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
result = await scanner.to_arrow()
assert result.num_rows == 5
ids = sorted(result.column("id").to_pylist())
names = [
n
for _, n in sorted(
zip(result.column("id").to_pylist(), result.column("name").to_pylist())
)
]
assert ids == [1, 2, 3, 4, 5]
assert names == ["alice", "bob", "charlie", "dave", "eve"]
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_write_pandas(connection, admin):
"""Test writing a Pandas DataFrame via write_pandas()."""
import pandas as pd
table_path = fluss.TablePath("fluss", "py_test_write_pandas")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
df = pd.DataFrame({"id": [10, 20, 30], "name": ["x", "y", "z"]})
writer.write_pandas(df)
await writer.flush()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner = await table.new_scan().create_record_batch_log_scanner()
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
result = await scanner.to_pandas()
assert len(result) == 3
result_sorted = result.sort_values("id").reset_index(drop=True)
assert result_sorted["id"].tolist() == [10, 20, 30]
assert result_sorted["name"].tolist() == ["x", "y", "z"]
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_partitioned_table_to_arrow(connection, admin, wait_for_table_ready):
"""Test to_arrow() on partitioned tables."""
table_path = fluss.TablePath("fluss", "py_test_partitioned_to_arrow")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema(
[
pa.field("id", pa.int32()),
pa.field("region", pa.string()),
pa.field("value", pa.int64()),
]
)
)
table_descriptor = fluss.TableDescriptor(schema, partition_keys=["region"])
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
for region in ["US", "EU"]:
await admin.create_partition(
table_path, {"region": region}, ignore_if_exists=True
)
await wait_for_table_ready(table_path, partition_name=region)
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
writer.append({"id": 1, "region": "US", "value": 100})
writer.append({"id": 2, "region": "EU", "value": 200})
await writer.flush()
scanner = await table.new_scan().create_record_batch_log_scanner()
partition_infos = await admin.list_partition_infos(table_path)
for p in partition_infos:
scanner.subscribe_partition(p.partition_id, 0, fluss.EARLIEST_OFFSET)
arrow_table = await scanner.to_arrow()
assert arrow_table.num_rows == 2
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_scan_records_indexing_and_slicing(connection, admin):
"""Test ScanRecords indexing, slicing (incl. negative steps), and iteration consistency."""
table_path = fluss.TablePath("fluss", "py_test_scan_records_indexing")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("val", pa.string())])
)
await admin.create_table(table_path, fluss.TableDescriptor(schema))
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[
pa.array(list(range(1, 9)), type=pa.int32()),
pa.array([f"v{i}" for i in range(1, 9)]),
],
schema=pa.schema(
[pa.field("id", pa.int32()), pa.field("val", pa.string())]
),
)
)
await writer.flush()
scanner = await table.new_scan().create_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
# Poll until we get a non-empty ScanRecords (need ≥2 records for slice tests)
sr = None
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
sr = await scanner.poll(5000)
if len(sr) >= 2:
break
assert sr is not None and len(sr) >= 2, "Expected at least 2 records"
n = len(sr)
offsets = [sr[i].offset for i in range(n)]
# Iteration and indexing must produce the same order
assert [r.offset for r in sr] == offsets
# Negative indexing
assert sr[-1].offset == offsets[-1]
assert sr[-n].offset == offsets[0]
# Verify slices match the same operation on the offsets reference list
test_slices = [
slice(1, n - 1), # forward subrange
slice(None, None, -1), # [::-1] full reverse
slice(n - 2, 0, -1), # reverse with bounds
slice(n - 1, 0, -2), # reverse with step
slice(None, None, 2), # [::2]
slice(1, None, 3), # [1::3]
slice(2, 2), # empty
]
for s in test_slices:
result = [r.offset for r in sr[s]]
assert result == offsets[s], f"slice {s}: got {result}, expected {offsets[s]}"
# Bucket-based indexing
for bucket in sr.buckets():
assert len(sr[bucket]) > 0
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_async_iterator(connection, admin):
"""Test the Python asynchronous iterator loop (`async for`) on LogScanner."""
table_path = fluss.TablePath("fluss", "py_test_async_iterator")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("val", pa.string())])
)
await admin.create_table(table_path, fluss.TableDescriptor(schema))
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
# Write 5 records
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[
pa.array(list(range(1, 6)), type=pa.int32()),
pa.array([f"async{i}" for i in range(1, 6)]),
],
schema=pa.schema(
[pa.field("id", pa.int32()), pa.field("val", pa.string())]
),
)
)
await writer.flush()
scanner = await table.new_scan().create_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
collected = []
# Here is the magical Issue #424 async iterator logic at work:
async def consume_scanner():
async for record in scanner:
collected.append(record)
if len(collected) == 5:
break
await consume_scanner()
assert len(collected) == 5, f"Expected 5 records, got {len(collected)}"
collected.sort(key=lambda r: r.row["id"])
for i, record in enumerate(collected):
assert record.row["id"] == i + 1
assert record.row["val"] == f"async{i + 1}"
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_async_iterator_break_no_leak(connection, admin):
"""Verify that breaking out of `async for` does not leak resources.
After breaking, the scanner must still be usable for synchronous
`poll()` calls. If the old implementation's tokio::spawn'd task
were still alive, it would hold the Mutex and cause `poll()` to
deadlock or error.
"""
table_path = fluss.TablePath("fluss", "py_test_async_break_leak")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("val", pa.string())])
)
await admin.create_table(table_path, fluss.TableDescriptor(schema))
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[
pa.array(list(range(1, 11)), type=pa.int32()),
pa.array([f"v{i}" for i in range(1, 11)]),
],
schema=pa.schema(
[pa.field("id", pa.int32()), pa.field("val", pa.string())]
),
)
)
await writer.flush()
scanner = await table.new_scan().create_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
# Phase 1: async for with early break (collect only 3 of 10)
collected_async = []
async def consume_and_break():
async for record in scanner:
collected_async.append(record)
if len(collected_async) >= 3:
break
await consume_and_break()
assert len(collected_async) == 3, (
f"Expected 3 records from async for, got {len(collected_async)}"
)
# Phase 2: sync poll() must still work — proves no leaked task / lock.
# With small data and few buckets, _async_poll may have fetched all
# records in one batch. After break, the un-yielded records from that
# batch are lost. So sync poll may return 0 records — the key assertion
# is that poll() completes without deadlock (returns within timeout).
remaining = await scanner.poll(2000)
assert remaining is not None, "poll() should return (not deadlock)"
# If we got records, verify no duplicates
async_ids = {r.row["id"] for r in collected_async}
sync_ids = {r.row["id"] for r in remaining}
assert async_ids.isdisjoint(sync_ids), (
f"Duplicate IDs between async and sync: {async_ids & sync_ids}"
)
# All IDs must be from the original 1-10 range
all_ids = async_ids | sync_ids
assert all_ids.issubset(set(range(1, 11))), (
f"Unexpected IDs: {all_ids - set(range(1, 11))}"
)
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_async_iterator_multiple_batches(connection, admin):
"""Verify async iteration works across multiple network poll cycles.
_async_poll does a single bounded poll per call. Writing 20 records
to multiple buckets ensures the Python generator must loop through
several _async_poll calls to collect them all.
"""
table_path = fluss.TablePath("fluss", "py_test_async_multi_batch")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("val", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema, bucket_count=3, bucket_keys=["id"])
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
num_records = 20
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[
pa.array(list(range(1, num_records + 1)), type=pa.int32()),
pa.array([f"multi{i}" for i in range(1, num_records + 1)]),
],
schema=pa.schema(
[pa.field("id", pa.int32()), pa.field("val", pa.string())]
),
)
)
await writer.flush()
scanner = await table.new_scan().create_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
collected = []
async def consume_all():
async for record in scanner:
collected.append(record)
if len(collected) >= num_records:
break
await consume_all()
assert len(collected) == num_records, (
f"Expected {num_records} records, got {len(collected)}"
)
# Verify all IDs are present (order may vary due to bucketing)
ids = sorted(r.row["id"] for r in collected)
assert ids == list(range(1, num_records + 1))
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_batch_async_iterator(connection, admin):
"""Test the Python asynchronous iterator loop (`async for`) on a batch LogScanner.
With our __aiter__ dispatch, a batch-based scanner should yield RecordBatch
objects (not ScanRecord). Each yielded item has .batch (PyArrow RecordBatch),
.bucket, .base_offset, .last_offset.
"""
table_path = fluss.TablePath("fluss", "py_test_batch_async_iter")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("val", pa.string())])
)
await admin.create_table(table_path, fluss.TableDescriptor(schema))
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[
pa.array(list(range(1, 7)), type=pa.int32()),
pa.array([f"bv{i}" for i in range(1, 7)]),
],
schema=pa.schema(
[pa.field("id", pa.int32()), pa.field("val", pa.string())]
),
)
)
await writer.flush()
batch_scanner = await table.new_scan().create_record_batch_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
batch_scanner.subscribe_buckets(
{i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
)
collected_batches = []
total_rows = 0
async def consume_batches():
nonlocal total_rows
async for rb in batch_scanner:
collected_batches.append(rb)
total_rows += rb.batch.num_rows
if total_rows >= 6:
break
await consume_batches()
assert total_rows >= 6, f"Expected >=6 total rows, got {total_rows}"
assert len(collected_batches) > 0
# Verify each yielded item is a RecordBatch with expected attributes
for rb in collected_batches:
assert hasattr(rb, "batch"), "RecordBatch should have .batch"
assert hasattr(rb, "bucket"), "RecordBatch should have .bucket"
assert hasattr(rb, "base_offset"), "RecordBatch should have .base_offset"
assert hasattr(rb, "last_offset"), "RecordBatch should have .last_offset"
# .batch should be a PyArrow RecordBatch
arrow_batch = rb.batch
assert isinstance(arrow_batch, pa.RecordBatch), (
f"Expected PyArrow RecordBatch, got {type(arrow_batch).__name__}"
)
assert arrow_batch.num_columns == 2
assert set(arrow_batch.schema.names) == {"id", "val"}
# Verify all 6 IDs are present
all_ids = []
for rb in collected_batches:
all_ids.extend(rb.batch.column("id").to_pylist())
assert sorted(all_ids[:6]) == [1, 2, 3, 4, 5, 6]
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_batch_async_iterator_break_no_leak(connection, admin):
"""Verify that breaking out of batch `async for` does not leak resources.
After breaking, the scanner must still be usable for synchronous
poll_record_batch() calls, proving no leaked task or lock.
"""
table_path = fluss.TablePath("fluss", "py_test_batch_async_break")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("val", pa.string())])
)
await admin.create_table(table_path, fluss.TableDescriptor(schema))
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[
pa.array(list(range(1, 11)), type=pa.int32()),
pa.array([f"bl{i}" for i in range(1, 11)]),
],
schema=pa.schema(
[pa.field("id", pa.int32()), pa.field("val", pa.string())]
),
)
)
await writer.flush()
batch_scanner = await table.new_scan().create_record_batch_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
batch_scanner.subscribe_buckets(
{i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
)
# Phase 1: async for with early break (collect just 1 batch)
first_batch = None
async def consume_and_break():
nonlocal first_batch
async for rb in batch_scanner:
first_batch = rb
break
await consume_and_break()
assert first_batch is not None, "Should have received at least 1 batch"
assert first_batch.batch.num_rows > 0
# Phase 2: sync poll_record_batch() must still work — proves no leak
remaining = await batch_scanner.poll_record_batch(2000)
assert remaining is not None, "poll_record_batch() should return (not deadlock)"
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_batch_async_iterator_multiple_batches(connection, admin):
"""Verify batch async iteration works across multiple network poll cycles.
Writing 20 records to 3 buckets ensures the generator must loop through
several _async_poll_batches calls to collect them all.
"""
table_path = fluss.TablePath("fluss", "py_test_batch_async_multi")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("val", pa.string())])
)
table_descriptor = fluss.TableDescriptor(schema, bucket_count=3, bucket_keys=["id"])
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
num_records = 20
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
[
pa.array(list(range(1, num_records + 1)), type=pa.int32()),
pa.array([f"bm{i}" for i in range(1, num_records + 1)]),
],
schema=pa.schema(
[pa.field("id", pa.int32()), pa.field("val", pa.string())]
),
)
)
await writer.flush()
batch_scanner = await table.new_scan().create_record_batch_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
batch_scanner.subscribe_buckets(
{i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
)
all_ids = []
async def consume_all():
async for rb in batch_scanner:
all_ids.extend(rb.batch.column("id").to_pylist())
if len(all_ids) >= num_records:
break
await consume_all()
assert len(all_ids) >= num_records, (
f"Expected >={num_records} IDs, got {len(all_ids)}"
)
assert sorted(all_ids[:num_records]) == list(range(1, num_records + 1))
await admin.drop_table(table_path, ignore_if_not_exists=False)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _poll_records(scanner, expected_count, timeout_s=10):
"""Poll a record-based scanner until expected_count records are collected."""
collected = []
deadline = time.monotonic() + timeout_s
while len(collected) < expected_count and time.monotonic() < deadline:
records = await scanner.poll(5000)
collected.extend(records)
return collected
async def _poll_arrow_ids(scanner, expected_count, timeout_s=10):
"""Poll a batch scanner and extract 'id' column values."""
all_ids = []
deadline = time.monotonic() + timeout_s
while len(all_ids) < expected_count and time.monotonic() < deadline:
arrow_table = await scanner.poll_arrow(5000)
if arrow_table.num_rows > 0:
all_ids.extend(arrow_table.column("id").to_pylist())
return all_ids
async def test_append_and_scan_with_array(connection, admin):
"""Test appending and scanning with array columns."""
table_path = fluss.TablePath("fluss", "py_test_append_and_scan_with_array")
await admin.drop_table(table_path, ignore_if_not_exists=True)
pa_schema = pa.schema(
[
pa.field("id", pa.int32()),
pa.field("tags", pa.list_(pa.string())),
pa.field("scores", pa.list_(pa.int32())),
]
)
schema = fluss.Schema(pa_schema)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
# Batch 1: Testing standard lists
batch1 = pa.RecordBatch.from_arrays(
[
pa.array([1, 2], type=pa.int32()),
pa.array([["a", "b"], ["c"]], type=pa.list_(pa.string())),
pa.array([[10, 20], [30]], type=pa.list_(pa.int32())),
],
schema=pa_schema,
)
append_writer.write_arrow_batch(batch1)
# Batch 2: Testing null values inside arrays and null arrays
batch2 = pa.RecordBatch.from_arrays(
[
pa.array([3, 4, 5, 6], type=pa.int32()),
pa.array([["d", None], None, [], [None]], type=pa.list_(pa.string())),
pa.array([[40, 50], [60], None, []], type=pa.list_(pa.int32())),
],
schema=pa_schema,
)
append_writer.write_arrow_batch(batch2)
await append_writer.flush()
# Verify via LogScanner (record-by-record)
scanner = await table.new_scan().create_log_scanner()
scanner.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
records = await _poll_records(scanner, expected_count=6)
assert len(records) == 6
records.sort(key=lambda r: r.row["id"])
# Verify Batch 1
assert records[0].row["tags"] == ["a", "b"]
assert records[0].row["scores"] == [10, 20]
assert records[1].row["tags"] == ["c"]
assert records[1].row["scores"] == [30]
# Verify Batch 2
assert records[2].row["tags"] == ["d", None]
assert records[2].row["scores"] == [40, 50]
assert records[3].row["tags"] is None
assert records[3].row["scores"] == [60]
assert records[4].row["tags"] == []
assert records[4].row["scores"] is None
assert records[5].row["tags"] == [None]
assert records[5].row["scores"] == []
# Verify via to_arrow (batch-based)
scanner2 = await table.new_scan().create_record_batch_log_scanner()
scanner2.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
result_table = await scanner2.to_arrow()
assert result_table.num_rows == 6
assert result_table.column("tags").to_pylist() == [
["a", "b"],
["c"],
["d", None],
None,
[],
[None],
]
assert result_table.column("scores").to_pylist() == [
[10, 20],
[30],
[40, 50],
[60],
None,
[],
]
async def test_append_rows_with_array(connection, admin):
"""Test appending rows with array data as Python lists and scanning."""
table_path = fluss.TablePath("fluss", "py_test_append_rows_with_array")
await admin.drop_table(table_path, ignore_if_not_exists=True)
pa_schema = pa.schema(
[
pa.field("id", pa.int32()),
pa.field("tags", pa.list_(pa.string())),
pa.field("scores", pa.list_(pa.int32())),
]
)
schema = fluss.Schema(pa_schema)
table_descriptor = fluss.TableDescriptor(schema)
await admin.create_table(table_path, table_descriptor, ignore_if_exists=False)
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
# Append rows using dicts with lists
append_writer.append({"id": 1, "tags": ["a", "b"], "scores": [10, 20]})
append_writer.append({"id": 2, "tags": ["c"], "scores": [30]})
# Append row using list with nested list (null handling)
append_writer.append([3, None, [40, None, 60]])
await append_writer.flush()
scanner = await table.new_scan().create_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
records = await _poll_records(scanner, expected_count=3)
assert len(records) == 3
rows = sorted([r.row for r in records], key=lambda r: r["id"])
assert rows[0] == {"id": 1, "tags": ["a", "b"], "scores": [10, 20]}
assert rows[1] == {"id": 2, "tags": ["c"], "scores": [30]}
# Note: records[2].row["tags"] will be None, records[2].row["scores"] will be [40, None, 60]
assert rows[2]["id"] == 3
assert rows[2]["tags"] is None
assert rows[2]["scores"] == [40, None, 60]
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_append_rows_with_nested_array(connection, admin):
"""Test appending rows with nested array data (ARRAY<ARRAY<INT>>) and scanning."""
table_path = fluss.TablePath("fluss", "py_test_append_rows_with_nested_array")
await admin.drop_table(table_path, ignore_if_not_exists=True)
pa_schema = pa.schema(
[
pa.field("id", pa.int32()),
pa.field("matrix", pa.list_(pa.list_(pa.int32()))),
]
)
schema = fluss.Schema(pa_schema)
await admin.create_table(
table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
)
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
# Append nested lists
append_writer.append({"id": 1, "matrix": [[1, 2], [3, 4]]})
append_writer.append({"id": 2, "matrix": [[], [5], [6, 7, 8]]})
append_writer.append({"id": 3, "matrix": None})
append_writer.append({"id": 4, "matrix": [[1, None], None, []]})
append_writer.append({"id": 5, "matrix": [[None, None]]})
await append_writer.flush()
scanner = await table.new_scan().create_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in range(num_buckets)})
records = await _poll_records(scanner, expected_count=5)
assert len(records) == 5
rows = sorted([r.row for r in records], key=lambda r: r["id"])
assert rows[0] == {"id": 1, "matrix": [[1, 2], [3, 4]]}
assert rows[1] == {"id": 2, "matrix": [[], [5], [6, 7, 8]]}
assert rows[2] == {"id": 3, "matrix": None}
assert rows[3] == {"id": 4, "matrix": [[1, None], None, []]}
assert rows[4] == {"id": 5, "matrix": [[None, None]]}
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_append_rows_with_invalid_array(connection, admin):
"""Test that appending invalid data to an array column raises an error."""
table_path = fluss.TablePath("fluss", "py_test_append_rows_with_invalid_array")
await admin.drop_table(table_path, ignore_if_not_exists=True)
pa_schema = pa.schema(
[
pa.field("id", pa.int32()),
pa.field("tags", pa.list_(pa.string())),
]
)
schema = fluss.Schema(pa_schema)
await admin.create_table(
table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
)
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
# Appending a string instead of a list should raise an error
with pytest.raises(Exception, match="Expected sequence for Array column"):
append_writer.append({"id": 4, "tags": "not_a_list"})
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_all_complex_datatypes(connection, admin):
"""Comprehensive ARRAY/MAP/ROW coverage on a log table.
Appends fully-populated, edge-case, and all-null rows (see complex_types.py)
and verifies them through both the record (dict) scan path and the Arrow
scan path -- the two paths must agree. Mirrors the section-based
``all_supported_datatypes`` structure of the Rust integration tests.
"""
table_path = fluss.TablePath("fluss", "py_test_log_all_complex")
await admin.drop_table(table_path, ignore_if_not_exists=True)
schema = complex_schema()
await admin.create_table(
table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
)
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
append_writer.append(complex_full_row(1))
append_writer.append(complex_edge_row(2))
append_writer.append(complex_null_row(3))
await append_writer.flush()
# Record (dict) scan path.
scanner = await table.new_scan().create_log_scanner()
scanner.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
records = await _poll_records(scanner, expected_count=3)
assert len(records) == 3
poll_rows = sorted([r.row for r in records], key=lambda r: r["id"])
assert_complex_full(poll_rows[0])
assert_complex_edge(poll_rows[1])
for col in complex_column_names():
assert poll_rows[2][col] is None
# Arrow scan path: to_pylist() agrees with the dict path column-for-column,
# except NaN-bearing float columns (NaN != NaN), which are checked elsewhere.
scanner2 = await table.new_scan().create_record_batch_log_scanner()
scanner2.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
result_table = await scanner2.to_arrow()
assert result_table.num_rows == 3
arrow_rows = result_table.sort_by("id").to_pylist()
float_cols = {"arr_float", "arr_double", "map_float", "map_double"}
for i in range(3):
for col in complex_column_names():
if col in float_cols:
continue
assert arrow_rows[i][col] == poll_rows[i][col], (
f"scan-path mismatch at row {i}, column {col}"
)
await admin.drop_table(table_path, ignore_if_not_exists=False)
async def test_append_arrow_batch_complex_types(connection, admin):
"""Arrow write path: write MAP and ROW columns via write_arrow_batch and
verify through both the record and Arrow scan paths."""
table_path = fluss.TablePath("fluss", "py_test_arrow_batch_complex")
await admin.drop_table(table_path, ignore_if_not_exists=True)
row_type = pa.struct([("seq", pa.int32()), ("label", pa.string())])
map_type = pa.map_(pa.string(), pa.int32())
pa_schema = pa.schema(
[
pa.field("id", pa.int32()),
pa.field("attrs", map_type),
pa.field("nested", row_type),
]
)
schema = fluss.Schema(pa_schema)
await admin.create_table(
table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
)
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
batch = pa.RecordBatch.from_arrays(
[
pa.array([1, 2], type=pa.int32()),
pa.array([[("a", 1), ("b", 2)], []], type=map_type),
pa.array(
[{"seq": 10, "label": "open"}, {"seq": 20, "label": None}],
type=row_type,
),
],
schema=pa_schema,
)
append_writer.write_arrow_batch(batch)
await append_writer.flush()
# Record scan path.
scanner = await table.new_scan().create_log_scanner()
scanner.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
records = await _poll_records(scanner, expected_count=2)
assert len(records) == 2
rows = sorted([r.row for r in records], key=lambda r: r["id"])
assert dict(rows[0]["attrs"]) == {"a": 1, "b": 2}
assert rows[0]["nested"] == {"seq": 10, "label": "open"}
assert rows[1]["attrs"] == []
assert rows[1]["nested"] == {"seq": 20, "label": None}
# Arrow scan path.
scanner2 = await table.new_scan().create_record_batch_log_scanner()
scanner2.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
result_table = await scanner2.to_arrow()
assert result_table.column("id").to_pylist() == [1, 2]
assert result_table.column("attrs").to_pylist() == [[("a", 1), ("b", 2)], []]
assert result_table.column("nested").to_pylist() == [
{"seq": 10, "label": "open"},
{"seq": 20, "label": None},
]
await admin.drop_table(table_path, ignore_if_not_exists=False)