Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
Retention runs on its own thread, so a slow pass cannot delay replacing a
crashed role, and a failed pass retries at monitor cadence with a doubling
backoff rather than deferring for the whole interval.
- Batch component refreshes on reconnect. A reconnecting subscription refreshed
every stale component individually, ignoring the batches those components
declared, so a page with twenty batched components issued twenty requests
instead of one. That happens at the worst moment: a server restart reconnects
every client at once. Reconnect now shares the batching the live invalidation
path uses.
- Cover the reconnect burst in the browser suite: convergence of batched and
unbatched components, an inert replay of an already-applied revision,
cancellation of the request left in flight by the drop, incarnation ordering
after a destroy and recreate, and payload delivery exactly once per revision.
- Add Ruby 4.0 to the compatibility matrix, which now covers Ruby 3.3, 3.4, and
4.0 against Rails 8.0 and 8.1.

Expand Down
5 changes: 5 additions & 0 deletions docs/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,11 @@ ordered commits within one incarnation. Out-of-order invalidations at or below
the last transmitted pair are ignored. The durable state row remains source of
truth.

Stale components that share a `batch:` are refreshed together, exactly as a
live invalidation refreshes them, so reconnecting costs one request per batch
rather than one per component. That matters most on a restart, when every
client reconnects at once.

The component endpoint rejects a requested revision newer than the committed
snapshot. This is a final server-side guard; browser safety primarily comes
from monotonic channel filtering plus replace-frame detachment or morph
Expand Down
12 changes: 9 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@
- A JavaScript suite covering every browser module, run in CI with Node's test
runner and jsdom, plus a browser suite running the same modules against real
Chromium and a real Turbo build, with every GitHub Actions reference pinned to
a commit SHA
a commit SHA. The browser suite covers the reconnect burst: convergence of
batched and unbatched components, an inert replay of an applied revision,
cancellation of the request left in flight by the drop, incarnation ordering
after a destroy and recreate, and payload delivery exactly once per revision

## Partially implemented

Expand All @@ -73,7 +76,10 @@
implemented; application-directed Turbo append intents are not. Batch
coalescing happens in the browser rather than the broadcast executor, so one
commit still sends one Action Cable message per changed observable even
though it costs one browser request.
though it costs one browser request. Reconnect convergence previously
bypassed batching entirely, issuing one request per stale component at the
moment a restart reconnects every client at once; it now shares the batching
the live invalidation path uses.
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
distributed per-actor rate limits and global admission control do not.
- Administration: actor and dead-letter views plus policy hooks exist; richer
Expand All @@ -84,7 +90,7 @@
## Next milestones

1. Add result lookup by request ID and broader deadlock retry classification.
2. Add Turbo append intents and expand reconnect coverage in a full browser.
2. Add Turbo append intents.
3. Add distributed rate limits, global admission hooks, and cache-capacity
eviction.
4. Expand security scanning beyond the Brakeman scan, such as dependency
Expand Down
30 changes: 19 additions & 11 deletions lib/solid_objects/component_subscriptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,26 +52,19 @@ def refreshes_for(invalidation)
registration.dependencies.include?(observable_name) &&
newer_revision?(registration.dom_id, instance_id, revision)
end
batched, individual = changed.partition(&:batch)
streams = individual.map { |registration| refresh(registration, instance_id, revision) }
batched.group_by(&:batch).each_value do |group|
group.each { |registration| record_revision(registration, instance_id, revision) }
streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
end
streams
refresh_streams(changed, instance_id, revision)
end

# @rbs (ActorSnapshot) -> Array[String]
def reconnect_refreshes(snapshot)
registrations.filter_map do |registration|
next unless newer_revision?(
stale = registrations.select do |registration|
newer_revision?(
registration.dom_id,
snapshot.instance_id,
snapshot.revision
)

refresh(registration, snapshot.instance_id, snapshot.revision)
end
refresh_streams(stale, snapshot.instance_id, snapshot.revision)
end

class << self
Expand All @@ -91,6 +84,21 @@ def validate_identity!(registration, reference)

attr_reader :registrations, :revisions

# Live invalidations and reconnect replays share this, so a reconnecting
# client pays the same number of requests a connected one does.
# @rbs (Array[ComponentRegistration], Integer, Integer) -> Array[String]
def refresh_streams(changed, instance_id, revision)
batched, individual = changed.partition(&:batch)
streams = individual.map do |registration|
refresh(registration, instance_id, revision)
end
batched.group_by(&:batch).each_value do |group|
group.each { |registration| record_revision(registration, instance_id, revision) }
streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
end
streams
end

# @rbs (ComponentRegistration, Integer, Integer) -> void
def record_revision(registration, instance_id, revision)
revisions[registration.dom_id] = [ instance_id, revision ]
Expand Down
5 changes: 5 additions & 0 deletions sig/generated/lib/solid_objects/component_subscriptions.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ module SolidObjects

attr_reader revisions: untyped

# Live invalidations and reconnect replays share this, so a reconnecting
# client pays the same number of requests a connected one does.
# @rbs (Array[ComponentRegistration], Integer, Integer) -> Array[String]
def refresh_streams: (Array[ComponentRegistration], Integer, Integer) -> Array[String]

# @rbs (ComponentRegistration, Integer, Integer) -> void
def record_revision: (ComponentRegistration, Integer, Integer) -> void

Expand Down
17 changes: 15 additions & 2 deletions test/browser/browser_test_helper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,20 @@ export async function openPage(origin) {
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
await page.goto(`${origin}/`)
await page.waitForFunction(() => Boolean(customElements.get("solid-objects-refresh")))
await loadPage(page, origin)
return { browser, page }
}

// The browser modules keep applied revisions in module scope, so a test that
// needs to start from a lower revision than the previous one left behind has to
// reload rather than only reset the DOM.
export async function loadPage(page, origin) {
await page.goto(`${origin}/`)
await page.waitForFunction(() =>
Boolean(
customElements.get("solid-objects-refresh") &&
customElements.get("solid-objects-batch-refresh") &&
customElements.get("solid-objects-payload")
)
)
}
Loading
Loading