solid_objects 0.6.0 → 0.7.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6996e5e1ea8fa47b59b66cb6ed0f72e28d1673c2da5697e5507873db55f0b7ff
4
- data.tar.gz: edb728dd6427ca4a1486dc791816b7a3ab8d3bdf822992c27d3800ab925c120e
3
+ metadata.gz: 75595f12197f05985076afb8974ec528b85914db25da49c29b342b9fc531ad97
4
+ data.tar.gz: d240311fe0bfbbd12386d582c4a8dcb560a2698385d65c1f0781340880cbd11c
5
5
  SHA512:
6
- metadata.gz: 888370023e0d24e18eda279862a01a31b8583fccc5913fae571fa76d4001de1f408b22e7c3242c23975f01aeb293c47bd9314d94d98177e99c353fb78796caaa
7
- data.tar.gz: 130982ddf19508a6268ae7356ac6249cd8efcc10cc29dd5c13e5cd865322540deabae688e511825a3a92bdc6bfe6314cf9f003d3ec2f4d273d968668438a1588
6
+ metadata.gz: e303049a41809dca76e99ed3b035cde028817e2f0a6a7fadc1fd90f41bde9e108b83bc06247d596722c135ffad4b9b2fda9e5fb75277a89e837acc0c3ade9b25
7
+ data.tar.gz: 8f800ed5e80df86176ff1f7967da7f117363607c2993872670bfe0d85a0ece83db1eef8f1f4f72a74992182cae77f36bd80ea30ebc81b9c7a5bd03ba16a14785
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1 - 2026-08-09
4
+
5
+ - Retry a contended SQLite write outside a synchronous deadline. Asynchronous
6
+ enqueue had no Ruby-level retry budget, so it depended entirely on SQLite's
7
+ busy handler and raised `SQLite3::BusyException` once concurrent writers
8
+ exhausted it. Bounded by the new `lock_retry_attempts` setting.
9
+ - Pin every GitHub Actions reference to a commit SHA.
10
+ - Add a benchmark comparing individual, batched, and payload delivery for one
11
+ mutation that changes three components.
12
+
13
+ ## 0.7.0 - 2026-08-09
14
+
15
+ - Add `batch:` to reactive components. Components sharing a batch in one actor
16
+ scope collapse into a single browser request per revision instead of one
17
+ request per component. The new `GET /solid_objects/components/batch` endpoint
18
+ returns HTML frames inside a documented JSON envelope, so Turbo morph and ERB
19
+ rendering are unchanged while the contract stays machine readable. Duplicate
20
+ notifications for the same batch and revision coalesce in the browser,
21
+ unchanged components are never requested, and stale frames cannot overwrite a
22
+ newer target. Components without `batch:` behave exactly as before.
23
+ - Add a JavaScript test suite for the browser modules, run in CI with Node's
24
+ test runner and jsdom.
25
+
3
26
  ## 0.6.0 - 2026-08-09
4
27
 
5
28
  - Add `broadcast_payload`, an actor DSL for sending one personalized JSON state
@@ -0,0 +1,148 @@
1
+ const pendingBatches = new Map()
2
+ const activeBatches = new Map()
3
+
4
+ class SolidObjectsBatchRefreshElement extends HTMLElement {
5
+ connectedCallback() {
6
+ if (this.dataset.started === "true") return
7
+
8
+ this.dataset.started = "true"
9
+ this.enqueue()
10
+ }
11
+
12
+ // Several observables changing in one commit produce several notifications
13
+ // for the same batch and revision. Merging them in a microtask turns those
14
+ // into a single request.
15
+ enqueue() {
16
+ const batch = this.dataset.batch
17
+ const revision = this.dataset.revision
18
+ const source = this.dataset.source
19
+ const scope = this.closest("[id]")?.id
20
+ if (!batch || !revision || !source || !scope) return this.remove()
21
+
22
+ // Two actor scopes may reuse a batch name on one page. Keying by scope
23
+ // keeps their requests from merging or cancelling each other.
24
+ const group = `${scope}:${batch}`
25
+ const key = `${group}:${revision}`
26
+ const pending = pendingBatches.get(key)
27
+ if (pending) {
28
+ pending.sources.add(source)
29
+ this.remove()
30
+ return
31
+ }
32
+
33
+ const merged = { sources: new Set([ source ]) }
34
+ pendingBatches.set(key, merged)
35
+ queueMicrotask(() => {
36
+ pendingBatches.delete(key)
37
+ requestBatch(group, batch, merged.sources)
38
+ })
39
+ this.remove()
40
+ }
41
+ }
42
+
43
+ async function requestBatch(group, batch, sources) {
44
+ const previous = activeBatches.get(group)
45
+ previous?.abort()
46
+
47
+ const controller = new AbortController()
48
+ activeBatches.set(group, controller)
49
+
50
+ try {
51
+ const url = mergedUrl(sources)
52
+ if (!url) return
53
+
54
+ const response = await fetch(url, {
55
+ credentials: "same-origin",
56
+ headers: { Accept: "application/json" },
57
+ redirect: "error",
58
+ signal: controller.signal
59
+ })
60
+ if (!response.ok) return dispatchBatchError(batch, `http_${response.status}`)
61
+
62
+ const body = await response.json()
63
+ if (!Array.isArray(body?.frames)) {
64
+ return dispatchBatchError(batch, "invalid_response")
65
+ }
66
+
67
+ body.frames.forEach(applyFrame)
68
+ } catch (error) {
69
+ if (error.name !== "AbortError") dispatchBatchError(batch, "request_failed")
70
+ } finally {
71
+ if (activeBatches.get(group) === controller) activeBatches.delete(group)
72
+ }
73
+ }
74
+
75
+ // Every notification for one batch and revision carries the same endpoint and
76
+ // differs only by which components changed, so the union of their tokens is the
77
+ // complete set to render.
78
+ function mergedUrl(sources) {
79
+ const urls = [ ...sources ].map((source) => new URL(source, window.location.href))
80
+ const first = urls[0]
81
+ if (!first || first.origin !== window.location.origin) return
82
+
83
+ const tokens = new Set()
84
+ urls.forEach((url) => {
85
+ url.searchParams.getAll("tokens[]").forEach((token) => tokens.add(token))
86
+ })
87
+ first.searchParams.delete("tokens[]")
88
+ tokens.forEach((token) => first.searchParams.append("tokens[]", token))
89
+ return first
90
+ }
91
+
92
+ function applyFrame(frame) {
93
+ const target = document.getElementById(frame?.target)
94
+ if (!target || !frame.html) return
95
+ if (!newerRevision(frame.revision, target.dataset.solidObjectsRevision)) return
96
+
97
+ const parsed = new DOMParser().parseFromString(frame.html, "text/html")
98
+ const replacement = parsed.getElementById(frame.target)
99
+ if (!replacement) return
100
+
101
+ const stream = document.createElement("turbo-stream")
102
+ stream.setAttribute("action", "replace")
103
+ if (frame.refresh_method === "morph") stream.setAttribute("method", "morph")
104
+ stream.setAttribute("target", frame.target)
105
+
106
+ const template = document.createElement("template")
107
+ template.content.append(document.importNode(replacement, true))
108
+ stream.append(template)
109
+ document.documentElement.append(stream)
110
+ }
111
+
112
+ function newerRevision(candidate, current) {
113
+ const candidateRevision = parseRevision(candidate)
114
+ const currentRevision = parseRevision(current)
115
+ if (!candidateRevision || !currentRevision) return false
116
+
117
+ return candidateRevision[0] > currentRevision[0] ||
118
+ (candidateRevision[0] === currentRevision[0] &&
119
+ candidateRevision[1] > currentRevision[1])
120
+ }
121
+
122
+ function parseRevision(revision) {
123
+ if (!revision) return
124
+
125
+ const values = String(revision).split(":").map(Number)
126
+ if (
127
+ values.length !== 2 ||
128
+ values.some((value) => !Number.isSafeInteger(value) || value < 0)
129
+ ) return
130
+
131
+ return values
132
+ }
133
+
134
+ function dispatchBatchError(batch, reason) {
135
+ document.dispatchEvent(
136
+ new CustomEvent("solid-objects:batch-refresh-error", {
137
+ bubbles: true,
138
+ detail: { batch, reason }
139
+ })
140
+ )
141
+ }
142
+
143
+ if (!customElements.get("solid-objects-batch-refresh")) {
144
+ customElements.define(
145
+ "solid-objects-batch-refresh",
146
+ SolidObjectsBatchRefreshElement
147
+ )
148
+ }
@@ -6,13 +6,97 @@ module SolidObjects
6
6
  class ComponentsController < ActionController::Base
