feat: the transmit family, ingest and staging - #49
Merged
Conversation
Add SolidObjects::Transmission.receive, the server ingest for the browser transmit family that solid-objects-js ships. A browser actor stages a transmit intent in the same transaction as its state change; the browser outbox posts one camelCase JSON envelope per effect to a route the host application owns, and receive replays it onto a server actor. The gem already held every primitive: the ingest validates the envelope, resolves the actor type (with a per-call resolve_actor_type: escape hatch for diverged names), and enqueues one internal message keyed transmit:<effectId>, so at-least-once delivery applies once. Internal delivery skips authorize_message by construction, which is why the host must authenticate before the call; docs/transmission.md draws that boundary with a controller example. Malformed envelopes raise the new SolidObjects::InvalidTransmission, so a host can return 422 and let the browser outbox dead-letter the effect instead of retrying forever. Golden fixtures in compatibility/transmit-envelopes.json pin the wire contract; the JS repo gets the same file with a consuming test when its transmit branch lands. Closes #47
Add Actor#transmit and SolidObjects.register_transmit, the staging side of the transmit family and the counterpart of the ingest in the previous commit. transmit returns the same fluent dispatcher schedule returns and stages a solid-objects.transmit effect in the actor commit; a raw emit with explicit actorType/actorId targets a different actor, matching the JS staging surface. register_transmit wraps register_effect with envelope construction and the ordered drain ported from solid-objects-js: a claimed transmit effect delivers every undelivered sibling for its actor up to its own mailbox sequence, oldest first. Per-actor order therefore survives a failed delivery, and redelivery is safe because the receiving side dedups on transmit:<effectId>. A raised delivery retries with backoff and dead-letters on exhaustion, like any other effect; a malformed staged effect raises InvalidTransmission and is skipped by sibling drains rather than blocking them. The drain-ordering test was verified against a neutered drain that delivers only the claimed effect: it fails with [2] where [1, 2] is expected, so the test observes the guarantee it claims. Closes #48
The transmit family is new public surface, so the release after 0.13.3 takes a minor bump. Date the changelog section accordingly.
The prompt-processing test bounded elapsed time at 0.4 seconds, and a loaded CI runner measured 0.409 and failed the compatibility (3.4, 7.1) job. Wall time never observed the mechanism anyway: promptness comes from the enqueue signal interrupting the idle wait, and that interruption is visible as a polling interval reset with reason :wake_up. A poll-driven completion resets with :work instead, so the assertion still fails when the signal path breaks, without a wall clock in the loop.
Manual QA in a fresh Rails app found the primary use case broken in development: a controller calling Transmission.receive got UnknownActorType for every envelope. The ingest looks actors up by string, and a string cannot trigger the autoload that referencing the class constant does, so a lazy-loading web process has an empty registry. Only the CLI installed ApplicationActorLoader. receive now loads the application's actor classes once on a registry miss and retries, through an injectable actor_loader: that follows the mailbox: injection pattern. The first fix attempt also raised NameError in the web process because application_actor_loader was required only by the CLI; the gem now requires it, and the load contract ledger drops its used-only-by-the-CLI row. QA also showed a conflicting replay returning 500 through the documented controller, which would make a browser outbox retry a permanently unappliable envelope forever. IdempotencyConflict joins the documented 422 rescue list. The transmit docs gain the effect retry budget: the defaults dead-letter an envelope after roughly fifteen seconds offline, a dead effect has no retry API, and the roadmap records that limitation.
Owner
Author
|
The contract bug is fixed on the JS side in cardmagic/solid-objects-js@acdcba5 (PR cardmagic/solid-objects-js#18): |
Add POST /solid_objects/transmit, the engine-mounted ingest that #47 deferred. The route parses the body, authorizes it through the new authorize_transmission policy, and hands it to Transmission.receive with the new transmission_actor_type_resolver configuration. The policy denies by default like the other five, because the ingest skips authorize_message by design. It receives the parsed envelope and the controller as authorization_context, so an application can bind actorType and actorId to the authenticated caller rather than only compare a shared token. An unauthorized envelope gets 403; a permanently unappliable one (malformed, unknown type or operation, oversized, conflicting replay, unparseable body) gets 422, so a sending outbox dead-letters it instead of retrying forever. The controller inherits ActionController::API: the endpoint is token-authenticated machine traffic with no session, which keeps the Brakeman scan warning-free where a null_session forgery setting does not. The install generator template gains the policy with a bearer token example, and the generator test pins six deny-by-default policies. Verified live in a scratch Rails app: 403 without or with a wrong token, 200 with the token, 422 for malformed and unparseable bodies, and the effect worker delivering through the engine route end to end with the configured actor type resolver.
The static and sqlite CI jobs failed at RBS validation: the generated signature for TransmissionsController names ActionController::API as its superclass, and sig/support/framework.rbs only declared ActionController::Base. The local run before the push looked green because the exit code check read the tail of a grep pipeline, not rake itself; a fresh clone reproduced the failure immediately. The stub now declares the API class, and rake passes in a clean clone with its own exit code checked.
A Greptile review of the branch flagged concurrent effect workers on one actor as an ordering hazard: a later effect's drain deliberately includes a processing sibling, so two workers can deliver overlapping envelope sets. The overlap is the documented at-least-once design and the ingest dedups it on transmit:<effectId>; the reorder cannot happen, because a drain delivers older siblings first and only sends a later envelope after the earlier delivery returned successfully. This test forces the exact interleaving: worker A claims the early effect and stalls inside its delivery, worker B claims the later effect and drains both, then A finishes as a deduplicated replay. The mirror applies [1, 2] and both effects complete. Against a neutered drain that delivers only the claimed effect, the same interleaving fails with the reordering the review predicted, so the test observes the guarantee rather than the implementation.
A second Greptile pass flagged that a drained sibling's effect row stays pending and later redelivers, a duplicate outbound request. The duplicate is the documented at-least-once design: completing the sibling would take over another worker's claim, skipping it would break ordering, and the JS runtime behaves the same, so the docs now state the trade explicitly instead of leaving it implied. The concurrency race test already pins the behavior: order holds, replays apply nothing, and every effect completes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #47. Closes #48.
What this adds
Both sides of the transmit family, over one wire contract:
SolidObjects::Transmission.receive(envelope)(RFP: SolidObjects::Transmission.receive, the server ingest for browser transmit envelopes #47): the server ingest for transmit envelopes. It validates a camelCase envelope, resolves the actor type, and enqueues one internal message with the idempotency keytransmit:<effectId>.Actor#transmitandSolidObjects.register_transmit(RFP: Actor#transmit and register_transmit, the staging side of the transmit family #48): the staging side.transmit.increment(amount:)stages asolid-objects.transmiteffect in the same commit as the state change;register_transmitdrains staged effects into envelopes and hands each to the delivery block. The ordered drain is ported from solid-objects-js: a claimed transmit effect delivers every undelivered sibling for its actor up to its own mailbox sequence, oldest first.With both sides in place, Rails-to-Rails, Rails-to-Node, Node-to-Rails, and browser-to-Rails replication ride the same contract.
docs/transmission.mddocuments both sides and the controller boundary.API
SolidObjects::Transmission.receive(envelope, resolve_actor_type:, mailbox:)returns aMessageReference.resolve_actor_type:is a per-call proc for diverged actor type names; the default is identity.Actor#transmitreturns the same fluent dispatcherschedulereturns. A rawemit "solid-objects.transmit"with explicitactorType/actorIdtargets a different actor, matching the JS staging surface.SolidObjects.register_transmit(&deliver)wrapsregister_effect("solid-objects.transmit")with envelope construction and the ordered drain. The block raises to retry.SolidObjects::InvalidTransmission < SolidObjects::Erroris new and covers malformed envelopes on both sides.UnknownActorType,UnknownMessage, andPayloadTooLargepropagate from the existing primitives.POST /solid_objects/transmit(the follow-up RFP: SolidObjects::Transmission.receive, the server ingest for browser transmit envelopes #47 deferred), anActionController::APIendpoint behind the new deny-by-defaultauthorize_transmissionpolicy. The policy receives the parsed envelope and the controller asauthorization_context:; the newtransmission_actor_type_resolverconfiguration maps diverged actor type names. 403 for an unauthorized envelope, 422 for a permanently unappliable one.Correctness
transmit:<effectId>, byte-identical in both runtimes.Mailbox#enqueuededup makes a replayed or redelivered envelope apply once.transmit:<effectId>; a reorder cannot happen, because a drain sends older siblings first and only sends a later envelope after the earlier delivery returned successfully. A race test forces the exact interleaving (worker A claims the early effect and stalls mid-delivery, worker B claims the later effect and drains both) and fails with the predicted reorder against a neutered drain.InvalidTransmission, dead-letters, and is skipped by sibling drains rather than blocking them.compatibility/transmit-envelopes.jsonpin the wire contract, consumed by both the ingest suite and a staging test that asserts a Ruby-staged envelope matches the fixture. The JS side of solid-objects-js#18 now carries the same file with a consuming suite (four tests: valid fixtures apply once, the duplicate pair applies once, malformed fixtures reject, and a staged envelope matches the fixture), so the contract is enforced from both sides of the repo boundary. The parity ledger rows move when both PRs merge.Security
delivery_mode: "internal", so it skipsauthorize_messageby construction, the same semantics as the JSenqueueInternalMessage. The host application must authenticate the request before it callsreceive.docs/transmission.mdstates this and shows the controller example with a 422 path, so the browser outbox dead-letters an unappliable effect instead of retrying it forever.Serialization.dumpbefore persistence.Migration and compatibility
transmitandregister_transmitare new surface; the effect namesolid-objects.transmitis namespaced.authorize_transmissiondenies by default. Bidirectional replication as a declared surface (the "replica" feature) stays out; the RFP issue records its design questions.Test-first evidence
Both test files ran before their implementations existed and failed for the expected reasons:
The drain-ordering test was additionally verified against a neutered drain that delivers only the claimed effect; it fails with
Expected: [1, 2] Actual: [2], so the test observes the ordering guarantee it claims.Validation