solid_objects 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +12 -4
- data/Rakefile +5 -0
- data/docs/correctness.md +9 -0
- data/docs/operations.md +23 -1
- data/docs/roadmap.md +7 -1
- data/examples/at_least_once/actor.rb +14 -0
- data/examples/at_least_once/boot.rb +47 -0
- data/examples/at_least_once/demo.rb +92 -0
- data/examples/at_least_once/effect_worker.rb +40 -0
- data/examples/at_least_once/sink.rb +27 -0
- data/lib/solid_objects/actor_channel.rb +33 -3
- data/lib/solid_objects/engine.rb +4 -0
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/lib/solid_objects/actor_channel.rbs +10 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3b9abc40f188e1ea4964cfffca89e030990948c937a8aca0ec32c19830da272
|
|
4
|
+
data.tar.gz: 57707f08fc468d705202bbc3f20ddd60bfed4cd3d9153c58b7a06c981051ef93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 203477264f47ba941dd4a29b08e00ce5e8ccce298fce361beeaed73f54d3fb1edbbd46f05733e44eaacb366301fc1a6128f084024976268e42ce302d6a7aab76
|
|
7
|
+
data.tar.gz: 1f82eeb81cd282bbf0cdabc3e4346376b21f5688997440a3f6790f3332f2762764d3a3e3a48778629702c0604480e4e0a6380a677d76a106d9f439c466302cf4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.1 - 2026-08-24
|
|
4
|
+
|
|
5
|
+
- Register application actors in every process that boots the application.
|
|
6
|
+
The engine now loads the host application's `app/actors` directories from a
|
|
7
|
+
`to_prepare` hook, which previously only the `solid_objects start` process
|
|
8
|
+
did. An actor registers itself as a side effect of its class loading, so a
|
|
9
|
+
lazily loading web process began with an empty registry. `ActorChannel`
|
|
10
|
+
looks the actor up by name, and the resulting `UnknownActorType` reached the
|
|
11
|
+
rescue that rejects the subscription: a Cable subscription for a real actor
|
|
12
|
+
was rejected in any web process that had not yet rendered that actor, and
|
|
13
|
+
the page kept a card that never updated. `ComponentsController` resolves the
|
|
14
|
+
same way through `ActorSnapshot`. `Transmission.receive` already carried a
|
|
15
|
+
registry-miss retry for this reason, and it stays as a guard for a host that
|
|
16
|
+
reaches the gem without the engine.
|
|
17
|
+
- Report why a Cable subscription was rejected. Every reject path in
|
|
18
|
+
`ActorChannel#subscribed` now emits `solid_objects.subscription.rejected`
|
|
19
|
+
with a `reason`, the actor identity, and the `error_class` where an
|
|
20
|
+
exception caused it. Five conditions previously collapsed into one silent
|
|
21
|
+
`reject`, which is invisible from the browser and left nothing in the log to
|
|
22
|
+
distinguish an unregistered actor type from a tampered token. Exception
|
|
23
|
+
messages stay out of the payload, because a component or payload failure can
|
|
24
|
+
carry actor state.
|
|
25
|
+
|
|
26
|
+
- State where `async` waits when no worker runs. The `async` section of the
|
|
27
|
+
README and the runtime section of `docs/operations.md` now say that the
|
|
28
|
+
generator and the migrations start no role, so an application that serves
|
|
29
|
+
web requests alone leaves the message ready until
|
|
30
|
+
`bundle exec solid_objects start` runs the roles. The message is durable
|
|
31
|
+
and waits; it is not lost. `test/integration/background_pickup_test.rb`
|
|
32
|
+
pins it: the message reads `ready` and the actor state stays empty until a
|
|
33
|
+
worker runs. This matches solid-objects-js#22, which reported the same gap
|
|
34
|
+
for `runtime.run(signal)` in the Node package.
|
|
35
|
+
|
|
36
|
+
- Add `examples/at_least_once` and `bundle exec rake at_least_once`, an
|
|
37
|
+
executable proof that the at-least-once clause fires and that the
|
|
38
|
+
documented remedy absorbs it. One actor turn stages an effect that writes
|
|
39
|
+
to an external sink file. The first effect worker crashes between the sink
|
|
40
|
+
write and the acknowledgement, and a second worker reclaims the stale
|
|
41
|
+
effect after the liveness threshold and delivers again. With deduplication
|
|
42
|
+
off the sink reads 2, both deliveries carrying the same `context.id` at
|
|
43
|
+
attempts 1 and 2; with a guard on that id the sink reads 1. The actor state
|
|
44
|
+
commits exactly once in both runs. CI runs the demo in the SQLite job, and
|
|
45
|
+
`docs/correctness.md` links it from the handler idempotency section. This
|
|
46
|
+
mirrors `pnpm run test:at-least-once` in solid-objects-js.
|
|
47
|
+
|
|
3
48
|
## 0.14.0 - 2026-08-22
|
|
4
49
|
|
|
5
50
|
- Add `SolidObjects::Transmission.receive(envelope)`, the server ingest for
|
data/README.md
CHANGED
|
@@ -662,6 +662,12 @@ message = order.async(
|
|
|
662
662
|
).submit
|
|
663
663
|
```
|
|
664
664
|
|
|
665
|
+
`async` needs a running actor worker. Installing the engine and migrating the
|
|
666
|
+
schema starts no role, so a process that only serves web requests leaves the
|
|
667
|
+
message ready. Nothing is lost. The message waits until
|
|
668
|
+
`bundle exec solid_objects start` runs the roles. See
|
|
669
|
+
[Worker requirements](#worker-requirements) for the feature-by-role table.
|
|
670
|
+
|
|
665
671
|
Use `available_at:` to spread bulk work or delay one message:
|
|
666
672
|
|
|
667
673
|
```ruby
|
|
@@ -1117,10 +1123,12 @@ and marks process rows stopped on graceful shutdown. A hard-killed worker's
|
|
|
1117
1123
|
claimed turn is recovered after its process heartbeat or activation lease
|
|
1118
1124
|
becomes stale.
|
|
1119
1125
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
an initializer.
|
|
1126
|
+
The engine loads actors from the host application's `app/actors` directories
|
|
1127
|
+
through Rails' main autoloader, in every process that boots the application.
|
|
1128
|
+
This works when eager loading is disabled and does not require actor
|
|
1129
|
+
references in an initializer. A web process therefore resolves an actor by
|
|
1130
|
+
name for a Cable subscription or a component render without having loaded that
|
|
1131
|
+
class through an earlier request.
|
|
1124
1132
|
|
|
1125
1133
|
See the [operations guide](docs/operations.md) for monitoring, reconciliation,
|
|
1126
1134
|
shutdown, retention, and backup guidance.
|
data/Rakefile
CHANGED
|
@@ -32,6 +32,11 @@ task :steep do
|
|
|
32
32
|
sh "bundle exec steep check"
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
desc "Prove the at-least-once clause by crashing an effect worker at a sink"
|
|
36
|
+
task :at_least_once do
|
|
37
|
+
sh "bundle exec ruby examples/at_least_once/demo.rb"
|
|
38
|
+
end
|
|
39
|
+
|
|
35
40
|
desc "Scan the Rails engine for security warnings"
|
|
36
41
|
task :security do
|
|
37
42
|
sh "bundle exec brakeman --force --no-pager -q ."
|
data/docs/correctness.md
CHANGED
|
@@ -93,6 +93,15 @@ end
|
|
|
93
93
|
The guard prevents a repeated state transition. The effect consumer still
|
|
94
94
|
deduplicates with `context.id`.
|
|
95
95
|
|
|
96
|
+
This clause is observable, not decorative. `bundle exec rake at_least_once`
|
|
97
|
+
crashes an effect worker between the external sink write and the
|
|
98
|
+
acknowledgement, restarts one after the liveness threshold, and shows the sink
|
|
99
|
+
reading 2 with deduplication off. Both deliveries carry the same `context.id`
|
|
100
|
+
at attempts 1 and 2. A guard on that id absorbs the same duplicate and the
|
|
101
|
+
sink reads 1. The actor state commits exactly once in both runs. The source is
|
|
102
|
+
`examples/at_least_once/`; solid-objects-js runs the same proof with
|
|
103
|
+
`pnpm run test:at-least-once`.
|
|
104
|
+
|
|
96
105
|
## Atomic boundaries
|
|
97
106
|
|
|
98
107
|
The following are atomic:
|
data/docs/operations.md
CHANGED
|
@@ -31,12 +31,25 @@ Start all configured roles:
|
|
|
31
31
|
bundle exec solid_objects start
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
The
|
|
34
|
+
The generator and the migrations prepare the database and start nothing. A
|
|
35
|
+
process claims ready messages only after this command starts its roles, so an
|
|
36
|
+
application that serves web requests alone leaves every `async` message ready.
|
|
37
|
+
The message is durable and waits for the first process that runs the roles. A
|
|
38
|
+
direct call or an explicit `sync` needs no running role, because the caller's
|
|
39
|
+
own path executes it.
|
|
40
|
+
|
|
41
|
+
The engine loads the host application's `app/actors` directories in every
|
|
42
|
+
process that boots the application, and the command repeats that load before
|
|
35
43
|
starting any runtime role, even when Rails eager loading is disabled. Actors in
|
|
36
44
|
the conventional directory do not need initializer references. The targeted
|
|
37
45
|
loader participates in Rails preparation callbacks so a development reload can
|
|
38
46
|
replace a registered actor class without loading unrelated application code.
|
|
39
47
|
|
|
48
|
+
An actor registers itself as its class loads, and a web process resolves
|
|
49
|
+
actors by name for Cable subscriptions and component renders. Loading them in
|
|
50
|
+
every process is what lets a freshly booted web process serve a live card for
|
|
51
|
+
an actor no request in that process has rendered yet.
|
|
52
|
+
|
|
40
53
|
Inspect process records and clean stale ownership:
|
|
41
54
|
|
|
42
55
|
```bash
|
|
@@ -187,6 +200,15 @@ transaction rejection, commit-action start/completion/failure, effect and
|
|
|
187
200
|
broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
|
|
188
201
|
retention pruning, process cleanup, and supervisor lifecycle.
|
|
189
202
|
|
|
203
|
+
`solid_objects.subscription.rejected` reports a rejected Cable subscription.
|
|
204
|
+
A rejection closes the socket and leaves the page holding a stale card, and
|
|
205
|
+
the browser cannot say which of the conditions applied. The event carries the
|
|
206
|
+
`reason`, the actor identity, and the `error_class` where an exception caused
|
|
207
|
+
it. The reason is one of `unregistered_actor_type`, `invalid_stream_token`,
|
|
208
|
+
`invalid_component_token`, `malformed_component_registration`,
|
|
209
|
+
`missing_subscription_parameter`, or `unauthorized`. Exception messages are
|
|
210
|
+
excluded, because a component or payload failure can carry actor state.
|
|
211
|
+
|
|
190
212
|
`solid_objects.reminder.replaced` reports a `schedule` call that moved an alarm
|
|
191
213
|
already armed under the same name on the same actor, carrying the actor
|
|
192
214
|
identity, reminder `name`, `previous_run_at`, and `next_run_at`. Reminders are
|
data/docs/roadmap.md
CHANGED
|
@@ -126,7 +126,13 @@
|
|
|
126
126
|
untested end to end, which is how a raising payload block came to reject the
|
|
127
127
|
subscription; it is now covered and confined, and the payload authorization
|
|
128
128
|
context is resolved through `payload_authorization_context` rather than
|
|
129
|
-
handing the block a raw Cable connection.
|
|
129
|
+
handing the block a raw Cable connection. Actor registration in a web process
|
|
130
|
+
was assumed rather than arranged: an actor registered only as a side effect
|
|
131
|
+
of its class loading, and only the worker CLI loaded the host's `app/actors`,
|
|
132
|
+
so a lazily loading web process rejected subscriptions for actors it could
|
|
133
|
+
serve until some earlier request happened to load the class. The engine now
|
|
134
|
+
loads them in every process, and a rejected subscription reports which
|
|
135
|
+
condition caused it instead of closing the socket silently.
|
|
130
136
|
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
|
|
131
137
|
distributed per-actor rate limits and global admission control do not.
|
|
132
138
|
- Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "active_record"
|
|
5
|
+
require "solid_objects"
|
|
6
|
+
|
|
7
|
+
# Boots a standalone runtime against a shared SQLite file, the way the
|
|
8
|
+
# demo's parent and its crashing children all attach to one database.
|
|
9
|
+
module AtLeastOnceBoot
|
|
10
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
11
|
+
|
|
12
|
+
# @rbs (String database_path) -> void
|
|
13
|
+
def self.call(database_path)
|
|
14
|
+
ActiveRecord::Base.establish_connection(
|
|
15
|
+
adapter: "sqlite3",
|
|
16
|
+
database: database_path,
|
|
17
|
+
pool: 5,
|
|
18
|
+
timeout: 5_000
|
|
19
|
+
)
|
|
20
|
+
ActiveRecord::Migration.verbose = false
|
|
21
|
+
migrate unless ActiveRecord::Base.connection.table_exists?("solid_objects_instances")
|
|
22
|
+
|
|
23
|
+
require "solid_objects/database_adapter"
|
|
24
|
+
%w[
|
|
25
|
+
record process instance message ready_message claimed_message
|
|
26
|
+
reminder effect broadcast dead_letter
|
|
27
|
+
].each { |model| require File.join(ROOT, "app/models/solid_objects", model) }
|
|
28
|
+
|
|
29
|
+
SolidObjects.configuration.authorize_message = ->(**) { true }
|
|
30
|
+
SolidObjects.configuration.authorize_query = ->(**) { true }
|
|
31
|
+
SolidObjects.configuration.polling_interval = 0.01
|
|
32
|
+
SolidObjects.configuration.process_heartbeat_interval = 0.075
|
|
33
|
+
SolidObjects.configuration.process_alive_threshold = 0.3
|
|
34
|
+
SolidObjects.configuration.lease_duration = 0.25
|
|
35
|
+
SolidObjects.configuration.lease_renewal_interval = 0.05
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @rbs () -> void
|
|
39
|
+
def self.migrate
|
|
40
|
+
require File.join(ROOT, "db/migrate/20260805000000_create_solid_objects_tables")
|
|
41
|
+
require File.join(ROOT, "db/migrate/20260806000000_add_state_revision_to_solid_objects_instances")
|
|
42
|
+
require File.join(ROOT, "db/migrate/20260813000000_rename_message_dispatch_columns")
|
|
43
|
+
CreateSolidObjectsTables.new.migrate(:up)
|
|
44
|
+
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
|
|
45
|
+
RenameMessageDispatchColumns.new.migrate(:up)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
# An executable proof for the at-least-once clause: a contract clause
|
|
4
|
+
# nobody can observe firing is decoration. Run with:
|
|
5
|
+
#
|
|
6
|
+
# bundle exec rake at_least_once
|
|
7
|
+
#
|
|
8
|
+
# Phase one crashes an effect worker between the external sink write and
|
|
9
|
+
# the acknowledgement, restarts one, and shows the sink reading 2 with
|
|
10
|
+
# deduplication off. Both deliveries carry the same stable effect id.
|
|
11
|
+
# Phase two repeats the crash with a guard on that id; the sink reads 1.
|
|
12
|
+
# The actor state commits exactly once in both phases.
|
|
13
|
+
|
|
14
|
+
require_relative "boot"
|
|
15
|
+
require_relative "actor"
|
|
16
|
+
require_relative "sink"
|
|
17
|
+
require "fileutils"
|
|
18
|
+
require "json"
|
|
19
|
+
require "rbconfig"
|
|
20
|
+
require "tmpdir"
|
|
21
|
+
|
|
22
|
+
directory = Dir.mktmpdir("solid_objects_at_least_once_")
|
|
23
|
+
database_path = File.join(directory, "state.sqlite3")
|
|
24
|
+
AtLeastOnceBoot.call(database_path)
|
|
25
|
+
|
|
26
|
+
# @rbs (String message) -> void
|
|
27
|
+
def prove(message)
|
|
28
|
+
raise "proof failed: #{message}" unless yield
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @rbs (String actor_id) -> void
|
|
32
|
+
def stage_one_delivery(actor_id)
|
|
33
|
+
DeliveryCounter.ref(actor_id).async.deliver
|
|
34
|
+
worker = SolidObjects::Worker.new
|
|
35
|
+
begin
|
|
36
|
+
worker.run_until_idle
|
|
37
|
+
ensure
|
|
38
|
+
worker.stop
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @rbs (database_path: String, sink_path: String, mode: String, deduplication: String) -> Integer?
|
|
43
|
+
def run_effect_worker(database_path:, sink_path:, mode:, deduplication:)
|
|
44
|
+
script = File.expand_path("effect_worker.rb", __dir__)
|
|
45
|
+
pid = Process.spawn(
|
|
46
|
+
RbConfig.ruby, script, database_path, sink_path, mode, deduplication,
|
|
47
|
+
chdir: AtLeastOnceBoot::ROOT
|
|
48
|
+
)
|
|
49
|
+
_pid, status = Process.wait2(pid)
|
|
50
|
+
status.exitstatus
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @rbs (database_path: String, sink_path: String, deduplication: String) -> void
|
|
54
|
+
def crash_then_recover(database_path:, sink_path:, deduplication:)
|
|
55
|
+
crash = run_effect_worker(database_path:, sink_path:, mode: "crash", deduplication:)
|
|
56
|
+
prove("the first delivery crashed before acknowledgement") { crash == 1 }
|
|
57
|
+
sleep 0.4
|
|
58
|
+
recovery = run_effect_worker(database_path:, sink_path:, mode: "complete", deduplication:)
|
|
59
|
+
prove("the second delivery completed and acknowledged") { recovery == 0 }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
sink_off = File.join(directory, "sink-dedup-off.json")
|
|
64
|
+
stage_one_delivery("dedup-off")
|
|
65
|
+
crash_then_recover(database_path:, sink_path: sink_off, deduplication: "off")
|
|
66
|
+
deliveries = AtLeastOnceSink.read(sink_off)
|
|
67
|
+
effect_ids = deliveries.map { |delivery| delivery.fetch("effect_id") }
|
|
68
|
+
state_off = SolidObjects::Instance.find_by!(actor_id: "dedup-off").state.fetch("count")
|
|
69
|
+
prove("the state commit happened exactly once") { state_off == 1 }
|
|
70
|
+
prove("the sink observed the duplicate") { deliveries.length == 2 }
|
|
71
|
+
prove("both deliveries carried the same stable effect id") { effect_ids.uniq.length == 1 }
|
|
72
|
+
|
|
73
|
+
sink_on = File.join(directory, "sink-dedup-on.json")
|
|
74
|
+
stage_one_delivery("dedup-on")
|
|
75
|
+
crash_then_recover(database_path:, sink_path: sink_on, deduplication: "on")
|
|
76
|
+
guarded = AtLeastOnceSink.read(sink_on)
|
|
77
|
+
state_on = SolidObjects::Instance.find_by!(actor_id: "dedup-on").state.fetch("count")
|
|
78
|
+
prove("the state commit happened exactly once") { state_on == 1 }
|
|
79
|
+
prove("the stable effect id absorbed the duplicate") { guarded.length == 1 }
|
|
80
|
+
|
|
81
|
+
puts JSON.pretty_generate(
|
|
82
|
+
duplicate: {
|
|
83
|
+
state_commits: state_off,
|
|
84
|
+
sink_deliveries: deliveries.length,
|
|
85
|
+
same_effect_id: effect_ids.uniq.length == 1,
|
|
86
|
+
attempts: deliveries.map { |delivery| delivery.fetch("attempt") }
|
|
87
|
+
},
|
|
88
|
+
remedy: { state_commits: state_on, sink_deliveries: guarded.length }
|
|
89
|
+
)
|
|
90
|
+
ensure
|
|
91
|
+
FileUtils.remove_entry(directory) if directory
|
|
92
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require_relative "boot"
|
|
4
|
+
require_relative "actor"
|
|
5
|
+
require_relative "sink"
|
|
6
|
+
|
|
7
|
+
database_path, sink_path, mode, deduplication = ARGV
|
|
8
|
+
raise ArgumentError, "usage: effect_worker.rb DATABASE SINK crash|complete on|off" unless deduplication
|
|
9
|
+
|
|
10
|
+
AtLeastOnceBoot.call(database_path.to_s)
|
|
11
|
+
|
|
12
|
+
SolidObjects.register_effect(:record) do |_arguments, context|
|
|
13
|
+
AtLeastOnceSink.record(
|
|
14
|
+
path: sink_path.to_s,
|
|
15
|
+
effect_id: context.id,
|
|
16
|
+
attempt: context.attempt,
|
|
17
|
+
deduplication: deduplication.to_sym
|
|
18
|
+
)
|
|
19
|
+
# A crash between the external write and the acknowledgement: the sink
|
|
20
|
+
# has the delivery, the effect row never completes.
|
|
21
|
+
Process.exit!(1) if mode == "crash"
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Production runs this on the dead-process-cleanup interval; the demo runs
|
|
26
|
+
# it once, after the liveness threshold, to release the crashed claim.
|
|
27
|
+
SolidObjects::ProcessRegistry.cleanup_dead
|
|
28
|
+
|
|
29
|
+
effect_executor = SolidObjects::EffectExecutor.new
|
|
30
|
+
begin
|
|
31
|
+
worked = false
|
|
32
|
+
200.times do
|
|
33
|
+
worked = effect_executor.run_once
|
|
34
|
+
break if worked
|
|
35
|
+
sleep 0.01
|
|
36
|
+
end
|
|
37
|
+
raise "no effect became claimable" unless worked
|
|
38
|
+
ensure
|
|
39
|
+
effect_executor.stop
|
|
40
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# The external system in the at-least-once demo: a JSON file that records
|
|
6
|
+
# every delivery it accepts. With deduplication :off it accepts everything,
|
|
7
|
+
# which makes an at-least-once duplicate visible. With deduplication :on it
|
|
8
|
+
# accepts each stable effect id once, which is the documented remedy.
|
|
9
|
+
module AtLeastOnceSink
|
|
10
|
+
# @rbs (String path) -> Array[Hash[String, untyped]]
|
|
11
|
+
def self.read(path)
|
|
12
|
+
JSON.parse(File.read(path))
|
|
13
|
+
rescue Errno::ENOENT
|
|
14
|
+
[]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @rbs (path: String, effect_id: String, attempt: Integer, deduplication: Symbol) -> bool
|
|
18
|
+
def self.record(path:, effect_id:, attempt:, deduplication:)
|
|
19
|
+
deliveries = read(path)
|
|
20
|
+
seen = deliveries.any? { |delivery| delivery.fetch("effect_id") == effect_id }
|
|
21
|
+
return false if deduplication == :on && seen
|
|
22
|
+
|
|
23
|
+
deliveries << { "effect_id" => effect_id, "attempt" => attempt }
|
|
24
|
+
File.write(path, JSON.pretty_generate(deliveries))
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -4,6 +4,14 @@ require "action_cable"
|
|
|
4
4
|
|
|
5
5
|
module SolidObjects
|
|
6
6
|
class ActorChannel < ActionCable::Channel::Base
|
|
7
|
+
REJECT_REASONS = {
|
|
8
|
+
UnknownActorType => "unregistered_actor_type",
|
|
9
|
+
InvalidStreamToken => "invalid_stream_token",
|
|
10
|
+
InvalidComponentToken => "invalid_component_token",
|
|
11
|
+
JSON::ParserError => "malformed_component_registration",
|
|
12
|
+
KeyError => "missing_subscription_parameter"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
7
15
|
# @rbs () -> void
|
|
8
16
|
def subscribed
|
|
9
17
|
identity = StreamToken.verify(params.fetch("token"))
|
|
@@ -15,7 +23,9 @@ module SolidObjects
|
|
|
15
23
|
actor_id:,
|
|
16
24
|
authorization_context: connection
|
|
17
25
|
)
|
|
18
|
-
|
|
26
|
+
unless authorized
|
|
27
|
+
return reject_and_report("unauthorized", actor_type:, actor_id:)
|
|
28
|
+
end
|
|
19
29
|
|
|
20
30
|
@reference = Reference.new(actor_type:, actor_id:)
|
|
21
31
|
@scalar_observables = identity["observables"]
|
|
@@ -43,8 +53,8 @@ module SolidObjects
|
|
|
43
53
|
JSON::ParserError,
|
|
44
54
|
InvalidStreamToken,
|
|
45
55
|
InvalidComponentToken,
|
|
46
|
-
UnknownActorType
|
|
47
|
-
|
|
56
|
+
UnknownActorType => error
|
|
57
|
+
reject_and_report(reject_reason(error), actor_type:, actor_id:, error:)
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
private
|
|
@@ -54,6 +64,26 @@ module SolidObjects
|
|
|
54
64
|
:scalar_observables,
|
|
55
65
|
:payload_names
|
|
56
66
|
|
|
67
|
+
# The exception message stays out of the payload, because a component or
|
|
68
|
+
# payload error can carry actor state into logs.
|
|
69
|
+
# @rbs (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void
|
|
70
|
+
def reject_and_report(reason, actor_type:, actor_id:, error: nil)
|
|
71
|
+
SolidObjects.instrument(
|
|
72
|
+
:"subscription.rejected",
|
|
73
|
+
reason:,
|
|
74
|
+
actor_type:,
|
|
75
|
+
actor_id:,
|
|
76
|
+
error_class: error&.class&.name
|
|
77
|
+
)
|
|
78
|
+
reject
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @rbs (Exception) -> String
|
|
82
|
+
def reject_reason(error)
|
|
83
|
+
match = REJECT_REASONS.find { |error_class, _| error.is_a?(error_class) }
|
|
84
|
+
match ? match.last : "invalid_subscription"
|
|
85
|
+
end
|
|
86
|
+
|
|
57
87
|
# @rbs (String) -> void
|
|
58
88
|
def receive_broadcast(stream)
|
|
59
89
|
invalidation = TurboStreamRenderer.invalidation(stream)
|
data/lib/solid_objects/engine.rb
CHANGED
|
@@ -15,6 +15,10 @@ module SolidObjects
|
|
|
15
15
|
SolidObjects::LogSubscriber.install
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
initializer "solid_objects.actors" do |application|
|
|
19
|
+
application.config.to_prepare { ApplicationActorLoader.new.call }
|
|
20
|
+
end
|
|
21
|
+
|
|
18
22
|
initializer "solid_objects.database", after: :load_config_initializers do
|
|
19
23
|
ActiveSupport.on_load(:active_record) do
|
|
20
24
|
require RECORD_PATH
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class ActorChannel < ActionCable::Channel::Base
|
|
5
|
+
REJECT_REASONS: untyped
|
|
6
|
+
|
|
5
7
|
# @rbs () -> void
|
|
6
8
|
def subscribed: () -> void
|
|
7
9
|
|
|
@@ -15,6 +17,14 @@ module SolidObjects
|
|
|
15
17
|
|
|
16
18
|
attr_reader payload_names: untyped
|
|
17
19
|
|
|
20
|
+
# The exception message stays out of the payload, because a component or
|
|
21
|
+
# payload error can carry actor state into logs.
|
|
22
|
+
# @rbs (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void
|
|
23
|
+
def reject_and_report: (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void
|
|
24
|
+
|
|
25
|
+
# @rbs (Exception) -> String
|
|
26
|
+
def reject_reason: (Exception) -> String
|
|
27
|
+
|
|
18
28
|
# @rbs (String) -> void
|
|
19
29
|
def receive_broadcast: (String) -> void
|
|
20
30
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_objects
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.14.
|
|
4
|
+
version: 0.14.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucas Carlson
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actioncable
|
|
@@ -371,6 +371,11 @@ files:
|
|
|
371
371
|
- examples/application/app/views/chat_rooms/show.html.erb
|
|
372
372
|
- examples/application/config/initializers/solid_objects.rb
|
|
373
373
|
- examples/application/config/routes.rb
|
|
374
|
+
- examples/at_least_once/actor.rb
|
|
375
|
+
- examples/at_least_once/boot.rb
|
|
376
|
+
- examples/at_least_once/demo.rb
|
|
377
|
+
- examples/at_least_once/effect_worker.rb
|
|
378
|
+
- examples/at_least_once/sink.rb
|
|
374
379
|
- exe/solid_objects
|
|
375
380
|
- lib/generators/solid_objects/install_generator.rb
|
|
376
381
|
- lib/generators/solid_objects/templates/solid_objects.rb
|