solid_objects 0.2.1 → 0.4.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +261 -31
  4. data/app/controllers/solid_objects/components_controller.rb +78 -0
  5. data/app/helpers/solid_objects/actor_helper.rb +13 -4
  6. data/config/routes.rb +1 -0
  7. data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
  8. data/docs/architecture.md +116 -23
  9. data/docs/authorization.md +53 -3
  10. data/docs/benchmarks.md +6 -2
  11. data/docs/correctness.md +45 -1
  12. data/docs/database-schema.md +18 -5
  13. data/docs/development.md +40 -0
  14. data/docs/fit.md +10 -2
  15. data/docs/migrating-existing-state.md +8 -1
  16. data/docs/operations.md +74 -36
  17. data/docs/realtime.md +106 -12
  18. data/docs/roadmap.md +14 -7
  19. data/docs/security.md +25 -3
  20. data/docs/state-migrations.md +2 -0
  21. data/examples/application/README.md +5 -4
  22. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
  23. data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
  24. data/examples/application/config/initializers/solid_objects.rb +9 -3
  25. data/lib/generators/solid_objects/templates/solid_objects.rb +33 -2
  26. data/lib/solid_objects/activation.rb +33 -4
  27. data/lib/solid_objects/actor.rb +58 -6
  28. data/lib/solid_objects/actor_channel.rb +73 -5
  29. data/lib/solid_objects/actor_definition.rb +2 -0
  30. data/lib/solid_objects/actor_snapshot.rb +35 -10
  31. data/lib/solid_objects/actor_view.rb +103 -10
  32. data/lib/solid_objects/application_write_guard.rb +24 -0
  33. data/lib/solid_objects/caller_process.rb +28 -0
  34. data/lib/solid_objects/cli.rb +44 -5
  35. data/lib/solid_objects/client.rb +98 -5
  36. data/lib/solid_objects/commit_action_registry.rb +42 -0
  37. data/lib/solid_objects/component_path_resolver.rb +27 -0
  38. data/lib/solid_objects/component_registration.rb +117 -0
  39. data/lib/solid_objects/component_renderer.rb +82 -0
  40. data/lib/solid_objects/component_subscriptions.rb +109 -0
  41. data/lib/solid_objects/component_token.rb +139 -0
  42. data/lib/solid_objects/component_view.rb +67 -0
  43. data/lib/solid_objects/configuration.rb +39 -1
  44. data/lib/solid_objects/database_adapter.rb +28 -1
  45. data/lib/solid_objects/database_adapters/mysql.rb +46 -0
  46. data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
  47. data/lib/solid_objects/database_adapters/sqlite.rb +40 -0
  48. data/lib/solid_objects/errors.rb +156 -0
  49. data/lib/solid_objects/executor.rb +65 -4
  50. data/lib/solid_objects/instance_pruner.rb +97 -0
  51. data/lib/solid_objects/message_pruner.rb +97 -0
  52. data/lib/solid_objects/message_reference.rb +9 -0
  53. data/lib/solid_objects/process_pruner.rb +49 -0
  54. data/lib/solid_objects/reference.rb +5 -0
  55. data/lib/solid_objects/state_snapshot.rb +41 -0
  56. data/lib/solid_objects/stream_token.rb +32 -13
  57. data/lib/solid_objects/sync_deadline.rb +57 -0
  58. data/lib/solid_objects/sync_diagnostics.rb +133 -0
  59. data/lib/solid_objects/synchronous_invocation.rb +26 -7
  60. data/lib/solid_objects/test_helper.rb +78 -0
  61. data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
  62. data/lib/solid_objects/version.rb +1 -1
  63. data/lib/solid_objects/worker.rb +1 -1
  64. data/lib/solid_objects.rb +40 -0
  65. data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
  66. data/sig/generated/lib/solid_objects/activation.rbs +3 -0
  67. data/sig/generated/lib/solid_objects/actor.rbs +29 -0
  68. data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
  69. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
  70. data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
  71. data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
  72. data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
  73. data/sig/generated/lib/solid_objects/cli.rbs +11 -2
  74. data/sig/generated/lib/solid_objects/client.rbs +15 -0
  75. data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
  76. data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
  77. data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
  78. data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
  79. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
  80. data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
  81. data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
  82. data/sig/generated/lib/solid_objects/configuration.rbs +35 -7
  83. data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
  84. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
  85. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  86. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +13 -0
  87. data/sig/generated/lib/solid_objects/errors.rbs +130 -0
  88. data/sig/generated/lib/solid_objects/executor.rbs +12 -0
  89. data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
  90. data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
  91. data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
  92. data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
  93. data/sig/generated/lib/solid_objects/reference.rbs +3 -0
  94. data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
  95. data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
  96. data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
  97. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
  98. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
  99. data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
  100. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
  101. data/sig/generated/lib/solid_objects.rbs +12 -0
  102. metadata +34 -1
