solid_objects 0.1.0
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 +7 -0
- data/CHANGELOG.md +17 -0
- data/MIT-LICENSE +19 -0
- data/README.md +744 -0
- data/Rakefile +40 -0
- data/app/controllers/solid_objects/application_controller.rb +23 -0
- data/app/controllers/solid_objects/dead_letters_controller.rb +23 -0
- data/app/controllers/solid_objects/instances_controller.rb +29 -0
- data/app/helpers/solid_objects/actor_helper.rb +25 -0
- data/app/models/solid_objects/broadcast.rb +10 -0
- data/app/models/solid_objects/claimed_message.rb +14 -0
- data/app/models/solid_objects/dead_letter.rb +13 -0
- data/app/models/solid_objects/effect.rb +10 -0
- data/app/models/solid_objects/instance.rb +93 -0
- data/app/models/solid_objects/message.rb +53 -0
- data/app/models/solid_objects/process.rb +13 -0
- data/app/models/solid_objects/ready_message.rb +10 -0
- data/app/models/solid_objects/record.rb +17 -0
- data/app/models/solid_objects/reminder.rb +9 -0
- data/app/views/solid_objects/dead_letters/index.html.erb +26 -0
- data/app/views/solid_objects/instances/index.html.erb +24 -0
- data/app/views/solid_objects/instances/show.html.erb +35 -0
- data/benchmark/activation_cache.rb +5 -0
- data/benchmark/ask_latency.rb +5 -0
- data/benchmark/claim.rb +5 -0
- data/benchmark/cold_actors.rb +5 -0
- data/benchmark/concurrent_actors.rb +5 -0
- data/benchmark/enqueue.rb +5 -0
- data/benchmark/hot_actor.rb +5 -0
- data/benchmark/processing.rb +5 -0
- data/benchmark/query_count.rb +5 -0
- data/benchmark/support.rb +271 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20260805000000_create_solid_objects_tables.rb +319 -0
- data/docs/adr/0001-postgresql-backend.md +21 -0
- data/docs/adr/0002-jsonb-actor-state.md +21 -0
- data/docs/adr/0003-mailbox-ordering.md +30 -0
- data/docs/adr/0004-activation-leasing.md +21 -0
- data/docs/adr/0005-fencing-tokens.md +25 -0
- data/docs/adr/0006-at-least-once-delivery.md +24 -0
- data/docs/adr/0007-transactional-outbox.md +21 -0
- data/docs/adr/0008-actor-communication.md +21 -0
- data/docs/adr/0009-realtime-updates.md +21 -0
- data/docs/adr/0010-state-versioning.md +29 -0
- data/docs/adr/0011-wake-up-strategy.md +34 -0
- data/docs/adr/0012-not-active-jobs.md +21 -0
- data/docs/adr/0013-database-adapters.md +48 -0
- data/docs/architecture.md +615 -0
- data/docs/benchmarks.md +26 -0
- data/docs/correctness.md +124 -0
- data/docs/database-schema.md +111 -0
- data/docs/development.md +87 -0
- data/docs/implementation-plan.md +518 -0
- data/docs/operations.md +123 -0
- data/docs/realtime.md +51 -0
- data/docs/research/solid_queue.md +545 -0
- data/docs/roadmap.md +53 -0
- data/docs/security.md +61 -0
- data/docs/state-migrations.md +46 -0
- data/examples/application/README.md +16 -0
- data/examples/application/app/actors/chat_room_actor.rb +34 -0
- data/examples/application/app/actors/shopping_cart_actor.rb +79 -0
- data/examples/application/app/controllers/cart_controller.rb +54 -0
- data/examples/application/app/controllers/chat_rooms_controller.rb +44 -0
- data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +8 -0
- data/examples/application/app/views/actors/shopping_cart_actor/_summary.html.erb +10 -0
- data/examples/application/app/views/cart/show.html.erb +13 -0
- data/examples/application/app/views/chat_rooms/show.html.erb +8 -0
- data/examples/application/config/initializers/solid_objects.rb +23 -0
- data/examples/application/config/routes.rb +20 -0
- data/exe/solid_objects +9 -0
- data/lib/generators/solid_objects/install_generator.rb +21 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +13 -0
- data/lib/solid_objects/action_cable_broadcast_adapter.rb +19 -0
- data/lib/solid_objects/activation.rb +183 -0
- data/lib/solid_objects/activation_manager.rb +102 -0
- data/lib/solid_objects/actor.rb +271 -0
- data/lib/solid_objects/actor_channel.rb +29 -0
- data/lib/solid_objects/actor_definition.rb +212 -0
- data/lib/solid_objects/actor_registry.rb +65 -0
- data/lib/solid_objects/actor_snapshot.rb +42 -0
- data/lib/solid_objects/actor_view.rb +117 -0
- data/lib/solid_objects/broadcast_executor.rb +162 -0
- data/lib/solid_objects/cli.rb +118 -0
- data/lib/solid_objects/client.rb +153 -0
- data/lib/solid_objects/configuration.rb +168 -0
- data/lib/solid_objects/context.rb +41 -0
- data/lib/solid_objects/database_adapter.rb +82 -0
- data/lib/solid_objects/database_adapters/mysql.rb +22 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +17 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +12 -0
- data/lib/solid_objects/dead_letter_manager.rb +47 -0
- data/lib/solid_objects/dom_identity.rb +38 -0
- data/lib/solid_objects/effect_executor.rb +235 -0
- data/lib/solid_objects/effect_registry.rb +34 -0
- data/lib/solid_objects/engine.rb +33 -0
- data/lib/solid_objects/errors.rb +65 -0
- data/lib/solid_objects/executor.rb +290 -0
- data/lib/solid_objects/instrumentation.rb +10 -0
- data/lib/solid_objects/lease.rb +172 -0
- data/lib/solid_objects/lease_renewer.rb +70 -0
- data/lib/solid_objects/log_subscriber.rb +29 -0
- data/lib/solid_objects/mailbox.rb +178 -0
- data/lib/solid_objects/message_reference.rb +52 -0
- data/lib/solid_objects/process_registry.rb +143 -0
- data/lib/solid_objects/reference.rb +96 -0
- data/lib/solid_objects/reminder_scheduler.rb +168 -0
- data/lib/solid_objects/serialization.rb +99 -0
- data/lib/solid_objects/state.rb +111 -0
- data/lib/solid_objects/stream_name.rb +29 -0
- data/lib/solid_objects/stream_token.rb +59 -0
- data/lib/solid_objects/supervisor.rb +87 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +35 -0
- data/lib/solid_objects/version.rb +5 -0
- data/lib/solid_objects/wake_up.rb +28 -0
- data/lib/solid_objects/worker.rb +139 -0
- data/lib/solid_objects.rb +118 -0
- data/sig/generated/controllers/solid_objects/application_controller.rbs +10 -0
- data/sig/generated/controllers/solid_objects/dead_letters_controller.rbs +11 -0
- data/sig/generated/controllers/solid_objects/instances_controller.rbs +11 -0
- data/sig/generated/helpers/solid_objects/actor_helper.rbs +8 -0
- data/sig/generated/lib/generators/solid_objects/install_generator.rbs +13 -0
- data/sig/generated/lib/solid_objects/action_cable_broadcast_adapter.rbs +8 -0
- data/sig/generated/lib/solid_objects/activation.rbs +65 -0
- data/sig/generated/lib/solid_objects/activation_manager.rbs +36 -0
- data/sig/generated/lib/solid_objects/actor.rbs +183 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +8 -0
- data/sig/generated/lib/solid_objects/actor_definition.rbs +117 -0
- data/sig/generated/lib/solid_objects/actor_registry.rbs +36 -0
- data/sig/generated/lib/solid_objects/actor_snapshot.rbs +28 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +56 -0
- data/sig/generated/lib/solid_objects/broadcast_executor.rbs +55 -0
- data/sig/generated/lib/solid_objects/cli.rbs +31 -0
- data/sig/generated/lib/solid_objects/client.rbs +35 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +147 -0
- data/sig/generated/lib/solid_objects/context.rbs +56 -0
- data/sig/generated/lib/solid_objects/database_adapter.rbs +42 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +16 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +13 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +10 -0
- data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +16 -0
- data/sig/generated/lib/solid_objects/dom_identity.rbs +20 -0
- data/sig/generated/lib/solid_objects/effect_executor.rbs +82 -0
- data/sig/generated/lib/solid_objects/effect_registry.rbs +24 -0
- data/sig/generated/lib/solid_objects/engine.rbs +7 -0
- data/sig/generated/lib/solid_objects/errors.rbs +64 -0
- data/sig/generated/lib/solid_objects/executor.rbs +60 -0
- data/sig/generated/lib/solid_objects/instrumentation.rbs +8 -0
- data/sig/generated/lib/solid_objects/lease.rbs +57 -0
- data/sig/generated/lib/solid_objects/lease_renewer.rbs +42 -0
- data/sig/generated/lib/solid_objects/log_subscriber.rbs +11 -0
- data/sig/generated/lib/solid_objects/mailbox.rbs +46 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +37 -0
- data/sig/generated/lib/solid_objects/process_registry.rbs +46 -0
- data/sig/generated/lib/solid_objects/reference.rbs +45 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +55 -0
- data/sig/generated/lib/solid_objects/serialization.rbs +31 -0
- data/sig/generated/lib/solid_objects/state.rbs +72 -0
- data/sig/generated/lib/solid_objects/stream_name.rbs +11 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +19 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +38 -0
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +14 -0
- data/sig/generated/lib/solid_objects/version.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +24 -0
- data/sig/generated/lib/solid_objects/worker.rbs +56 -0
- data/sig/generated/lib/solid_objects.rbs +41 -0
- data/sig/generated/models/solid_objects/broadcast.rbs +6 -0
- data/sig/generated/models/solid_objects/claimed_message.rbs +6 -0
- data/sig/generated/models/solid_objects/dead_letter.rbs +6 -0
- data/sig/generated/models/solid_objects/effect.rbs +6 -0
- data/sig/generated/models/solid_objects/instance.rbs +25 -0
- data/sig/generated/models/solid_objects/message.rbs +22 -0
- data/sig/generated/models/solid_objects/process.rbs +6 -0
- data/sig/generated/models/solid_objects/ready_message.rbs +6 -0
- data/sig/generated/models/solid_objects/record.rbs +8 -0
- data/sig/generated/models/solid_objects/reminder.rbs +6 -0
- data/sig/support/framework.rbs +37 -0
- metadata +467 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class ActorSnapshot
|
|
5
|
+
# @rbs @reference: Reference
|
|
6
|
+
# @rbs @actor_class: Class
|
|
7
|
+
# @rbs @actor: Actor
|
|
8
|
+
|
|
9
|
+
attr_reader :reference, :actor_class, :actor
|
|
10
|
+
|
|
11
|
+
# @rbs (Reference) -> void
|
|
12
|
+
def initialize(reference)
|
|
13
|
+
@reference = reference
|
|
14
|
+
@actor_class = SolidObjects.registry.fetch(reference.actor_type)
|
|
15
|
+
@actor = build_actor
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @rbs () -> Hash[String, untyped]
|
|
19
|
+
def observable_values
|
|
20
|
+
actor.observable_values
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# @rbs () -> Actor
|
|
26
|
+
def build_actor
|
|
27
|
+
instance = Instance.find_by(
|
|
28
|
+
actor_type: reference.actor_type,
|
|
29
|
+
actor_id: reference.actor_id
|
|
30
|
+
)
|
|
31
|
+
state_version = instance&.state_version || actor_class.state_version
|
|
32
|
+
state_data = actor_class.definition.migrate_state(
|
|
33
|
+
state_version,
|
|
34
|
+
instance&.state || {}
|
|
35
|
+
)
|
|
36
|
+
actor_class.new(
|
|
37
|
+
actor_id: reference.actor_id,
|
|
38
|
+
state: State.new(actor_class.definition.state_definition, state_data)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class ActorView
|
|
5
|
+
# @rbs @reference: Reference
|
|
6
|
+
# @rbs @view_context: untyped
|
|
7
|
+
# @rbs @authorization_context: untyped
|
|
8
|
+
# @rbs @snapshot: ActorSnapshot
|
|
9
|
+
|
|
10
|
+
attr_reader :reference
|
|
11
|
+
|
|
12
|
+
# @rbs (reference: Reference, view_context: untyped, authorization_context: untyped) -> void
|
|
13
|
+
def initialize(reference:, view_context:, authorization_context:)
|
|
14
|
+
@reference = reference
|
|
15
|
+
@view_context = view_context
|
|
16
|
+
@authorization_context = authorization_context
|
|
17
|
+
@snapshot = ActorSnapshot.new(reference)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @rbs (Symbol | String) -> untyped
|
|
21
|
+
def value(name)
|
|
22
|
+
observable_name = name.to_sym
|
|
23
|
+
handler = snapshot.actor_class.definition.observables[observable_name]
|
|
24
|
+
raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
|
|
25
|
+
|
|
26
|
+
authorize_read!(observable_name)
|
|
27
|
+
value = snapshot.observable_values.fetch(observable_name.to_s)
|
|
28
|
+
view_context.content_tag(
|
|
29
|
+
:span,
|
|
30
|
+
display_value(value),
|
|
31
|
+
id: DomIdentity.observable(reference, observable_name)
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @rbs (Symbol | String, ?partial: String?) -> untyped
|
|
36
|
+
def component(name, partial: nil)
|
|
37
|
+
component_name = normalized_component_name(name)
|
|
38
|
+
authorize_read!(component_name)
|
|
39
|
+
rendered = view_context.render(
|
|
40
|
+
partial: partial || default_partial(component_name),
|
|
41
|
+
locals: { actor: self }
|
|
42
|
+
)
|
|
43
|
+
view_context.content_tag(
|
|
44
|
+
:div,
|
|
45
|
+
rendered,
|
|
46
|
+
id: DomIdentity.component(reference, component_name)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @rbs () -> State
|
|
51
|
+
def state
|
|
52
|
+
snapshot.actor.state
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @rbs (Symbol, *untyped, **untyped) -> untyped
|
|
56
|
+
def method_missing(name, *arguments, **keywords)
|
|
57
|
+
return value(name) if arguments.empty? && keywords.empty? && observable?(name)
|
|
58
|
+
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @rbs (Symbol, bool) -> bool
|
|
63
|
+
def respond_to_missing?(name, include_private = false)
|
|
64
|
+
observable?(name) || super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
attr_reader :view_context, :authorization_context, :snapshot
|
|
70
|
+
|
|
71
|
+
# @rbs (Symbol | String) -> void
|
|
72
|
+
def authorize_read!(name)
|
|
73
|
+
authorized = SolidObjects.configuration.authorize_query.call(
|
|
74
|
+
actor_type: reference.actor_type,
|
|
75
|
+
actor_id: reference.actor_id,
|
|
76
|
+
message_name: name.to_s,
|
|
77
|
+
arguments: {},
|
|
78
|
+
authorization_context:
|
|
79
|
+
)
|
|
80
|
+
return if authorized
|
|
81
|
+
|
|
82
|
+
raise Unauthorized, "actor state query is not authorized"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @rbs (Symbol | String) -> bool
|
|
86
|
+
def observable?(name)
|
|
87
|
+
snapshot.actor_class.definition.observables.key?(name.to_sym)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @rbs (untyped) -> String
|
|
91
|
+
def display_value(value)
|
|
92
|
+
return value if value.is_a?(String)
|
|
93
|
+
return value.to_s if value.is_a?(Numeric) || value == true || value == false
|
|
94
|
+
return "" if value.nil?
|
|
95
|
+
|
|
96
|
+
JSON.generate(value)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @rbs (Symbol | String) -> String
|
|
100
|
+
def normalized_component_name(name)
|
|
101
|
+
component_name = name.to_s
|
|
102
|
+
unless component_name.match?(/\A[a-zA-Z0-9_]+\z/)
|
|
103
|
+
raise ArgumentError, "component names may contain only letters, digits, and underscores"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
component_name
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @rbs (String) -> String
|
|
110
|
+
def default_partial(component_name)
|
|
111
|
+
actor_name = snapshot.actor_class.name
|
|
112
|
+
raise InvalidActor, "anonymous actors must provide an explicit component partial" unless actor_name
|
|
113
|
+
|
|
114
|
+
"actors/#{actor_name.underscore}/#{component_name}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class BroadcastExecutor
|
|
5
|
+
# @rbs @process_registry: ProcessRegistry
|
|
6
|
+
# @rbs @database_adapter: DatabaseAdapter
|
|
7
|
+
# @rbs @stopped: bool
|
|
8
|
+
# @rbs @shutdown_requested: bool
|
|
9
|
+
|
|
10
|
+
# @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
|
|
11
|
+
def initialize(
|
|
12
|
+
process_registry: ProcessRegistry.new,
|
|
13
|
+
database_adapter: SolidObjects.database_adapter
|
|
14
|
+
)
|
|
15
|
+
@process_registry = process_registry
|
|
16
|
+
@database_adapter = database_adapter
|
|
17
|
+
process_registry.register(kind: "broadcast")
|
|
18
|
+
@stopped = false
|
|
19
|
+
@shutdown_requested = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs () -> bool
|
|
23
|
+
def run_once
|
|
24
|
+
return false if stopped?
|
|
25
|
+
|
|
26
|
+
process_registry.heartbeat
|
|
27
|
+
broadcast = claim_next
|
|
28
|
+
return false unless broadcast
|
|
29
|
+
|
|
30
|
+
broadcast_adapter.call(broadcast)
|
|
31
|
+
complete(broadcast)
|
|
32
|
+
true
|
|
33
|
+
rescue => error
|
|
34
|
+
fail_broadcast(broadcast, error) if broadcast
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @rbs () -> void
|
|
39
|
+
def stop
|
|
40
|
+
return if stopped?
|
|
41
|
+
|
|
42
|
+
@stopped = true
|
|
43
|
+
process_registry.start_draining
|
|
44
|
+
process_registry.stop
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @rbs () -> void
|
|
48
|
+
def run
|
|
49
|
+
until shutdown_requested?
|
|
50
|
+
worked = run_once
|
|
51
|
+
next if worked
|
|
52
|
+
|
|
53
|
+
SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
|
|
54
|
+
end
|
|
55
|
+
ensure
|
|
56
|
+
stop
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @rbs () -> void
|
|
60
|
+
def request_shutdown
|
|
61
|
+
@shutdown_requested = true
|
|
62
|
+
SolidObjects.wake_up.signal
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @rbs () -> bool
|
|
66
|
+
def stopped?
|
|
67
|
+
@stopped
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @rbs () -> bool
|
|
71
|
+
def shutdown_requested?
|
|
72
|
+
@shutdown_requested
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
attr_reader :process_registry, :database_adapter
|
|
78
|
+
|
|
79
|
+
# @rbs () -> Broadcast?
|
|
80
|
+
def claim_next
|
|
81
|
+
database_adapter.transaction do
|
|
82
|
+
now = database_adapter.database_now
|
|
83
|
+
stale_at = now - SolidObjects.configuration.process_alive_threshold
|
|
84
|
+
relation = Broadcast
|
|
85
|
+
.where(status: "pending", available_at: ..now)
|
|
86
|
+
.or(Broadcast.where(status: "processing", claimed_at: ..stale_at))
|
|
87
|
+
.order(:available_at, :id)
|
|
88
|
+
broadcast = database_adapter.lock_candidates(relation).first
|
|
89
|
+
next unless broadcast
|
|
90
|
+
|
|
91
|
+
broadcast.update!(
|
|
92
|
+
status: "processing",
|
|
93
|
+
attempt_count: broadcast.attempt_count + 1,
|
|
94
|
+
claimed_by: process_registry.process_record.id,
|
|
95
|
+
claimed_at: now
|
|
96
|
+
)
|
|
97
|
+
broadcast
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @rbs () -> Proc | ActionCableBroadcastAdapter
|
|
102
|
+
def broadcast_adapter
|
|
103
|
+
SolidObjects.configuration.broadcast_adapter ||
|
|
104
|
+
ActionCableBroadcastAdapter.new
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @rbs (Broadcast) -> void
|
|
108
|
+
def complete(broadcast)
|
|
109
|
+
database_adapter.transaction do
|
|
110
|
+
locked_broadcast = Broadcast.lock.find(broadcast.id)
|
|
111
|
+
verify_claim!(locked_broadcast)
|
|
112
|
+
locked_broadcast.update!(
|
|
113
|
+
status: "delivered",
|
|
114
|
+
error: nil,
|
|
115
|
+
delivered_at: database_adapter.database_now,
|
|
116
|
+
claimed_by: nil,
|
|
117
|
+
claimed_at: nil
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
SolidObjects.instrument(
|
|
121
|
+
:"broadcast.delivered",
|
|
122
|
+
broadcast_id: broadcast.broadcast_id,
|
|
123
|
+
message_id: broadcast.message_id,
|
|
124
|
+
actor_type: broadcast.instance.actor_type,
|
|
125
|
+
actor_id: broadcast.instance.actor_id,
|
|
126
|
+
observable_name: broadcast.observable_name,
|
|
127
|
+
attempt: broadcast.attempt_count
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @rbs (Broadcast, Exception) -> void
|
|
132
|
+
def fail_broadcast(broadcast, error)
|
|
133
|
+
database_adapter.transaction do
|
|
134
|
+
locked_broadcast = Broadcast.lock.find(broadcast.id)
|
|
135
|
+
verify_claim!(locked_broadcast)
|
|
136
|
+
dead = locked_broadcast.attempt_count >= SolidObjects.configuration.max_attempts
|
|
137
|
+
locked_broadcast.update!(
|
|
138
|
+
status: dead ? "dead" : "pending",
|
|
139
|
+
available_at: database_adapter.database_now +
|
|
140
|
+
SolidObjects.configuration.retry_delay.call(locked_broadcast.attempt_count),
|
|
141
|
+
error: {
|
|
142
|
+
"class" => error.class.name,
|
|
143
|
+
"message" => error.message.to_s.byteslice(0, 8_192),
|
|
144
|
+
"backtrace" => Array(error.backtrace).first(50)
|
|
145
|
+
},
|
|
146
|
+
claimed_by: nil,
|
|
147
|
+
claimed_at: nil
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
rescue ActiveRecord::RecordNotFound, LostActivation
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @rbs (Broadcast) -> void
|
|
155
|
+
def verify_claim!(broadcast)
|
|
156
|
+
return if broadcast.status == "processing" &&
|
|
157
|
+
broadcast.claimed_by == process_registry.process_record.id
|
|
158
|
+
|
|
159
|
+
raise LostActivation, "broadcast claim changed"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
package_name "solid_objects"
|
|
8
|
+
|
|
9
|
+
desc "start", "Start actor, effect, reminder, and broadcast processes"
|
|
10
|
+
option :environment, type: :string, aliases: "-e"
|
|
11
|
+
option :workers, type: :numeric
|
|
12
|
+
option :effect_workers, type: :numeric
|
|
13
|
+
option :broadcast_workers, type: :numeric
|
|
14
|
+
option :reminder_schedulers, type: :numeric
|
|
15
|
+
|
|
16
|
+
# @rbs () -> void
|
|
17
|
+
def start
|
|
18
|
+
boot_application
|
|
19
|
+
supervisor = Supervisor.new(
|
|
20
|
+
worker_count: numeric_option(:workers, SolidObjects.configuration.worker_count),
|
|
21
|
+
effect_worker_count: numeric_option(:effect_workers, SolidObjects.configuration.effect_worker_count),
|
|
22
|
+
broadcast_worker_count: numeric_option(:broadcast_workers, SolidObjects.configuration.broadcast_worker_count),
|
|
23
|
+
reminder_scheduler_count: numeric_option(:reminder_schedulers, SolidObjects.configuration.reminder_scheduler_count)
|
|
24
|
+
)
|
|
25
|
+
%w[INT TERM].each do |signal|
|
|
26
|
+
Signal.trap(signal) { Thread.new { supervisor.stop } }
|
|
27
|
+
end
|
|
28
|
+
supervisor.run
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
desc "status", "Print registered runtime processes"
|
|
32
|
+
option :environment, type: :string, aliases: "-e"
|
|
33
|
+
|
|
34
|
+
# @rbs () -> void
|
|
35
|
+
def status
|
|
36
|
+
boot_application
|
|
37
|
+
authorize_administration!(:status)
|
|
38
|
+
rows = SolidObjects::Process.order(:kind, :started_at).map do |process_record|
|
|
39
|
+
{
|
|
40
|
+
id: process_record.id,
|
|
41
|
+
kind: process_record.kind,
|
|
42
|
+
hostname: process_record.hostname,
|
|
43
|
+
pid: process_record.pid,
|
|
44
|
+
state: process_record.shutdown_state,
|
|
45
|
+
last_heartbeat_at: process_record.last_heartbeat_at
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
puts JSON.pretty_generate(rows)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
desc "cleanup", "Release coordination records owned by dead processes"
|
|
52
|
+
option :environment, type: :string, aliases: "-e"
|
|
53
|
+
|
|
54
|
+
# @rbs () -> void
|
|
55
|
+
def cleanup
|
|
56
|
+
boot_application
|
|
57
|
+
authorize_administration!(:cleanup)
|
|
58
|
+
puts JSON.generate(cleaned_processes: ProcessRegistry.cleanup_dead)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
desc "dead_letters", "Print dead actor messages"
|
|
62
|
+
option :environment, type: :string, aliases: "-e"
|
|
63
|
+
|
|
64
|
+
# @rbs () -> void
|
|
65
|
+
def dead_letters
|
|
66
|
+
boot_application
|
|
67
|
+
rows = SolidObjects.dead_letters
|
|
68
|
+
.all(authorization_context: { source: "cli" })
|
|
69
|
+
.map(&:attributes)
|
|
70
|
+
puts JSON.pretty_generate(rows)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
desc "retry_dead_letter ID", "Retry one dead actor message"
|
|
74
|
+
option :environment, type: :string, aliases: "-e"
|
|
75
|
+
|
|
76
|
+
# @rbs (String) -> void
|
|
77
|
+
def retry_dead_letter(id)
|
|
78
|
+
boot_application
|
|
79
|
+
message_reference = SolidObjects.dead_letters.retry(
|
|
80
|
+
Integer(id),
|
|
81
|
+
authorization_context: { source: "cli" }
|
|
82
|
+
)
|
|
83
|
+
puts JSON.generate(message_id: message_reference.id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# @rbs () -> void
|
|
89
|
+
def boot_application
|
|
90
|
+
path = options[:environment] ||
|
|
91
|
+
File.expand_path("config/environment.rb", Dir.pwd)
|
|
92
|
+
unless File.file?(path)
|
|
93
|
+
raise Error, "Rails environment not found at #{path}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
require path
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @rbs (Symbol, Integer) -> Integer
|
|
100
|
+
def numeric_option(name, default)
|
|
101
|
+
value = options[name]
|
|
102
|
+
value ? Integer(value) : default
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @rbs (Symbol) -> void
|
|
106
|
+
def authorize_administration!(action)
|
|
107
|
+
authorized = SolidObjects.configuration.authorize_administration.call(
|
|
108
|
+
action:,
|
|
109
|
+
resource: "processes",
|
|
110
|
+
resource_id: nil,
|
|
111
|
+
authorization_context: { source: "cli" }
|
|
112
|
+
)
|
|
113
|
+
return if authorized
|
|
114
|
+
|
|
115
|
+
raise Unauthorized, "actor administration is not authorized"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "solid_objects/mailbox"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
class Client
|
|
7
|
+
# @rbs @mailbox: Mailbox
|
|
8
|
+
|
|
9
|
+
# @rbs (?mailbox: Mailbox) -> void
|
|
10
|
+
def initialize(mailbox: Mailbox.new)
|
|
11
|
+
@mailbox = mailbox
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference
|
|
15
|
+
def tell(reference, message_name, arguments, available_at: nil, idempotency_key: nil, authorization_context: nil)
|
|
16
|
+
actor_class = SolidObjects.registry.fetch(reference.actor_type)
|
|
17
|
+
message_symbol = message_name.to_sym
|
|
18
|
+
raise UnknownMessage, "unknown message #{message_name.inspect}" unless actor_class.definition.messages.key?(message_symbol)
|
|
19
|
+
|
|
20
|
+
authorize!(
|
|
21
|
+
SolidObjects.configuration.authorize_message,
|
|
22
|
+
reference,
|
|
23
|
+
message_name,
|
|
24
|
+
arguments,
|
|
25
|
+
authorization_context:
|
|
26
|
+
)
|
|
27
|
+
mailbox.enqueue(
|
|
28
|
+
reference,
|
|
29
|
+
message_name,
|
|
30
|
+
arguments,
|
|
31
|
+
kind: "tell",
|
|
32
|
+
available_at:,
|
|
33
|
+
idempotency_key:
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], timeout: Numeric, ?idempotency_key: String?, ?authorization_context: untyped) -> untyped
|
|
38
|
+
def ask(reference, message_name, arguments, timeout:, idempotency_key: nil, authorization_context: nil)
|
|
39
|
+
actor_class = SolidObjects.registry.fetch(reference.actor_type)
|
|
40
|
+
message_symbol = message_name.to_sym
|
|
41
|
+
query = actor_class.definition.queries.key?(message_symbol)
|
|
42
|
+
actor_message = actor_class.definition.messages.key?(message_symbol)
|
|
43
|
+
raise UnknownMessage, "unknown message #{message_name.inspect}" unless query || actor_message
|
|
44
|
+
|
|
45
|
+
authorize!(
|
|
46
|
+
query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
|
|
47
|
+
reference,
|
|
48
|
+
message_name,
|
|
49
|
+
arguments,
|
|
50
|
+
authorization_context:
|
|
51
|
+
)
|
|
52
|
+
message_reference = mailbox.enqueue(
|
|
53
|
+
reference,
|
|
54
|
+
message_name,
|
|
55
|
+
arguments,
|
|
56
|
+
kind: "ask",
|
|
57
|
+
idempotency_key:
|
|
58
|
+
)
|
|
59
|
+
wait_for_result(message_reference, timeout:)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @rbs (Reference, ?authorization_context: untyped) -> bool
|
|
63
|
+
def destroy(reference, authorization_context: nil)
|
|
64
|
+
raise ActorCallCycle, "actors cannot synchronously destroy another actor" if Context.current_actor
|
|
65
|
+
|
|
66
|
+
SolidObjects.registry.fetch(reference.actor_type)
|
|
67
|
+
authorize_destroy!(reference, authorization_context:)
|
|
68
|
+
instance_id = SolidObjects.database_adapter.transaction do
|
|
69
|
+
instance = Instance.lock.find_by(
|
|
70
|
+
actor_type: reference.actor_type,
|
|
71
|
+
actor_id: reference.actor_id
|
|
72
|
+
)
|
|
73
|
+
next unless instance
|
|
74
|
+
|
|
75
|
+
instance_id = instance.id
|
|
76
|
+
instance.delete
|
|
77
|
+
instance_id
|
|
78
|
+
end
|
|
79
|
+
return false unless instance_id
|
|
80
|
+
|
|
81
|
+
SolidObjects.instrument(
|
|
82
|
+
:"actor.destroyed",
|
|
83
|
+
instance_id:,
|
|
84
|
+
actor_type: reference.actor_type,
|
|
85
|
+
actor_id: reference.actor_id
|
|
86
|
+
)
|
|
87
|
+
SolidObjects.wake_up.signal
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
attr_reader :mailbox
|
|
94
|
+
|
|
95
|
+
# @rbs (Proc, Reference, Symbol | String, Hash[Symbol | String, untyped], authorization_context: untyped) -> void
|
|
96
|
+
def authorize!(hook, reference, message_name, arguments, authorization_context:)
|
|
97
|
+
authorized = hook.call(
|
|
98
|
+
actor_type: reference.actor_type,
|
|
99
|
+
actor_id: reference.actor_id,
|
|
100
|
+
message_name: message_name.to_s,
|
|
101
|
+
arguments:,
|
|
102
|
+
authorization_context:
|
|
103
|
+
)
|
|
104
|
+
return if authorized
|
|
105
|
+
|
|
106
|
+
raise Unauthorized, "actor invocation is not authorized"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @rbs (Reference, authorization_context: untyped) -> void
|
|
110
|
+
def authorize_destroy!(reference, authorization_context:)
|
|
111
|
+
authorized = SolidObjects.configuration.authorize_destroy.call(
|
|
112
|
+
actor_type: reference.actor_type,
|
|
113
|
+
actor_id: reference.actor_id,
|
|
114
|
+
authorization_context:
|
|
115
|
+
)
|
|
116
|
+
return if authorized
|
|
117
|
+
|
|
118
|
+
raise Unauthorized, "actor destruction is not authorized"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @rbs (MessageReference, timeout: Numeric) -> untyped
|
|
122
|
+
def wait_for_result(message_reference, timeout:)
|
|
123
|
+
deadline = monotonic_now + timeout.to_f
|
|
124
|
+
|
|
125
|
+
loop do
|
|
126
|
+
message = Message.uncached { Message.find(message_reference.id) }
|
|
127
|
+
return message.result if message.completed?
|
|
128
|
+
|
|
129
|
+
if message.dead?
|
|
130
|
+
raise MessageFailed.new(
|
|
131
|
+
"actor message failed permanently",
|
|
132
|
+
message_id: message.id,
|
|
133
|
+
details: message.error || {}
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
remaining = deadline - monotonic_now
|
|
138
|
+
raise AskTimeout, "actor request timed out after #{timeout} seconds" unless remaining.positive?
|
|
139
|
+
|
|
140
|
+
SolidObjects.wake_up.wait(
|
|
141
|
+
timeout: [ remaining, SolidObjects.configuration.ask_polling_interval ].min
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
rescue ActiveRecord::RecordNotFound
|
|
145
|
+
raise ActorDestroyed, "actor was destroyed while waiting for its result"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @rbs () -> Float
|
|
149
|
+
def monotonic_now
|
|
150
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|