Skip to content

Commit 4cf22c1

Browse files
committed
fix: harden fluent actor dispatch
Reject operation names that collide with dispatcher methods so every declared operation is either callable or rejected at definition time.\n\nUse operation and delivery-mode terminology through persistence and make multi-argument internal APIs keyword-only for clearer call sites. Keep the original schema migration immutable and carry the column renames in a new migration.
1 parent a235a53 commit 4cf22c1

69 files changed

Lines changed: 595 additions & 454 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
- Validate fluent operations before enqueueing or staging them. Direct and
1414
configured synchronous calls accept messages and queries, while `async`,
1515
`send_to`, and `schedule` accept public actor messages only.
16+
- Use operation terminology throughout invocation persistence and diagnostics.
17+
The new migration renames stored message names to `operation`, message kind
18+
to `delivery_mode`, and effect callback message names to operation names.
19+
- Make internal methods with more than two arguments keyword-only so dispatch,
20+
persistence, component refresh, and diagnostics call sites name every value.
1621
- Keep SQLite reconnect failures inside a synchronous lock deadline. Active
1722
Record can reconnect after a lock error while the lock is still held, and
1823
configuring WAL then raises `SQLite3::CantOpenException`; it now retries

app/controllers/solid_objects/components_controller.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def refresh_batch(payload)
5555
"target" => registration.dom_id,
5656
"revision" => "#{snapshot.instance_id}:#{snapshot.revision}",
5757
"refresh_method" => registration.refresh_method,
58-
"html" => component_frame(registration, snapshot, rendered)
58+
"html" => component_frame(registration:, snapshot:, rendered:)
5959
}
6060
end
6161
response.headers["Cache-Control"] = "private, no-store"
@@ -118,7 +118,7 @@ def refresh(payload)
118118
).call
119119
response.headers["Cache-Control"] = "private, no-store"
120120
payload[:outcome] = "rendered"
121-
render html: component_frame(registration, snapshot, rendered)
121+
render html: component_frame(registration:, snapshot:, rendered:)
122122
rescue Unauthorized
123123
payload[:outcome] = "unauthorized"
124124
head :forbidden
@@ -182,8 +182,8 @@ def component_view_context
182182
end
183183
end
184184

