Skip to content

Commit ff62c57

Browse files
authored
[python][ray] Abort worker writes on failure (#8124)
Ray write tasks previously closed on the worker-side `TableWrite` when write, prepare, or close failed. That is unsafe because close can flush pending data, and `prepare_commit()` can materialize normal, blob, or vector files before a later failure prevents the driver commit. This PR makes worker-side Ray writes abort on failure and propagates abort through `TableWrite` and `FileStoreWrite`. It also keeps dedicated blob/vector metadata reachable from the parent writer so abort can delete files produced before the failed commit path.
1 parent 85de16f commit ff62c57

8 files changed

Lines changed: 238 additions & 20 deletions

File tree

paimon-python/pypaimon/tests/data_evolution_formats_test.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ def setUpClass(cls):
4646
def tearDownClass(cls):
4747
shutil.rmtree(cls.tempdir, ignore_errors=True)
4848

49+
@staticmethod
50+
def _file_path(file_meta):
51+
return file_meta.external_path if file_meta.external_path else file_meta.file_path
52+
4953
# ------------------------------------------------------------------
5054
# Parquet-format data evolution
5155
# ------------------------------------------------------------------
@@ -236,6 +240,41 @@ def test_blob_write_and_read(self):
236240
self.assertEqual(actual.column('id').to_pylist(), [1, 2, 3])
237241
self.assertEqual(actual.column('payload').to_pylist(), blobs)
238242

243+
def test_blob_abort_deletes_uncommitted_files(self):
244+
pa_schema = pa.schema([
245+
('id', pa.int32()),
246+
('payload', pa.large_binary()),
247+
])
248+
schema = Schema.from_pyarrow_schema(pa_schema, options={
249+
'row-tracking.enabled': 'true',
250+
'data-evolution.enabled': 'true',
251+
})
252+
self.catalog.create_table('default.fmt_blob_abort_cleanup', schema, False)
253+
table = self.catalog.get_table('default.fmt_blob_abort_cleanup')
254+
255+
writer = table.new_batch_write_builder().new_write()
256+
writer.write_arrow(pa.Table.from_pydict({
257+
'id': [1, 2, 3],
258+
'payload': [b'a', b'b', b'c'],
259+
}, schema=pa_schema))
260+
commit_messages = writer.prepare_commit()
261+
262+
all_files = [nf for msg in commit_messages for nf in msg.new_files]
263+
parquet_files = [f for f in all_files if f.file_name.endswith('.parquet')]
264+
blob_files = [f for f in all_files if f.file_name.endswith('.blob')]
265+
self.assertGreater(len(parquet_files), 0)
266+
self.assertGreater(len(blob_files), 0)
267+
for file_meta in all_files:
268+
self.assertTrue(table.file_io.exists(self._file_path(file_meta)))
269+
270+
writer.abort()
271+
272+
for file_meta in all_files:
273+
self.assertFalse(
274+
table.file_io.exists(self._file_path(file_meta)),
275+
f"Expected abort to delete {file_meta.file_name}",
276+
)
277+
239278
def test_blob_column_subset_evolution(self):
240279
"""Write normal+blob cols in one commit, overwrite normal col in another, merge-read."""
241280
pa_schema = pa.schema([
@@ -563,6 +602,87 @@ def test_vortex_with_row_id_and_filter(self):
563602
# Vector (vortex) file format for embedding columns
564603
# ------------------------------------------------------------------
565604

605+
def test_vector_abort_deletes_uncommitted_files(self):
606+
pa_schema = pa.schema([
607+
('id', pa.int64()),
608+
('embed', pa.list_(pa.float32(), 3)),
609+
])
610+
schema = Schema.from_pyarrow_schema(pa_schema, options={
611+
'row-tracking.enabled': 'true',
612+
'data-evolution.enabled': 'true',
613+
'vector.file.format': 'parquet',
614+
})
615+
self.catalog.create_table('default.fmt_vector_abort_cleanup', schema, False)
616+
table = self.catalog.get_table('default.fmt_vector_abort_cleanup')
617+
618+
writer = table.new_batch_write_builder().new_write()
619+
writer.write_arrow(pa.table({
620+
'id': pa.array([1, 2, 3], type=pa.int64()),
621+
'embed': pa.FixedSizeListArray.from_arrays(
622+
pa.array([0.1, 0.2, 0.3,
623+
0.4, 0.5, 0.6,
624+
0.7, 0.8, 0.9], type=pa.float32()), 3),
625+
}))
626+
commit_messages = writer.prepare_commit()
627+
628+
all_files = [nf for msg in commit_messages for nf in msg.new_files]
629+
normal_files = [f for f in all_files if not DataFileMeta.is_vector_file(f.file_name)]
630+
vector_files = [f for f in all_files if DataFileMeta.is_vector_file(f.file_name)]
631+
self.assertGreater(len(normal_files), 0)
632+
self.assertGreater(len(vector_files), 0)
633+
for file_meta in all_files:
634+
self.assertTrue(table.file_io.exists(self._file_path(file_meta)))
635+
636+
writer.abort()
637+
638+
for file_meta in all_files:
639+
self.assertFalse(
640+
table.file_io.exists(self._file_path(file_meta)),
641+
f"Expected abort to delete {file_meta.file_name}",
642+
)
643+
644+
def test_vector_close_failure_after_prepare_raises(self):
645+
from unittest.mock import patch
646+
647+
pa_schema = pa.schema([
648+
('id', pa.int64()),
649+
('embed', pa.list_(pa.float32(), 3)),
650+
])
651+
schema = Schema.from_pyarrow_schema(pa_schema, options={
652+
'row-tracking.enabled': 'true',
653+
'data-evolution.enabled': 'true',
654+
'vector.file.format': 'parquet',
655+
})
656+
self.catalog.create_table('default.fmt_vector_close_failure', schema, False)
657+
table = self.catalog.get_table('default.fmt_vector_close_failure')
658+
659+
writer = table.new_batch_write_builder().new_write()
660+
writer.write_arrow(pa.table({
661+
'id': pa.array([1, 2, 3], type=pa.int64()),
662+
'embed': pa.FixedSizeListArray.from_arrays(
663+
pa.array([0.1, 0.2, 0.3,
664+
0.4, 0.5, 0.6,
665+
0.7, 0.8, 0.9], type=pa.float32()), 3),
666+
}))
667+
commit_messages = writer.prepare_commit()
668+
669+
all_files = [nf for msg in commit_messages for nf in msg.new_files]
670+
for file_meta in all_files:
671+
self.assertTrue(table.file_io.exists(self._file_path(file_meta)))
672+
673+
data_writer = next(iter(writer.file_store_write.data_writers.values()))
674+
with patch.object(
675+
data_writer, '_close_current_writers',
676+
side_effect=RuntimeError("Close error")):
677+
with self.assertRaisesRegex(RuntimeError, "Close error"):
678+
writer.close()
679+
680+
for file_meta in all_files:
681+
self.assertFalse(
682+
table.file_io.exists(self._file_path(file_meta)),
683+
f"Expected abort to delete {file_meta.file_name}",
684+
)
685+
566686
@unittest.skipIf(sys.version_info < (3, 11), "vortex-data requires Python >= 3.11")
567687
@unittest.skipUnless(
568688
__import__('importlib').util.find_spec('vortex') is not None,

paimon-python/pypaimon/tests/ray_sink_test.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ def tearDown(self):
6565
if os.path.exists(self.temp_dir):
6666
shutil.rmtree(self.temp_dir)
6767

68+
@staticmethod
69+
def _data_files_under(table):
70+
table_path = table.file_io.to_filesystem_path(table.table_path)
71+
data_files = []
72+
for root, _, files in os.walk(table_path):
73+
for file_name in files:
74+
if file_name.endswith(('.parquet', '.blob')) or '.vector.' in file_name:
75+
data_files.append(os.path.join(root, file_name))
76+
return data_files
77+
6878
def test_init_and_serialization(self):
6979
"""Test initialization, serialization, and table name."""
7080
datasink = PaimonDatasink(self.table, overwrite=False)
@@ -272,7 +282,66 @@ def test_write(self):
272282
})
273283
with self.assertRaises(Exception):
274284
datasink.write([data_table], ctx)
275-
mock_write.close.assert_called_once()
285+
mock_write.abort.assert_called_once()
286+
mock_write.close.assert_not_called()
287+
288+
with patch.object(self.table, 'new_batch_write_builder') as mock_builder:
289+
mock_write_builder = Mock()
290+
mock_write_builder.overwrite.return_value = mock_write_builder
291+
mock_write = Mock()
292+
mock_write.prepare_commit.return_value = [Mock(spec=CommitMessage)]
293+
mock_write.close.side_effect = Exception("Close error")
294+
mock_write_builder.new_write.return_value = mock_write
295+
mock_builder.return_value = mock_write_builder
296+
297+
data_table = pa.table({
298+
'id': [1],
299+
'name': ['Alice'],
300+
'value': [1.1]
301+
})
302+
with self.assertRaises(Exception):
303+
datasink.write([data_table], ctx)
304+
mock_write.prepare_commit.assert_called_once()
305+
mock_write.abort.assert_called_once()
306+
307+
def test_write_does_not_return_prepared_messages_when_dedicated_close_aborts(self):
308+
from pypaimon.write.writer.dedicated_format_writer import DedicatedFormatWriter
309+
310+
pa_schema = pa.schema([
311+
('id', pa.int32()),
312+
('payload', pa.large_binary()),
313+
])
314+
schema = Schema.from_pyarrow_schema(pa_schema, options={
315+
'row-tracking.enabled': 'true',
316+
'data-evolution.enabled': 'true',
317+
})
318+
table_identifier = "test_db.test_blob_close_failure"
319+
self.catalog.create_table(table_identifier, schema, False)
320+
table = self.catalog.get_table(table_identifier)
321+
322+
datasink = PaimonDatasink(table, overwrite=False)
323+
datasink.on_write_start()
324+
ctx = Mock(spec=TaskContext)
325+
data_table = pa.Table.from_pydict({
326+
'id': [1, 2, 3],
327+
'payload': [b'a', b'b', b'c'],
328+
}, schema=pa_schema)
329+
330+
original_close_current_writers = DedicatedFormatWriter._close_current_writers
331+
close_current_calls = {'count': 0}
332+
333+
def fail_during_close(writer):
334+
close_current_calls['count'] += 1
335+
if close_current_calls['count'] == 1:
336+
return original_close_current_writers(writer)
337+
raise RuntimeError("Close error")
338+
339+
with patch.object(DedicatedFormatWriter, '_close_current_writers', fail_during_close):
340+
with self.assertRaisesRegex(RuntimeError, "Close error"):
341+
datasink.write([data_table], ctx)
342+
343+
self.assertEqual(close_current_calls['count'], 2)
344+
self.assertEqual([], self._data_files_under(table))
276345

277346
def test_on_write_complete(self):
278347
from ray.data.datasource.datasink import WriteResult

paimon-python/pypaimon/write/file_store_write.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,15 @@ def close(self):
251251
writer.close()
252252
self.data_writers.clear()
253253

254+
def abort(self):
255+
"""Abort all data writers and clean up files produced by this write."""
256+
for writer in self.data_writers.values():
257+
try:
258+
writer.abort()
259+
except Exception as e:
260+
logger.warning("Failed to abort data writer.", exc_info=e)
261+
self.data_writers.clear()
262+
254263
def _seq_number_stats(self, partition: Tuple) -> Dict[int, int]:
255264
buckets = self.max_seq_numbers.get(partition)
256265
if buckets is None:

paimon-python/pypaimon/write/ray_datasink.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,20 @@ def write(
136136

137137
commit_messages = table_write.prepare_commit()
138138
commit_messages_list.extend(commit_messages)
139-
finally:
140-
if table_write is not None:
141-
table_write.close()
142139

143-
return commit_messages_list
140+
table_write.close()
141+
table_write = None
142+
return commit_messages_list
143+
except Exception:
144+
if table_write is not None:
145+
try:
146+
table_write.abort()
147+
except Exception as abort_error:
148+
logger.warning(
149+
f"Error aborting worker-side table_write: {abort_error}",
150+
exc_info=abort_error
151+
)
152+
raise
144153

145154
@staticmethod
146155
def _extract_write_returns(write_result: Any):

paimon-python/pypaimon/write/table_write.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ def write_ray(
135135
def close(self):
136136
self.file_store_write.close()
137137

138+
def abort(self):
139+
self.file_store_write.abort()
140+
138141
def _validate_pyarrow_schema(self, data_schema: pa.Schema):
139142
if data_schema == self.table_pyarrow_schema:
140143
return

paimon-python/pypaimon/write/writer/data_vector_writer.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ def close(self):
143143
except Exception as e:
144144
logger.error("Exception occurs when closing writer. Cleaning up.", exc_info=e)
145145
self.abort()
146+
raise
146147
finally:
147148
self.closed = True
148149
self.pending_normal_data = None
@@ -151,7 +152,7 @@ def abort(self):
151152
if self.vector_writer is not None:
152153
self.vector_writer.abort()
153154
self.pending_normal_data = None
154-
self.committed_files.clear()
155+
super().abort()
155156

156157
def _split_data(self, data: pa.RecordBatch) -> Tuple[pa.RecordBatch, pa.RecordBatch]:
157158
normal_data = (
@@ -187,11 +188,11 @@ def _close_current_writers(self):
187188

188189
if self.vector_writer is not None:
189190
vector_metas = self.vector_writer.prepare_commit()
190-
self.vector_writer.committed_files.clear()
191191
if vector_metas:
192192
if normal_meta is not None:
193193
self._validate_consistency(normal_meta, vector_metas)
194194
self.committed_files.extend(vector_metas)
195+
self.vector_writer.committed_files.clear()
195196

196197
self.pending_normal_data = None
197198

paimon-python/pypaimon/write/writer/data_writer.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,15 @@ def abort(self):
138138
Abort all writers and clean up resources. This method should be called when an error occurs
139139
during writing. It deletes any files that were written and cleans up resources.
140140
"""
141-
# Delete any files that were written (data + changelog)
142-
for file_meta in self.committed_files + self.committed_changelog_files:
141+
self._delete_committed_files(self.committed_files + self.committed_changelog_files)
142+
143+
# Clean up resources
144+
self.pending_data = None
145+
self.committed_files.clear()
146+
self.committed_changelog_files.clear()
147+
148+
def _delete_committed_files(self, file_metas: List[DataFileMeta]):
149+
for file_meta in file_metas:
143150
try:
144151
path_to_delete = file_meta.external_path if file_meta.external_path else file_meta.file_path
145152
if path_to_delete:
@@ -151,11 +158,6 @@ def abort(self):
151158
path_to_delete = file_meta.external_path if file_meta.external_path else file_meta.file_path
152159
logger.warning(f"Failed to delete file {path_to_delete} during abort: {e}")
153160

154-
# Clean up resources
155-
self.pending_data = None
156-
self.committed_files.clear()
157-
self.committed_changelog_files.clear()
158-
159161
@abstractmethod
160162
def _process_data(self, data: pa.RecordBatch) -> pa.RecordBatch:
161163
"""Process incoming data (e.g., add system fields, sort). Must be implemented by subclasses."""

paimon-python/pypaimon/write/writer/dedicated_format_writer.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ def close(self):
252252
except Exception as e:
253253
logger.error("Exception occurs when closing writer. Cleaning up.", exc_info=e)
254254
self.abort()
255+
raise
255256
finally:
256257
self.closed = True
257258
self.pending_normal_data = None
@@ -264,7 +265,13 @@ def abort(self):
264265
self.vector_writer.abort()
265266
if self._external_storage_writer:
266267
self._external_storage_writer.abort()
268+
committed_non_blob_files = [
269+
file_meta for file_meta in self.committed_files
270+
if not DataFileMeta.is_blob_file(file_meta.file_name)
271+
]
272+
self._delete_committed_files(committed_non_blob_files)
267273
self.pending_normal_data = None
274+
self.pending_data = None
268275
self.committed_files.clear()
269276

270277
def _split_data(self, data: pa.RecordBatch) -> Tuple[
@@ -370,25 +377,23 @@ def _close_current_writers(self):
370377
normal_meta = None
371378
if self.pending_normal_data is not None and self.pending_normal_data.num_rows > 0:
372379
normal_meta = self._write_normal_data_to_file(self.pending_normal_data)
380+
self.committed_files.append(normal_meta)
373381

374382
blob_metas = []
375383
for blob_column in self.blob_file_column_names:
376384
writer_metas = self.blob_writers[blob_column].prepare_commit()
377385
if normal_meta is not None:
378386
self._validate_consistency(normal_meta, writer_metas, blob_column)
379387
blob_metas.extend(writer_metas)
388+
self.committed_files.extend(blob_metas)
380389

381390
vector_metas = []
382391
if self.vector_writer is not None:
383392
vector_metas = self.vector_writer.prepare_commit()
384-
self.vector_writer.committed_files.clear()
385393
if vector_metas and normal_meta is not None:
386394
self._validate_consistency(normal_meta, vector_metas, 'vector')
387-
388-
if normal_meta is not None:
389-
self.committed_files.append(normal_meta)
390-
self.committed_files.extend(blob_metas)
391-
self.committed_files.extend(vector_metas)
395+
self.committed_files.extend(vector_metas)
396+
self.vector_writer.committed_files.clear()
392397

393398
self.pending_normal_data = None
394399

0 commit comments

Comments
 (0)