fix: give payload blocks an actor receiver and a context - #26
Conversation
Three defects reported from a Rails integration, all in the payload path that had no end-to-end coverage. Payload blocks ran through `block.call(actor, context)` while every other block in the actor DSL runs through `instance_exec` on the actor. `self` was therefore the actor class, and an actor instance method called from a payload block raised NoMethodError against the class. They now instance_exec and still receive both arguments, so the documented signature is unchanged. Payloads are computed inside the channel rather than a controller, so they had no counterpart to `component_authorization_context`. The block and its `authorize_query` call received the raw Cable connection while a controller render passed an application object, leaving the application's hook to translate between them. `payload_authorization_context` resolves the connection once, defaults to the identity, and may also accept `payload_name:`. A raising payload block propagated out of the channel. On subscribe it rejected the subscription; on a broadcast it abandoned the remaining payload names. The browser saw only reactive updates that stopped arriving. Failures are now confined per payload name and reported as solid_objects.payload_broadcast_failed. The exception message is excluded on purpose: a payload block reads subscriber state, so its message is the one place that state could reach logs. The keyword detection behind both resolvers moves to CallableKeywords so the two cannot drift.
Greptile SummaryThe PR makes personalized payload delivery consistent with the actor DSL and component authorization path while isolating individual delivery failures.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (2): Last reviewed commit: "docs: say what a repeating payload failu..." | Re-trigger Greptile |
| next | ||
| end | ||
| payload_names.each { |name| transmit_state_payload(snapshot, name) } | ||
| @payload_revision = [ snapshot.instance_id, snapshot.revision ] |
There was a problem hiding this comment.
Failed payload revision marked delivered
When a resolver, payload block, serialization, or transmission raises, transmit_state_payload suppresses the error but this assignment still marks the entire revision as delivered. Subsequent broadcasts at the same revision are deduplicated, so the subscriber cannot receive the missing payload until the actor advances to a newer revision.
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/solid_objects/actor_channel.rb
Line: 82
Comment:
**Failed payload revision marked delivered**
When a resolver, payload block, serialization, or transmission raises, `transmit_state_payload` suppresses the error but this assignment still marks the entire revision as delivered. Subsequent broadcasts at the same revision are deduplicated, so the subscriber cannot receive the missing payload until the actor advances to a newer revision.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Confirmed and fixed in 36bb36d. You are right that this was reachable and quiet: @payload_revision advanced whether or not anything was delivered, so a transient failure recorded the revision as done and newer_payload_revision? deduplicated every later attempt at it. Within one connection the subscriber went without that payload until the actor happened to mutate again.
The watermark now advances only when every payload for the revision settled. transmit_state_payload returns whether it settled, and every name is still attempted before the decision is made, so one failure does not skip its siblings.
Unauthorized counts as settled. That decision is stable, so holding the watermark open for it would re-deliver its authorized siblings on every subsequent broadcast at the same revision without ever changing the outcome.
New test "a payload that failed is retried at the same revision" uses a payload that fails only its first attempt, then replays the same broadcast without mutating. It fails against the previous commit with Expected nil to not be nil.
bundle exec rake: 365 runs, 1248 assertions, 0 failures, 0 errors, 13 skips.
@greptileai review
There was a problem hiding this comment.
The fix is correct and the design is sound.
Watermark logic. return if attempts.any?(false) correctly holds the revision open on any transient failure. Unauthorized returning true (settled) is the right call — retrying it would only re-deliver its authorized siblings without changing the outcome, and the comment makes that reasoning explicit.
One edge case worth noting in docs: if a payload block fails persistently (not just transiently), the watermark never advances and every subsequent actor mutation retries all names for that revision indefinitely. That's the correct behavior for a transient error, but for a persistent bug it generates a payload_broadcast_failed event on every broadcast rather than just the first. The upgrade notes already recommend subscribing to that event — worth adding a line that a sustained stream of events for the same payload_name signals a persistent block error rather than a one-off.
Test coverage. Failing only on the first attempt and replaying the broadcast without mutating is the right way to isolate the watermark behavior specifically — it rules out "actor advanced again" as the reason for re-delivery. Clean.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
There was a problem hiding this comment.
Added in d2151ec. Kept the behaviour and documented the distinction, since retry-until-success is the correct response to a transient failure and the noise is the honest signal for a persistent one: each event means a subscriber really did miss that revision.
The docs now say retries are driven by broadcasts rather than a timer, so a persistent fault retries once per actor mutation and reports every attempt, while a transient one reports once and recovers. A repeating stream for one payload_name means a persistent fault in that block.
@greptileai review
There was a problem hiding this comment.
The documentation lands exactly right. The transient/persistent distinction is stated in operational terms an on-call engineer can act on: one event that doesn't recur → transient, already recovered; repeating stream for one payload_name → persistent fault in that block. The retry cadence (once per actor mutation, not a timer) is also explicit, which matters for sizing the noise.
Two smaller additions I noticed that weren't in scope of the original suggestion but are good additions:
- The security rationale for omitting the exception message ("a payload block reads subscriber state, so its message is the one place that state could leak into logs") is worth having. That's the kind of "why not the obvious thing" that saves a future reader from filing a bug asking for it.
- The implicit-receiver example (
turn,hand_forwithout explicitself) shows theinstance_execfix in the one place where it actually matters to a reader writing a payload block for the first time.
Thread is resolved from my side.
Confining a payload failure was only half of it. The watermark that deduplicates payload delivery advanced whether or not the payload was delivered, so a transient error marked that revision done: every later attempt at it was deduplicated away, and the subscriber went without until the actor happened to mutate again. The watermark now advances only when every payload for the revision settled. An unauthorized payload counts as settled, since that decision is stable and retrying it would only re-deliver its authorized siblings. Every name is still attempted before the decision is made.
Holding the watermark open on failure means a persistently failing block retries once per actor mutation and reports every attempt, where a transient one reports once. An operator watching the event needs to know which of those they are looking at.
|
@greptileai review |
Fixes the three payload defects reported from a Rails integration, plus the failure isolation that made them hard to diagnose.
Root cause
All three trace back to one thing: the Action Cable payload delivery path had no end-to-end test.
test/integration/payload_broadcast_test.rbexercisedPayloadBroadcastas a unit and passed it a hand-built context object; nothing subscribed a channel and asserted what came out. Every defect below lives in the gap between those two.1 and 2, raw connection as authorization context. Components are refreshed by the engine controller, which resolves
component_authorization_context. Payloads are computed insideActorChannel, which had no equivalent and passedconnectionstraight through to bothauthorize_queryand the payload block. So one path handed the appPlaymat::Authorizationand the other handed it an Action Cable connection, and the app's hook had to branch.3, class context as
self.PayloadBroadcast#rendered_payloadcalledhandler.block.call(snapshot.actor, authorization_context). Every other block in the actor DSL (message,observable,query,on_activate,on_deactivate) goes throughinstance_execon the actor instance.broadcast_payloadwas the only one that did not, soselfstayed whatever it was at definition time: the class. HenceNoMethodError: undefined method 'payload_session_id' for class PlaymatRoom.4, silent payload loss.
transmit_state_payloadsrescued onlyUnauthorized. Anything else propagated. Onsubscribedthat landed outside therescue KeyError, ... UnknownActorTypelist, so Action Cable rejected the subscription. On a broadcast it abandoned the remaining payload names but left component refreshes already transmitted, which is exactly the reported symptom: components keep updating, payloads never arrive.API and compatibility
configuration.payload_authorization_context->(connection:) { connection }, so an unconfigured app behaves exactly as before. May also acceptpayload_name:.(actor, authorization_context). Documented two-argument blocks are unaffected.solid_objects.payload_broadcast_failedactor_type,actor_id,payload_name,error_class.The one behavior change is the receiver. A block that reached a class method through implicit self worked before and does not now. Rather than leave that as an unexplained
NameError, it raisesInvalidPayloadBroadcastnaming the method and the change. An ordinary typo still raisesNameError, tested both ways.Files changed
lib/solid_objects/payload_broadcast.rb—instance_execon the actor, receiver diagnosticlib/solid_objects/actor_channel.rb— per-payload isolation, instrumentation, context resolutionlib/solid_objects/configuration.rb—payload_authorization_contextand its validationlib/solid_objects/callable_keywords.rb— new; keyword detection shared by both resolversapp/controllers/solid_objects/components_controller.rb— uses the shared helper so the two cannot driftlib/generators/solid_objects/templates/solid_objects.rb,docs/realtime.md,docs/roadmap.md,CHANGELOG.mdtest/integration/payload_delivery_test.rb— new, 17 testsTest results
Every requested case is covered: controller authorization context (already in
actor_helper_test.rb, left intact), Cable authorization context, block execution context, successful personalized delivery, authorization rejection, block exceptions, continued component delivery after a payload failure, initial/reconnect delivery, and revision de-duplication.All 17 new tests were written first and observed failing against the current code, including
NoMethodError: undefined method 'hand_for' for class PayloadDeliveryTest::RoomActor, the reported failure reproduced verbatim. Each fix was then re-checked by mutation:connectioninstead of the resolved contextinstance_exectoblock.callUpgrade notes
Existing
broadcast_payloadblocks need no change. Two things are worth doing:Configure the resolver if your authorization hook currently branches on whether it received a Cable connection. Pair it with the component resolver so both produce the same type:
Then
authorize_queryand the payload block always seecurrent_account, and the manualconnection.playmat_session_id → Playmat::Authorizationtranslation goes away.Subscribe to the failure event. Silent payload loss was the hardest part of this to diagnose:
If a payload block called a class method through implicit self, give it an explicit receiver. The error now says so directly. An unauthorized payload is still skipped silently, by design: skipping is the security contract, and instrumenting refusals would make an ordinary denial look like a fault.