@@ -0,0 +1,67 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ComponentView
5
+ # @rbs @snapshot: ActorSnapshot
6
+ # @rbs @dependencies: Array[String]
7
+ # @rbs @authorization_context: untyped
8
+
9
+ attr_reader :authorization_context
10
+
11
+ # @rbs (snapshot: ActorSnapshot, dependencies: Array[String], authorization_context: untyped) -> void
12
+ def initialize(snapshot:, dependencies:, authorization_context:)
13
+ @snapshot = snapshot
14
+ @dependencies = dependencies
15
+ @authorization_context = authorization_context
16
+ end
17
+
18
+ # @rbs () -> Reference
19
+ def reference
20
+ snapshot.reference
21
+ end
22
+
23
+ # @rbs () -> String
24
+ def actor_id
25
+ reference.actor_id
26
+ end
27
+
28
+ # @rbs () -> void
29
+ def state
30
+ raise UnknownComponentDependency,
31
+ "reactive components must read declared observables instead of actor.state"
32
+ end
33
+
34
+ # @rbs (Symbol, *untyped, **untyped) -> untyped
35
+ def method_missing(name, *arguments, **keywords)
36
+ return super unless arguments.empty? && keywords.empty?
37
+ return observable_value(name) if observable?(name)
38
+
39
+ super
40
+ end
41
+
42
+ # @rbs (Symbol, bool) -> bool
43
+ def respond_to_missing?(name, include_private = false)
44
+ observable?(name) || super
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :snapshot, :dependencies
50
+
51
+ # @rbs (Symbol | String) -> bool
52
+ def observable?(name)
53
+ snapshot.actor_class.definition.observables.key?(name.to_sym)
54
+ end
55
+
56
+ # @rbs (Symbol | String) -> untyped
57
+ def observable_value(name)
58
+ dependency = name.to_s
59
+ unless dependencies.include?(dependency)
60
+ raise UnknownComponentDependency,
61
+ "observable #{dependency.inspect} is not declared by this component"
62
+ end
63
+
64
+ snapshot.observable_value(dependency)
65
+ end
66
+ end
67
+ end
@@ -20,6 +20,11 @@ module SolidObjects
20
20
  # @rbs @process_heartbeat_interval: Float
21
21
  # @rbs @process_alive_threshold: Float
22
22
  # @rbs @shutdown_timeout: Float
23
+ # @rbs @message_retention: Numeric
24
+ # @rbs @message_retention_by_actor_type: Hash[String, Numeric]
25
+ # @rbs @instance_retention_by_actor_type: Hash[String, Numeric]
26
+ # @rbs @process_retention: Numeric
27
+ # @rbs @prune_batch_size: Integer
23
28
  # @rbs @worker_count: Integer
24
29
  # @rbs @effect_worker_count: Integer
25
30
  # @rbs @broadcast_worker_count: Integer
@@ -29,6 +34,8 @@ module SolidObjects
29
34
  # @rbs @stream_signing_secret: String?
30
35
  # @rbs @broadcast_adapter: Proc?
31
36
  # @rbs @wake_up_adapter: untyped
37
+ # @rbs @component_path_resolver: Proc?
38
+ # @rbs @component_authorization_context: Proc
32
39
  # @rbs @authorize_message: Proc
33
40
  # @rbs @authorize_query: Proc
34
41
  # @rbs @authorize_destroy: Proc
@@ -53,6 +60,11 @@ module SolidObjects
53
60
  :process_heartbeat_interval,