185-
# @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String
186-
def component_frame(registration, snapshot, rendered)
185+
# @rbs (registration: ComponentRegistration, snapshot: ActorSnapshot, rendered: untyped) -> String
186+
def component_frame(registration:, snapshot:, rendered:)
187187
revision = "#{snapshot.instance_id}:#{snapshot.revision}"
188188
%(<turbo-frame id="#{registration.dom_id}" data-solid-objects-revision="#{revision}" data-solid-objects-refresh="#{registration.refresh_method}">#{rendered}</turbo-frame>).html_safe
189189
end

app/models/solid_objects/message.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class Message < Record
2020

2121
before_validation :supply_defaults
2222

23-
validates :message_kind, inclusion: { in: %w[async sync internal] }
23+
validates :delivery_mode, inclusion: { in: %w[async sync internal] }
2424

2525
# @rbs () -> bool
2626
def ready?

app/views/solid_objects/dead_letters/index.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<% @dead_letters.each do |dead_letter| %>
1616
<tr>
1717
<td><%= dead_letter.actor_type %>(<%= dead_letter.actor_id %>)</td>
18-
<td><%= dead_letter.message_name %></td>
18+
<td><%= dead_letter.operation %></td>
1919
<td><%= dead_letter.attempts %></td>
2020
<td><%= dead_letter.exception_class %>: <%= dead_letter.exception_message %></td>
2121
<td><%= dead_letter.last_failed_at %></td>

app/views/solid_objects/instances/show.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
<% @messages.each do |message| %>
2727
<tr>
2828
<td><%= message.sequence %></td>
29-
<td><%= message.message_name %></td>
29+
<td><%= message.operation %></td>
3030
<td><%= message.attempt_count %></td>
3131
<td><%= message.completed_at %></td>
3232
</tr>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# rbs_inline: enabled
2+
3+
class RenameMessageDispatchColumns < ActiveRecord::Migration[8.0]
4+
# @rbs () -> void
5+
def up
6+
remove_check_constraint messages_table, name: "chk_so_messages_kind"
7+
rename_column messages_table, :message_name, :operation
8+
rename_column messages_table, :message_kind, :delivery_mode
9+
rename_column reminders_table, :message_name, :operation
10+
rename_column effects_table, :success_message_name, :success_operation
11+
rename_column effects_table, :failure_message_name, :failure_operation
12+
rename_column dead_letters_table, :message_name, :operation
13+
add_check_constraint messages_table,
14+
"delivery_mode IN ('async', 'sync', 'internal')",
15+
name: "chk_so_messages_delivery_mode"
16+
end
17+
18+
# @rbs () -> void
19+
def down
20+
remove_check_constraint messages_table, name: "chk_so_messages_delivery_mode"
21+
rename_column messages_table, :operation, :message_name
22+
rename_column messages_table, :delivery_mode, :message_kind
23+
rename_column reminders_table, :operation, :message_name
24+
rename_column effects_table, :success_operation, :success_message_name
25+
rename_column effects_table, :failure_operation, :failure_message_name
26+
rename_column dead_letters_table, :operation, :message_name
27+
add_check_constraint messages_table,
28+
"message_kind IN ('async', 'sync', 'internal')",
29+
name: "chk_so_messages_kind"
30+
end
31+
32+
private
33+
34+
# @rbs () -> String
35+
def messages_table
36+
SolidObjects.table_name(:messages)
37+
end
38+
39+
# @rbs () -> String
40+
def reminders_table
41+
SolidObjects.table_name(:reminders)
42+
end
43+
44+
# @rbs () -> String
45+
def effects_table
46+
SolidObjects.table_name(:effects)
47+
end
48+
49+
# @rbs () -> String
50+
def dead_letters_table
51+
SolidObjects.table_name(:dead_letters)
52+
end
53+
end

docs/architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ through the client.
7171

7272
### Client and mailbox
7373

74-
The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates message names and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented.
74+
The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented.
7575

7676
Message execution state is table membership, not a status column. The durable message remains for results, retention, and diagnostics. Only live work occupies `ready_messages` or `claimed_messages`, so completed history cannot inflate the polling index.
7777

@@ -185,7 +185,7 @@ with application Active Record writes prevented.
185185
Enqueue uses one transaction:
186186

187187
1. Resolve actor class from the registry.
188-
2. Validate authorization, message name, actor ID, arguments, and size.
188+
2. Validate authorization, operation, actor ID, arguments, and size.
189189
3. `INSERT ... ON CONFLICT` the actor instance if missing.
190190
4. Lock the instance row.
191191
5. Enforce the mailbox limit.

docs/authorization.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ retain an empty arguments hash. This lets a policy authorize a projection such
6464
as one seat or player:
6565

6666
```ruby
67-
configuration.authorize_query = lambda do |actor_type:, actor_id:, message_name:, arguments:, authorization_context:|
67+
configuration.authorize_query = lambda do |actor_type:, actor_id:, operation:, arguments:, authorization_context:|
6868
user = authorization_context
6969
player_id = arguments["player_id"]
7070

@@ -126,7 +126,7 @@ SolidObjects.configure do |configuration|
126126
end
127127
```
128128

129-
The policy receives normalized actor type and ID strings, message name and
129+
The policy receives normalized actor type and ID strings, operation and
130130
arguments where relevant, and the context supplied by the caller. Avoid
131131
authorizing from arguments alone; bind the actor identity to the authenticated
132132
principal and tenant.

docs/database-schema.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ scheduled reminder, unresolved outbox, or dead letter before deletion.
3737

3838
### `messages`
3939

40-
Durable immutable invocation identity and arguments plus sequence, attempt
41-
count, request/idempotency IDs, result/error, requested availability, and
42-
execution timestamps. A terminal domain rejection stores a structured
40+
Durable immutable invocation identity, selected operation, delivery mode, and
41+
arguments plus sequence, attempt count, request/idempotency IDs, result/error,
42+
requested availability, and execution timestamps. A terminal domain rejection stores a structured
4343
code/message/details document and rejection time. The table intentionally has
4444
no status column.
4545

@@ -85,7 +85,7 @@ limit recurrence intervals, missed-work policies, and statuses.
8585
### `effects`
8686

8787
Transactional external-effect outbox. It stores stable effect UUID, arguments,
88-
optional actor outcome messages, attempts, claim ownership, result/error, and
88+
optional actor outcome operations, attempts, claim ownership, result/error, and
8989
completion time. Claim ownership references the process registry.
9090
Status/availability/ID drives delivery; completion/ID drives cleanup.
9191

lib/solid_objects.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
require "solid_objects/context"
2222
require "solid_objects/actor_registry"
2323
require "solid_objects/state"
24-
require "solid_objects/actor_definition"
2524
require "solid_objects/operation_dispatcher"
25+
require "solid_objects/actor_definition"
2626
require "solid_objects/application_write_guard"
2727
require "solid_objects/actor"
2828
require "solid_objects/reference"

0 commit comments

Comments
 (0)