solid_objects 0.13.3 → 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 +79 -0
- data/README.md +19 -11
- data/Rakefile +5 -0
- data/app/controllers/solid_objects/transmissions_controller.rb +32 -0
- data/config/routes.rb +1 -0
- data/docs/correctness.md +9 -0
- data/docs/operations.md +23 -1
- data/docs/research/solid_queue.md +2 -2
- data/docs/roadmap.md +25 -2
- data/docs/state-migrations.md +1 -1
- data/docs/transmission.md +198 -0
- 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/generators/solid_objects/templates/solid_objects.rb +17 -1
- data/lib/solid_objects/actor.rb +11 -0
- data/lib/solid_objects/actor_channel.rb +33 -3
- data/lib/solid_objects/configuration.rb +7 -1
- data/lib/solid_objects/engine.rb +4 -0
- data/lib/solid_objects/errors.rb +3 -0
- data/lib/solid_objects/transmission.rb +139 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +16 -0
- data/sig/generated/controllers/solid_objects/transmissions_controller.rbs +13 -0
- data/sig/generated/lib/solid_objects/actor.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +10 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
- data/sig/generated/lib/solid_objects/errors.rbs +3 -0
- data/sig/generated/lib/solid_objects/transmission.rbs +34 -0
- data/sig/generated/lib/solid_objects.rbs +3 -0
- data/sig/support/framework.rbs +3 -0
- metadata +16 -6
|
@@ -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
|
|
@@ -37,7 +37,7 @@ SolidObjects.configure do |configuration|
|
|
|
37
37
|
#
|
|
38
38
|
# Prefer policies that bind actor_type and actor_id to a trusted
|
|
39
39
|
# authorization_context. See:
|
|
40
|
-
# https://github.com/cardmagic/
|
|
40
|
+
# https://github.com/cardmagic/solid-objects-ruby/blob/main/docs/authorization.md
|
|
41
41
|
# and run:
|
|
42
42
|
#
|
|
43
43
|
# bin/rails solid_objects:doctor
|
|
@@ -49,6 +49,22 @@ SolidObjects.configure do |configuration|
|
|
|
49
49
|
configuration.authorize_subscription = ->(**) { false }
|
|
50
50
|
configuration.authorize_administration = ->(**) { false }
|
|
51
51
|
|
|
52
|
+
# The engine route POST /solid_objects/transmit ingests transmit envelopes
|
|
53
|
+
# from another Solid Objects runtime. It stays denied until its callers are
|
|
54
|
+
# authenticated, because the ingest skips authorize_message by design:
|
|
55
|
+
#
|
|
56
|
+
# configuration.authorize_transmission = lambda do |envelope:, authorization_context:|
|
|
57
|
+
# ActiveSupport::SecurityUtils.secure_compare(
|
|
58
|
+
# authorization_context.request.headers["Authorization"].to_s,
|
|
59
|
+
# "Bearer #{Rails.application.credentials.transmit_token}"
|
|
60
|
+
# )
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# When the sending runtime names actor types differently, map them here:
|
|
64
|
+
#
|
|
65
|
+
# configuration.transmission_actor_type_resolver = ->(actor_type) { actor_type.sub("browser-", "server-") }
|
|
66
|
+
configuration.authorize_transmission = ->(**) { false }
|
|
67
|
+
|
|
52
68
|
# Configure component_authorization_context to return the authenticated
|
|
53
69
|
# principal used for reactive component refreshes.
|
|
54
70
|
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -190,6 +190,17 @@ module SolidObjects
|
|
|
190
190
|
nil
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
+
# @rbs () -> OperationDispatcher
|
|
194
|
+
def transmit
|
|
195
|
+
OperationDispatcher.new(
|
|
196
|
+
actor_type: self.class.actor_type,
|
|
197
|
+
handlers: self.class.definition.messages
|
|
198
|
+
) do |operation, arguments|
|
|
199
|
+
emit(Transmission::EFFECT_NAME, operation: operation.to_s, arguments:)
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
193
204
|
# @rbs (Symbol | String, **untyped) -> nil
|
|
194
205
|
def commit_action(name, **arguments)
|
|
195
206
|
CommitActionIntent.new(
|
|
@@ -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)
|
|
@@ -47,6 +47,8 @@ module SolidObjects
|
|
|
47
47
|
# @rbs @authorize_destroy: Proc
|
|
48
48
|
# @rbs @authorize_subscription: Proc
|
|
49
49
|
# @rbs @authorize_administration: Proc
|
|
50
|
+
# @rbs @authorize_transmission: Proc
|
|
51
|
+
# @rbs @transmission_actor_type_resolver: Proc
|
|
50
52
|
|
|
51
53
|
attr_accessor :table_name_prefix,
|
|
52
54
|
:polling_interval,
|
|
@@ -92,7 +94,9 @@ module SolidObjects
|
|
|
92
94
|
:authorize_query,
|
|
93
95
|
:authorize_destroy,
|
|
94
96
|
:authorize_subscription,
|
|
95
|
-
:authorize_administration
|
|
97
|
+
:authorize_administration,
|
|
98
|
+
:authorize_transmission,
|
|
99
|
+
:transmission_actor_type_resolver
|
|
96
100
|
|
|
97
101
|
# @rbs @additional_components: Array[untyped]
|
|
98
102
|
attr_reader :additional_components
|
|
@@ -148,6 +152,8 @@ module SolidObjects
|
|
|
148
152
|
@authorize_destroy = ->(**) { false }
|
|
149
153
|
@authorize_subscription = ->(**) { false }
|
|
150
154
|
@authorize_administration = ->(**) { false }
|
|
155
|
+
@authorize_transmission = ->(**) { false }
|
|
156
|
+
@transmission_actor_type_resolver = ->(actor_type) { actor_type }
|
|
151
157
|
@additional_components = []
|
|
152
158
|
end
|
|
153
159
|
|
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
|
data/lib/solid_objects/errors.rb
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
module Transmission
|
|
5
|
+
REQUIRED_FIELDS = %w[effectId actorType actorId operation].freeze
|
|
6
|
+
IDEMPOTENCY_PREFIX = "transmit:"
|
|
7
|
+
EFFECT_NAME = "solid-objects.transmit"
|
|
8
|
+
UNDELIVERED_STATUSES = %w[pending processing].freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# @rbs (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox, ?actor_loader: ^() -> bool) -> MessageReference
|
|
12
|
+
def receive(envelope, resolve_actor_type: :itself.to_proc, mailbox: Mailbox.new, actor_loader: method(:load_application_actors))
|
|
13
|
+
validate!(envelope)
|
|
14
|
+
|
|
15
|
+
actor_type = resolve_actor_type.call(envelope["actorType"]).to_s
|
|
16
|
+
actor_class = fetch_actor_class(actor_type, actor_loader)
|
|
17
|
+
operation = envelope["operation"].to_sym
|
|
18
|
+
unless actor_class.definition.messages.key?(operation)
|
|
19
|
+
raise UnknownMessage, "unknown operation #{envelope["operation"].inspect}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
mailbox.enqueue(
|
|
23
|
+
reference: Reference.new(actor_type:, actor_id: envelope["actorId"]),
|
|
24
|
+
operation:,
|
|
25
|
+
arguments: envelope.fetch("arguments", {}),
|
|
26
|
+
delivery_mode: "internal",
|
|
27
|
+
idempotency_key: "#{IDEMPOTENCY_PREFIX}#{envelope["effectId"]}"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @rbs (effect_name: String, arguments: Hash[String, untyped], context: EffectContext, deliver: Proc) -> nil
|
|
32
|
+
def deliver_through(effect_name:, arguments:, context:, deliver:)
|
|
33
|
+
staged_envelope(
|
|
34
|
+
arguments,
|
|
35
|
+
effect_id: context.id,
|
|
36
|
+
actor_type: context.actor_type,
|
|
37
|
+
actor_id: context.actor_id
|
|
38
|
+
)
|
|
39
|
+
undelivered_envelopes_through(effect_name:, context:).each do |envelope|
|
|
40
|
+
deliver.call(envelope)
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# @rbs (String, ^() -> bool) -> Class
|
|
48
|
+
def fetch_actor_class(actor_type, actor_loader)
|
|
49
|
+
SolidObjects.registry.fetch(actor_type)
|
|
50
|
+
rescue UnknownActorType
|
|
51
|
+
raise unless actor_loader.call
|
|
52
|
+
|
|
53
|
+
SolidObjects.registry.fetch(actor_type)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @rbs () -> bool
|
|
57
|
+
def load_application_actors
|
|
58
|
+
return false unless defined?(Rails.application) && Rails.application
|
|
59
|
+
|
|
60
|
+
ApplicationActorLoader.new.call
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @rbs (Hash[String, untyped], effect_id: String, actor_type: String, actor_id: String) -> Hash[String, untyped]
|
|
65
|
+
def staged_envelope(arguments, effect_id:, actor_type:, actor_id:)
|
|
66
|
+
operation = arguments["operation"]
|
|
67
|
+
unless operation.is_a?(String) && !operation.empty?
|
|
68
|
+
raise InvalidTransmission, "transmit effect arguments require a non-empty operation"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
target_arguments = arguments["arguments"]
|
|
72
|
+
target_arguments = {} if target_arguments.nil?
|
|
73
|
+
unless target_arguments.is_a?(Hash)
|
|
74
|
+
raise InvalidTransmission, %(transmit effect arguments must hold a JSON object in "arguments")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
target_type = arguments["actorType"].nil? ? actor_type : arguments["actorType"]
|
|
78
|
+
target_id = arguments["actorId"].nil? ? actor_id : arguments["actorId"]
|
|
79
|
+
{ "actorType" => target_type, "actorId" => target_id }.each do |field, value|
|
|
80
|
+
next if value.is_a?(String) && !value.empty?
|
|
81
|
+
|
|
82
|
+
raise InvalidTransmission, "transmit effect #{field} must be a non-empty string"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
"effectId" => effect_id,
|
|
87
|
+
"actorType" => target_type,
|
|
88
|
+
"actorId" => target_id,
|
|
89
|
+
"operation" => operation,
|
|
90
|
+
"arguments" => target_arguments
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @rbs (effect_name: String, context: EffectContext) -> Array[Hash[String, untyped]]
|
|
95
|
+
def undelivered_envelopes_through(effect_name:, context:)
|
|
96
|
+
source_sequence = Message.find(context.source_message_id).sequence
|
|
97
|
+
effects = Effect
|
|
98
|
+
.joins(:message)
|
|
99
|
+
.where(name: effect_name, status: UNDELIVERED_STATUSES)
|
|
100
|
+
.merge(
|
|
101
|
+
Message.where(
|
|
102
|
+
actor_type: context.actor_type,
|
|
103
|
+
actor_id: context.actor_id,
|
|
104
|
+
sequence: ..source_sequence
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
.order(Message.arel_table[:sequence].asc, :id)
|
|
108
|
+
|
|
109
|
+
effects.filter_map do |effect|
|
|
110
|
+
staged_envelope(
|
|
111
|
+
effect.arguments,
|
|
112
|
+
effect_id: effect.effect_id,
|
|
113
|
+
actor_type: context.actor_type,
|
|
114
|
+
actor_id: context.actor_id
|
|
115
|
+
)
|
|
116
|
+
rescue InvalidTransmission
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @rbs (untyped) -> void
|
|
122
|
+
def validate!(envelope)
|
|
123
|
+
raise InvalidTransmission, "envelope must be a JSON object" unless envelope.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
REQUIRED_FIELDS.each do |field|
|
|
126
|
+
value = envelope[field]
|
|
127
|
+
next if value.is_a?(String) && !value.empty?
|
|
128
|
+
|
|
129
|
+
raise InvalidTransmission, "envelope field #{field.inspect} must be a non-empty string"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
arguments = envelope.fetch("arguments", {})
|
|
133
|
+
return if arguments.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
raise InvalidTransmission, %(envelope field "arguments" must be a JSON object)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/solid_objects.rb
CHANGED
|
@@ -63,6 +63,8 @@ require "solid_objects/lease_renewer"
|
|
|
63
63
|
# was reachable only through the caller path, so requiring the gem was not
|
|
64
64
|
# enough to run a role that uses it.
|
|
65
65
|
require "solid_objects/mailbox"
|
|
66
|
+
require "solid_objects/application_actor_loader"
|
|
67
|
+
require "solid_objects/transmission"
|
|
66
68
|
require "solid_objects/worker"
|
|
67
69
|
require "solid_objects/effect_executor"
|
|
68
70
|
require "solid_objects/reminder_scheduler"
|
|
@@ -100,6 +102,20 @@ module SolidObjects
|
|
|
100
102
|
effect_registry.register(name, handler)
|
|
101
103
|
end
|
|
102
104
|
|
|
105
|
+
# @rbs (?effect_name: String | Symbol) { (Hash[String, untyped]) -> untyped } -> Proc
|
|
106
|
+
def register_transmit(effect_name: Transmission::EFFECT_NAME, &deliver)
|
|
107
|
+
raise ArgumentError, "register_transmit requires a delivery block" unless deliver
|
|
108
|
+
|
|
109
|
+
register_effect(effect_name) do |arguments, context|
|
|
110
|
+
Transmission.deliver_through(
|
|
111
|
+
effect_name: effect_name.to_s,
|
|
112
|
+
arguments:,
|
|
113
|
+
context:,
|
|
114
|
+
deliver:
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
103
119
|
# @rbs () -> CommitActionRegistry
|
|
104
120
|
def commit_action_registry
|
|
105
121
|
@commit_action_registry ||= CommitActionRegistry.new
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Generated from app/controllers/solid_objects/transmissions_controller.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class TransmissionsController < ActionController::API
|
|
5
|
+
# @rbs () -> void
|
|
6
|
+
def create: () -> void
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# @rbs (untyped) -> bool
|
|
11
|
+
def authorized_transmission?: (untyped) -> bool
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -161,6 +161,9 @@ module SolidObjects
|
|
|
161
161
|
# @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> nil
|
|
162
162
|
def emit: (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> nil
|
|
163
163
|
|
|
164
|
+
# @rbs () -> OperationDispatcher
|
|
165
|
+
def transmit: () -> OperationDispatcher
|
|
166
|
+
|
|
164
167
|
# @rbs (Symbol | String, **untyped) -> nil
|
|
165
168
|
def commit_action: (Symbol | String, **untyped) -> nil
|
|
166
169
|
|
|
@@ -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
|
|
|
@@ -4,8 +4,6 @@ module SolidObjects
|
|
|
4
4
|
class Configuration
|
|
5
5
|
@table_name_prefix: String
|
|
6
6
|
|
|
7
|
-
@process_alive_threshold: Float
|
|
8
|
-
|
|
9
7
|
@shutdown_timeout: Float
|
|
10
8
|
|
|
11
9
|
@supervisor_monitor_interval: Float
|
|
@@ -58,6 +56,10 @@ module SolidObjects
|
|
|
58
56
|
|
|
59
57
|
@authorize_administration: Proc
|
|
60
58
|
|
|
59
|
+
@authorize_transmission: Proc
|
|
60
|
+
|
|
61
|
+
@transmission_actor_type_resolver: Proc
|
|
62
|
+
|
|
61
63
|
@polling_interval: Float
|
|
62
64
|
|
|
63
65
|
@idle_polling_interval: Float
|
|
@@ -92,6 +94,8 @@ module SolidObjects
|
|
|
92
94
|
|
|
93
95
|
@process_heartbeat_interval: Float
|
|
94
96
|
|
|
97
|
+
@process_alive_threshold: Float
|
|
98
|
+
|
|
95
99
|
attr_accessor table_name_prefix: untyped
|
|
96
100
|
|
|
97
101
|
attr_accessor polling_interval: untyped
|
|
@@ -182,6 +186,10 @@ module SolidObjects
|
|
|
182
186
|
|
|
183
187
|
attr_accessor authorize_administration: untyped
|
|
184
188
|
|
|
189
|
+
attr_accessor authorize_transmission: untyped
|
|
190
|
+
|
|
191
|
+
attr_accessor transmission_actor_type_resolver: untyped
|
|
192
|
+
|
|
185
193
|
# @rbs @additional_components: Array[untyped]
|
|
186
194
|
attr_reader additional_components: untyped
|
|
187
195
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Generated from lib/solid_objects/transmission.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
module Transmission
|
|
5
|
+
REQUIRED_FIELDS: untyped
|
|
6
|
+
|
|
7
|
+
IDEMPOTENCY_PREFIX: ::String
|
|
8
|
+
|
|
9
|
+
EFFECT_NAME: ::String
|
|
10
|
+
|
|
11
|
+
UNDELIVERED_STATUSES: untyped
|
|
12
|
+
|
|
13
|
+
# @rbs (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox, ?actor_loader: ^() -> bool) -> MessageReference
|
|
14
|
+
def self.receive: (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox, ?actor_loader: ^() -> bool) -> MessageReference
|
|
15
|
+
|
|
16
|
+
# @rbs (effect_name: String, arguments: Hash[String, untyped], context: EffectContext, deliver: Proc) -> nil
|
|
17
|
+
def self.deliver_through: (effect_name: String, arguments: Hash[String, untyped], context: EffectContext, deliver: Proc) -> nil
|
|
18
|
+
|
|
19
|
+
# @rbs (String, ^() -> bool) -> Class
|
|
20
|
+
private def self.fetch_actor_class: (String, ^() -> bool) -> Class
|
|
21
|
+
|
|
22
|
+
# @rbs () -> bool
|
|
23
|
+
private def self.load_application_actors: () -> bool
|
|
24
|
+
|
|
25
|
+
# @rbs (Hash[String, untyped], effect_id: String, actor_type: String, actor_id: String) -> Hash[String, untyped]
|
|
26
|
+
private def self.staged_envelope: (Hash[String, untyped], effect_id: String, actor_type: String, actor_id: String) -> Hash[String, untyped]
|
|
27
|
+
|
|
28
|
+
# @rbs (effect_name: String, context: EffectContext) -> Array[Hash[String, untyped]]
|
|
29
|
+
private def self.undelivered_envelopes_through: (effect_name: String, context: EffectContext) -> Array[Hash[String, untyped]]
|
|
30
|
+
|
|
31
|
+
# @rbs (untyped) -> void
|
|
32
|
+
private def self.validate!: (untyped) -> void
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -18,6 +18,9 @@ module SolidObjects
|
|
|
18
18
|
# @rbs (String | Symbol) { (Hash[String, untyped], EffectContext) -> untyped } -> Proc
|
|
19
19
|
def self.register_effect: (String | Symbol) { (Hash[String, untyped], EffectContext) -> untyped } -> Proc
|
|
20
20
|
|
|
21
|
+
# @rbs (?effect_name: String | Symbol) { (Hash[String, untyped]) -> untyped } -> Proc
|
|
22
|
+
def self.register_transmit: (?effect_name: String | Symbol) { (Hash[String, untyped]) -> untyped } -> Proc
|
|
23
|
+
|
|
21
24
|
# @rbs () -> CommitActionRegistry
|
|
22
25
|
def self.commit_action_registry: () -> CommitActionRegistry
|
|
23
26
|
|