54
61
  :process_alive_threshold,
55
62
  :shutdown_timeout,
63
+ :message_retention,
64
+ :message_retention_by_actor_type,
65
+ :instance_retention_by_actor_type,
66
+ :process_retention,
67
+ :prune_batch_size,
56
68
  :worker_count,
57
69
  :effect_worker_count,
58
70
  :broadcast_worker_count,
@@ -62,6 +74,8 @@ module SolidObjects
62
74
  :stream_signing_secret,
63
75
  :broadcast_adapter,
64
76
  :wake_up_adapter,
77
+ :component_path_resolver,
78
+ :component_authorization_context,
65
79
  :authorize_message,
66
80
  :authorize_query,
67
81
  :authorize_destroy,
@@ -88,6 +102,11 @@ module SolidObjects
88
102
  @process_heartbeat_interval = 15.0
89
103
  @process_alive_threshold = 60.0
90
104
  @shutdown_timeout = 15.0
105
+ @message_retention = 30.days
106
+ @message_retention_by_actor_type = {}
107
+ @instance_retention_by_actor_type = {}
108
+ @process_retention = 7.days
109
+ @prune_batch_size = 1_000
91
110
  @worker_count = 1
92
111
  @effect_worker_count = 1
93
112
  @broadcast_worker_count = 1
@@ -96,6 +115,8 @@ module SolidObjects
96
115
  @stream_signing_secret = nil
97
116
  @broadcast_adapter = nil
98
117
  @wake_up_adapter = nil
118
+ @component_path_resolver = nil
119
+ @component_authorization_context = ->(controller:) { controller }
99
120
  @logger = if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
100
121
  Rails.logger
101
122
  else
@@ -128,6 +149,20 @@ module SolidObjects
128
149
  positive_values.each do |name, value|
129
150
  raise ArgumentError, "#{name} must be positive" unless value.positive?
130
151
  end
152
+ message_retention_by_actor_type.each do |actor_type, retention|
153
+ raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
154
+ raise ArgumentError, "message retention must be positive" unless retention.positive?
155
+ end
156
+ instance_retention_by_actor_type.each do |actor_type, retention|
157
+ raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
158
+ raise ArgumentError, "instance retention must be positive" unless retention.positive?
159
+ end
160
+ unless component_path_resolver.nil? || component_path_resolver.respond_to?(:call)
161
+ raise ArgumentError, "component_path_resolver must respond to call"
162
+ end
163
+ unless component_authorization_context.respond_to?(:call)
164
+ raise ArgumentError, "component_authorization_context must respond to call"
165
+ end
131
166
 
132
167
  self
133
168
  end
@@ -151,7 +186,10 @@ module SolidObjects
151
186
  max_attempts:,
152
187
  process_heartbeat_interval:,
153
188
  process_alive_threshold:,
154
- shutdown_timeout:
189
+ shutdown_timeout:,
190
+ message_retention:,
191
+ process_retention:,
192
+ prune_batch_size:
155
193
  }
156
194
  end
157
195
 
@@ -54,9 +54,22 @@ module SolidObjects
54
54
 
55
55
  # @rbs () { () -> untyped } -> untyped
56
56
  def transaction(&block)
57
+ raise DatabaseDeadlineExceeded, "synchronous invocation deadline expired" if SyncDeadline.expired?
58
+
57
59
  with_connection do |connection|
58
- connection.transaction(requires_new: true, &block)
60
+ with_transaction_deadline(connection) do
61
+ connection.transaction(requires_new: true) do
62
+ configure_transaction_deadline(connection)
63
+ block.call
64
+ end
65
+ end
59
66
  end
67
+ rescue => error
68
+ raise unless deadline_error?(error)
69
+
70
+ raise DatabaseDeadlineExceeded,
71
+ "database lock wait exceeded the synchronous invocation deadline",
72
+ cause: error
60
73
  end
61
74
 
62
75
  # @rbs (ActiveRecord::Relation[untyped]) -> ActiveRecord::Relation[untyped]
@@ -68,6 +81,20 @@ module SolidObjects
68
81
 
69
82
  attr_reader :connection_pool, :fixed_connection
70
83
 
