solid_objects 0.4.2 → 0.5.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +62 -10
  4. data/app/assets/javascripts/solid_objects/component_refresh.js +124 -0
  5. data/app/controllers/solid_objects/components_controller.rb +2 -7
  6. data/app/helpers/solid_objects/actor_helper.rb +8 -1
  7. data/docs/adr/0009-realtime-updates.md +18 -1
  8. data/docs/architecture.md +34 -18
  9. data/docs/authorization.md +24 -0
  10. data/docs/correctness.md +19 -8
  11. data/docs/operations.md +6 -0
  12. data/docs/realtime.md +78 -13
  13. data/docs/roadmap.md +6 -5
  14. data/examples/application/app/views/chat_rooms/show.html.erb +3 -1
  15. data/lib/solid_objects/activation.rb +9 -5
  16. data/lib/solid_objects/actor_registry.rb +6 -1
  17. data/lib/solid_objects/actor_view.rb +40 -13
  18. data/lib/solid_objects/application_actor_loader.rb +53 -0
  19. data/lib/solid_objects/caller_process.rb +6 -4
  20. data/lib/solid_objects/cli.rb +2 -0
  21. data/lib/solid_objects/client.rb +26 -20
  22. data/lib/solid_objects/component_registration.rb +61 -16
  23. data/lib/solid_objects/component_renderer.rb +14 -17
  24. data/lib/solid_objects/component_subscriptions.rb +7 -7
  25. data/lib/solid_objects/component_token.rb +70 -2
  26. data/lib/solid_objects/database_adapter.rb +10 -0
  27. data/lib/solid_objects/database_adapters/sqlite.rb +51 -2
  28. data/lib/solid_objects/dom_identity.rb +12 -3
  29. data/lib/solid_objects/engine.rb +7 -0
  30. data/lib/solid_objects/process_registry.rb +21 -14
  31. data/lib/solid_objects/sync_diagnostics.rb +57 -3
  32. data/lib/solid_objects/synchronous_invocation.rb +45 -13
  33. data/lib/solid_objects/turbo_stream_renderer.rb +14 -5
  34. data/lib/solid_objects/version.rb +1 -1
  35. data/sig/generated/lib/solid_objects/actor_registry.rbs +3 -0
  36. data/sig/generated/lib/solid_objects/actor_view.rbs +10 -4
  37. data/sig/generated/lib/solid_objects/application_actor_loader.rbs +30 -0
  38. data/sig/generated/lib/solid_objects/component_registration.rbs +34 -10
  39. data/sig/generated/lib/solid_objects/component_renderer.rbs +4 -8
  40. data/sig/generated/lib/solid_objects/component_token.rbs +24 -2
  41. data/sig/generated/lib/solid_objects/database_adapter.rbs +8 -2
  42. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +13 -0
  43. data/sig/generated/lib/solid_objects/dom_identity.rbs +5 -2
  44. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +9 -0
  45. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +9 -0
  46. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +3 -0
  47. metadata +4 -1
@@ -3,46 +3,44 @@
3
3
  module SolidObjects
4
4
  class ComponentRenderer
5
5
  # @rbs @snapshot: ActorSnapshot
6
- # @rbs @component_name: String
7
- # @rbs @dependencies: Array[String]
6
+ # @rbs @registration: ComponentRegistration
8
7
  # @rbs @view_context: untyped
9
8
  # @rbs @authorization_context: untyped
10
9
 
11
- # @rbs (snapshot: ActorSnapshot, component_name: String, dependencies: Array[String], view_context: untyped, authorization_context: untyped) -> void
10
+ # @rbs (snapshot: ActorSnapshot, registration: ComponentRegistration, view_context: untyped, authorization_context: untyped) -> void
12
11
  def initialize(
13
12
  snapshot:,
14
- component_name:,
15
- dependencies:,
13
+ registration:,
16
14
  view_context:,
17
15
  authorization_context:
18
16
  )
