blob: 7f123948a24c26e9eb6967e52924e1a0c7a29332 [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.
"""Validate ROSBag transforms before creating a Paimon writer."""
import io
from contextlib import contextmanager
from pathlib import Path
import shutil
from tempfile import TemporaryDirectory
from urllib.parse import quote, urlparse
import pyarrow as pa
from pypaimon.multimodal.arrow_utils import strict_arrow_table
from pypaimon.multimodal.rosbag.source import RosbagSource
from pypaimon.multimodal.rosbag.staging import (
_materialized_rosbag,
_verify_manifest_members,
)
from pypaimon.multimodal.table import _target_schema
from pypaimon.write.commit_callback import CommitCallback
class _SnapshotRecorder(CommitCallback):
def __init__(self):
self.snapshot_id = None
def call(self, context):
self.snapshot_id = context.snapshot.id
class _BoundedStagingOutput(io.RawIOBase):
"""File output that rejects a write before it exceeds its byte limit."""
def __init__(self, path, max_bytes):
super().__init__()
self._output = Path(path).open("wb")
self._max_bytes = max_bytes
self._reserved_bytes = 0
def set_reserved_bytes(self, byte_count):
required_bytes = self._output.tell() + byte_count
if required_bytes > self._max_bytes:
self._raise_limit(required_bytes)
self._reserved_bytes = byte_count
def writable(self):
return True
def write(self, value):
required_bytes = (
self._output.tell() + len(value) + self._reserved_bytes)
if required_bytes > self._max_bytes:
self._raise_limit(required_bytes)
return self._output.write(value)
def _raise_limit(self, required_bytes):
raise ValueError(
"ROSBag staging exceeds configured limit of %d bytes "
"(requires at least %d bytes)."
% (self._max_bytes, required_bytes))
def tell(self):
return self._output.tell()
def flush(self):
if not self._output.closed:
self._output.flush()
def close(self):
if not self.closed:
try:
super().close()
finally:
self._output.close()
@contextmanager
def _staging_sink(path, max_bytes):
if max_bytes is None:
with pa.OSFile(str(path), "wb") as sink:
yield sink, None
return
with _BoundedStagingOutput(path, max_bytes) as output:
with pa.PythonFile(output, mode="w") as sink:
yield sink, output
def _load_rosbag_manifests(
table,
manifests,
transform,
source_file_io,
AnyReader,
*,
default_typestore,
typestore_factory,
staging):
from pypaimon.multimodal.rosbag.api import RosbagLoadResult
target_schema = _target_schema(table.raw_table)
with TemporaryDirectory(
prefix="pypaimon_rosbag_", dir=staging.directory) as temp_dir:
_check_staging_free_space(temp_dir, staging)
spool_path = Path(temp_dir) / "validated.arrow"
batch_count = 0
row_count = 0
with _staging_sink(
spool_path, staging.max_bytes) as (sink, bounded_output):
with pa.ipc.new_file(sink, target_schema) as spool:
for source_index, manifest in enumerate(manifests):
source_staging_bytes = _source_staging_bytes(manifest)
_check_staging_bytes(
sink.tell() + source_staging_bytes, staging)
if bounded_output is not None:
bounded_output.set_reserved_bytes(
source_staging_bytes)
try:
for table_value in _transform_rosbag_manifest(
manifest,
transform,
source_file_io,
AnyReader,
target_schema,
default_typestore=default_typestore,
typestore_factory=typestore_factory,
staging=staging,
staging_root=Path(temp_dir) /
("source-%06d" % source_index),
batch_index=batch_count,
base_staging_bytes=sink.tell()):
batch_count += 1
row_count += table_value.num_rows
for record_batch in table_value.to_batches():
spool.write_batch(record_batch)
_check_staging_bytes(
sink.tell() + source_staging_bytes,
staging)
finally:
if bounded_output is not None:
bounded_output.set_reserved_bytes(0)
_check_staging_bytes(sink.tell(), staging)
for manifest in manifests:
_verify_manifest_members(manifest, source_file_io)
snapshot_id = _write_spool(table, spool_path)
return RosbagLoadResult(
source_count=len(manifests),
batch_count=batch_count,
row_count=row_count,
snapshot_id=snapshot_id,
)
def _source_staging_bytes(manifest):
if urlparse(manifest.uri).scheme.lower() == "file":
return 0
return sum(member.size for member in manifest.members)
def _check_staging_bytes(actual_bytes, staging):
if staging.max_bytes is not None and actual_bytes > staging.max_bytes:
raise ValueError(
"ROSBag staging exceeds configured limit of %d bytes "
"(requires at least %d bytes)."
% (staging.max_bytes, actual_bytes))
def _check_staging_free_space(directory, staging):
if shutil.disk_usage(str(directory)).free < staging.min_free_bytes:
raise ValueError(
"ROSBag staging has less than %d free bytes at %s."
% (staging.min_free_bytes, directory))
def _transform_rosbag_manifest(
manifest,
transform,
source_file_io,
AnyReader,
target_schema,
*,
default_typestore,
typestore_factory,
staging,
staging_root,
batch_index=0,
base_staging_bytes=0):
"""Yield validated Arrow tables for one fully preflighted source."""
produced_rows = 0
with _materialized_rosbag(
manifest,
source_file_io,
staging_root,
staging,
base_staging_bytes=base_staging_bytes) as local_path:
if manifest.format == "ros2_sqlite3_fragment":
_validate_sqlite_fragment(local_path, manifest.uri)
_preflight_reader(
manifest,
local_path,
AnyReader,
default_typestore,
typestore_factory,
)
source = RosbagSource(
uri=manifest.uri,
local_path=local_path,
format=manifest.format,
)
typestore = _new_typestore(default_typestore, typestore_factory)
with AnyReader(
[local_path], default_typestore=typestore) as reader:
transformed = transform(reader, source)
batches = None
try:
batches = _arrow_batches(transformed)
for index, value in enumerate(batches, start=batch_index):
table_value = strict_arrow_table(
value,
target_schema,
manifest.uri,
index,
"ROSBag",
)
produced_rows += table_value.num_rows
yield table_value
finally:
_close_transform_iterator(
batches if batches is not None else transformed)
if produced_rows == 0:
raise ValueError(
"ROSBag source %s produced no rows." % manifest.uri)
def _validate_sqlite_fragment(local_path, source_uri):
import sqlite3
database_uri = "file:%s?mode=ro&immutable=1" % quote(
str(local_path), safe="/")
try:
connection = sqlite3.connect(database_uri, uri=True)
try:
results = [row[0] for row in connection.execute(
"PRAGMA quick_check")]
finally:
connection.close()
except sqlite3.DatabaseError as error:
raise ValueError(
"SQLite integrity check failed for ROSBag fragment %s: %s"
% (source_uri, error)) from error
if results != ["ok"]:
raise ValueError(
"SQLite integrity check failed for ROSBag fragment %s: %s"
% (source_uri, "; ".join(results)))
def _preflight_reader(
manifest,
local_path,
AnyReader,
default_typestore,
typestore_factory):
typestore = _new_typestore(default_typestore, typestore_factory)
with AnyReader([local_path], default_typestore=typestore) as reader:
topic_counts = {}
actual_count = 0
for connection, _, _ in reader.messages():
actual_count += 1
topic_counts[connection.id] = (
topic_counts.get(connection.id, 0) + 1)
declared_count = reader.message_count
connections = list(reader.connections)
if actual_count != declared_count:
raise ValueError(
"ROSBag source %s declared %d messages but %d were readable."
% (manifest.uri, declared_count, actual_count))
if (
manifest.expected_message_count is not None
and actual_count != manifest.expected_message_count):
raise ValueError(
"ROSBag source %s metadata declares %d messages but %d were "
"readable."
% (
manifest.uri,
manifest.expected_message_count,
actual_count,
))
for connection in connections:
actual_topic_count = topic_counts.get(connection.id, 0)
if actual_topic_count != connection.msgcount:
raise ValueError(
"ROSBag source %s topic %s declares %d messages but %d "
"were readable."
% (
manifest.uri,
connection.topic,
connection.msgcount,
actual_topic_count,
))
def _new_typestore(default_typestore, typestore_factory):
if typestore_factory is not None:
return typestore_factory()
return default_typestore
def _arrow_batches(transformed):
if isinstance(transformed, (pa.Table, pa.RecordBatch)):
return iter([transformed])
if transformed is None or isinstance(transformed, (str, bytes, dict)):
raise ValueError(
"ROSBag transform must return Arrow data or an iterable of "
"Arrow data.")
try:
return iter(transformed)
except TypeError as error:
raise ValueError(
"ROSBag transform must return Arrow data or an iterable of "
"Arrow data.") from error
def _close_transform_iterator(iterator):
close = getattr(iterator, "close", None)
if close is not None:
close()
def _write_spool(table, spool_path):
write_builder = table.raw_table.new_batch_write_builder()
table_write = None
table_commit = None
commit_started = False
snapshot_recorder = _SnapshotRecorder()
try:
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
table_commit.add_commit_callback(snapshot_recorder)
with pa.memory_map(str(spool_path), "r") as source:
spool = pa.ipc.open_file(source)
for index in range(spool.num_record_batches):
table_write.write_arrow(pa.Table.from_batches([
spool.get_batch(index)]))
commit_messages = table_write.prepare_commit()
commit_started = True
table_commit.commit(commit_messages)
if snapshot_recorder.snapshot_id is None:
raise RuntimeError(
"ROSBag append committed without reporting a snapshot id.")
return snapshot_recorder.snapshot_id
except BaseException:
if table_write is not None and not commit_started:
table_write.abort()
raise
finally:
try:
if table_write is not None:
table_write.close()
finally:
if table_commit is not None:
table_commit.close()