84
+ # @rbs (untyped) { () -> untyped } -> untyped
85
+ def with_transaction_deadline(_connection)
86
+ yield
87
+ end
88
+
89
+ # @rbs (untyped) -> void
90
+ def configure_transaction_deadline(_connection)
91
+ end
92
+
93
+ # @rbs (Exception) -> bool
94
+ def deadline_error?(_error)
95
+ false
96
+ end
97
+
71
98
  # @rbs () { (untyped) -> untyped } -> untyped
72
99
  def with_connection
73
100
  return yield fixed_connection unless connection_pool
@@ -17,6 +17,52 @@ module SolidObjects
17
17
  def current_time_expression
18
18
  "CURRENT_TIMESTAMP(6)"
19
19
  end
20
+
21
+ private
22
+
23
+ # @rbs (untyped) { () -> untyped } -> untyped
24
+ def with_transaction_deadline(connection)
25
+ return yield unless SyncDeadline.active?
26
+
27
+ previous_lock_timeout = connection.select_value(
28
+ "SELECT @@SESSION.innodb_lock_wait_timeout"
29
+ ).to_i
30
+ previous_execution_timeout = connection.select_value(
31
+ "SELECT @@SESSION.max_execution_time"
32
+ ).to_i
33
+ connection.execute(
34
+ "SET SESSION innodb_lock_wait_timeout = #{SyncDeadline.remaining_seconds}"
35
+ )
36
+ connection.execute(
37
+ "SET SESSION max_execution_time = #{SyncDeadline.remaining_milliseconds}"
38
+ )
39
+ yield
40
+ ensure
41
+ if previous_lock_timeout
42
+ connection.execute(
43
+ "SET SESSION innodb_lock_wait_timeout = #{previous_lock_timeout}"
44
+ )
45
+ end
46
+ if previous_execution_timeout
47
+ connection.execute(
48
+ "SET SESSION max_execution_time = #{previous_execution_timeout}"
49
+ )
50
+ end
51
+ end
52
+
53
+ # @rbs (Exception) -> bool
54
+ def deadline_error?(error)
55
+ return false unless SyncDeadline.active?
56
+ return true if error.is_a?(ActiveRecord::LockWaitTimeout)
57
+
58
+ cause = error
59
+ while cause
60
+ return true if cause.respond_to?(:error_number) && cause.error_number == 3024
61
+
62
+ cause = cause.cause
63
+ end
64
+ false
65
+ end
20
66
  end
21
67
  end
22
68
  end
@@ -12,6 +12,35 @@ module SolidObjects
12
12
  def claim_lock
13
13
  "FOR UPDATE SKIP LOCKED"
14
14
  end
15
+
16
+ private
17
+
18
+ # @rbs (untyped) -> void
19
+ def configure_transaction_deadline(connection)
20
+ return unless SyncDeadline.active?
21
+
22
+ connection.execute(
23
+ "SET LOCAL lock_timeout = #{SyncDeadline.remaining_milliseconds}"
24
+ )
25
+ connection.execute(
26
+ "SET LOCAL statement_timeout = #{SyncDeadline.remaining_milliseconds}"
27
+ )
28
+ end
29
+
30
+ # @rbs (Exception) -> bool
31
+ def deadline_error?(error)
32
+ return false unless SyncDeadline.active?
33
+ return true if error.is_a?(ActiveRecord::LockWaitTimeout)
34
+
35
+ cause = error
36
+ while cause
37
+ result = cause.respond_to?(:result) ? cause.result : nil
38
+ return true if result&.error_field(PG::Result::PG_DIAG_SQLSTATE) == "57014"
39
+
40
+ cause = cause.cause
41
+ end
42
+ false
43
+ end
15
44
  end
16
45
  end
17
46
  end
@@ -3,10 +3,50 @@
3
3
  module SolidObjects
4
4
  module DatabaseAdapters
5
5
  class Sqlite < DatabaseAdapter
6
+ LOCK_RETRY_INTERVAL = 0.001
7
+
6
8
  # @rbs () -> String
7
9
  def current_time_expression
8
10
  "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
9
11
  end
