apache / apache/datafusion-comet

Comet writer should support 2PC and staging output

Open
#3,015 2 comments 0 reactions 0 assignees View on GitHub
enhancement
Dominant language
Scala
Stars
1.3k
Forks
373
Avg merge
2d 4h
Merged PRs (30d)
198

Description

### What is the problem the feature request solves?

```
Spark Uses Committer and Staging Paths for Writes
Apache Spark uses the FileCommitProtocol with staging paths to ensure atomic, fault-tolerant, and consistent distributed writes. Here's why:

1. The Problem: Distributed Writing is Complex
When multiple tasks write data in parallel across a cluster:

Task failures can leave partial/corrupted data
Speculative execution may create duplicate files
Network failures can interrupt writes mid-stream
Concurrent writes to the same location can conflict
2. The Solution: Two-Phase Commit Protocol
Spark implements a two-phase commit pattern borrowed from database systems:

Phase 1: Write to Staging (Task Commit)
Each task writes to a temporary staging location instead of the final destination.

Phase 2: Atomic Move (Job Commit)
Only after ALL tasks succeed, the driver atomically moves files from staging to final location.
```

Currently Comet just doing naive writes without 2PC

### Spark implementation

#### The Staging Directory From `FileCommitProtocol.getStagingDir()`

```
def getStagingDir(path: String, jobId: String): Path = {
new Path(path, ".spark-staging-" + jobId)
}
```

Example: If writing to /data/output, staging is `/data/output/.spark-staging-{jobId}`

#### How It Works: HadoopMapReduceCommitProtocol
here's the complete flow:

##### A. Task Execution
Each task writes to staging:

```
override def newTaskTempFile(taskContext: TaskAttemptContext, dir: Option[String], spec: FileNameSpec): String = {
val filename = getFilename(taskContext, spec)
val stagingDir: Path = committer match {
case f: FileOutputCommitter =>
new Path(Option(f.getWorkPath).map(_.toString).getOrElse(path))
case _ => new Path(path)
}

dir.map { d =>
new Path(new Path(stagingDir, d), filename).toString
}.getOrElse {
new Path(stagingDir, filename).toString
}
}
```
File path example:

`/data/output/.spark-staging-{jobId}/_temporary/{appAttemptId}/_temporary/{taskAttemptId}/part-00000.parquet`

##### Task Commit
When a task completes successfully:

```
override def commitTask(taskContext: TaskAttemptContext): TaskCommitMessage = {
SparkHadoopMapRedUtil.commitTask(committer, taskContext, ...)
new TaskCommitMessage(addedAbsPathFiles.toMap -> partitionPaths.toSet)
}
```

Files move from task-specific temp to job-level staging:

`/data/output/.spark-staging-{jobId}/part-00000.parquet`

##### Job Commit
Only after ALL tasks succeed, the driver commits:

```
override def commitJob(jobContext: JobContext, taskCommits: Seq[TaskCommitMessage]): Unit = {
committer.commitJob(jobContext)

if (hasValidPath) {
val fs = stagingDir.getFileSystem(jobContext.getConfiguration)

// Move files from staging to final location
for ((src, dst) <- filesToMove) {
if (!fs.rename(new Path(src), new Path(dst))) {
throw new IOException(s"Failed to rename $src to $dst")
}
}

// Clean up staging directory
fs.delete(stagingDir, true)
}
}
```

Files move to final location:

`/data/output/part-00000.parquet`

##### Job Abort
If ANY task fails, the driver aborts:

```
override def abortJob(jobContext: JobContext): Unit = {
try {
committer.abortJob(jobContext, JobStatus.State.FAILED)
} catch { case e: IOException => logWarning(...) }

try {
if (hasValidPath) {
val fs = stagingDir.getFileSystem(jobContext.getConfiguration)
fs.delete(stagingDir, true) // Delete ALL staging data
}
} catch { case e: IOException => logWarning(...) }
}
```

### Describe the potential solution

_No response_

### Additional context

_No response_

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.