7
7
  protect_from_forgery with: :exception
8
8
 
9
+ BATCH_LIMIT = 50
10
+
9
11
  # @rbs () -> void
10
12
  def show
11
13
  SolidObjects.instrument(:"component.refreshed") { |payload| refresh(payload) }
12
14
  end
13
15
 
16
+ # @rbs () -> void
17
+ def batch
18
+ SolidObjects.instrument(:"component.batch_refreshed") { |payload| refresh_batch(payload) }
19
+ end
20
+
14
21
  private
15
22
 
23
+ # @rbs (Hash[Symbol, untyped]) -> void
24
+ def refresh_batch(payload)
25
+ tokens = Array(params.require(:tokens))
26
+ raise ActionController::ParameterMissing, :tokens if tokens.empty?
27
+ raise ArgumentError if tokens.length > BATCH_LIMIT
28
+
29
+ registrations = tokens.map { |token| ComponentRegistration.from_token(token) }
30
+ validate_single_batch!(registrations)
31
+ requested_revision = requested_revision_key
32
+ snapshot = ActorSnapshot.new(registrations.first.reference)
33
+ payload.merge!(
34
+ actor_type: snapshot.reference.actor_type,
35
+ actor_id: snapshot.reference.actor_id,
36
+ batch: registrations.first.batch,
37
+ components: registrations.map(&:component_name),
38
+ instance_id: snapshot.instance_id,
39
+ revision: snapshot.revision
40
+ )
41
+ if newer_than_snapshot?(requested_revision, snapshot)
42
+ payload[:outcome] = "conflict"
43
+ return head :conflict
44
+ end
45
+
46
+ authorization_context = SolidObjects
47
+ .configuration
48
+ .component_authorization_context
49
+ .call(controller: self)
50
+ frames = registrations.map do |registration|
51
+ rendered = ComponentRenderer.new(
52
+ snapshot:,
53
+ registration:,
54
+ view_context: component_view_context,
55
+ authorization_context:
56
+ ).call
57
+ {
58
+ "target" => registration.dom_id,
59
+ "revision" => "#{snapshot.instance_id}:#{snapshot.revision}",
60
+ "refresh_method" => registration.refresh_method,
61
+ "html" => component_frame(registration, snapshot, rendered)
62
+ }
63
+ end
64
+ response.headers["Cache-Control"] = "private, no-store"
65
+ payload[:outcome] = "rendered"
66
+ render json: {
67
+ "actor_type" => snapshot.reference.actor_type,
68
+ "actor_id" => snapshot.reference.actor_id,
69
+ "batch" => registrations.first.batch,
70
+ "instance_id" => snapshot.instance_id,
71
+ "revision" => snapshot.revision,
72
+ "frames" => frames
73
+ }
74
+ rescue Unauthorized
75
+ payload[:outcome] = "unauthorized"
76
+ head :forbidden
77
+ rescue UnknownComponent
78
+ payload[:outcome] = "unknown_component"
79
+ head :not_found
80
+ rescue ActionController::ParameterMissing,
81
+ ArgumentError,
82
+ InvalidComponentToken
83
+ payload[:outcome] = "invalid_token"
84
+ head :bad_request
85
+ end
86
+
87
+ # @rbs (Array[ComponentRegistration]) -> void
88
+ def validate_single_batch!(registrations)
89
+ first = registrations.first
90
+ raise ArgumentError unless first.batch
91
+ return if registrations.all? do |registration|
92
+ registration.batch == first.batch &&
93
+ registration.reference.actor_type == first.reference.actor_type &&
94
+ registration.reference.actor_id == first.reference.actor_id
95
+ end
96
+
97
+ raise ArgumentError
98
+ end
99
+
16
100
  # @rbs (Hash[Symbol, untyped]) -> void
17
101
  def refresh(payload)
18
102
  registration = ComponentRegistration.from_token(
@@ -32,6 +32,13 @@ module SolidObjects
32
32
  data: { turbo_track: "reload" }
33
33
  )
