fix(amber): register EvaluatedValue in ControlReturn's sealed oneof - #7927
fix(amber): register EvaluatedValue in ControlReturn's sealed oneof#7927eugenegujing wants to merge 3 commits into
Conversation
Add EvaluatedValue as oneof member 53 in controlreturns.proto, and add two Python test files: a regression test for the EvaluatedValue set_one_of/wire round-trip and an invariant test that every declared RPC reply type is a registered oneof member.
Backport auto-label reportThis
|
Automated Reviewer SuggestionsBased on the
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #7927 +/- ##
============================================
+ Coverage 91.02% 91.74% +0.72%
- Complexity 4454 4630 +176
============================================
Files 1174 1173 -1
Lines 47146 47514 +368
Branches 5287 5319 +32
============================================
+ Hits 42915 43594 +679
+ Misses 2551 2291 -260
+ Partials 1680 1629 -51
*This pull request uses carry forward flags. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
| config | throughput | MB/s | latency | max Δ latest / 7d | |
|---|---|---|---|---|---|
| 🔴 | bs=10 sw=10 sl=64 | 712 | 0.435 | 13,818/16,373/16,373 us | 🔴 -9.4% / 🔴 +20.3% |
| ⚪ | bs=100 sw=10 sl=64 | 1,568 | 0.957 | 60,015/90,235/90,235 us | ⚪ within ±5% / 🟢 +39.1% |
| 🔴 | bs=1000 sw=10 sl=64 | 1,945 | 1.187 | 508,130/606,944/606,944 us | 🔴 +8.2% / 🟢 +68.3% |
Baseline details
Latest main 6b8027e from same runner
| config | metric | PR | latest main | 7d avg | Δ latest | Δ 7d |
|---|---|---|---|---|---|---|
| bs=10 sw=10 sl=64 | throughput | 712 tuples/sec | 786 tuples/sec | 863.81 tuples/sec | -9.4% | -17.6% |
| bs=10 sw=10 sl=64 | MB/s | 0.435 MB/s | 0.48 MB/s | 0.527 MB/s | -9.4% | -17.5% |
| bs=10 sw=10 sl=64 | p50 | 13,818 us | 12,841 us | 11,488 us | +7.6% | +20.3% |
| bs=10 sw=10 sl=64 | p95 | 16,373 us | 17,661 us | 14,067 us | -7.3% | +16.4% |
| bs=10 sw=10 sl=64 | p99 | 16,373 us | 17,661 us | 17,591 us | -7.3% | -6.9% |
| bs=100 sw=10 sl=64 | throughput | 1,568 tuples/sec | 1,600 tuples/sec | 1,127 tuples/sec | -2.0% | +39.1% |
| bs=100 sw=10 sl=64 | MB/s | 0.957 MB/s | 0.977 MB/s | 0.688 MB/s | -2.0% | +39.1% |
| bs=100 sw=10 sl=64 | p50 | 60,015 us | 59,840 us | 89,893 us | +0.3% | -33.2% |
| bs=100 sw=10 sl=64 | p95 | 90,235 us | 89,913 us | 96,506 us | +0.4% | -6.5% |
| bs=100 sw=10 sl=64 | p99 | 90,235 us | 89,913 us | 104,915 us | +0.4% | -14.0% |
| bs=1000 sw=10 sl=64 | throughput | 1,945 tuples/sec | 1,955 tuples/sec | 1,156 tuples/sec | -0.5% | +68.3% |
| bs=1000 sw=10 sl=64 | MB/s | 1.187 MB/s | 1.193 MB/s | 0.705 MB/s | -0.5% | +68.3% |
| bs=1000 sw=10 sl=64 | p50 | 508,130 us | 508,582 us | 886,448 us | -0.1% | -42.7% |
| bs=1000 sw=10 sl=64 | p95 | 606,944 us | 560,750 us | 930,093 us | +8.2% | -34.7% |
| bs=1000 sw=10 sl=64 | p99 | 606,944 us | 560,750 us | 963,104 us | +8.2% | -37.0% |
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,280.88,200,128000,712,0.435,13817.81,16373.10,16373.10
1,100,10,64,20,1275.46,2000,1280000,1568,0.957,60014.61,90234.61,90234.61
2,1000,10,64,20,10281.27,20000,12800000,1945,1.187,508129.72,606943.58,606943.58…JSON Add a GeneratedSealedOneofMixin with @JsonIgnore on isEmpty/isDefined and register it on JSONUtils.objectMapper for scalapb.GeneratedSealedOneof, so sealed-oneof members no longer serialize the helper methods as JSON fields.
What changes were proposed in this PR?
Register
EvaluatedValueas a member ofControlReturn's sealed oneof (EvaluatedValue evaluatedValue = 53;in the worker-responses range ofcontrolreturns.proto), add two Python test files that pin the fix and the underlying invariant, and add a Jackson mix-in inJSONUtils.scalathat keeps the scalapb sealed-oneof helper methods out of the websocket JSON (see the follow-up section below).Why this is a bug. The two proto files disagree about
EvaluatePythonExpression's reply type:workerservice.protodeclares the worker's reply asEvaluatedValue, but every worker reply must travel insideControlReturn's sealed oneof, and that oneof only registers the coordinator-side wrapperEvaluatePythonExpressionResponse(the reply type of the coordinator's RPC, which isrepeated EvaluatedValue);EvaluatedValueitself is defined in the same file but never joined the oneof, so the declared worker reply has no wire slot.The failure is silent. On the Python engine,
set_one_ofassigns by the snake_case field name derived from the type name; assigning a name that is not a oneof field raises nothing on a betterproto dataclass, and serialization ignores it, so the worker's reply is packed into an emptyControlReturn:bytes(set_one_of(ControlReturn, EvaluatedValue(...)))isb''andget_one_ofreturnsNone, with no exception and no log anywhere. The Python worker's handler does produce the correctEvaluatedValue; it is lost at the packing step, so the coordinator'sFuture.collectover worker replies can never receive a real value.Why fix it in the proto. The bug lives in the contract, not in either engine's code: both engines' packing/receiving logic is correct under the assumption that the declared reply type is registered. Registering the type restores that assumption, both engines regenerate their bindings from the shared proto (generated bindings are not checked in), and no handler code changes on either side. The alternative (changing
workerservice.prototo reply with the wrapper type) would touch handlers in both engines for no additional benefit.Follow-up: keep the sealed-oneof helper methods out of the websocket JSON. Joining the sealed oneof makes the generated Scala
EvaluatedValueextend theControlReturntrait, which carries scalapb's helper methodsisEmpty/isDefined. Jackson's getter scan then serialized them as"empty"/"defined"fields in the websocket JSON (EvaluatedValueis embedded in thePythonExpressionEvaluateResponsewebsocket event), and deserialization rejects them because the constructor only knowsvalue/attributes. This is exactly the round-trip failureTexeraWebSocketEventSpeccaught on the first CI run of this PR. The fix adds aGeneratedSealedOneofMixinwith@JsonIgnoreon both methods and registers it onJSONUtils.objectMapperagainst thescalapb.GeneratedSealedOneofinterface, so every current and future sealed-oneof member is covered and the JSON wire format is byte-identical tomain. Alternatives were rejected: disablingFAIL_ON_UNKNOWN_PROPERTIESglobally would hide real deserialization bugs and leave the junk fields on the wire; loosening the spec's expectations would legitimize the junk fields; a per-class mix-in would leave the next sealed-oneof member to hit the same failure.Not in scope. The Scala worker's
evaluatePythonExpressionremains a???stub (DataProcessorRPCHandlerInitializer.scala), and the coordinator fans the request out to all workers of the operator, so evaluating against an operator with Scala workers still fails on the stub; that is pre-existing behavior independent of this fix.Any related issues, documentation, discussions?
Fixes #7924
How was this PR tested?
Two new test files were added under
amber/src/test/python:core/util/proto/test_set_one_of.py— regression tests pinning this bug:EvaluatedValuesurvivesset_one_of/get_one_of, and a full wire round-trip produces non-empty bytes that parse back to the original value. Rollback-verified: with the proto fix removed and bindings regenerated from the original proto, both tests fail exactly on the empty-bytes/Nonesymptoms; with the fix restored they pass.core/architecture/rpc/test_reply_types_registered.py— invariant tests covering the whole bug class: every reply type declared byWorkerServiceStub(21 RPCs) andCoordinatorServiceStub(18 RPCs) must be a registeredControlReturnoneof member (the assertion message names any missing type and points tocontrolreturns.proto), and every registered member must survive a realset_one_of/get_one_ofround-trip, which also pins the field-naming conventionset_one_ofdepends on; non-empty guards prevent the reflection from silently passing if the generated-code layout ever changes. Rollback-verified: on the unfixed proto the invariant test fails precisely onWorkerServiceStub.evaluate_python_expression -> EvaluatedValue, so this class of bug is caught by CI at the PR that introduces it.For the Jackson mix-in:
TexeraWebSocketEventSpecgoes from 9/10 (failing on the round-trip test withUnrecognized field "empty", matching this PR's first CI run) to 10/10, rollback-verified in both directions (removing only the mix-in reproduces exactly the original failure). Serializing anEvaluatedValuethroughJSONUtils.objectMapperwas additionally asserted to produce onlyvalue/attributesand to round-trip. No other JSON path is affected: the web layer's only type carrying a sealed-oneof member isPythonExpressionEvaluateResponse, the other sealed oneofs never reach Jackson (verified per oneof), and no code references"empty"/"defined"in JSON. Wider sweep after the mix-in: the wholeWorkflowCoretest module passes (656 tests) and all websocket serde specs pass (25 tests);scalafmtCheckandscalafix --checkpass.Full verification: the Python suite (
pytest -m "not integration") passes with the two new files included;ruff checkandruff format --checkpass; ScalaPB code generation and a fullsbt compileon JDK 17 succeed, and the generated ScalaControlReturngains theSealedValue.EvaluatedValuecase so the coordinator-sideFuture[EvaluatedValue]typing holds.Manual repro before/after:
bytes(set_one_of(ControlReturn, EvaluatedValue(value=TypedValue(expression="1+1", value_str="2"))))returnsb''before the fix andb'\xaa\x03\n\n\x08\n\x031+1\x1a\x012'(field 53) after, withget_one_ofreturning the full value.Was this PR authored or co-authored using generative AI tooling?
Co-authored by: Claude Code (Claude Fable 5)