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,168 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Configuration
5
+ # @rbs @table_name_prefix: String
6
+ # @rbs @polling_interval: Float
7
+ # @rbs @ask_polling_interval: Float
8
+ # @rbs @lease_duration: Float
9
+ # @rbs @lease_renewal_interval: Float
10
+ # @rbs @idle_deactivation_timeout: Float
11
+ # @rbs @max_messages_per_activation_pass: Integer
12
+ # @rbs @max_activation_duration: Float
13
+ # @rbs @max_mailbox_length: Integer
14
+ # @rbs @claim_scan_limit: Integer
15
+ # @rbs @max_payload_bytes: Integer
16
+ # @rbs @max_state_bytes: Integer
17
+ # @rbs @max_result_bytes: Integer
18
+ # @rbs @max_attempts: Integer
19
+ # @rbs @retry_delay: Proc
20
+ # @rbs @process_heartbeat_interval: Float
21
+ # @rbs @process_alive_threshold: Float
22
+ # @rbs @shutdown_timeout: Float
23
+ # @rbs @worker_count: Integer
24
+ # @rbs @effect_worker_count: Integer
25
+ # @rbs @broadcast_worker_count: Integer
26
+ # @rbs @reminder_scheduler_count: Integer
27
+ # @rbs @connects_to: Hash[Symbol, untyped]?
28
+ # @rbs @logger: untyped
29
+ # @rbs @stream_signing_secret: String?
30
+ # @rbs @broadcast_adapter: Proc?
31
+ # @rbs @wake_up_adapter: untyped
32
+ # @rbs @authorize_message: Proc
33
+ # @rbs @authorize_query: Proc
34
+ # @rbs @authorize_destroy: Proc
35
+ # @rbs @authorize_subscription: Proc
36
+ # @rbs @authorize_administration: Proc
37
+
38
+ attr_accessor :table_name_prefix,
39
+ :polling_interval,
40
+ :ask_polling_interval,
41
+ :lease_duration,
42
+ :lease_renewal_interval,
43
+ :idle_deactivation_timeout,
44
+ :max_messages_per_activation_pass,
45
+ :max_activation_duration,
46
+ :max_mailbox_length,
47
+ :claim_scan_limit,
48
+ :max_payload_bytes,
49
+ :max_state_bytes,
50
+ :max_result_bytes,
51
+ :max_attempts,
52
+ :retry_delay,
53
+ :process_heartbeat_interval,
54
+ :process_alive_threshold,
55
+ :shutdown_timeout,
56
+ :worker_count,
57
+ :effect_worker_count,
58
+ :broadcast_worker_count,
59
+ :reminder_scheduler_count,
60
+ :connects_to,
61
+ :logger,
62
+ :stream_signing_secret,
63
+ :broadcast_adapter,
64
+ :wake_up_adapter,
65
+ :authorize_message,
66
+ :authorize_query,
67
+ :authorize_destroy,
68
+ :authorize_subscription,
69
+ :authorize_administration
70
+
71
+ # @rbs () -> void
72
+ def initialize
73
+ @table_name_prefix = "solid_objects_"
74
+ @polling_interval = 0.1
75
+ @ask_polling_interval = 0.05
76
+ @lease_duration = 30.0
77
+ @lease_renewal_interval = 10.0
78
+ @idle_deactivation_timeout = 30.0
79
+ @max_messages_per_activation_pass = 50
80
+ @max_activation_duration = 5.0
81
+ @max_mailbox_length = 10_000
82
+ @claim_scan_limit = 100
83
+ @max_payload_bytes = 1.megabyte
84
+ @max_state_bytes = 5.megabytes
85
+ @max_result_bytes = 1.megabyte
86
+ @max_attempts = 5
87
+ @retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f }
88
+ @process_heartbeat_interval = 15.0
89
+ @process_alive_threshold = 60.0
90
+ @shutdown_timeout = 15.0
91
+ @worker_count = 1
92
+ @effect_worker_count = 1
93
+ @broadcast_worker_count = 1
94
+ @reminder_scheduler_count = 1
95
+ @connects_to = nil
96
+ @stream_signing_secret = nil
97
+ @broadcast_adapter = nil
98
+ @wake_up_adapter = nil
99
+ @logger = if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
100
+ Rails.logger
101
+ else
102
+ Logger.new($stdout)
103
+ end
104
+ @authorize_message = ->(**) { false }
105
+ @authorize_query = ->(**) { false }
106
+ @authorize_destroy = ->(**) { false }
107
+ @authorize_subscription = ->(**) { false }
108
+ @authorize_administration = ->(**) { false }
109
+ end
110
+
111
+ # @rbs () -> self
112
+ def validate!
113
+ unless table_name_prefix.match?(/\A[a-z][a-z0-9_]*\z/)
114
+ raise ArgumentError, "table_name_prefix must contain lowercase letters, digits, and underscores"
115
+ end
116
+
117
+ unless lease_duration > lease_renewal_interval
118
+ raise ArgumentError, "lease_duration must be greater than lease_renewal_interval"
119
+ end
120
+
121
+ component_counts.each do |name, value|
122
+ raise ArgumentError, "#{name} must not be negative" if value.negative?
123
+ end
124
+ if component_counts.values.sum.zero?
125
+ raise ArgumentError, "at least one runtime component must be configured"
126
+ end
127
+
128
+ positive_values.each do |name, value|
129
+ raise ArgumentError, "#{name} must be positive" unless value.positive?
130
+ end
131
+
132
+ self
133
+ end
134
+
135
+ private
136
+
137
+ # @rbs () -> Hash[Symbol, Numeric]
138
+ def positive_values
139
+ {
140
+ polling_interval:,
141
+ ask_polling_interval:,
142
+ lease_duration:,
143
+ lease_renewal_interval:,
144
+ max_messages_per_activation_pass:,
145
+ max_activation_duration:,
146
+ max_mailbox_length:,
147
+ claim_scan_limit:,
148
+ max_payload_bytes:,
149
+ max_state_bytes:,
150
+ max_result_bytes:,
151
+ max_attempts:,
152
+ process_heartbeat_interval:,
153
+ process_alive_threshold:,
154
+ shutdown_timeout:
155
+ }
156
+ end
157
+
158
+ # @rbs () -> Hash[Symbol, Integer]
159
+ def component_counts
160
+ {
161
+ worker_count:,
162
+ effect_worker_count:,
163
+ broadcast_worker_count:,
164
+ reminder_scheduler_count:
165
+ }
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,41 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ MessageContext = Data.define(:id, :attempt, :enqueued_at, :idempotency_key, :request_id)
5
+
6
+ module Context
7
+ STORAGE_KEY = :solid_objects_context
8
+ Frame = Data.define(:actor, :message, :authorization_context)
9
+
10
+ class << self
11
+ # @rbs () -> Frame?
12
+ def current
13
+ ActiveSupport::IsolatedExecutionState[STORAGE_KEY]
14
+ end
15
+
16
+ # @rbs () -> Actor?
17
+ def current_actor
18
+ current&.actor
19
+ end
20
+
21
+ # @rbs () -> MessageContext?
22
+ def current_message
23
+ current&.message
24
+ end
25
+
26
+ # @rbs () -> untyped
27
+ def authorization_context
28
+ current&.authorization_context
29
+ end
30
+
31
+ # @rbs (actor: Actor?, message: MessageContext?, authorization_context: untyped) { () -> untyped } -> untyped
32
+ def with(actor:, message:, authorization_context: nil)
33
+ previous = current
34
+ ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = Frame.new(actor:, message:, authorization_context:)
35
+ yield
36
+ ensure
37
+ ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = previous
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,82 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "time"
4
+
5
+ module SolidObjects
6
+ class DatabaseAdapter
7
+ class << self
8
+ # @rbs (untyped) -> DatabaseAdapter
9
+ def for(connection)
10
+ case connection.adapter_name
11
+ when /postgres/i
12
+ DatabaseAdapters::Postgresql.new(connection)
13
+ when /mysql/i
14
+ DatabaseAdapters::Mysql.new(connection)
15
+ when /sqlite/i
16
+ DatabaseAdapters::Sqlite.new(connection)
17
+ else
18
+ raise UnsupportedDatabase, "unsupported database adapter #{connection.adapter_name.inspect}"
19
+ end
20
+ end
21
+ end
22
+
23
+ # @rbs @connection_pool: untyped
24
+ # @rbs @fixed_connection: untyped
25
+
26
+ # @rbs (untyped) -> void
27
+ def initialize(connection)
28
+ @connection_pool = connection.respond_to?(:pool) ? connection.pool : nil
29
+ @fixed_connection = connection_pool ? nil : connection
30
+ end
31
+
32
+ # @rbs () -> bool
33
+ def supports_skip_locked?
34
+ false
35
+ end
36
+
37
+ # @rbs () -> String?
38
+ def claim_lock
39
+ nil
40
+ end
41
+
42
+ # @rbs () -> String
43
+ def current_time_expression
44
+ "CURRENT_TIMESTAMP"
45
+ end
46
+
47
+ # @rbs () -> Time
48
+ def database_now
49
+ value = with_connection do |connection|
50
+ connection.select_value("SELECT #{current_time_expression}")
51
+ end
52
+ value.is_a?(Time) ? value.utc : Time.parse("#{value} UTC").utc
53
+ end
54
+
55
+ # @rbs () { () -> untyped } -> untyped
56
+ def transaction(&block)
57
+ with_connection do |connection|
58
+ connection.transaction(requires_new: true, &block)
59
+ end
60
+ end
61
+
62
+ # @rbs (ActiveRecord::Relation[untyped]) -> ActiveRecord::Relation[untyped]
63
+ def lock_candidates(relation)
64
+ claim_lock ? relation.lock(claim_lock) : relation
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :connection_pool, :fixed_connection
70
+
71
+ # @rbs () { (untyped) -> untyped } -> untyped
72
+ def with_connection
73
+ return yield fixed_connection unless connection_pool
74
+
75
+ connection_pool.with_connection { |connection| yield connection }
76
+ end
77
+ end
78
+ end
79
+
80
+ require "solid_objects/database_adapters/postgresql"
81
+ require "solid_objects/database_adapters/mysql"
82
+ require "solid_objects/database_adapters/sqlite"
@@ -0,0 +1,22 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ module DatabaseAdapters
5
+ class Mysql < DatabaseAdapter
6
+ # @rbs () -> bool
7
+ def supports_skip_locked?
8
+ true
9
+ end
10
+
11
+ # @rbs () -> String
12
+ def claim_lock
13
+ "FOR UPDATE SKIP LOCKED"
14
+ end
15
+
16
+ # @rbs () -> String
17
+ def current_time_expression
18
+ "CURRENT_TIMESTAMP(6)"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ module DatabaseAdapters
5
+ class Postgresql < DatabaseAdapter
6
+ # @rbs () -> bool
7
+ def supports_skip_locked?
8
+ true
9
+ end
10
+
11
+ # @rbs () -> String
12
+ def claim_lock
13
+ "FOR UPDATE SKIP LOCKED"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ module DatabaseAdapters
5
+ class Sqlite < DatabaseAdapter
6
+ # @rbs () -> String
7
+ def current_time_expression
8
+ "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,47 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class DeadLetterManager
5
+ # @rbs (?authorization_context: untyped) -> ActiveRecord::Relation[DeadLetter]
6
+ def all(authorization_context: nil)
7
+ authorize!(:inspect, authorization_context:)
8
+ DeadLetter.order(last_failed_at: :desc, id: :desc)
9
+ end
10
+
11
+ # @rbs (Integer, ?authorization_context: untyped) -> MessageReference
12
+ def retry(dead_letter_id, authorization_context: nil)
13
+ authorize!(:retry, authorization_context:, dead_letter_id:)
14
+ dead_letter = DeadLetter.find(dead_letter_id)
15
+ return MessageReference.from_message(Message.find(dead_letter.retried_message_id)) if dead_letter.retried_message_id
16
+
17
+ original_message = dead_letter.message
18
+ message_reference = Mailbox.new.enqueue(
19
+ Reference.new(
20
+ actor_type: dead_letter.actor_type,
21
+ actor_id: dead_letter.actor_id
22
+ ),
23
+ dead_letter.message_name,
24
+ dead_letter.arguments,
25
+ kind: original_message.message_kind,
26
+ idempotency_key: "dead-letter:#{dead_letter.id}"
27
+ )
28
+ dead_letter.update!(retried_message_id: message_reference.id)
29
+ message_reference
30
+ end
31
+
32
+ private
33
+
34
+ # @rbs (Symbol, authorization_context: untyped, ?dead_letter_id: Integer?) -> void
35
+ def authorize!(action, authorization_context:, dead_letter_id: nil)
36
+ authorized = SolidObjects.configuration.authorize_administration.call(
37
+ action: action.to_s,
38
+ resource: "dead_letters",
39
+ resource_id: dead_letter_id,
40
+ authorization_context:
41
+ )
42
+ return if authorized
43
+
44
+ raise Unauthorized, "actor administration is not authorized"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,38 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "digest"
4
+
5
+ module SolidObjects
6
+ module DomIdentity
7
+ module_function
8
+
9
+ # @rbs (Reference) -> String
10
+ def scope(reference)
11
+ "solid_objects_actor_#{identity_digest(reference)}"
12
+ end
13
+
14
+ # @rbs (Reference, Symbol | String) -> String
15
+ def observable(reference, name)
16
+ "#{scope(reference)}_observable_#{normalized_name(name)}"
17
+ end
18
+
19
+ # @rbs (Reference, Symbol | String) -> String
20
+ def component(reference, name)
21
+ "#{scope(reference)}_component_#{normalized_name(name)}"
22
+ end
23
+
24
+ # @rbs (Reference) -> String
25
+ def identity_digest(reference)
26
+ Digest::SHA256.hexdigest(
27
+ "#{reference.actor_type}\0#{reference.actor_id}"
28
+ ).first(24)
29
+ end
30
+ private_class_method :identity_digest
31
+
32
+ # @rbs (Symbol | String) -> String
33
+ def normalized_name(name)
34
+ name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
35
+ end
36
+ private_class_method :normalized_name
37
+ end
38
+ end
@@ -0,0 +1,235 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ EffectContext = Data.define(:id, :attempt, :source_message_id, :actor_type, :actor_id)
5
+
6
+ class EffectExecutor
7
+ ACTOR_MESSAGE_EFFECT = "__actor_message__"
8
+
9
+ # @rbs @process_registry: ProcessRegistry
10
+ # @rbs @database_adapter: DatabaseAdapter
11
+ # @rbs @stopped: bool
12
+ # @rbs @shutdown_requested: bool
13
+
14
+ # @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
15
+ def initialize(
16
+ process_registry: ProcessRegistry.new,
17
+ database_adapter: SolidObjects.database_adapter
18
+ )
19
+ @process_registry = process_registry
20
+ @database_adapter = database_adapter
21
+ process_registry.register(kind: "effect")
22
+ @stopped = false
23
+ @shutdown_requested = false
24
+ end
25
+
26
+ # @rbs () -> bool
27
+ def run_once
28
+ return false if stopped?
29
+
30
+ process_registry.heartbeat
31
+ effect = claim_next
32
+ return false unless effect
33
+
34
+ result = deliver(effect)
35
+ complete(effect, result)
36
+ true
37
+ rescue => error
38
+ fail_effect(effect, error) if effect
39
+ false
40
+ end
41
+
42
+ # @rbs () -> void
43
+ def stop
44
+ return if stopped?
45
+
46
+ @stopped = true
47
+ process_registry.start_draining
48
+ process_registry.stop
49
+ end
50
+
51
+ # @rbs () -> void
52
+ def run
53
+ until shutdown_requested?
54
+ worked = run_once
55
+ next if worked
56
+
57
+ SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
58
+ end
59
+ ensure
60
+ stop
61
+ end
62
+
63
+ # @rbs () -> void
64
+ def request_shutdown
65
+ @shutdown_requested = true
66
+ SolidObjects.wake_up.signal
67
+ end
68
+
69
+ # @rbs () -> bool
70
+ def stopped?
71
+ @stopped
72
+ end
73
+
74
+ # @rbs () -> bool
75
+ def shutdown_requested?
76
+ @shutdown_requested
77
+ end
78
+
79
+ private
80
+
81
+ attr_reader :process_registry, :database_adapter
82
+
83
+ # @rbs () -> Effect?
84
+ def claim_next
85
+ database_adapter.transaction do
86
+ now = database_adapter.database_now
87
+ effect = database_adapter.lock_candidates(
88
+ Effect.where(status: "pending", available_at: ..now).order(:available_at, :id)
89
+ ).first
90
+ next unless effect
91
+
92
+ effect.update!(
93
+ status: "processing",
94
+ attempt_count: effect.attempt_count + 1,
95
+ claimed_by: process_registry.process_record.id,
96
+ claimed_at: now
97
+ )
98
+ effect
99
+ end
100
+ end
101
+
102
+ # @rbs (Effect) -> untyped
103
+ def deliver(effect)
104
+ return deliver_actor_message(effect) if effect.name == ACTOR_MESSAGE_EFFECT
105
+
106
+ handler = SolidObjects.effect_registry.fetch(effect.name)
107
+ context = EffectContext.new(
108
+ id: effect.effect_id,
109
+ attempt: effect.attempt_count,
110
+ source_message_id: effect.message_id,
111
+ actor_type: effect.instance.actor_type,
112
+ actor_id: effect.instance.actor_id
113
+ )
114
+ handler.call(effect.arguments, context)
115
+ end
116
+
117
+ # @rbs (Effect) -> Integer
118
+ def deliver_actor_message(effect)
119
+ arguments = effect.arguments
120
+ reference = Reference.new(
121
+ actor_type: arguments.fetch("actor_type"),
122
+ actor_id: arguments.fetch("actor_id")
123
+ )
124
+ actor_class = SolidObjects.registry.fetch(reference.actor_type)
125
+ message_name = arguments.fetch("message_name")
126
+ unless actor_class.definition.messages.key?(message_name.to_sym)
127
+ raise UnknownMessage, "unknown actor-to-actor message #{message_name.inspect}"
128
+ end
129
+
130
+ available_at = arguments["available_at"]
131
+ available_at = Time.iso8601(available_at) if available_at
132
+ Mailbox.new.enqueue(
133
+ reference,
134
+ message_name,
135
+ arguments.fetch("arguments"),
136
+ kind: "internal",
137
+ available_at:,
138
+ idempotency_key: effect.effect_id
139
+ ).id
140
+ end
141
+
142
+ # @rbs (Effect, untyped) -> void
143
+ def complete(effect, result)
144
+ serialized_result = Serialization.dump(
145
+ result,
146
+ max_bytes: SolidObjects.configuration.max_result_bytes
147
+ )
148
+ result_message = nil
149
+ database_adapter.transaction do
150
+ locked_effect = Effect.lock.find(effect.id)
151
+ verify_claim!(locked_effect)
152
+ result_message = enqueue_result_message(
153
+ locked_effect,
154
+ locked_effect.success_message_name,
155
+ "success",
156
+ { "effect_id" => locked_effect.effect_id, "result" => serialized_result }
157
+ )
158
+ locked_effect.update!(
159
+ status: "completed",
160
+ result: serialized_result,
161
+ error: nil,
162
+ completed_at: database_adapter.database_now,
163
+ claimed_by: nil,
164
+ claimed_at: nil
165
+ )
166
+ end
167
+ Mailbox.new.announce(result_message) if result_message
168
+ SolidObjects.instrument(
169
+ :"effect.completed",
170
+ effect_id: effect.effect_id,
171
+ effect_name: effect.name,
172
+ message_id: effect.message_id,
173
+ attempt: effect.attempt_count
174
+ )
175
+ end
176
+
177
+ # @rbs (Effect, Exception) -> void
178
+ def fail_effect(effect, error)
179
+ result_message = nil
180
+ database_adapter.transaction do
181
+ locked_effect = Effect.lock.find(effect.id)
182
+ verify_claim!(locked_effect)
183
+ dead = locked_effect.attempt_count >= locked_effect.max_attempts
184
+ error_details = {
185
+ "class" => error.class.name,
186
+ "message" => error.message.to_s.byteslice(0, 8_192),
187
+ "backtrace" => Array(error.backtrace).first(50)
188
+ }
189
+ if dead
190
+ result_message = enqueue_result_message(
191
+ locked_effect,
192
+ locked_effect.failure_message_name,
193
+ "failure",
194
+ { "effect_id" => locked_effect.effect_id, "error" => error_details }
195
+ )
196
+ end
197
+ locked_effect.update!(
198
+ status: dead ? "dead" : "pending",
199
+ available_at: database_adapter.database_now +
200
+ SolidObjects.configuration.retry_delay.call(locked_effect.attempt_count),
201
+ error: error_details,
202
+ claimed_by: nil,
203
+ claimed_at: nil
204
+ )
205
+ end
206
+ Mailbox.new.announce(result_message) if result_message
207
+ rescue ActiveRecord::RecordNotFound, LostActivation
208
+ nil
209
+ end
210
+
211
+ # @rbs (Effect) -> void
212
+ def verify_claim!(effect)
213
+ return if effect.status == "processing" &&
214
+ effect.claimed_by == process_registry.process_record.id
215
+
216
+ raise LostActivation, "effect claim changed"
217
+ end
218
+
219
+ # @rbs (Effect, String?, String, Hash[String, untyped]) -> Message?
220
+ def enqueue_result_message(effect, message_name, outcome, arguments)
221
+ return unless message_name
222
+
223
+ Mailbox.new.enqueue_in_transaction(
224
+ Reference.new(
225
+ actor_type: effect.instance.actor_type,
226
+ actor_id: effect.instance.actor_id
227
+ ),
228
+ message_name,
229
+ arguments,
230
+ kind: "internal",
231
+ idempotency_key: "effect:#{effect.effect_id}:#{outcome}"
232
+ )
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,34 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class EffectRegistry
5
+ # @rbs @handlers: Hash[String, Proc]
6
+ # @rbs @mutex: Mutex
7
+
8
+ # @rbs () -> void
9
+ def initialize
10
+ @handlers = {}
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ # @rbs (String | Symbol, Proc) -> Proc
15
+ def register(name, handler)
16
+ effect_name = name.to_s
17
+ raise ArgumentError, "effect name cannot be empty" if effect_name.empty?
18
+
19
+ mutex.synchronize { handlers[effect_name] = handler }
20
+ handler
21
+ end
22
+
23
+ # @rbs (String | Symbol) -> Proc
24
+ def fetch(name)
25
+ handlers.fetch(name.to_s) do
26
+ raise InvalidActor, "no effect handler registered for #{name.inspect}"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :handlers, :mutex
33
+ end
34
+ end