34
34
  end
35
+ batch_client = if actor.batched_components?
36
+ javascript_include_tag(
37
+ "solid_objects/component_batch_refresh",
38
+ type: "module",
39
+ data: { turbo_track: "reload" }
40
+ )
41
+ end
35
42
  payload_client = if payload_names
36
43
  javascript_include_tag(
37
44
  "solid_objects/state_payload",
@@ -42,7 +49,9 @@ module SolidObjects
42
49
 
43
50
  content_tag(
44
51
  :div,
45
- safe_join([ refresh_client, payload_client, subscription, content ].compact),
52
+ safe_join(
53
+ [ refresh_client, batch_client, payload_client, subscription, content ].compact
54
+ ),
46
55
  id: DomIdentity.scope(reference)
47
56
  )
48
57
  end
@@ -0,0 +1,5 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative "support"
4
+
5
+ SolidObjectsBenchmark.component_delivery
data/benchmark/support.rb CHANGED
@@ -22,6 +22,43 @@ module SolidObjectsBenchmark
22
22
  end
23
23
  end
24
24
 
25
+ class BenchmarkConnection
26
+ attr_reader :session_id
27
+
28
+ def initialize(session_id)
29
+ @session_id = session_id
30
+ end
31
+ end
32
+
33
+ class PlaymatActor < SolidObjects::Actor
34
+ actor_type "benchmark-playmat"
35
+
36
+ attribute :player, default: "unseated"
37
+ attribute :player_controls, default: -> { [] }
38
+ attribute :library_search, default: -> { [] }
39
+ attribute :hands, default: -> { {} }
40
+
41
+ observable :player
42
+ observable :player_controls
43
+ observable :library_search
44
+
45
+ broadcast_payload :playmat_state do |actor, context|
46
+ {
47
+ "player" => actor.player,
48
+ "controls" => actor.player_controls,
49
+ "library" => actor.library_search,
50
+ "hand" => actor.hands.fetch(context.session_id, [])
51
+ }
52
+ end
53
+
54
+ def seat(player:)
55
+ self.player = player
56
+ self.player_controls = %w[untap draw]
57
+ self.library_search = %w[Island Forest]
58
+ self.hands = hands.merge(player => %w[Island])
59
+ end
60
+ end
61
+
25
62
  class << self
26
63
  # @rbs () -> Integer
27
64
  def count
@@ -189,6 +226,69 @@ module SolidObjectsBenchmark
189
226
  worker&.stop
190
227
  end
191
228
 
229
+ # Compares how many browser requests one actor mutation costs across the
230
+ # three delivery paths, and how long the server spends producing them.
231
+ # @rbs () -> void
232
+ def component_delivery
233
+ require "action_controller"
234
+ require "action_view"
235
+ require "action_view/testing/resolvers"
236
+
237
+ SolidObjects.configuration.stream_signing_secret = "benchmark-secret"
238
+ SolidObjects.configuration.authorize_query = ->(**) { true }
239
+ reference = PlaymatActor.ref("table")
240
+ reference.seat(player: "alice")
241
+
242
+ view_context = benchmark_view_context
243
+ snapshot = SolidObjects::ActorSnapshot.new(reference)
244
+ registrations = %w[player player_controls library_search].map do |name|
245
+ SolidObjects::ComponentRegistration.issue(
246
+ reference:,
247
+ component_name: name,
248
+ component_key: nil,
249
+ dependencies: [ name ],
250
+ locals: {},
251
+ refresh_method: "morph",
252
+ snapshot:,
253
+ refresh_path: "/solid_objects/components",
254
+ batch: "playmat"
255
+ )
256
+ end
257
+
258
+ individual = measure_delivery(count) do
259
+ registrations.each do |registration|
260
+ render_component(registration, view_context)
261
+ end
262
+ end
263
+ batched = measure_delivery(count) do
264
+ current = SolidObjects::ActorSnapshot.new(reference)
265
+ registrations.each do |registration|
266
+ render_component(registration, view_context, snapshot: current)
267
+ end
268
+ end
269
+ payload = measure_delivery(count) do
270
+ SolidObjects::PayloadBroadcast.new(
271
+ snapshot: SolidObjects::ActorSnapshot.new(reference),
272
+ name: "playmat_state",
273
+ authorization_context: BenchmarkConnection.new("alice")
274
+ ).call
275
+ end
276
+
277
+ puts "three components changing in one mutation, #{count} iterations"
278
+ puts format(
279
+ " individual refreshes: 3 requests, %.3fms per mutation",
280
+ individual
281
+ )
282
+ puts format(
283
+ " batched refresh: 1 request, %.3fms per mutation",
284
+ batched
285
+ )
286
+ puts format(
287
+ " state payload: 0 requests, %.3fms per mutation",
288
+ payload
289
+ )
290
+ end
291
+
192
292
  # @rbs () -> void
193
293
  def query_count
194
294
  CounterActor.ref("queries").async(:increment)
@@ -209,6 +309,37 @@ module SolidObjectsBenchmark
209
309
 
210
310
  private
211
311
 
312
+ # @rbs (ComponentRegistration, untyped, ?snapshot: ActorSnapshot?) -> untyped
313
+ def render_component(registration, view_context, snapshot: nil)
314
+ SolidObjects::ComponentRenderer.new(
315
+ snapshot: snapshot || SolidObjects::ActorSnapshot.new(registration.reference),
316
+ registration:,
317
+ view_context:,
318
+ authorization_context: nil
319
+ ).call
320
+ end
321
+
322
+ # @rbs (Integer) { () -> untyped } -> Float
323
+ def measure_delivery(iterations)
324
+ yield
325
+ elapsed = Benchmark.realtime { iterations.times { yield } }
326
+ (elapsed / iterations) * 1_000
327
+ end
328
+
329
+ # @rbs () -> untyped
330
+ def benchmark_view_context
331
+ resolver = ActionView::FixtureResolver.new(
332
+ "actors/solid_objects_benchmark/playmat_actor/_player.html.erb" => "<p><%= actor.player %></p>",
333
+ "actors/solid_objects_benchmark/playmat_actor/_player_controls.html.erb" => "<ul><% actor.player_controls.each do |c| %><li><%= c %></li><% end %></ul>",
334
+ "actors/solid_objects_benchmark/playmat_actor/_library_search.html.erb" => "<ul><% actor.library_search.each do |c| %><li><%= c %></li><% end %></ul>"
335
+ )
336
+ ActionView::Base.with_empty_template_cache.new(
337
+ ActionView::LookupContext.new([ resolver ]),
338
+ {},
339
+ nil
340
+ )
341
+ end
342
+
212
343
  # @rbs () -> void
213
344
  def establish_connection
214
345
  database_url = ENV["SOLID_OBJECTS_DATABASE_URL"]
data/config/routes.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  SolidObjects::Engine.routes.draw do
4
4
  get :components, to: "components#show"
5
+ get "components/batch", to: "components#batch"
5
6
  resources :instances, only: %i[index show]
6
7
  resources :dead_letters, only: %i[index] do
7
8
  post :retry, on: :member
data/docs/benchmarks.md CHANGED
@@ -54,6 +54,28 @@ result is why Solid Objects does not publish one latency promise. Network
54
54
  topology, adapter behavior, host schema, logging, callbacks, and contention all
55
55
  matter.
56
56
 
57
+ ## Reactive delivery paths
58
+
59
+ Measured 2026-08-09 on an Apple M5 with 200 iterations, for one actor mutation
60
+ that changes three components.
61
+
62
+ | Delivery path | Browser requests | Server render time |
63
+ | --- | ---: | ---: |
64
+ | Individual component refreshes | 3 | 0.535 ms |
65
+ | Batched refresh | 1 | 0.249 ms |
66
+ | State payload broadcast | 0 | 0.100 ms |
67
+
68
+ The request column is the headline. Server render time is small in every path,
69
+ so the win is not faster rendering, it is fewer round trips: each individual
70
+ refresh costs a full HTTP request through the Rails middleware stack, and a
71
+ batch replaces three of those with one. A state payload removes the HTTP leg
72
+ entirely by travelling on the Action Cable connection the page already holds.
73
+
74
+ These are server-side numbers. They do not include network latency, Action
75
+ Cable delivery, or browser rendering, which dominate wall-clock time in a real
76
+ deployment and make the request-count difference matter more than it appears
77
+ here. End-to-end latency against a deployed application has not been measured.
78
+
57
79
  ## Durable row growth
58
80
 
59
81
  The storage cost is deterministic even when latency is not:
data/docs/realtime.md CHANGED
@@ -110,6 +110,80 @@ applications discover the namespaced engine asset. Applications created with
110
110
  explicitly serve the module. Turbo's normal morph rules still apply; use
111
111
  `data-turbo-permanent` for elements that must never be changed.
112
112
 
113
+ ## Batched component refreshes
114
+
115
+ A component refresh costs one browser request. When one actor mutation changes
116
+ several components, the page pays one request per component. Adding `batch:`
117
+ groups them so a revision costs one request no matter how many components in the
118
+ group changed:
119
+
120
+ ```erb
121
+ <%= actor.component :player, key: 1,
122
+ observes: :player_one, batch: :playmat, refresh: :morph %>
123
+
124
+ <%= actor.component :player_controls, key: 1,
125
+ observes: :player_one_controls, batch: :playmat, refresh: :morph %>
126
+
127
+ <%= actor.component :library_search, key: 1,
128
+ observes: :library, batch: :playmat, refresh: :morph %>
129
+ ```
130
+
131
+ Before, one mutation touching all three observables produced three requests:
132
+
133
+ ```
134
+ commit -> 3 invalidations -> 3 refresh elements -> 3 GET /solid_objects/components
135
+ ```
136
+
137
+ After, the three notifications coalesce in the browser into one request:
138
+
139
+ ```
140
+ commit -> 3 invalidations -> 1 GET /solid_objects/components/batch -> 3 frames
141
+ ```
142
+
143
+ ### The batch endpoint
144
+
145
+ `GET /solid_objects/components/batch` takes the signed `tokens[]` of the
146
+ components to render plus the `instance_id` and `revision` the browser holds. It
147
+ returns HTML frames inside a JSON envelope:
148
+
149
+ ```json
150
+ {
151
+ "actor_type": "playmat_room",
152
+ "actor_id": "table-1",
153
+ "batch": "playmat",
154
+ "instance_id": 12,
155
+ "revision": 48,
156
+ "frames": [
157
+ {
158
+ "target": "solid-objects-component-...",
159
+ "revision": "12:48",
160
+ "refresh_method": "morph",
161
+ "html": "<turbo-frame id=\"...\" data-solid-objects-revision=\"12:48\">...</turbo-frame>"
162
+ }
163
+ ]
164
+ }
165
+ ```
166
+
167
+ **Why frames inside JSON rather than one HTML document or pure JSON state.** HTML
168
+ alone would force the browser to pick frames out of an undocumented document.
169
+ Pure JSON would mean a second renderer and would give up ERB and Turbo morph. A
170
+ JSON envelope of frame descriptors keeps `ComponentRenderer` and Turbo exactly as
171
+ they are while giving the client a documented contract with per-frame revisions.
172
+
173
+ ### What the protocol guarantees
174
+
175
+ Only components whose dependencies changed are requested; the rest are never
176
+ named in the batch. Duplicate notifications for the same batch and revision merge
177
+ into one request, and a superseded request for the same batch is aborted. Each
178
+ frame carries its own revision and cannot overwrite a target that already holds a
179
+ newer one. Authorization is unchanged: every component in the batch passes the
180
+ same `authorize_query` boundary an individual refresh uses, and the batch name is
181
+ signed into the component token, so a browser cannot invent or widen a group. A
182
+ batch mixing actors or groups is rejected.
183
+
184
+ Components without `batch:` keep issuing their own request, and a scope can mix
185
+ batched and unbatched components freely.
186
+
113
187
  ## Personalized state payloads
114
188
 
115
189
  Reactive ERB components cost one browser request per changed component. When a
@@ -37,18 +37,19 @@ module SolidObjects
37
37
  )