19
17
  @snapshot = snapshot
20
- @component_name = component_name
21
- @dependencies = dependencies
18
+ @registration = registration
22
19
  @view_context = view_context
23
20
  @authorization_context = authorization_context
24
21
  end
25
22
 
26
23
  # @rbs () -> untyped
27
24
  def call
28
- [ component_name, *dependencies ].uniq.each do |authorization_name|
25
+ [ registration.component_name, *registration.dependencies ].uniq.each do |authorization_name|
29
26
  authorize_read!(authorization_name)
30
27
  end
31
28
  actor = ComponentView.new(
32
29
  snapshot:,
33
- dependencies:,
30
+ dependencies: registration.dependencies,
34
31
  authorization_context:
35
32
  )
36
33
  view_context.render(
37
34
  partial: default_partial,
38
- locals: {
35
+ locals: registration.locals.transform_keys(&:to_sym).merge(
39
36
  actor:,
40
- authorization_context:
41
- }
37
+ authorization_context:,
38
+ component_key: registration.component_key
39
+ )
42
40
  )
43
41
  rescue ActionView::MissingTemplate
44
42
  raise UnknownComponent,
45
- "unknown component #{component_name.inspect} for #{snapshot.reference.actor_type}"
43
+ "unknown component #{registration.component_name.inspect} for #{snapshot.reference.actor_type}"
46
44
  rescue ActionView::Template::Error => error
47
45
  raise error.cause if error.cause.is_a?(SolidObjects::Error)
48
46
 
@@ -52,8 +50,7 @@ module SolidObjects
52
50
  private
53
51
 
54
52
  attr_reader :snapshot,
55
- :component_name,
56
- :dependencies,
53
+ :registration,
57
54
  :view_context,
58
55
  :authorization_context
59
56
 
@@ -63,7 +60,7 @@ module SolidObjects
63
60
  actor_type: snapshot.reference.actor_type,
64
61
  actor_id: snapshot.reference.actor_id,
65
62
  message_name: observable_name,
66
- arguments: {},
63
+ arguments: registration.authorization_arguments,
67
64
  authorization_context:
68
65
  )
69
66
  return if authorized
@@ -76,7 +73,7 @@ module SolidObjects
76
73
  actor_name = snapshot.actor_class.name
77
74
  raise InvalidActor, "anonymous actors cannot render reactive components" unless actor_name
78
75
 
79
- "actors/#{actor_name.underscore}/#{component_name}"
76
+ "actors/#{actor_name.underscore}/#{registration.component_name}"
80
77
  end
81
78
  end
82
79
  end
@@ -28,7 +28,7 @@ module SolidObjects
28
28
  validate_identity!(registration, reference)
29
29
  end
30
30
  end
31
- if registrations.map(&:component_name).uniq.length != registrations.length
31
+ if registrations.map(&:dom_id).uniq.length != registrations.length
32
32
  raise InvalidComponentToken, "duplicate actor component registration"
33
33
  end
34
34
 
@@ -39,7 +39,7 @@ module SolidObjects
39
39
  def initialize(registrations)
40
40
  @registrations = registrations
41
41
  @revisions = registrations.to_h do |registration|
42
- [ registration.component_name, registration.revision_key ]
42
+ [ registration.dom_id, registration.revision_key ]
43
43
  end
44
44
  end
45
45
 
@@ -51,7 +51,7 @@ module SolidObjects
51
51
  registrations.filter_map do |registration|
52
52
  next unless registration.dependencies.include?(observable_name)
53
53
  next unless newer_revision?(
54
- registration.component_name,
54
+ registration.dom_id,
55
55
  instance_id,
56
56
  revision
57
57
  )
@@ -64,7 +64,7 @@ module SolidObjects
64
64
  def reconnect_refreshes(snapshot)
65
65
  registrations.filter_map do |registration|
