solid_objects 0.2.1 → 0.3.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +129 -8
  4. data/docs/architecture.md +62 -15
  5. data/docs/authorization.md +16 -3
  6. data/docs/benchmarks.md +6 -2
  7. data/docs/correctness.md +19 -1
  8. data/docs/database-schema.md +5 -0
  9. data/docs/development.md +40 -0
  10. data/docs/fit.md +10 -2
  11. data/docs/migrating-existing-state.md +8 -1
  12. data/docs/operations.md +74 -36
  13. data/docs/roadmap.md +8 -2
  14. data/docs/security.md +25 -3
  15. data/docs/state-migrations.md +2 -0
  16. data/lib/generators/solid_objects/templates/solid_objects.rb +30 -2
  17. data/lib/solid_objects/activation.rb +33 -4
  18. data/lib/solid_objects/actor.rb +47 -6
  19. data/lib/solid_objects/actor_definition.rb +2 -0
  20. data/lib/solid_objects/actor_snapshot.rb +10 -4
  21. data/lib/solid_objects/application_write_guard.rb +24 -0
  22. data/lib/solid_objects/caller_process.rb +28 -0
  23. data/lib/solid_objects/cli.rb +44 -5
  24. data/lib/solid_objects/client.rb +98 -5
  25. data/lib/solid_objects/commit_action_registry.rb +42 -0
  26. data/lib/solid_objects/configuration.rb +27 -1
  27. data/lib/solid_objects/database_adapter.rb +28 -1
  28. data/lib/solid_objects/database_adapters/mysql.rb +46 -0
  29. data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
  30. data/lib/solid_objects/database_adapters/sqlite.rb +28 -0
  31. data/lib/solid_objects/errors.rb +144 -0
  32. data/lib/solid_objects/executor.rb +64 -4
  33. data/lib/solid_objects/instance_pruner.rb +97 -0
  34. data/lib/solid_objects/message_pruner.rb +97 -0
  35. data/lib/solid_objects/message_reference.rb +9 -0
  36. data/lib/solid_objects/process_pruner.rb +49 -0
  37. data/lib/solid_objects/reference.rb +5 -0
  38. data/lib/solid_objects/state_snapshot.rb +41 -0
  39. data/lib/solid_objects/sync_deadline.rb +57 -0
  40. data/lib/solid_objects/sync_diagnostics.rb +133 -0
  41. data/lib/solid_objects/synchronous_invocation.rb +26 -7
  42. data/lib/solid_objects/test_helper.rb +78 -0
  43. data/lib/solid_objects/version.rb +1 -1
  44. data/lib/solid_objects/worker.rb +1 -1
  45. data/lib/solid_objects.rb +34 -0
  46. data/sig/generated/lib/solid_objects/activation.rbs +3 -0
  47. data/sig/generated/lib/solid_objects/actor.rbs +26 -0
  48. data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
  49. data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
  50. data/sig/generated/lib/solid_objects/cli.rbs +11 -2
  51. data/sig/generated/lib/solid_objects/client.rbs +15 -0
  52. data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
  53. data/sig/generated/lib/solid_objects/configuration.rbs +27 -7
  54. data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
  55. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
  56. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  57. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +8 -0
  58. data/sig/generated/lib/solid_objects/errors.rbs +118 -0
  59. data/sig/generated/lib/solid_objects/executor.rbs +12 -0
  60. data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
  61. data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
  62. data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
  63. data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
  64. data/sig/generated/lib/solid_objects/reference.rbs +3 -0
  65. data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
  66. data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
  67. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
  68. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
  69. data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
  70. data/sig/generated/lib/solid_objects.rbs +12 -0
  71. metadata +19 -1
@@ -50,16 +50,57 @@ module SolidObjects
50
50
  arguments,
51
51
  authorization_context:
52
52
  )