38
38
  end
39
39
 
40
- # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol) -> untyped
40
+ # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol, ?batch: untyped) -> untyped
41
41
  def component(
42
42
  name,
43
43
  observes: nil,
44
44
  partial: nil,
45
45
  key: nil,
46
46
  locals: {},
47
- refresh: :replace
47
+ refresh: :replace,
48
+ batch: nil
48
49
  )
49
50
  component_name = normalized_component_name(name)
50
51
  unless observes
51
- validate_static_options!(key:, locals:, refresh:)
52
+ validate_static_options!(key:, locals:, refresh:, batch:)
52
53
  return static_component(component_name, partial:)
53
54
  end
54
55
  if partial
@@ -67,7 +68,8 @@ module SolidObjects
67
68
  locals:,
68
69
  refresh_method: refresh,
69
70
  snapshot:,
70
- refresh_path:
71
+ refresh_path:,
72
+ batch: batch&.to_s
71
73
  )
72
74
  ensure_unique_component!(registration)
73
75
  rendered = ComponentRenderer.new(
@@ -98,6 +100,11 @@ module SolidObjects
98
100
  observable_names.dup
99
101
  end
100
102
 
103
+ # @rbs () -> bool
104
+ def batched_components?
105
+ component_registrations.any?(&:batch)
106
+ end
107
+
101
108
  # @rbs () -> bool
102
109
  def morph_components?
103
110
  component_registrations.any?(&:morph?)
@@ -212,9 +219,9 @@ module SolidObjects
212
219
  "#{registration.component_key.inspect} is already rendered in this solid_object scope"
213
220
  end
214
221
 
215
- # @rbs (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol) -> void
216
- def validate_static_options!(key:, locals:, refresh:)
217
- return if key.nil? && locals.empty? && refresh.to_s == "replace"
222
+ # @rbs (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol, batch: untyped) -> void
223
+ def validate_static_options!(key:, locals:, refresh:, batch:)
224
+ return if key.nil? && locals.empty? && refresh.to_s == "replace" && batch.nil?
218
225
 
219
226
  raise ArgumentError,
220
227
  "key, locals, and refresh require an observable component"
@@ -7,6 +7,7 @@ module SolidObjects
7
7
  # @rbs @reference: Reference
8
8
  # @rbs @component_name: String
9
9
  # @rbs @component_key: String | Integer?
10
+ # @rbs @batch: String?
10
11
  # @rbs @dependencies: Array[String]
11
12
  # @rbs @locals: Hash[String, untyped]
12
13
  # @rbs @refresh_method: String
@@ -18,6 +19,7 @@ module SolidObjects
18
19
  attr_reader :reference,
19
20
  :component_name,
20
21
  :component_key,
22
+ :batch,
21
23
  :dependencies,
22
24
  :locals,
23
25
  :refresh_method,
@@ -26,7 +28,7 @@ module SolidObjects
26
28
  :refresh_path,
27
29
  :token
28
30
 
29
- # @rbs (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
31
+ # @rbs (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String, ?batch: String?) -> void
30
32
  def initialize(
31
33
  reference:,
32
34
  component_name:,
@@ -37,9 +39,11 @@ module SolidObjects
37
39
  instance_id:,
38
40
  revision:,
39
41
  refresh_path:,
40
- token:
42
+ token:,
43
+ batch: nil
41
44
  )
42
45
  @reference = reference
46
+ @batch = batch
43
47
  @component_name = component_name
44
48
  @component_key = component_key
45
49
  @dependencies = dependencies.freeze
@@ -52,7 +56,7 @@ module SolidObjects
52
56
  end
53
57
 
54
58
  class << self
55
- # @rbs (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
59
+ # @rbs (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String, ?batch: String?) -> ComponentRegistration
56
60
  def issue(
57
61
  reference:,
58
62
  component_name:,
@@ -61,7 +65,8 @@ module SolidObjects
61
65
  locals:,
62
66
  refresh_method:,
63
67
  snapshot:,
64
- refresh_path:
68
+ refresh_path:,
69
+ batch: nil
65
70
  )
66
71
  token = ComponentToken.generate(
67
72
  reference:,
@@ -70,6 +75,7 @@ module SolidObjects
70
75
  dependencies:,
71
76
  locals:,
72
77
  refresh_method:,
78
+ batch:,
73
79
  instance_id: snapshot.instance_id,
74
80
  revision: snapshot.revision,
75
81
  refresh_path:
@@ -99,6 +105,7 @@ module SolidObjects
99
105
  reference:,
100
106
  component_name: payload.fetch("component_name"),
101
107
  component_key: payload["component_key"],
108
+ batch: payload["batch"],
102
109
  dependencies:,
103
110
  locals: payload.fetch("locals"),
104
111
  refresh_method: payload.fetch("refresh_method"),
@@ -149,6 +156,18 @@ module SolidObjects
149
156
  locals.merge("component_key" => component_key).freeze
150
157
  end
151
158
 
159
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> String
160
+ def batch_refresh_url(registrations, instance_id, revision)
161
+ query = URI.encode_www_form(
162
+ [
163
+ [ "instance_id", instance_id ],
164
+ [ "revision", revision ],
165
+ *registrations.map { |registration| [ "tokens[]", registration.token ] }
166
+ ]
167
+ )
168
+ "#{refresh_path}/batch?#{query}"
169
+ end
170
+
152
171
  # @rbs (Integer, Integer) -> String
153
172
  def refresh_url(instance_id, revision)
154
173
  query = URI.encode_www_form(
@@ -48,16 +48,17 @@ module SolidObjects
48
48
  observable_name = invalidation.fetch("observable_name")
49
49
  instance_id = invalidation.fetch("instance_id")
50
50
  revision = invalidation.fetch("revision")
51
- registrations.filter_map do |registration|
52
- next unless registration.dependencies.include?(observable_name)
53
- next unless newer_revision?(
54
- registration.dom_id,
55
- instance_id,
56
- revision
57
- )
58
-
59
- refresh(registration, instance_id, revision)
51
+ changed = registrations.select do |registration|
52
+ registration.dependencies.include?(observable_name) &&
53
+ newer_revision?(registration.dom_id, instance_id, revision)
60
54
  end
55
+ batched, individual = changed.partition(&:batch)
56
+ streams = individual.map { |registration| refresh(registration, instance_id, revision) }
57
+ batched.group_by(&:batch).each_value do |group|
58
+ group.each { |registration| record_revision(registration, instance_id, revision) }
59
+ streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
60
+ end
61
+ streams
61
62
  end
62
63
 
63
64
  # @rbs (ActorSnapshot) -> Array[String]
@@ -90,9 +91,14 @@ module SolidObjects
90
91
 
91
92
  attr_reader :registrations, :revisions
92
93
 
94
+ # @rbs (ComponentRegistration, Integer, Integer) -> void
95
+ def record_revision(registration, instance_id, revision)
96
+ revisions[registration.dom_id] = [ instance_id, revision ]
97
+ end
98
+
93
99
  # @rbs (ComponentRegistration, Integer, Integer) -> String
94
100
  def refresh(registration, instance_id, revision)
95
- revisions[registration.dom_id] = [ instance_id, revision ]
101
+ record_revision(registration, instance_id, revision)
96
102
  TurboStreamRenderer.component_refresh(
97
103
  registration,
98
104
  instance_id,
@@ -9,6 +9,7 @@ module SolidObjects
9
9
  MAXIMUM_DEPENDENCIES = 50
10
10
  MAXIMUM_LOCALS = 50
11
11
  MAXIMUM_COMPONENT_KEY_BYTES = 512
12
+ MAXIMUM_BATCH_BYTES = 64
12
13
  REFRESH_METHODS = %w[replace morph].freeze
13
14
  RESERVED_LOCALS = %w[actor authorization_context component_key].freeze
14
15
  RUBY_KEYWORDS = %w[
@@ -19,7 +20,7 @@ module SolidObjects
19
20
 
20
21
  module_function
21
22
 
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
23
+ # @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, ?batch: untyped) -> String
23
24
  def generate(
24
25
  reference:,
25
26
  component_name:,
@@ -29,13 +30,15 @@ module SolidObjects
29
30
  refresh_path:,
30
31
  component_key: nil,
31
32
  locals: {},
32
- refresh_method: "replace"
33
+ refresh_method: "replace",
34
+ batch: nil
33
35
  )
34
36
  payload = {
35
37
  "actor_type" => reference.actor_type,
36
38
  "actor_id" => reference.actor_id,
37
39
  "component_name" => component_name,
38
40
  "component_key" => Serialization.dump(component_key),
41
+ "batch" => batch&.to_s,
39
42
  "dependencies" => dependencies,
40
43
  "locals" => Serialization.dump(locals),
41
44
  "refresh_method" => refresh_method.to_s,
@@ -80,6 +83,7 @@ module SolidObjects
80
83
 
81
84
  validate_component_name!(payload.fetch("component_name"))
82
85
  validate_component_key!(payload["component_key"])
86
+ validate_batch!(payload["batch"])
83
87
  validate_dependencies!(payload.fetch("dependencies"))
84
88
  validate_locals!(payload.fetch("locals"))
85
89
  validate_refresh_method!(payload.fetch("refresh_method"))
@@ -97,6 +101,7 @@ module SolidObjects
97
101
  return unless payload.is_a?(Hash)
98
102
 
99
103
  payload["component_key"] = nil unless payload.key?("component_key")
104
+ payload["batch"] = nil unless payload.key?("batch")
100
105
  payload["locals"] = {} unless payload.key?("locals")
101
106
  payload["refresh_method"] = "replace" unless payload.key?("refresh_method")
102
107
  end
@@ -124,6 +129,18 @@ module SolidObjects
124
129
  end
125
130
  private_class_method :validate_component_key!
126
131
 
132
+ # @rbs (untyped) -> void
133
+ def validate_batch!(batch)
134
+ return if batch.nil?
135
+ return if batch.is_a?(String) &&
136
+ batch.bytesize.positive? &&
137
+ batch.bytesize <= MAXIMUM_BATCH_BYTES &&
138
+ batch.match?(/\A[a-zA-Z0-9_]+\z/)
139
+
140
+ raise InvalidComponentToken, "invalid actor component batch"
141
+ end
142
+ private_class_method :validate_batch!
143
+
127
144
  # @rbs (Array[untyped]) -> void
128
145
  def validate_dependencies!(dependencies)
129
146
  valid = dependencies.any? &&
@@ -17,6 +17,7 @@ module SolidObjects
17
17
  # @rbs @max_result_bytes: Integer
18
18
  # @rbs @max_attempts: Integer
19
19
  # @rbs @retry_delay: Proc
20
+ # @rbs @lock_retry_attempts: Integer
20
21
  # @rbs @process_heartbeat_interval: Float
21
22
  # @rbs @process_alive_threshold: Float
22
23
  # @rbs @shutdown_timeout: Float
@@ -57,6 +58,7 @@ module SolidObjects
57
58
  :max_result_bytes,
58
59
  :max_attempts,
59
60
  :retry_delay,
61
+ :lock_retry_attempts,
60
62
  :process_heartbeat_interval,
61
63
  :process_alive_threshold,
62
64
  :shutdown_timeout,
@@ -99,6 +101,7 @@ module SolidObjects
99
101
  @max_result_bytes = 1.megabyte
100
102
  @max_attempts = 5
101
103
  @retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f }
104
+ @lock_retry_attempts = 10
102
105
  @process_heartbeat_interval = 15.0
103
106
  @process_alive_threshold = 60.0
104
107
  @shutdown_timeout = 15.0
@@ -4,6 +4,7 @@ module SolidObjects
4
4
  module DatabaseAdapters
5
5
  class Sqlite < DatabaseAdapter
6
6
  LOCK_RETRY_INTERVAL = 0.001
7
+ MAXIMUM_BUSY_RETRY_INTERVAL = 0.25
7
8
  LOCK_RETRY_MUTEX = Thread::Mutex.new
8
9
  LOCK_RETRY_CONDITION = Thread::ConditionVariable.new
9
10
 
@@ -14,9 +15,28 @@ module SolidObjects
14
15
 
15
16
  # @rbs () { () -> untyped } -> untyped
16
17
  def transaction(&block)
17
- return super unless SyncDeadline.active?
18
+ return with_lock_retry { super } if SyncDeadline.active?
18
19
 
19
- with_lock_retry { super }
20
+ with_busy_retry { super }
21
+ end
22
+
23
+ # A write outside a synchronous deadline has no Ruby-level budget, so it
24
+ # depends entirely on SQLite's busy handler. Concurrent writers can
25
+ # exhaust that, which surfaces as a lock error the caller cannot retry.
26
+ # @rbs () { () -> untyped } -> untyped
27
+ def with_busy_retry
28
+ attempts = 0
29
+ begin
30
+ yield
31
+ rescue => error
32
+ raise unless busy_error?(error)
33
+
34
+ attempts += 1
35
+ raise if attempts > SolidObjects.configuration.lock_retry_attempts
36
+
37
+ wait_before_busy_retry(attempts)
38
+ retry
39
+ end
20
40
  end
21
41
 
22
42
  # @rbs () { () -> untyped } -> untyped
@@ -116,6 +136,11 @@ module SolidObjects
116
136
  def deadline_error?(error)
117
137
  return false unless SyncDeadline.active?
118
138
 
139
+ busy_error?(error)
140
+ end
141
+
142
+ # @rbs (Exception) -> bool
143
+ def busy_error?(error)
119
144
  cause = error
120
145
  while cause
121
146
  return true if cause.class.name.match?(/BusyException|BusyError/)
@@ -125,6 +150,16 @@ module SolidObjects
125
150
  false
126
151
  end
127
152
 
153
+ # @rbs (Integer) -> void
154
+ def wait_before_busy_retry(attempts)
155
+ LOCK_RETRY_MUTEX.synchronize do
156
+ LOCK_RETRY_CONDITION.wait(
157
+ LOCK_RETRY_MUTEX,
158
+ [ LOCK_RETRY_INTERVAL * (2**(attempts - 1)), MAXIMUM_BUSY_RETRY_INTERVAL ].min
159
+ )
160
+ end
161
+ end
162
+
128
163
  # @rbs () -> void
129
164
  def wait_before_retry
130
165
  LOCK_RETRY_MUTEX.synchronize do
@@ -54,6 +54,18 @@ module SolidObjects
54
54
  %(<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>)
55
55
  end
56
56
 
57
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> String
58
+ def batch_refresh(registrations, instance_id, revision)
59
+ first = registrations.first
60
+ scope = DomIdentity.scope(first.reference)
61
+ batch = ERB::Util.html_escape(first.batch)
62
+ source = ERB::Util.html_escape(
63
+ first.batch_refresh_url(registrations, instance_id, revision)
64
+ )
65
+ targets = ERB::Util.html_escape(registrations.map(&:dom_id).join(" "))
66
+ %(<turbo-stream action="append" target="#{scope}"><template><solid-objects-batch-refresh data-batch="#{batch}" data-revision="#{instance_id}:#{revision}" data-targets="#{targets}" data-source="#{source}"></solid-objects-batch-refresh></template></turbo-stream>)
67
+ end
68
+
57
69
  # @rbs (Hash[String, untyped]) -> String
58
70
  def state_payload(payload)
59
71
  reference = Reference.new(
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.1"
5
5
  end
@@ -2,11 +2,22 @@
2
2
 
3
3
  module SolidObjects
4
4
  class ComponentsController < ActionController::Base
5
+ BATCH_LIMIT: ::Integer
6
+
5
7
  # @rbs () -> void
6
8
  def show: () -> void
7
9
 
10
+ # @rbs () -> void
11
+ def batch: () -> void
12
+
8
13
  private
9
14
 
15
+ # @rbs (Hash[Symbol, untyped]) -> void
16
+ def refresh_batch: (Hash[Symbol, untyped]) -> void
17
+
18
+ # @rbs (Array[ComponentRegistration]) -> void
19
+ def validate_single_batch!: (Array[ComponentRegistration]) -> void
20
+
10
21
  # @rbs (Hash[Symbol, untyped]) -> void
11
22
  def refresh: (Hash[Symbol, untyped]) -> void
12
23
 
@@ -22,8 +22,8 @@ module SolidObjects
22
22
  # @rbs (Symbol | String) -> untyped
23
23
  def value: (Symbol | String) -> untyped
24
24
 
25
- # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol) -> untyped
26
- def component: (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol) -> untyped
25
+ # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol, ?batch: untyped) -> untyped
26
+ def component: (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol, ?batch: untyped) -> untyped
27
27
 
28
28
  # @rbs () -> Array[String]
29
29
  def component_tokens: () -> Array[String]
@@ -31,6 +31,9 @@ module SolidObjects
31
31
  # @rbs () -> Array[String]
32
32
  def scalar_observable_names: () -> Array[String]
33
33
 
34
+ # @rbs () -> bool
35
+ def batched_components?: () -> bool
36
+
34
37
  # @rbs () -> bool
35
38
  def morph_components?: () -> bool
36
39
 
@@ -79,8 +82,8 @@ module SolidObjects
79
82
  # @rbs (ComponentRegistration) -> void
80
83
  def ensure_unique_component!: (ComponentRegistration) -> void
81
84
 
82
- # @rbs (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol) -> void
83
- def validate_static_options!: (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol) -> void
85
+ # @rbs (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol, batch: untyped) -> void
86
+ def validate_static_options!: (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol, batch: untyped) -> void
84
87
 
85
88
  # @rbs () -> Proc | ComponentPathResolver
86
89
  def component_path_resolver: () -> Proc
@@ -16,6 +16,8 @@ module SolidObjects
16
16
 
17
17
  @dependencies: Array[String]
18
18
 
19
+ @batch: String?
20
+
19
21
  @component_key: String | Integer?
20
22
 
21
23
  @component_name: String
@@ -28,6 +30,8 @@ module SolidObjects
28
30
 
29
31
  attr_reader component_key: untyped
30
32
 
33
+ attr_reader batch: untyped
34
+
31
35
  attr_reader dependencies: untyped
32
36
 
33
37
  attr_reader locals: untyped
@@ -42,11 +46,11 @@ module SolidObjects
42
46
 
43
47
  attr_reader token: untyped
44
48
 
45
- # @rbs (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
46
- def initialize: (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
49
+ # @rbs (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String, ?batch: String?) -> void
50
+ def initialize: (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String, ?batch: String?) -> void
47
51
 
48
- # @rbs (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
49
- def self.issue: (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
52
+ # @rbs (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String, ?batch: String?) -> ComponentRegistration
53
+ def self.issue: (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String, ?batch: String?) -> ComponentRegistration
50
54
 
51
55
  # @rbs (String) -> ComponentRegistration
52
56
  def self.from_token: (String) -> ComponentRegistration
@@ -69,6 +73,9 @@ module SolidObjects
69
73
  # @rbs () -> Hash[String, untyped]
70
74
  def authorization_arguments: () -> Hash[String, untyped]
71
75
 
76
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> String
77
+ def batch_refresh_url: (Array[ComponentRegistration], Integer, Integer) -> String
78
+
72
79
  # @rbs (Integer, Integer) -> String
73
80
  def refresh_url: (Integer, Integer) -> String
74
81
  end
@@ -31,6 +31,9 @@ module SolidObjects
31
31
 
32
32
  attr_reader revisions: untyped
33
33
 
34
+ # @rbs (ComponentRegistration, Integer, Integer) -> void
35
+ def record_revision: (ComponentRegistration, Integer, Integer) -> void
36
+
34
37
  # @rbs (ComponentRegistration, Integer, Integer) -> String
35
38
  def refresh: (ComponentRegistration, Integer, Integer) -> String
36
39
 
@@ -12,14 +12,16 @@ module SolidObjects
12
12
 
13
13
  MAXIMUM_COMPONENT_KEY_BYTES: ::Integer
14
14
 
15
+ MAXIMUM_BATCH_BYTES: ::Integer
16
+
15
17
  REFRESH_METHODS: untyped
16
18
 
17
19
  RESERVED_LOCALS: untyped
18
20
 
19
21
  RUBY_KEYWORDS: untyped
20
22
 
21
- # @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
22
- def self?.generate: (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
23
+ # @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, ?batch: untyped) -> String
24
+ def self?.generate: (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, ?batch: untyped) -> String
23
25
 
24
26
  # @rbs (String) -> Hash[String, untyped]
25
27
  def self?.verify: (String) -> Hash[String, untyped]
@@ -36,6 +38,9 @@ module SolidObjects
36
38
  # @rbs (untyped) -> void
37
39
  def self?.validate_component_key!: (untyped) -> void
38
40
 
41
+ # @rbs (untyped) -> void
42
+ def self?.validate_batch!: (untyped) -> void
43
+
39
44
  # @rbs (Array[untyped]) -> void
40
45
  def self?.validate_dependencies!: (Array[untyped]) -> void
41
46
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Configuration
5
- @table_name_prefix: String
5
+ @process_alive_threshold: Float
6
6
 
7
7
  @shutdown_timeout: Float
8
8
 
@@ -48,6 +48,8 @@ module SolidObjects
48
48
 
49
49
  @authorize_administration: Proc
50
50
 
51
+ @table_name_prefix: String
52
+
51
53
  @polling_interval: Float
52
54
 
53
55
  @sync_polling_interval: Float
@@ -76,9 +78,9 @@ module SolidObjects
76
78
 
77
79
  @retry_delay: Proc
78
80
 
79
- @process_heartbeat_interval: Float
81
+ @lock_retry_attempts: Integer
80
82
 
81
- @process_alive_threshold: Float
83
+ @process_heartbeat_interval: Float
82
84
 
83
85
  attr_accessor table_name_prefix: untyped
84
86
 
@@ -110,6 +112,8 @@ module SolidObjects
110
112
 
111
113
  attr_accessor retry_delay: untyped
112
114
 
115
+ attr_accessor lock_retry_attempts: untyped
116
+
113
117
  attr_accessor process_heartbeat_interval: untyped
114
118
 
115
119
  attr_accessor process_alive_threshold: untyped
@@ -5,6 +5,8 @@ module SolidObjects
5
5
  class Sqlite < DatabaseAdapter
6
6
  LOCK_RETRY_INTERVAL: ::Float
7
7
 
8
+ MAXIMUM_BUSY_RETRY_INTERVAL: ::Float
9
+
8
10
  LOCK_RETRY_MUTEX: untyped
9
11
 
10
12
  LOCK_RETRY_CONDITION: untyped
@@ -15,6 +17,12 @@ module SolidObjects
15
17
  # @rbs () { () -> untyped } -> untyped
16
18
  def transaction: () { () -> untyped } -> untyped
17
19
 
20
+ # A write outside a synchronous deadline has no Ruby-level budget, so it
21
+ # depends entirely on SQLite's busy handler. Concurrent writers can
22
+ # exhaust that, which surfaces as a lock error the caller cannot retry.
23
+ # @rbs () { () -> untyped } -> untyped
24
+ def with_busy_retry: () { () -> untyped } -> untyped
25
+
18
26
  # @rbs () { () -> untyped } -> untyped
19
27
  def with_lock_retry: () { () -> untyped } -> untyped
20
28
 
@@ -38,6 +46,12 @@ module SolidObjects
38
46
  # @rbs (Exception) -> bool
39
47
  def deadline_error?: (Exception) -> bool
40
48
 
49
+ # @rbs (Exception) -> bool
50
+ def busy_error?: (Exception) -> bool
51
+
52
+ # @rbs (Integer) -> void
53
+ def wait_before_busy_retry: (Integer) -> void
54
+
41
55
  # @rbs () -> void
42
56
  def wait_before_retry: () -> void
43
57
  end
@@ -15,6 +15,9 @@ module SolidObjects
15
15
  # @rbs (ComponentRegistration, Integer, Integer) -> String
16
16
  def self?.component_refresh: (ComponentRegistration, Integer, Integer) -> String
17
17
 
18
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> String
19
+ def self?.batch_refresh: (Array[ComponentRegistration], Integer, Integer) -> String
20
+
18
21
  # @rbs (Hash[String, untyped]) -> String
19
22
  def self?.state_payload: (Hash[String, untyped]) -> String
20
23
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
@@ -262,6 +262,7 @@ files:
262
262
  - MIT-LICENSE
263
263
  - README.md
264
264
  - Rakefile
265
+ - app/assets/javascripts/solid_objects/component_batch_refresh.js
265
266
  - app/assets/javascripts/solid_objects/component_refresh.js
266
267
  - app/assets/javascripts/solid_objects/state_payload.js
267
268
  - app/controllers/solid_objects/application_controller.rb
@@ -286,6 +287,7 @@ files:
286
287
  - benchmark/adoption_latency.rb
287
288
  - benchmark/claim.rb
288
289
  - benchmark/cold_actors.rb
290
+ - benchmark/component_delivery.rb
289
291
  - benchmark/concurrent_actors.rb
290
292
  - benchmark/enqueue.rb
291
293
  - benchmark/hot_actor.rb