feat: report synchronized property updates to RPC invocation observers - #25237
feat: report synchronized property updates to RPC invocation observers#25237totally-not-ai[bot] wants to merge 12 commits into
Conversation
Property synchronizations are routed to MapSyncRpcHandler directly from handleInvocations, bypassing handleInvocationData, so no listener ever saw an mSync invocation at all -- not just one with a null name. Notify listeners around the deferred property change event, which is the step that runs application code and therefore the one whose duration and failures are worth attributing, and resolve the invocation name of an mSync to the synchronized property. Only the property name is exposed, never the value sent from the client. Fixes #25236
RpcInvocationListener
The contract stated for RpcInvocationListener did not match the code in either direction. An update rejected by a model filter, or one whose value is already the one the client sent, yields a change event runnable that does nothing, so it is reported even though nothing was handled, while a detached, disabled or inert node is not reported at all. A property bound to a signal through Element.bindProperty is a further case: its listeners run while the value is written to the signal, in the step before the change event, so its span covers no work and a failure never reaches invocationFailed. Spell all of this out, along with the fact that property updates are reported ahead of the other invocations of the same request. Pin the notifications around the change event rather than only asserting that they happened, and pin the failure order on the new path.
…cations Two statements added in the previous commit were wrong. Only a property synchronization goes unreported when its node is detached, disabled or inert; every other type is observed around the routing to its handler, so it is reported first and discarded afterwards. And notifications do not overlap only within the handling of a single request: a listener is registered on the service, so invocations of different sessions reach it from several threads at once. Pin the asymmetry with a request that carries an event and a property sync for the same missing node, since that is the detail the wording got wrong.
RpcInvocationListener|
@claude merge origin/main |
|
Claude finished @Artur-'s task in 2m 22s —— View job Merging
|
…ation-name # Conflicts: # flow-server/src/main/java/com/vaadin/flow/server/communication/RpcInvocationEvent.java # flow-server/src/main/java/com/vaadin/flow/server/communication/ServerRpcHandler.java
| * around; see {@link AbstractRpcInvocationEvent} for what that means for a | ||
| * property whose update produces no change event. | ||
| */ | ||
| private void runPropertyChangeEvent(UI ui, JsonNode invocationJson, |
There was a problem hiding this comment.
Most of this seems to be duplicated from below? Can we extract an observer instead of extracting this, and e.g. pass in a lambda?
There was a problem hiding this comment.
Good catch — it was a full copy of the plumbing, not just a few lines: the same three-way hasListener check, the same node id / name extraction, and the same started/failed/ended firing around a body.
Extracted observeInvocation(ui, type, invocationJson, Runnable), which now holds the only copy. handleInvocationData passes the routing to the handler as the lambda, and the property sync path passes the deferred change event, so runPropertyChangeEvent and the isInvocationObserved helper are both gone. Net −19 lines.
Two things that fell out of doing it this way: the failure path also collapses, since both sites already routed the throwable to callErrorHandler identically, and the observed short-circuit is now guaranteed to behave the same for both rather than by inspection.
The property sync path repeated the whole of the phase-event plumbing that routing to a handler already did: the same listener check, the same extraction of the node id and name, and the same started/failed/ended firing around a body. Only the body and the invocation type differed, so take those as parameters and keep one copy of the plumbing.
The quality gate flagged the new code as under-covered. The gaps were in the observer extracted for both RPC paths: nothing registered a listener for a single phase, so the short-circuit that skips the work when the event bus has no listener at all was only ever entered from one side, and nothing let an invocation fail unobserved, which is the default in production and the path this refactoring most had to leave alone. Cover each of those, plus the unsupported invocation type that had no test at all. What is left uncovered in the observer is the assertion that a handler returned no runnable, which only evaluates when a handler breaks that contract.
It repeated the setup and payload of the existing test for a throwing value change listener and asserted a subset of it. The branch it was added for turns out to be reached by the rest of the suite anyway: removing it leaves the coverage of the listener check in the observer unchanged.
…he request Applying the value of a synchronized property is the first of the two steps a property sync is handled in, and it was outside every try/catch: a property that is not synchronized at all, or a signal bound to it that rejects the write, threw straight out of handleRpc. No phase event was fired, the node-aware error handler was never reached, and every remaining invocation of the request was skipped in favour of a critical notification. Report such a failure where it happens, with the same phase events and the same error handler every other invocation type uses, and carry on with the rest of the request. Since applying the value can now fail without reaching the change event, the events of one property sync are still fired exactly once, either around the change event or around the failure that replaced it. The pending change events no longer need their own error handling, which the observer already does, and the javadoc no longer claims that a signal-bound failure is quietly routed to the session error handler.
…ation-name # Conflicts: # flow-server/src/main/java/com/vaadin/flow/server/communication/AbstractRpcInvocationEvent.java
Reporting a refused value application as an invocation failure was right, but routing it to the error handler and carrying on was not: refusing a value the client should not have sent deliberately aborts the request and reports an internal error to the client, which ElementPropertySignalBindingIT asserts for a binding without a write callback. Fire the phase events as before and then rethrow, so the failure is attributed to the invocation that caused it while the request keeps ending the way it always has. Say so in the javadoc, since this is where a property sync differs from the failure of the application code an invocation runs. Cover the read-only signal binding the integration test exercises, so the next change to this path fails in the unit tests rather than in a browser.
RuntimeException would have been satisfied by any failure of the request, including one unrelated to the binding, so the test did not pin the rejection it covers. A read-only binding throws IllegalStateException, which deferredUpdateFromClient documents.
|



What
Synchronized property updates (
mSync) were the one client-to-server RPC type that never reached the RPC invocation observers on the service event bus. Every other invocation — DOM events,@ClientCallable/template handlers, navigation, return channel messages — was already bracketed byRpcInvocationStartedEvent,RpcInvocationFailedEventandRpcInvocationEndedEvent, so a tracing integration saw a gap exactly where property syncs spend session-lock time.This PR reports property syncs like any other invocation, with the property name as the invocation name, and fixes the error handling on that path.
Why
A property sync is handled in two steps: the value of every synchronized property in the request is applied to the state tree first, and only then are the corresponding change events fired, so application code sees a fully updated tree.
The phase events surround the second step, since that is where application code runs. But the first step — applying the value — sat outside every
try/catch. A property that is not synchronized at all, or a signal bound to it that rejects the write, threw straight out ofhandleRpc: no phase event was fired and the node-aware error handler was never reached.Now such a failure is reported where it happens, with the same started/failed/ended events, and then rethrown — because refusing a value the client should not have sent deliberately aborts the request and reports an internal error to the client, which is the behaviour
ElementPropertySignalBindingITasserts for a binding without a write callback. The events of one property sync are still fired exactly once: either around the change event, or around the failure that replaced it.Contract, spelled out
The javadoc of
AbstractRpcInvocationEventnow states what reporting does and does not cover, because the code and the previous wording disagreed in both directions:Element.bindPropertyhas its write callback run in the first step, so its span brackets no work.Refactor
The property sync path would have repeated the whole of the phase-event plumbing that routing to a handler already did — the same listener check, the same extraction of node id and name, the same started/failed/ended firing around a body. Only the body and the invocation type differ, so those became parameters of a shared
observeInvocation, and the details are still extracted once and only when the event bus actually has a listener, so an application observing nothing allocates nothing per invocation.Tests
ServerRpcHandlerTestgains coverage for the events and the failure ordering on the new path, a signal-bound property whose span brackets no change event, a read-only signal binding (asserting theIllegalStateExceptionthatdeferredUpdateFromClientdocuments, rather than anyRuntimeException), a non-synchronized property, an unobserved failure still reaching the error handler, the single-phase-listener short-circuit, and the previously untested unsupported invocation type.Use case
A team runs Vaadin behind OpenTelemetry and wants to know which client interaction is holding the session lock when a view feels slow. They already get spans for DOM events and
@ClientCallablecalls, but a form where every keystroke syncs a property showed up as unexplained time inside the request span. With property syncs reported, each sync becomes its own span labelled with the property name — and a signal binding that rejects a write surfaces as a failed span instead of a silent abort.Registered through
META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener, this now produces one span per property sync in the request, each named after the property that changed.