sangkyoonnam opened a new pull request, #1188:
URL: https://github.com/apache/flink-agents/pull/1188
Linked issue: #1187
### Purpose of change
#### User-visible outcome
A `set` or `newObject` whose path runs below a field that holds a value now
throws `IllegalArgumentException` and leaves nothing behind. The message names
the absolute requested path and offending ancestor; with `o` an object and
`o.a` a value, `get("o").set("a.b", v)` reports `Cannot write field 'o.a.b':
'o.a' exists but is not an object.`. Previously these calls threw
`UnsupportedOperationException`; deeper paths could leave orphans, while
`newObject("a.b")` also recorded an update that made replay fail after restore.
A path with an empty component (`"a."`, `".a"`, `"a..b"`, `""`) is rejected
the same way, with `... path has an empty component.`. Before, such paths threw
`NullPointerException` or `UnsupportedOperationException`, stored a key the
parent could not list, or were accepted: `set("a..b", v)` with `a` absent
stored a readable value, and root `newObject("")` recorded an update and
registered the root as its own field. Those two are now rejected on purpose.
Root `set("")` already threw, after registering that field.
#### Intent
Validate the whole write path before any mutation, so a rejected write can't
corrupt keyed state or the updates recovery replays.
#### Runtime flow
`set` and `newObject` call `checkWritePath` before `fillParents`. It splits
with `split("\\.", -1)` to keep trailing empty components, rejects any empty
component, then walks every proper prefix and throws if the item stored there
exists and is not an OBJECT.
#### Key decisions
- `IllegalArgumentException`, as for the existing overwrite conflicts.
- `overwrite=true` on `newObject` still applies to the target field only;
replacing a value ancestor would drop its value silently.
- Empty components are rejected rather than given a meaning.
- The check costs one store `get` per proper prefix, none for a top-level
write.
- No change to `MemoryUpdateReplayer`. An `ActionState` written before this
change can still carry a rejected update; replaying it now fails with this
message. Skipping it on replay is a separate decision I'll leave out unless
you'd rather handle it here.
### Behavioral Semantics
#### Interaction decisions
| Memory state | Call | Before | After |
|---|---|---|---|
| `a` is a value | `set("a.b", v)`, `get("a").set("b", v)` |
`UnsupportedOperationException`, no mutation | `IllegalArgumentException`, no
mutation |
| `a` is a value | `set("a.b.c", v)`, `newObject("a.b.c")` | same, orphan
`a.b` persisted | `IllegalArgumentException`, no mutation |
| `a` is a value | `newObject("a.b")`, either `overwrite` | same, orphan
`a.b` and update recorded | `IllegalArgumentException`, no mutation, nothing
recorded |
| any | empty path component | NPE, UOE, unlistable key, or accepted (two
cases above) | `IllegalArgumentException`, no mutation |
#### Behavioral contracts
1. A write below a value field, or with an empty path component, throws
`IllegalArgumentException` naming the absolute requested path.
2. The rejected write leaves keyed state untouched: no intermediate node,
and the value and its parent's field list as they were.
3. The rejected write records no `MemoryUpdate`, so replaying a completed
action that caught it reproduces only the writes that succeeded.
4. Writes whose proper prefixes are all objects or absent, with no empty
components, behave as before.
#### Failure behavior
The exception is thrown before `fillParents`, so nothing needs rolling back.
### Tests
#### Contracts to tests
| Contract | Tests |
|---|---|
| 1, 2 | the four `MemoryObjectTest.test*IsRejectedWithoutSideEffects` tests
|
| 2 across restore |
`MemoryWriteBelowValueRecoveryTest.rejectedWriteBelowValueLeavesNoOrphanAcrossRestore`
(both backends; an action reads value, fields and `isExist` through
`MemoryObject` before and after restore) |
| 3 |
`MemoryUpdateReplayerTest.testReplayOfActionThatCaughtRejectedNewObjectUnderValue`;
`MemoryWriteBelowValueRecoveryTest.replayOfActionThatCaughtRejectedNewObjectCompletesRecovery`
(both backends, redelivery after restore) |
| 4 | existing tests in those classes and `ActionExecutionOperatorTest` |
#### Coverage and what was not verified
With `MemoryObjectImpl` reverted to `main`, all 9 new runs fail (7 methods,
2 parameterized; modes in the details below). With the change: `mvn -pl api
test` 523 run, 0 failures, 12 skipped; `mvn -pl runtime test` 1151 run, 0
failures, 1 skipped.
Not verified: a job with a restart strategy (I ran one redelivery after
restore; replay is deterministic, so each restart should fail the same way);
the serial replay path at `ActionExecutionOperator.java:772`; Kafka and Fluss
action state stores; JDK 11 and 17 test runs; Python past a Pemja probe.
Separate defect, left alone here: `set` still accepts and records a
`MemoryObject` value (`MemoryObjectImpl.java:114`) although the javadoc lists
that as an error.
<details>
<summary>Supporting evidence</summary>
- On `main`, the new tests fail with a wrong exception type, an extra
recorded update, and `UnsupportedOperationException` at
`MemoryObjectImpl.newObject:148` under `MemoryUpdateReplayer.replay:51` in the
recovery test on both backends.
- The orphan `a.b` was observed in keyed state on both backends and survived
`snapshot` + `initializeState`. The recorded update was observed in the
`InMemoryActionStateStore` retained across the restore, where replay reads it
from.
- `tools/lint.sh -c` passes on JDK 11.
- `FlinkMemoryObject` delegates to the Java object and needs no change; a
local Pemja probe showed the Java exception wrapped in `MemoryObjectError`. The
`LocalMemoryObject` test double differs on this sequence and is left alone here.
</details>
### API
#### Compatibility impact
No signature changes. These writes are now intentionally rejected before
mutation with `IllegalArgumentException`, including formerly accepted malformed
paths. The `MemoryObject` javadoc names both cases on `set` and both
`newObject` overloads. Nothing in the repository catches the old types or
writes such paths.
### Documentation
<!-- Do not remove this section. Check the proper box only. -->
- [ ] `doc-needed` <!-- Your PR changes impact docs -->
- [x] `doc-not-needed` <!-- Your PR changes do not impact docs -->
- [ ] `doc-included` <!-- Your PR already contains the necessary
documentation updates -->
### Was this patch authored or co-authored using generative AI tooling?
<!-- Do not remove this section. Check the proper box only. -->
- [x] Yes
- [ ] No
Generated-by: Claude Code 2.1.284 (Claude Fable 5.1)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]