-
Notifications
You must be signed in to change notification settings - Fork 29.3k
[SPARK-23202][SQL] Break down DataSourceV2Writer.commit into two phase #20386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7ae1029
0d2e39a
59b1857
5938ab3
939ad06
aefef15
0e5ad84
f72c86c
d198671
540ff06
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,40 +32,44 @@ | |
| @InterfaceStability.Evolving | ||
| public interface StreamWriter extends DataSourceWriter { | ||
| /** | ||
| * Commits this writing job for the specified epoch with a list of commit messages. The commit | ||
| * messages are collected from successful data writers and are produced by | ||
| * {@link DataWriter#commit()}. | ||
| * Commits this writing job for the specified epoch. | ||
| * | ||
| * If this method fails (by throwing an exception), this writing job is considered to have been | ||
| * failed, and the execution engine will attempt to call {@link #abort(WriterCommitMessage[])}. | ||
| * When this method is called, the number of commit messages added by | ||
| * {@link #add(WriterCommitMessage)} equals to the number of input data partitions. | ||
| * | ||
| * If this method fails (by throwing an exception), this writing job is considered to to have been | ||
| * failed, and {@link #abort()} would be called. The state of the destination | ||
| * is undefined and @{@link #abort()} may not be able to deal with it. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: javadoc typo. |
||
| * | ||
| * To support exactly-once processing, writer implementations should ensure that this method is | ||
| * idempotent. The execution engine may call commit() multiple times for the same epoch | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I realize this isn't part of this commit, but why would an exactly-once guarantee require idempotent commits? Processing the same data twice with an idempotent guarantee is not the same thing as exactly-once.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The StreamWriter is responsible for setting up a distributed transaction to commit the data within batch both locally and to the remote system. But the StreamExecution keeps its own log of which batches have been fully completed. ("Fully completed" includes things like stateful aggregation commits and progress logging which can't reasonably participate in the StreamWriter's transaction.) So there's a scenario where Spark fails between StreamWriter commit and StreamExecution commit, in which the StreamExecution must re-execute the batch to ensure everything is in the right state. The StreamWriter is responsible for ensuring this doesn't generate duplicate data in the remote system. Note that the "true" exactly once strategy, where the StreamWriter aborts the retried batch because it was already committed before, is indeed idempotent wrt StreamWriter.commit(epochId). But there are weaker strategies which still provide equivalent semantics.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for this explanation, I think I see what you're saying. But I think your statement that refers to "true" exactly-once gives away the fact that this does not provide exactly-once semantics. Maybe this is a question for the dev list: why the weaker version? Shouldn't this API provide a check to see whether the data was already committed?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What are the exact guarantees you're looking for when calling a system "exactly-once"? I worry you're looking for something that isn't possible. In particular, I don't know of any additional guarantee that check would allow us to make.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For a commit interface, I expect the guarantee to be that data is committed exactly once. If commits are idempotent, data may be reprocessed, and commits may happen more than once, then that is not an exactly-once commit: that is an at-least-once commit. I'm not trying to split hairs. My point is that if there's no difference in behavior between exactly-once and at-least-once because the commit must be idempotent, then you don't actually have a exactly-once guarantee.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's true that there's no exactly-once behavior with respect to StreamWriter.commit(). "Exactly-once processing" refers to the promise that the remote sink will contain exactly one committed copy of each processed record.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If that's the case, then this interface should be clear about it instead of including wording about exactly-once. For this interface, there is no exactly-once guarantee. |
||
| * in some circumstances. | ||
| */ | ||
| void commit(long epochId, WriterCommitMessage[] messages); | ||
| void commit(long epochId); | ||
|
|
||
| /** | ||
| * Aborts this writing job because some data writers are failed and keep failing when retry, or | ||
| * the Spark job fails with some unknown reasons, or {@link #commit(WriterCommitMessage[])} fails. | ||
| * Aborts this writing job because some data writers are failed and keep failing when retry, | ||
| * or the Spark job fails with some unknown reasons, | ||
| * or {@link #commit()} / {@link #add(WriterCommitMessage)} fails | ||
| * | ||
| * If this method fails (by throwing an exception), the underlying data source may require manual | ||
| * cleanup. | ||
| * | ||
| * Unless the abort is triggered by the failure of commit, the given messages should have some | ||
| * null slots as there maybe only a few data writers that are committed before the abort | ||
| * happens, or some data writers were committed but their commit messages haven't reached the | ||
| * driver when the abort is triggered. So this is just a "best effort" for data sources to | ||
| * clean up the data left by data writers. | ||
| * Unless the abort is triggered by the failure of commit, the number of commit | ||
| * messages added by {@link #add(WriterCommitMessage)} should be smaller than the number | ||
| * of input data partitions, as there may be only a few data writers that are committed | ||
| * before the abort happens, or some data writers were committed but their commit messages | ||
| * haven't reached the driver when the abort is triggered. So this is just a "best effort" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Commit messages in flight should be handled and aborted. Otherwise, this isn't a "best effort". Best effort means that Spark does everything that is feasible to ensure that commit messages are added before aborting, and that should include race conditions from RPC. The case where "best effort" might miss a message is if the message is created, but a node fails before it is sent to the driver.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there is no difference between "the message is created, but a node fails before it is sent" and "the message is in flight". Implementations need to deal with the case when a writer finishes successfully but its message is not available in
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Best effort is not just how we describe the behavior, it is a requirement of the contract. Spark should not drop commit messages because it is convenient. Spark knows what tasks succeeded and failed and which ones were authorized to commit. That's enough information to provide the best-effort guarantee.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a bit of a weird case for API documentation, because the external users of the API will be implementing rather than consuming the interface. We shouldn't drop messages just because we don't want to be bothered, but it's easy to fix that if we make a mistake and there's no serious problem if we miss cases we really could have handled. It's a more serious issue if people misunderstand what Spark can provide, and implement sources which assume any commit message that's been generated will be passed to abort. |
||
| * for data sources to clean up the data left by data writers. | ||
| */ | ||
| void abort(long epochId, WriterCommitMessage[] messages); | ||
| void abort(long epochId); | ||
|
|
||
| default void commit(WriterCommitMessage[] messages) { | ||
| default void commit() { | ||
| throw new UnsupportedOperationException( | ||
| "Commit without epoch should not be called with StreamWriter"); | ||
| } | ||
|
|
||
| default void abort(WriterCommitMessage[] messages) { | ||
| default void abort() { | ||
| throw new UnsupportedOperationException( | ||
| "Abort without epoch should not be called with StreamWriter"); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,16 +40,21 @@ | |
| * 1. Create a writer factory by {@link #createWriterFactory()}, serialize and send it to all the | ||
| * partitions of the input data(RDD). | ||
| * 2. For each partition, create the data writer, and write the data of the partition with this | ||
| * writer. If all the data are written successfully, call {@link DataWriter#commit()}. If | ||
| * exception happens during the writing, call {@link DataWriter#abort()}. | ||
| * 3. If all writers are successfully committed, call {@link #commit(WriterCommitMessage[])}. If | ||
| * some writers are aborted, or the job failed with an unknown reason, call | ||
| * {@link #abort(WriterCommitMessage[])}. | ||
| * writer. If one data writer finishes successfully, the commit message will be sent back to | ||
| * the driver side and Spark will call {@link #add(WriterCommitMessage)}. | ||
| * If exception happens during the writing, call {@link DataWriter#abort()}. | ||
| * 3. If all the data writers finish successfully, and {@link #add(WriterCommitMessage)} is | ||
| * successfully called for all the commit messages, Spark will call {@link #commit()}. | ||
| * If any of the data writers failed, or any of the {@link #add(WriterCommitMessage)} | ||
| * calls failed, or the job failed with an unknown reason, call {@link #abort()}. | ||
| * | ||
| * While Spark will retry failed writing tasks, Spark won't retry failed writing jobs. Users should | ||
| * do it manually in their Spark applications if they want to retry. | ||
| * | ||
| * Please refer to the documentation of commit/abort methods for detailed specifications. | ||
| * All these methods are guaranteed to be called in a single thread on driver side. | ||
| * No concurrency control is needed. | ||
| * | ||
| * Please refer to the documentation of add/commit/abort methods for detailed specifications. | ||
| */ | ||
| @InterfaceStability.Evolving | ||
| public interface DataSourceWriter { | ||
|
|
@@ -63,32 +68,42 @@ public interface DataSourceWriter { | |
| DataWriterFactory<Row> createWriterFactory(); | ||
|
|
||
| /** | ||
| * Commits this writing job with a list of commit messages. The commit messages are collected from | ||
| * successful data writers and are produced by {@link DataWriter#commit()}. | ||
| * Handles a commit message which is collected from a successful data writer. | ||
| * | ||
| * Note that, implementations might need to cache all commit messages before calling | ||
| * {@link #commit()} or {@link #abort()}. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In what case would an implementation not cache and commit all at once? What is the point of a commit if not to make sure all of the data shows up at the same time? |
||
| * | ||
| * If this method fails (by throwing an exception), this writing job is considered to to have been | ||
| * failed, and {@link #abort(WriterCommitMessage[])} would be called. The state of the destination | ||
| * is undefined and @{@link #abort(WriterCommitMessage[])} may not be able to deal with it. | ||
| * failed, and {@link #abort()} would be called. The state of the destination | ||
| * is undefined and @{@link #abort()} may not be able to deal with it. | ||
| */ | ||
| void add(WriterCommitMessage message); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the only method shared between the stream and batch writers. Why does the streaming interface extend this one?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It probably shouldn't anymore. But I'd suggest dealing with that in another PR, because removing the inheritance will require splitting off some streaming parts of the execution engine.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 for separating and using another PR. Thanks. |
||
|
|
||
| /** | ||
| * Commits this writing job. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| * When this method is called, the number of commit messages added by | ||
| * {@link #add(WriterCommitMessage)} equals to the number of input data partitions. | ||
| * | ||
| * Note that, one partition may have multiple committed data writers because of speculative tasks. | ||
| * Spark will pick the first successful one and get its commit message. Implementations should be | ||
| * aware of this and handle it correctly, e.g., have a coordinator to make sure only one data | ||
| * writer can commit, or have a way to clean up the data of already-committed writers. | ||
| * If this method fails (by throwing an exception), this writing job is considered to to have been | ||
| * failed, and {@link #abort()} would be called. The state of the destination | ||
| * is undefined and @{@link #abort()} may not be able to deal with it. | ||
| */ | ||
| void commit(WriterCommitMessage[] messages); | ||
| void commit(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WDYT of using the same API as FileCommitProtocol, where the engine both calls add() for each message but also passes them in to commit() at the end? It seems like most writers will have to keep an array of the messages they received.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is something we wanna improve at the API level. I think the implementation should be free to decide how to store the messages, in case each message is big and there are a lot of them. If this is not a problem at all, we can follow |
||
|
|
||
| /** | ||
| * Aborts this writing job because some data writers are failed and keep failing when retry, or | ||
| * the Spark job fails with some unknown reasons, or {@link #commit(WriterCommitMessage[])} fails. | ||
| * Aborts this writing job because some data writers are failed and keep failing when retry, | ||
| * or the Spark job fails with some unknown reasons, | ||
| * or {@link #commit()} / {@link #add(WriterCommitMessage)} fails. | ||
| * | ||
| * If this method fails (by throwing an exception), the underlying data source may require manual | ||
| * cleanup. | ||
| * | ||
| * Unless the abort is triggered by the failure of commit, the given messages should have some | ||
| * null slots as there maybe only a few data writers that are committed before the abort | ||
| * happens, or some data writers were committed but their commit messages haven't reached the | ||
| * driver when the abort is triggered. So this is just a "best effort" for data sources to | ||
| * clean up the data left by data writers. | ||
| * Unless the abort is triggered by the failure of {@link #commit()}, the number of commit | ||
| * messages added by {@link #add(WriterCommitMessage)} should be smaller than the number | ||
| * of input data partitions, as there may be only a few data writers that are committed | ||
| * before the abort happens, or some data writers were committed but their commit messages | ||
| * haven't reached the driver when the abort is triggered. So this is just a "best effort" | ||
| * for data sources to clean up the data left by data writers. | ||
| */ | ||
| void abort(WriterCommitMessage[] messages); | ||
| void abort(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -118,15 +118,23 @@ class MemoryWriter(sink: MemorySinkV2, batchId: Long, outputMode: OutputMode) | |
|
|
||
| override def createWriterFactory: MemoryWriterFactory = MemoryWriterFactory(outputMode) | ||
|
|
||
| def commit(messages: Array[WriterCommitMessage]): Unit = { | ||
| private val messages = new ArrayBuffer[WriterCommitMessage]() | ||
|
|
||
| override def add(message: WriterCommitMessage): Unit = { | ||
| messages += message | ||
| } | ||
|
|
||
| def commit(): Unit = { | ||
| val newRows = messages.flatMap { | ||
| case message: MemoryWriterCommitMessage => message.data | ||
| } | ||
| }.toArray | ||
| sink.write(batchId, outputMode, newRows) | ||
| messages.clear() | ||
| } | ||
|
|
||
| override def abort(messages: Array[WriterCommitMessage]): Unit = { | ||
| override def abort(): Unit = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ditto |
||
| // Don't accept any of the new input. | ||
| messages.clear() | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -135,15 +143,23 @@ class MemoryStreamWriter(val sink: MemorySinkV2, outputMode: OutputMode) | |
|
|
||
| override def createWriterFactory: MemoryWriterFactory = MemoryWriterFactory(outputMode) | ||
|
|
||
| override def commit(epochId: Long, messages: Array[WriterCommitMessage]): Unit = { | ||
| private val messages = new ArrayBuffer[WriterCommitMessage]() | ||
|
|
||
| override def add(message: WriterCommitMessage): Unit = { | ||
| messages += message | ||
| } | ||
|
|
||
| override def commit(epochId: Long): Unit = { | ||
| val newRows = messages.flatMap { | ||
| case message: MemoryWriterCommitMessage => message.data | ||
| } | ||
| }.toArray | ||
| sink.write(epochId, outputMode, newRows) | ||
| messages.clear() | ||
| } | ||
|
|
||
| override def abort(epochId: Long, messages: Array[WriterCommitMessage]): Unit = { | ||
| override def abort(epochId: Long): Unit = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ditto |
||
| // Don't accept any of the new input. | ||
| messages.clear() | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does this mean? It isn't clear to me what "the number of input partitions" means, or why it isn't obvious that it is equal to the number of pending
WriterCommitMessageinstances passed to add.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how about
the number of data(RDD) partitions to write?Maybe we can just follow
FileCommitProtocol, i.e.commitandabortstill takes an array of messages.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Passing the messages to commit and abort seems simpler and better to me, but that's for the batch side. And, we shouldn't move forward with this unless there's a use case.
As for the docs here, what is an implementer intended to understand as a result of this? "The number of data partitions to write" is also misleading: weren't these already written and committed by tasks?