53
- message_reference = mailbox.enqueue(
53
+ reject_sync_inside_transaction!(reference, message_name)
54
+ SyncDeadline.with(timeout:) do
55
+ message_reference = enqueue_sync(
56
+ reference,
57
+ message_name,
58
+ arguments,
59
+ idempotency_key:,
60
+ timeout:
61
+ )
62
+ SynchronousInvocation.new.call(message_reference, timeout:)
63
+ end
64
+ end
65
+
66
+ # @rbs (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped
67
+ def wait(message_reference, timeout:, authorization_context: nil)
68
+ message = Message.find(message_reference.id)
69
+ validate_message_reference!(message_reference, message)
70
+ reference = Reference.new(
71
+ actor_type: message.actor_type,
72
+ actor_id: message.actor_id
73
+ )
74
+ actor_class = SolidObjects.registry.fetch(reference.actor_type)
75
+ query = actor_class.definition.queries.key?(message.message_name.to_sym)
76
+ actor_message = actor_class.definition.messages.key?(message.message_name.to_sym)
77
+ unless query || actor_message
78
+ raise UnknownMessage, "unknown message #{message.message_name.inspect}"
79
+ end
80
+ authorize!(
81
+ query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
54
82
  reference,
55
- message_name,
56
- arguments,
57
- kind: "sync",
58
- idempotency_key:
83
+ message.message_name,
84
+ message.arguments,
85
+ authorization_context:
59
86
  )
87
+ reject_sync_inside_transaction!(reference, message.message_name)
60
88
  SynchronousInvocation.new.call(message_reference, timeout:)
61
89
  end
62
90
 
91
+ # @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot
92
+ def snapshot(reference, authorization_context: nil)
93
+ SolidObjects.registry.fetch(reference.actor_type)
94
+ authorize!(
95
+ SolidObjects.configuration.authorize_query,
96
+ reference,
97
+ "__snapshot__",
98
+ {},
99
+ authorization_context:
100
+ )
101
+ StateSnapshot.new(reference)
102
+ end
103
+
63
104
  # @rbs (Reference, ?authorization_context: untyped) -> bool
64
105
  def destroy(reference, authorization_context: nil)
65
106
  raise ActorCallCycle, "actors cannot synchronously destroy another actor" if Context.current_actor
@@ -93,6 +134,58 @@ module SolidObjects
93
134
 
94
135
  attr_reader :mailbox
95
136
 
137
+ # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference
138
+ def enqueue_sync(reference, message_name, arguments, idempotency_key:, timeout:)
139
+ mailbox.enqueue(
140
+ reference,
141
+ message_name,
142
+ arguments,
143
+ kind: "sync",
144
+ idempotency_key:
145
+ )
146
+ rescue DatabaseDeadlineExceeded
147
+ SolidObjects.instrument(
148
+ :"sync.enqueue_timeout",
149
+ actor_type: reference.actor_type,
150
+ actor_id: reference.actor_id,
151
+ message_name: message_name.to_s
152
+ )
153
+ raise SyncEnqueueTimeout.new(
154
+ timeout:,
155
+ actor_type: reference.actor_type,
156
+ actor_id: reference.actor_id,
157
+ message_name: message_name.to_s
158
+ )
159
+ end
160
+
161
+ # @rbs (MessageReference, Message) -> void
162
+ def validate_message_reference!(message_reference, message)
163
+ valid = message_reference.request_id == message.request_id &&
164
+ message_reference.actor_type == message.actor_type &&
165
+ message_reference.actor_id == message.actor_id &&
166
+ message_reference.sequence == message.sequence
167
+ return if valid
168
+
169
+ raise ActorDestroyed, "message reference no longer identifies this invocation"
170
+ end
171
+
172
+ # @rbs (Reference, Symbol | String) -> void
173
+ def reject_sync_inside_transaction!(reference, message_name)
174
+ return unless SolidObjects::Record.connection.transaction_open?
175
+
176
+ SolidObjects.instrument(
177
+ :"sync.transaction_rejected",
178
+ actor_type: reference.actor_type,
179
+ actor_id: reference.actor_id,
180
+ message_name: message_name.to_s
181
+ )
182
+ raise SyncInsideTransaction.new(
183
+ actor_type: reference.actor_type,
184
+ actor_id: reference.actor_id,
185
+ message_name: message_name.to_s
186
+ )
187
+ end
188
+
96
189
  # @rbs (Proc, Reference, Symbol | String, Hash[Symbol | String, untyped], authorization_context: untyped) -> void
97
190
  def authorize!(hook, reference, message_name, arguments, authorization_context:)
98
191
  authorized = hook.call(
@@ -0,0 +1,42 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ CommitActionContext = Data.define(
5
+ :message_id,
6
+ :request_id,
7
+ :actor_type,
8
+ :actor_id,
9
+ :activation_generation
10
+ )
11
+
12
+ class CommitActionRegistry
13
+ # @rbs @handlers: Hash[String, Proc]
14
+ # @rbs @mutex: Mutex
15
+
16
+ # @rbs () -> void
17
+ def initialize
18
+ @handlers = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # @rbs (String | Symbol, Proc) -> Proc
23
+ def register(name, handler)
24
+ action_name = name.to_s
25
+ raise ArgumentError, "commit action name cannot be empty" if action_name.empty?
26
+
27
+ mutex.synchronize { handlers[action_name] = handler }
28
+ handler
29
+ end
30
+
31
+ # @rbs (String | Symbol) -> Proc
32
+ def fetch(name)
33
+ handlers.fetch(name.to_s) do
34
+ raise UnknownCommitAction, "no commit action registered for #{name.inspect}"
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :handlers, :mutex
41
+ end
42
+ 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
@@ -53,6 +58,11 @@ module SolidObjects
53
58
  :process_heartbeat_interval,
54
59
  :process_alive_threshold,
55
60
  :shutdown_timeout,
61
+ :message_retention,
62
+ :message_retention_by_actor_type,
63
+ :instance_retention_by_actor_type,
64
+ :process_retention,
65
+ :prune_batch_size,
56
66
  :worker_count,
57
67
  :effect_worker_count,
58
68
  :broadcast_worker_count,
@@ -88,6 +98,11 @@ module SolidObjects
88
98
  @process_heartbeat_interval = 15.0
89
99
  @process_alive_threshold = 60.0
90
100
  @shutdown_timeout = 15.0
101
+ @message_retention = 30.days
102
+ @message_retention_by_actor_type = {}
103
+ @instance_retention_by_actor_type = {}
104
+ @process_retention = 7.days
105
+ @prune_batch_size = 1_000
91
106
  @worker_count = 1
92
107
  @effect_worker_count = 1
93
108
  @broadcast_worker_count = 1
@@ -128,6 +143,14 @@ module SolidObjects
128
143
  positive_values.each do |name, value|
129
144
  raise ArgumentError, "#{name} must be positive" unless value.positive?
130
145
  end
146
+ message_retention_by_actor_type.each do |actor_type, retention|
147
+ raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
148
+ raise ArgumentError, "message retention must be positive" unless retention.positive?
149
+ end
150
+ instance_retention_by_actor_type.each do |actor_type, retention|
151
+ raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
152
+ raise ArgumentError, "instance retention must be positive" unless retention.positive?
153
+ end
131
154
 
132
155
  self
133
156
  end
@@ -151,7 +174,10 @@ module SolidObjects
151
174
  max_attempts:,
152
175
  process_heartbeat_interval:,
153
176
  process_alive_threshold:,
154
- shutdown_timeout:
177
+ shutdown_timeout:,
178
+ message_retention:,
179
+ process_retention:,
180
+ prune_batch_size:
155
181
  }
156
182
  end
157
183
 
@@ -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
@@ -7,6 +7,34 @@ module SolidObjects
7
7
  def current_time_expression
8
8
  "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
9
9
  end
10
+
11
+ private
12
+
13
+ # @rbs (untyped) { () -> untyped } -> untyped
14
+ def with_transaction_deadline(connection)
15
+ return yield unless SyncDeadline.active?
16
+
17
+ previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i
18
+ connection.execute(
19
+ "PRAGMA busy_timeout = #{SyncDeadline.remaining_milliseconds}"
20
+ )
21
+ yield
22
+ ensure
23
+ connection.execute("PRAGMA busy_timeout = #{previous_timeout}") if previous_timeout
24
+ end
25
+
26
+ # @rbs (Exception) -> bool
27
+ def deadline_error?(error)
28
+ return false unless SyncDeadline.active?
29
+
30
+ cause = error
31
+ while cause
32
+ return true if cause.class.name.match?(/BusyException|BusyError/)
33
+
34
+ cause = cause.cause
35
+ end
36
+ false
37
+ end
10
38
  end
11
39
  end
12
40
  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
 
@@ -40,7 +43,148 @@ module SolidObjects
40
43
  class ActorDestroyed < LostActivation
41
44
  end
42
45
 
46
+ class DatabaseDeadlineExceeded < Error
47
+ end
48
+
49
+ class SyncEnqueueTimeout < Error
50
+ # @rbs @timeout: Numeric
51
+ # @rbs @actor_type: String
52
+ # @rbs @actor_id: String
53
+ # @rbs @message_name: String
54
+
55
+ attr_reader :timeout, :actor_type, :actor_id, :message_name
56
+
57
+ # @rbs (timeout: Numeric, actor_type: String, actor_id: String, message_name: String) -> void
58
+ def initialize(timeout:, actor_type:, actor_id:, message_name:)
59
+ @timeout = timeout
60
+ @actor_type = actor_type
61
+ @actor_id = actor_id
62
+ @message_name = message_name
63
+ super(
64
+ "actor invocation could not be durably enqueued within #{timeout} seconds for " \
65
+ "#{actor_type}(#{actor_id.inspect}).#{message_name}"
66
+ )
67
+ end
68
+ end
69
+
43
70
  class SyncTimeout < Error
71
+ # @rbs @timeout: Numeric
72
+ # @rbs @actor_type: String
73
+ # @rbs @actor_id: String
74
+ # @rbs @message_name: String
75
+ # @rbs @message_id: Integer
76
+ # @rbs @request_id: String
77
+ # @rbs @sequence: Integer
78
+ # @rbs @status: String
79
+ # @rbs @waiting_on: String
80
+ # @rbs @activation: Hash[String, untyped]
81
+ # @rbs @blocker: Hash[String, untyped]?
82
+
83
+ attr_reader :timeout,
84
+ :actor_type,
85
+ :actor_id,
86
+ :message_name,
87
+ :message_id,
88
+ :request_id,
89
+ :sequence,
90
+ :status,
91
+ :waiting_on,
92
+ :activation,
93
+ :blocker
94
+
95
+ # @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
96
+ def initialize(
97
+ timeout:,
98
+ actor_type:,
99
+ actor_id:,
100
+ message_name:,
101
+ message_id:,
102
+ request_id:,
103
+ sequence:,
104
+ status:,
105
+ waiting_on:,
106
+ activation:,
107
+ blocker:
108
+ )
109
+ @timeout = timeout
110
+ @actor_type = actor_type
111
+ @actor_id = actor_id
112
+ @message_name = message_name
113
+ @message_id = message_id
114
+ @request_id = request_id
115
+ @sequence = sequence
116
+ @status = status
117
+ @waiting_on = waiting_on
118
+ @activation = Serialization.readonly_copy(activation)
119
+ @blocker = Serialization.readonly_copy(blocker)
120
+ super(
121
+ "actor invocation timed out after #{timeout} seconds for " \
122
+ "#{actor_type}(#{actor_id.inspect}).#{message_name} " \
123
+ "message_id=#{message_id} sequence=#{sequence} status=#{status} waiting_on=#{waiting_on}"
124
+ )
125
+ end
126
+
127
+ # @rbs () -> MessageReference
128
+ def message_reference
129
+ MessageReference.new(
130
+ id: message_id,
131
+ request_id:,
132
+ actor_type:,
133
+ actor_id:,
134
+ sequence:
135
+ )
136
+ end
137
+ end
138
+
139
+ class SyncInsideTransaction < Error
140
+ # @rbs @actor_type: String
141
+ # @rbs @actor_id: String
142
+ # @rbs @message_name: String
143
+
144
+ attr_reader :actor_type, :actor_id, :message_name
145
+
146
+ # @rbs (actor_type: String, actor_id: String, message_name: String) -> void
147
+ def initialize(actor_type:, actor_id:, message_name:)
148
+ @actor_type = actor_type
149
+ @actor_id = actor_id
150
+ @message_name = message_name
151
+ super(
152
+ "cannot synchronously invoke #{actor_type}(#{actor_id.inspect}).#{message_name} " \
153
+ "inside an open database transaction; call it before the transaction or enqueue it with async"
154
+ )
155
+ end
156
+ end
157
+
158
+ class ApplicationWriteForbidden < NonRetryableError
159
+ # @rbs @actor_type: String
160
+ # @rbs @actor_id: String
161
+ # @rbs @message_name: String
162
+
163
+ attr_reader :actor_type, :actor_id, :message_name
164
+
165
+ # @rbs (actor_type: String, actor_id: String, message_name: String) -> void
166
+ def initialize(actor_type:, actor_id:, message_name:)
167
+ @actor_type = actor_type
168
+ @actor_id = actor_id
169
+ @message_name = message_name
170
+ super(
171
+ "#{actor_type}(#{actor_id.inspect}).#{message_name} attempted an Active Record write; " \
172
+ "mutate actor state, stage a commit action, or emit a durable effect instead"
173
+ )
174
+ end
175
+ end
176
+
177
+ class CommitActionUnavailable < NonRetryableError
178
+ # @rbs (actor_type: String, actor_id: String, message_name: String) -> void
179
+ def initialize(actor_type:, actor_id:, message_name:)
180
+ super(
181
+ "#{actor_type}(#{actor_id.inspect}).#{message_name} staged a commit action, " \
182
+ "but Solid Objects uses a separate database from Active Record"
183
+ )
184
+ end
185
+ end
186
+
187
+ class UnknownCommitAction < NonRetryableError
44
188
  end
45
189
 
46
190
  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,6 +94,7 @@ 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,
@@ -128,6 +135,58 @@ module SolidObjects
128
135
  SolidObjects.wake_up.signal
129
136
  end
130
137
 
138
+ # @rbs (Array[Actor::CommitActionIntent]) -> void
139
+ def execute_commit_actions(intents)
140
+ ensure_application_database_is_shared! if intents.any?
141
+ context = CommitActionContext.new(
142
+ message_id: message.id,
143
+ request_id: message.request_id,
144
+ actor_type: message.actor_type,
145
+ actor_id: message.actor_id,
146
+ activation_generation: activation.lease.generation
147
+ )
148
+
149
+ intents.each do |intent|
150
+ handler = SolidObjects.commit_action_registry.fetch(intent.name)
151
+ execute_commit_action(intent, handler, context)
152
+ end
153
+ end
154
+
155
+ # @rbs (Actor::CommitActionIntent, Proc, CommitActionContext) -> untyped
156
+ def execute_commit_action(intent, handler, context)
157
+ payload = {
158
+ commit_action_name: intent.name,
159
+ message_id: message.id,
160
+ request_id: message.request_id,
161
+ actor_type: message.actor_type,
162
+ actor_id: message.actor_id,
163
+ activation_generation: activation.lease.generation
164
+ }
165
+ SolidObjects.instrument(:"commit_action.started", **payload)
166
+ handler.call(intent.arguments, context).tap do
167
+ SolidObjects.instrument(:"commit_action.completed", **payload)
168
+ end
169
+ rescue => error
170
+ SolidObjects.instrument(
171
+ :"commit_action.failed",
172
+ **payload,
173
+ error_class: error.class.name,
174
+ error_message: error.message
175
+ )
176
+ raise
177
+ end
178
+
179
+ # @rbs () -> void
180
+ def ensure_application_database_is_shared!
181
+ return if SolidObjects::Record.connection_pool.equal?(ActiveRecord::Base.connection_pool)
182
+
183
+ raise CommitActionUnavailable.new(
184
+ actor_type: message.actor_type,
185
+ actor_id: message.actor_id,
186
+ message_name: message.message_name
187
+ )
188
+ end
189
+
131
190
  # @rbs (Message, Instance, Array[Actor::EffectIntent]) -> Array[Effect]
132
191
  def enqueue_effects(locked_message, instance, intents)
133
192
  intents.map do |intent|
@@ -218,7 +277,8 @@ module SolidObjects
218
277
  locked_message.update!(error: error_details, last_failed_at: now)
219
278
  claimed_message.destroy!
220
279
 
221
- if locked_message.attempt_count >= locked_message.max_attempts
280
+ if error.is_a?(NonRetryableError) ||
281
+ locked_message.attempt_count >= locked_message.max_attempts
222
282
  create_dead_letter(locked_message, error_details, now)
223
283
  dead = true
224
284
  else