12
+
13
+ # @rbs () { () -> untyped } -> untyped
14
+ def transaction(&block)
15
+ return super unless SyncDeadline.active?
16
+
17
+ super
18
+ rescue DatabaseDeadlineExceeded
19
+ raise if SyncDeadline.expired?
20
+
21
+ sleep [ LOCK_RETRY_INTERVAL, SyncDeadline.remaining ].min
22
+ retry
23
+ end
24
+
25
+ private
26
+
27
+ # @rbs (untyped) { () -> untyped } -> untyped
28
+ def with_transaction_deadline(connection)
29
+ return yield unless SyncDeadline.active?
30
+
31
+ previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i
32
+ connection.execute("PRAGMA busy_timeout = 0")
33
+ yield
34
+ ensure
35
+ connection.execute("PRAGMA busy_timeout = #{previous_timeout}") if previous_timeout
36
+ end
37
+
38
+ # @rbs (Exception) -> bool
39
+ def deadline_error?(error)
40
+ return false unless SyncDeadline.active?
41
+
42
+ cause = error
43
+ while cause
44
+ return true if cause.class.name.match?(/BusyException|BusyError/)
45
+
46
+ cause = cause.cause
47
+ end
48
+ false
49
+ end
10
50
  end
11
51
  end
12
52
  end
@@ -4,6 +4,9 @@ module SolidObjects
4
4
  class Error < StandardError
5
5
  end
6
6
 
7
+ class NonRetryableError < Error
8
+ end
9
+
7
10
  class UnsupportedDatabase < Error
8
11
  end
9
12
 
@@ -34,13 +37,166 @@ module SolidObjects
34
37
  class InvalidStreamToken < Error
35
38
  end
36
39
 
40
+ class InvalidComponentToken < Error
41
+ end
42
+
43
+ class UnknownComponent < Error
44
+ end
45
+
46
+ class UnknownComponentDependency < Error
47
+ end
48
+
49
+ class UnsupportedComponentRendering < Error
50
+ end
51
+
37
52
  class LostActivation < Error
38
53
  end
39
54
 
40
55
  class ActorDestroyed < LostActivation
41
56
  end
42
57
 
58
+ class DatabaseDeadlineExceeded < Error
59
+ end
60
+
61
+ class SyncEnqueueTimeout < Error
62
+ # @rbs @timeout: Numeric
63
+ # @rbs @actor_type: String
64
+ # @rbs @actor_id: String
65
+ # @rbs @message_name: String
66
+
67
+ attr_reader :timeout, :actor_type, :actor_id, :message_name
68
+
69
+ # @rbs (timeout: Numeric, actor_type: String, actor_id: String, message_name: String) -> void
70
+ def initialize(timeout:, actor_type:, actor_id:, message_name:)
71
+ @timeout = timeout
72
+ @actor_type = actor_type
73
+ @actor_id = actor_id
74
+ @message_name = message_name
75
+ super(
76
+ "actor invocation could not be durably enqueued within #{timeout} seconds for " \
77
+ "#{actor_type}(#{actor_id.inspect}).#{message_name}"
78
+ )
79
+ end
80
+ end
81
+
43
82
  class SyncTimeout < Error
