This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 982d71fb4 [api][runtime][java][python] Reject framework-owned lineage
at event emission (#1212)
982d71fb4 is described below
commit 982d71fb47e183f170111ac9eeac5d10eb0d5f20
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Fri Oct 9 15:59:19 2026 +0900
[api][runtime][java][python] Reject framework-owned lineage at event
emission (#1212)
Generated-by: Claude Code 2.1.295 (Claude Opus 5.5)
---
.../java/org/apache/flink/agents/api/Event.java | 11 +-
.../flink/agents/api/context/RunnerContext.java | 4 +-
docs/content/docs/development/workflow_agent.md | 9 +-
python/flink_agents/api/events/event.py | 20 +++-
python/flink_agents/api/runner_context.py | 7 +-
python/flink_agents/api/tests/test_event.py | 4 +-
.../flink_agents/runtime/flink_runner_context.py | 17 +++
.../runtime/tests/test_flink_runner_context.py | 60 +++++++++-
.../agents/runtime/context/RunnerContextImpl.java | 29 +++++
.../runtime/context/RunnerContextLineageTest.java | 127 +++++++++++++++++++++
.../agents/runtime/operator/ActionTaskTest.java | 19 +++
11 files changed, 294 insertions(+), 13 deletions(-)
diff --git a/api/src/main/java/org/apache/flink/agents/api/Event.java
b/api/src/main/java/org/apache/flink/agents/api/Event.java
index 7a7024607..593bdae31 100644
--- a/api/src/main/java/org/apache/flink/agents/api/Event.java
+++ b/api/src/main/java/org/apache/flink/agents/api/Event.java
@@ -79,8 +79,9 @@ public class Event {
/**
* Reconstructs an Event with an existing identity and optional
framework-managed lineage.
*
- * <p>The lineage values support deserialization and reconstruction. When
an Action emits this
- * Event, the runtime overwrites them with the current trigger Event ID
and Action name.
+ * <p>The lineage values support deserialization and reconstruction. {@code
+ * RunnerContext#sendEvent} rejects an Event that carries them; the
runtime sets lineage to the
+ * current trigger Event ID and Action name when it finalizes the Action's
outputs.
*
* @param id the existing Event ID
* @param type the Event type used for routing
@@ -160,7 +161,8 @@ public class Event {
/**
* Sets the framework-managed ID of the Event consumed by the emitting
Action.
*
- * <p>The runtime overwrites this value when an Action emits the Event.
+ * <p>The runtime sets this value when it finalizes an Action's outputs.
{@code
+ * RunnerContext#sendEvent} rejects an Event that already carries it.
*/
public void setUpstreamEventId(@Nullable UUID upstreamEventId) {
this.upstreamEventId = upstreamEventId;
@@ -176,7 +178,8 @@ public class Event {
/**
* Sets the framework-managed name of the Action that emitted this Event.
*
- * <p>The runtime overwrites this value when an Action emits the Event.
+ * <p>The runtime sets this value when it finalizes an Action's outputs.
{@code
+ * RunnerContext#sendEvent} rejects an Event that already carries it.
*/
public void setUpstreamActionName(@Nullable String upstreamActionName) {
this.upstreamActionName = upstreamActionName;
diff --git
a/api/src/main/java/org/apache/flink/agents/api/context/RunnerContext.java
b/api/src/main/java/org/apache/flink/agents/api/context/RunnerContext.java
index 08bf79ce5..c1ef17e20 100644
--- a/api/src/main/java/org/apache/flink/agents/api/context/RunnerContext.java
+++ b/api/src/main/java/org/apache/flink/agents/api/context/RunnerContext.java
@@ -36,7 +36,9 @@ public interface RunnerContext {
/**
* Sends an event.
*
- * @param event the event to be sent
+ * @param event the event to be sent; a new Event without upstream lineage
+ * @throws IllegalArgumentException if the event already carries {@code
upstreamEventId} or
+ * {@code upstreamActionName}, which the runtime sets when the Action
emits it
*/
void sendEvent(Event event);
diff --git a/docs/content/docs/development/workflow_agent.md
b/docs/content/docs/development/workflow_agent.md
index 48f1a6243..9fbeebcbc 100644
--- a/docs/content/docs/development/workflow_agent.md
+++ b/docs/content/docs/development/workflow_agent.md
@@ -773,9 +773,12 @@ public static void handleMyEvent(Event event,
RunnerContext ctx) {
{{< hint info >}}
`upstreamEventId` and `upstreamActionName` (`upstream_event_id` and
-`upstream_action_name` in Python) are framework-managed lineage metadata. User
code should keep
-user data in `attributes`. Values accepted during deserialization or
reconstruction are
-overwritten when an Action emits the Event.
+`upstream_action_name` in Python) are framework-managed lineage metadata, set
when an Action
+emits the Event. User code should keep user data in `attributes`. `sendEvent`
/ `send_event`
+rejects an Event that already carries lineage, such as an Event received from
another Action or
+a typed reconstruction of one: emit a new Event instead. Deserialization and
typed
+reconstruction keep the lineage an Event already has; after recovery, restored
outputs are bound
+to the replayed trigger again.
{{< /hint >}}
### JSON Serialization
diff --git a/python/flink_agents/api/events/event.py
b/python/flink_agents/api/events/event.py
index 2150f3235..725c6d736 100644
--- a/python/flink_agents/api/events/event.py
+++ b/python/flink_agents/api/events/event.py
@@ -31,11 +31,12 @@ from pydantic import (
BaseModel,
Field,
SerializerFunctionWrapHandler,
+ ValidationError,
field_validator,
model_serializer,
model_validator,
)
-from pydantic_core import PydanticSerializationError
+from pydantic_core import PydanticCustomError, PydanticSerializationError
from pyflink.common import Row
from flink_agents.api.memory_reference import MemoryRef
@@ -229,6 +230,23 @@ class Event(BaseModel, extra="allow"):
return self
def __setattr__(self, name: str, value: Any) -> None:
+ # Pydantic's own frozen-field error would not say how to keep an
identity.
+ if name == "id":
+ raise ValidationError.from_exception_data(
+ type(self).__name__,
+ [
+ {
+ "type": PydanticCustomError(
+ "frozen_event_id",
+ "Field is frozen. An Event's id is fixed when it
is "
+ "created; to give a typed Event the identity of an
"
+ "existing Event, use typed =
typed.reconstruct_from(existing).",
+ ),
+ "loc": ("id",),
+ "input": value,
+ }
+ ],
+ )
super().__setattr__(name, value)
# Raw attachments are offloaded to sensory memory before sending.
Validate every
# other field here without serializing those payloads.
diff --git a/python/flink_agents/api/runner_context.py
b/python/flink_agents/api/runner_context.py
index d14e171a7..254035649 100644
--- a/python/flink_agents/api/runner_context.py
+++ b/python/flink_agents/api/runner_context.py
@@ -143,7 +143,12 @@ class RunnerContext(ABC):
Parameters
----------
event : Event
- The event to be sent.
+ The event to be sent; a new Event without upstream lineage.
+
+ Raises:
+ ValueError: If the event already carries ``upstream_event_id`` or
+ ``upstream_action_name``, which the runtime sets when the
Action
+ emits it.
"""
@abstractmethod
diff --git a/python/flink_agents/api/tests/test_event.py
b/python/flink_agents/api/tests/test_event.py
index ce9454614..5ea521534 100644
--- a/python/flink_agents/api/tests/test_event.py
+++ b/python/flink_agents/api/tests/test_event.py
@@ -124,9 +124,11 @@ def test_event_id_does_not_change_with_event_content() ->
None:
def test_event_id_cannot_be_reassigned() -> None:
event = Event(type="test")
- with pytest.raises(ValidationError, match="Field is frozen"):
+ with pytest.raises(ValidationError, match="Field is frozen") as raised:
event.id = uuid4()
+ assert "reconstruct_from" in str(raised.value)
+
def test_explicit_none_id_generates_uuid() -> None:
event = Event(id=None, type="x")
diff --git a/python/flink_agents/runtime/flink_runner_context.py
b/python/flink_agents/runtime/flink_runner_context.py
index 140d5b29c..16cd59b95 100644
--- a/python/flink_agents/runtime/flink_runner_context.py
+++ b/python/flink_agents/runtime/flink_runner_context.py
@@ -600,6 +600,23 @@ class FlinkRunnerContext(RunnerContext, ExecutionReporter):
event : Event
The event to be processed by the agent system.
"""
+ preset = [
+ f"{name}={value}"
+ for name, value in (
+ ("upstream_event_id", event.upstream_event_id),
+ ("upstream_action_name", event.upstream_action_name),
+ )
+ if value is not None
+ ]
+ if preset:
+ # Java's sendEvent checks this too; checking here names the Python
fields.
+ err_msg = (
+ f"Event '{event.get_type()}' ({event.id}) already carries "
+ f"{' and '.join(preset)}. The runtime sets lineage when an
Action "
+ "emits an Event: emit a new Event rather than one received
from "
+ "another Action, and keep user metadata in attributes."
+ )
+ raise ValueError(err_msg)
store_event_attachments(event, self)
event_json = event.model_dump_json()
try:
diff --git a/python/flink_agents/runtime/tests/test_flink_runner_context.py
b/python/flink_agents/runtime/tests/test_flink_runner_context.py
index f283bab02..2a4562632 100644
--- a/python/flink_agents/runtime/tests/test_flink_runner_context.py
+++ b/python/flink_agents/runtime/tests/test_flink_runner_context.py
@@ -16,8 +16,11 @@
# limitations under the License.
#################################################################################
from typing import Any
+from uuid import UUID
-from flink_agents.api.events.event import Event
+import pytest
+
+from flink_agents.api.events.event import Event, InputEvent
from flink_agents.api.memory_reference import MemoryRef
from flink_agents.runtime.flink_runner_context import FlinkRunnerContext
@@ -51,10 +54,15 @@ class _FakeJavaRunnerContext:
self.sent_event_json = event_json
-def test_send_event_offloads_attachments_before_forwarding() -> None:
+def _send_event_context() -> tuple[FlinkRunnerContext, _FakeJavaRunnerContext]:
java_context = _FakeJavaRunnerContext()
ctx = FlinkRunnerContext.__new__(FlinkRunnerContext)
ctx._j_runner_context = java_context
+ return ctx, java_context
+
+
+def test_send_event_offloads_attachments_before_forwarding() -> None:
+ ctx, java_context = _send_event_context()
payload = {"value": "original"}
event = Event(type="AttachmentStep", attachments={"payload": payload})
@@ -66,3 +74,51 @@ def test_send_event_offloads_attachments_before_forwarding()
-> None:
assert java_context.sent_event_json is not None
forwarded_event = Event.from_json(java_context.sent_event_json)
assert forwarded_event.get_attachment("payload") == attachment
+
+
[email protected](
+ ("field", "value"),
+ [
+ ("upstream_event_id", UUID("00000000-0000-0000-0000-000000000001")),
+ ("upstream_action_name", "user_action"),
+ ],
+)
+def test_send_event_rejects_preset_lineage(field: str, value: Any) -> None:
+ ctx, java_context = _send_event_context()
+ event = Event(type="result", **{field: value})
+
+ with pytest.raises(ValueError, match=f"carries {field}=") as raised:
+ ctx.send_event(event)
+
+ other = ({"upstream_event_id", "upstream_action_name"} - {field}).pop()
+ assert f"{other}=" not in str(raised.value)
+
+ assert java_context.sent_event_json is None
+
+
+def test_send_event_rejects_reconstructed_event_with_lineage() -> None:
+ ctx, java_context = _send_event_context()
+ received = Event(
+ type="generic",
+ attributes={"input": 1},
+ upstream_event_id=UUID("00000000-0000-0000-0000-000000000002"),
+ upstream_action_name="upstream_action",
+ )
+ reconstructed = InputEvent(input=1).reconstruct_from(received)
+
+ with pytest.raises(ValueError, match="new Event"):
+ ctx.send_event(reconstructed)
+
+ assert java_context.sent_event_json is None
+
+
+def test_send_event_forwards_fresh_event_without_lineage() -> None:
+ ctx, java_context = _send_event_context()
+
+ ctx.send_event(Event(type="result", attributes={"source": "user"}))
+
+ assert java_context.sent_event_json is not None
+ forwarded = Event.from_json(java_context.sent_event_json)
+ assert forwarded.upstream_event_id is None
+ assert forwarded.upstream_action_name is None
+ assert forwarded.get_attr("source") == "user"
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
index 691be4113..fdde4ed7e 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/context/RunnerContextImpl.java
@@ -218,6 +218,7 @@ public class RunnerContextImpl implements RunnerContext,
ExecutionReporter {
@Override
public void sendEvent(Event event) {
mailboxThreadChecker.run();
+ checkNoPresetLineage(event);
try {
EventAttachmentUtils.storeEventAttachments(event, this);
} catch (Exception e) {
@@ -233,6 +234,34 @@ public class RunnerContextImpl implements RunnerContext,
ExecutionReporter {
pendingEvents.add(event);
}
+ /**
+ * Rejects an emitted Event that already carries lineage. The framework
binds lineage to the
+ * emitting Action when it finalizes the Action's outputs; outputs
restored from action state
+ * reach that step without passing through here.
+ */
+ private static void checkNoPresetLineage(Event event) {
+ List<String> preset = new ArrayList<>();
+ if (event.getUpstreamEventId() != null) {
+ preset.add("upstreamEventId=" + event.getUpstreamEventId());
+ }
+ if (event.getUpstreamActionName() != null) {
+ preset.add("upstreamActionName=" + event.getUpstreamActionName());
+ }
+ if (preset.isEmpty()) {
+ return;
+ }
+ throw new IllegalArgumentException(
+ "Event '"
+ + event.getType()
+ + "' ("
+ + event.getId()
+ + ") already carries "
+ + String.join(" and ", preset)
+ + ". The runtime sets lineage when an Action emits an
Event: emit a new"
+ + " Event rather than one received from another
Action, and keep user"
+ + " metadata in attributes.");
+ }
+
public List<Event> drainEvents(Long timestamp) {
mailboxThreadChecker.run();
return drainPendingEvents(timestamp);
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextLineageTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextLineageTest.java
new file mode 100644
index 000000000..6a6f14fd5
--- /dev/null
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/context/RunnerContextLineageTest.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.runtime.context;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.InputEvent;
+import org.apache.flink.agents.plan.AgentPlan;
+import org.apache.flink.agents.runtime.metrics.FlinkAgentsMetricGroupImpl;
+import org.apache.flink.runtime.metrics.groups.UnregisteredMetricGroups;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests that emitted Events cannot carry framework-owned lineage. */
+class RunnerContextLineageTest {
+
+ @Test
+ void freshEventIsAccepted() {
+ RunnerContextImpl context = newContext();
+ Event event = new Event("result");
+
+ context.sendEvent(event);
+
+ assertThat(context.drainEvents(null)).containsExactly(event);
+ }
+
+ @Test
+ void presetUpstreamEventIdIsRejected() {
+ RunnerContextImpl context = newContext();
+ Event event = new Event("result");
+ event.setUpstreamEventId(UUID.randomUUID());
+
+ assertThatThrownBy(() -> context.sendEvent(event))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("upstreamEventId=")
+ .hasMessageNotContaining("upstreamActionName=")
+ .hasMessageContaining("attributes");
+ assertThat(context.drainEvents(null)).isEmpty();
+ }
+
+ @Test
+ void presetUpstreamActionNameIsRejected() {
+ RunnerContextImpl context = newContext();
+ Event event = new Event("result");
+ event.setUpstreamActionName("user_action");
+
+ assertThatThrownBy(() -> context.sendEvent(event))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("upstreamActionName=user_action")
+ .hasMessageNotContaining("upstreamEventId=");
+ assertThat(context.drainEvents(null)).isEmpty();
+ }
+
+ @Test
+ void typedReconstructionOfAnEventFromAnotherActionIsRejected() {
+ RunnerContextImpl context = newContext();
+ Event received = new Event("generic", new HashMap<>());
+ received.setUpstreamEventId(UUID.randomUUID());
+ received.setUpstreamActionName("upstream_action");
+ received.setAttr("input", 1L);
+
+ Event reconstructed = InputEvent.fromEvent(received);
+
+
assertThat(reconstructed.getUpstreamEventId()).isEqualTo(received.getUpstreamEventId());
+ assertThatThrownBy(() -> context.sendEvent(reconstructed))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("new Event");
+ }
+
+ @Test
+ void lineageArrivingThroughJsonIsRejected() throws Exception {
+ // The Python bridge deserializes the event with Event.fromJson before
sendEvent.
+ RunnerContextImpl context = newContext();
+ Event event =
+ Event.fromJson(
+
"{\"type\":\"result\",\"attributes\":{},\"upstreamEventId\":\""
+ + UUID.randomUUID()
+ +
"\",\"upstreamActionName\":\"python_action\"}");
+
+ assertThatThrownBy(() -> context.sendEvent(event))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("python_action");
+ }
+
+ private static RunnerContextImpl newContext() {
+ RunnerContextImpl context =
+ new RunnerContextImpl(
+ new FlinkAgentsMetricGroupImpl(
+
UnregisteredMetricGroups.createUnregisteredOperatorMetricGroup()),
+ () -> {},
+ new AgentPlan(new HashMap<>(), new HashMap<>()),
+ null,
+ "job");
+ List<Event> buffer = new ArrayList<>();
+ context.switchActionContext(
+ "action",
+ new RunnerContextImpl.MemoryContext(null, null),
+ buffer,
+ "key",
+ "obs",
+ false,
+ null);
+ return context;
+ }
+}
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionTaskTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionTaskTest.java
index ea24d79ca..988a746e5 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionTaskTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionTaskTest.java
@@ -28,6 +28,7 @@ import
org.apache.flink.agents.runtime.python.utils.PythonActionExecutor;
import org.junit.jupiter.api.Test;
import java.util.List;
+import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -128,6 +129,24 @@ class ActionTaskTest {
assertThat(outputEvent.getUpstreamActionName()).isEqualTo(action.getName());
}
+ @Test
+ void resultRebindsLineageOfOutputsRestoredFromActionState() {
+ // Recovered outputs reach finalization without sendEvent, still
carrying the lineage
+ // bound before the failure; they keep identity and are bound to this
task again.
+ Event triggeringEvent = new InputEvent(1L);
+ Action action = TestActions.noopAction();
+ ActionTask task = new JavaActionTask("key", triggeringEvent, action,
1L);
+ Event restoredOutput = new Event("result");
+ restoredOutput.setUpstreamEventId(UUID.randomUUID());
+ restoredOutput.setUpstreamActionName("previous_action");
+
+ List<Event> outputs =
task.finalizeOutputEvents(List.of(restoredOutput));
+
+ assertThat(outputs).containsExactly(restoredOutput);
+
assertThat(restoredOutput.getUpstreamEventId()).isEqualTo(triggeringEvent.getId());
+
assertThat(restoredOutput.getUpstreamActionName()).isEqualTo(action.getName());
+ }
+
@Test
void resultRejectsSelfLoopBeforeMutatingAnyOutput() {
Event triggeringEvent = new InputEvent(1L);