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,42 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ActorSnapshot
5
+ # @rbs @reference: Reference
6
+ # @rbs @actor_class: Class
7
+ # @rbs @actor: Actor
8
+
9
+ attr_reader :reference, :actor_class, :actor
10
+
11
+ # @rbs (Reference) -> void
12
+ def initialize(reference)
13
+ @reference = reference
14
+ @actor_class = SolidObjects.registry.fetch(reference.actor_type)
15
+ @actor = build_actor
16
+ end
17
+
18
+ # @rbs () -> Hash[String, untyped]
19
+ def observable_values
20
+ actor.observable_values
21
+ end
22
+
23
+ private
24
+
25
+ # @rbs () -> Actor
26
+ def build_actor
27
+ instance = Instance.find_by(
28
+ actor_type: reference.actor_type,
29
+ actor_id: reference.actor_id
30
+ )
31
+ state_version = instance&.state_version || actor_class.state_version
32
+ state_data = actor_class.definition.migrate_state(
33
+ state_version,
34
+ instance&.state || {}
35
+ )
36
+ actor_class.new(
37
+ actor_id: reference.actor_id,
38
+ state: State.new(actor_class.definition.state_definition, state_data)
39
+ )
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,117 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ActorView
5
+ # @rbs @reference: Reference
6
+ # @rbs @view_context: untyped
7
+ # @rbs @authorization_context: untyped
8
+ # @rbs @snapshot: ActorSnapshot
9
+
10
+ attr_reader :reference
11
+
12
+ # @rbs (reference: Reference, view_context: untyped, authorization_context: untyped) -> void
13
+ def initialize(reference:, view_context:, authorization_context:)
14
+ @reference = reference
15
+ @view_context = view_context
16
+ @authorization_context = authorization_context
17
+ @snapshot = ActorSnapshot.new(reference)
18
+ end
19
+
20
+ # @rbs (Symbol | String) -> untyped
21
+ def value(name)
22
+ observable_name = name.to_sym
23
+ handler = snapshot.actor_class.definition.observables[observable_name]
24
+ raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
25
+
26
+ authorize_read!(observable_name)
27
+ value = snapshot.observable_values.fetch(observable_name.to_s)
28
+ view_context.content_tag(
29
+ :span,
30
+ display_value(value),
31
+ id: DomIdentity.observable(reference, observable_name)
32
+ )
33
+ end
34
+
35
+ # @rbs (Symbol | String, ?partial: String?) -> untyped
36
+ def component(name, partial: nil)
37
+ component_name = normalized_component_name(name)
38
+ authorize_read!(component_name)
39
+ rendered = view_context.render(
40
+ partial: partial || default_partial(component_name),
41
+ locals: { actor: self }
42
+ )
43
+ view_context.content_tag(
44
+ :div,
45
+ rendered,
46
+ id: DomIdentity.component(reference, component_name)
47
+ )
48
+ end
49
+
50
+ # @rbs () -> State
51
+ def state
52
+ snapshot.actor.state
53
+ end
54
+
55
+ # @rbs (Symbol, *untyped, **untyped) -> untyped
56
+ def method_missing(name, *arguments, **keywords)
57
+ return value(name) if arguments.empty? && keywords.empty? && observable?(name)
58
+
59
+ super
60
+ end
61
+
62
+ # @rbs (Symbol, bool) -> bool
63
+ def respond_to_missing?(name, include_private = false)
64
+ observable?(name) || super
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :view_context, :authorization_context, :snapshot
70
+
71
+ # @rbs (Symbol | String) -> void
72
+ def authorize_read!(name)
73
+ authorized = SolidObjects.configuration.authorize_query.call(
74
+ actor_type: reference.actor_type,
75
+ actor_id: reference.actor_id,
76
+ message_name: name.to_s,
77
+ arguments: {},
78
+ authorization_context:
79
+ )
80
+ return if authorized
81
+
82
+ raise Unauthorized, "actor state query is not authorized"
83
+ end
84
+
85
+ # @rbs (Symbol | String) -> bool
86
+ def observable?(name)
87
+ snapshot.actor_class.definition.observables.key?(name.to_sym)
88
+ end
89
+
90
+ # @rbs (untyped) -> String
91
+ def display_value(value)
92
+ return value if value.is_a?(String)
93
+ return value.to_s if value.is_a?(Numeric) || value == true || value == false
94
+ return "" if value.nil?
95
+
96
+ JSON.generate(value)
97
+ end
98
+
99
+ # @rbs (Symbol | String) -> String
100
+ def normalized_component_name(name)
101
+ component_name = name.to_s
102
+ unless component_name.match?(/\A[a-zA-Z0-9_]+\z/)
103
+ raise ArgumentError, "component names may contain only letters, digits, and underscores"
104
+ end
105
+
106
+ component_name
107
+ end
108
+
109
+ # @rbs (String) -> String
110
+ def default_partial(component_name)
111
+ actor_name = snapshot.actor_class.name
112
+ raise InvalidActor, "anonymous actors must provide an explicit component partial" unless actor_name
113
+
114
+ "actors/#{actor_name.underscore}/#{component_name}"
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,162 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class BroadcastExecutor
5
+ # @rbs @process_registry: ProcessRegistry
6
+ # @rbs @database_adapter: DatabaseAdapter
7
+ # @rbs @stopped: bool
8
+ # @rbs @shutdown_requested: bool
9
+
10
+ # @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
11
+ def initialize(
12
+ process_registry: ProcessRegistry.new,
13
+ database_adapter: SolidObjects.database_adapter
14
+ )
15
+ @process_registry = process_registry
16
+ @database_adapter = database_adapter
17
+ process_registry.register(kind: "broadcast")
18
+ @stopped = false
19
+ @shutdown_requested = false
20
+ end
21
+
22
+ # @rbs () -> bool
23
+ def run_once
24
+ return false if stopped?
25
+
26
+ process_registry.heartbeat
27
+ broadcast = claim_next
28
+ return false unless broadcast
29
+
30
+ broadcast_adapter.call(broadcast)
31
+ complete(broadcast)
32
+ true
33
+ rescue => error
34
+ fail_broadcast(broadcast, error) if broadcast
35
+ false
36
+ end
37
+
38
+ # @rbs () -> void
39
+ def stop
40
+ return if stopped?
41
+
42
+ @stopped = true
43
+ process_registry.start_draining
44
+ process_registry.stop
45
+ end
46
+
47
+ # @rbs () -> void
48
+ def run
49
+ until shutdown_requested?
50
+ worked = run_once
51
+ next if worked
52
+
53
+ SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
54
+ end
55
+ ensure
56
+ stop
57
+ end
58
+
59
+ # @rbs () -> void
60
+ def request_shutdown
61
+ @shutdown_requested = true
62
+ SolidObjects.wake_up.signal
63
+ end
64
+
65
+ # @rbs () -> bool
66
+ def stopped?
67
+ @stopped
68
+ end
69
+
70
+ # @rbs () -> bool
71
+ def shutdown_requested?
72
+ @shutdown_requested
73
+ end
74
+
75
+ private
76
+
77
+ attr_reader :process_registry, :database_adapter
78
+
79
+ # @rbs () -> Broadcast?
80
+ def claim_next
81
+ database_adapter.transaction do
82
+ now = database_adapter.database_now
83
+ stale_at = now - SolidObjects.configuration.process_alive_threshold
84
+ relation = Broadcast
85
+ .where(status: "pending", available_at: ..now)
86
+ .or(Broadcast.where(status: "processing", claimed_at: ..stale_at))
87
+ .order(:available_at, :id)
88
+ broadcast = database_adapter.lock_candidates(relation).first
89
+ next unless broadcast
90
+
91
+ broadcast.update!(
92
+ status: "processing",
93
+ attempt_count: broadcast.attempt_count + 1,
94
+ claimed_by: process_registry.process_record.id,
95
+ claimed_at: now
96
+ )
97
+ broadcast
98
+ end
99
+ end
100
+
101
+ # @rbs () -> Proc | ActionCableBroadcastAdapter
102
+ def broadcast_adapter
103
+ SolidObjects.configuration.broadcast_adapter ||
104
+ ActionCableBroadcastAdapter.new
105
+ end
106
+
107
+ # @rbs (Broadcast) -> void
108
+ def complete(broadcast)
109
+ database_adapter.transaction do
110
+ locked_broadcast = Broadcast.lock.find(broadcast.id)
111
+ verify_claim!(locked_broadcast)
112
+ locked_broadcast.update!(
113
+ status: "delivered",
114
+ error: nil,
115
+ delivered_at: database_adapter.database_now,
116
+ claimed_by: nil,
117
+ claimed_at: nil
118
+ )
119
+ end
120
+ SolidObjects.instrument(
121
+ :"broadcast.delivered",
122
+ broadcast_id: broadcast.broadcast_id,
123
+ message_id: broadcast.message_id,
124
+ actor_type: broadcast.instance.actor_type,
125
+ actor_id: broadcast.instance.actor_id,
126
+ observable_name: broadcast.observable_name,
127
+ attempt: broadcast.attempt_count
128
+ )
129
+ end
130
+
131
+ # @rbs (Broadcast, Exception) -> void
132
+ def fail_broadcast(broadcast, error)
133
+ database_adapter.transaction do
134
+ locked_broadcast = Broadcast.lock.find(broadcast.id)
135
+ verify_claim!(locked_broadcast)
136
+ dead = locked_broadcast.attempt_count >= SolidObjects.configuration.max_attempts
137
+ locked_broadcast.update!(
138
+ status: dead ? "dead" : "pending",
139
+ available_at: database_adapter.database_now +
140
+ SolidObjects.configuration.retry_delay.call(locked_broadcast.attempt_count),
141
+ error: {
142
+ "class" => error.class.name,
143
+ "message" => error.message.to_s.byteslice(0, 8_192),
144
+ "backtrace" => Array(error.backtrace).first(50)
145
+ },
146
+ claimed_by: nil,
147
+ claimed_at: nil
148
+ )
149
+ end
150
+ rescue ActiveRecord::RecordNotFound, LostActivation
151
+ nil
152
+ end
153
+
154
+ # @rbs (Broadcast) -> void
155
+ def verify_claim!(broadcast)
156
+ return if broadcast.status == "processing" &&
157
+ broadcast.claimed_by == process_registry.process_record.id
158
+
159
+ raise LostActivation, "broadcast claim changed"
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,118 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "thor"
4
+
5
+ module SolidObjects
6
+ class CLI < Thor
7
+ package_name "solid_objects"
8
+
9
+ desc "start", "Start actor, effect, reminder, and broadcast processes"
10
+ option :environment, type: :string, aliases: "-e"
11
+ option :workers, type: :numeric
12
+ option :effect_workers, type: :numeric
13
+ option :broadcast_workers, type: :numeric
14
+ option :reminder_schedulers, type: :numeric
15
+
16
+ # @rbs () -> void
17
+ def start
18
+ boot_application
19
+ supervisor = Supervisor.new(
20
+ worker_count: numeric_option(:workers, SolidObjects.configuration.worker_count),
21
+ effect_worker_count: numeric_option(:effect_workers, SolidObjects.configuration.effect_worker_count),
22
+ broadcast_worker_count: numeric_option(:broadcast_workers, SolidObjects.configuration.broadcast_worker_count),
23
+ reminder_scheduler_count: numeric_option(:reminder_schedulers, SolidObjects.configuration.reminder_scheduler_count)
24
+ )
25
+ %w[INT TERM].each do |signal|
26
+ Signal.trap(signal) { Thread.new { supervisor.stop } }
27
+ end
28
+ supervisor.run
29
+ end
30
+
31
+ desc "status", "Print registered runtime processes"
32
+ option :environment, type: :string, aliases: "-e"
33
+
34
+ # @rbs () -> void
35
+ def status
36
+ boot_application
37
+ authorize_administration!(:status)
38
+ rows = SolidObjects::Process.order(:kind, :started_at).map do |process_record|
39
+ {
40
+ id: process_record.id,
41
+ kind: process_record.kind,
42
+ hostname: process_record.hostname,
43
+ pid: process_record.pid,
44
+ state: process_record.shutdown_state,
45
+ last_heartbeat_at: process_record.last_heartbeat_at
46
+ }
47
+ end
48
+ puts JSON.pretty_generate(rows)
49
+ end
50
+
51
+ desc "cleanup", "Release coordination records owned by dead processes"
52
+ option :environment, type: :string, aliases: "-e"
53
+
54
+ # @rbs () -> void
55
+ def cleanup
56
+ boot_application
57
+ authorize_administration!(:cleanup)
58
+ puts JSON.generate(cleaned_processes: ProcessRegistry.cleanup_dead)
59
+ end
60
+
61
+ desc "dead_letters", "Print dead actor messages"
62
+ option :environment, type: :string, aliases: "-e"
63
+
64
+ # @rbs () -> void
65
+ def dead_letters
66
+ boot_application
67
+ rows = SolidObjects.dead_letters
68
+ .all(authorization_context: { source: "cli" })
69
+ .map(&:attributes)
70
+ puts JSON.pretty_generate(rows)
71
+ end
72
+
73
+ desc "retry_dead_letter ID", "Retry one dead actor message"
74
+ option :environment, type: :string, aliases: "-e"
75
+
76
+ # @rbs (String) -> void
77
+ def retry_dead_letter(id)
78
+ boot_application
79
+ message_reference = SolidObjects.dead_letters.retry(
80
+ Integer(id),
81
+ authorization_context: { source: "cli" }
82
+ )
83
+ puts JSON.generate(message_id: message_reference.id)
84
+ end
85
+
86
+ private
87
+
88
+ # @rbs () -> void
89
+ def boot_application
90
+ path = options[:environment] ||
91
+ File.expand_path("config/environment.rb", Dir.pwd)
92
+ unless File.file?(path)
93
+ raise Error, "Rails environment not found at #{path}"
94
+ end
95
+
96
+ require path
97
+ end
98
+
99
+ # @rbs (Symbol, Integer) -> Integer
100
+ def numeric_option(name, default)
101
+ value = options[name]
102
+ value ? Integer(value) : default
103
+ end
104
+
105
+ # @rbs (Symbol) -> void
106
+ def authorize_administration!(action)
107
+ authorized = SolidObjects.configuration.authorize_administration.call(
108
+ action:,
109
+ resource: "processes",
110
+ resource_id: nil,
111
+ authorization_context: { source: "cli" }
112
+ )
113
+ return if authorized
114
+
115
+ raise Unauthorized, "actor administration is not authorized"
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,153 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "solid_objects/mailbox"
4
+
5
+ module SolidObjects
6
+ class Client
7
+ # @rbs @mailbox: Mailbox
8
+
9
+ # @rbs (?mailbox: Mailbox) -> void
10
+ def initialize(mailbox: Mailbox.new)
11
+ @mailbox = mailbox
12
+ end
13
+
14
+ # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference
15
+ def tell(reference, message_name, arguments, available_at: nil, idempotency_key: nil, authorization_context: nil)
16
+ actor_class = SolidObjects.registry.fetch(reference.actor_type)
17
+ message_symbol = message_name.to_sym
18
+ raise UnknownMessage, "unknown message #{message_name.inspect}" unless actor_class.definition.messages.key?(message_symbol)
19
+
20
+ authorize!(
21
+ SolidObjects.configuration.authorize_message,
22
+ reference,
23
+ message_name,
24
+ arguments,
25
+ authorization_context:
26
+ )
27
+ mailbox.enqueue(
28
+ reference,
29
+ message_name,
30
+ arguments,
31
+ kind: "tell",
32
+ available_at:,
33
+ idempotency_key:
34
+ )
35
+ end
36
+
37
+ # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], timeout: Numeric, ?idempotency_key: String?, ?authorization_context: untyped) -> untyped
38
+ def ask(reference, message_name, arguments, timeout:, idempotency_key: nil, authorization_context: nil)
39
+ actor_class = SolidObjects.registry.fetch(reference.actor_type)
40
+ message_symbol = message_name.to_sym
41
+ query = actor_class.definition.queries.key?(message_symbol)
42
+ actor_message = actor_class.definition.messages.key?(message_symbol)
43
+ raise UnknownMessage, "unknown message #{message_name.inspect}" unless query || actor_message
44
+
45
+ authorize!(
46
+ query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
47
+ reference,
48
+ message_name,
49
+ arguments,
50
+ authorization_context:
51
+ )
52
+ message_reference = mailbox.enqueue(
53
+ reference,
54
+ message_name,
55
+ arguments,
56
+ kind: "ask",
57
+ idempotency_key:
58
+ )
59
+ wait_for_result(message_reference, timeout:)
60
+ end
61
+
62
+ # @rbs (Reference, ?authorization_context: untyped) -> bool
63
+ def destroy(reference, authorization_context: nil)
64
+ raise ActorCallCycle, "actors cannot synchronously destroy another actor" if Context.current_actor
65
+
66
+ SolidObjects.registry.fetch(reference.actor_type)
67
+ authorize_destroy!(reference, authorization_context:)
68
+ instance_id = SolidObjects.database_adapter.transaction do
69
+ instance = Instance.lock.find_by(
70
+ actor_type: reference.actor_type,
71
+ actor_id: reference.actor_id
72
+ )
73
+ next unless instance
74
+
75
+ instance_id = instance.id
76
+ instance.delete
77
+ instance_id
78
+ end
79
+ return false unless instance_id
80
+
81
+ SolidObjects.instrument(
82
+ :"actor.destroyed",
83
+ instance_id:,
84
+ actor_type: reference.actor_type,
85
+ actor_id: reference.actor_id
86
+ )
87
+ SolidObjects.wake_up.signal
88
+ true
89
+ end
90
+
91
+ private
92
+
93
+ attr_reader :mailbox
94
+
95
+ # @rbs (Proc, Reference, Symbol | String, Hash[Symbol | String, untyped], authorization_context: untyped) -> void
96
+ def authorize!(hook, reference, message_name, arguments, authorization_context:)
97
+ authorized = hook.call(
98
+ actor_type: reference.actor_type,
99
+ actor_id: reference.actor_id,
100
+ message_name: message_name.to_s,
101
+ arguments:,
102
+ authorization_context:
103
+ )
104
+ return if authorized
105
+
106
+ raise Unauthorized, "actor invocation is not authorized"
107
+ end
108
+
109
+ # @rbs (Reference, authorization_context: untyped) -> void
110
+ def authorize_destroy!(reference, authorization_context:)
111
+ authorized = SolidObjects.configuration.authorize_destroy.call(
112
+ actor_type: reference.actor_type,
113
+ actor_id: reference.actor_id,
114
+ authorization_context:
115
+ )
116
+ return if authorized
117
+
118
+ raise Unauthorized, "actor destruction is not authorized"
119
+ end
120
+
121
+ # @rbs (MessageReference, timeout: Numeric) -> untyped
122
+ def wait_for_result(message_reference, timeout:)
123
+ deadline = monotonic_now + timeout.to_f
124
+
125
+ loop do
126
+ message = Message.uncached { Message.find(message_reference.id) }
127
+ return message.result if message.completed?
128
+
129
+ if message.dead?
130
+ raise MessageFailed.new(
131
+ "actor message failed permanently",
132
+ message_id: message.id,
133
+ details: message.error || {}
134
+ )
135
+ end
136
+
137
+ remaining = deadline - monotonic_now
138
+ raise AskTimeout, "actor request timed out after #{timeout} seconds" unless remaining.positive?
139
+
140
+ SolidObjects.wake_up.wait(
141
+ timeout: [ remaining, SolidObjects.configuration.ask_polling_interval ].min
142
+ )
143
+ end
144
+ rescue ActiveRecord::RecordNotFound
145
+ raise ActorDestroyed, "actor was destroyed while waiting for its result"
146
+ end
147
+
148
+ # @rbs () -> Float
149
+ def monotonic_now
150
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
151
+ end
152
+ end
153
+ end