[SPARK-59168][CORE] Avoid NoSuchElementException on missing keys in KVStore reads and writes - #58466
[SPARK-59168][CORE] Avoid NoSuchElementException on missing keys in KVStore reads and writes#58466ulysses-you wants to merge 4 commits into
Conversation
|
cc @LuciferYang @dongjoon-hyun thank you |
LuciferYang
left a comment
There was a problem hiding this comment.
Reviewed the kvstore changes. Overall this looks clean: the enumeration of the package-private get(byte[], Class) call sites is complete, the public contract (read() throwing NoSuchElementException, getMetadata() returning null) is preserved and matches InMemoryStore, and the perf motivation holds since both write() and writeAll() go through updateBatch on the replay path. Only two minor non-blocking nits below.
|
Thank you for the PR. I went through the diff and the surrounding code paths in Design / simplification
Efficiency (pre-existing, on the same write path; fine as follow-ups)
PR description
|
|
Thanks @dongjoon-hyun for the detailed review.
4./5./6./7. Agreed, left for a follow-up to keep this PR focused. |
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thank you for the update. I re-reviewed b5ac972 end to end, including the serializer.deserialize paths under KVStoreScalaSerializer / KVStoreProtobufSerializer, and I still don't see any correctness issue: with Jackson's default WRAP_EXCEPTIONS a NoSuchElementException from a nested deserializer is wrapped into JsonMappingException, so the old catch (NoSuchElementException) blocks never covered anything beyond get()'s own throw, which getOrNull now expresses directly.
A few remaining non-blocking comments, plus two that can't be attached inline:
-
KVStore.deleteJavadoc (KVStore.java:111) still says@throws java.util.NoSuchElementException If an element with the given key does not exist.None of the three implementations throw, every caller incorerelies on the no-op, and this PR now pins the no-op withtestDeleteEdgeCaseswhile removing the only catch that ever hinted at the documented behavior. It would be good to fix that@throwsline in the same PR (e.g. "Deleting a missing key is a no-op."). -
PR description:
.github/PULL_REQUEST_TEMPLATEasks forGenerated-by:followed by the tool name and its version; the description currently has onlyGenerated-by: Claude Code.
| @@ -240,8 +238,6 @@ public void delete(Class<?> type, Object naturalKey) throws Exception { | |||
| db().write(batch); | |||
There was a problem hiding this comment.
delete() (a few lines above, at byte[] data = db().get(key); if (data != null) { Object existing = serializer.deserialize(data, type); ... }) is exactly the body of the new getOrNull. Since this PR already touches delete(), how about
Object existing = getOrNull(key, type);
if (existing != null) {so the read-or-null sequence has a single home per store? It is behavior-identical (same synchronized (ti) block; the Class<?> capture already compiles the same way in updateBatch).
There was a problem hiding this comment.
Done in 1330280: Object existing = getOrNull(key, type).
| @@ -272,8 +270,6 @@ public void delete(Class<?> type, Object naturalKey) throws Exception { | |||
| db().write(writeOptions, writeBatch); | |||
There was a problem hiding this comment.
Same as LevelDB.delete: db().get(key) + serializer.deserialize(data, type) here can be Object existing = getOrNull(key, type); if (existing != null) { ... }.
| * callers where a missing key is expected do not pay the cost of throwing and filling in an | ||
| * exception stack trace. | ||
| */ | ||
| @VisibleForTesting |
There was a problem hiding this comment.
nit: @VisibleForTesting reads as "widened only for tests", but getOrNull is a production helper with four callers in this class (constructor, getMetadata, get, updateBatch) and its visibility is the same package-private as the unannotated get() right above. I'd drop the annotation to match get().
| * callers where a missing key is expected do not pay the cost of throwing and filling in an | ||
| * exception stack trace. | ||
| */ | ||
| @VisibleForTesting |
There was a problem hiding this comment.
nit: same as LevelDB.getOrNull; @VisibleForTesting can be dropped.
| } | ||
|
|
||
| @Test | ||
| public void testGetOrNullMissingKey() throws Exception { |
There was a problem hiding this comment.
Only the assertNull(db.getOrNull(missingKey, ...)) line is new coverage here. The get()-throws half and the present-key round trip are already asserted by testObjectWriteReadDelete through db.read / db.write, and read() builds exactly this key (getTypeInfo(klass).naturalIndex().start(null, naturalKey)). Hand-building the key twice per suite couples the test to the key layout. I'd keep the assertNull and use the public API for the rest:
assertThrows(NoSuchElementException.class, () -> db.read(CustomType1.class, "missing"));There was a problem hiding this comment.
Done in 1330280: only the assertNull keeps the hand-built key; the throws and present-key assertions go through db.read.
| } | ||
|
|
||
| @Test | ||
| public void testGetOrNullMissingKey() throws Exception { |
There was a problem hiding this comment.
Same as LevelDBSuite.testGetOrNullMissingKey: only the assertNull is new; the other assertions can go through db.read.
| } | ||
|
|
||
| @Test | ||
| public void testDeleteEdgeCases() throws Exception { |
There was a problem hiding this comment.
Just noting for the record: all three cases short-circuit at the pre-existing if (data != null) guard in delete(), so this test passes identically at the merge base and at this commit. It documents the no-op contract (which is useful given the KVStore Javadoc mismatch), but it isn't a regression guard for the removed catch. Fine to keep as-is.
There was a problem hiding this comment.
Kept as-is; it now documents the same no-op contract the corrected KVStore.delete javadoc states.
| db.delete(CustomType1.class, "missing"); | ||
| assertEquals(1L, db.count(CustomType1.class)); | ||
|
|
||
| // Mismatched key type: the encoded lookup key misses, nothing is removed. |
There was a problem hiding this comment.
nit: Integer is a legal key type in Index.toKey (sign marker + hex), so this is not really a "mismatched key type"; it's another never-written key in a different encoding. Maybe reword the comment, or drop the case since the one above already covers a missing key.
| db.delete(CustomType1.class, "missing"); | ||
| assertEquals(1L, db.count(CustomType1.class)); | ||
|
|
||
| // Mismatched key type: the encoded lookup key misses, nothing is removed. |
There was a problem hiding this comment.
nit: same as LevelDBSuite; 42 is a valid Integer key, so "mismatched key type" is misleading.
|
Both done in 1330280:
|
…and writes RocksDB.get and LevelDB.get now return null instead of throwing NoSuchElementException for a missing key, letting callers decide whether a missing key is an error. updateBatch previously threw and filled in an exception stack trace on every write of a new entry, and most writes during an event log rebuild are new entries, so this removes that overhead. read keeps throwing NoSuchElementException to preserve the KVStore contract; getMetadata, the constructor and the secondary-index iterator path handle the null directly. Assisted-by: Claude Opus 4.8
The secondary-index iterator paths resolve entries through a live get() and now include the missing key in the NoSuchElementException message, matching read(). Adds testNextAfterEntityDelete to both DB suites. Assisted-by: Claude Opus 4.8
Keep get() throwing NoSuchElementException and add a null-returning getOrNull() for callers where a missing key is expected: the store constructor, getMetadata and updateBatch. Also drop the catch blocks in delete() that nothing inside their try bodies can throw. Assisted-by: Claude Opus 4.8
delete() reads the existing entry through getOrNull, getOrNull drops the @VisibleForTesting annotation, and KVStore.delete javadoc states that deleting a missing key is a no-op. The new tests assert through the public API. Assisted-by: Claude Opus 4.8
1330280 to
db615c0
Compare
|
+1, thank you @ulysses-you and @LuciferYang @dongjoon-hyun! |
What changes were proposed in this pull request?
Keep
RocksDB.getandLevelDB.getthrowingNoSuchElementExceptionfor a missing key, and add a null-returninggetOrNullsibling for the callers where a missing key is expected:getMetadata,updateBatch, anddeleteusegetOrNullinstead of catchingNoSuchElementException.delete()drops catch blocks that nothing inside their try bodies can throw; deleting a missing entry remains a silent no-op, and theKVStore.deletejavadoc now says so instead of claiming aNoSuchElementException.Why are the changes needed?
updateBatchlooks up the existing value viagetbefore writing. During an event-log rebuild most writes are new entries, so the key is absent andgetthrowsNoSuchElementExceptionon essentially every write. That exception is immediately caught, but constructing it still fills in a full stack trace, using exceptions for ordinary control flow. In a History Server CPU profile this showed up as a measurable share of CPU spent inThrowable.fillInStackTraceunderRocksDB.get.getOrNullavoids building the exception entirely.Does this PR introduce any user-facing change?
No. The public
KVStorebehavior is unchanged:readstill throwsNoSuchElementExceptionfor a missing key, andgetMetadatastill returnsnullwhen no metadata is present.How was this patch tested?
Added to both
RocksDBSuiteandLevelDBSuite:testGetOrNullMissingKey:getOrNullreturnsnullfor a missing key, whilereadstill throwsNoSuchElementExceptionfor it.testDeleteEdgeCases: deleting a never-written type or a missing key is a silent no-op.Existing kvstore test suites pass.
Was this patch authored or co-authored using generative AI tooling?
Yes. Generated-by: Claude Code 2.1.259