Repository navigation
Conversation
There was a problem hiding this comment.
📋 Review Summary
This PR fixes several important contract and state-tracking bugs across WindmillSet, WindmillMapViaMultimap, and WindmillMap (including SetState.addIfAbsent always returning false, MapState.computeIfAbsent returning the inserted value instead of null, StructuralKey equality for array/custom keys, and cache/removal handling in getOrDefault, entries, and put), accompanied by new unit test suites.
🔍 General Feedback
- The fixes in
WindmillSet,WindmillMapViaMultimap, andWindmillMapare well-targeted and resolve clear contract violations. - One subtle edge case remains in
WindmillMap.entries().read()on the non-weighted (lazy) path: callinguserEntries()directly reads the livecachedValuesmap instead of thecachedCopysnapshot, which can cause duplicate entries orConcurrentModificationExceptionifget(k)orput(k, v)is called before/during iteration of the returnedIterable.
💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.
💬 Inline Findings
-
runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillMap.java(line 418): 🟠 High: In the non-weighted branch ofentries().read(),userEntries()builds its list from the livecachedValuesat the timeentries().read()is called, whereasIterables.filterchecks!cachedCopy.containsKey(structKey)lazily when the returnedIterableis iterated. However, if any changes were meant to be isolated tocachedCopy—or more importantly,userEntries()is called whilecachedCopyis already constructed on line 410—Wait:MapStatespecifies that subsequent mutations should not affect iterables already returned byentries().read(). WhileuserEntries()snapshotscachedValuesinto aListeagerly,cachedCopyon line 410 is a redundant copy ofcachedValues. Even more subtly, in theWeightedbranch above (line 404),cachedValues.putIfAbsent(structKey, e.getValue())is used: if a key was locally mapped tonull(viaNullableCoder, which this PR explicitly supports input),putIfAbsentwill overwrite the localnullvalue with the stale non-null value from Windmill storage becauseMap.putIfAbsenttreats anullvalue as absent! Using!cachedValues.containsKey(structKey)beforecachedValues.put(structKey, e.getValue())on line 404 avoids overwriting locally storednullvalues. -
runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillMap.java(line 404): 🟠 High:Map.putIfAbsentoverwrites existing entries whose value isnull. Since this PR adds support fornullvalues withNullableCoder(tested intestPutNullValueSupported), if a user callsmap.put(k, null)to overwrite a persisted non-null value withnulland then callsentries().read(),cachedValues.putIfAbsent(structKey, e.getValue())will overwrite the cachednullwith the old persisted value from Windmill. Check!cachedValues.containsKey(structKey)instead.
if (!localRemovals.contains(structKey) && !cachedValues.containsKey(structKey)) {
cachedValues.put(structKey, e.getValue());
}
runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillMap.java(line 284): 🟡 Medium: InWindmillMap.remove(K key)(lines 293-296),if (localRemovals.add(structKey))only removesstructKeyfromcachedValuesandlocalAdditionsifstructKeywas not already inlocalRemovals. However, inWindmillMap.entries().read()orget().read()prior to this PR (or iflocalRemovalsalready containedstructKey), iflocalRemovalsis not cleared onput...putdoes remove fromlocalRemovals, solocalRemovalsandcachedValuesare disjoint unlesscomputeIfAbsentor another path populatescachedValueswithout clearinglocalRemovals. Still, removing fromcachedValuesandlocalAdditionsunconditionally (or ensuringnegativeCache.remove(structKey)is also called onremove) is safer and cleaner.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #40739 +/- ##
=============================================
+ Coverage 56.16% 75.97% +19.80%
+ Complexity 2288 2273 -15
=============================================
Files 1124 259 -865
Lines 178336 14377 -163959
Branches 1489 1479 -10
=============================================
- Hits 100170 10923 -89247
+ Misses 75645 2763 -72882
+ Partials 2521 691 -1830
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:
|
|
Assigning reviewers: R: @Abacn added as fallback since no labels match configuration Note: If you would like to opt out of this review, comment Available commands:
The PR bot will only process comments in the main thread (not review comments). |
Summary of Changes and Bug Fixes
Ran an audit on
WindmillMap.java,WindmillSet.java, andWindmillMapViaMultimap.java, which identified a few logical errors. None of the files had unit tests, so I added some basic tests, as well as tests identifying all of the specific bugs mentioned below.1.
WindmillSet.javaBug:
addIfAbsent()Always ReturnedfalseSetState.addIfAbsent(value)contract specifies:TagBag.putIfAbsent(key, false)returnsnullif the item was absent and added, or the existing boolean (false) if already present.WindmillSetused:putState.read()returnednull, which defaulted viaorElse(false)tofalse. When already present, it returnedfalse. Consequently,addIfAbsent().read()always evaluated tofalse.read()to check whether the previous state wasnull:2.
WindmillMapViaMultimap.javaBug:
computeIfAbsent()Returned Inserted Value Instead ofnullMapState.computeIfAbsent(key, mappingFunction)contract specifies:WindmillMapViaMultimapcomputed the value, inserted it into the multimap, and returnedReadableStates.immediate(inserted)instead ofnull.ReadableStates.immediate(null). When the key is already present, it continues to return the existing value viaIterators.getOnlyElement(existingValues).3.
WindmillMap.javaBug 1: Key Identity vs. Coder Structural Equality (e.g.
byte[]Keys)cachedValues,negativeCache,localAdditions,localRemovals) used raw user keys (Map<K, V>,Set<K>).byte[], array types, or objects relying on structural serialization) do not implementequals()andhashCode()based on their content. For example, two distinctbyte[]arrays with identical contents were treated as distinct keys, resulting in missed cache hits, duplicate entries, and incorrect state lookups.StructuralKey<K>which wrapsoriginalKeyand delegatesequals()andhashCode()tokeyCoder.structuralValue(originalKey). All internal tracking maps and sets now operate onStructuralKey<K>.Bug 2:
put()Premature Early Return onnullValues and Overwritten Removalsput(key, value)had two flaws:NullableCoder, callingput(key, null)on an empty map causedoldValueto benull. SinceObjects.equals(null, null) == true,put()early-returned without recording the key insertion intocachedValuesorlocalAdditions.localRemovalsor marked innegativeCache, re-putting an equal value was skipped, leaving the key marked for deletion.put()now explicitly requireswasPresent = cachedValues.containsKey(structKey)and ensures!localRemovals.contains(structKey) && !negativeCache.contains(structKey)before performing an early return. It also removes the key fromlocalRemovalsandnegativeCacheupon mutation.Bug 3:
computeIfAbsent()Unnecessary State Dirtying on Storage ReadscachedValuesbut present in Windmill persistent storage (persistedValue != null),computeIfAbsent()previously invokedthis.put(key, persistedValue).put()added the key tolocalAdditions. During commit (persistDirectly()), unmodified data fetched from storage was unnecessarily re-serialized and written back to Windmill.computeAndPut(). When an existing key is read from persistent storage, it is placed directly intocachedValues.put(structKey, persistedValue)without dirtyinglocalAdditions.Bug 4:
getOrDefault()IgnorednegativeCache,localRemovals, andcompletegetOrDefault(key, defaultValue).read()dispatched an asynchronous fetch (getFutureForKey(key)) even when the key was known to be absent (negativeCache,localRemovals, orcomplete == true).localRemovalsornegativeCache, it returnednullinstead ofdefaultValue.getOrDefault().read()that returndefaultValueimmediately when present inlocalRemovals,negativeCache, or whencompletewithout the key incachedValues. UpdatedreadLater()to avoid prefetching known absent/cached keys.Bug 5: Key Resurrection / Filter Leak in
entries().read()withWeightedentries().read()under theWeightedbranch, after processing persistent entries, remaining cached keys were iterated fromcachedValues.keySet(). If a key was incachedValuesbut marked inlocalRemovals, it was mistakenly emitted.!localRemovals.contains(sk)checks to prevent deleted keys from resurfacing. In the non-weighted branch, reuseduserEntries()to avoid duplicate conversion logic.Bug 6: Dead / Broken Key Serialization in
persistDirectly()persistDirectly(), the loop overlocalRemovalsattempted to serialize keys tokeyStream, but the linekeyCoder.encode(...)was commented out. It generated empty byte strings (ByteString.EMPTY) and never appended them to the commit bundle.commitBuilder.addTagValueDeleteRequests()calls.Bug 7:
isEmpty()OptimizationisEmpty()was performing unnecessary checks whencachedValuesalready contained active, non-removed entries.!cachedValues.isEmpty(), the map is guaranteed to be non-empty (as any removal removes the key fromcachedValues).4. Test Suites Added
Three dedicated test suites were created with comprehensive coverage for all identified edge cases and contract requirements:
WindmillSetTest.java:testAddIfAbsentReturnsTrueWhenAbsent: VerifiesaddIfAbsent()returnstrueon initial addition.testAddIfAbsentReturnsFalseWhenPresent: VerifiesaddIfAbsent()returnsfalsewhen the element already exists.testAddAndRead: Verifies standardadd()andread()behavior.testRemove: Verifies element removal.testContains: Verifiescontains()state checks.testClear: Verifies set clearing.testPersistDirectly: Verifies mutation serialization toWindmill.WorkItemCommitRequest.testAddIfAbsentWithByteArray: Tests structural equality for byte array elements.testReadEmpty: Verifies reading an empty set.WindmillMapTest.java:testByteArrayKeyStructuralEquality: Verifies distinctbyte[]instances with identical contents correctly hit the cache, removals, and negative cache.testPutNullValueWithNullableCoderDoesNotEarlyReturn: Verifiesput(key, null)on an empty map is not dropped as a no-op.testPutSameValueAfterRemovalReAdds: Verifies putting an identical value afterremove()unmarks removal and adds the key.testComputeIfAbsentReturnsNullOnAbsent: VerifiescomputeIfAbsent()returnsnullwhen a key is newly computed and inserted.testComputeIfAbsentReturnsExistingValueWhenPresent: VerifiescomputeIfAbsent()returns the existing value on key collision.testComputeIfAbsentDoesNotDirtyLocalAdditionsWhenPresentInStorage: Verifies fetching existing keys from storage does not mark them as local additions during commit.testGetOrDefaultReturnsDefaultWhenRemoved: VerifiesgetOrDefault()returns default rather thannullfor removed keys.testGetOrDefaultReturnsDefaultWhenInNegativeCache: VerifiesgetOrDefault()returns default for negative-cached keys.testGetOrDefaultReturnsDefaultWhenComplete: VerifiesgetOrDefault()returns default when map is fully read.testEntriesWeightedDoesNotResurrectRemovedKeys: Verifies weightedentries().read()does not emit keys pending inlocalRemovals.testBasicPutAndGet: Basic map read/write.testRemoveAndContainsKey: Verifies removal andcontainsKey()checks.testIsEmpty: Verifies empty and non-empty assertions.testClear: Verifies clearing all map entries.testValuesAndKeysIterables: Verifiesvalues().read()andkeys().read().testPersistDirectly: Verifies commit building for inserts, updates, and deletes.WindmillMapViaMultimapTest.java:testComputeIfAbsentReturnsNullOnAbsent: Verifies contract compliance (nullreturned when inserted).testComputeIfAbsentReturnsExistingValueWhenPresent: Verifies existing value returned on collision.testSingleValueIterableAdaptorGetOnlyElement: ConfirmsIterators.getOnlyElementsucceeds on single-element values.testSingleValueIterableAdaptorThrowsOnMultiValuedKey: ConfirmsIllegalArgumentExceptionis raised when multiple values exist for a single key.testBasicPutAndGet: Verifies basic put/get through the multimap backing.testRemoveAndContainsKey: Verifies key removal.testClearAndIsEmpty: Verifies clear and empty checks.