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,99 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
module Serialization
|
|
5
|
+
MAX_NESTING = 100
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
# @rbs (untyped, ?max_bytes: Integer?) -> untyped
|
|
9
|
+
def dump(value, max_bytes: nil)
|
|
10
|
+
normalized = normalize(value)
|
|
11
|
+
encoded = JSON.generate(normalized, max_nesting: MAX_NESTING)
|
|
12
|
+
|
|
13
|
+
if max_bytes && encoded.bytesize > max_bytes
|
|
14
|
+
raise PayloadTooLarge, "serialized value exceeds #{max_bytes} bytes"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
normalized
|
|
18
|
+
rescue JSON::GeneratorError, EncodingError => error
|
|
19
|
+
raise InvalidPayload, error.message
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs (untyped) -> untyped
|
|
23
|
+
def load(value)
|
|
24
|
+
return nil if value.nil?
|
|
25
|
+
|
|
26
|
+
normalize(value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @rbs (untyped) -> untyped
|
|
30
|
+
def deep_copy(value)
|
|
31
|
+
JSON.parse(JSON.generate(normalize(value), max_nesting: MAX_NESTING))
|
|
32
|
+
rescue JSON::GeneratorError, JSON::ParserError, EncodingError => error
|
|
33
|
+
raise InvalidPayload, error.message
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @rbs (untyped) -> untyped
|
|
37
|
+
def readonly_copy(value)
|
|
38
|
+
freeze_recursively(deep_copy(value))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# @rbs (untyped) -> untyped
|
|
44
|
+
def freeze_recursively(value)
|
|
45
|
+
case value
|
|
46
|
+
when Array
|
|
47
|
+
value.each { |item| freeze_recursively(item) }
|
|
48
|
+
when Hash
|
|
49
|
+
value.each do |key, item|
|
|
50
|
+
freeze_recursively(key)
|
|
51
|
+
freeze_recursively(item)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
value.freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @rbs (untyped, ?depth: Integer) -> untyped
|
|
59
|
+
def normalize(value, depth: 0)
|
|
60
|
+
raise InvalidPayload, "serialized value is nested too deeply" if depth > MAX_NESTING
|
|
61
|
+
|
|
62
|
+
case value
|
|
63
|
+
when nil, true, false, String, Integer
|
|
64
|
+
value
|
|
65
|
+
when Float
|
|
66
|
+
raise InvalidPayload, "non-finite numbers are not supported" unless value.finite?
|
|
67
|
+
|
|
68
|
+
value
|
|
69
|
+
when Symbol
|
|
70
|
+
value.to_s
|
|
71
|
+
when Array
|
|
72
|
+
value.map { |item| normalize(item, depth: depth + 1) }
|
|
73
|
+
when Hash
|
|
74
|
+
normalize_hash(value, depth:)
|
|
75
|
+
else
|
|
76
|
+
raise InvalidPayload, "#{value.class} is not JSON-compatible"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @rbs (Hash[untyped, untyped], depth: Integer) -> Hash[String, untyped]
|
|
81
|
+
def normalize_hash(value, depth:)
|
|
82
|
+
value.each_with_object({}) do |(key, item), normalized|
|
|
83
|
+
normalized_key = normalize_key(key)
|
|
84
|
+
raise InvalidPayload, "duplicate key after normalization: #{normalized_key}" if normalized.key?(normalized_key)
|
|
85
|
+
|
|
86
|
+
normalized[normalized_key] = normalize(item, depth: depth + 1)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @rbs (untyped) -> String
|
|
91
|
+
def normalize_key(key)
|
|
92
|
+
return key if key.is_a?(String)
|
|
93
|
+
return key.to_s if key.is_a?(Symbol)
|
|
94
|
+
|
|
95
|
+
raise InvalidPayload, "JSON object keys must be strings or symbols"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class StateDefinition
|
|
5
|
+
Attribute = Data.define(:name, :default)
|
|
6
|
+
|
|
7
|
+
# @rbs @attributes: Hash[Symbol, Attribute]
|
|
8
|
+
|
|
9
|
+
# @rbs () -> void
|
|
10
|
+
def initialize
|
|
11
|
+
@attributes = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @rbs (Symbol | String, ?default: untyped) -> Attribute
|
|
15
|
+
def attribute(name, default: nil)
|
|
16
|
+
attribute_name = name.to_sym
|
|
17
|
+
raise InvalidActor, "state attribute #{attribute_name.inspect} is already defined" if attributes.key?(attribute_name)
|
|
18
|
+
|
|
19
|
+
attributes[attribute_name] = Attribute.new(name: attribute_name, default:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs () -> Hash[Symbol, Attribute]
|
|
23
|
+
def to_h
|
|
24
|
+
attributes.dup.freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @rbs () -> StateDefinition
|
|
28
|
+
def duplicate
|
|
29
|
+
self.class.new.tap do |definition|
|
|
30
|
+
attributes.each_value { |attribute| definition.attribute(attribute.name, default: attribute.default) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :attributes
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class State
|
|
40
|
+
# @rbs @definition: StateDefinition
|
|
41
|
+
# @rbs @data: Hash[String, untyped]
|
|
42
|
+
|
|
43
|
+
# @rbs (StateDefinition, Hash[String, untyped]?) -> void
|
|
44
|
+
def initialize(definition, data = nil)
|
|
45
|
+
@definition = definition
|
|
46
|
+
@data = data ? Serialization.deep_copy(data) : {}
|
|
47
|
+
apply_defaults
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @rbs () -> Hash[String, untyped]
|
|
51
|
+
def to_h
|
|
52
|
+
Serialization.deep_copy(data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @rbs (Symbol | String) -> untyped
|
|
56
|
+
def fetch(name)
|
|
57
|
+
key = name.to_s
|
|
58
|
+
raise NoMethodError, "undefined state attribute #{name.inspect}" unless defined_attribute?(name)
|
|
59
|
+
|
|
60
|
+
data.fetch(key)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @rbs (Symbol | String, untyped) -> untyped
|
|
64
|
+
def write(name, value)
|
|
65
|
+
key = name.to_s
|
|
66
|
+
raise NoMethodError, "undefined state attribute #{name.inspect}" unless defined_attribute?(name)
|
|
67
|
+
|
|
68
|
+
data[key] = value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @rbs (Symbol, *untyped) -> untyped
|
|
72
|
+
def method_missing(name, *arguments)
|
|
73
|
+
name_string = name.to_s
|
|
74
|
+
|
|
75
|
+
if name_string.end_with?("=")
|
|
76
|
+
attribute_name = name_string.delete_suffix("=")
|
|
77
|
+
return write(attribute_name, arguments.fetch(0)) if arguments.one? && defined_attribute?(attribute_name)
|
|
78
|
+
elsif arguments.empty? && defined_attribute?(name)
|
|
79
|
+
return fetch(name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
super
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @rbs (Symbol, bool) -> bool
|
|
86
|
+
def respond_to_missing?(name, include_private = false)
|
|
87
|
+
attribute_name = name.to_s.delete_suffix("=")
|
|
88
|
+
defined_attribute?(attribute_name) || super
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
attr_reader :definition, :data
|
|
94
|
+
|
|
95
|
+
# @rbs () -> void
|
|
96
|
+
def apply_defaults
|
|
97
|
+
definition.to_h.each_value do |attribute|
|
|
98
|
+
key = attribute.name.to_s
|
|
99
|
+
next if data.key?(key)
|
|
100
|
+
|
|
101
|
+
value = attribute.default.respond_to?(:call) ? attribute.default.call : attribute.default
|
|
102
|
+
data[key] = Serialization.deep_copy(value)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @rbs (Symbol | String) -> bool
|
|
107
|
+
def defined_attribute?(name)
|
|
108
|
+
definition.to_h.key?(name.to_sym)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
module StreamName
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# @rbs (Reference | Hash[String, String]) -> String
|
|
10
|
+
def for(reference)
|
|
11
|
+
identity = identity_for(reference)
|
|
12
|
+
digest = Digest::SHA256.hexdigest(
|
|
13
|
+
"#{identity.fetch("actor_type")}\0#{identity.fetch("actor_id")}"
|
|
14
|
+
).first(32)
|
|
15
|
+
"solid_objects_stream_#{digest}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @rbs (Reference | Hash[String, String]) -> Hash[String, String]
|
|
19
|
+
def identity_for(reference)
|
|
20
|
+
return reference if reference.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
"actor_type" => reference.actor_type,
|
|
24
|
+
"actor_id" => reference.actor_id
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
private_class_method :identity_for
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "active_support/message_verifier"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
module StreamToken
|
|
7
|
+
PURPOSE = "solid_objects.actor_stream"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @rbs (Reference) -> String
|
|
12
|
+
def generate(reference)
|
|
13
|
+
verifier.generate(
|
|
14
|
+
{
|
|
15
|
+
"actor_type" => reference.actor_type,
|
|
16
|
+
"actor_id" => reference.actor_id
|
|
17
|
+
},
|
|
18
|
+
purpose: PURPOSE
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs (String) -> Hash[String, String]
|
|
23
|
+
def verify(token)
|
|
24
|
+
identity = verifier.verified(token, purpose: PURPOSE)
|
|
25
|
+
unless identity.is_a?(Hash) &&
|
|
26
|
+
identity["actor_type"].is_a?(String) &&
|
|
27
|
+
identity["actor_id"].is_a?(String)
|
|
28
|
+
raise InvalidStreamToken, "invalid actor stream token"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
identity
|
|
32
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
33
|
+
raise InvalidStreamToken, "invalid actor stream token"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @rbs () -> ActiveSupport::MessageVerifier
|
|
37
|
+
def verifier
|
|
38
|
+
ActiveSupport::MessageVerifier.new(
|
|
39
|
+
signing_secret,
|
|
40
|
+
digest: "SHA256",
|
|
41
|
+
serializer: JSON
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
private_class_method :verifier
|
|
45
|
+
|
|
46
|
+
# @rbs () -> String
|
|
47
|
+
def signing_secret
|
|
48
|
+
configured_secret = SolidObjects.configuration.stream_signing_secret
|
|
49
|
+
return configured_secret if configured_secret
|
|
50
|
+
|
|
51
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
52
|
+
return Rails.application.key_generator.generate_key(PURPOSE, 64)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "configure stream_signing_secret outside a Rails application"
|
|
56
|
+
end
|
|
57
|
+
private_class_method :signing_secret
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Supervisor
|
|
5
|
+
# @rbs @components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
|
|
6
|
+
# @rbs @threads: Array[Thread]
|
|
7
|
+
# @rbs @started: bool
|
|
8
|
+
|
|
9
|
+
# @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
|
|
10
|
+
def initialize(
|
|
11
|
+
worker_count: SolidObjects.configuration.worker_count,
|
|
12
|
+
effect_worker_count: SolidObjects.configuration.effect_worker_count,
|
|
13
|
+
broadcast_worker_count: SolidObjects.configuration.broadcast_worker_count,
|
|
14
|
+
reminder_scheduler_count: SolidObjects.configuration.reminder_scheduler_count
|
|
15
|
+
)
|
|
16
|
+
@components = build_components(
|
|
17
|
+
worker_count:,
|
|
18
|
+
effect_worker_count:,
|
|
19
|
+
broadcast_worker_count:,
|
|
20
|
+
reminder_scheduler_count:
|
|
21
|
+
)
|
|
22
|
+
@threads = []
|
|
23
|
+
@started = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @rbs () -> void
|
|
27
|
+
def run
|
|
28
|
+
start
|
|
29
|
+
threads.each(&:join)
|
|
30
|
+
ensure
|
|
31
|
+
stop
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @rbs () -> void
|
|
35
|
+
def start
|
|
36
|
+
return if @started
|
|
37
|
+
|
|
38
|
+
@started = true
|
|
39
|
+
@threads = components.map { |component| Thread.new { component.run } }
|
|
40
|
+
SolidObjects.instrument(:"supervisor.started", component_count: components.length)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @rbs () -> void
|
|
44
|
+
def stop
|
|
45
|
+
return unless @started
|
|
46
|
+
|
|
47
|
+
components.each(&:request_shutdown)
|
|
48
|
+
join_until_timeout
|
|
49
|
+
components.reject(&:stopped?).each(&:stop)
|
|
50
|
+
@started = false
|
|
51
|
+
SolidObjects.instrument(:"supervisor.stopped", component_count: components.length)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :components, :threads
|
|
57
|
+
|
|
58
|
+
# @rbs (worker_count: Integer, effect_worker_count: Integer, broadcast_worker_count: Integer, reminder_scheduler_count: Integer) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
|
|
59
|
+
def build_components(
|
|
60
|
+
worker_count:,
|
|
61
|
+
effect_worker_count:,
|
|
62
|
+
broadcast_worker_count:,
|
|
63
|
+
reminder_scheduler_count:
|
|
64
|
+
)
|
|
65
|
+
Array.new(worker_count) { Worker.new } +
|
|
66
|
+
Array.new(effect_worker_count) { EffectExecutor.new } +
|
|
67
|
+
Array.new(broadcast_worker_count) { BroadcastExecutor.new } +
|
|
68
|
+
Array.new(reminder_scheduler_count) { ReminderScheduler.new }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @rbs () -> void
|
|
72
|
+
def join_until_timeout
|
|
73
|
+
deadline = monotonic_now + SolidObjects.configuration.shutdown_timeout
|
|
74
|
+
threads.each do |thread|
|
|
75
|
+
remaining = deadline - monotonic_now
|
|
76
|
+
break unless remaining.positive?
|
|
77
|
+
|
|
78
|
+
thread.join(remaining)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @rbs () -> Float
|
|
83
|
+
def monotonic_now
|
|
84
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
module TurboStreamRenderer
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# @rbs (Broadcast) -> String
|
|
10
|
+
def observable(broadcast)
|
|
11
|
+
reference = Reference.new(
|
|
12
|
+
actor_type: broadcast.instance.actor_type,
|
|
13
|
+
actor_id: broadcast.instance.actor_id
|
|
14
|
+
)
|
|
15
|
+
observable_value(reference, broadcast.observable_name, broadcast.value)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @rbs (Reference, Symbol | String, untyped) -> String
|
|
19
|
+
def observable_value(reference, name, value)
|
|
20
|
+
target = DomIdentity.observable(reference, name)
|
|
21
|
+
content = ERB::Util.html_escape(display_value(value))
|
|
22
|
+
%(<turbo-stream action="replace" target="#{target}"><template><span id="#{target}">#{content}</span></template></turbo-stream>)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @rbs (untyped) -> String
|
|
26
|
+
def display_value(value)
|
|
27
|
+
return value if value.is_a?(String)
|
|
28
|
+
return value.to_s if value.is_a?(Numeric) || value == true || value == false
|
|
29
|
+
return "" if value.nil?
|
|
30
|
+
|
|
31
|
+
JSON.generate(value)
|
|
32
|
+
end
|
|
33
|
+
private_class_method :display_value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class WakeUp
|
|
5
|
+
# @rbs @mutex: Mutex
|
|
6
|
+
# @rbs @condition: Thread::ConditionVariable
|
|
7
|
+
|
|
8
|
+
# @rbs () -> void
|
|
9
|
+
def initialize
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@condition = Thread::ConditionVariable.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @rbs () -> void
|
|
15
|
+
def signal
|
|
16
|
+
mutex.synchronize { condition.broadcast }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @rbs (timeout: Numeric) -> void
|
|
20
|
+
def wait(timeout:)
|
|
21
|
+
mutex.synchronize { condition.wait(mutex, timeout) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :mutex, :condition
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "solid_objects/process_registry"
|
|
4
|
+
require "solid_objects/activation_manager"
|
|
5
|
+
require "solid_objects/activation"
|
|
6
|
+
require "solid_objects/executor"
|
|
7
|
+
require "solid_objects/lease_renewer"
|
|
8
|
+
|
|
9
|
+
module SolidObjects
|
|
10
|
+
class Worker
|
|
11
|
+
# @rbs @process_registry: ProcessRegistry
|
|
12
|
+
# @rbs @activation_manager: ActivationManager
|
|
13
|
+
# @rbs @activations: Hash[Integer, Activation]
|
|
14
|
+
# @rbs @stopped: bool
|
|
15
|
+
# @rbs @shutdown_requested: bool
|
|
16
|
+
|
|
17
|
+
# @rbs (?process_registry: ProcessRegistry) -> void
|
|
18
|
+
def initialize(process_registry: ProcessRegistry.new)
|
|
19
|
+
@process_registry = process_registry
|
|
20
|
+
process_record = process_registry.register
|
|
21
|
+
@activation_manager = ActivationManager.new(owner_id: process_record.id)
|
|
22
|
+
@activations = {}
|
|
23
|
+
@stopped = false
|
|
24
|
+
@shutdown_requested = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @rbs () -> Integer
|
|
28
|
+
def run_once
|
|
29
|
+
return 0 if stopped?
|
|
30
|
+
|
|
31
|
+
process_registry.heartbeat
|
|
32
|
+
maintain_cached_activations
|
|
33
|
+
activation = cached_ready_activation || activation_manager.claim_next
|
|
34
|
+
return 0 unless activation
|
|
35
|
+
|
|
36
|
+
activations[activation.lease.instance_id] = activation
|
|
37
|
+
processed = LeaseRenewer.new(
|
|
38
|
+
activation:,
|
|
39
|
+
process_registry:
|
|
40
|
+
).around { activation.drain }
|
|
41
|
+
release_activation(activation) if activation.pass_exhausted?
|
|
42
|
+
processed
|
|
43
|
+
rescue ActorDestroyed
|
|
44
|
+
release_activation(activation) if activation
|
|
45
|
+
0
|
|
46
|
+
rescue StateMigrationError, LostActivation => error
|
|
47
|
+
release_activation(activation) if activation
|
|
48
|
+
SolidObjects.configuration.logger.error(
|
|
49
|
+
event: "solid_objects.worker.error",
|
|
50
|
+
process_id: process_registry.process_record&.id,
|
|
51
|
+
error_class: error.class.name,
|
|
52
|
+
error_message: error.message
|
|
53
|
+
)
|
|
54
|
+
0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @rbs (?max_passes: Integer) -> Integer
|
|
58
|
+
def run_until_idle(max_passes: 1_000)
|
|
59
|
+
total = 0
|
|
60
|
+
|
|
61
|
+
max_passes.times do
|
|
62
|
+
processed = run_once
|
|
63
|
+
break if processed.zero?
|
|
64
|
+
|
|
65
|
+
total += processed
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
total
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @rbs () -> void
|
|
72
|
+
def run
|
|
73
|
+
until shutdown_requested?
|
|
74
|
+
processed = run_once
|
|
75
|
+
next if processed.positive?
|
|
76
|
+
|
|
77
|
+
SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
|
|
78
|
+
end
|
|
79
|
+
ensure
|
|
80
|
+
stop
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @rbs () -> void
|
|
84
|
+
def request_shutdown
|
|
85
|
+
@shutdown_requested = true
|
|
86
|
+
SolidObjects.wake_up.signal
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @rbs () -> void
|
|
90
|
+
def stop
|
|
91
|
+
return if stopped?
|
|
92
|
+
|
|
93
|
+
@stopped = true
|
|
94
|
+
process_registry.start_draining
|
|
95
|
+
activations.each_value(&:deactivate)
|
|
96
|
+
activations.clear
|
|
97
|
+
process_registry.stop
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @rbs () -> bool
|
|
101
|
+
def stopped?
|
|
102
|
+
@stopped
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @rbs () -> bool
|
|
106
|
+
def shutdown_requested?
|
|
107
|
+
@shutdown_requested
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
attr_reader :process_registry, :activation_manager, :activations
|
|
113
|
+
|
|
114
|
+
# @rbs () -> Activation?
|
|
115
|
+
def cached_ready_activation
|
|
116
|
+
activations.each_value.find(&:ready?)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @rbs () -> void
|
|
120
|
+
def maintain_cached_activations
|
|
121
|
+
activations.each_value do |activation|
|
|
122
|
+
if activation.idle?
|
|
123
|
+
release_activation(activation)
|
|
124
|
+
elsif activation.lease_renewal_due?
|
|
125
|
+
activation.renew_lease
|
|
126
|
+
end
|
|
127
|
+
rescue LostActivation
|
|
128
|
+
release_activation(activation)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @rbs (Activation) -> void
|
|
133
|
+
def release_activation(activation)
|
|
134
|
+
activations.delete(activation.lease.instance_id)
|
|
135
|
+
activation.yield_ready_messages if activation.pass_exhausted?
|
|
136
|
+
activation.deactivate
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|