83
+ # @rbs @timeout: Numeric
84
+ # @rbs @actor_type: String
85
+ # @rbs @actor_id: String
86
+ # @rbs @message_name: String
87
+ # @rbs @message_id: Integer
88
+ # @rbs @request_id: String
89
+ # @rbs @sequence: Integer
90
+ # @rbs @status: String
91
+ # @rbs @waiting_on: String
92
+ # @rbs @activation: Hash[String, untyped]
93
+ # @rbs @blocker: Hash[String, untyped]?
94
+
95
+ attr_reader :timeout,
96
+ :actor_type,
97
+ :actor_id,
98
+ :message_name,
99
+ :message_id,
100
+ :request_id,
101
+ :sequence,
102
+ :status,
103
+ :waiting_on,
104
+ :activation,
105
+ :blocker
106
+
107
+ # @rbs (timeout: Numeric, actor_type: String, actor_id: String, message_name: String, message_id: Integer, request_id: String, sequence: Integer, status: String, waiting_on: String, activation: Hash[String, untyped], blocker: Hash[String, untyped]?) -> void
108
+ def initialize(
109
+ timeout:,
110
+ actor_type:,
111
+ actor_id:,
112
+ message_name:,
113
+ message_id:,
114
+ request_id:,
115
+ sequence:,
116
+ status:,
117
+ waiting_on:,
118
+ activation:,
119
+ blocker:
120
+ )
121
+ @timeout = timeout
122
+ @actor_type = actor_type
123
+ @actor_id = actor_id
124
+ @message_name = message_name
125
+ @message_id = message_id
126
+ @request_id = request_id
127
+ @sequence = sequence
128
+ @status = status
129
+ @waiting_on = waiting_on
130
+ @activation = Serialization.readonly_copy(activation)
131
+ @blocker = Serialization.readonly_copy(blocker)
132
+ super(
133
+ "actor invocation timed out after #{timeout} seconds for " \
134
+ "#{actor_type}(#{actor_id.inspect}).#{message_name} " \
135
+ "message_id=#{message_id} sequence=#{sequence} status=#{status} waiting_on=#{waiting_on}"
136
+ )
137
+ end
138
+
139
+ # @rbs () -> MessageReference
140
+ def message_reference
141
+ MessageReference.new(
142
+ id: message_id,
143
+ request_id:,
144
+ actor_type:,
145
+ actor_id:,
146
+ sequence:
147
+ )
148
+ end
149
+ end
150
+
151
+ class SyncInsideTransaction < Error
152
+ # @rbs @actor_type: String
153
+ # @rbs @actor_id: String
154
+ # @rbs @message_name: String
155
+
156
+ attr_reader :actor_type, :actor_id, :message_name
157
+
158
+ # @rbs (actor_type: String, actor_id: String, message_name: String) -> void
159
+ def initialize(actor_type:, actor_id:, message_name:)
160
+ @actor_type = actor_type
161
+ @actor_id = actor_id
162
+ @message_name = message_name
163
+ super(
164
+ "cannot synchronously invoke #{actor_type}(#{actor_id.inspect}).#{message_name} " \
165
+ "inside an open database transaction; call it before the transaction or enqueue it with async"
166
+ )
167
+ end
168
+ end
169
+
170
+ class ApplicationWriteForbidden < NonRetryableError
171
+ # @rbs @actor_type: String
172
+ # @rbs @actor_id: String
173
+ # @rbs @message_name: String
174
+
175
+ attr_reader :actor_type, :actor_id, :message_name
176
+
177
+ # @rbs (actor_type: String, actor_id: String, message_name: String) -> void
178
+ def initialize(actor_type:, actor_id:, message_name:)
179
+ @actor_type = actor_type
180
+ @actor_id = actor_id
181
+ @message_name = message_name
182
+ super(
183
+ "#{actor_type}(#{actor_id.inspect}).#{message_name} attempted an Active Record write; " \
184
+ "mutate actor state, stage a commit action, or emit a durable effect instead"
185
+ )
186
+ end
187
+ end
188
+
189
+ class CommitActionUnavailable < NonRetryableError
190
+ # @rbs (actor_type: String, actor_id: String, message_name: String) -> void
191
+ def initialize(actor_type:, actor_id:, message_name:)
192
+ super(
193
+ "#{actor_type}(#{actor_id.inspect}).#{message_name} staged a commit action, " \
194
+ "but Solid Objects uses a separate database from Active Record"
195
+ )
196
+ end
197
+ end
198
+
199
+ class UnknownCommitAction < NonRetryableError
44
200
  end
45
201
 
46
202
  class Rejected < Error
@@ -24,9 +24,7 @@ module SolidObjects
24
24
  )
25
25
 
26
26
  SolidObjects.instrument(:"message.started", **instrumentation_payload)
27
- result = Context.with(actor:, message: message_context) do
28
- actor.invoke(message.message_name, message.arguments)
29
- end
27
+ result = invoke_actor(message_context)
30
28
  ensure_query_did_not_mutate_state!(state_before)
31
29
  observable_changes = changed_observables(observables_before, actor.observable_values)
32
30
  complete(result, observable_changes)
@@ -54,6 +52,13 @@ module SolidObjects
54
52
  activation.actor
55
53
  end
56
54
 
