Skip to content

fix: Delete data files left by a failed native Iceberg write task - #5652

Open
andygrove wants to merge 1 commit into
apache:mainfrom
andygrove:fix/iceberg-write-task-cleanup-5618
Open

fix: Delete data files left by a failed native Iceberg write task#5652
andygrove wants to merge 1 commit into
apache:mainfrom
andygrove:fix/iceberg-write-task-cleanup-5618

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #5618.

Rationale for this change

When a native Iceberg write task fails partway through, the data files it had already finalized stay in the table's data location. They are invisible to readers and remove_orphan_files eventually reclaims them, but iceberg-java deletes them synchronously (DataWriter.abort() calls SparkCleanupUtil.deleteTaskFiles), so on spot-heavy or preemption-prone clusters the native path silently accumulates orphans that the JVM path does not. This is the first phase-2 item of the native Iceberg writes epic (#5649).

What changes are included in this PR?

Two halves, matching the two places a task can fail.

Inside the native writer. iceberg-rust's writers keep the DataFiles they have finalized private until close and have no abort hook, so the task cannot ask a failed writer what it wrote. Instead, the RollingFileWriterBuilder is given a TrackingLocationGenerator, a wrapper around DefaultLocationGenerator that records every location it hands to a file writer. If the input stream, a write, or the final close fails, run_write_task deletes every recorded location through the task's FileIO before propagating the original error.

That explicit path is not enough on its own, and the new end-to-end test proved it: when the JVM-side input iterator throws (a UDF failure upstream of the write, the most common shape), executePlan returns the error straight from its JNI batch pull and the JVM releases the plan, so the write task's future is dropped without ever seeing an error. An AbortOnDrop guard covers that case. It is disarmed once the task completes; if it is dropped while still armed it deletes the tracked files, synchronously on a throwaway current-thread runtime when dropped from a plain JVM thread (releasePlan), or spawned onto the current runtime when dropped from inside one. Deletion is best-effort in both paths: failures are logged, never returned, so the task failure Spark reports is still the real one. The location that was open at the time of the failure is included; deleting a path that was never materialized is a no-op.

After the native writer has returned. Once the manifest is decoded, the JVM knows the files. CometIcebergWriteExec registers a TaskFailureListener that deletes them through the table FileIO already in the task closure if the metrics rebuild, TaskCommit construction, or serialization fails. IcebergReflection.deleteFilesQuietly prefers SupportsBulkOperations.deleteFiles and falls back to FileIO.deleteFile(String) per path, and likewise never throws.

The "Failure handling" section of iceberg-writes.md is updated to describe the cleanup and drop the reference to this issue.

How are these changes tested?

  • Rust unit tests for the tracking generator (layout unchanged, every location recorded), for the cleanup against an in-memory FileIO including a recorded location that was never written, and for the drop guard (disarmed guard deletes nothing; armed guard dropped outside a runtime deletes synchronously).
  • CometIcebergWriteActionSuite: a failure-injection test writes a single-task source with a two-row Comet batch size into a table with a one-byte write.target-file-size-bytes, so the rolling writer finalizes a file per batch, and a UDF throws on the seventh row. A control run with the same source and settings first proves the writer rolls into several files; the failing run then asserts the write planned natively (CometIcebergWriteExec in the failed plan), no snapshot was created, the pre-existing data file is untouched, and no other parquet file remains under the table's data location.
  • A direct test of deleteFilesQuietly through a real table's HadoopFileIO: written files are removed, a nonexistent path is tolerated, and a second call is a no-op.
  • The existing Iceberg write suites were run locally on the default Spark profile.

iceberg-java's writer abort deletes the files a failed task attempt
wrote; the native path left them for remove_orphan_files. Close the gap
in both places a task can fail.

Inside the native writer, a TrackingLocationGenerator records every
location handed to a file writer, since iceberg-rust's writers keep
finalized files private until close and have no abort hook. The task
deletes the recorded locations when a write fails, and an AbortOnDrop
guard does the same when the task future is dropped without ever seeing
an error, which is what happens when the JVM input iterator throws:
executePlan returns that error from its JNI batch pull and the JVM
releases the plan.

After the native writer has returned, CometIcebergWriteExec registers a
task failure listener that deletes the decoded manifest's files through
the table FileIO, via a new best-effort IcebergReflection helper.

Both deletions log failures rather than raising them, so the original
task failure is the one Spark reports.

Closes apache#5618
if outcome.is_err() {
delete_task_files(&file_io, location_generator.locations()).await;
}
abort_guard.disarm();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We disarm the cleanup guard here as soon as run_write_task completes, but the outer IcebergWriteExec::execute still performs fallible encode_data_files_as_manifest(...).await? and build_output_batch(...)? operations afterward. If either fails, the data files have already been written, this guard is disarmed, and the JVM never receives a manifest from which it could recover their locations. That leaves the task's files orphaned.

Could we keep cleanup ownership alive through manifest encoding/output-batch construction and disarm only once the native result is successfully materialized? One option would be for run_write_task to return the cleanup token alongside the DataFiles and let the outer task disarm it after packaging succeeds.

// serialization), delete them the way iceberg-java's `DataWriter.abort()` would; failures
// inside the native writer itself are cleaned up on the native side.
Option(TaskContext.get()).foreach { tc =>
tc.addTaskFailureListener(new TaskFailureListener {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the time this listener is registered, both drainAvroPayload(batches) and decodeManifestToDataFiles(manifestBytes, specId) have already run. A failure in either operation happens after the native writer has successfully produced its data files, but before this listener owns cleanup. Since the native guard has also already been disarmed on successful writer close, neither side can remove those files.

This is especially important for decodeManifestToDataFiles: the cleanup paths are currently recoverable only by successfully decoding the same manifest whose decode may fail. I think cleanup ownership/locations need to cross this boundary independently of successful manifest decoding (or native cleanup needs to remain armed until the JVM acknowledges successful decode).

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Adding evidence to the existing JVM handoff thread: a valid 864,547-byte manifest from 4,096 real Parquet files decoded successfully with more heap available, but the same Iceberg 1.11 decoder failed under controlled heap pressure. In a 128 MiB standalone JVM, the low-headroom run had about 6.7 MiB free before decoding and raised OutOfMemoryError inside the actual Avro reader. All task files remained.

At this boundary, drainAvroPayload has already released the native plan, while the new failure listener is not yet registered. Could cleanup ownership remain armed until decoding and listener registration succeed? A component ownership model retaining the exact native guard across that decoder failure subsequently deleted all 4,096 files. This was isolated component validation, not a full Spark/JNI run. The decoder behavior predates this PR. The concern is the uncovered handoff in this cleanup fix, not a newly introduced decoder regression.

Current CI has 53 successful, 12 running and 7 skipped checks. I did not run the full Comet or Spark suites.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native Iceberg write: clean up task-attempt data files on task failure

3 participants