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,102 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class ActivationManager
|
|
5
|
+
# @rbs @owner_id: String
|
|
6
|
+
# @rbs @database_adapter: DatabaseAdapter
|
|
7
|
+
|
|
8
|
+
# @rbs (owner_id: String, ?database_adapter: DatabaseAdapter) -> void
|
|
9
|
+
def initialize(owner_id:, database_adapter: SolidObjects.database_adapter)
|
|
10
|
+
@owner_id = owner_id
|
|
11
|
+
@database_adapter = database_adapter
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @rbs () -> Activation?
|
|
15
|
+
def claim_next
|
|
16
|
+
lease = database_adapter.transaction do
|
|
17
|
+
now = database_adapter.database_now
|
|
18
|
+
claimed_lease = nil
|
|
19
|
+
candidate_instance_ids(now).each do |instance_id|
|
|
20
|
+
instance = database_adapter.lock_candidates(
|
|
21
|
+
Instance.where(id: instance_id)
|
|
22
|
+
).first
|
|
23
|
+
next unless instance
|
|
24
|
+
next unless claimable?(instance, now)
|
|
25
|
+
|
|
26
|
+
claimed_lease = Lease.claim(instance:, owner_id:, now:, database_adapter:)
|
|
27
|
+
break if claimed_lease
|
|
28
|
+
end
|
|
29
|
+
claimed_lease
|
|
30
|
+
end
|
|
31
|
+
return unless lease
|
|
32
|
+
|
|
33
|
+
SolidObjects.instrument(
|
|
34
|
+
:"activation.claimed",
|
|
35
|
+
instance_id: lease.instance_id,
|
|
36
|
+
owner_id:,
|
|
37
|
+
generation: lease.generation
|
|
38
|
+
)
|
|
39
|
+
Activation.new(lease:)
|
|
40
|
+
rescue
|
|
41
|
+
lease&.release
|
|
42
|
+
raise
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :owner_id, :database_adapter
|
|
48
|
+
|
|
49
|
+
# @rbs (Time) -> Array[Integer]
|
|
50
|
+
def candidate_instance_ids(now)
|
|
51
|
+
(ready_instance_ids(now) + claimed_instance_ids(now)).uniq
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @rbs (Time) -> Array[Integer]
|
|
55
|
+
def ready_instance_ids(now)
|
|
56
|
+
ReadyMessage
|
|
57
|
+
.joins(:instance)
|
|
58
|
+
.where(available_at: ..now)
|
|
59
|
+
.where("#{Instance.table_name}.paused_at IS NULL")
|
|
60
|
+
.where(available_lease_sql, now)
|
|
61
|
+
.group(:instance_id)
|
|
62
|
+
.order(
|
|
63
|
+
Arel.sql("MIN(available_at)"),
|
|
64
|
+
Arel.sql("MIN(#{ReadyMessage.table_name}.id)")
|
|
65
|
+
)
|
|
66
|
+
.limit(SolidObjects.configuration.claim_scan_limit)
|
|
67
|
+
.pluck(:instance_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @rbs (Time) -> Array[Integer]
|
|
71
|
+
def claimed_instance_ids(now)
|
|
72
|
+
ClaimedMessage
|
|
73
|
+
.joins(:instance)
|
|
74
|
+
.where("#{Instance.table_name}.paused_at IS NULL")
|
|
75
|
+
.where(available_lease_sql, now)
|
|
76
|
+
.group(:instance_id)
|
|
77
|
+
.order(
|
|
78
|
+
Arel.sql("MIN(claimed_at)"),
|
|
79
|
+
Arel.sql("MIN(#{ClaimedMessage.table_name}.id)")
|
|
80
|
+
)
|
|
81
|
+
.limit(SolidObjects.configuration.claim_scan_limit)
|
|
82
|
+
.pluck(:instance_id)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @rbs (Instance, Time) -> bool
|
|
86
|
+
def claimable?(instance, now)
|
|
87
|
+
return false if instance.paused_at
|
|
88
|
+
return true unless instance.activation_owner_id
|
|
89
|
+
return true unless instance.activation_expires_at
|
|
90
|
+
|
|
91
|
+
instance.activation_expires_at <= now
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @rbs () -> String
|
|
95
|
+
def available_lease_sql
|
|
96
|
+
table_name = Instance.table_name
|
|
97
|
+
"#{table_name}.activation_owner_id IS NULL OR " \
|
|
98
|
+
"#{table_name}.activation_expires_at IS NULL OR " \
|
|
99
|
+
"#{table_name}.activation_expires_at <= ?"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Actor
|
|
5
|
+
EffectIntent = Data.define(:name, :arguments, :success_message_name, :failure_message_name)
|
|
6
|
+
ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
|
|
7
|
+
OutboundMessageIntent = Data.define(:actor_type, :actor_id, :message_name, :arguments, :available_at, :idempotency_key)
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
# @rbs (Class) -> void
|
|
11
|
+
def inherited(subclass)
|
|
12
|
+
super
|
|
13
|
+
subclass.instance_variable_set(:@definition, definition.duplicate)
|
|
14
|
+
subclass.instance_variable_set(:@generated_attribute_methods, generated_attribute_methods.dup)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @rbs (?String | Symbol) -> String
|
|
18
|
+
def actor_type(value = nil)
|
|
19
|
+
if value
|
|
20
|
+
@actor_type = value.to_s
|
|
21
|
+
SolidObjects.register_actor(@actor_type, self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@actor_type ||= default_actor_type
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @rbs (Symbol | String, ?default: untyped) -> StateDefinition::Attribute
|
|
28
|
+
def attribute(name, default: nil)
|
|
29
|
+
attribute_name = name.to_sym
|
|
30
|
+
ensure_attribute_methods_available!(attribute_name)
|
|
31
|
+
attribute = definition.add_attribute(attribute_name, default:)
|
|
32
|
+
@defining_attribute_methods = true
|
|
33
|
+
begin
|
|
34
|
+
define_method(attribute_name) { state.fetch(attribute_name) }
|
|
35
|
+
define_method(:"#{attribute_name}=") { |value| state.write(attribute_name, value) }
|
|
36
|
+
generated_attribute_methods[attribute_name] = true
|
|
37
|
+
generated_attribute_methods[:"#{attribute_name}="] = true
|
|
38
|
+
ensure
|
|
39
|
+
@defining_attribute_methods = false
|
|
40
|
+
end
|
|
41
|
+
attribute
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @rbs (Symbol | String) { (*untyped, **untyped) -> untyped } -> ActorDefinition::Handler
|
|
45
|
+
def message(name, &block)
|
|
46
|
+
definition.add_message(name, block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @rbs (Symbol | String) { (*untyped, **untyped) -> untyped } -> ActorDefinition::Handler
|
|
50
|
+
def query(name, &block)
|
|
51
|
+
definition.add_query(name, block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @rbs (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
55
|
+
def observable(name, &block)
|
|
56
|
+
definition.add_observable(name, block)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @rbs (?Integer) -> Integer
|
|
60
|
+
def state_version(version = nil)
|
|
61
|
+
definition.set_state_version(version) if version
|
|
62
|
+
definition.state_version
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @rbs (from: Integer, to: Integer) { (Hash[String, untyped]) -> Hash[String, untyped] } -> ActorDefinition::StateMigration
|
|
66
|
+
def migrate_state(from:, to:, &block)
|
|
67
|
+
definition.add_state_migration(from, to, block)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @rbs () { () -> untyped } -> Proc
|
|
71
|
+
def on_activate(&block)
|
|
72
|
+
definition.activation_hooks << block
|
|
73
|
+
block
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @rbs () { () -> untyped } -> Proc
|
|
77
|
+
def on_deactivate(&block)
|
|
78
|
+
definition.deactivation_hooks << block
|
|
79
|
+
block
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @rbs (String | Integer) -> Reference
|
|
83
|
+
def ref(actor_id)
|
|
84
|
+
ensure_registered!
|
|
85
|
+
Reference.new(actor_type:, actor_id: actor_id.to_s)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @rbs () -> ActorDefinition
|
|
89
|
+
def definition
|
|
90
|
+
(@definition ||= ActorDefinition.new).synchronize_instance_messages(self)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @rbs () -> Class
|
|
94
|
+
def ensure_registered!
|
|
95
|
+
SolidObjects.register_actor(actor_type, self)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# @rbs (Symbol) -> void
|
|
101
|
+
def method_added(name)
|
|
102
|
+
super
|
|
103
|
+
generated_attribute_methods.delete(name) unless @defining_attribute_methods
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @rbs () -> Hash[Symbol, bool]
|
|
107
|
+
def generated_attribute_methods
|
|
108
|
+
@generated_attribute_methods ||= {}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @rbs (Symbol) -> void
|
|
112
|
+
def ensure_attribute_methods_available!(attribute_name)
|
|
113
|
+
conflicting_name = [ attribute_name, :"#{attribute_name}=" ].find do |method_name|
|
|
114
|
+
method_defined?(method_name) ||
|
|
115
|
+
private_method_defined?(method_name) ||
|
|
116
|
+
protected_method_defined?(method_name)
|
|
117
|
+
end
|
|
118
|
+
return unless conflicting_name
|
|
119
|
+
|
|
120
|
+
raise InvalidActor, "attribute #{attribute_name.inspect} conflicts with actor method #{conflicting_name}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @rbs () -> String
|
|
124
|
+
def default_actor_type
|
|
125
|
+
raise InvalidActor, "anonymous actor classes must declare actor_type" unless name
|
|
126
|
+
|
|
127
|
+
name
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @rbs @actor_id: String
|
|
132
|
+
# @rbs @state: State
|
|
133
|
+
# @rbs @effect_intents: Array[EffectIntent]
|
|
134
|
+
# @rbs @reminder_intents: Array[ReminderIntent]
|
|
135
|
+
# @rbs @outbound_message_intents: Array[OutboundMessageIntent]
|
|
136
|
+
|
|
137
|
+
attr_reader :actor_id, :state
|
|
138
|
+
|
|
139
|
+
# @rbs (actor_id: String, state: State) -> void
|
|
140
|
+
def initialize(actor_id:, state:)
|
|
141
|
+
@actor_id = actor_id
|
|
142
|
+
@state = state
|
|
143
|
+
@effect_intents = []
|
|
144
|
+
@reminder_intents = []
|
|
145
|
+
@outbound_message_intents = []
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @rbs () -> MessageContext?
|
|
149
|
+
def current_message
|
|
150
|
+
Context.current_message
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> EffectIntent
|
|
154
|
+
def emit(name, on_success: nil, on_failure: nil, **arguments)
|
|
155
|
+
validate_effect_callback!(on_success)
|
|
156
|
+
validate_effect_callback!(on_failure)
|
|
157
|
+
EffectIntent.new(
|
|
158
|
+
name: name.to_s,
|
|
159
|
+
arguments: Serialization.dump(arguments),
|
|
160
|
+
success_message_name: on_success&.to_s,
|
|
161
|
+
failure_message_name: on_failure&.to_s
|
|
162
|
+
).tap do |intent|
|
|
163
|
+
effect_intents << intent
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) -> ReminderIntent
|
|
168
|
+
def schedule(name, at:, every: nil, missed: :latest, arguments: {})
|
|
169
|
+
interval_seconds = every&.to_f
|
|
170
|
+
if interval_seconds && !interval_seconds.positive?
|
|
171
|
+
raise ArgumentError, "reminder interval must be positive"
|
|
172
|
+
end
|
|
173
|
+
missed_policy = missed.to_s
|
|
174
|
+
unless %w[all latest].include?(missed_policy)
|
|
175
|
+
raise ArgumentError, "missed reminder policy must be all or latest"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
ReminderIntent.new(
|
|
179
|
+
name: name.to_s,
|
|
180
|
+
at:,
|
|
181
|
+
arguments: Serialization.dump(arguments),
|
|
182
|
+
interval_seconds:,
|
|
183
|
+
missed_policy:
|
|
184
|
+
).tap do |intent|
|
|
185
|
+
reminder_intents << intent
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @rbs (Reference, Symbol | String, ?available_at: Time?, ?idempotency_key: String?, **untyped) -> OutboundMessageIntent
|
|
190
|
+
def send_to(reference, message_name, available_at: nil, idempotency_key: nil, **arguments)
|
|
191
|
+
stage_outbound_message(reference, message_name, arguments, available_at:, idempotency_key:)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @rbs (Symbol | String, Hash[String, untyped]) -> untyped
|
|
195
|
+
def invoke(message_name, arguments)
|
|
196
|
+
handler = self.class.definition.messages[message_name.to_sym] ||
|
|
197
|
+
self.class.definition.queries[message_name.to_sym]
|
|
198
|
+
raise UnknownMessage, "unknown message #{message_name.inspect} for #{self.class.actor_type}" unless handler
|
|
199
|
+
|
|
200
|
+
instance_exec(**keyword_arguments(arguments), &handler.block)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# @rbs () -> Hash[String, untyped]
|
|
204
|
+
def observable_values
|
|
205
|
+
self.class.definition.observables.each_with_object({}) do |(name, handler), values|
|
|
206
|
+
values[name.to_s] = Serialization.dump(instance_exec(&handler.block))
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# @rbs () -> void
|
|
211
|
+
def activate
|
|
212
|
+
self.class.definition.activation_hooks.each { |hook| instance_exec(&hook) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @rbs () -> void
|
|
216
|
+
def deactivate
|
|
217
|
+
self.class.definition.deactivation_hooks.each { |hook| instance_exec(&hook) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, idempotency_key: String?) -> OutboundMessageIntent
|
|
221
|
+
def stage_outbound_message(reference, message_name, arguments, available_at: nil, idempotency_key: nil)
|
|
222
|
+
OutboundMessageIntent.new(
|
|
223
|
+
actor_type: reference.actor_type,
|
|
224
|
+
actor_id: reference.actor_id,
|
|
225
|
+
message_name: message_name.to_s,
|
|
226
|
+
arguments: Serialization.dump(arguments),
|
|
227
|
+
available_at:,
|
|
228
|
+
idempotency_key:
|
|
229
|
+
).tap { |intent| outbound_message_intents << intent }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @rbs () -> Array[EffectIntent]
|
|
233
|
+
def drain_effect_intents
|
|
234
|
+
effect_intents.shift(effect_intents.length)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @rbs () -> Array[ReminderIntent]
|
|
238
|
+
def drain_reminder_intents
|
|
239
|
+
reminder_intents.shift(reminder_intents.length)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# @rbs () -> Array[OutboundMessageIntent]
|
|
243
|
+
def drain_outbound_message_intents
|
|
244
|
+
outbound_message_intents.shift(outbound_message_intents.length)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# @rbs () -> void
|
|
248
|
+
def discard_intents
|
|
249
|
+
effect_intents.clear
|
|
250
|
+
reminder_intents.clear
|
|
251
|
+
outbound_message_intents.clear
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
attr_reader :effect_intents, :reminder_intents, :outbound_message_intents
|
|
257
|
+
|
|
258
|
+
# @rbs (Hash[String, untyped]) -> Hash[Symbol, untyped]
|
|
259
|
+
def keyword_arguments(arguments)
|
|
260
|
+
arguments.each_with_object({}) { |(key, value), converted| converted[key.to_sym] = value }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @rbs (Symbol | String?) -> void
|
|
264
|
+
def validate_effect_callback!(message_name)
|
|
265
|
+
return unless message_name
|
|
266
|
+
return if self.class.definition.messages.key?(message_name.to_sym)
|
|
267
|
+
|
|
268
|
+
raise UnknownMessage, "unknown effect callback message #{message_name.inspect}"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "action_cable/channel/base"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
class ActorChannel < ActionCable::Channel::Base
|
|
7
|
+
# @rbs () -> void
|
|
8
|
+
def subscribed
|
|
9
|
+
identity = StreamToken.verify(params.fetch("token"))
|
|
10
|
+
actor_type = identity.fetch("actor_type")
|
|
11
|
+
actor_id = identity.fetch("actor_id")
|
|
12
|
+
SolidObjects.registry.fetch(actor_type)
|
|
13
|
+
authorized = SolidObjects.configuration.authorize_subscription.call(
|
|
14
|
+
actor_type:,
|
|
15
|
+
actor_id:,
|
|
16
|
+
authorization_context: connection
|
|
17
|
+
)
|
|
18
|
+
return reject unless authorized
|
|
19
|
+
|
|
20
|
+
reference = Reference.new(actor_type:, actor_id:)
|
|
21
|
+
stream_from StreamName.for(reference)
|
|
22
|
+
ActorSnapshot.new(reference).observable_values.each do |name, value|
|
|
23
|
+
transmit TurboStreamRenderer.observable_value(reference, name, value)
|
|
24
|
+
end
|
|
25
|
+
rescue KeyError, InvalidStreamToken, UnknownActorType
|
|
26
|
+
reject
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class ActorDefinition
|
|
5
|
+
Handler = Data.define(:name, :block)
|
|
6
|
+
StateMigration = Data.define(:from, :to, :block)
|
|
7
|
+
|
|
8
|
+
# @rbs @state_definition: StateDefinition
|
|
9
|
+
# @rbs @messages: Hash[Symbol, Handler]
|
|
10
|
+
# @rbs @queries: Hash[Symbol, Handler]
|
|
11
|
+
# @rbs @observables: Hash[Symbol, Handler]
|
|
12
|
+
# @rbs @state_version: Integer
|
|
13
|
+
# @rbs @state_migrations: Array[StateMigration]
|
|
14
|
+
# @rbs @activation_hooks: Array[Proc]
|
|
15
|
+
# @rbs @deactivation_hooks: Array[Proc]
|
|
16
|
+
# @rbs @attribute_queries: Hash[Symbol, bool]
|
|
17
|
+
# @rbs @method_messages: Hash[Symbol, bool]
|
|
18
|
+
|
|
19
|
+
attr_reader :state_definition,
|
|
20
|
+
:messages,
|
|
21
|
+
:queries,
|
|
22
|
+
:observables,
|
|
23
|
+
:state_version,
|
|
24
|
+
:state_migrations,
|
|
25
|
+
:activation_hooks,
|
|
26
|
+
:deactivation_hooks
|
|
27
|
+
|
|
28
|
+
# @rbs () -> void
|
|
29
|
+
def initialize
|
|
30
|
+
@state_definition = StateDefinition.new
|
|
31
|
+
@messages = {}
|
|
32
|
+
@queries = {}
|
|
33
|
+
@observables = {}
|
|
34
|
+
@state_version = 1
|
|
35
|
+
@state_migrations = []
|
|
36
|
+
@activation_hooks = []
|
|
37
|
+
@deactivation_hooks = []
|
|
38
|
+
@attribute_queries = {}
|
|
39
|
+
@method_messages = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @rbs (Symbol | String, default: untyped) -> StateDefinition::Attribute
|
|
43
|
+
def add_attribute(name, default:)
|
|
44
|
+
attribute_name = name.to_sym
|
|
45
|
+
if messages.key?(attribute_name) || queries.key?(attribute_name)
|
|
46
|
+
raise InvalidActor, "#{attribute_name.inspect} is already defined"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
state_definition.attribute(attribute_name, default:).tap do
|
|
50
|
+
queries[attribute_name] = Handler.new(
|
|
51
|
+
name: attribute_name,
|
|
52
|
+
block: -> { state.fetch(attribute_name) }
|
|
53
|
+
)
|
|
54
|
+
attribute_queries[attribute_name] = true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @rbs (Symbol | String, Proc) -> Handler
|
|
59
|
+
def add_message(name, block)
|
|
60
|
+
add_handler(messages, name, block)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @rbs (Symbol | String, Proc) -> Handler
|
|
64
|
+
def add_query(name, block)
|
|
65
|
+
query_name = name.to_sym
|
|
66
|
+
if attribute_queries.delete(query_name)
|
|
67
|
+
return Handler.new(name: query_name, block:).tap do |handler|
|
|
68
|
+
queries[query_name] = handler
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
add_handler(queries, name, block)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @rbs (Symbol | String, Proc?) -> Handler
|
|
76
|
+
def add_observable(name, block = nil)
|
|
77
|
+
observable_name = name.to_sym
|
|
78
|
+
raise InvalidActor, "#{observable_name.inspect} observable is already defined" if observables.key?(observable_name)
|
|
79
|
+
|
|
80
|
+
observable_block = block || -> { state.fetch(observable_name) }
|
|
81
|
+
Handler.new(name: observable_name, block: observable_block).tap do |handler|
|
|
82
|
+
observables[observable_name] = handler
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @rbs (Class) -> ActorDefinition
|
|
87
|
+
def synchronize_instance_messages(actor_class)
|
|
88
|
+
names = actor_message_method_names(actor_class)
|
|
89
|
+
|
|
90
|
+
(method_messages.keys - names).each do |name|
|
|
91
|
+
messages.delete(name)
|
|
92
|
+
method_messages.delete(name)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
names.each { |name| add_method_message(name) unless method_messages.key?(name) }
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @rbs (Integer) -> Integer
|
|
100
|
+
def set_state_version(version)
|
|
101
|
+
raise InvalidActor, "state version must be positive" unless version.positive?
|
|
102
|
+
|
|
103
|
+
@state_version = version
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @rbs (Integer, Integer, Proc) -> StateMigration
|
|
107
|
+
def add_state_migration(from, to, block)
|
|
108
|
+
unless to == from + 1
|
|
109
|
+
raise InvalidActor, "state migrations must advance exactly one version"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if state_migrations.any? { |migration| migration.from == from }
|
|
113
|
+
raise InvalidActor, "state migration from version #{from} is already defined"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
StateMigration.new(from:, to:, block:).tap { |migration| state_migrations << migration }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @rbs (Integer, Hash[String, untyped]) -> Hash[String, untyped]
|
|
120
|
+
def migrate_state(version, data)
|
|
121
|
+
migrated = Serialization.deep_copy(data)
|
|
122
|
+
current_version = version
|
|
123
|
+
|
|
124
|
+
while current_version < state_version
|
|
125
|
+
migration = state_migrations.find { |candidate| candidate.from == current_version }
|
|
126
|
+
raise StateMigrationError, "missing state migration from version #{current_version}" unless migration
|
|
127
|
+
|
|
128
|
+
migrated = Serialization.dump(migration.block.call(migrated))
|
|
129
|
+
current_version = migration.to
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if current_version > state_version
|
|
133
|
+
raise StateMigrationError, "stored state version #{current_version} is newer than code version #{state_version}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
migrated
|
|
137
|
+
rescue StateMigrationError
|
|
138
|
+
raise
|
|
139
|
+
rescue => error
|
|
140
|
+
raise StateMigrationError, "state migration failed: #{error.message}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @rbs () -> ActorDefinition
|
|
144
|
+
def duplicate
|
|
145
|
+
self.class.new.tap do |copy|
|
|
146
|
+
copy.instance_variable_set(:@state_definition, state_definition.duplicate)
|
|
147
|
+
copy.instance_variable_set(:@messages, messages.dup)
|
|
148
|
+
copy.instance_variable_set(:@queries, queries.dup)
|
|
149
|
+
copy.instance_variable_set(:@observables, observables.dup)
|
|
150
|
+
copy.instance_variable_set(:@state_version, state_version)
|
|
151
|
+
copy.instance_variable_set(:@state_migrations, state_migrations.dup)
|
|
152
|
+
copy.instance_variable_set(:@activation_hooks, activation_hooks.dup)
|
|
153
|
+
copy.instance_variable_set(:@deactivation_hooks, deactivation_hooks.dup)
|
|
154
|
+
copy.instance_variable_set(:@attribute_queries, attribute_queries.dup)
|
|
155
|
+
copy.instance_variable_set(:@method_messages, method_messages.dup)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
attr_reader :attribute_queries, :method_messages
|
|
162
|
+
|
|
163
|
+
# @rbs (Symbol) -> Handler
|
|
164
|
+
def add_method_message(name)
|
|
165
|
+
if messages.key?(name) || queries.key?(name)
|
|
166
|
+
raise InvalidActor, "#{name.inspect} is already defined"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
Handler.new(
|
|
170
|
+
name:,
|
|
171
|
+
block: ->(**arguments) { public_send(name, **arguments) }
|
|
172
|
+
).tap do |handler|
|
|
173
|
+
messages[name] = handler
|
|
174
|
+
method_messages[name] = true
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @rbs (Class) -> Array[Symbol]
|
|
179
|
+
def actor_message_method_names(actor_class)
|
|
180
|
+
actor_ancestors = actor_class.ancestors.take_while { |ancestor| ancestor != Actor }
|
|
181
|
+
attribute_method_names = actor_class.send(:generated_attribute_methods).keys
|
|
182
|
+
validate_attribute_methods!(attribute_method_names)
|
|
183
|
+
|
|
184
|
+
actor_class.public_instance_methods.filter_map do |name|
|
|
185
|
+
method_name = name.to_sym
|
|
186
|
+
next if attribute_method_names.include?(method_name)
|
|
187
|
+
next unless actor_ancestors.include?(actor_class.instance_method(method_name).owner)
|
|
188
|
+
|
|
189
|
+
method_name
|
|
190
|
+
end.uniq
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# @rbs (Array[Symbol]) -> void
|
|
194
|
+
def validate_attribute_methods!(attribute_method_names)
|
|
195
|
+
state_definition.to_h.each_key do |name|
|
|
196
|
+
[ name, :"#{name}=" ].each do |method_name|
|
|
197
|
+
next if attribute_method_names.include?(method_name)
|
|
198
|
+
|
|
199
|
+
raise InvalidActor, "actor method #{method_name} overrides attribute #{name.inspect}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @rbs (Hash[Symbol, Handler], Symbol | String, Proc) -> Handler
|
|
205
|
+
def add_handler(collection, name, block)
|
|
206
|
+
handler_name = name.to_sym
|
|
207
|
+
raise InvalidActor, "#{handler_name.inspect} is already defined" if messages.key?(handler_name) || queries.key?(handler_name)
|
|
208
|
+
|
|
209
|
+
Handler.new(name: handler_name, block:).tap { |handler| collection[handler_name] = handler }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class ActorRegistry
|
|
5
|
+
# @rbs @actors: Hash[String, Class]
|
|
6
|
+
# @rbs @mutex: Mutex
|
|
7
|
+
|
|
8
|
+
# @rbs () -> void
|
|
9
|
+
def initialize
|
|
10
|
+
@actors = {}
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @rbs (String | Symbol, Class) -> Class
|
|
15
|
+
def register(type, actor_class)
|
|
16
|
+
actor_type = normalize(type)
|
|
17
|
+
validate_actor_class!(actor_class)
|
|
18
|
+
|
|
19
|
+
mutex.synchronize do
|
|
20
|
+
existing = actors[actor_type]
|
|
21
|
+
if existing && existing != actor_class
|
|
22
|
+
raise InvalidActor, "#{actor_type.inspect} is already registered by #{existing.name}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
actors[actor_type] = actor_class
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
actor_class
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @rbs (String | Symbol) -> Class
|
|
32
|
+
def fetch(type)
|
|
33
|
+
actor_type = normalize(type)
|
|
34
|
+
actors.fetch(actor_type) { raise UnknownActorType, "unknown actor type #{actor_type.inspect}" }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @rbs (String | Symbol) -> bool
|
|
38
|
+
def registered?(type)
|
|
39
|
+
actors.key?(normalize(type))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @rbs () -> Hash[String, Class]
|
|
43
|
+
def to_h
|
|
44
|
+
mutex.synchronize { actors.dup.freeze }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :actors, :mutex
|
|
50
|
+
|
|
51
|
+
# @rbs (String | Symbol) -> String
|
|
52
|
+
def normalize(type)
|
|
53
|
+
type.to_s.tap do |value|
|
|
54
|
+
raise InvalidActor, "actor type cannot be empty" if value.empty?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @rbs (untyped) -> void
|
|
59
|
+
def validate_actor_class!(actor_class)
|
|
60
|
+
return if actor_class.is_a?(Class) && actor_class < Actor
|
|
61
|
+
|
|
62
|
+
raise InvalidActor, "registered actor must inherit from SolidObjects::Actor"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|