-
Notifications
You must be signed in to change notification settings - Fork 29k
[SPARK-19413][SS] MapGroupsWithState for arbitrary stateful operations #16758
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
Conversation
|
Test build #72205 has finished for PR 16758 at commit
|
| } | ||
| } | ||
|
|
||
| trait StateStoreReader extends StatefulOperator { |
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.
This files should probably be renamed from StatefulAggregation to StatefulOperations.
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.
and lowercase if it contains multiple classes
|
Test build #72206 has finished for PR 16758 at commit
|
marmbrus
left a comment
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.
Looking pretty good!
| * optionally update or remove the corresponding state. The returned object will form a new | ||
| * [[Dataset]]. | ||
| * | ||
| * This function can be applied on both batch and streaming Datasets. With a streaming dataset, |
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.
will be called
| * [[Dataset]]. | ||
| * | ||
| * This function can be applied on both batch and streaming Datasets. With a streaming dataset, | ||
| * this function will be once for each in every trigger. For each key, the updated state from the |
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.
Any updates to the state will be stored and passed to the user given function in subsequent batches when executed as a Streaming Query.
|
|
||
| /** | ||
| * ::Experimental:: | ||
| * (Scala-specific) |
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.
while maintaining some user-defined state for each key.
| /** | ||
| * ::Experimental:: | ||
| * (Scala-specific) | ||
| * Applies the given function to each group of data, while using an additional keyed state. |
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.
I would consider breaking this up to make it a little easier to follow:
For each unique group, the given function will be invoked with the following arguments:
- The key of the group.
- A user-defined state object set by previous invocations of the given function. Note that, for batch queries, there is only ever one invocation and thus the state object will always be empty.
- An iterator containing all the values for this key.
| * function call in a trigger will be the state available in the function call in the next | ||
| * trigger. However, for batch, `mapGroupsWithState` behaves exactly as `mapGroups` and the | ||
| * function is called only once per key without any prior state. | ||
| * |
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.
I'd maybe put these into bullets as well.
| ClusteredDistribution(groupingAttributes) :: Nil | ||
|
|
||
| override def requiredChildOrdering: Seq[Seq[SortOrder]] = | ||
| Seq(groupingAttributes.map(SortOrder(_, Ascending))) // is this ordering needed? |
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.
Yes, the GroupedIterator relies on sorting.
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.
Thanks for confirming, will remove the comment.
| Seq(groupingAttributes.map(SortOrder(_, Ascending))) // is this ordering needed? | ||
|
|
||
| override protected def doExecute(): RDD[InternalRow] = { | ||
|
|
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.
nit: extra newline
| child.output.toStructType, | ||
| sqlContext.sessionState, | ||
| Some(sqlContext.streams.stateStoreCoordinator)) { (store, iter) => | ||
| try { |
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.
existing, but should mapPartitionsWithStateStore be implementing the abort handling. Seems generic.
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.
I thought of that. If I change the mapPartitionsWithStateStore, then StateStoreSave should also need to be change, which I didnt want to touch in this already big PR
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.
Managed to do it. Not a big change. Improves correctness, and reduces code.
|
|
||
| // Assumption: Append mode can be done only when watermark has been specified | ||
| store.remove(watermarkPredicate.get.eval) | ||
| store.remove(watermarkPredicate.get.eval _) |
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.
Why this change?
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.
Because of the addition of a new version of stateStore.remove. I think the compiler can disambiguate correctly without this.
| } | ||
|
|
||
| def deserializeRowToObject( | ||
| deserializer: Expression): InternalRow => Any = { |
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.
indent, also does this not fit?
|
Test build #72215 has finished for PR 16758 at commit
|
|
Test build #72216 has finished for PR 16758 at commit
|
|
Test build #72217 has finished for PR 16758 at commit
|
|
Test build #72223 has finished for PR 16758 at commit
|
|
Test build #72230 has finished for PR 16758 at commit
|
|
Test build #72256 has finished for PR 16758 at commit
|
| removed = true | ||
| } | ||
|
|
||
| override def toString: String = "KeyedState($value)" |
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.
nit: s"KeyedState($value)"
:)
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.
done
| // Function that returns running count only if its even, otherwise does not return | ||
| val stateFunc = (key: String, values: Iterator[String], state: KeyedState[RunningCount]) => { | ||
| if (state.exists) throw new IllegalArgumentException("state.exists should be false") | ||
| if (state.exists) { |
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.
nit: state.get == ...? also state.get in the error message
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.
done. not a nit! wrong test!
| test("mapGroupsWithState - batch") { | ||
| val stateFunc = (key: String, values: Iterator[String], state: KeyedState[RunningCount]) => { | ||
| if (state.exists) throw new IllegalArgumentException("state.exists should be false") | ||
| if (state.exists) { |
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.
same here
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.
done. thanks for catching these.
| * - If the `remove()` is called, then `exists()` will return `false`, and | ||
| * `getOption()` will return `None`. | ||
| * - After that `update(newState)` is called, then `exists()` will return `true`, | ||
| * and `getOption()` will return `Some(...)`. |
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.
nit: getOption ...
zsxwing
left a comment
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.
Made one pass. Most of my comments are nits.
|
|
||
| // MapGroupsWithState: Not supported after a streaming aggregation | ||
| val att = new AttributeReference(name = "a", dataType = LongType)() | ||
| assertSupportedInStreamingPlan( |
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.
nit: assertSupportedInStreamingPlan -> assertSupportedInBatchPlan
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.
done
| // Returns the data and the count if state is defined, otherwise does not return anything | ||
| val stateFunc = (key: String, values: Iterator[String], state: KeyedState[RunningCount]) => { | ||
|
|
||
| var count = Option(state.get).map(_.count).getOrElse(0L) + values.size |
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.
nit: var -> val
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.
done
| StartStream(), | ||
| CheckLastBatch(("a", 3L)) // task should not fail, and should show correct count | ||
| ) | ||
| } |
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.
Could you add a test for aggregation after mapGroupsWithState?
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.
Added.
|
|
||
| /** | ||
| * Update the value of the state. Note that null is not a valid value, and `update(null)` is | ||
| * same as `remove()` |
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.
It's better to disallow this case. Otherwise, the user may happen to send a null by mistake and we just hide the error.
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.
@marmbrus any thoughts on this?
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.
if get for a non-existent key returns null then I think its reasonable that remove(key) and put(key, null) are the same.
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.
@marmbrus The user may pass a null by mistake (e.g., just call some method that may return null but not be aware of it). It's pretty hard to debug such silent mistake.
| * - The key of the group. | ||
| * - An iterator containing all the values for this key. | ||
| * - A user-defined state object set by previous invocations of the given function. | ||
| * In case of a batch Dataset, there is only invocation and state object will be empty as |
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.
nit: only one invocation
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.
done
| * preparation walks the query plan. | ||
| */ | ||
| private var operatorId = 0 | ||
| private val operatorId = new AtomicInteger(0) |
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.
there is only one thread here. Why change it? Any concern?
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.
not really. just a tiny bit cleaner to do getAndIncrement than using x and then calling x += 1
| case Some(ValueRemoved(_, _)) => | ||
| // Remove already in update map, no need to change | ||
| } | ||
| } |
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.
Missing writeToDeltaFile(tempDeltaFileStream, ValueRemoved(key, value)). It's better to extract the duplicated codes to a new method.
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.
yeah. that's true. I should add tests for this StateStore operation.
| */ | ||
| def remove(condition: UnsafeRow => Boolean): Unit | ||
|
|
||
| def remove(key: UnsafeRow): Unit |
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.
nit: missing scala doc
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.
done
| val groupedIter = GroupedIterator(iter, groupingAttributes, child.output) | ||
|
|
||
| val getKeyObj = ObjectOperator.deserializeRowToObject(keyDeserializer, groupingAttributes) | ||
| val getKey = GenerateUnsafeProjection.generate(groupingAttributes, child.output) |
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.
nit: not used
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.
done.
| val wrappedState = KeyedStateImpl[Any](stateObjOption.orNull) | ||
| val mappedIterator = func(keyObj, valueObjIter, wrappedState) | ||
|
|
||
| if (wrappedState.isRemoved) { |
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.
this should not be checked here. mappedIterator may be lazy. You can wrap it with a CompletionIterator and add these codes there.
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.
good point. this would confusing for the user if we dont do this.
|
|
||
| /** Collect all the streaming aggregates in a sub plan */ | ||
| def collectStreamingAggregates(subplan: LogicalPlan): Seq[Aggregate] = { | ||
| subplan.collect { case a@Aggregate(_, _, _) if a.isStreaming => a } |
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.
nit: space before and after @. Actually if you're not going to use any of the Aggregate params, just change this to a: Aggregate
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.
done.
| stateSerializer: Seq[NamedExpression], | ||
| child: LogicalPlan) extends UnaryNode with ObjectProducer | ||
|
|
||
|
|
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.
nit: extra line
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.
removed
| * Important points to note about using KeyedState. | ||
| * - The value of the state cannot be null. So updating state with null is same as removing it. | ||
| * - Operations on `KeyedState` are not thread-safe. This is to avoid memory barriers. | ||
| * - If the `remove()` is called, then `exists()` will return `false`, and |
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.
nit: If ``remove`` is called. Remove the
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.
done.
|
|
||
| import org.apache.spark.sql.KeyedState | ||
|
|
||
| /** Internal implementation of the [[KeyedState]] interface */ |
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.
Would be nice to note here that this implementation is not thread safe
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.
i have mentioned that the trait KeyedState is not thread-safe
|
I addressed all the comments. However, @zsxwing @marmbrus, our offline discussion of throwing error on |
|
Test build #72490 has started for PR 16758 at commit |
|
@zsxwing had a discussion with @marmbrus. It is indeed weird that after calling
|
|
Test build #72543 has finished for PR 16758 at commit
|
|
LGTM. Merging to master and |
|
It conflicts with 2.1. Could you submit a backport PR, please? |
What changes were proposed in this pull request?
mapGroupsWithStateis a new API for arbitrary stateful operations in Structured Streaming, similar toDStream.mapWithStateRequirements
Proposed API
Key Semantics of the State class
Usage
How was this patch tested?
New unit tests.