66
66
  next unless newer_revision?(
67
- registration.component_name,
67
+ registration.dom_id,
68
68
  snapshot.instance_id,
69
69
  snapshot.revision
70
70
  )
@@ -92,7 +92,7 @@ module SolidObjects
92
92
 
93
93
  # @rbs (ComponentRegistration, Integer, Integer) -> String
94
94
  def refresh(registration, instance_id, revision)
95
- revisions[registration.component_name] = [ instance_id, revision ]
95
+ revisions[registration.dom_id] = [ instance_id, revision ]
96
96
  TurboStreamRenderer.component_refresh(
97
97
  registration,
98
98
  instance_id,
@@ -101,8 +101,8 @@ module SolidObjects
101
101
  end
102
102
 
103
103
  # @rbs (String, Integer, Integer) -> bool
104
- def newer_revision?(component_name, instance_id, revision)
105
- current = revisions.fetch(component_name)
104
+ def newer_revision?(dom_id, instance_id, revision)
105
+ current = revisions.fetch(dom_id)
106
106
  (current <=> [ instance_id, revision ]) == -1
107
107
  end
108
108
  end
@@ -7,23 +7,38 @@ module SolidObjects
7
7
  PURPOSE = "solid_objects.actor_component"
8
8
  MAXIMUM_TOKEN_BYTES = 16_384
9
9
  MAXIMUM_DEPENDENCIES = 50
10
+ MAXIMUM_LOCALS = 50
11
+ MAXIMUM_COMPONENT_KEY_BYTES = 512
12
+ REFRESH_METHODS = %w[replace morph].freeze
13
+ RESERVED_LOCALS = %w[actor authorization_context component_key].freeze
14
+ RUBY_KEYWORDS = %w[
15
+ alias and begin break case class def defined do else elsif end ensure
16
+ false for if in module next nil not or redo rescue retry return self
17
+ super then true undef unless until when while yield
18
+ ].freeze
10
19
 
11
20
  module_function
12
21
 
13
- # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String) -> String
22
+ # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, ?component_key: untyped, ?locals: Hash[untyped, untyped], ?refresh_method: String | Symbol) -> String
14
23
  def generate(
15
24
  reference:,
16
25
  component_name:,
17
26
  dependencies:,
18
27
  instance_id:,
19
28
  revision:,
20
- refresh_path:
29
+ refresh_path:,
30
+ component_key: nil,
31
+ locals: {},
32
+ refresh_method: "replace"
21
33
  )
22
34
  payload = {
23
35
  "actor_type" => reference.actor_type,
24
36
  "actor_id" => reference.actor_id,
25
37
  "component_name" => component_name,
38
+ "component_key" => Serialization.dump(component_key),
26
39
  "dependencies" => dependencies,
40
+ "locals" => Serialization.dump(locals),
41
+ "refresh_method" => refresh_method.to_s,
27
42
  "instance_id" => instance_id,
28
43
  "revision" => revision,
29
44
  "refresh_path" => refresh_path
@@ -41,6 +56,7 @@ module SolidObjects
41
56
  end
42
57
 
43
58
  payload = verifier.verified(token, purpose: PURPOSE)
59
+ apply_defaults!(payload)
44
60
  validate_payload!(payload)
45
61
  payload
46
62
  rescue ActiveSupport::MessageVerifier::InvalidSignature
@@ -54,6 +70,8 @@ module SolidObjects
54
70
  payload["actor_id"].is_a?(String) &&
55
71
  payload["component_name"].is_a?(String) &&
56
72
  payload["dependencies"].is_a?(Array) &&
73
+ payload["locals"].is_a?(Hash) &&
74
+ payload["refresh_method"].is_a?(String) &&
57
75
  payload["instance_id"].is_a?(Integer) &&
58
76
  payload["revision"].is_a?(Integer) &&
59
77
  payload["refresh_path"].is_a?(String)
@@ -61,7 +79,10 @@ module SolidObjects
61
79
  end
62
80
 
63
81
  validate_component_name!(payload.fetch("component_name"))
82
+ validate_component_key!(payload["component_key"])
64
83
  validate_dependencies!(payload.fetch("dependencies"))
84
+ validate_locals!(payload.fetch("locals"))
85
+ validate_refresh_method!(payload.fetch("refresh_method"))
65
86
  validate_revision!(
66
87
  payload.fetch("instance_id"),
67
88
  payload.fetch("revision")
@@ -71,6 +92,16 @@ module SolidObjects
71
92
  end
72
93
  private_class_method :validate_payload!
73
94
 
95
+ # @rbs (Hash[String, untyped]?) -> void
96
+ def apply_defaults!(payload)
97
+ return unless payload.is_a?(Hash)
98
+
99
+ payload["component_key"] = nil unless payload.key?("component_key")
100
+ payload["locals"] = {} unless payload.key?("locals")
101
+ payload["refresh_method"] = "replace" unless payload.key?("refresh_method")
102
+ end
103
+ private_class_method :apply_defaults!
104
+
74
105
  # @rbs (String) -> void
75
106
  def validate_component_name!(component_name)
76
107
  return if component_name.match?(/\A[a-zA-Z0-9_]+\z/)
@@ -79,6 +110,20 @@ module SolidObjects
79
110
  end
80
111
  private_class_method :validate_component_name!
81
112
 
113
+ # @rbs (untyped) -> void
114
+ def validate_component_key!(component_key)
115
+ return if component_key.nil?
116
+ return if component_key.is_a?(Integer)
117
+ if component_key.is_a?(String) &&
118
+ component_key.bytesize.positive? &&
119
+ component_key.bytesize <= MAXIMUM_COMPONENT_KEY_BYTES
120
+ return
121
+ end
122
+
123
+ raise InvalidComponentToken, "invalid actor component key"
124
+ end
125
+ private_class_method :validate_component_key!
126
+
82
127
  # @rbs (Array[untyped]) -> void
83
128
  def validate_dependencies!(dependencies)
84
129
  valid = dependencies.any? &&
@@ -91,6 +136,29 @@ module SolidObjects
91
136
  end
92
137
  private_class_method :validate_dependencies!
93
138
 
139
+ # @rbs (Hash[untyped, untyped]) -> void
140
+ def validate_locals!(locals)
141
+ valid = locals.length <= MAXIMUM_LOCALS &&
142
+ locals.keys.all? do |name|
143
+ name.is_a?(String) &&
144
+ name.match?(/\A[a-z_][a-zA-Z0-9_]*\z/) &&
145
+ !RESERVED_LOCALS.include?(name) &&
146
+ !RUBY_KEYWORDS.include?(name)
147
+ end
148
+ return if valid
149
+
150
+ raise InvalidComponentToken, "invalid actor component locals"
151
+ end
152
+ private_class_method :validate_locals!
153
+
154
+ # @rbs (String) -> void
155
+ def validate_refresh_method!(refresh_method)
156
+ return if REFRESH_METHODS.include?(refresh_method)
157
+
158
+ raise InvalidComponentToken, "invalid actor component refresh method"
159
+ end
160
+ private_class_method :validate_refresh_method!
161
+
94
162
  # @rbs (Integer, Integer) -> void
95
163
  def validate_revision!(instance_id, revision)
96
164
  return unless instance_id.negative? || revision.negative?
@@ -52,6 +52,16 @@ module SolidObjects
52
52
  value.is_a?(Time) ? value.utc : Time.parse("#{value} UTC").utc
53
53
  end
54
54
 
55
+ # @rbs () { () -> untyped } -> untyped
56
+ def with_lock_retry
57
+ yield
58
+ end
59
+
60
+ # @rbs () { () -> untyped } -> untyped
61
+ def with_lock_probe
62
+ yield
63
+ end
64
+
55
65
  # @rbs () { () -> untyped } -> untyped
56
66
  def transaction(&block)
57
67
  raise DatabaseDeadlineExceeded, "synchronous invocation deadline expired" if SyncDeadline.expired?
@@ -4,6 +4,8 @@ module SolidObjects
4
4
  module DatabaseAdapters
5
5
  class Sqlite < DatabaseAdapter
6
6
  LOCK_RETRY_INTERVAL = 0.001
7
+ LOCK_RETRY_MUTEX = Thread::Mutex.new
8
+ LOCK_RETRY_CONDITION = Thread::ConditionVariable.new
7
9
 
8
10
  # @rbs () -> String
9
11
  def current_time_expression
@@ -14,14 +16,51 @@ module SolidObjects
14
16
  def transaction(&block)
15
17
  return super unless SyncDeadline.active?
16
18
 
17
- super
19
+ with_lock_retry { super }
20
+ end
21
+
22
+ # @rbs () { () -> untyped } -> untyped
23
+ def with_lock_retry
24
+ return yield unless SyncDeadline.active?
25
+
26
+ raise DatabaseDeadlineExceeded, "synchronous invocation deadline expired" if SyncDeadline.expired?
27
+
28
+ with_connection do |connection|
29
+ with_transaction_deadline(connection) { yield }
30
+ end
18
31
  rescue DatabaseDeadlineExceeded
19
32
  raise if SyncDeadline.expired?
20
33
 
21
- sleep [ LOCK_RETRY_INTERVAL, SyncDeadline.remaining ].min
34
+ wait_before_retry
35
+ retry
36
+ rescue => error
37
+ raise unless deadline_error?(error)
38
+
39
+ if SyncDeadline.expired?
40
+ raise DatabaseDeadlineExceeded,
41
+ "database lock wait exceeded the synchronous invocation deadline",
42
+ cause: error
43
+ end
44
+
45
+ wait_before_retry
22
46
  retry
23
47
  end
24
48
 
49
+ # @rbs () { () -> untyped } -> untyped
50
+ def with_lock_probe
51
+ return yield unless SyncDeadline.active?
52
+
53
+ with_connection do |connection|
54
+ with_transaction_deadline(connection) { yield }
55
+ end
56
+ rescue => error
57
+ raise unless deadline_error?(error)
58
+
59
+ raise DatabaseDeadlineExceeded,
60
+ "database remained locked at the synchronous invocation deadline",
61
+ cause: error
62
+ end
63
+
25
64
  private
26
65
 
27
66
  # @rbs (untyped) { () -> untyped } -> untyped
@@ -47,6 +86,16 @@ module SolidObjects
47
86
  end
48
87
  false
49
88
  end
89
+
90
+ # @rbs () -> void
91
+ def wait_before_retry
92
+ LOCK_RETRY_MUTEX.synchronize do
93
+ LOCK_RETRY_CONDITION.wait(
94
+ LOCK_RETRY_MUTEX,
95
+ [ LOCK_RETRY_INTERVAL, SyncDeadline.remaining ].min
96
+ )
97
+ end
98
+ end
50
99
  end
51
100
  end
52
101
  end
@@ -16,9 +16,12 @@ module SolidObjects
16
16
  "#{scope(reference)}_observable_#{normalized_name(name)}"
17
17
  end
18
18
 
19
- # @rbs (Reference, Symbol | String) -> String
20
- def component(reference, name)
21
- "#{scope(reference)}_component_#{normalized_name(name)}"
19
+ # @rbs (Reference, Symbol | String, ?key: String | Integer?) -> String
20
+ def component(reference, name, key: nil)
21
+ identity = "#{scope(reference)}_component_#{normalized_name(name)}"
22
+ return identity unless key
23
+
24
+ "#{identity}_#{component_key_digest(key)}"
22
25
  end
23
26
 
24
27
  # @rbs (Reference) -> String
@@ -34,5 +37,11 @@ module SolidObjects
34
37
  name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
35
38
  end
36
39
  private_class_method :normalized_name
40
+
41
+ # @rbs (String | Integer) -> String
42
+ def component_key_digest(key)
43
+ Digest::SHA256.hexdigest(JSON.generate(key)).first(24)
44
+ end
45
+ private_class_method :component_key_digest
37
46
  end
38
47
  end
@@ -26,6 +26,13 @@ module SolidObjects
26
26
  end
27
27
  end
28
28
 
29
+ initializer "solid_objects.assets" do |application|
30
+ next unless application.config.respond_to?(:assets)
31
+ next unless application.config.assets.respond_to?(:precompile)
32
+
33
+ application.config.assets.precompile << "solid_objects/component_refresh.js"
34
+ end
35
+
29
36
  rake_tasks do
30
37
  tasks_path = File.expand_path("../tasks", __dir__)
31
38
  Dir[File.join(tasks_path, "**/*.rake")].sort.each { |task| load task }
@@ -80,18 +80,21 @@ module SolidObjects
80
80
 
81
81
  # @rbs (?kind: String, ?metadata: Hash[String | Symbol, untyped]) -> Process
82
82
  def register(kind: "worker", metadata: {})
83
- now = SolidObjects.database_adapter.database_now
84
- @process_record = Process.create!(
85
- id: SecureRandom.uuid,
86
- kind:,
87
- hostname: Socket.gethostname,
88
- pid: ::Process.pid,
89
- started_at: now,
90
- last_heartbeat_at: now,
91
- metadata: Serialization.dump(default_metadata.merge(metadata))
92
- )
83
+ process_record = SolidObjects.database_adapter.with_lock_retry do
84
+ now = SolidObjects.database_adapter.database_now
85
+ Process.create!(
86
+ id: SecureRandom.uuid,
87
+ kind:,
88
+ hostname: Socket.gethostname,
89
+ pid: ::Process.pid,
90
+ started_at: now,
91
+ last_heartbeat_at: now,
92
+ metadata: Serialization.dump(default_metadata.merge(metadata))
93
+ )
94
+ end
95
+ @process_record = process_record
93
96
  @last_heartbeat_at = monotonic_now
94
- @process_record
97
+ process_record
95
98
  end
96
99
 
97
100
  # @rbs () -> bool
@@ -99,9 +102,13 @@ module SolidObjects
99
102
  return false unless process_record
100
103
  return false if heartbeat_recent?
101
104
 
102
- process_record.update(
103
- last_heartbeat_at: SolidObjects.database_adapter.database_now
104
- ).tap { @last_heartbeat_at = monotonic_now }
105
+ updated = SolidObjects.database_adapter.with_lock_retry do
106
+ process_record.update(
107
+ last_heartbeat_at: SolidObjects.database_adapter.database_now
108
+ )
109
+ end
110
+ @last_heartbeat_at = monotonic_now if updated
111
+ updated
105
112
  end
106
113
 
107
114
  # @rbs () -> bool
@@ -10,6 +10,62 @@ module SolidObjects
10
10
  status = message_status(message)
11
11
  waiting_on = waiting_reason(message, instance, blocker)
12
12
  activation = activation_details(instance)
13
+ build_error(
14
+ message,
15
+ timeout:,
16
+ status:,
17
+ waiting_on:,
18
+ activation:,
19
+ blocker: blocker_details(blocker)
20
+ )
21
+ end
22
+
23
+ # @rbs (Message, timeout: Numeric) -> SyncTimeout
24
+ def database_contention(message, timeout:)
25
+ build_error(
26
+ message,
27
+ timeout:,
28
+ status: "unknown",
29
+ waiting_on: "database_contention",
30
+ activation: {},
31
+ blocker: nil
32
+ )
33
+ end
34
+
35
+ # @rbs (MessageReference, timeout: Numeric) -> SyncTimeout
36
+ def database_contention_for(message_reference, timeout:)
37
+ error = SyncTimeout.new(
38
+ timeout:,
39
+ actor_type: message_reference.actor_type,
40
+ actor_id: message_reference.actor_id,
41
+ message_name: "unknown",
42
+ message_id: message_reference.id,
43
+ request_id: message_reference.request_id,
44
+ sequence: message_reference.sequence,
45
+ status: "unknown",
46
+ waiting_on: "database_contention",
47
+ activation: {},
48
+ blocker: nil
49
+ )
50
+ SolidObjects.instrument(
51
+ :"sync.timeout",
52
+ message_id: message_reference.id,
53
+ request_id: message_reference.request_id,
54
+ actor_type: message_reference.actor_type,
55
+ actor_id: message_reference.actor_id,
56
+ sequence: message_reference.sequence,
57
+ status: "unknown",
58
+ waiting_on: "database_contention",
59
+ activation_owner_id: nil,
60
+ activation_generation: nil
61
+ )
62
+ error
63
+ end
64
+
65
+ private
66
+
67
+ # @rbs (Message, timeout: Numeric, status: String, waiting_on: String, activation: Hash[String, untyped], blocker: Hash[String, untyped]?) -> SyncTimeout
68
+ def build_error(message, timeout:, status:, waiting_on:, activation:, blocker:)
13
69
  error = SyncTimeout.new(
14
70
  timeout:,
15
71
  actor_type: message.actor_type,
@@ -21,7 +77,7 @@ module SolidObjects
21
77
  status:,
22
78
  waiting_on:,
23
79
  activation:,
24
- blocker: blocker_details(blocker)
80
+ blocker:
25
81
  )
26
82
  SolidObjects.instrument(
27
83
  :"sync.timeout",
@@ -38,8 +94,6 @@ module SolidObjects
38
94
  error
39
95
  end
40
96
 
41
- private
42
-
43
97
  # @rbs (Message) -> String
44
98
  def message_status(message)
45
99
  return "rejected" if message.rejected?
@@ -22,33 +22,49 @@ module SolidObjects
22
22
  # @rbs (MessageReference, timeout: Numeric) -> untyped
23
23
  def call_before_deadline(message_reference, timeout:)
24
24
  deadline = monotonic_now + SyncDeadline.remaining
25
+ message = nil
25
26
 
26
27
  loop do
27
- message = load_message(message_reference)
28
- return completed_result(message) if message.completed? || message.dead?
29
-
30
- remaining = deadline - monotonic_now
31
- unless remaining.positive?
32
- message = load_message(message_reference)
33
- return completed_result(message) if message.completed? || message.dead?
28
+ unless (deadline - monotonic_now).positive?
29
+ final_message = load_message_at_deadline(message_reference)
30
+ return completed_result(final_message) if final_message&.completed? || final_message&.dead?
34
31
 
35
- raise SyncDiagnostics.new.call(message, timeout:)
32
+ raise final_message ?
33
+ diagnose_timeout(final_message, timeout:) :
34
+ contention_timeout(message, message_reference, timeout:)
36
35
  end
37
36
 
37
+ message = load_message(message_reference)
38
+ return completed_result(message) if message.completed? || message.dead?
39
+
38
40
  processed = assist(message, deadline:)
39
41
  remaining = deadline - monotonic_now
40
42
  wait(remaining) if processed.zero? && remaining.positive?
41
43
  end
42
44
  rescue DatabaseDeadlineExceeded
43
- message = load_message(message_reference)
44
- return completed_result(message) if message.completed? || message.dead?
45
-
46
- raise SyncDiagnostics.new.call(message, timeout:)
45
+ raise message ?
46
+ diagnose_timeout(message, timeout:) :
47
+ contention_timeout(message, message_reference, timeout:)
47
48
  end
48
49
 
49
50
  # @rbs (MessageReference) -> Message
50
51
  def load_message(message_reference)
51
- Message.uncached { Message.find(message_reference.id) }
52
+ SolidObjects.database_adapter.with_lock_retry do
53
+ Message.uncached do
54
+ Message.includes(:dead_letter).find(message_reference.id)
55
+ end
56
+ end
57
+ end
58
+
59
+ # @rbs (MessageReference) -> Message?
60
+ def load_message_at_deadline(message_reference)
61
+ SolidObjects.database_adapter.with_lock_probe do
62
+ Message.uncached do
63
+ Message.includes(:dead_letter).find(message_reference.id)
64
+ end
65
+ end
66
+ rescue DatabaseDeadlineExceeded
67
+ nil
52
68
  end
53
69
 
54
70
  # @rbs (Message) -> untyped
@@ -104,6 +120,22 @@ module SolidObjects
104
120
  )
105
121
  end
106
122
 
123
+ # @rbs (Message, timeout: Numeric) -> SyncTimeout
124
+ def diagnose_timeout(message, timeout:)
125
+ SolidObjects.database_adapter.with_lock_probe do
126
+ SyncDiagnostics.new.call(message, timeout:)
127
+ end
128
+ rescue DatabaseDeadlineExceeded
129
+ SyncDiagnostics.new.database_contention(message, timeout:)
130
+ end
131
+
132
+ # @rbs (Message?, MessageReference, timeout: Numeric) -> SyncTimeout
133
+ def contention_timeout(message, message_reference, timeout:)
134
+ return SyncDiagnostics.new.database_contention(message, timeout:) if message
135
+
136
+ SyncDiagnostics.new.database_contention_for(message_reference, timeout:)
137
+ end
138
+
107
139
  # @rbs () -> Float
108
140
  def monotonic_now
109
141
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
@@ -40,15 +40,14 @@ module SolidObjects
40
40
 
41
41
  # @rbs (ComponentRegistration, Integer, Integer) -> String
42
42
  def component_refresh(registration, instance_id, revision)
43
- target = DomIdentity.component(
44
- registration.reference,
45
- registration.component_name
46
- )
43
+ return morph_component_refresh(registration, instance_id, revision) if registration.morph?
44
+
45
+ target = registration.dom_id
47
46
  source = ERB::Util.html_escape(
48
47
  registration.refresh_url(instance_id, revision)
49
48
  )
50
49
  revision_value = "#{instance_id}:#{revision}"
51
- %(<turbo-stream action="replace" target="#{target}"><template><turbo-frame id="#{target}" src="#{source}" data-solid-objects-revision="#{revision_value}"></turbo-frame></template></turbo-stream>)
50
+ %(<turbo-stream action="replace" target="#{target}"><template><turbo-frame id="#{target}" src="#{source}" data-solid-objects-revision="#{revision_value}" data-solid-objects-refresh="replace"></turbo-frame></template></turbo-stream>)
52
51
  end
53
52
 
54
53
  # @rbs (String) -> Hash[String, untyped]?
@@ -75,5 +74,15 @@ module SolidObjects
75
74
  JSON.generate(value)
76
75
  end
77
76
  private_class_method :display_value
77
+
78
+ # @rbs (ComponentRegistration, Integer, Integer) -> String
79
+ def morph_component_refresh(registration, instance_id, revision)
80
+ source = ERB::Util.html_escape(
81
+ registration.refresh_url(instance_id, revision)
82
+ )
83
+ scope = DomIdentity.scope(registration.reference)
84
+ %(<turbo-stream action="append" target="#{scope}"><template><solid-objects-refresh data-target="#{registration.dom_id}" data-source="#{source}" data-refresh-method="morph"></solid-objects-refresh></template></turbo-stream>)
85
+ end
86
+ private_class_method :morph_component_refresh
78
87
  end
79
88
  end
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end