55
+ # @rbs (MessageContext) -> untyped
56
+ def invoke_actor(message_context)
57
+ Context.with(actor:, message: message_context) do
58
+ actor.invoke(message.message_name, message.arguments)
59
+ end
60
+ end
61
+
57
62
  # @rbs (Hash[String, untyped]) -> void
58
63
  def ensure_query_did_not_mutate_state!(state_before)
59
64
  return unless message.message_kind == "sync"
@@ -81,6 +86,7 @@ module SolidObjects
81
86
  max_bytes: SolidObjects.configuration.max_result_bytes
82
87
  )
83
88
  effect_intents = actor.drain_effect_intents
89
+ commit_action_intents = actor.drain_commit_action_intents
84
90
  reminder_intents = actor.drain_reminder_intents
85
91
  outbound_message_intents = actor.drain_outbound_message_intents
86
92
  enqueued_effects = []
@@ -88,9 +94,11 @@ module SolidObjects
88
94
  activation.lease.fenced_transaction do |instance|
89
95
  claimed_message = matching_claim!
90
96
  locked_message = Message.lock.find(message.id)
97
+ execute_commit_actions(commit_action_intents)
91
98
  instance.update!(
92
99
  state: serialized_state,
93
100
  state_version: actor.class.state_version,
101
+ state_revision: locked_message.sequence,
94
102
  last_used_at: SolidObjects.database_adapter.database_now
95
103
  )
96
104
  locked_message.update!(
@@ -128,6 +136,58 @@ module SolidObjects
128
136
  SolidObjects.wake_up.signal
129
137
  end
130
138
 
139
+ # @rbs (Array[Actor::CommitActionIntent]) -> void
140
+ def execute_commit_actions(intents)
141
+ ensure_application_database_is_shared! if intents.any?
142
+ context = CommitActionContext.new(
143
+ message_id: message.id,
144
+ request_id: message.request_id,
145
+ actor_type: message.actor_type,
146
+ actor_id: message.actor_id,
147
+ activation_generation: activation.lease.generation
148
+ )
149
+
150
+ intents.each do |intent|
151
+ handler = SolidObjects.commit_action_registry.fetch(intent.name)
152
+ execute_commit_action(intent, handler, context)
153
+ end
154
+ end
155
+
156
+ # @rbs (Actor::CommitActionIntent, Proc, CommitActionContext) -> untyped
157
+ def execute_commit_action(intent, handler, context)
158
+ payload = {
159
+ commit_action_name: intent.name,
160
+ message_id: message.id,
161
+ request_id: message.request_id,
162
+ actor_type: message.actor_type,
163
+ actor_id: message.actor_id,
164
+ activation_generation: activation.lease.generation
165
+ }
166
+ SolidObjects.instrument(:"commit_action.started", **payload)
167
+ handler.call(intent.arguments, context).tap do
168
+ SolidObjects.instrument(:"commit_action.completed", **payload)
169
+ end
170
+ rescue => error
171
+ SolidObjects.instrument(
172
+ :"commit_action.failed",
173
+ **payload,
174
+ error_class: error.class.name,
175
+ error_message: error.message
176
+ )
177
+ raise
178
+ end
179
+
180
+ # @rbs () -> void
181
+ def ensure_application_database_is_shared!
182
+ return if SolidObjects::Record.connection_pool.equal?(ActiveRecord::Base.connection_pool)
183
+
184
+ raise CommitActionUnavailable.new(
185
+ actor_type: message.actor_type,
186
+ actor_id: message.actor_id,
187
+ message_name: message.message_name
188
+ )
189
+ end
190
+
131
191
  # @rbs (Message, Instance, Array[Actor::EffectIntent]) -> Array[Effect]
132
192
  def enqueue_effects(locked_message, instance, intents)
133
193
  intents.map do |intent|
@@ -218,7 +278,8 @@ module SolidObjects
218
278
  locked_message.update!(error: error_details, last_failed_at: now)
219
279
  claimed_message.destroy!
220
280
 
221
- if locked_message.attempt_count >= locked_message.max_attempts
281
+ if error.is_a?(NonRetryableError) ||
282
+ locked_message.attempt_count >= locked_message.max_attempts
222
283
  create_dead_letter(locked_message, error_details, now)
223
284
  dead = true
224
285
  else