blob: 39f49b3d6410501620ed25ecf241bea2b15ee2ca [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.
"""Tests for ``WriteBuffer`` and the deferred-fold write path.
The regression these guard: ``DataWriter.write`` used to fold every incoming
batch into its buffer and then measure the result, which made writing N batches
O(N^2) -- ``pa.concat_tables`` leaves N chunks per column and the
``pa.Table.nbytes`` walk behind each rolling decision re-visits all of them.
Folding is now deferred, so the tests below assert both halves of that: the
fold count no longer scales with the number of writes, and every rolling
trigger the eager path had still fires.
"""
import contextlib
import unittest
import pyarrow as pa
from pypaimon.write.writer.append_only_data_writer import AppendOnlyDataWriter
from pypaimon.write.writer.data_vector_writer import DataVectorWriter
from pypaimon.write.writer.dedicated_format_writer import DedicatedFormatWriter
from pypaimon.write.writer.write_buffer import WriteBuffer
_SCHEMA = pa.schema([
pa.field('id', pa.int64(), nullable=False),
pa.field('name', pa.string()),
])
# Anything past this is effectively "no rolling"; matches how
# ``target_file_row_num`` defaults to the max long when the option is unset.
_NO_LIMIT = 2 ** 63 - 1
def _batch(start: int, num_rows: int) -> pa.RecordBatch:
return pa.RecordBatch.from_pydict(
{
'id': list(range(start, start + num_rows)),
'name': ['n%d' % i for i in range(start, start + num_rows)],
},
schema=_SCHEMA,
)
def _table(start: int, num_rows: int) -> pa.Table:
return pa.Table.from_batches([_batch(start, num_rows)])
# Same columns and types as ``_SCHEMA``, but ``id`` is nullable. This is one of
# the differences ``TableWrite._validate_pyarrow_schema`` lets through (it only
# compares field types) while ``pa.concat_tables`` rejects it.
_NULLABLE_SCHEMA = pa.schema([
pa.field('id', pa.int64()),
pa.field('name', pa.string()),
])
# ``_SCHEMA`` carrying metadata, which neither ``Schema.equals`` nor
# ``concat_tables`` looks at.
_ANNOTATED_SCHEMA = pa.schema(
[
pa.field('id', pa.int64(), nullable=False, metadata={b'k': b'v'}),
pa.field('name', pa.string()),
],
metadata={b'origin': b'test'},
)
def _batch_with(schema: pa.Schema, start: int, num_rows: int) -> pa.RecordBatch:
return pa.RecordBatch.from_pydict(
{
'id': list(range(start, start + num_rows)),
'name': ['n%d' % i for i in range(start, start + num_rows)],
},
schema=schema,
)
def _table_with(schema: pa.Schema, start: int, num_rows: int) -> pa.Table:
return pa.Table.from_batches([_batch_with(schema, start, num_rows)])
@contextlib.contextmanager
def _count_folds():
"""Count the concat calls a fold performs, as a list of one entry each.
``pa.concat_tables`` is the only way the buffer or a writer's
``_merge_data`` collapses tables, so its call count is the fold count. The
eager path called it once per write; the deferred path calls it once per
``materialize`` that has something to fold.
"""
calls = []
original = pa.concat_tables
def counting(*args, **kwargs):
calls.append(1)
return original(*args, **kwargs)
pa.concat_tables = counting
try:
yield calls
finally:
pa.concat_tables = original
class _Harness(AppendOnlyDataWriter):
"""Append-only writer with the file layer stubbed out.
Bypasses ``DataWriter.__init__`` -- which needs a real table, catalog and
file IO -- and sets up only what the write/roll path reads.
"""
def __init__(self, target_file_size: int = _NO_LIMIT,
target_file_row_num: int = _NO_LIMIT):
self.target_file_size = target_file_size
self.target_file_row_num = target_file_row_num
self._buffer = WriteBuffer(self._merge_data)
self.committed_files = []
self.written_chunks = []
self.aborted = False
def _write_data_to_file(self, data: pa.Table):
self.written_chunks.append(data)
def abort(self):
# The real ``abort`` deletes the committed files through file IO this
# harness has none of; record that it ran and do the rest.
self.aborted = True
self._buffer.reset()
self.committed_files.clear()
class WriteBufferTest(unittest.TestCase):
def _buffer(self):
return WriteBuffer(lambda a, b: pa.concat_tables([a, b]))
def test_append_tracks_counts_without_folding(self):
buffer = self._buffer()
with _count_folds() as folds:
for i in range(50):
buffer.append(_table(i * 10, 10))
self.assertEqual(folds, [])
self.assertEqual(buffer.num_rows, 500)
self.assertFalse(buffer.is_empty)
def test_running_nbytes_matches_the_folded_table(self):
# The gate on the write path trusts the running sum, so it has to agree
# with what the folded table reports -- separate tables share no Arrow
# buffers, so the per-table sizes add up exactly.
buffer = self._buffer()
for i in range(10):
buffer.append(_table(i * 10, 10))
running = buffer.nbytes
self.assertEqual(running, buffer.materialize().nbytes)
self.assertEqual(running, buffer.nbytes)
def test_materialize_folds_once_and_is_idempotent(self):
buffer = self._buffer()
for i in range(20):
buffer.append(_table(i, 1))
with _count_folds() as folds:
first = buffer.materialize()
second = buffer.materialize()
# One concat for the 20 appended tables, and nothing on re-read.
self.assertEqual(len(folds), 1)
self.assertIs(first, second)
self.assertEqual(first.num_rows, 20)
self.assertEqual(first.column('id').to_pylist(), list(range(20)))
def test_single_append_skips_the_concat_entirely(self):
buffer = self._buffer()
buffer.append(_table(0, 5))
with _count_folds() as folds:
self.assertEqual(buffer.materialize().num_rows, 5)
self.assertEqual(folds, [])
def test_append_after_materialize_goes_through_merge(self):
merged = []
def merge(existing, new):
merged.append((existing.num_rows, new.num_rows))
return pa.concat_tables([existing, new])
buffer = WriteBuffer(merge)
buffer.append(_table(0, 3))
buffer.materialize()
buffer.append(_table(3, 4))
self.assertEqual(buffer.materialize().num_rows, 7)
self.assertEqual(merged, [(3, 4)])
def test_empty_buffer_materializes_to_none(self):
buffer = self._buffer()
self.assertTrue(buffer.is_empty)
self.assertIsNone(buffer.materialize())
self.assertEqual(buffer.nbytes, 0)
self.assertEqual(buffer.num_rows, 0)
def test_reset_replaces_contents_and_remeasures(self):
buffer = self._buffer()
buffer.append(_table(0, 100))
replacement = _table(0, 7)
buffer.reset(replacement)
self.assertIs(buffer.materialize(), replacement)
self.assertEqual(buffer.num_rows, 7)
self.assertEqual(buffer.nbytes, replacement.nbytes)
def test_reset_to_none_empties_the_buffer(self):
buffer = self._buffer()
buffer.append(_table(0, 100))
buffer.reset()
self.assertTrue(buffer.is_empty)
self.assertIsNone(buffer.materialize())
self.assertEqual(buffer.num_rows, 0)
self.assertEqual(buffer.nbytes, 0)
def test_zero_row_table_is_not_reported_empty(self):
# ``is_empty`` has to distinguish "nothing set" from "an empty table was
# set", because the writers use it to decide whether there is anything
# to roll at all.
buffer = self._buffer()
buffer.reset(_table(0, 0))
self.assertFalse(buffer.is_empty)
self.assertEqual(buffer.num_rows, 0)
self.assertIsNotNone(buffer.materialize())
class SchemaGuardTest(unittest.TestCase):
"""The guard keeps a mismatch failing where the eager fold failed.
Folding on append meant a batch ``concat_tables`` could not accept raised
inside ``DataWriter.write``, which aborts and cleans up. Deferring the fold
would otherwise push that failure out to ``prepare_commit``, which has no
such handler, so ``append`` rejects up front exactly what concat rejects.
"""
def _buffer(self):
return WriteBuffer(lambda a, b: pa.concat_tables([a, b]))
def test_append_rejects_a_schema_concat_would_reject(self):
buffer = self._buffer()
buffer.append(_table(0, 3))
with self.assertRaises(ValueError) as caught:
buffer.append(_table_with(_NULLABLE_SCHEMA, 3, 3))
self.assertIn('schema differs', str(caught.exception))
# And a rejected batch leaves the running counts describing the rows
# that are actually buffered.
self.assertEqual(buffer.num_rows, 3)
self.assertEqual(buffer.materialize().num_rows, 3)
def test_append_rejects_a_mismatch_after_a_materialize(self):
# ``materialize`` rebases the schema onto the folded table, so the
# second half of a buffer's life is guarded too.
buffer = self._buffer()
buffer.append(_table(0, 3))
buffer.materialize()
with self.assertRaises(ValueError):
buffer.append(_table_with(_NULLABLE_SCHEMA, 3, 3))
def test_append_accepts_a_metadata_only_difference(self):
# ``concat_tables`` ignores schema and field metadata, so the guard has
# to as well or it would reject batches the old path folded fine.
buffer = self._buffer()
buffer.append(_table(0, 3))
buffer.append(_table_with(_ANNOTATED_SCHEMA, 3, 4))
self.assertEqual(buffer.materialize().num_rows, 7)
def test_reset_rebases_the_schema(self):
# A writer that resets to a new table starts a new file; batches
# matching that table's schema have to be accepted afterwards.
buffer = self._buffer()
buffer.append(_table(0, 3))
buffer.reset(_table_with(_NULLABLE_SCHEMA, 0, 2))
buffer.append(_table_with(_NULLABLE_SCHEMA, 2, 2))
self.assertEqual(buffer.materialize().num_rows, 4)
with self.assertRaises(ValueError):
buffer.append(_table(4, 1))
def test_first_append_after_an_empty_reset_sets_the_schema(self):
buffer = self._buffer()
buffer.append(_table(0, 3))
buffer.reset()
buffer.append(_table_with(_NULLABLE_SCHEMA, 0, 3))
self.assertEqual(buffer.materialize().num_rows, 3)
class DeferredFoldWritePathTest(unittest.TestCase):
def test_many_small_writes_fold_a_constant_number_of_times(self):
# The regression: with an eager fold this was 199 concats for 200
# writes, each walking a buffer one chunk longer than the last.
writer = _Harness()
with _count_folds() as folds:
for i in range(200):
writer.write(_batch(i * 5, 5))
self.assertEqual(folds, [])
# Reading the buffer folds it -- once, not once per write.
with _count_folds() as folds:
pending = writer._buffer.materialize()
self.assertEqual(len(folds), 1)
self.assertEqual(pending.num_rows, 1000)
self.assertEqual(pending.column('id').to_pylist(), list(range(1000)))
def test_row_num_rolling_still_fires(self):
# ``target_file_row_num`` postdates the eager fold, so a byte-only gate
# would leave row-based rolling silently dead for tables that set it
# while staying well under target_file_size.
writer = _Harness(target_file_row_num=10)
for i in range(10):
writer.write(_batch(i * 3, 3))
self.assertEqual([c.num_rows for c in writer.written_chunks],
[10, 10])
self.assertEqual(writer.pending_row_count, 10)
self.assertEqual(
[row for c in writer.written_chunks
for row in c.column('id').to_pylist()],
list(range(20)),
)
def test_row_num_rolling_splits_a_single_oversized_write(self):
writer = _Harness(target_file_row_num=4)
writer.write(_batch(0, 14))
self.assertEqual([c.num_rows for c in writer.written_chunks],
[4, 4, 4])
self.assertEqual(writer.pending_row_count, 2)
def test_size_rolling_still_bounds_written_files(self):
target = _table(0, 100).nbytes // 4
writer = _Harness(target_file_size=target)
for i in range(20):
writer.write(_batch(i * 5, 5))
self.assertGreaterEqual(len(writer.written_chunks), 3)
for chunk in writer.written_chunks:
self.assertLessEqual(chunk.nbytes, target)
written = sum(c.num_rows for c in writer.written_chunks)
self.assertEqual(written + writer.pending_row_count, 100)
def test_row_that_alone_exceeds_target_size_is_rolled_by_itself(self):
writer = _Harness(target_file_size=1)
writer.write(_batch(0, 3))
# Rolls the first two rows one at a time; the last stays buffered
# because the loop stops once the buffer is down to a single row.
self.assertEqual([c.num_rows for c in writer.written_chunks], [1, 1])
self.assertEqual(writer.pending_row_count, 1)
def test_rolling_does_not_refold_on_every_subsequent_write(self):
# After a roll the remainder goes back into the buffer, which
# re-measures it. If that left the roll condition stuck open, every
# later write would fold again and the quadratic would be back.
writer = _Harness(target_file_row_num=8)
writer.write(_batch(0, 12))
# Rolled 8 rows, 4 left buffered.
self.assertEqual(len(writer.written_chunks), 1)
with _count_folds() as folds:
for i in range(20):
writer.write(_batch(100 + i, 1))
# 4 buffered + 20 single-row writes crosses 8 rows exactly twice, so
# only two of those 20 writes fold anything. Asserting a bound rather
# than an exact count: how many concats one fold takes is up to
# ``WriteBuffer``, but it must not be one per write.
self.assertLess(len(folds), 20)
self.assertEqual(len(writer.written_chunks), 3)
def test_pending_row_count_does_not_fold(self):
# The composite writers read this on every write to size the next
# slice; folding here would bring the quadratic back through the side
# door.
writer = _Harness()
with _count_folds() as folds:
for i in range(30):
writer.write(_batch(i, 1))
self.assertEqual(writer.pending_row_count, i + 1)
self.assertEqual(folds, [])
def test_prepare_commit_flushes_the_deferred_buffer(self):
writer = _Harness()
for i in range(10):
writer.write(_batch(i * 2, 2))
writer.prepare_commit()
self.assertEqual([c.num_rows for c in writer.written_chunks], [20])
self.assertTrue(writer._buffer.is_empty)
def test_close_flushes_the_deferred_buffer(self):
writer = _Harness()
for i in range(10):
writer.write(_batch(i * 2, 2))
writer.close()
self.assertEqual([c.num_rows for c in writer.written_chunks], [20])
self.assertTrue(writer._buffer.is_empty)
def test_mismatched_schema_fails_the_write_that_carries_it(self):
# Not ``prepare_commit``: only ``write`` aborts, so a failure deferred
# to commit time would leave the files rolled so far orphaned.
writer = _Harness()
writer.write(_batch(0, 3))
with self.assertRaises(ValueError):
writer.write(_batch_with(_NULLABLE_SCHEMA, 3, 3))
self.assertTrue(writer.aborted)
class FlushFailureTest(unittest.TestCase):
"""A failed flush leaves the rows buffered for the retry.
``StreamTableWrite`` is reusable, so a transient storage error followed by
another ``prepare_commit`` on the same writer has to write the same rows,
not silently skip them. Draining the buffer before the write would lose
them.
"""
class _FailOnceHarness(_Harness):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fail_next = True
def _write_data_to_file(self, data: pa.Table):
if self.fail_next:
self.fail_next = False
raise IOError('transient storage failure')
super()._write_data_to_file(data)
def test_failed_prepare_commit_keeps_the_rows_for_the_retry(self):
writer = self._FailOnceHarness()
for i in range(3):
writer.write(_batch(i, 1))
with self.assertRaises(IOError):
writer.prepare_commit()
self.assertEqual(writer.pending_row_count, 3)
writer.prepare_commit()
self.assertEqual([c.num_rows for c in writer.written_chunks], [3])
self.assertEqual(writer.pending_row_count, 0)
class _FailOnceVectorHarness(DataVectorWriter):
"""``_close_current_writers`` with the file layer stubbed out.
No ``vector_writer``, so this covers the normal half on its own; the
point is only where the buffer is cleared relative to the write.
"""
def __init__(self):
self.target_file_size = _NO_LIMIT
self.target_file_row_num = _NO_LIMIT
self.record_count = 0
self.vector_writer = None
self._normal_buffer = WriteBuffer(self._merge_data)
self.committed_files = []
self.written = []
self.fail_next = True
def _write_normal_data_to_file(self, data: pa.Table):
if self.fail_next:
self.fail_next = False
raise IOError('transient storage failure')
self.written.append(data)
return object()
def test_failed_normal_flush_keeps_the_rows_for_the_retry(self):
# Otherwise the retry finds no normal_meta, flushes the sidecars alone
# and skips the row-count check, committing sidecar-only metadata.
writer = self._FailOnceVectorHarness()
writer._normal_buffer.append(_table(0, 3))
with self.assertRaises(IOError):
writer._close_current_writers()
self.assertEqual(writer._normal_buffer.num_rows, 3)
writer._close_current_writers()
self.assertEqual([t.num_rows for t in writer.written], [3])
self.assertEqual(writer._normal_buffer.num_rows, 0)
class _StubMeta:
"""The handful of ``DataFileMeta`` fields the flush and abort paths read."""
def __init__(self, row_count: int, file_name: str):
self.row_count = row_count
self.file_name = file_name
self.file_path = '/warehouse/%s' % file_name
self.external_path = None
self.extra_files = []
class _RecordingFileIO:
def __init__(self):
self.deleted = []
def delete_quietly(self, path):
self.deleted.append(path)
class _StubSidecarWriter:
"""A blob/vector writer that fails its first ``prepare_commit``.
Models the real ones in the way that matters here: a failure produces no
metadata, and because the sub-writer drains its own buffer as it writes, a
later call returns whatever has landed so far -- so the parent must be able
to harvest the same metas twice without double-counting them.
"""
def __init__(self, row_count: int, file_name: str, fail_times: int = 0,
delete_on_abort: bool = True):
self.committed_files = []
self.pending_row_count = 0
self.prepare_commit_calls = 0
self.aborted = False
self._row_count = row_count
self._file_name = file_name
self._fail_times = fail_times
self._delete_on_abort = delete_on_abort
def prepare_commit(self):
self.prepare_commit_calls += 1
if self._fail_times > 0:
self._fail_times -= 1
raise IOError('transient sidecar failure')
if not self.committed_files:
self.committed_files.append(
_StubMeta(self._row_count, self._file_name))
return self.committed_files.copy()
def delete_file_upon_abort(self):
return self._delete_on_abort
def abort(self):
self.aborted = True
self.committed_files.clear()
class CompositeFlushResumeTest(unittest.TestCase):
"""A composite flush publishes all of its files or none of them.
One flush writes the normal data file and then the blob/vector sidecars. The
sidecar writers drain their own buffers as they go, so a failure part way
through cannot be rolled back -- deleting the sidecars that already landed
would lose rows nothing can replay. So the flush resumes instead: the normal
rows stay buffered until their file lands, the landed file is remembered so
the retry skips it, and no metadata is published until every phase is done.
Publishing the normal meta before the sidecars ran, as the code used to,
left the retry writing a second copy of rows the first meta already covered
-- and ``_validate_consistency`` then checked the sidecars against that
second copy only.
"""
class _VectorHarness(DataVectorWriter):
def __init__(self, vector_writer, fail_normal_times: int = 0):
self.target_file_size = _NO_LIMIT
self.target_file_row_num = _NO_LIMIT
self.record_count = 0
self.vector_writer = vector_writer
self.normal_column_names = ['id', 'name']
self.vector_write_columns = []
self._normal_buffer = WriteBuffer(self._merge_data)
self._buffer = WriteBuffer(self._merge_data)
self.committed_files = []
self.committed_changelog_files = []
self.file_io = _RecordingFileIO()
self.written = []
self._fail_normal_times = fail_normal_times
def _write_normal_data_to_file(self, data: pa.Table):
if self._fail_normal_times > 0:
self._fail_normal_times -= 1
raise IOError('transient storage failure')
self.written.append(data)
return _StubMeta(data.num_rows, 'data-%d' % len(self.written))
class _DedicatedHarness(DedicatedFormatWriter):
def __init__(self, blob_writers, vector_writer=None):
self.target_file_size = _NO_LIMIT
self.target_file_row_num = _NO_LIMIT
self.record_count = 0
self.blob_writers = blob_writers
self.blob_file_column_names = list(blob_writers)
self.vector_writer = vector_writer
self._normal_buffer = WriteBuffer(self._merge_normal_data)
self._buffer = WriteBuffer(self._merge_normal_data)
self.committed_files = []
self._committed_files_to_delete_on_abort = []
self.file_io = _RecordingFileIO()
self.written = []
self._video_group_policy = None
def _write_normal_data_to_file(self, data: pa.Table):
self.written.append(data)
return _StubMeta(data.num_rows, 'data-%d' % len(self.written))
def test_failed_sidecar_publishes_nothing_and_the_retry_resumes(self):
vector = _StubSidecarWriter(3, 'vector-0', fail_times=1)
writer = self._VectorHarness(vector)
writer._normal_buffer.append(_table(0, 3))
with self.assertRaises(IOError):
writer._close_current_writers()
# The normal file landed, so the rows are no longer buffered -- but
# nothing is committed and the file is remembered for the retry.
self.assertEqual([t.num_rows for t in writer.written], [3])
self.assertEqual(writer.committed_files, [])
self.assertEqual(writer._normal_buffer.num_rows, 0)
self.assertIsNotNone(writer._pending_normal_meta)
writer._close_current_writers()
# Still one normal file: the retry resumed at the sidecar phase rather
# than writing the same 3 rows again.
self.assertEqual([t.num_rows for t in writer.written], [3])
self.assertEqual([m.file_name for m in writer.committed_files],
['data-1', 'vector-0'])
self.assertIsNone(writer._pending_normal_meta)
# Harvested once the flush completed, and cleared only then.
self.assertEqual(vector.committed_files, [])
def test_successful_flush_publishes_normal_then_sidecars(self):
vector = _StubSidecarWriter(3, 'vector-0')
writer = self._VectorHarness(vector)
writer._normal_buffer.append(_table(0, 3))
writer._close_current_writers()
self.assertEqual([m.file_name for m in writer.committed_files],
['data-1', 'vector-0'])
self.assertEqual(vector.committed_files, [])
self.assertIsNone(writer._pending_normal_meta)
self.assertEqual(writer.record_count, 0)
def test_failed_normal_write_keeps_the_rows_and_publishes_nothing(self):
# The other half of the same rule: the sidecars are never reached, so
# the rows have to stay where a retry can find them.
vector = _StubSidecarWriter(3, 'vector-0')
writer = self._VectorHarness(vector, fail_normal_times=1)
writer._normal_buffer.append(_table(0, 3))
with self.assertRaises(IOError):
writer._close_current_writers()
self.assertEqual(writer._normal_buffer.num_rows, 3)
self.assertEqual(writer.committed_files, [])
self.assertIsNone(writer._pending_normal_meta)
self.assertEqual(vector.prepare_commit_calls, 0)
writer._close_current_writers()
self.assertEqual([t.num_rows for t in writer.written], [3])
self.assertEqual([m.file_name for m in writer.committed_files],
['data-1', 'vector-0'])
def test_write_is_rejected_while_a_flush_is_unfinished(self):
# Rows appended between a failed flush and its retry would belong to no
# file: the resumed flush skips the normal write, while the sidecar
# writer would drain them -- breaking the row-count check.
vector = _StubSidecarWriter(3, 'vector-0', fail_times=1)
writer = self._VectorHarness(vector)
writer._normal_buffer.append(_table(0, 3))
with self.assertRaises(IOError):
writer._close_current_writers()
with self.assertRaises(RuntimeError) as caught:
writer.write(_batch(3, 1))
self.assertIn('Cannot write', str(caught.exception))
# Rejecting does not abort, so the flush is still resumable.
self.assertIsNotNone(writer._pending_normal_meta)
writer._close_current_writers()
self.assertEqual([t.num_rows for t in writer.written], [3])
def test_abort_deletes_the_unpublished_normal_file(self):
# It is in no committed list, so abort has to know about it separately
# or it leaks a data file no snapshot references.
vector = _StubSidecarWriter(3, 'vector-0', fail_times=1)
writer = self._VectorHarness(vector)
writer._normal_buffer.append(_table(0, 3))
with self.assertRaises(IOError):
writer._close_current_writers()
writer.abort()
self.assertEqual(writer.file_io.deleted, ['/warehouse/data-1'])
self.assertIsNone(writer._pending_normal_meta)
self.assertTrue(vector.aborted)
def test_dedicated_writer_failed_blob_phase_publishes_nothing(self):
blob = _StubSidecarWriter(3, 'blob-0', fail_times=1)
writer = self._DedicatedHarness({'payload': blob})
writer._normal_buffer.append(_table(0, 3))
with self.assertRaises(IOError):
writer._close_current_writers()
self.assertEqual([t.num_rows for t in writer.written], [3])
self.assertEqual(writer.committed_files, [])
# Already tracked for abort, since no committed list holds it yet.
self.assertEqual(
[m.file_name for m in writer._committed_files_to_delete_on_abort],
['data-1'])
writer._close_current_writers()
self.assertEqual([t.num_rows for t in writer.written], [3])
self.assertEqual([m.file_name for m in writer.committed_files],
['data-1', 'blob-0'])
self.assertEqual(blob.committed_files, [])
self.assertIsNone(writer._pending_normal_meta)
def test_dedicated_writer_keeps_the_documented_meta_order(self):
blob = _StubSidecarWriter(3, 'blob-0')
vector = _StubSidecarWriter(3, 'vector-0')
writer = self._DedicatedHarness({'payload': blob}, vector)
writer._normal_buffer.append(_table(0, 3))
writer._close_current_writers()
self.assertEqual([m.file_name for m in writer.committed_files],
['data-1', 'blob-0', 'vector-0'])
self.assertEqual(
[m.file_name for m in writer._committed_files_to_delete_on_abort],
['data-1', 'blob-0', 'vector-0'])
def test_dedicated_writer_respects_the_blob_delete_policy(self):
# Externally managed blob files are not the writer's to delete, so they
# must stay out of the abort list even now that it is filled at publish
# time rather than as each sidecar lands.
blob = _StubSidecarWriter(3, 'blob-0', delete_on_abort=False)
writer = self._DedicatedHarness({'payload': blob})
writer._normal_buffer.append(_table(0, 3))
writer._close_current_writers()
self.assertEqual([m.file_name for m in writer.committed_files],
['data-1', 'blob-0'])
self.assertEqual(
[m.file_name for m in writer._committed_files_to_delete_on_abort],
['data-1'])
class VectorNormalBufferTest(unittest.TestCase):
"""``DataVectorWriter`` keeps its own buffer for the normal columns."""
class _VectorHarness(DataVectorWriter):
"""Only the fields ``_should_roll_normal`` reads.
``CHECK_ROLLING_RECORD_CNT`` is 1 rather than the real 1000 so the size
branch is reached on every write instead of being short-circuited by the
periodic check.
"""
CHECK_ROLLING_RECORD_CNT = 1
def __init__(self, target_file_size: int = _NO_LIMIT):
self.target_file_size = target_file_size
self.target_file_row_num = _NO_LIMIT
self.record_count = 0
self.vector_writer = None
self._normal_buffer = WriteBuffer(self._merge_data)
self._buffer = WriteBuffer(self._merge_data)
def test_should_roll_normal_does_not_fold(self):
writer = self._VectorHarness()
with _count_folds() as folds:
for i in range(50):
writer._normal_buffer.append(_table(i * 5, 5))
writer.record_count += 5
self.assertFalse(writer._should_roll_normal())
self.assertEqual(folds, [])
self.assertEqual(writer._normal_buffer.num_rows, 250)
def test_should_roll_normal_fires_off_the_running_size(self):
writer = self._VectorHarness(target_file_size=_table(0, 20).nbytes)
writer.record_count = 1
for i in range(10):
writer._normal_buffer.append(_table(i * 5, 5))
if writer._should_roll_normal():
break
self.assertTrue(writer._should_roll_normal())
# Fires before the buffer grows far past the target, i.e. off the
# accumulated size rather than at some arbitrary later point.
self.assertLessEqual(writer._normal_buffer.num_rows, 30)
def test_pending_row_count_reports_the_normal_buffer(self):
# The inherited property reads the base ``_buffer``, which this writer
# never fills, so it has to be overridden or it always answers zero.
writer = self._VectorHarness()
self.assertEqual(writer.pending_row_count, 0)
writer._normal_buffer.append(_table(0, 7))
self.assertEqual(writer.pending_row_count, 7)
self.assertTrue(writer._buffer.is_empty)
def test_pending_row_count_falls_back_to_the_vector_writer(self):
# A table whose columns are all vectors buffers nothing normal, so the
# count has to come from the sidecar instead of reading as zero.
writer = self._VectorHarness()
writer.vector_writer = _StubSidecarWriter(0, 'vector-0')
writer.vector_writer.pending_row_count = 4
self.assertEqual(writer.pending_row_count, 4)
writer._normal_buffer.append(_table(0, 7))
self.assertEqual(writer.pending_row_count, 7)
def test_dedicated_writer_pending_row_count_reports_the_normal_buffer(self):
writer = CompositeFlushResumeTest._DedicatedHarness({})
self.assertEqual(writer.pending_row_count, 0)
writer._normal_buffer.append(_table(0, 3))
self.assertEqual(writer.pending_row_count, 3)
self.assertTrue(writer._buffer.is_empty)
if __name__ == '__main__':
unittest.main()