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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/MIT-LICENSE +19 -0
  4. data/README.md +744 -0
  5. data/Rakefile +40 -0
  6. data/app/controllers/solid_objects/application_controller.rb +23 -0
  7. data/app/controllers/solid_objects/dead_letters_controller.rb +23 -0
  8. data/app/controllers/solid_objects/instances_controller.rb +29 -0
  9. data/app/helpers/solid_objects/actor_helper.rb +25 -0
  10. data/app/models/solid_objects/broadcast.rb +10 -0
  11. data/app/models/solid_objects/claimed_message.rb +14 -0
  12. data/app/models/solid_objects/dead_letter.rb +13 -0
  13. data/app/models/solid_objects/effect.rb +10 -0
  14. data/app/models/solid_objects/instance.rb +93 -0
  15. data/app/models/solid_objects/message.rb +53 -0
  16. data/app/models/solid_objects/process.rb +13 -0
  17. data/app/models/solid_objects/ready_message.rb +10 -0
  18. data/app/models/solid_objects/record.rb +17 -0
  19. data/app/models/solid_objects/reminder.rb +9 -0
  20. data/app/views/solid_objects/dead_letters/index.html.erb +26 -0
  21. data/app/views/solid_objects/instances/index.html.erb +24 -0
  22. data/app/views/solid_objects/instances/show.html.erb +35 -0
  23. data/benchmark/activation_cache.rb +5 -0
  24. data/benchmark/ask_latency.rb +5 -0
  25. data/benchmark/claim.rb +5 -0
  26. data/benchmark/cold_actors.rb +5 -0
  27. data/benchmark/concurrent_actors.rb +5 -0
  28. data/benchmark/enqueue.rb +5 -0
  29. data/benchmark/hot_actor.rb +5 -0
  30. data/benchmark/processing.rb +5 -0
  31. data/benchmark/query_count.rb +5 -0
  32. data/benchmark/support.rb +271 -0
  33. data/config/routes.rb +8 -0
  34. data/db/migrate/20260805000000_create_solid_objects_tables.rb +319 -0
  35. data/docs/adr/0001-postgresql-backend.md +21 -0
  36. data/docs/adr/0002-jsonb-actor-state.md +21 -0
  37. data/docs/adr/0003-mailbox-ordering.md +30 -0
  38. data/docs/adr/0004-activation-leasing.md +21 -0
  39. data/docs/adr/0005-fencing-tokens.md +25 -0
  40. data/docs/adr/0006-at-least-once-delivery.md +24 -0
  41. data/docs/adr/0007-transactional-outbox.md +21 -0
  42. data/docs/adr/0008-actor-communication.md +21 -0
  43. data/docs/adr/0009-realtime-updates.md +21 -0
  44. data/docs/adr/0010-state-versioning.md +29 -0
  45. data/docs/adr/0011-wake-up-strategy.md +34 -0
  46. data/docs/adr/0012-not-active-jobs.md +21 -0
  47. data/docs/adr/0013-database-adapters.md +48 -0
  48. data/docs/architecture.md +615 -0
  49. data/docs/benchmarks.md +26 -0
  50. data/docs/correctness.md +124 -0
  51. data/docs/database-schema.md +111 -0
  52. data/docs/development.md +87 -0
  53. data/docs/implementation-plan.md +518 -0
  54. data/docs/operations.md +123 -0
  55. data/docs/realtime.md +51 -0
  56. data/docs/research/solid_queue.md +545 -0
  57. data/docs/roadmap.md +53 -0
  58. data/docs/security.md +61 -0
  59. data/docs/state-migrations.md +46 -0
  60. data/examples/application/README.md +16 -0
  61. data/examples/application/app/actors/chat_room_actor.rb +34 -0
  62. data/examples/application/app/actors/shopping_cart_actor.rb +79 -0
  63. data/examples/application/app/controllers/cart_controller.rb +54 -0
  64. data/examples/application/app/controllers/chat_rooms_controller.rb +44 -0
  65. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +8 -0
  66. data/examples/application/app/views/actors/shopping_cart_actor/_summary.html.erb +10 -0
  67. data/examples/application/app/views/cart/show.html.erb +13 -0
  68. data/examples/application/app/views/chat_rooms/show.html.erb +8 -0
  69. data/examples/application/config/initializers/solid_objects.rb +23 -0
  70. data/examples/application/config/routes.rb +20 -0
  71. data/exe/solid_objects +9 -0
  72. data/lib/generators/solid_objects/install_generator.rb +21 -0
  73. data/lib/generators/solid_objects/templates/solid_objects.rb +13 -0
  74. data/lib/solid_objects/action_cable_broadcast_adapter.rb +19 -0
  75. data/lib/solid_objects/activation.rb +183 -0
  76. data/lib/solid_objects/activation_manager.rb +102 -0
  77. data/lib/solid_objects/actor.rb +271 -0
  78. data/lib/solid_objects/actor_channel.rb +29 -0
  79. data/lib/solid_objects/actor_definition.rb +212 -0
  80. data/lib/solid_objects/actor_registry.rb +65 -0
  81. data/lib/solid_objects/actor_snapshot.rb +42 -0
  82. data/lib/solid_objects/actor_view.rb +117 -0
  83. data/lib/solid_objects/broadcast_executor.rb +162 -0
  84. data/lib/solid_objects/cli.rb +118 -0
  85. data/lib/solid_objects/client.rb +153 -0
  86. data/lib/solid_objects/configuration.rb +168 -0
  87. data/lib/solid_objects/context.rb +41 -0
  88. data/lib/solid_objects/database_adapter.rb +82 -0
  89. data/lib/solid_objects/database_adapters/mysql.rb +22 -0
  90. data/lib/solid_objects/database_adapters/postgresql.rb +17 -0
  91. data/lib/solid_objects/database_adapters/sqlite.rb +12 -0
  92. data/lib/solid_objects/dead_letter_manager.rb +47 -0
  93. data/lib/solid_objects/dom_identity.rb +38 -0
  94. data/lib/solid_objects/effect_executor.rb +235 -0
  95. data/lib/solid_objects/effect_registry.rb +34 -0
  96. data/lib/solid_objects/engine.rb +33 -0
  97. data/lib/solid_objects/errors.rb +65 -0
  98. data/lib/solid_objects/executor.rb +290 -0
  99. data/lib/solid_objects/instrumentation.rb +10 -0
  100. data/lib/solid_objects/lease.rb +172 -0
  101. data/lib/solid_objects/lease_renewer.rb +70 -0
  102. data/lib/solid_objects/log_subscriber.rb +29 -0
  103. data/lib/solid_objects/mailbox.rb +178 -0
  104. data/lib/solid_objects/message_reference.rb +52 -0
  105. data/lib/solid_objects/process_registry.rb +143 -0
  106. data/lib/solid_objects/reference.rb +96 -0
  107. data/lib/solid_objects/reminder_scheduler.rb +168 -0
  108. data/lib/solid_objects/serialization.rb +99 -0
  109. data/lib/solid_objects/state.rb +111 -0
  110. data/lib/solid_objects/stream_name.rb +29 -0
  111. data/lib/solid_objects/stream_token.rb +59 -0
  112. data/lib/solid_objects/supervisor.rb +87 -0
  113. data/lib/solid_objects/turbo_stream_renderer.rb +35 -0
  114. data/lib/solid_objects/version.rb +5 -0
  115. data/lib/solid_objects/wake_up.rb +28 -0
  116. data/lib/solid_objects/worker.rb +139 -0
  117. data/lib/solid_objects.rb +118 -0
  118. data/sig/generated/controllers/solid_objects/application_controller.rbs +10 -0
  119. data/sig/generated/controllers/solid_objects/dead_letters_controller.rbs +11 -0
  120. data/sig/generated/controllers/solid_objects/instances_controller.rbs +11 -0
  121. data/sig/generated/helpers/solid_objects/actor_helper.rbs +8 -0
  122. data/sig/generated/lib/generators/solid_objects/install_generator.rbs +13 -0
  123. data/sig/generated/lib/solid_objects/action_cable_broadcast_adapter.rbs +8 -0
  124. data/sig/generated/lib/solid_objects/activation.rbs +65 -0
  125. data/sig/generated/lib/solid_objects/activation_manager.rbs +36 -0
  126. data/sig/generated/lib/solid_objects/actor.rbs +183 -0
  127. data/sig/generated/lib/solid_objects/actor_channel.rbs +8 -0
  128. data/sig/generated/lib/solid_objects/actor_definition.rbs +117 -0
  129. data/sig/generated/lib/solid_objects/actor_registry.rbs +36 -0
  130. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +28 -0
  131. data/sig/generated/lib/solid_objects/actor_view.rbs +56 -0
  132. data/sig/generated/lib/solid_objects/broadcast_executor.rbs +55 -0
  133. data/sig/generated/lib/solid_objects/cli.rbs +31 -0
  134. data/sig/generated/lib/solid_objects/client.rbs +35 -0
  135. data/sig/generated/lib/solid_objects/configuration.rbs +147 -0
  136. data/sig/generated/lib/solid_objects/context.rbs +56 -0
  137. data/sig/generated/lib/solid_objects/database_adapter.rbs +42 -0
  138. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +16 -0
  139. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +13 -0
  140. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +10 -0
  141. data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +16 -0
  142. data/sig/generated/lib/solid_objects/dom_identity.rbs +20 -0
  143. data/sig/generated/lib/solid_objects/effect_executor.rbs +82 -0
  144. data/sig/generated/lib/solid_objects/effect_registry.rbs +24 -0
  145. data/sig/generated/lib/solid_objects/engine.rbs +7 -0
  146. data/sig/generated/lib/solid_objects/errors.rbs +64 -0
  147. data/sig/generated/lib/solid_objects/executor.rbs +60 -0
  148. data/sig/generated/lib/solid_objects/instrumentation.rbs +8 -0
  149. data/sig/generated/lib/solid_objects/lease.rbs +57 -0
  150. data/sig/generated/lib/solid_objects/lease_renewer.rbs +42 -0
  151. data/sig/generated/lib/solid_objects/log_subscriber.rbs +11 -0
  152. data/sig/generated/lib/solid_objects/mailbox.rbs +46 -0
  153. data/sig/generated/lib/solid_objects/message_reference.rbs +37 -0
  154. data/sig/generated/lib/solid_objects/process_registry.rbs +46 -0
  155. data/sig/generated/lib/solid_objects/reference.rbs +45 -0
  156. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +55 -0
  157. data/sig/generated/lib/solid_objects/serialization.rbs +31 -0
  158. data/sig/generated/lib/solid_objects/state.rbs +72 -0
  159. data/sig/generated/lib/solid_objects/stream_name.rbs +11 -0
  160. data/sig/generated/lib/solid_objects/stream_token.rbs +19 -0
  161. data/sig/generated/lib/solid_objects/supervisor.rbs +38 -0
  162. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +14 -0
  163. data/sig/generated/lib/solid_objects/version.rbs +5 -0
  164. data/sig/generated/lib/solid_objects/wake_up.rbs +24 -0
  165. data/sig/generated/lib/solid_objects/worker.rbs +56 -0
  166. data/sig/generated/lib/solid_objects.rbs +41 -0
  167. data/sig/generated/models/solid_objects/broadcast.rbs +6 -0
  168. data/sig/generated/models/solid_objects/claimed_message.rbs +6 -0
  169. data/sig/generated/models/solid_objects/dead_letter.rbs +6 -0
  170. data/sig/generated/models/solid_objects/effect.rbs +6 -0
  171. data/sig/generated/models/solid_objects/instance.rbs +25 -0
  172. data/sig/generated/models/solid_objects/message.rbs +22 -0
  173. data/sig/generated/models/solid_objects/process.rbs +6 -0
  174. data/sig/generated/models/solid_objects/ready_message.rbs +6 -0
  175. data/sig/generated/models/solid_objects/record.rbs +8 -0
  176. data/sig/generated/models/solid_objects/reminder.rbs +6 -0
  177. data/sig/support/framework.rbs +37 -0
  178. 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,5 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ VERSION = "0.1.0"
5
+ 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