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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/MIT-LICENSE +19 -0
- data/README.md +744 -0
- data/Rakefile +40 -0
- data/app/controllers/solid_objects/application_controller.rb +23 -0
- data/app/controllers/solid_objects/dead_letters_controller.rb +23 -0
- data/app/controllers/solid_objects/instances_controller.rb +29 -0
- data/app/helpers/solid_objects/actor_helper.rb +25 -0
- data/app/models/solid_objects/broadcast.rb +10 -0
- data/app/models/solid_objects/claimed_message.rb +14 -0
- data/app/models/solid_objects/dead_letter.rb +13 -0
- data/app/models/solid_objects/effect.rb +10 -0
- data/app/models/solid_objects/instance.rb +93 -0
- data/app/models/solid_objects/message.rb +53 -0
- data/app/models/solid_objects/process.rb +13 -0
- data/app/models/solid_objects/ready_message.rb +10 -0
- data/app/models/solid_objects/record.rb +17 -0
- data/app/models/solid_objects/reminder.rb +9 -0
- data/app/views/solid_objects/dead_letters/index.html.erb +26 -0
- data/app/views/solid_objects/instances/index.html.erb +24 -0
- data/app/views/solid_objects/instances/show.html.erb +35 -0
- data/benchmark/activation_cache.rb +5 -0
- data/benchmark/ask_latency.rb +5 -0
- data/benchmark/claim.rb +5 -0
- data/benchmark/cold_actors.rb +5 -0
- data/benchmark/concurrent_actors.rb +5 -0
- data/benchmark/enqueue.rb +5 -0
- data/benchmark/hot_actor.rb +5 -0
- data/benchmark/processing.rb +5 -0
- data/benchmark/query_count.rb +5 -0
- data/benchmark/support.rb +271 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20260805000000_create_solid_objects_tables.rb +319 -0
- data/docs/adr/0001-postgresql-backend.md +21 -0
- data/docs/adr/0002-jsonb-actor-state.md +21 -0
- data/docs/adr/0003-mailbox-ordering.md +30 -0
- data/docs/adr/0004-activation-leasing.md +21 -0
- data/docs/adr/0005-fencing-tokens.md +25 -0
- data/docs/adr/0006-at-least-once-delivery.md +24 -0
- data/docs/adr/0007-transactional-outbox.md +21 -0
- data/docs/adr/0008-actor-communication.md +21 -0
- data/docs/adr/0009-realtime-updates.md +21 -0
- data/docs/adr/0010-state-versioning.md +29 -0
- data/docs/adr/0011-wake-up-strategy.md +34 -0
- data/docs/adr/0012-not-active-jobs.md +21 -0
- data/docs/adr/0013-database-adapters.md +48 -0
- data/docs/architecture.md +615 -0
- data/docs/benchmarks.md +26 -0
- data/docs/correctness.md +124 -0
- data/docs/database-schema.md +111 -0
- data/docs/development.md +87 -0
- data/docs/implementation-plan.md +518 -0
- data/docs/operations.md +123 -0
- data/docs/realtime.md +51 -0
- data/docs/research/solid_queue.md +545 -0
- data/docs/roadmap.md +53 -0
- data/docs/security.md +61 -0
- data/docs/state-migrations.md +46 -0
- data/examples/application/README.md +16 -0
- data/examples/application/app/actors/chat_room_actor.rb +34 -0
- data/examples/application/app/actors/shopping_cart_actor.rb +79 -0
- data/examples/application/app/controllers/cart_controller.rb +54 -0
- data/examples/application/app/controllers/chat_rooms_controller.rb +44 -0
- data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +8 -0
- data/examples/application/app/views/actors/shopping_cart_actor/_summary.html.erb +10 -0
- data/examples/application/app/views/cart/show.html.erb +13 -0
- data/examples/application/app/views/chat_rooms/show.html.erb +8 -0
- data/examples/application/config/initializers/solid_objects.rb +23 -0
- data/examples/application/config/routes.rb +20 -0
- data/exe/solid_objects +9 -0
- data/lib/generators/solid_objects/install_generator.rb +21 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +13 -0
- data/lib/solid_objects/action_cable_broadcast_adapter.rb +19 -0
- data/lib/solid_objects/activation.rb +183 -0
- data/lib/solid_objects/activation_manager.rb +102 -0
- data/lib/solid_objects/actor.rb +271 -0
- data/lib/solid_objects/actor_channel.rb +29 -0
- data/lib/solid_objects/actor_definition.rb +212 -0
- data/lib/solid_objects/actor_registry.rb +65 -0
- data/lib/solid_objects/actor_snapshot.rb +42 -0
- data/lib/solid_objects/actor_view.rb +117 -0
- data/lib/solid_objects/broadcast_executor.rb +162 -0
- data/lib/solid_objects/cli.rb +118 -0
- data/lib/solid_objects/client.rb +153 -0
- data/lib/solid_objects/configuration.rb +168 -0
- data/lib/solid_objects/context.rb +41 -0
- data/lib/solid_objects/database_adapter.rb +82 -0
- data/lib/solid_objects/database_adapters/mysql.rb +22 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +17 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +12 -0
- data/lib/solid_objects/dead_letter_manager.rb +47 -0
- data/lib/solid_objects/dom_identity.rb +38 -0
- data/lib/solid_objects/effect_executor.rb +235 -0
- data/lib/solid_objects/effect_registry.rb +34 -0
- data/lib/solid_objects/engine.rb +33 -0
- data/lib/solid_objects/errors.rb +65 -0
- data/lib/solid_objects/executor.rb +290 -0
- data/lib/solid_objects/instrumentation.rb +10 -0
- data/lib/solid_objects/lease.rb +172 -0
- data/lib/solid_objects/lease_renewer.rb +70 -0
- data/lib/solid_objects/log_subscriber.rb +29 -0
- data/lib/solid_objects/mailbox.rb +178 -0
- data/lib/solid_objects/message_reference.rb +52 -0
- data/lib/solid_objects/process_registry.rb +143 -0
- data/lib/solid_objects/reference.rb +96 -0
- data/lib/solid_objects/reminder_scheduler.rb +168 -0
- data/lib/solid_objects/serialization.rb +99 -0
- data/lib/solid_objects/state.rb +111 -0
- data/lib/solid_objects/stream_name.rb +29 -0
- data/lib/solid_objects/stream_token.rb +59 -0
- data/lib/solid_objects/supervisor.rb +87 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +35 -0
- data/lib/solid_objects/version.rb +5 -0
- data/lib/solid_objects/wake_up.rb +28 -0
- data/lib/solid_objects/worker.rb +139 -0
- data/lib/solid_objects.rb +118 -0
- data/sig/generated/controllers/solid_objects/application_controller.rbs +10 -0
- data/sig/generated/controllers/solid_objects/dead_letters_controller.rbs +11 -0
- data/sig/generated/controllers/solid_objects/instances_controller.rbs +11 -0
- data/sig/generated/helpers/solid_objects/actor_helper.rbs +8 -0
- data/sig/generated/lib/generators/solid_objects/install_generator.rbs +13 -0
- data/sig/generated/lib/solid_objects/action_cable_broadcast_adapter.rbs +8 -0
- data/sig/generated/lib/solid_objects/activation.rbs +65 -0
- data/sig/generated/lib/solid_objects/activation_manager.rbs +36 -0
- data/sig/generated/lib/solid_objects/actor.rbs +183 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +8 -0
- data/sig/generated/lib/solid_objects/actor_definition.rbs +117 -0
- data/sig/generated/lib/solid_objects/actor_registry.rbs +36 -0
- data/sig/generated/lib/solid_objects/actor_snapshot.rbs +28 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +56 -0
- data/sig/generated/lib/solid_objects/broadcast_executor.rbs +55 -0
- data/sig/generated/lib/solid_objects/cli.rbs +31 -0
- data/sig/generated/lib/solid_objects/client.rbs +35 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +147 -0
- data/sig/generated/lib/solid_objects/context.rbs +56 -0
- data/sig/generated/lib/solid_objects/database_adapter.rbs +42 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +16 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +13 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +10 -0
- data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +16 -0
- data/sig/generated/lib/solid_objects/dom_identity.rbs +20 -0
- data/sig/generated/lib/solid_objects/effect_executor.rbs +82 -0
- data/sig/generated/lib/solid_objects/effect_registry.rbs +24 -0
- data/sig/generated/lib/solid_objects/engine.rbs +7 -0
- data/sig/generated/lib/solid_objects/errors.rbs +64 -0
- data/sig/generated/lib/solid_objects/executor.rbs +60 -0
- data/sig/generated/lib/solid_objects/instrumentation.rbs +8 -0
- data/sig/generated/lib/solid_objects/lease.rbs +57 -0
- data/sig/generated/lib/solid_objects/lease_renewer.rbs +42 -0
- data/sig/generated/lib/solid_objects/log_subscriber.rbs +11 -0
- data/sig/generated/lib/solid_objects/mailbox.rbs +46 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +37 -0
- data/sig/generated/lib/solid_objects/process_registry.rbs +46 -0
- data/sig/generated/lib/solid_objects/reference.rbs +45 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +55 -0
- data/sig/generated/lib/solid_objects/serialization.rbs +31 -0
- data/sig/generated/lib/solid_objects/state.rbs +72 -0
- data/sig/generated/lib/solid_objects/stream_name.rbs +11 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +19 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +38 -0
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +14 -0
- data/sig/generated/lib/solid_objects/version.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +24 -0
- data/sig/generated/lib/solid_objects/worker.rbs +56 -0
- data/sig/generated/lib/solid_objects.rbs +41 -0
- data/sig/generated/models/solid_objects/broadcast.rbs +6 -0
- data/sig/generated/models/solid_objects/claimed_message.rbs +6 -0
- data/sig/generated/models/solid_objects/dead_letter.rbs +6 -0
- data/sig/generated/models/solid_objects/effect.rbs +6 -0
- data/sig/generated/models/solid_objects/instance.rbs +25 -0
- data/sig/generated/models/solid_objects/message.rbs +22 -0
- data/sig/generated/models/solid_objects/process.rbs +6 -0
- data/sig/generated/models/solid_objects/ready_message.rbs +6 -0
- data/sig/generated/models/solid_objects/record.rbs +8 -0
- data/sig/generated/models/solid_objects/reminder.rbs +6 -0
- data/sig/support/framework.rbs +37 -0
- 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,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
|