yrb-lite 0.1.0.beta4-aarch64-mingw-ucrt → 0.1.0.beta5-aarch64-mingw-ucrt
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 +20 -1
- data/lib/yrb_lite/3.4/yrb_lite.so +0 -0
- data/lib/yrb_lite/4.0/yrb_lite.so +0 -0
- data/lib/yrb_lite/version.rb +1 -1
- data/lib/yrb_lite.rb +4 -4
- metadata +8 -23
- data/lib/yrb_lite/sync.rb +0 -580
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 37c13f7aae4aa27d452e416d1b031eb2e45b8061a0714dfac0f7902deb0fb665
|
|
4
|
+
data.tar.gz: 4aa669fe72867180c4e7a07e71e10a0081ee17125731428859032372c030c550
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e19d4975e0e494030f25cbb7c8d53535f4e6f73a67838f0da1d28d29cd332e7301c5f72b181793a4e6dbbfd1e2a95e1c81ac1505bb3e457189b4e81557d70e8a
|
|
7
|
+
data.tar.gz: '097a38f3b1587f7cde986035e8dc34397fd7c1fe876698c9eae004af3565e9830dc46cb37793dcf9865443a1089f3ef9f4d93cc349e530221b7719f0a691d6c6'
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.0.beta5] - 2026-06-18
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Breaking:** the ActionCable integration has been extracted into a separate
|
|
14
|
+
gem, [`yrb-lite-actioncable`](https://rubygems.org/gems/yrb-lite-actioncable).
|
|
15
|
+
`yrb-lite` is now a standalone y-crdt wrapper: CRDT documents, awareness, and
|
|
16
|
+
the y-websocket sync protocol primitives, with no Rails/ActionCable coupling
|
|
17
|
+
(mirrors the `y-rb` / `yrb-actioncable` split). The `base64` runtime
|
|
18
|
+
dependency moved with it.
|
|
19
|
+
|
|
20
|
+
### Migration
|
|
21
|
+
|
|
22
|
+
- Using `YrbLite::Sync`? Add `gem "yrb-lite-actioncable"` and change
|
|
23
|
+
`include YrbLite::Sync` to `include YrbLite::ActionCable::Sync`. The concern's
|
|
24
|
+
API is otherwise unchanged. If you only use `YrbLite::Doc`/`YrbLite::Awareness`,
|
|
25
|
+
nothing changes.
|
|
26
|
+
|
|
9
27
|
## [0.1.0.beta4] - 2026-06-18
|
|
10
28
|
|
|
11
29
|
### Changed
|
|
@@ -91,7 +109,8 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
91
109
|
- Precompiled native gems for common platforms (no Rust toolchain needed to
|
|
92
110
|
install) via the cross-gem workflow.
|
|
93
111
|
|
|
94
|
-
[Unreleased]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.
|
|
112
|
+
[Unreleased]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta5...main
|
|
113
|
+
[0.1.0.beta5]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta4...v0.1.0.beta5
|
|
95
114
|
[0.1.0.beta4]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta3...v0.1.0.beta4
|
|
96
115
|
[0.1.0.beta3]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta2...v0.1.0.beta3
|
|
97
116
|
[0.1.0.beta2]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta1...v0.1.0.beta2
|
|
Binary file
|
|
Binary file
|
data/lib/yrb_lite/version.rb
CHANGED
data/lib/yrb_lite.rb
CHANGED
|
@@ -13,8 +13,8 @@ rescue LoadError
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
module YrbLite
|
|
16
|
-
# Error class is defined in Rust extension
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
|
|
16
|
+
# Error class is defined in the Rust extension.
|
|
17
|
+
#
|
|
18
|
+
# The ActionCable integration (YrbLite::ActionCable::Sync) lives in the
|
|
19
|
+
# separate `yrb-lite-actioncable` gem; require "yrb_lite/action_cable".
|
|
20
20
|
end
|
metadata
CHANGED
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yrb-lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.
|
|
4
|
+
version: 0.1.0.beta5
|
|
5
5
|
platform: aarch64-mingw-ucrt
|
|
6
6
|
authors:
|
|
7
7
|
- JP Camara
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
|
-
- !ruby/object:Gem::Dependency
|
|
14
|
-
name: base64
|
|
15
|
-
requirement: !ruby/object:Gem::Requirement
|
|
16
|
-
requirements:
|
|
17
|
-
- - "~>"
|
|
18
|
-
- !ruby/object:Gem::Version
|
|
19
|
-
version: '0.2'
|
|
20
|
-
type: :runtime
|
|
21
|
-
prerelease: false
|
|
22
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
-
requirements:
|
|
24
|
-
- - "~>"
|
|
25
|
-
- !ruby/object:Gem::Version
|
|
26
|
-
version: '0.2'
|
|
27
13
|
- !ruby/object:Gem::Dependency
|
|
28
14
|
name: minitest
|
|
29
15
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -66,10 +52,10 @@ dependencies:
|
|
|
66
52
|
- - "~>"
|
|
67
53
|
- !ruby/object:Gem::Version
|
|
68
54
|
version: '1.2'
|
|
69
|
-
description: yrb-lite is a thread-safe Ruby binding over the Rust y-crdt (yrs) library
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
55
|
+
description: 'yrb-lite is a thread-safe Ruby binding over the Rust y-crdt (yrs) library:
|
|
56
|
+
CRDT documents, awareness/presence, and the y-websocket sync protocol primitives,
|
|
57
|
+
with the GVL released during native work so documents sync in parallel. The ActionCable/Rails
|
|
58
|
+
integration lives in the companion yrb-lite-actioncable gem.'
|
|
73
59
|
email:
|
|
74
60
|
- johnpcamara@gmail.com
|
|
75
61
|
executables: []
|
|
@@ -83,7 +69,6 @@ files:
|
|
|
83
69
|
- lib/yrb_lite.rb
|
|
84
70
|
- lib/yrb_lite/3.4/yrb_lite.so
|
|
85
71
|
- lib/yrb_lite/4.0/yrb_lite.so
|
|
86
|
-
- lib/yrb_lite/sync.rb
|
|
87
72
|
- lib/yrb_lite/version.rb
|
|
88
73
|
homepage: https://github.com/jpcamara/yrb-lite
|
|
89
74
|
licenses:
|
|
@@ -114,6 +99,6 @@ requirements: []
|
|
|
114
99
|
rubygems_version: 3.5.23
|
|
115
100
|
signing_key:
|
|
116
101
|
specification_version: 4
|
|
117
|
-
summary: Thread-safe Ruby bindings for y-crdt (Y.js)
|
|
118
|
-
|
|
102
|
+
summary: 'Thread-safe Ruby bindings for y-crdt (Y.js): documents, awareness, and the
|
|
103
|
+
y-websocket sync protocol'
|
|
119
104
|
test_files: []
|
data/lib/yrb_lite/sync.rb
DELETED
|
@@ -1,580 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "base64"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
|
|
6
|
-
module YrbLite
|
|
7
|
-
# y-websocket protocol over ActionCable.
|
|
8
|
-
#
|
|
9
|
-
# Include this module in an ActionCable channel to sync Y.js documents
|
|
10
|
-
# (and awareness/presence) with browser clients. Messages are the standard
|
|
11
|
-
# y-protocols binary messages, base64-encoded in a JSON envelope:
|
|
12
|
-
#
|
|
13
|
-
# { "m" => "<base64 bytes>" } # client -> server
|
|
14
|
-
# { "m" => "...", "origin" => "<id>" } # server -> subscribers
|
|
15
|
-
#
|
|
16
|
-
# Example:
|
|
17
|
-
# class DocumentChannel < ApplicationCable::Channel
|
|
18
|
-
# include YrbLite::Sync
|
|
19
|
-
#
|
|
20
|
-
# on_load { |key| Document.find_by(key: key)&.content }
|
|
21
|
-
# on_save { |key, update| Document.find_by(key: key)&.update!(content: update) }
|
|
22
|
-
#
|
|
23
|
-
# # on_change blocks run in the channel instance's context, so instance
|
|
24
|
-
# # methods (current_user, params, ...) are available without plumbing:
|
|
25
|
-
# on_change { |key, update| Document.record!(key, update, by: current_user) }
|
|
26
|
-
#
|
|
27
|
-
# def subscribed
|
|
28
|
-
# sync_for params[:id]
|
|
29
|
-
# end
|
|
30
|
-
#
|
|
31
|
-
# def receive(data)
|
|
32
|
-
# sync_receive(data)
|
|
33
|
-
# end
|
|
34
|
-
#
|
|
35
|
-
# def unsubscribed
|
|
36
|
-
# sync_clear_presence
|
|
37
|
-
# end
|
|
38
|
-
# end
|
|
39
|
-
#
|
|
40
|
-
# The shared YrbLite::Awareness instances are safe to use from ActionCable's
|
|
41
|
-
# worker thread pool: the native types are Send + Sync and every operation
|
|
42
|
-
# releases the GVL, so concurrent clients sync in parallel.
|
|
43
|
-
module Sync
|
|
44
|
-
# Validated frame kinds from Awareness#message_kind. A frame only gets a
|
|
45
|
-
# non-DROP kind if it is exactly one well-formed message; anything
|
|
46
|
-
# malformed, truncated, multi-message, or unknown is dropped before it can
|
|
47
|
-
# be processed or relayed.
|
|
48
|
-
MSG_KIND_DROP = 0
|
|
49
|
-
MSG_KIND_SYNC_STEP1 = 1
|
|
50
|
-
MSG_KIND_UPDATE = 2
|
|
51
|
-
MSG_KIND_AWARENESS = 3
|
|
52
|
-
MSG_KIND_AWARENESS_QUERY = 4
|
|
53
|
-
|
|
54
|
-
def self.included(base)
|
|
55
|
-
base.extend(ClassMethods)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
module ClassMethods
|
|
59
|
-
# Load persisted document state. Called once per key with (key);
|
|
60
|
-
# return a binary Y.js update (or nil for a fresh document).
|
|
61
|
-
def on_load(callable = nil, &block)
|
|
62
|
-
@on_load = callable || block if callable || block
|
|
63
|
-
@on_load
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Persist document state. Called with (key, update) after every
|
|
67
|
-
# message that modified the document.
|
|
68
|
-
def on_save(callable = nil, &block)
|
|
69
|
-
@on_save = callable || block if callable || block
|
|
70
|
-
@on_save
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Record every document change durably before it is applied or
|
|
74
|
-
# distributed (authoritative audit mode). Called synchronously with
|
|
75
|
-
# (key, update), where update is the exact CRDT delta, serialized per
|
|
76
|
-
# document so the recorded order is the apply order. If the block raises,
|
|
77
|
-
# the change is rejected: neither applied to the shared document nor
|
|
78
|
-
# broadcast to other subscribers.
|
|
79
|
-
#
|
|
80
|
-
# A block recorder runs in the *channel instance's* context, so it can
|
|
81
|
-
# call the channel's own methods (current_user, params, a per-connection
|
|
82
|
-
# Current.* accessor) directly, with no thread-local plumbing. (A non-Proc
|
|
83
|
-
# callable is invoked with #call instead, since it carries its own
|
|
84
|
-
# context.) on_change always fires from within sync_receive, unlike
|
|
85
|
-
# on_load/on_save, which can run context-free in the shared registry.
|
|
86
|
-
#
|
|
87
|
-
# Registering an on_change switches that channel onto the strict path
|
|
88
|
-
# (record, apply, broadcast). Without it, the default fast path applies
|
|
89
|
-
# and broadcasts, with an optional on_save snapshot.
|
|
90
|
-
def on_change(callable = nil, &block)
|
|
91
|
-
@on_change = callable || block if callable || block
|
|
92
|
-
@on_change
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Select the document backend:
|
|
96
|
-
# :memory (default): keep a warm in-memory replica per process and keep
|
|
97
|
-
# it current via a custom stream_from callback. Fast, but it assumes
|
|
98
|
-
# classic ActionCable (the callback runs in Ruby) and
|
|
99
|
-
# process<->document affinity.
|
|
100
|
-
# :store: stateless per message, with no warm replica and no custom
|
|
101
|
-
# stream callback. Handshakes and reads are served from the durable
|
|
102
|
-
# store (`on_load`); changes are recorded (`on_change`) and relayed.
|
|
103
|
-
# Works under AnyCable (broadcasts handled outside Ruby, no worker
|
|
104
|
-
# affinity) and across processes. Requires `on_load` and `on_change`.
|
|
105
|
-
def sync_backend(mode = nil)
|
|
106
|
-
@sync_backend = mode if mode
|
|
107
|
-
@sync_backend || :memory
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Call from `subscribed`. Streams broadcasts for this document and
|
|
112
|
-
# transmits the server's opening handshake (SyncStep1 + awareness).
|
|
113
|
-
def sync_for(key)
|
|
114
|
-
@sync_key = key.to_s
|
|
115
|
-
@sync_origin = SecureRandom.hex(8)
|
|
116
|
-
@sync_clients = [] # awareness client IDs seen on this connection
|
|
117
|
-
|
|
118
|
-
return sync_for_store_backed if self.class.sync_backend == :store
|
|
119
|
-
|
|
120
|
-
Sync.subscribe(@sync_key)
|
|
121
|
-
awareness = sync_awareness
|
|
122
|
-
|
|
123
|
-
stream_from sync_stream_name, coder: ActiveSupport::JSON do |payload|
|
|
124
|
-
sync_on_broadcast(payload)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Opening handshake: SyncStep1 then the current awareness, each as its
|
|
128
|
-
# own single-message frame, so providers that parse one message per frame
|
|
129
|
-
# (e.g. @y-rb/actioncable) handle both. The client replies SyncStep2 to
|
|
130
|
-
# the SyncStep1, delivering its state to the server.
|
|
131
|
-
sync_transmit(awareness.sync_step1)
|
|
132
|
-
sync_transmit(awareness.encode_awareness_update)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Call from `receive`. Applies the client's message, replies directly
|
|
136
|
-
# when the protocol calls for it, and relays document/awareness changes
|
|
137
|
-
# to the other subscribers.
|
|
138
|
-
#
|
|
139
|
-
# If an `on_change` recorder is registered, document changes take the
|
|
140
|
-
# strict authoritative path (record -> apply -> broadcast, serialized per
|
|
141
|
-
# document); otherwise the fast path is used.
|
|
142
|
-
#
|
|
143
|
-
# Reliable delivery (opt-in, client-driven): if the frame carries an "id",
|
|
144
|
-
# the server replies `{ "ack" => id }` once the update has been accepted
|
|
145
|
-
# (recorded in audit mode, applied in fast mode). A causally-gapped update
|
|
146
|
-
# is not acked -- it gets a resync instead -- so an ack-aware client knows
|
|
147
|
-
# to retransmit until the update lands. Stock clients send no "id", never
|
|
148
|
-
# get acks, and are completely unaffected.
|
|
149
|
-
def sync_receive(data, key = nil)
|
|
150
|
-
# Pass `key` (params[:id]) when your transport doesn't keep the channel
|
|
151
|
-
# instance alive across actions. Under AnyCable each RPC command gets a
|
|
152
|
-
# fresh channel, so instance variables set in `subscribed` are gone here.
|
|
153
|
-
@sync_key = key.to_s if key
|
|
154
|
-
|
|
155
|
-
# Accept both envelope keys: "m" (yrb-lite's own clients) and "update"
|
|
156
|
-
# (the @y-rb/actioncable browser provider).
|
|
157
|
-
m = data.is_a?(Hash) ? (data["m"] || data["update"]) : nil
|
|
158
|
-
return unless m.is_a?(String)
|
|
159
|
-
|
|
160
|
-
# Optional client-supplied id for reliable delivery (see sync_send_ack).
|
|
161
|
-
id = data.is_a?(Hash) ? data["id"] : nil
|
|
162
|
-
|
|
163
|
-
begin
|
|
164
|
-
bytes = Base64.strict_decode64(m)
|
|
165
|
-
rescue ArgumentError
|
|
166
|
-
return # not valid base64; ignore the frame and keep the connection
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
sync_send_ack(id, sync_dispatch(m, bytes))
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# Route a decoded frame to the backend/path that handles it and return the
|
|
173
|
-
# outcome symbol (:recorded/:applied/:gap/:noop) used by the reliable-
|
|
174
|
-
# delivery ack. A dropped frame returns nil (never acked).
|
|
175
|
-
def sync_dispatch(encoded, bytes)
|
|
176
|
-
return sync_receive_store_backed(encoded, bytes) if self.class.sync_backend == :store
|
|
177
|
-
|
|
178
|
-
awareness = sync_awareness
|
|
179
|
-
kind = awareness.message_kind(bytes)
|
|
180
|
-
# Malformed / truncated / multi-message / unknown frames are dropped
|
|
181
|
-
# before they can be processed or relayed to other clients.
|
|
182
|
-
return if kind == MSG_KIND_DROP
|
|
183
|
-
|
|
184
|
-
sync_track_clients(awareness, bytes) if kind == MSG_KIND_AWARENESS
|
|
185
|
-
|
|
186
|
-
if kind == MSG_KIND_UPDATE && self.class.on_change
|
|
187
|
-
sync_apply_authoritative(awareness, encoded, bytes)
|
|
188
|
-
else
|
|
189
|
-
sync_apply_fast(awareness, encoded, bytes, kind)
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
# Call from `unsubscribed`. Clears the presence states this connection
|
|
194
|
-
# introduced and tells the other subscribers to drop those cursors, so a
|
|
195
|
-
# closed tab or dropped socket doesn't leave a ghost cursor behind until
|
|
196
|
-
# the client-side timeout reaps it.
|
|
197
|
-
def sync_clear_presence
|
|
198
|
-
return if @sync_clients.nil? || @sync_clients.empty?
|
|
199
|
-
|
|
200
|
-
removal = sync_awareness.remove_clients(@sync_clients)
|
|
201
|
-
@sync_clients = []
|
|
202
|
-
return if removal.empty?
|
|
203
|
-
|
|
204
|
-
sync_distribute(Base64.strict_encode64(removal))
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# Call from `unsubscribed`. Clears this connection's presence and, when the
|
|
208
|
-
# last subscriber for the document leaves, persists and unloads it from
|
|
209
|
-
# memory (only when an `on_load` is configured to bring it back; otherwise
|
|
210
|
-
# the in-memory document is the only copy and is kept). Prevents a
|
|
211
|
-
# long-running server from accumulating every document it has ever served.
|
|
212
|
-
def sync_unsubscribed(key = nil)
|
|
213
|
-
@sync_key = key.to_s if key
|
|
214
|
-
return if self.class.sync_backend == :store # nothing cached per process
|
|
215
|
-
|
|
216
|
-
sync_clear_presence
|
|
217
|
-
saver = self.class.on_save
|
|
218
|
-
Sync.release(@sync_key, evictable: !self.class.on_load.nil?) do |awareness|
|
|
219
|
-
saver&.call(@sync_key, awareness.encode_state_as_update)
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# The shared Awareness (document + presence) for this channel's key.
|
|
224
|
-
# Also useful for server-side reads, e.g.:
|
|
225
|
-
# sync_awareness.encode_state_as_update
|
|
226
|
-
def sync_awareness
|
|
227
|
-
Sync.awareness_for(@sync_key, self.class.on_load)
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
private
|
|
231
|
-
|
|
232
|
-
# Default path: apply the message, answer direct requests, relay
|
|
233
|
-
# state-changing messages to the other subscribers. Routing comes from the
|
|
234
|
-
# native `kind` (from Awareness#message_kind) rather than peeking at bytes.
|
|
235
|
-
# Document changes (SyncStep2, Update) and awareness get relayed; requests
|
|
236
|
-
# (SyncStep1, awareness-query) are answered above and not relayed. An
|
|
237
|
-
# optional on_save snapshot is taken after a document change.
|
|
238
|
-
#
|
|
239
|
-
# Returns an outcome symbol for the reliable-delivery ack: :applied when a
|
|
240
|
-
# document update was integrated and relayed, :gap when it was rejected for
|
|
241
|
-
# a resync, :noop for everything else (requests, awareness, empty updates).
|
|
242
|
-
def sync_apply_fast(awareness, encoded, bytes, kind)
|
|
243
|
-
# A document update that isn't causally ready (an earlier one was lost in
|
|
244
|
-
# transit) would relay an un-integrable change to peers and stall the
|
|
245
|
-
# replica. Drop it and ask the client to resync instead, which re-delivers
|
|
246
|
-
# the missing piece. See sync_apply_authoritative for the durable variant.
|
|
247
|
-
if kind == MSG_KIND_UPDATE
|
|
248
|
-
update = awareness.update_from_message(bytes)
|
|
249
|
-
# A no-op message (e.g. the empty SyncStep2 in an opening handshake)
|
|
250
|
-
# carries no change, so there's nothing to relay, persist, or ack.
|
|
251
|
-
return :noop unless update
|
|
252
|
-
|
|
253
|
-
unless awareness.update_ready?(update)
|
|
254
|
-
sync_request_resync(awareness)
|
|
255
|
-
return :gap
|
|
256
|
-
end
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
response = awareness.handle(bytes)
|
|
260
|
-
sync_transmit(response) unless response.empty?
|
|
261
|
-
|
|
262
|
-
return :noop unless [MSG_KIND_UPDATE, MSG_KIND_AWARENESS].include?(kind)
|
|
263
|
-
|
|
264
|
-
sync_distribute(encoded)
|
|
265
|
-
return :noop unless kind == MSG_KIND_UPDATE
|
|
266
|
-
|
|
267
|
-
sync_persist
|
|
268
|
-
:applied
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
# Authoritative path: record the change durably, then apply it to the
|
|
272
|
-
# shared document, then distribute it. The sequence runs under a
|
|
273
|
-
# per-document lock so changes are recorded in a single total order that
|
|
274
|
-
# matches the order they're applied, and nothing is distributed (or applied)
|
|
275
|
-
# before it has been recorded. If the recorder raises, the change is
|
|
276
|
-
# rejected (not applied, not broadcast) and the exception propagates, so the
|
|
277
|
-
# channel can surface it and the client can resync.
|
|
278
|
-
#
|
|
279
|
-
# Before recording, the update must be causally ready: every dependency it
|
|
280
|
-
# references must already be in the doc. If an earlier update was lost in
|
|
281
|
-
# transit, or its record failed, a later update arrives with a gap. Recording
|
|
282
|
-
# it would write a permanently-pending entry to the log -- one that can never
|
|
283
|
-
# be replayed until the missing update shows up. Such an update is rejected
|
|
284
|
-
# (not recorded, not applied, not relayed) and the client is asked to resync,
|
|
285
|
-
# which re-delivers the missing range as one causally-complete delta.
|
|
286
|
-
def sync_apply_authoritative(awareness, encoded, bytes)
|
|
287
|
-
recorder = self.class.on_change
|
|
288
|
-
|
|
289
|
-
outcome = Sync.lock_for(@sync_key).synchronize do
|
|
290
|
-
update = awareness.update_from_message(bytes)
|
|
291
|
-
# A no-op message (e.g. the empty SyncStep2 in a client's opening
|
|
292
|
-
# handshake) carries no change, so there's nothing to record or relay.
|
|
293
|
-
next :noop unless update
|
|
294
|
-
next :gap unless awareness.update_ready?(update)
|
|
295
|
-
|
|
296
|
-
sync_record_change(recorder, update) # durable write; raise to reject
|
|
297
|
-
awareness.apply_update(update) # only recorded changes reach the doc
|
|
298
|
-
sync_distribute(encoded) # ...and only then the wire
|
|
299
|
-
:recorded
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
case outcome
|
|
303
|
-
when :recorded then sync_persist
|
|
304
|
-
when :gap then sync_request_resync(awareness)
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
# Surface the outcome for the reliable-delivery ack: :recorded means the
|
|
308
|
-
# update is durably written (and will be acked); :gap triggered a resync
|
|
309
|
-
# (no ack); :noop carried no change.
|
|
310
|
-
outcome
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
# Ask this connection's client to resync: re-send SyncStep1 carrying the
|
|
314
|
-
# server's current (gap-free) state vector. The client replies SyncStep2
|
|
315
|
-
# with everything the server is missing, delivered as one causally-complete
|
|
316
|
-
# delta -- which heals the gap that triggered the resync.
|
|
317
|
-
def sync_request_resync(awareness)
|
|
318
|
-
sync_transmit(awareness.sync_step1)
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
# Reliable delivery: acknowledge an accepted update back to the sending
|
|
322
|
-
# connection. An ack-aware client tags each outgoing update with an "id"
|
|
323
|
-
# and retains it until the matching `{ "ack" => id }` returns, retransmitting
|
|
324
|
-
# on a timer or reconnect; idempotent CRDT apply makes resends free. We ack
|
|
325
|
-
# only when the client supplied an id (so stock clients are unaffected) and
|
|
326
|
-
# the update was actually accepted -- recorded in audit mode, applied in fast
|
|
327
|
-
# mode. A gapped update gets no ack (it got a resync), so the client keeps
|
|
328
|
-
# retransmitting until the missing range lands and the update can integrate.
|
|
329
|
-
def sync_send_ack(id, outcome)
|
|
330
|
-
return if id.nil?
|
|
331
|
-
return unless %i[recorded applied].include?(outcome)
|
|
332
|
-
|
|
333
|
-
# Braces are load-bearing: a bare hash would bind to transmit's `via:`
|
|
334
|
-
# keyword instead of its positional data argument.
|
|
335
|
-
transmit({ "ack" => id })
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
# Single broadcast point for both paths (and presence removal), so the
|
|
339
|
-
# relay semantics live in one place and tests can observe distribution.
|
|
340
|
-
# `origin` identifies the sending connection (don't echo to it); `pid`
|
|
341
|
-
# identifies the sending process (other processes apply it to their own
|
|
342
|
-
# replica; see sync_on_broadcast).
|
|
343
|
-
def sync_distribute(encoded)
|
|
344
|
-
ActionCable.server.broadcast(
|
|
345
|
-
sync_stream_name,
|
|
346
|
-
sync_envelope(encoded, "origin" => @sync_origin, "pid" => Sync.process_id)
|
|
347
|
-
)
|
|
348
|
-
end
|
|
349
|
-
|
|
350
|
-
# Transmit raw protocol bytes to this connection (base64, dual-key).
|
|
351
|
-
def sync_transmit(bytes)
|
|
352
|
-
transmit(sync_envelope(Base64.strict_encode64(bytes)))
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
# Build an outgoing envelope. We send the payload under both keys: "m"
|
|
356
|
-
# (yrb-lite's own clients) and "update" (the @y-rb/actioncable provider),
|
|
357
|
-
# so either client works against the same server.
|
|
358
|
-
def sync_envelope(encoded, extra = {})
|
|
359
|
-
{ "m" => encoded, "update" => encoded }.merge(extra)
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
# Handle a broadcast delivered by the cable adapter. With a multi-process
|
|
363
|
-
# adapter (Redis, solid_cable), it may have come from another server
|
|
364
|
-
# process. Keep this process's in-memory replica current with changes that
|
|
365
|
-
# originated elsewhere, then relay to this connection's browser.
|
|
366
|
-
def sync_on_broadcast(payload)
|
|
367
|
-
sync_apply_remote(payload["m"]) if payload["pid"] != Sync.process_id
|
|
368
|
-
transmit(payload) unless payload["origin"] == @sync_origin
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
# Apply a change that originated on another process to this process's
|
|
372
|
-
# replica, without re-recording it (the origin process already recorded it
|
|
373
|
-
# before broadcasting). The CRDT merge is idempotent and commutative, so a
|
|
374
|
-
# cold replica converges regardless of ordering, and applying from several
|
|
375
|
-
# local connections is harmless.
|
|
376
|
-
def sync_apply_remote(encoded)
|
|
377
|
-
return unless encoded.is_a?(String)
|
|
378
|
-
|
|
379
|
-
begin
|
|
380
|
-
bytes = Base64.strict_decode64(encoded)
|
|
381
|
-
rescue ArgumentError
|
|
382
|
-
return
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
awareness = sync_awareness
|
|
386
|
-
case awareness.message_kind(bytes)
|
|
387
|
-
when MSG_KIND_UPDATE
|
|
388
|
-
update = awareness.update_from_message(bytes)
|
|
389
|
-
awareness.apply_update(update) if update
|
|
390
|
-
when MSG_KIND_AWARENESS
|
|
391
|
-
awareness.handle(bytes)
|
|
392
|
-
end
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
# -- Store-backed (AnyCable-native) path --------------------------------
|
|
396
|
-
|
|
397
|
-
# Subscribe without a custom block, so AnyCable (which delivers broadcasts
|
|
398
|
-
# outside Ruby) relays them directly. Send the opening SyncStep1 built from
|
|
399
|
-
# the durable store. No warm replica is kept.
|
|
400
|
-
def sync_for_store_backed
|
|
401
|
-
stream_from sync_stream_name
|
|
402
|
-
sync_transmit(sync_load_doc.sync_step1)
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
# Stateless per message: no warm replica, no assumptions about which process
|
|
406
|
-
# owns a document. A client's SyncStep1 is answered from the store, document
|
|
407
|
-
# changes are recorded durably before relay and then broadcast, and
|
|
408
|
-
# awareness is relayed best-effort. Echoing back to the sender is harmless,
|
|
409
|
-
# since the CRDT apply is idempotent.
|
|
410
|
-
#
|
|
411
|
-
# Returns an outcome symbol for the reliable-delivery ack: :recorded when a
|
|
412
|
-
# document update was durably recorded and relayed, :gap when it was
|
|
413
|
-
# rejected for a resync, :noop for everything else.
|
|
414
|
-
def sync_receive_store_backed(encoded, bytes)
|
|
415
|
-
case Sync.codec.message_kind(bytes)
|
|
416
|
-
when MSG_KIND_SYNC_STEP1
|
|
417
|
-
result = sync_load_doc.handle_sync_message(bytes)
|
|
418
|
-
sync_transmit(result[2]) if result
|
|
419
|
-
:noop
|
|
420
|
-
when MSG_KIND_UPDATE
|
|
421
|
-
update = Sync.codec.update_from_message(bytes)
|
|
422
|
-
return :noop unless update
|
|
423
|
-
|
|
424
|
-
# Store mode keeps no warm replica, so to tell whether this update is
|
|
425
|
-
# causally ready we rebuild the doc from the store and check against it.
|
|
426
|
-
# That's an O(history) load per update (mitigated by snapshotting the
|
|
427
|
-
# store on the load path). A gappy update -- an earlier one was lost or
|
|
428
|
-
# its record failed -- is rejected and the client asked to resync,
|
|
429
|
-
# rather than written to the log as a permanently-pending entry.
|
|
430
|
-
doc = sync_load_doc
|
|
431
|
-
unless doc.update_ready?(update)
|
|
432
|
-
sync_transmit(doc.sync_step1)
|
|
433
|
-
return :gap
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
if (recorder = self.class.on_change)
|
|
437
|
-
sync_record_change(recorder, update) # record before relay
|
|
438
|
-
end
|
|
439
|
-
sync_distribute(encoded)
|
|
440
|
-
:recorded
|
|
441
|
-
when MSG_KIND_AWARENESS
|
|
442
|
-
sync_distribute(encoded)
|
|
443
|
-
:noop
|
|
444
|
-
else
|
|
445
|
-
:noop
|
|
446
|
-
end
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
# Build a fresh document from the durable store (on_load).
|
|
450
|
-
def sync_load_doc
|
|
451
|
-
doc = YrbLite::Doc.new
|
|
452
|
-
state = self.class.on_load&.call(@sync_key)
|
|
453
|
-
doc.apply_update(state) if state
|
|
454
|
-
doc
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
# Record the awareness client IDs carried by an incoming message (already
|
|
458
|
-
# known to be an awareness frame) so we can clear them when this connection
|
|
459
|
-
# closes.
|
|
460
|
-
def sync_track_clients(awareness, bytes)
|
|
461
|
-
awareness.awareness_client_ids(bytes).each do |id|
|
|
462
|
-
@sync_clients << id unless @sync_clients.include?(id)
|
|
463
|
-
end
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
def sync_stream_name
|
|
467
|
-
"yrb_lite:#{@sync_key}"
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
def sync_persist
|
|
471
|
-
return unless (saver = self.class.on_save)
|
|
472
|
-
|
|
473
|
-
saver.call(@sync_key, sync_awareness.encode_state_as_update)
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
# Invoke the on_change recorder. A block/proc runs in this channel instance's
|
|
477
|
-
# context (instance_exec) so it can reach the channel's own methods; a
|
|
478
|
-
# non-Proc callable is invoked with #call, since it carries its own context.
|
|
479
|
-
def sync_record_change(recorder, update)
|
|
480
|
-
args = [@sync_key, update]
|
|
481
|
-
recorder.is_a?(Proc) ? instance_exec(*args, &recorder) : recorder.call(*args)
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
# -- Shared document registry ------------------------------------------
|
|
485
|
-
|
|
486
|
-
@registry = {}
|
|
487
|
-
@locks = {}
|
|
488
|
-
@subscribers = Hash.new(0)
|
|
489
|
-
@registry_mutex = Mutex.new
|
|
490
|
-
|
|
491
|
-
class << self
|
|
492
|
-
# A stable id for this server process, stamped on every broadcast so
|
|
493
|
-
# other processes know to apply it to their replica and this process
|
|
494
|
-
# knows to skip its own. Survives for the life of the process.
|
|
495
|
-
def process_id
|
|
496
|
-
@process_id ||= SecureRandom.hex(8)
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
# A shared, stateless decoder for the store-backed path. message_kind and
|
|
500
|
-
# update_from_message only read their argument (they don't touch the
|
|
501
|
-
# instance's document), so one shared instance is safe across threads.
|
|
502
|
-
def codec
|
|
503
|
-
@codec ||= YrbLite::Awareness.new
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
# Get or create the shared Awareness for a key. Creation (including
|
|
507
|
-
# the on_load callback) is serialized under a mutex so concurrent
|
|
508
|
-
# subscribers can never observe two documents for one key; all
|
|
509
|
-
# subsequent operations run lock-free on the thread-safe native types.
|
|
510
|
-
def awareness_for(key, loader = nil)
|
|
511
|
-
@registry_mutex.synchronize do
|
|
512
|
-
@registry[key] ||= begin
|
|
513
|
-
awareness = YrbLite::Awareness.new
|
|
514
|
-
if loader && (state = loader.call(key))
|
|
515
|
-
awareness.apply_update(state)
|
|
516
|
-
end
|
|
517
|
-
awareness
|
|
518
|
-
end
|
|
519
|
-
end
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
# Per-document mutex serializing the authoritative record -> apply ->
|
|
523
|
-
# broadcast section, so a document's audit log is a single total order.
|
|
524
|
-
# Only briefly holds the registry mutex to fetch/create the lock; the
|
|
525
|
-
# durable write itself runs while holding only this per-key lock.
|
|
526
|
-
def lock_for(key)
|
|
527
|
-
@registry_mutex.synchronize { @locks[key] ||= Mutex.new }
|
|
528
|
-
end
|
|
529
|
-
|
|
530
|
-
# Count a new subscriber for a document.
|
|
531
|
-
def subscribe(key)
|
|
532
|
-
@registry_mutex.synchronize { @subscribers[key] += 1 }
|
|
533
|
-
end
|
|
534
|
-
|
|
535
|
-
# Drop a subscriber. When the last one leaves and the document is
|
|
536
|
-
# evictable (there's an on_load to bring it back, so unloading can't lose
|
|
537
|
-
# data), persist it via the given block and unload it from memory, so a
|
|
538
|
-
# long-running server doesn't accumulate every document and lock it has
|
|
539
|
-
# ever seen. Returns true if the document was evicted.
|
|
540
|
-
#
|
|
541
|
-
# The persist runs outside the registry lock (it may do I/O), and we
|
|
542
|
-
# re-check the subscriber count afterward: if someone reconnected while
|
|
543
|
-
# we were saving, eviction is aborted and the warm document is kept.
|
|
544
|
-
def release(key, evictable:)
|
|
545
|
-
awareness = @registry_mutex.synchronize do
|
|
546
|
-
@subscribers[key] -= 1 if @subscribers[key].positive?
|
|
547
|
-
next nil unless @subscribers[key].zero?
|
|
548
|
-
|
|
549
|
-
@subscribers.delete(key)
|
|
550
|
-
evictable ? @registry[key] : nil
|
|
551
|
-
end
|
|
552
|
-
return false unless awareness
|
|
553
|
-
|
|
554
|
-
yield awareness if block_given?
|
|
555
|
-
|
|
556
|
-
@registry_mutex.synchronize do
|
|
557
|
-
# A subscriber may have returned during the persist above.
|
|
558
|
-
next false unless @subscribers[key].zero?
|
|
559
|
-
|
|
560
|
-
@subscribers.delete(key)
|
|
561
|
-
@locks.delete(key)
|
|
562
|
-
!@registry.delete(key).nil?
|
|
563
|
-
end
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
def registry
|
|
567
|
-
@registry_mutex.synchronize { @registry.dup }
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
# Clear all documents (useful for testing).
|
|
571
|
-
def reset!
|
|
572
|
-
@registry_mutex.synchronize do
|
|
573
|
-
@registry = {}
|
|
574
|
-
@locks = {}
|
|
575
|
-
@subscribers = Hash.new(0)
|
|
576
|
-
end
|
|
577
|
-
end
|
|
578
|
-
end
|
|
579
|
-
end
|
|
580
|
-
end
|