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 +4 -4
- data/CHANGELOG.md +23 -0
- data/app/assets/javascripts/solid_objects/component_batch_refresh.js +148 -0
- data/app/controllers/solid_objects/components_controller.rb +84 -0
- data/app/helpers/solid_objects/actor_helper.rb +10 -1
- data/benchmark/component_delivery.rb +5 -0
- data/benchmark/support.rb +131 -0
- data/config/routes.rb +1 -0
- data/docs/benchmarks.md +22 -0
- data/docs/realtime.md +74 -0
- data/lib/solid_objects/actor_view.rb +14 -7
- data/lib/solid_objects/component_registration.rb +23 -4
- data/lib/solid_objects/component_subscriptions.rb +16 -10
- data/lib/solid_objects/component_token.rb +19 -2
- data/lib/solid_objects/configuration.rb +3 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +37 -2
- data/lib/solid_objects/turbo_stream_renderer.rb +12 -0
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/controllers/solid_objects/components_controller.rbs +11 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +7 -4
- data/sig/generated/lib/solid_objects/component_registration.rbs +11 -4
- data/sig/generated/lib/solid_objects/component_subscriptions.rbs +3 -0
- data/sig/generated/lib/solid_objects/component_token.rbs +7 -2
- data/sig/generated/lib/solid_objects/configuration.rbs +7 -3
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +14 -0
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +3 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 75595f12197f05985076afb8974ec528b85914db25da49c29b342b9fc531ad97
|
|
4
|
+
data.tar.gz: d240311fe0bfbbd12386d582c4a8dcb560a2698385d65c1f0781340880cbd11c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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
|
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
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.
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
18
|
+
return with_lock_retry { super } if SyncDeadline.active?
|
|
18
19
|
|
|
19
|
-
|
|
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(
|
|
@@ -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
|
-
@
|
|
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
|
-
@
|
|
81
|
+
@lock_retry_attempts: Integer
|
|
80
82
|
|
|
81
|
-
@
|
|
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.
|
|
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
|