[Dataflow Streaming] [Multi Key] MultiKey failure handling + Integration - #38919
Conversation
…lients - Add MultiKeyWorkItemCommitRequest to windmill.proto. - Support MultiKey commits in Commit model and StreamingEngineWorkCommitter. - Update GrpcCommitWorkStream to batch and stream MultiKey commit requests.
…oring for multi-key execution.
# Conflicts: # runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/StreamingModeExecutionContext.java
Resolved conflicts in StreamingModeExecutionContext.java and StreamingModeExecutionContextTest.java. Fixed compilation error in Work.java by removing duplicate getComputationId() method. TAG=agy CONV=143daaa5-e902-4d26-820d-cf1af2babb84
|
R: @scwhittle This change is on top of #38814 and #38768 PTAL |
|
Stopping reviewer notifications for this pull request: review requested by someone other than the bot, ceding control. If you'd like to restart, comment |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces multi-key commit support for Dataflow streaming, allowing multiple work items to be committed in a single transaction. It enhances failure handling by enabling re-execution of specific work items within a batch if a retryable failure occurs. Additionally, it optimizes the execution context to pull more work items from the BoundedWorkQueue, thereby increasing processing efficiency for multi-key bundles. Highlights
New Features🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces multi-key bundle support for streaming in the Google Cloud Dataflow Java worker, enabling batching and advancing through multiple keys within a key group, as well as transactionally committing multi-key work batches. Feedback suggests adding robust error handling when parsing experimental options to prevent worker crashes, and implementing defensive null and bounds checks when accessing read operation receivers to avoid potential runtime exceptions.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| String batchSizeStr = | ||
| ExperimentalOptions.getExperimentValue(options, WINDMILL_MAX_KEY_GROUP_BATCH_SIZE); | ||
| this.maxKeyGroupBatchSize = batchSizeStr != null ? Integer.parseInt(batchSizeStr) : 100; | ||
|
|
||
| String batchTimeStr = | ||
| ExperimentalOptions.getExperimentValue(options, WINDMILL_MAX_KEY_GROUP_BATCH_TIME_MS); | ||
| this.maxKeyGroupBatchTimeNanos = | ||
| TimeUnit.MILLISECONDS.toNanos(batchTimeStr != null ? Long.parseLong(batchTimeStr) : 100); |
There was a problem hiding this comment.
Parsing user-provided experimental options directly using Integer.parseInt and Long.parseLong without error handling can cause the worker to crash if the values are malformed. It is safer to wrap these in a try-catch block and fall back to the default values with a warning log.
String batchSizeStr =
ExperimentalOptions.getExperimentValue(options, WINDMILL_MAX_KEY_GROUP_BATCH_SIZE);
int batchSize = 100;
if (batchSizeStr != null) {
try {
batchSize = Integer.parseInt(batchSizeStr);
} catch (NumberFormatException e) {
LOG.warn("Failed to parse {} as integer, using default of 100", WINDMILL_MAX_KEY_GROUP_BATCH_SIZE, e);
}
}
this.maxKeyGroupBatchSize = batchSize;
String batchTimeStr =
ExperimentalOptions.getExperimentValue(options, WINDMILL_MAX_KEY_GROUP_BATCH_TIME_MS);
long batchTimeMs = 100;
if (batchTimeStr != null) {
try {
batchTimeMs = Long.parseLong(batchTimeStr);
} catch (NumberFormatException e) {
LOG.warn("Failed to parse {} as long, using default of 100", WINDMILL_MAX_KEY_GROUP_BATCH_TIME_MS, e);
}
}
this.maxKeyGroupBatchTimeNanos = TimeUnit.MILLISECONDS.toNanos(batchTimeMs);| HashMap<String, ElementCounter> counters = | ||
| ((DataflowMapTaskExecutor) workExecutor) | ||
| .getReadOperation() | ||
| .receivers[0] | ||
| .getOutputCounters(); |
There was a problem hiding this comment.
Defensive programming: Accessing receivers[0] directly without checking if getReadOperation() is null, or if receivers is null or empty, can lead to NullPointerException or ArrayIndexOutOfBoundsException. Adding appropriate guards ensures robust execution.
DataflowMapTaskExecutor mapTaskExecutor = (DataflowMapTaskExecutor) workExecutor;
if (mapTaskExecutor.getReadOperation() == null
|| mapTaskExecutor.getReadOperation().receivers == null
|| mapTaskExecutor.getReadOperation().receivers.length == 0) {
return 0L;
}
HashMap<String, ElementCounter> counters =
mapTaskExecutor.getReadOperation().receivers[0].getOutputCounters();
if (counters == null) {
return 0L;
}
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #38919 +/- ##
=============================================
- Coverage 59.33% 55.39% -3.95%
+ Complexity 16593 2243 -14350
=============================================
Files 2845 1104 -1741
Lines 291334 171355 -119979
Branches 14421 1437 -12984
=============================================
- Hits 172859 94915 -77944
+ Misses 111065 74000 -37065
+ Partials 7410 2440 -4970
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Hi @scwhittle, can you review this at your convenience? Thanks |
scwhittle
left a comment
There was a problem hiding this comment.
waiting on tests until previous change has been merged
| @@ -88,6 +88,11 @@ static ActiveWorkState create(WindmillStateCache.ForComputation computationState | |||
| return new ActiveWorkState(new HashMap<>(), computationStateCache); | |||
| } | |||
|
|
|||
| synchronized Optional<ExecutableWork> getActiveWork(ShardedKey shardedKey, WorkId workId) { | |||
There was a problem hiding this comment.
should we just return nullable ExecutableWork instead? With annotations that seems preferrable to me to avoid the optional allocation
| @@ -131,6 +131,10 @@ public void completeWorkAndScheduleNextWorkForKey(ShardedKey shardedKey, WorkId | |||
| .ifPresent(this::forceExecute); | |||
| } | |||
|
|
|||
| public void reExecuteActiveWork(ShardedKey shardedKey, WorkId workId) { | |||
There was a problem hiding this comment.
nit: I would uncapitalized E since reexecute is one word
| this.latencyTrackingId = | ||
| Long.toHexString(workItem.getShardingKey()) | ||
| + '-' | ||
| + Long.toHexString(workItem.getWorkToken()); | ||
| this.currentState = TimedState.initialState(startTime); | ||
| this.isFailed = false; | ||
| this.getWorkStreamLatencies = getWorkStreamLatencies; | ||
| } | ||
|
|
||
| public static Work create( |
There was a problem hiding this comment.
if we don't use this often, perhaps we should just add the ImmutableList.of() to the call-site
| for (LatencyAttribution latency : getWorkStreamLatencies) { | ||
| totalDurationPerState.put( | ||
| latency.getState(), Duration.millis(latency.getTotalDurationMillis())); | ||
| public ImmutableList<LatencyAttribution> getWorkStreamLatencies() { |
There was a problem hiding this comment.
I don't see usage of this, can we just get rid of this.getWorkStreamLatencies and instead do the recordGetWorkStreamLatencies logic in the constructor?
There was a problem hiding this comment.
removed getWorkStreamLatencies().
recordGetWorkStreamLatencies was initially inside the constructor, moved it out of the constructor to reduce load on the Getworkstream thread. recordGetWorkStreamLatencies runs in the processing thread.
There was a problem hiding this comment.
Not sure if you sent comments, but I was also suggesting removing the member variable and public recordGetWorkStreamLatencies method as well. Seems it could just be done in constructor?
There was a problem hiding this comment.
Sorry forgot to publish comments. Published now.
| this.elements = elements; | ||
| private BoundedQueueExecutorWorkHandleImpl(Work work, long bytes) { | ||
| checkArgument(bytes >= 0); | ||
| this.workBatch = new ArrayList<>(); |
There was a problem hiding this comment.
could workBatch be an ImmutableList.Builder?
There was a problem hiding this comment.
changing it to ImmutableList.Builder is adding more copies in merge(). ArrayList seems to be better.
| Windmill.MultiKeyWorkItemCommitRequest.Builder multiKeyBuilder = | ||
| Windmill.MultiKeyWorkItemCommitRequest.newBuilder(); | ||
|
|
||
| Work primaryWork = workBatch.get(0); |
There was a problem hiding this comment.
checkState that workBatch is non-empty
|
|
||
| for (int i = 0; i < workBatch.size(); i++) { | ||
| // TODO: Add commit size validation | ||
| Windmill.WorkItemCommitRequest commit = workItemCommits.get(i); |
There was a problem hiding this comment.
precondition that workitemcommits and workBatch are same size
| boolean hotKeyLoggingEnabled, | ||
| String stepName, | ||
| String sourceBytesProcessCounterName, | ||
| PipelineOptions options, |
There was a problem hiding this comment.
instead of passing in full options, could pass some internal multikeyoptions struct
benefits is that the parsing doesn't have to be here and can be shared across all the contexts for now. But in the future we may want to configure differently for different fused stages based upon other information and that would allow us to do so as it would be separate from the single experiment value.
There was a problem hiding this comment.
Changed to use MultiKeyBundleOptions.
| return false; | ||
| } | ||
| if (workIsFailed()) { | ||
| throw new WorkItemCancelledException(checkStateNotNull(work).getWorkItem().getShardingKey()); |
There was a problem hiding this comment.
move activeWork definition above and use it here instead of separate check
| * Indicates that the work is no longer valid and should be canceled. It is thrown as a signal for | ||
| * upper layers to mark the work as failed. | ||
| */ | ||
| public class WorkCancelingException extends RuntimeException { |
There was a problem hiding this comment.
nit: can we use Cancelling with two ls to match other usage in beam
should we just use WorkItemCancelledException.java? If not can you explain the differences here?
There was a problem hiding this comment.
Renamed and added differences. once is thrown before marking work as failed and the other is thrown after noticing that a work is failed.
scwhittle
left a comment
There was a problem hiding this comment.
Code looks mostly good, still going through tests
| + Long.toHexString(workItem.getWorkToken()); | ||
| this.currentState = TimedState.initialState(startTime); | ||
| this.isFailed = false; | ||
| this.getWorkStreamLatencies = getWorkStreamLatencies; |
There was a problem hiding this comment.
instead of saving just populate totalDurationPerState here
There was a problem hiding this comment.
this was intentionally moved out of the submission loop in #33736
There was a problem hiding this comment.
can you add a comment here?
// We defer recordGetWorkStreamLatencies() to be called during bundle processing
// as these are constructed on the hot GetWork thread
| switch (evaluateRetry(computationId, executableWork.work(), t)) { | ||
| case DO_NOT_RETRY: | ||
| // Consider the item invalid. It will eventually be retried by Windmill if it still needs | ||
| // to |
| if (workBatch.isEmpty()) { | ||
| return; | ||
| } | ||
| if (workBatch.size() > 1 || multiKeyBundleOptions.multiKeyBundleEnabled()) { |
There was a problem hiding this comment.
is there a benefit to sending single keys in multi-key format? Otherwise seems likely more overhead in protos etc
There was a problem hiding this comment.
The benefit is eventually we can remove the single key code path and have only the multikey path. Having one code path on a job also will make debugging easier.
scwhittle
left a comment
There was a problem hiding this comment.
finished looking at all tests, so think this is likely final round
| + Long.toHexString(workItem.getWorkToken()); | ||
| this.currentState = TimedState.initialState(startTime); | ||
| this.isFailed = false; | ||
| this.getWorkStreamLatencies = getWorkStreamLatencies; |
There was a problem hiding this comment.
can you add a comment here?
// We defer recordGetWorkStreamLatencies() to be called during bundle processing
// as these are constructed on the hot GetWork thread
| Windmill.MultiKeyWorkItemCommitRequest.newBuilder(); | ||
|
|
||
| Work primaryWork = workBatch.get(0); | ||
| Work.KeyGroup keyGroup = primaryWork.getKeyGroup(); |
There was a problem hiding this comment.
since we are using this for single keys also that likely don't have a group, should we avoid setting the key group nested field if 0?
| Windmill.Uint128Proto.newBuilder().setHigh(keyGroup.high()).setLow(keyGroup.low()).build()); | ||
|
|
||
| for (int i = 0; i < workBatch.size(); i++) { | ||
| // TODO: Retry on commit truncations |
There was a problem hiding this comment.
should we throw an exception for now?
| // Key switch listener to delegate MDC logging context and thread name updates | ||
| public interface KeyTransitionListener { | ||
| void onKeyTransition(Work oldWork, Work newWork); | ||
| void onKeyTransition(@Nullable Work oldWork, Work newWork); |
There was a problem hiding this comment.
// oldWork is null when newWork is the first work for the bundle.
| currentBuilder.clear(); | ||
| currentBuilder.mergeFrom(truncationBuilder.build()); | ||
|
|
||
| // TODO: throw and retry when truncation is not on a single key bundle. |
There was a problem hiding this comment.
can we throw an exception if multikey bundles are enabled to make sure we don't lose data if we forget to address this?
| makeWorker( | ||
| defaultWorkerParams( | ||
| "--experiments=unstable_enable_multi_key_bundle,windmill_max_key_group_batch_time_ms=50000", | ||
| "--numberOfWorkerHarnessThreads=1") |
There was a problem hiding this comment.
do we need this harness threads because otherwise we start new threads to process the same group in parallel?
If so I wonder if that something we might want to try to prevent in the future? If we have more threads than # of CPU, it is likely better to not parallelize a key group.
There was a problem hiding this comment.
Yes, there is room to improve the batching. Planning to tackle it separately.
| .setKey(keyRequest.getKey()) | ||
| .setShardingKey(keyRequest.getShardingKey()); | ||
| if (keyRequest.getWorkToken() == 2) { | ||
| keyBuilder.setFailed(true); |
There was a problem hiding this comment.
if we do add support for the generic response to the server, we could also add the ability to set work tokens to fail get data requests.
There was a problem hiding this comment.
Added StreamingDataflowWorkerTest::emptyDataResponderWithFailedWorkTokens that take the failed work tokens.
| options = PipelineOptionsFactory.as(DataflowWorkerHarnessOptions.class); | ||
| options | ||
| .as(ExperimentalOptions.class) | ||
| .setExperiments(Arrays.asList("unstable_enable_multi_key_bundle")); |
| work1, workExecutor, mockExecutor, mockHandle, null, (oldWork, newWork) -> {}); | ||
|
|
||
| assertTrue(executionContext.advance()); | ||
| assertEquals("key2", executionContext.getSerializedKey().toStringUtf8()); |
|
|
||
| work1.setFailed(); | ||
|
|
||
| assertThrows(WorkItemCancelledException.class, () -> executionContext.advance()); |
There was a problem hiding this comment.
assert no interactions on executor
The change connects adds failure handling for multi key commits.
Integrates StreamingWorkScheduler and multikey commit methods.
Updates StreamingModeExecutionContext::advance to pull in more items from BoundedWorkQueue
All changes are behind the experiment
unstable_enable_multi_key_bundleand does not affect default logic.