yrb-lite 0.1.0.beta1
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 +7 -0
- data/CHANGELOG.md +29 -0
- data/Cargo.toml +3 -0
- data/LICENSE +21 -0
- data/README.md +427 -0
- data/ext/yrb_lite/Cargo.toml +19 -0
- data/ext/yrb_lite/extconf.rb +6 -0
- data/ext/yrb_lite/src/lib.rs +752 -0
- data/lib/yrb-lite.rb +4 -0
- data/lib/yrb_lite/sync.rb +456 -0
- data/lib/yrb_lite/version.rb +5 -0
- data/lib/yrb_lite.rb +20 -0
- metadata +130 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 73c6ed02c103b7647be2d6052ff660490088bff3e1dba3a82105f8fb42ffecab
|
|
4
|
+
data.tar.gz: c308cb9c4e426992b1cbc31b2a870dc3ab3863c3a0217949b7744c4bc4c48d91
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8eb77739b34c860f961a850a70ed39f778f0711d83fda715c8447af1c7d81625472697142ce5aaf8b8a339c408992a6ada2d8a176cf03b1145afc62bd5f7aca5
|
|
7
|
+
data.tar.gz: 8886214b02d90bcbe44958deaa0b1b73bd0aa4707f185f34868f5444e99ca8e4ed02dce4100486543fb7dec82d609996de6f90f81acf12fbf1217466115ba985
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims
|
|
5
|
+
to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Thread-safe `YrbLite::Doc` and `YrbLite::Awareness` over `yrs` (magnus/rb-sys
|
|
12
|
+
native extension). The GVL is released during CRDT work so docs can run in
|
|
13
|
+
parallel on MRI.
|
|
14
|
+
- `YrbLite::Sync` ActionCable channel concern implementing the y-websocket
|
|
15
|
+
protocol (document sync plus awareness/presence). It's wire-compatible with
|
|
16
|
+
the [`@y-rb/actioncable`](https://www.npmjs.com/package/@y-rb/actioncable)
|
|
17
|
+
browser provider, and accepts its `{ update: ... }` envelope and `{ m: ... }`.
|
|
18
|
+
- A "record-before-distribute" mode via an `on_change` hook, so every change is
|
|
19
|
+
recorded durably before it's applied or relayed.
|
|
20
|
+
- Presence cleanup on disconnect, and idle-document eviction.
|
|
21
|
+
- Two backends: `sync_backend :memory` (default, classic ActionCable) and
|
|
22
|
+
`sync_backend :store` (stateless, AnyCable-ready, multi-process).
|
|
23
|
+
- Hardening against bad input: malformed or multi-message frames are dropped
|
|
24
|
+
before processing or relay, and native panics are contained at the FFI
|
|
25
|
+
boundary.
|
|
26
|
+
- Precompiled native gems for common platforms (no Rust toolchain needed to
|
|
27
|
+
install) via the cross-gem workflow.
|
|
28
|
+
|
|
29
|
+
[Unreleased]: https://github.com/jpcamara/yrb-lite/commits/main
|
data/Cargo.toml
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# yrb-lite
|
|
2
|
+
|
|
3
|
+
[](https://github.com/jpcamara/yrb-lite/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Collaborative editing for Rails, backed by [y-crdt](https://github.com/y-crdt/y-crdt)
|
|
6
|
+
(the Rust library behind Y.js). Your Rails server speaks the y-websocket sync
|
|
7
|
+
protocol directly, so there's no separate Node process hosting the Y.js
|
|
8
|
+
documents.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
12
|
+
include YrbLite::Sync
|
|
13
|
+
|
|
14
|
+
def subscribed = sync_for(params[:id])
|
|
15
|
+
def receive(data) = sync_receive(data)
|
|
16
|
+
def unsubscribed = sync_clear_presence
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
On the browser, use the [`@y-rb/actioncable`](https://www.npmjs.com/package/@y-rb/actioncable)
|
|
21
|
+
provider as-is. Tiptap, ProseMirror, and BlockNote all sync through it.
|
|
22
|
+
|
|
23
|
+
## What you get
|
|
24
|
+
|
|
25
|
+
- Thread-safe `Doc` and `Awareness`. You can share them across Puma threads,
|
|
26
|
+
and the GVL is released while yrs does the actual work.
|
|
27
|
+
- The y-websocket protocol (document sync plus awareness/presence) as a
|
|
28
|
+
one-include ActionCable concern.
|
|
29
|
+
- A store-backed mode for AnyCable and multi-process deployments.
|
|
30
|
+
- An optional authoritative mode that records each change durably before it
|
|
31
|
+
goes out to anyone.
|
|
32
|
+
|
|
33
|
+
What it doesn't do: auth, read-only connections, rate limiting, webhooks,
|
|
34
|
+
metrics. Hocuspocus ships extensions for those; here you'd build them with
|
|
35
|
+
Rails.
|
|
36
|
+
|
|
37
|
+
## Testing
|
|
38
|
+
|
|
39
|
+
Ruby and Rust unit tests cover the core, and an end-to-end suite runs the real
|
|
40
|
+
stack: it fuzzes the protocol, throws garbage and chaos at the server, kills the
|
|
41
|
+
server mid-write to check crash recovery, and drives real browsers under load.
|
|
42
|
+
The benchmark numbers below are from a single laptop. Issues and PRs are
|
|
43
|
+
welcome.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
gem "yrb-lite"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires Ruby 3.4 or newer. Precompiled gems ship for Linux and macOS on Ruby
|
|
52
|
+
3.4 and 4.0, so installing there needs no Rust. Other platforms (and any other
|
|
53
|
+
Ruby version) build from source, which needs [Rust](https://rustup.rs).
|
|
54
|
+
|
|
55
|
+
To work on the gem itself:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
git clone https://github.com/jpcamara/yrb-lite
|
|
59
|
+
cd yrb-lite
|
|
60
|
+
bundle install
|
|
61
|
+
bundle exec rake compile test
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The rest of the dev setup, plus the demo, is in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
65
|
+
|
|
66
|
+
## Docs
|
|
67
|
+
|
|
68
|
+
- The ActionCable concern and a quickstart are [below](#actioncable-integration).
|
|
69
|
+
- [`examples/actioncable-demo`](examples/actioncable-demo): a runnable Rails +
|
|
70
|
+
Tiptap app with collaborative cursors, the AnyCable setup, a Postgres store,
|
|
71
|
+
and the test/load suites.
|
|
72
|
+
- [CHANGELOG.md](CHANGELOG.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
### Doc (Low-Level Document Sync)
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
require "yrb_lite"
|
|
80
|
+
|
|
81
|
+
# Create docs
|
|
82
|
+
doc = YrbLite::Doc.new # random client ID
|
|
83
|
+
doc = YrbLite::Doc.new(12345) # specific client ID
|
|
84
|
+
|
|
85
|
+
# Get document info
|
|
86
|
+
doc.client_id # => unique client identifier
|
|
87
|
+
doc.guid # => document GUID
|
|
88
|
+
|
|
89
|
+
# Encoding
|
|
90
|
+
doc.encode_state_vector # => current state vector
|
|
91
|
+
doc.encode_state_as_update # => full update
|
|
92
|
+
doc.encode_state_as_update(sv) # => update diff against state vector
|
|
93
|
+
|
|
94
|
+
# Applying updates
|
|
95
|
+
doc.apply_update(update_bytes) # apply raw V1 update
|
|
96
|
+
|
|
97
|
+
# Sync protocol messages
|
|
98
|
+
doc.sync_step1 # => SyncStep1 message (contains state vector)
|
|
99
|
+
doc.sync_step2(state_vector) # => SyncStep2 message (contains update)
|
|
100
|
+
doc.handle_sync_message(data) # => [msg_type, sync_type, response]
|
|
101
|
+
doc.encode_update_message(update) # => wrap update as sync Update message
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Awareness (Document + Presence)
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# Create awareness instances (each contains a Doc)
|
|
108
|
+
awareness = YrbLite::Awareness.new # random client ID
|
|
109
|
+
awareness = YrbLite::Awareness.new(12345) # specific client ID
|
|
110
|
+
|
|
111
|
+
# Get document info
|
|
112
|
+
awareness.client_id # => unique client identifier
|
|
113
|
+
awareness.guid # => document GUID
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Handling Sync Messages
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# When connection opens, send initial sync messages
|
|
120
|
+
initial_message = awareness.start
|
|
121
|
+
# Send initial_message to peer via WebSocket
|
|
122
|
+
|
|
123
|
+
# When receiving messages from peer
|
|
124
|
+
response = awareness.handle(incoming_data)
|
|
125
|
+
# Send response back to peer if not empty
|
|
126
|
+
send_to_peer(response) unless response.empty?
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### ActionCable Integration
|
|
130
|
+
|
|
131
|
+
`YrbLite::Sync` is a channel concern that implements the full y-websocket
|
|
132
|
+
protocol (document sync + awareness/presence) over ActionCable:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
# app/channels/document_channel.rb
|
|
136
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
137
|
+
include YrbLite::Sync
|
|
138
|
+
|
|
139
|
+
# Optional persistence:
|
|
140
|
+
# on_load { |key| Document.find_by(key: key)&.content }
|
|
141
|
+
# on_save { |key, update| Document.find_by(key: key)&.update!(content: update) }
|
|
142
|
+
|
|
143
|
+
def subscribed
|
|
144
|
+
sync_for params[:id]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def receive(data)
|
|
148
|
+
sync_receive(data)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def unsubscribed
|
|
152
|
+
sync_clear_presence
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
One `YrbLite::Awareness` is shared per document key. Creating it is
|
|
158
|
+
mutex-serialized; after that everything runs lock-free on the thread-safe
|
|
159
|
+
native types. The concern answers SyncStep1 directly, relays document and
|
|
160
|
+
awareness changes to the other subscribers (not back to the sender), and calls
|
|
161
|
+
`on_save` after any message that changed the document.
|
|
162
|
+
|
|
163
|
+
`sync_unsubscribed` clears the connection's presence, so a closed tab doesn't
|
|
164
|
+
leave a stale cursor hanging until the client-side timeout. It also unloads the
|
|
165
|
+
document from memory once the last subscriber disconnects, which keeps the
|
|
166
|
+
process from holding onto every document it ever served. That unload only
|
|
167
|
+
happens when `on_load` is set and the document can be reloaded later; without
|
|
168
|
+
it, the in-memory copy is the only one and stays put.
|
|
169
|
+
|
|
170
|
+
Incoming frames are validated as a single well-formed protocol message before
|
|
171
|
+
anything processes or relays them. Malformed, truncated, multi-message,
|
|
172
|
+
oversized, or unknown frames are dropped. A bad frame can't crash the process: a
|
|
173
|
+
Rust panic is caught at the FFI boundary and re-raised as a Ruby exception. And
|
|
174
|
+
no single client can relay garbage that breaks the others in a room.
|
|
175
|
+
|
|
176
|
+
#### Multi-process deployments
|
|
177
|
+
|
|
178
|
+
Most Rails apps run several processes (Puma workers, multiple dynos), and any of
|
|
179
|
+
them might serve a given document. Two pieces keep them in step.
|
|
180
|
+
|
|
181
|
+
Broadcasts cross processes through the Action Cable adapter, so it needs to be a
|
|
182
|
+
real one (`redis` or `solid_cable`, not `async`). With that in place, a change
|
|
183
|
+
on one process reaches clients on all of them.
|
|
184
|
+
|
|
185
|
+
Each process also keeps its own copy of the document and applies broadcasts from
|
|
186
|
+
the others. The merge is an ordinary CRDT apply, idempotent and
|
|
187
|
+
order-independent, which keeps server reads and new-client handshakes current on
|
|
188
|
+
every process. Each broadcast carries a per-process id (`Sync.process_id`) that
|
|
189
|
+
tells a process to skip its own.
|
|
190
|
+
|
|
191
|
+
A cold process (no copy yet) rebuilds from the durable store through `on_load`.
|
|
192
|
+
In authoritative mode the store is always current, since changes are recorded
|
|
193
|
+
before they go out. Record-before-distribute therefore holds across processes:
|
|
194
|
+
whichever process receives a change records it to the shared store before
|
|
195
|
+
anyone, anywhere, sees it.
|
|
196
|
+
|
|
197
|
+
`bun multiprocess.mjs` in the demo runs clients across two processes and checks
|
|
198
|
+
the lot: convergence, fresh copies on both, presence across processes, and one
|
|
199
|
+
shared log.
|
|
200
|
+
|
|
201
|
+
##### AnyCable (`sync_backend :store`)
|
|
202
|
+
|
|
203
|
+
The default backend keeps that warm in-memory copy and relies on a `stream_from`
|
|
204
|
+
block running in Ruby for each broadcast. AnyCable breaks both assumptions.
|
|
205
|
+
anycable-go delivers broadcasts outside Ruby, so the block never runs. Each RPC
|
|
206
|
+
gets a fresh channel instance, which means ivars set in `subscribed` are gone by
|
|
207
|
+
`receive`. And there's no fixed worker-to-document mapping to lean on.
|
|
208
|
+
|
|
209
|
+
`sync_backend :store` is the path for that: stateless per message, no warm
|
|
210
|
+
copy.
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
214
|
+
include YrbLite::Sync
|
|
215
|
+
sync_backend :store
|
|
216
|
+
|
|
217
|
+
on_load { |key| MyStore.load(key) } # required: source of truth
|
|
218
|
+
on_change { |key, update| MyStore.append(key, update) } # required: record
|
|
219
|
+
|
|
220
|
+
def subscribed = sync_for(params[:id])
|
|
221
|
+
def receive(data) = sync_receive(data, params[:id]) # pass the key each call
|
|
222
|
+
def unsubscribed = sync_unsubscribed(params[:id])
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- `stream_from` is registered without a block; anycable-go does the relaying.
|
|
227
|
+
- A handshake (SyncStep1) is answered from the store. Changes are recorded, then
|
|
228
|
+
broadcast. Nothing is held in Ruby between calls, so any worker can handle any
|
|
229
|
+
message.
|
|
230
|
+
- Pass `params[:id]` into `sync_receive`/`sync_unsubscribed` so the document key
|
|
231
|
+
survives AnyCable's per-command instances.
|
|
232
|
+
- The sender gets its own updates echoed back (no Ruby callback to filter them).
|
|
233
|
+
That's a no-op, since applying an update twice does nothing.
|
|
234
|
+
|
|
235
|
+
The demo checks this against a real anycable-go + RPC server
|
|
236
|
+
(`frontend/anycable_probe.mjs`, `anycable_concurrent.mjs`): liveness, the
|
|
237
|
+
`@y-rb/actioncable` provider, cross-process reads, and concurrent convergence.
|
|
238
|
+
|
|
239
|
+
The wire format is the standard y-protocols binary messages, base64-encoded in
|
|
240
|
+
the ActionCable envelope. The server accepts the `@y-rb/actioncable` provider's
|
|
241
|
+
`{ "update" => ... }` envelope (and its own `{ "m" => ... }`) and sends one
|
|
242
|
+
message per frame, so the off-the-shelf provider works with no custom client
|
|
243
|
+
code:
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
import { createConsumer } from "@rails/actioncable"
|
|
247
|
+
import { WebsocketProvider } from "@y-rb/actioncable"
|
|
248
|
+
|
|
249
|
+
const provider = new WebsocketProvider(ydoc, createConsumer(), "DocumentChannel", { id: docId })
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
[`examples/actioncable-demo`](examples/actioncable-demo) is a full Rails + Tiptap
|
|
253
|
+
app using that provider, with end-to-end tests.
|
|
254
|
+
|
|
255
|
+
#### Authoritative audit mode (record before distribute)
|
|
256
|
+
|
|
257
|
+
By default a change is applied and broadcast immediately (the fast path). If you
|
|
258
|
+
need to durably record every change before anyone else sees it, whether for
|
|
259
|
+
auditing or to guarantee nothing is distributed until it's stored, register an
|
|
260
|
+
`on_change` recorder:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
264
|
+
include YrbLite::Sync
|
|
265
|
+
|
|
266
|
+
on_change do |key, update|
|
|
267
|
+
# Synchronous, durable write. `update` is the exact CRDT delta.
|
|
268
|
+
AuditLog.append!(key, update) # raise to REJECT the change
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def subscribed = sync_for(params[:id])
|
|
272
|
+
def receive(data) = sync_receive(data)
|
|
273
|
+
def unsubscribed = sync_clear_presence
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
With `on_change` registered, a change is recorded before it goes anywhere. The
|
|
278
|
+
recorder writes the raw CRDT delta synchronously; only then is the change
|
|
279
|
+
applied to the shared document and broadcast. The whole sequence runs under a
|
|
280
|
+
per-document lock, so every change to a document is recorded in the same order
|
|
281
|
+
it's applied. That's what makes the log authoritative. Replay the deltas onto a
|
|
282
|
+
fresh `Y.Doc` and you get the document back exactly.
|
|
283
|
+
|
|
284
|
+
If the recorder raises (say the store is down), the change is rejected: not
|
|
285
|
+
applied, not sent to anyone. The cost is a synchronous durable write per change,
|
|
286
|
+
which serializes that document's writes. Other documents use other locks and run
|
|
287
|
+
in parallel.
|
|
288
|
+
|
|
289
|
+
`on_change` and `on_save` are separate. `on_save` snapshots the whole document
|
|
290
|
+
when it gets a chance; `on_change` is the per-change log. The demo's `AUDIT=1`
|
|
291
|
+
mode (in [`examples/actioncable-demo`](examples/actioncable-demo)) wires
|
|
292
|
+
`on_change` to an fsync'd append-only log and checks, end to end, that the log
|
|
293
|
+
alone rebuilds the document.
|
|
294
|
+
|
|
295
|
+
### User Awareness/Presence
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
# Set local user state (cursor position, name, etc.)
|
|
299
|
+
awareness.set_local_state('{"user": {"name": "Alice", "color": "#ff0000"}}')
|
|
300
|
+
|
|
301
|
+
# Get local state
|
|
302
|
+
awareness.local_state # => '{"user": {"name": "Alice", "color": "#ff0000"}}'
|
|
303
|
+
|
|
304
|
+
# Clear local state (e.g., when disconnecting)
|
|
305
|
+
awareness.clear_local_state
|
|
306
|
+
|
|
307
|
+
# Encode awareness update for broadcasting
|
|
308
|
+
update = awareness.encode_awareness_update
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Low-Level Access
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
# Get state vector for manual sync
|
|
315
|
+
sv = awareness.encode_state_vector
|
|
316
|
+
|
|
317
|
+
# Get update diffed against a state vector
|
|
318
|
+
update = awareness.encode_state_as_update(remote_state_vector)
|
|
319
|
+
|
|
320
|
+
# Apply raw update to the document
|
|
321
|
+
awareness.apply_update(update_bytes)
|
|
322
|
+
|
|
323
|
+
# Wrap raw update data in a sync message
|
|
324
|
+
message = awareness.encode_update(update_bytes)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Thread Safety
|
|
328
|
+
|
|
329
|
+
Unlike the official `y-rb` gem, yrb-lite is safe to share across Ruby threads. A
|
|
330
|
+
`Doc` or `Awareness` can be used concurrently from Puma workers, ActionCable
|
|
331
|
+
connection threads, or background jobs without external locking.
|
|
332
|
+
|
|
333
|
+
That comes from how the underlying types work, not from locking on top:
|
|
334
|
+
|
|
335
|
+
- `yrs::Doc` is `Send + Sync`. Every operation takes the document's internal
|
|
336
|
+
RwLock with blocking semantics (`read_blocking`/`write_blocking`), so
|
|
337
|
+
concurrent access serializes instead of erroring or corrupting state.
|
|
338
|
+
- `yrs::sync::Awareness` is built for multi-threaded servers: client states
|
|
339
|
+
live in a `DashMap` and the whole API is `&self`.
|
|
340
|
+
- The extension adds no interior-mutability tricks. There's no `RefCell`, where
|
|
341
|
+
a re-entrant borrow would panic and take the Ruby process down with it.
|
|
342
|
+
Each native method opens and closes its transaction in one call, so no lock
|
|
343
|
+
or borrow outlives a call and there's nothing to deadlock on.
|
|
344
|
+
- A `Send + Sync` static assertion for both wrapped types lives in `lib.rs`. If
|
|
345
|
+
a yrs upgrade regressed this, the gem would fail to compile instead of quietly
|
|
346
|
+
turning thread-unsafe.
|
|
347
|
+
|
|
348
|
+
`test/thread_safety_test.rb` runs shared docs, the full sync handshake, fan-in
|
|
349
|
+
sync, and awareness state across 8 threads at once, and checks the interleaving
|
|
350
|
+
doesn't change convergence.
|
|
351
|
+
|
|
352
|
+
### Parallelism (GVL release)
|
|
353
|
+
|
|
354
|
+
Every method that does real CRDT work (applying updates, encoding state,
|
|
355
|
+
handling sync messages) releases Ruby's Global VM Lock
|
|
356
|
+
(`rb_thread_call_without_gvl`) while the native code runs. That buys two things.
|
|
357
|
+
|
|
358
|
+
CRDT work runs in parallel across Ruby threads on MRI, not just
|
|
359
|
+
JRuby/TruffleRuby. `bench/parallelism_bench.rb` measures over 2x wall-clock
|
|
360
|
+
speedup applying a ~900 KB update concurrently; native code that held the GVL
|
|
361
|
+
couldn't beat serial time.
|
|
362
|
+
|
|
363
|
+
A slow operation also can't stall the VM. A thread applying a large update holds
|
|
364
|
+
the doc's write lock without holding the GVL, so other Ruby threads keep running
|
|
365
|
+
instead of queuing behind it.
|
|
366
|
+
|
|
367
|
+
Each method has the same shape: copy the Ruby byte string, drop the GVL, do the
|
|
368
|
+
yrs work (taking and releasing the doc lock entirely inside the closure), take
|
|
369
|
+
the GVL back, then build Ruby objects. No Ruby API is touched without the GVL,
|
|
370
|
+
and the doc lock is never held across a GVL boundary, so the lock order can't
|
|
371
|
+
deadlock. Panics in native code are caught and re-raised as Ruby exceptions.
|
|
372
|
+
|
|
373
|
+
## Message Type Constants
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
YrbLite::MSG_SYNC # 0 - Document sync messages
|
|
377
|
+
YrbLite::MSG_AWARENESS # 1 - User presence data
|
|
378
|
+
YrbLite::MSG_AUTH # 2 - Authentication
|
|
379
|
+
YrbLite::MSG_QUERY_AWARENESS # 3 - Request awareness state
|
|
380
|
+
|
|
381
|
+
YrbLite::MSG_SYNC_STEP1 # 0 - State vector request
|
|
382
|
+
YrbLite::MSG_SYNC_STEP2 # 1 - Update response
|
|
383
|
+
YrbLite::MSG_SYNC_UPDATE # 2 - Incremental update
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## Sync Flow
|
|
387
|
+
|
|
388
|
+
```
|
|
389
|
+
Client A Server
|
|
390
|
+
| |
|
|
391
|
+
|-------- start() --------------->|
|
|
392
|
+
| (SyncStep1 + Awareness) |
|
|
393
|
+
| |
|
|
394
|
+
|<------- handle() response ------|
|
|
395
|
+
| (SyncStep2) |
|
|
396
|
+
| |
|
|
397
|
+
| (Document synchronized!) |
|
|
398
|
+
| |
|
|
399
|
+
|<------- updates ----------------|
|
|
400
|
+
|-------- updates --------------->|
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Development
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
# Setup
|
|
407
|
+
bundle install
|
|
408
|
+
|
|
409
|
+
# Build extension
|
|
410
|
+
rake compile
|
|
411
|
+
|
|
412
|
+
# Run tests
|
|
413
|
+
rake test
|
|
414
|
+
|
|
415
|
+
# Clean build artifacts
|
|
416
|
+
rake clean
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## License
|
|
420
|
+
|
|
421
|
+
MIT License
|
|
422
|
+
|
|
423
|
+
## Acknowledgments
|
|
424
|
+
|
|
425
|
+
- [y-crdt/yrs](https://github.com/y-crdt/y-crdt) - The Rust implementation of Y.js
|
|
426
|
+
- [Magnus](https://github.com/matsadler/magnus) - Ruby bindings for Rust
|
|
427
|
+
- [rb-sys](https://github.com/oxidize-rb/rb-sys) - Rust extensions for Ruby
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "yrb_lite"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
authors = ["JP Camara <johnpcamara@gmail.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
publish = false
|
|
8
|
+
|
|
9
|
+
[lib]
|
|
10
|
+
crate-type = ["cdylib"]
|
|
11
|
+
|
|
12
|
+
[dependencies]
|
|
13
|
+
magnus = "0.8"
|
|
14
|
+
rb-sys = "0.9"
|
|
15
|
+
yrs = { version = "0.21", features = ["sync"] }
|
|
16
|
+
serde_json = "1.0"
|
|
17
|
+
|
|
18
|
+
[dev-dependencies]
|
|
19
|
+
rb-sys-env = "0.1"
|