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,33 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require_relative "../../app/models/solid_objects/record"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace SolidObjects
|
|
8
|
+
|
|
9
|
+
config.generators do |generators|
|
|
10
|
+
generators.test_framework :minitest
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "solid_objects.configuration" do
|
|
14
|
+
SolidObjects.configuration.validate!
|
|
15
|
+
SolidObjects::LogSubscriber.install
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "solid_objects.database", after: :load_config_initializers do
|
|
19
|
+
SolidObjects::Record.configure_connection
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
initializer "solid_objects.helpers" do
|
|
23
|
+
ActiveSupport.on_load(:action_view) do
|
|
24
|
+
include SolidObjects::ActorHelper
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
rake_tasks do
|
|
29
|
+
tasks_path = File.expand_path("../tasks", __dir__)
|
|
30
|
+
Dir[File.join(tasks_path, "**/*.rake")].sort.each { |task| load task }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class UnsupportedDatabase < Error
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class InvalidActor < Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class UnknownActorType < Error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class UnknownMessage < Error
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class InvalidPayload < Error
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class PayloadTooLarge < InvalidPayload
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class MailboxFull < Error
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class IdempotencyConflict < Error
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class Unauthorized < Error
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class InvalidStreamToken < Error
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class LostActivation < Error
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class ActorDestroyed < LostActivation
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class AskTimeout < Error
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class MessageFailed < Error
|
|
47
|
+
# @rbs @message_id: Integer
|
|
48
|
+
# @rbs @details: Hash[String, untyped]
|
|
49
|
+
|
|
50
|
+
attr_reader :message_id, :details
|
|
51
|
+
|
|
52
|
+
# @rbs (String, message_id: Integer, details: Hash[String, untyped]) -> void
|
|
53
|
+
def initialize(message, message_id:, details:)
|
|
54
|
+
@message_id = message_id
|
|
55
|
+
@details = details
|
|
56
|
+
super(message)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class StateMigrationError < Error
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class ActorCallCycle < Error
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Executor
|
|
5
|
+
# @rbs @activation: Activation
|
|
6
|
+
# @rbs @message: Message
|
|
7
|
+
|
|
8
|
+
# @rbs (activation: Activation, message: Message) -> void
|
|
9
|
+
def initialize(activation:, message:)
|
|
10
|
+
@activation = activation
|
|
11
|
+
@message = message
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @rbs () -> bool
|
|
15
|
+
def call
|
|
16
|
+
state_before = actor.state.to_h
|
|
17
|
+
observables_before = actor.observable_values
|
|
18
|
+
message_context = MessageContext.new(
|
|
19
|
+
id: message.id,
|
|
20
|
+
attempt: message.attempt_count,
|
|
21
|
+
enqueued_at: message.enqueued_at,
|
|
22
|
+
idempotency_key: message.idempotency_key,
|
|
23
|
+
request_id: message.request_id
|
|
24
|
+
)
|
|
25
|
+
|
|
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
|
|
30
|
+
ensure_query_did_not_mutate_state!(state_before)
|
|
31
|
+
observable_changes = changed_observables(observables_before, actor.observable_values)
|
|
32
|
+
complete(result, observable_changes)
|
|
33
|
+
true
|
|
34
|
+
rescue LostActivation
|
|
35
|
+
raise
|
|
36
|
+
rescue => error
|
|
37
|
+
activation.restore_state(state_before) if state_before
|
|
38
|
+
actor.discard_intents
|
|
39
|
+
fail_message(error)
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
attr_reader :activation, :message
|
|
46
|
+
|
|
47
|
+
# @rbs () -> Actor
|
|
48
|
+
def actor
|
|
49
|
+
activation.actor
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @rbs (Hash[String, untyped]) -> void
|
|
53
|
+
def ensure_query_did_not_mutate_state!(state_before)
|
|
54
|
+
return unless message.message_kind == "ask"
|
|
55
|
+
return unless actor.class.definition.queries.key?(message.message_name.to_sym)
|
|
56
|
+
return if actor.state.to_h == state_before
|
|
57
|
+
|
|
58
|
+
raise InvalidActor, "query #{message.message_name.inspect} mutated actor state"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @rbs (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
|
|
62
|
+
def changed_observables(before, after)
|
|
63
|
+
after.each_with_object({}) do |(name, value), changes|
|
|
64
|
+
changes[name] = value unless before[name] == value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @rbs (untyped, Hash[String, untyped]) -> void
|
|
69
|
+
def complete(result, observable_changes)
|
|
70
|
+
serialized_state = Serialization.dump(
|
|
71
|
+
actor.state.to_h,
|
|
72
|
+
max_bytes: SolidObjects.configuration.max_state_bytes
|
|
73
|
+
)
|
|
74
|
+
serialized_result = Serialization.dump(
|
|
75
|
+
(message.message_kind == "ask") ? result : nil,
|
|
76
|
+
max_bytes: SolidObjects.configuration.max_result_bytes
|
|
77
|
+
)
|
|
78
|
+
effect_intents = actor.drain_effect_intents
|
|
79
|
+
reminder_intents = actor.drain_reminder_intents
|
|
80
|
+
outbound_message_intents = actor.drain_outbound_message_intents
|
|
81
|
+
enqueued_effects = []
|
|
82
|
+
|
|
83
|
+
activation.lease.fenced_transaction do |instance|
|
|
84
|
+
claimed_message = matching_claim!
|
|
85
|
+
locked_message = Message.lock.find(message.id)
|
|
86
|
+
instance.update!(
|
|
87
|
+
state: serialized_state,
|
|
88
|
+
state_version: actor.class.state_version,
|
|
89
|
+
last_used_at: SolidObjects.database_adapter.database_now
|
|
90
|
+
)
|
|
91
|
+
locked_message.update!(
|
|
92
|
+
result: serialized_result,
|
|
93
|
+
error: nil,
|
|
94
|
+
completed_at: SolidObjects.database_adapter.database_now
|
|
95
|
+
)
|
|
96
|
+
enqueued_effects.concat(enqueue_effects(locked_message, instance, effect_intents))
|
|
97
|
+
schedule_reminders(instance, reminder_intents)
|
|
98
|
+
enqueued_effects.concat(
|
|
99
|
+
enqueue_actor_messages(locked_message, instance, outbound_message_intents)
|
|
100
|
+
)
|
|
101
|
+
enqueue_broadcasts(locked_message, instance, observable_changes)
|
|
102
|
+
claimed_message.destroy!
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
observable_changes.each_key do |observable_name|
|
|
106
|
+
SolidObjects.instrument(
|
|
107
|
+
:"broadcast.enqueued",
|
|
108
|
+
**instrumentation_payload,
|
|
109
|
+
observable_name:
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
enqueued_effects.each do |effect|
|
|
113
|
+
SolidObjects.instrument(
|
|
114
|
+
:"effect.enqueued",
|
|
115
|
+
effect_id: effect.effect_id,
|
|
116
|
+
effect_name: effect.name,
|
|
117
|
+
message_id: effect.message_id,
|
|
118
|
+
actor_type: message.actor_type,
|
|
119
|
+
actor_id: message.actor_id
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
SolidObjects.instrument(:"message.completed", **instrumentation_payload)
|
|
123
|
+
SolidObjects.wake_up.signal
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @rbs (Message, Instance, Array[Actor::EffectIntent]) -> Array[Effect]
|
|
127
|
+
def enqueue_effects(locked_message, instance, intents)
|
|
128
|
+
intents.map do |intent|
|
|
129
|
+
Effect.create!(
|
|
130
|
+
message: locked_message,
|
|
131
|
+
instance:,
|
|
132
|
+
effect_id: SecureRandom.uuid,
|
|
133
|
+
name: intent.name,
|
|
134
|
+
arguments: intent.arguments,
|
|
135
|
+
success_message_name: intent.success_message_name,
|
|
136
|
+
failure_message_name: intent.failure_message_name,
|
|
137
|
+
status: "pending",
|
|
138
|
+
max_attempts: SolidObjects.configuration.max_attempts,
|
|
139
|
+
available_at: SolidObjects.database_adapter.database_now
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @rbs (Instance, Array[Actor::ReminderIntent]) -> void
|
|
145
|
+
def schedule_reminders(instance, intents)
|
|
146
|
+
intents.each do |intent|
|
|
147
|
+
reminder = Reminder.find_or_initialize_by(instance:, name: intent.name)
|
|
148
|
+
reminder.assign_attributes(
|
|
149
|
+
actor_type: instance.actor_type,
|
|
150
|
+
actor_id: instance.actor_id,
|
|
151
|
+
message_name: intent.name,
|
|
152
|
+
arguments: intent.arguments,
|
|
153
|
+
next_run_at: intent.at,
|
|
154
|
+
interval_seconds: intent.interval_seconds,
|
|
155
|
+
missed_policy: intent.missed_policy,
|
|
156
|
+
status: "scheduled",
|
|
157
|
+
claimed_by: nil,
|
|
158
|
+
claimed_at: nil
|
|
159
|
+
)
|
|
160
|
+
reminder.save!
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
|
|
165
|
+
def enqueue_actor_messages(locked_message, instance, intents)
|
|
166
|
+
intents.map do |intent|
|
|
167
|
+
Effect.create!(
|
|
168
|
+
message: locked_message,
|
|
169
|
+
instance:,
|
|
170
|
+
effect_id: SecureRandom.uuid,
|
|
171
|
+
name: EffectExecutor::ACTOR_MESSAGE_EFFECT,
|
|
172
|
+
arguments: {
|
|
173
|
+
"actor_type" => intent.actor_type,
|
|
174
|
+
"actor_id" => intent.actor_id,
|
|
175
|
+
"message_name" => intent.message_name,
|
|
176
|
+
"arguments" => intent.arguments,
|
|
177
|
+
"available_at" => intent.available_at&.iso8601(6),
|
|
178
|
+
"idempotency_key" => intent.idempotency_key
|
|
179
|
+
},
|
|
180
|
+
status: "pending",
|
|
181
|
+
max_attempts: SolidObjects.configuration.max_attempts,
|
|
182
|
+
available_at: SolidObjects.database_adapter.database_now
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @rbs (Message, Instance, Hash[String, untyped]) -> void
|
|
188
|
+
def enqueue_broadcasts(locked_message, instance, observable_changes)
|
|
189
|
+
observable_changes.each do |observable_name, value|
|
|
190
|
+
Broadcast.create!(
|
|
191
|
+
message: locked_message,
|
|
192
|
+
instance:,
|
|
193
|
+
broadcast_id: SecureRandom.uuid,
|
|
194
|
+
observable_name:,
|
|
195
|
+
value:,
|
|
196
|
+
state_version: actor.class.state_version,
|
|
197
|
+
activation_generation: activation.lease.generation,
|
|
198
|
+
status: "pending",
|
|
199
|
+
available_at: SolidObjects.database_adapter.database_now
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @rbs (Exception) -> void
|
|
205
|
+
def fail_message(error)
|
|
206
|
+
error_details = serialized_error(error)
|
|
207
|
+
dead = false
|
|
208
|
+
|
|
209
|
+
activation.lease.fenced_transaction do
|
|
210
|
+
claimed_message = matching_claim!
|
|
211
|
+
locked_message = Message.lock.find(message.id)
|
|
212
|
+
now = SolidObjects.database_adapter.database_now
|
|
213
|
+
locked_message.update!(error: error_details, last_failed_at: now)
|
|
214
|
+
claimed_message.destroy!
|
|
215
|
+
|
|
216
|
+
if locked_message.attempt_count >= locked_message.max_attempts
|
|
217
|
+
create_dead_letter(locked_message, error_details, now)
|
|
218
|
+
dead = true
|
|
219
|
+
else
|
|
220
|
+
ReadyMessage.create!(
|
|
221
|
+
message: locked_message,
|
|
222
|
+
instance: locked_message.instance,
|
|
223
|
+
sequence: locked_message.sequence,
|
|
224
|
+
available_at: now + SolidObjects.configuration.retry_delay.call(locked_message.attempt_count)
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
SolidObjects.instrument(
|
|
230
|
+
:"message.failed",
|
|
231
|
+
**instrumentation_payload,
|
|
232
|
+
error_class: error.class.name,
|
|
233
|
+
dead:
|
|
234
|
+
)
|
|
235
|
+
SolidObjects.wake_up.signal
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# @rbs () -> ClaimedMessage
|
|
239
|
+
def matching_claim!
|
|
240
|
+
ClaimedMessage.lock.find_by!(
|
|
241
|
+
message_id: message.id,
|
|
242
|
+
process_id: activation.lease.owner_id,
|
|
243
|
+
activation_generation: activation.lease.generation
|
|
244
|
+
)
|
|
245
|
+
rescue ActiveRecord::RecordNotFound
|
|
246
|
+
raise LostActivation, "message claim changed"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# @rbs (Exception) -> Hash[String, untyped]
|
|
250
|
+
def serialized_error(error)
|
|
251
|
+
Serialization.dump(
|
|
252
|
+
{
|
|
253
|
+
"class" => error.class.name,
|
|
254
|
+
"message" => error.message.to_s.byteslice(0, 8_192),
|
|
255
|
+
"backtrace" => Array(error.backtrace).first(50)
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# @rbs (Message, Hash[String, untyped], Time) -> DeadLetter
|
|
261
|
+
def create_dead_letter(locked_message, error_details, now)
|
|
262
|
+
DeadLetter.create!(
|
|
263
|
+
message: locked_message,
|
|
264
|
+
instance: locked_message.instance,
|
|
265
|
+
actor_type: locked_message.actor_type,
|
|
266
|
+
actor_id: locked_message.actor_id,
|
|
267
|
+
message_name: locked_message.message_name,
|
|
268
|
+
arguments: locked_message.arguments,
|
|
269
|
+
attempts: locked_message.attempt_count,
|
|
270
|
+
exception_class: error_details.fetch("class"),
|
|
271
|
+
exception_message: error_details.fetch("message"),
|
|
272
|
+
backtrace: error_details.fetch("backtrace"),
|
|
273
|
+
first_failed_at: locked_message.last_failed_at || now,
|
|
274
|
+
last_failed_at: now
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# @rbs () -> Hash[Symbol, untyped]
|
|
279
|
+
def instrumentation_payload
|
|
280
|
+
{
|
|
281
|
+
message_id: message.id,
|
|
282
|
+
actor_type: message.actor_type,
|
|
283
|
+
actor_id: message.actor_id,
|
|
284
|
+
sequence: message.sequence,
|
|
285
|
+
attempt: message.attempt_count,
|
|
286
|
+
request_id: message.request_id
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
module Instrumentation
|
|
5
|
+
# @rbs (Symbol, **untyped) { (Hash[Symbol, untyped]) -> untyped } -> untyped
|
|
6
|
+
def instrument(event, **payload, &block)
|
|
7
|
+
ActiveSupport::Notifications.instrument("solid_objects.#{event}", payload, &block)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Lease
|
|
5
|
+
class << self
|
|
6
|
+
# @rbs (instance: Instance, owner_id: String, now: Time, ?duration: Numeric, database_adapter: DatabaseAdapter) -> Lease?
|
|
7
|
+
def claim(
|
|
8
|
+
instance:,
|
|
9
|
+
owner_id:,
|
|
10
|
+
now:,
|
|
11
|
+
database_adapter:, duration: SolidObjects.configuration.lease_duration
|
|
12
|
+
)
|
|
13
|
+
return if held_by_another_live_owner?(instance, owner_id, now)
|
|
14
|
+
|
|
15
|
+
generation = instance.activation_generation + 1
|
|
16
|
+
expires_at = now + duration
|
|
17
|
+
instance.update!(
|
|
18
|
+
activation_owner_id: owner_id,
|
|
19
|
+
activation_expires_at: expires_at,
|
|
20
|
+
activation_generation: generation,
|
|
21
|
+
activated_at: now,
|
|
22
|
+
last_claimed_at: now
|
|
23
|
+
)
|
|
24
|
+
new(
|
|
25
|
+
instance_id: instance.id,
|
|
26
|
+
owner_id:,
|
|
27
|
+
generation:,
|
|
28
|
+
expires_at:,
|
|
29
|
+
database_adapter:
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @rbs (instance_id: Integer, owner_id: String, ?duration: Numeric, ?database_adapter: DatabaseAdapter) -> Lease?
|
|
34
|
+
def acquire(
|
|
35
|
+
instance_id:,
|
|
36
|
+
owner_id:,
|
|
37
|
+
duration: SolidObjects.configuration.lease_duration,
|
|
38
|
+
database_adapter: SolidObjects.database_adapter
|
|
39
|
+
)
|
|
40
|
+
lease = database_adapter.transaction do
|
|
41
|
+
instance = Instance.lock.find(instance_id)
|
|
42
|
+
now = database_adapter.database_now
|
|
43
|
+
claim(instance:, owner_id:, now:, duration:, database_adapter:)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if lease
|
|
47
|
+
SolidObjects.instrument(
|
|
48
|
+
:"activation.claimed",
|
|
49
|
+
instance_id:,
|
|
50
|
+
owner_id:,
|
|
51
|
+
generation: lease.generation
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
lease
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# @rbs (Instance, String, Time) -> bool
|
|
61
|
+
def held_by_another_live_owner?(instance, owner_id, now)
|
|
62
|
+
instance.activation_owner_id.present? &&
|
|
63
|
+
instance.activation_owner_id != owner_id &&
|
|
64
|
+
instance.activation_expires_at.present? &&
|
|
65
|
+
instance.activation_expires_at > now
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @rbs @instance_id: Integer
|
|
70
|
+
# @rbs @owner_id: String
|
|
71
|
+
# @rbs @generation: Integer
|
|
72
|
+
# @rbs @expires_at: Time
|
|
73
|
+
# @rbs @database_adapter: DatabaseAdapter
|
|
74
|
+
|
|
75
|
+
attr_reader :instance_id, :owner_id, :generation, :expires_at
|
|
76
|
+
|
|
77
|
+
# @rbs (instance_id: Integer, owner_id: String, generation: Integer, expires_at: Time, database_adapter: DatabaseAdapter) -> void
|
|
78
|
+
def initialize(instance_id:, owner_id:, generation:, expires_at:, database_adapter:)
|
|
79
|
+
@instance_id = instance_id
|
|
80
|
+
@owner_id = owner_id
|
|
81
|
+
@generation = generation
|
|
82
|
+
@expires_at = expires_at
|
|
83
|
+
@database_adapter = database_adapter
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @rbs (?duration: Numeric) -> Lease
|
|
87
|
+
def renew(duration: SolidObjects.configuration.lease_duration)
|
|
88
|
+
renewed_lease = database_adapter.transaction do
|
|
89
|
+
instance = locked_owned_instance!
|
|
90
|
+
now = database_adapter.database_now
|
|
91
|
+
verify_unexpired!(instance, now)
|
|
92
|
+
renewed_expiration = now + duration
|
|
93
|
+
instance.update!(activation_expires_at: renewed_expiration)
|
|
94
|
+
|
|
95
|
+
self.class.new(
|
|
96
|
+
instance_id:,
|
|
97
|
+
owner_id:,
|
|
98
|
+
generation:,
|
|
99
|
+
expires_at: renewed_expiration,
|
|
100
|
+
database_adapter:
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
SolidObjects.instrument(
|
|
104
|
+
:"activation.renewed",
|
|
105
|
+
instance_id:,
|
|
106
|
+
owner_id:,
|
|
107
|
+
generation:,
|
|
108
|
+
expires_at: renewed_lease.expires_at
|
|
109
|
+
)
|
|
110
|
+
renewed_lease
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @rbs () -> bool
|
|
114
|
+
def release
|
|
115
|
+
released = database_adapter.transaction do
|
|
116
|
+
instance = Instance.lock.find_by(id: instance_id)
|
|
117
|
+
next false unless instance
|
|
118
|
+
next false unless owned_generation?(instance)
|
|
119
|
+
|
|
120
|
+
instance.update!(activation_owner_id: nil, activation_expires_at: nil)
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if released
|
|
125
|
+
SolidObjects.instrument(
|
|
126
|
+
:"activation.released",
|
|
127
|
+
instance_id:,
|
|
128
|
+
owner_id:,
|
|
129
|
+
generation:
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
released
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @rbs () { (Instance) -> untyped } -> untyped
|
|
137
|
+
def fenced_transaction
|
|
138
|
+
database_adapter.transaction do
|
|
139
|
+
instance = locked_owned_instance!
|
|
140
|
+
verify_unexpired!(instance, database_adapter.database_now)
|
|
141
|
+
yield instance
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
attr_reader :database_adapter
|
|
148
|
+
|
|
149
|
+
# @rbs () -> Instance
|
|
150
|
+
def locked_owned_instance!
|
|
151
|
+
instance = Instance.lock.find_by(id: instance_id)
|
|
152
|
+
raise ActorDestroyed, "actor instance no longer exists" unless instance
|
|
153
|
+
|
|
154
|
+
instance.tap do
|
|
155
|
+
raise LostActivation, "activation owner or generation changed" unless owned_generation?(instance)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @rbs (Instance) -> bool
|
|
160
|
+
def owned_generation?(instance)
|
|
161
|
+
instance.activation_owner_id == owner_id &&
|
|
162
|
+
instance.activation_generation == generation
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @rbs (Instance, Time) -> void
|
|
166
|
+
def verify_unexpired!(instance, now)
|
|
167
|
+
return if instance.activation_expires_at && instance.activation_expires_at > now
|
|
168
|
+
|
|
169
|
+
raise LostActivation, "activation lease expired"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class LeaseRenewer
|
|
5
|
+
# @rbs @activation: Activation
|
|
6
|
+
# @rbs @process_registry: ProcessRegistry
|
|
7
|
+
# @rbs @mutex: Thread::Mutex
|
|
8
|
+
# @rbs @condition: Thread::ConditionVariable
|
|
9
|
+
# @rbs @stopped: bool
|
|
10
|
+
# @rbs @thread: Thread?
|
|
11
|
+
|
|
12
|
+
# @rbs (activation: Activation, process_registry: ProcessRegistry) -> void
|
|
13
|
+
def initialize(activation:, process_registry:)
|
|
14
|
+
@activation = activation
|
|
15
|
+
@process_registry = process_registry
|
|
16
|
+
@mutex = Thread::Mutex.new
|
|
17
|
+
@condition = Thread::ConditionVariable.new
|
|
18
|
+
@stopped = false
|
|
19
|
+
@thread = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs () { () -> untyped } -> untyped
|
|
23
|
+
def around
|
|
24
|
+
start
|
|
25
|
+
yield
|
|
26
|
+
ensure
|
|
27
|
+
stop
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :activation, :process_registry, :mutex, :condition
|
|
33
|
+
|
|
34
|
+
# @rbs () -> void
|
|
35
|
+
def start
|
|
36
|
+
@thread = Thread.new do
|
|
37
|
+
loop do
|
|
38
|
+
break if wait_for_interval
|
|
39
|
+
|
|
40
|
+
activation.renew_lease
|
|
41
|
+
process_registry.heartbeat
|
|
42
|
+
rescue LostActivation
|
|
43
|
+
break
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @rbs () -> bool
|
|
49
|
+
def wait_for_interval
|
|
50
|
+
mutex.synchronize do
|
|
51
|
+
unless @stopped
|
|
52
|
+
condition.wait(
|
|
53
|
+
mutex,
|
|
54
|
+
SolidObjects.configuration.lease_renewal_interval
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
@stopped
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @rbs () -> void
|
|
62
|
+
def stop
|
|
63
|
+
mutex.synchronize do
|
|
64
|
+
@stopped = true
|
|
65
|
+
condition.broadcast
|
|
66
|
+
end
|
|
67
|
+
@thread&.join
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
module LogSubscriber
|
|
5
|
+
class << self
|
|
6
|
+
# @rbs () -> void
|
|
7
|
+
def install
|
|
8
|
+
return if @subscription
|
|
9
|
+
|
|
10
|
+
@subscription = ActiveSupport::Notifications.subscribe(/\Asolid_objects\./) do |event|
|
|
11
|
+
SolidObjects.configuration.logger.info(
|
|
12
|
+
{
|
|
13
|
+
event: event.name,
|
|
14
|
+
duration_ms: event.duration.round(2)
|
|
15
|
+
}.merge(event.payload)
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @rbs () -> void
|
|
21
|
+
def uninstall
|
|
22
|
+
return unless @subscription
|
|
23
|
+
|
|
24
|
+
ActiveSupport::Notifications.unsubscribe(@subscription)
|
|
25
|
+
@subscription = nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|