yrb-lite 0.1.0.beta1 → 0.1.0.beta3
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 +54 -1
- data/README.md +50 -0
- data/ext/yrb_lite/Cargo.toml +1 -1
- data/ext/yrb_lite/src/lib.rs +198 -44
- data/lib/yrb_lite/sync.rb +113 -10
- data/lib/yrb_lite/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fdc68c7b4936a2e6598441f2def20050e456a543f44de6ecd35aeb1a894cdcc7
|
|
4
|
+
data.tar.gz: 5a5ffbf2c1df1fc41d7eba424579459587d46e1e38db0606b79efefb5d5321c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 00bff87ec9b51a46f7bee55e838ef629531c0f4faaa43400f2447055932fefc4d8ce597c15724b98e384bbd5f119c881ebf1f85f2b2bab614ff6d3e00d570eaa
|
|
7
|
+
data.tar.gz: 0dff053de27327d7517bdb83b850ee45c977d38f1556ee38210e8e3431da19937c1cc4e22201da2242ca249cee1a1647b8f0ec5dd1fe50ed75fb2d78743a4947
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,56 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.0.beta3] - 2026-06-18
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Upgraded the bundled `yrs` (y-crdt) from 0.21 to 0.27.2. No change to the
|
|
14
|
+
`YrbLite::Doc`, `YrbLite::Awareness`, or `YrbLite::Sync` public API; existing
|
|
15
|
+
code and the wire protocol are unaffected.
|
|
16
|
+
- Thread-safety is preserved across the upgrade. yrs 0.27 dropped `Awareness`'s
|
|
17
|
+
internal locking (its mutating methods now take `&mut self`, and `Awareness`
|
|
18
|
+
is no longer `Sync`), so `YrbLite::Awareness` now serializes access through an
|
|
19
|
+
internal `Mutex`. The lock is taken only while the GVL is released and is
|
|
20
|
+
never held across the GVL boundary, so concurrent access from multiple Ruby
|
|
21
|
+
threads stays safe and deadlock-free, and document reads still run in parallel
|
|
22
|
+
(they operate on a cheaply-cloned, `Arc`-backed `Doc` handle, not under the
|
|
23
|
+
presence lock).
|
|
24
|
+
|
|
25
|
+
### Build
|
|
26
|
+
|
|
27
|
+
- Building the gem from source now requires **Rust 1.94 or newer** (yrs 0.27.2
|
|
28
|
+
uses `let`-chains). The precompiled platform gems are unaffected -- they need
|
|
29
|
+
no Rust toolchain to install.
|
|
30
|
+
|
|
31
|
+
## [0.1.0.beta2] - 2026-06-16
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- Reliable delivery (opt-in, client-driven). A client may tag a document update
|
|
36
|
+
with an `"id"`; the server replies `{ "ack": <id> }` once the update has been
|
|
37
|
+
accepted (recorded in audit mode, applied in fast mode). This lets an
|
|
38
|
+
ack-aware client retain and retransmit an update until delivery is confirmed,
|
|
39
|
+
so an edit can't be silently lost on a flaky connection. Stock clients send no
|
|
40
|
+
`"id"`, never get acks, and behave exactly as before.
|
|
41
|
+
- A vendored, ack-aware `@y-rb/actioncable` provider in the demo
|
|
42
|
+
(`reliable_actioncable_provider.mjs`) that adds reliable delivery with
|
|
43
|
+
"sync-since-last-ack" framing (the unacknowledged tail is sent as one merged,
|
|
44
|
+
causally-complete delta), plus a minimal reference client and an intensive
|
|
45
|
+
message-loss stress test.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- Causal-gap protection. The authoritative, fast, and store paths now reject a
|
|
50
|
+
document update that isn't causally ready -- one whose dependencies are
|
|
51
|
+
missing because an earlier update was lost in transit or its durable record
|
|
52
|
+
failed -- and ask the client to resync, instead of recording or relaying an
|
|
53
|
+
un-integrable update that would leave the log permanently pending. Adds native
|
|
54
|
+
`Doc#update_ready?`/`#pending?` (cheap, read-only checks) used to gate the
|
|
55
|
+
record-before-distribute path.
|
|
56
|
+
|
|
57
|
+
## [0.1.0.beta1]
|
|
58
|
+
|
|
9
59
|
### Added
|
|
10
60
|
|
|
11
61
|
- Thread-safe `YrbLite::Doc` and `YrbLite::Awareness` over `yrs` (magnus/rb-sys
|
|
@@ -26,4 +76,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
26
76
|
- Precompiled native gems for common platforms (no Rust toolchain needed to
|
|
27
77
|
install) via the cross-gem workflow.
|
|
28
78
|
|
|
29
|
-
[Unreleased]: https://github.com/jpcamara/yrb-lite/
|
|
79
|
+
[Unreleased]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta3...main
|
|
80
|
+
[0.1.0.beta3]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta2...v0.1.0.beta3
|
|
81
|
+
[0.1.0.beta2]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta1...v0.1.0.beta2
|
|
82
|
+
[0.1.0.beta1]: https://github.com/jpcamara/yrb-lite/releases/tag/v0.1.0.beta1
|
data/README.md
CHANGED
|
@@ -292,6 +292,56 @@ mode (in [`examples/actioncable-demo`](examples/actioncable-demo)) wires
|
|
|
292
292
|
`on_change` to an fsync'd append-only log and checks, end to end, that the log
|
|
293
293
|
alone rebuilds the document.
|
|
294
294
|
|
|
295
|
+
#### Reliable delivery (acks)
|
|
296
|
+
|
|
297
|
+
The y-websocket protocol is fire-and-forget. If a client's update is lost in
|
|
298
|
+
transit (a flaky socket, a send that never lands) and the client makes no
|
|
299
|
+
further edits, the server stays idle and never asks anyone to resync, so that
|
|
300
|
+
edit is gone -- even though the client believes it was saved. CRDTs converge the
|
|
301
|
+
state everyone *has*; they don't recover an update that never arrived.
|
|
302
|
+
|
|
303
|
+
yrb-lite closes that gap with an opt-in, client-driven acknowledgement. If an
|
|
304
|
+
incoming frame carries an `"id"`, the server replies `{ "ack": <id> }` once the
|
|
305
|
+
update has been **accepted** -- recorded in audit mode, applied in fast mode. A
|
|
306
|
+
causally-gapped update is not acked (it gets a resync instead), so the client
|
|
307
|
+
knows it hasn't landed yet.
|
|
308
|
+
|
|
309
|
+
```
|
|
310
|
+
client -> server { "m": "<base64 update>", "id": 42 }
|
|
311
|
+
server -> client { "ack": 42 } # update accepted; safe to forget
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
That's the whole server side. A reliable client tags each outgoing update with
|
|
315
|
+
an incrementing id, keeps it in a pending buffer, and retransmits on a timer (and
|
|
316
|
+
on reconnect) until the matching ack returns. Because CRDT apply is idempotent, a
|
|
317
|
+
resend that already landed is a harmless no-op that just re-acks. An update lost
|
|
318
|
+
in transit is recovered by the client's own retransmit -- no reconnect required,
|
|
319
|
+
and no dependence on a later edit happening to trigger a resync.
|
|
320
|
+
|
|
321
|
+
This is entirely **self-gating**: stock clients send no `"id"`, so they never get
|
|
322
|
+
acks and behave exactly as before. Only a client that opts in by tagging its
|
|
323
|
+
frames participates.
|
|
324
|
+
|
|
325
|
+
Two client examples ship in the demo:
|
|
326
|
+
|
|
327
|
+
- [`frontend/reliable.mjs`](examples/actioncable-demo/frontend/reliable.mjs) — a
|
|
328
|
+
minimal reference client showing the raw mechanism (tag with an id, retain,
|
|
329
|
+
retransmit on a timer, drain on ack), with an end-to-end test that loses an
|
|
330
|
+
update mid-flight and recovers it purely by retransmit.
|
|
331
|
+
- [`frontend/provider/reliable_actioncable_provider.mjs`](examples/actioncable-demo/frontend/provider/reliable_actioncable_provider.mjs)
|
|
332
|
+
— the standard `@y-rb/actioncable` `WebsocketProvider`, vendored and augmented
|
|
333
|
+
for production use. It's a drop-in replacement that speaks the same protocol
|
|
334
|
+
and envelope, and adds reliability with **sync-since-last-ack** framing: rather
|
|
335
|
+
than retransmitting updates one by one, it keeps the unacknowledged local
|
|
336
|
+
updates in a queue and sends their *merge* as a single causally-complete delta,
|
|
337
|
+
with the id being the highest sequence in the batch (so one `{ ack: id }`
|
|
338
|
+
cumulatively confirms everything up to it). Because the whole unacked tail goes
|
|
339
|
+
as one self-contained delta, the server never sees an internal gap and never
|
|
340
|
+
has to round-trip a resync for a lost middle update — the next edit, or the
|
|
341
|
+
next timer tick, carries it. Awareness stays fire-and-forget; against a server
|
|
342
|
+
that doesn't implement acks it warns once and falls back to plain delivery; and
|
|
343
|
+
`reliable: false` opts out entirely. The demo's editor uses this provider.
|
|
344
|
+
|
|
295
345
|
### User Awareness/Presence
|
|
296
346
|
|
|
297
347
|
```ruby
|
data/ext/yrb_lite/Cargo.toml
CHANGED
data/ext/yrb_lite/src/lib.rs
CHANGED
|
@@ -4,7 +4,8 @@ use yrs::sync::protocol::MessageReader;
|
|
|
4
4
|
use yrs::sync::{Awareness, DefaultProtocol, Message, Protocol, SyncMessage};
|
|
5
5
|
use yrs::updates::decoder::{Decode, DecoderV1};
|
|
6
6
|
use yrs::updates::encoder::{Encode, Encoder, EncoderV1};
|
|
7
|
-
use
|
|
7
|
+
use std::sync::Mutex;
|
|
8
|
+
use yrs::{ClientID, Doc, ReadTxn, Transact};
|
|
8
9
|
|
|
9
10
|
/// Wrapper around yrs Doc.
|
|
10
11
|
///
|
|
@@ -18,20 +19,35 @@ struct RbDoc(Doc);
|
|
|
18
19
|
|
|
19
20
|
/// Wrapper around yrs Awareness (which contains a Doc).
|
|
20
21
|
///
|
|
21
|
-
/// Thread safety: `
|
|
22
|
-
///
|
|
23
|
-
///
|
|
22
|
+
/// Thread safety: as of yrs 0.27 `Awareness` dropped its internal locking and
|
|
23
|
+
/// its mutating methods (`handle`, `set_local_state`, `clean_local_state`,
|
|
24
|
+
/// `remove_state`, `update_with_clients`) take `&mut self`. It is `Send` but no
|
|
25
|
+
/// longer `Sync`, so we serialize all access through a `Mutex`.
|
|
26
|
+
///
|
|
27
|
+
/// CRITICAL: the `Mutex` is ALWAYS locked inside the `nogvl` closure (never with
|
|
28
|
+
/// the GVL held) and the guard is dropped before the closure returns. This obeys
|
|
29
|
+
/// the same rule as the doc's RwLock (see `nogvl`): a thread never waits on this
|
|
30
|
+
/// lock while holding the GVL, and never reacquires the GVL while holding this
|
|
31
|
+
/// lock, so the GVL and this `Mutex` can't deadlock on lock order. Locking with
|
|
32
|
+
/// the GVL held (outside `nogvl`) reintroduces that deadlock -- don't.
|
|
33
|
+
///
|
|
34
|
+
/// For doc-only reads we clone the (Arc-backed) `Doc` out under the brief lock
|
|
35
|
+
/// and operate on the owned clone, so a long encode holds only the doc's own
|
|
36
|
+
/// RwLock, not this `Mutex`, and never blocks presence updates on another
|
|
37
|
+
/// thread. Lock order is always Mutex-then-doc-RwLock (or doc-RwLock alone),
|
|
38
|
+
/// never the reverse.
|
|
24
39
|
#[magnus::wrap(class = "YrbLite::Awareness", free_immediately, size)]
|
|
25
|
-
struct RbAwareness(Awareness);
|
|
40
|
+
struct RbAwareness(Mutex<Awareness>);
|
|
26
41
|
|
|
27
42
|
/// Compile-time proof that the wrapped types are thread-safe. If a future
|
|
28
|
-
/// yrs upgrade makes Doc or Awareness lose Send
|
|
29
|
-
/// instead of silently shipping a thread-unsafe gem.
|
|
43
|
+
/// yrs upgrade makes Doc lose Send/Sync, or Awareness lose Send, this fails the
|
|
44
|
+
/// build instead of silently shipping a thread-unsafe gem. (Awareness is no
|
|
45
|
+
/// longer `Sync` as of yrs 0.27, hence the `Mutex` wrapper, which restores it.)
|
|
30
46
|
#[allow(dead_code)]
|
|
31
47
|
fn assert_thread_safe() {
|
|
32
48
|
fn is_send_sync<T: Send + Sync>() {}
|
|
33
49
|
is_send_sync::<Doc>();
|
|
34
|
-
is_send_sync::<Awareness
|
|
50
|
+
is_send_sync::<Mutex<Awareness>>();
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
/// Run `f` with the GVL (Global VM Lock) released, so other Ruby threads,
|
|
@@ -42,10 +58,13 @@ fn assert_thread_safe() {
|
|
|
42
58
|
/// out of Ruby strings before entering, and results are converted to Ruby
|
|
43
59
|
/// objects after returning.
|
|
44
60
|
/// - It must be `Send` (it runs while other threads own the GVL). `&Doc` and
|
|
45
|
-
/// `&Awareness
|
|
46
|
-
/// -
|
|
47
|
-
///
|
|
48
|
-
/// lock
|
|
61
|
+
/// `&Mutex<Awareness>` are fine: both are `Sync` (asserted above).
|
|
62
|
+
/// - LOCK DISCIPLINE: any native lock it takes -- the doc's internal RwLock OR
|
|
63
|
+
/// the awareness `Mutex` (`self.0.lock()`) -- must be acquired AND released
|
|
64
|
+
/// inside this closure (GVL already dropped). Never lock with the GVL held
|
|
65
|
+
/// (e.g. before calling `nogvl`), or a thread waiting on the lock while
|
|
66
|
+
/// holding the GVL can deadlock against the GVL reacquire. Same reason we
|
|
67
|
+
/// never hold a lock across the GVL boundary.
|
|
49
68
|
///
|
|
50
69
|
/// Panics inside the closure are caught and re-raised (resumed) after the GVL
|
|
51
70
|
/// is reacquired, where magnus converts them to Ruby exceptions.
|
|
@@ -153,8 +172,17 @@ fn merged_doc_update(bytes: &[u8]) -> Result<Option<Vec<u8>>, String> {
|
|
|
153
172
|
_ => yrs::merge_updates_v1(&updates).map_err(|e| e.to_string())?,
|
|
154
173
|
};
|
|
155
174
|
let update = yrs::Update::decode_v1(&merged).map_err(|e| e.to_string())?;
|
|
156
|
-
|
|
157
|
-
|
|
175
|
+
// A genuine no-op (e.g. the empty SyncStep2 in an opening handshake) carries
|
|
176
|
+
// no structs, no deletes, and no dependencies. We must NOT treat a causally-
|
|
177
|
+
// pending update as a no-op: since yrs 0.26 such an update reports an empty
|
|
178
|
+
// state_vector (its structs can't integrate yet), but it still carries
|
|
179
|
+
// content and a non-empty lower bound (the deps it's waiting on). Dropping it
|
|
180
|
+
// here would silently swallow a gappy update instead of rejecting + resyncing.
|
|
181
|
+
if update.state_vector().is_empty()
|
|
182
|
+
&& update.delete_set().is_empty()
|
|
183
|
+
&& update.state_vector_lower().is_empty()
|
|
184
|
+
{
|
|
185
|
+
return Ok(None);
|
|
158
186
|
}
|
|
159
187
|
Ok(Some(merged))
|
|
160
188
|
}
|
|
@@ -165,12 +193,30 @@ fn awareness_client_ids_in(bytes: &[u8]) -> Result<Vec<u64>, String> {
|
|
|
165
193
|
let mut ids = Vec::new();
|
|
166
194
|
for msg in MessageReader::new(&mut decoder) {
|
|
167
195
|
if let Message::Awareness(update) = msg.map_err(|e| e.to_string())? {
|
|
168
|
-
ids.extend(update.clients.keys().
|
|
196
|
+
ids.extend(update.clients.keys().map(|c| c.get()));
|
|
169
197
|
}
|
|
170
198
|
}
|
|
171
199
|
Ok(ids)
|
|
172
200
|
}
|
|
173
201
|
|
|
202
|
+
/// True if applying `update_bytes` to `doc` would integrate cleanly: every
|
|
203
|
+
/// dependency the update references is already present (the doc's state vector
|
|
204
|
+
/// covers the update's lower bound). A pure read; does not mutate the doc.
|
|
205
|
+
/// When false, applying it would park a pending struct -- the signal that an
|
|
206
|
+
/// earlier, causally-prior update is missing.
|
|
207
|
+
fn update_is_ready(doc: &Doc, update_bytes: &[u8]) -> Result<bool, String> {
|
|
208
|
+
let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
|
|
209
|
+
Ok(doc.transact().state_vector() >= update.state_vector_lower())
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// True if the doc holds pending structs or a pending delete set -- blocks that
|
|
213
|
+
/// couldn't integrate because a dependency is missing. Used as a backstop after
|
|
214
|
+
/// loading from storage: leftover pending means the stored log has a causal gap.
|
|
215
|
+
fn doc_has_pending(doc: &Doc) -> bool {
|
|
216
|
+
let txn = doc.transact();
|
|
217
|
+
txn.store().pending_update().is_some() || txn.store().pending_ds().is_some()
|
|
218
|
+
}
|
|
219
|
+
|
|
174
220
|
// ============================================================================
|
|
175
221
|
// Doc Implementation
|
|
176
222
|
// ============================================================================
|
|
@@ -189,7 +235,7 @@ impl RbDoc {
|
|
|
189
235
|
|
|
190
236
|
/// Get the client ID
|
|
191
237
|
fn client_id(&self) -> u64 {
|
|
192
|
-
self.0.client_id()
|
|
238
|
+
self.0.client_id().get()
|
|
193
239
|
}
|
|
194
240
|
|
|
195
241
|
/// Get the document GUID
|
|
@@ -240,6 +286,22 @@ impl RbDoc {
|
|
|
240
286
|
.map_err(runtime_error)
|
|
241
287
|
}
|
|
242
288
|
|
|
289
|
+
/// True if applying `update` would integrate cleanly (its dependencies are
|
|
290
|
+
/// all present). False means it would leave a pending struct -- an earlier
|
|
291
|
+
/// update is missing. Pure read; does not mutate.
|
|
292
|
+
fn update_ready(&self, update: RString) -> Result<bool, Error> {
|
|
293
|
+
let update_bytes = copy_bytes(update);
|
|
294
|
+
let doc = &self.0;
|
|
295
|
+
nogvl(move || update_is_ready(doc, &update_bytes)).map_err(runtime_error)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// True if the document holds pending (un-integrable) structs waiting on a
|
|
299
|
+
/// missing dependency.
|
|
300
|
+
fn pending(&self) -> bool {
|
|
301
|
+
let doc = &self.0;
|
|
302
|
+
nogvl(move || doc_has_pending(doc))
|
|
303
|
+
}
|
|
304
|
+
|
|
243
305
|
/// Sync step 1: Create a sync message with our state vector
|
|
244
306
|
fn sync_step1(&self) -> RString {
|
|
245
307
|
let doc = &self.0;
|
|
@@ -318,7 +380,6 @@ impl RbDoc {
|
|
|
318
380
|
let msg = Message::Sync(SyncMessage::Update(update_bytes.to_vec()));
|
|
319
381
|
binary_string(&msg.encode_v1())
|
|
320
382
|
}
|
|
321
|
-
|
|
322
383
|
}
|
|
323
384
|
|
|
324
385
|
// ============================================================================
|
|
@@ -334,17 +395,19 @@ impl RbAwareness {
|
|
|
334
395
|
let client_id: u64 = TryConvert::try_convert(args[0])?;
|
|
335
396
|
Awareness::new(Doc::with_client_id(client_id))
|
|
336
397
|
};
|
|
337
|
-
Ok(RbAwareness(awareness))
|
|
398
|
+
Ok(RbAwareness(Mutex::new(awareness)))
|
|
338
399
|
}
|
|
339
400
|
|
|
340
401
|
/// Get the client ID of the underlying document
|
|
341
402
|
fn client_id(&self) -> u64 {
|
|
342
|
-
self.0
|
|
403
|
+
let awareness = &self.0;
|
|
404
|
+
nogvl(move || awareness.lock().unwrap().doc().client_id().get())
|
|
343
405
|
}
|
|
344
406
|
|
|
345
407
|
/// Get the document GUID
|
|
346
408
|
fn guid(&self) -> String {
|
|
347
|
-
self.0
|
|
409
|
+
let awareness = &self.0;
|
|
410
|
+
nogvl(move || awareness.lock().unwrap().doc().guid().to_string())
|
|
348
411
|
}
|
|
349
412
|
|
|
350
413
|
/// A standalone SyncStep1 message (the server's state vector). Sent as its
|
|
@@ -353,7 +416,8 @@ impl RbAwareness {
|
|
|
353
416
|
fn sync_step1(&self) -> RString {
|
|
354
417
|
let awareness = &self.0;
|
|
355
418
|
let encoded = nogvl(move || {
|
|
356
|
-
let
|
|
419
|
+
let doc = awareness.lock().unwrap().doc().clone();
|
|
420
|
+
let txn = doc.transact();
|
|
357
421
|
let sv = txn.state_vector();
|
|
358
422
|
Message::Sync(SyncMessage::SyncStep1(sv)).encode_v1()
|
|
359
423
|
});
|
|
@@ -365,10 +429,11 @@ impl RbAwareness {
|
|
|
365
429
|
fn start(&self) -> Result<RString, Error> {
|
|
366
430
|
let awareness = &self.0;
|
|
367
431
|
let encoded = nogvl(move || -> Result<Vec<u8>, String> {
|
|
432
|
+
let awareness = awareness.lock().unwrap();
|
|
368
433
|
let protocol = DefaultProtocol;
|
|
369
434
|
let mut encoder = EncoderV1::new();
|
|
370
435
|
protocol
|
|
371
|
-
.start(awareness, &mut encoder)
|
|
436
|
+
.start(&awareness, &mut encoder)
|
|
372
437
|
.map_err(|e| e.to_string())?;
|
|
373
438
|
Ok(encoder.to_vec())
|
|
374
439
|
})
|
|
@@ -383,9 +448,10 @@ impl RbAwareness {
|
|
|
383
448
|
let awareness = &self.0;
|
|
384
449
|
|
|
385
450
|
let encoded = nogvl(move || -> Result<Vec<u8>, String> {
|
|
451
|
+
let mut awareness = awareness.lock().unwrap();
|
|
386
452
|
let protocol = DefaultProtocol;
|
|
387
453
|
let responses = protocol
|
|
388
|
-
.handle(awareness, &data_bytes)
|
|
454
|
+
.handle(&mut awareness, &data_bytes)
|
|
389
455
|
.map_err(|e| e.to_string())?;
|
|
390
456
|
|
|
391
457
|
if responses.is_empty() {
|
|
@@ -413,7 +479,8 @@ impl RbAwareness {
|
|
|
413
479
|
fn encode_state_vector(&self) -> RString {
|
|
414
480
|
let awareness = &self.0;
|
|
415
481
|
let sv = nogvl(move || {
|
|
416
|
-
let
|
|
482
|
+
let doc = awareness.lock().unwrap().doc().clone();
|
|
483
|
+
let txn = doc.transact();
|
|
417
484
|
txn.state_vector().encode_v1()
|
|
418
485
|
});
|
|
419
486
|
binary_string(&sv)
|
|
@@ -433,7 +500,8 @@ impl RbAwareness {
|
|
|
433
500
|
None => yrs::StateVector::default(),
|
|
434
501
|
Some(bytes) => yrs::StateVector::decode_v1(bytes).map_err(|e| e.to_string())?,
|
|
435
502
|
};
|
|
436
|
-
let
|
|
503
|
+
let doc = awareness.lock().unwrap().doc().clone();
|
|
504
|
+
let txn = doc.transact();
|
|
437
505
|
Ok(txn.encode_state_as_update_v1(&sv))
|
|
438
506
|
})
|
|
439
507
|
.map_err(runtime_error)?;
|
|
@@ -442,37 +510,47 @@ impl RbAwareness {
|
|
|
442
510
|
|
|
443
511
|
/// Set local awareness state (JSON string)
|
|
444
512
|
fn set_local_state(&self, json: String) -> Result<(), Error> {
|
|
445
|
-
let awareness = &self.0;
|
|
446
513
|
let value: serde_json::Value =
|
|
447
514
|
serde_json::from_str(&json).map_err(|e| runtime_error(e.to_string()))?;
|
|
448
|
-
awareness
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
515
|
+
let awareness = &self.0;
|
|
516
|
+
nogvl(move || -> Result<(), String> {
|
|
517
|
+
awareness
|
|
518
|
+
.lock()
|
|
519
|
+
.unwrap()
|
|
520
|
+
.set_local_state(value)
|
|
521
|
+
.map_err(|e| e.to_string())
|
|
522
|
+
})
|
|
523
|
+
.map_err(runtime_error)
|
|
452
524
|
}
|
|
453
525
|
|
|
454
526
|
/// Get local awareness state as JSON string (or nil if not set)
|
|
455
527
|
fn local_state(&self) -> Option<String> {
|
|
456
528
|
let awareness = &self.0;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
529
|
+
nogvl(move || {
|
|
530
|
+
awareness
|
|
531
|
+
.lock()
|
|
532
|
+
.unwrap()
|
|
533
|
+
.local_state::<serde_json::Value>()
|
|
534
|
+
.map(|v| v.to_string())
|
|
535
|
+
})
|
|
460
536
|
}
|
|
461
537
|
|
|
462
538
|
/// Clear local awareness state
|
|
463
539
|
fn clear_local_state(&self) {
|
|
464
540
|
let awareness = &self.0;
|
|
465
|
-
awareness.clean_local_state();
|
|
541
|
+
nogvl(move || awareness.lock().unwrap().clean_local_state());
|
|
466
542
|
}
|
|
467
543
|
|
|
468
544
|
/// Get awareness update for broadcasting to peers
|
|
469
545
|
fn encode_awareness_update(&self) -> Result<RString, Error> {
|
|
470
546
|
let awareness = &self.0;
|
|
471
|
-
let
|
|
472
|
-
.
|
|
473
|
-
.map_err(|e|
|
|
474
|
-
|
|
475
|
-
|
|
547
|
+
let encoded = nogvl(move || -> Result<Vec<u8>, String> {
|
|
548
|
+
let awareness = awareness.lock().unwrap();
|
|
549
|
+
let update = awareness.update().map_err(|e| e.to_string())?;
|
|
550
|
+
Ok(Message::Awareness(update).encode_v1())
|
|
551
|
+
})
|
|
552
|
+
.map_err(runtime_error)?;
|
|
553
|
+
Ok(binary_string(&encoded))
|
|
476
554
|
}
|
|
477
555
|
|
|
478
556
|
/// Apply a raw update to the underlying document
|
|
@@ -481,12 +559,36 @@ impl RbAwareness {
|
|
|
481
559
|
let awareness = &self.0;
|
|
482
560
|
nogvl(move || -> Result<(), String> {
|
|
483
561
|
let update = yrs::Update::decode_v1(&update_bytes).map_err(|e| e.to_string())?;
|
|
484
|
-
let
|
|
562
|
+
let doc = awareness.lock().unwrap().doc().clone();
|
|
563
|
+
let mut txn = doc.transact_mut();
|
|
485
564
|
txn.apply_update(update).map_err(|e| e.to_string())
|
|
486
565
|
})
|
|
487
566
|
.map_err(runtime_error)
|
|
488
567
|
}
|
|
489
568
|
|
|
569
|
+
/// True if applying `update` would integrate cleanly (its dependencies are
|
|
570
|
+
/// all present). False means it depends on a missing, causally-prior update.
|
|
571
|
+
/// Pure read; does not mutate.
|
|
572
|
+
fn update_ready(&self, update: RString) -> Result<bool, Error> {
|
|
573
|
+
let update_bytes = copy_bytes(update);
|
|
574
|
+
let awareness = &self.0;
|
|
575
|
+
nogvl(move || {
|
|
576
|
+
let doc = awareness.lock().unwrap().doc().clone();
|
|
577
|
+
update_is_ready(&doc, &update_bytes)
|
|
578
|
+
})
|
|
579
|
+
.map_err(runtime_error)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/// True if the document holds pending (un-integrable) structs waiting on a
|
|
583
|
+
/// missing dependency.
|
|
584
|
+
fn pending(&self) -> bool {
|
|
585
|
+
let awareness = &self.0;
|
|
586
|
+
nogvl(move || {
|
|
587
|
+
let doc = awareness.lock().unwrap().doc().clone();
|
|
588
|
+
doc_has_pending(&doc)
|
|
589
|
+
})
|
|
590
|
+
}
|
|
591
|
+
|
|
490
592
|
/// Decode the awareness client IDs referenced by a protocol message
|
|
491
593
|
/// (which may pack several sub-messages together). Sync sub-messages are
|
|
492
594
|
/// ignored. The ActionCable layer uses this to learn which presence
|
|
@@ -531,11 +633,13 @@ impl RbAwareness {
|
|
|
531
633
|
fn remove_clients(&self, client_ids: Vec<u64>) -> Result<RString, Error> {
|
|
532
634
|
let awareness = &self.0;
|
|
533
635
|
let encoded = nogvl(move || -> Result<Vec<u8>, String> {
|
|
636
|
+
let mut awareness = awareness.lock().unwrap();
|
|
534
637
|
let mut removed = Vec::new();
|
|
535
638
|
for id in client_ids {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
639
|
+
let cid = ClientID::new(id);
|
|
640
|
+
if awareness.meta(cid).is_some() {
|
|
641
|
+
awareness.remove_state(cid);
|
|
642
|
+
removed.push(cid);
|
|
539
643
|
}
|
|
540
644
|
}
|
|
541
645
|
if removed.is_empty() {
|
|
@@ -577,6 +681,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
577
681
|
method!(RbDoc::encode_state_as_update, -1),
|
|
578
682
|
)?;
|
|
579
683
|
doc_class.define_method("apply_update", method!(RbDoc::apply_update, 1))?;
|
|
684
|
+
doc_class.define_method("update_ready?", method!(RbDoc::update_ready, 1))?;
|
|
685
|
+
doc_class.define_method("pending?", method!(RbDoc::pending, 0))?;
|
|
580
686
|
doc_class.define_method("sync_step1", method!(RbDoc::sync_step1, 0))?;
|
|
581
687
|
doc_class.define_method("sync_step2", method!(RbDoc::sync_step2, 1))?;
|
|
582
688
|
doc_class.define_method(
|
|
@@ -606,6 +712,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
606
712
|
method!(RbAwareness::encode_state_as_update, -1),
|
|
607
713
|
)?;
|
|
608
714
|
awareness_class.define_method("apply_update", method!(RbAwareness::apply_update, 1))?;
|
|
715
|
+
awareness_class.define_method("update_ready?", method!(RbAwareness::update_ready, 1))?;
|
|
716
|
+
awareness_class.define_method("pending?", method!(RbAwareness::pending, 0))?;
|
|
609
717
|
awareness_class.define_method("set_local_state", method!(RbAwareness::set_local_state, 1))?;
|
|
610
718
|
awareness_class.define_method("local_state", method!(RbAwareness::local_state, 0))?;
|
|
611
719
|
awareness_class.define_method(
|
|
@@ -668,7 +776,7 @@ mod tests {
|
|
|
668
776
|
}
|
|
669
777
|
|
|
670
778
|
fn awareness_frame(client_id: u64) -> Vec<u8> {
|
|
671
|
-
let awareness = Awareness::new(Doc::with_client_id(client_id));
|
|
779
|
+
let mut awareness = Awareness::new(Doc::with_client_id(client_id));
|
|
672
780
|
awareness
|
|
673
781
|
.set_local_state(serde_json::json!({ "user": "alice" }))
|
|
674
782
|
.unwrap();
|
|
@@ -749,4 +857,50 @@ mod tests {
|
|
|
749
857
|
.unwrap()
|
|
750
858
|
.is_empty());
|
|
751
859
|
}
|
|
860
|
+
|
|
861
|
+
#[test]
|
|
862
|
+
fn update_readiness_and_pending_detect_a_causal_gap() {
|
|
863
|
+
// Three sequential single-char inserts from one client: A, then B, then
|
|
864
|
+
// C. Each delta depends on the previous, so C can't integrate without B.
|
|
865
|
+
let src = Doc::new();
|
|
866
|
+
let txt = src.get_or_insert_text("t");
|
|
867
|
+
let mut deltas: Vec<Vec<u8>> = Vec::new();
|
|
868
|
+
let mut prev = yrs::StateVector::default();
|
|
869
|
+
for (i, ch) in ["A", "B", "C"].into_iter().enumerate() {
|
|
870
|
+
txt.insert(&mut src.transact_mut(), i as u32, ch);
|
|
871
|
+
deltas.push(src.transact().encode_state_as_update_v1(&prev));
|
|
872
|
+
prev = src.transact().state_vector();
|
|
873
|
+
}
|
|
874
|
+
let (u1, u2, u3) = (&deltas[0], &deltas[1], &deltas[2]);
|
|
875
|
+
|
|
876
|
+
// A doc holding only u1 (u2 was lost in transit / its record failed):
|
|
877
|
+
let doc = Doc::new();
|
|
878
|
+
doc.transact_mut()
|
|
879
|
+
.apply_update(yrs::Update::decode_v1(u1).unwrap())
|
|
880
|
+
.unwrap();
|
|
881
|
+
assert!(update_is_ready(&doc, u1).unwrap(), "u1 has no missing deps");
|
|
882
|
+
assert!(
|
|
883
|
+
!update_is_ready(&doc, u3).unwrap(),
|
|
884
|
+
"u3 depends on the missing u2"
|
|
885
|
+
);
|
|
886
|
+
assert!(
|
|
887
|
+
!doc_has_pending(&doc),
|
|
888
|
+
"nothing pending until u3 is applied"
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
// Applying u3 anyway parks it as a pending struct.
|
|
892
|
+
doc.transact_mut()
|
|
893
|
+
.apply_update(yrs::Update::decode_v1(u3).unwrap())
|
|
894
|
+
.unwrap();
|
|
895
|
+
assert!(
|
|
896
|
+
doc_has_pending(&doc),
|
|
897
|
+
"u3 is pending: its parent u2 is missing"
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
// Once u2 arrives (via resync), u3 integrates and pending clears.
|
|
901
|
+
doc.transact_mut()
|
|
902
|
+
.apply_update(yrs::Update::decode_v1(u2).unwrap())
|
|
903
|
+
.unwrap();
|
|
904
|
+
assert!(!doc_has_pending(&doc), "u2 arrived; u3 integrated");
|
|
905
|
+
}
|
|
752
906
|
}
|
data/lib/yrb_lite/sync.rb
CHANGED
|
@@ -128,6 +128,13 @@ module YrbLite
|
|
|
128
128
|
# If an `on_change` recorder is registered, document changes take the
|
|
129
129
|
# strict authoritative path (record -> apply -> broadcast, serialized per
|
|
130
130
|
# document); otherwise the fast path is used.
|
|
131
|
+
#
|
|
132
|
+
# Reliable delivery (opt-in, client-driven): if the frame carries an "id",
|
|
133
|
+
# the server replies `{ "ack" => id }` once the update has been accepted
|
|
134
|
+
# (recorded in audit mode, applied in fast mode). A causally-gapped update
|
|
135
|
+
# is not acked -- it gets a resync instead -- so an ack-aware client knows
|
|
136
|
+
# to retransmit until the update lands. Stock clients send no "id", never
|
|
137
|
+
# get acks, and are completely unaffected.
|
|
131
138
|
def sync_receive(data, key = nil)
|
|
132
139
|
# Pass `key` (params[:id]) when your transport doesn't keep the channel
|
|
133
140
|
# instance alive across actions. Under AnyCable each RPC command gets a
|
|
@@ -139,13 +146,23 @@ module YrbLite
|
|
|
139
146
|
m = data.is_a?(Hash) ? (data["m"] || data["update"]) : nil
|
|
140
147
|
return unless m.is_a?(String)
|
|
141
148
|
|
|
149
|
+
# Optional client-supplied id for reliable delivery (see sync_send_ack).
|
|
150
|
+
id = data.is_a?(Hash) ? data["id"] : nil
|
|
151
|
+
|
|
142
152
|
begin
|
|
143
153
|
bytes = Base64.strict_decode64(m)
|
|
144
154
|
rescue ArgumentError
|
|
145
155
|
return # not valid base64; ignore the frame and keep the connection
|
|
146
156
|
end
|
|
147
157
|
|
|
148
|
-
|
|
158
|
+
sync_send_ack(id, sync_dispatch(m, bytes))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Route a decoded frame to the backend/path that handles it and return the
|
|
162
|
+
# outcome symbol (:recorded/:applied/:gap/:noop) used by the reliable-
|
|
163
|
+
# delivery ack. A dropped frame returns nil (never acked).
|
|
164
|
+
def sync_dispatch(encoded, bytes)
|
|
165
|
+
return sync_receive_store_backed(encoded, bytes) if self.class.sync_backend == :store
|
|
149
166
|
|
|
150
167
|
awareness = sync_awareness
|
|
151
168
|
kind = awareness.message_kind(bytes)
|
|
@@ -156,9 +173,9 @@ module YrbLite
|
|
|
156
173
|
sync_track_clients(awareness, bytes) if kind == MSG_KIND_AWARENESS
|
|
157
174
|
|
|
158
175
|
if kind == MSG_KIND_UPDATE && self.class.on_change
|
|
159
|
-
sync_apply_authoritative(awareness,
|
|
176
|
+
sync_apply_authoritative(awareness, encoded, bytes)
|
|
160
177
|
else
|
|
161
|
-
sync_apply_fast(awareness,
|
|
178
|
+
sync_apply_fast(awareness, encoded, bytes, kind)
|
|
162
179
|
end
|
|
163
180
|
end
|
|
164
181
|
|
|
@@ -207,14 +224,37 @@ module YrbLite
|
|
|
207
224
|
# Document changes (SyncStep2, Update) and awareness get relayed; requests
|
|
208
225
|
# (SyncStep1, awareness-query) are answered above and not relayed. An
|
|
209
226
|
# optional on_save snapshot is taken after a document change.
|
|
227
|
+
#
|
|
228
|
+
# Returns an outcome symbol for the reliable-delivery ack: :applied when a
|
|
229
|
+
# document update was integrated and relayed, :gap when it was rejected for
|
|
230
|
+
# a resync, :noop for everything else (requests, awareness, empty updates).
|
|
210
231
|
def sync_apply_fast(awareness, encoded, bytes, kind)
|
|
232
|
+
# A document update that isn't causally ready (an earlier one was lost in
|
|
233
|
+
# transit) would relay an un-integrable change to peers and stall the
|
|
234
|
+
# replica. Drop it and ask the client to resync instead, which re-delivers
|
|
235
|
+
# the missing piece. See sync_apply_authoritative for the durable variant.
|
|
236
|
+
if kind == MSG_KIND_UPDATE
|
|
237
|
+
update = awareness.update_from_message(bytes)
|
|
238
|
+
# A no-op message (e.g. the empty SyncStep2 in an opening handshake)
|
|
239
|
+
# carries no change, so there's nothing to relay, persist, or ack.
|
|
240
|
+
return :noop unless update
|
|
241
|
+
|
|
242
|
+
unless awareness.update_ready?(update)
|
|
243
|
+
sync_request_resync(awareness)
|
|
244
|
+
return :gap
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
211
248
|
response = awareness.handle(bytes)
|
|
212
249
|
sync_transmit(response) unless response.empty?
|
|
213
250
|
|
|
214
|
-
return unless [MSG_KIND_UPDATE, MSG_KIND_AWARENESS].include?(kind)
|
|
251
|
+
return :noop unless [MSG_KIND_UPDATE, MSG_KIND_AWARENESS].include?(kind)
|
|
215
252
|
|
|
216
253
|
sync_distribute(encoded)
|
|
217
|
-
|
|
254
|
+
return :noop unless kind == MSG_KIND_UPDATE
|
|
255
|
+
|
|
256
|
+
sync_persist
|
|
257
|
+
:applied
|
|
218
258
|
end
|
|
219
259
|
|
|
220
260
|
# Authoritative path: record the change durably, then apply it to the
|
|
@@ -224,22 +264,64 @@ module YrbLite
|
|
|
224
264
|
# before it has been recorded. If the recorder raises, the change is
|
|
225
265
|
# rejected (not applied, not broadcast) and the exception propagates, so the
|
|
226
266
|
# channel can surface it and the client can resync.
|
|
267
|
+
#
|
|
268
|
+
# Before recording, the update must be causally ready: every dependency it
|
|
269
|
+
# references must already be in the doc. If an earlier update was lost in
|
|
270
|
+
# transit, or its record failed, a later update arrives with a gap. Recording
|
|
271
|
+
# it would write a permanently-pending entry to the log -- one that can never
|
|
272
|
+
# be replayed until the missing update shows up. Such an update is rejected
|
|
273
|
+
# (not recorded, not applied, not relayed) and the client is asked to resync,
|
|
274
|
+
# which re-delivers the missing range as one causally-complete delta.
|
|
227
275
|
def sync_apply_authoritative(awareness, encoded, bytes)
|
|
228
276
|
recorder = self.class.on_change
|
|
229
277
|
|
|
230
|
-
|
|
278
|
+
outcome = Sync.lock_for(@sync_key).synchronize do
|
|
231
279
|
update = awareness.update_from_message(bytes)
|
|
232
280
|
# A no-op message (e.g. the empty SyncStep2 in a client's opening
|
|
233
281
|
# handshake) carries no change, so there's nothing to record or relay.
|
|
234
|
-
next
|
|
282
|
+
next :noop unless update
|
|
283
|
+
next :gap unless awareness.update_ready?(update)
|
|
235
284
|
|
|
236
285
|
recorder.call(@sync_key, update) # durable write; raise to reject
|
|
237
286
|
awareness.apply_update(update) # only recorded changes reach the doc
|
|
238
287
|
sync_distribute(encoded) # ...and only then the wire
|
|
239
|
-
|
|
288
|
+
:recorded
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
case outcome
|
|
292
|
+
when :recorded then sync_persist
|
|
293
|
+
when :gap then sync_request_resync(awareness)
|
|
240
294
|
end
|
|
241
295
|
|
|
242
|
-
|
|
296
|
+
# Surface the outcome for the reliable-delivery ack: :recorded means the
|
|
297
|
+
# update is durably written (and will be acked); :gap triggered a resync
|
|
298
|
+
# (no ack); :noop carried no change.
|
|
299
|
+
outcome
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Ask this connection's client to resync: re-send SyncStep1 carrying the
|
|
303
|
+
# server's current (gap-free) state vector. The client replies SyncStep2
|
|
304
|
+
# with everything the server is missing, delivered as one causally-complete
|
|
305
|
+
# delta -- which heals the gap that triggered the resync.
|
|
306
|
+
def sync_request_resync(awareness)
|
|
307
|
+
sync_transmit(awareness.sync_step1)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Reliable delivery: acknowledge an accepted update back to the sending
|
|
311
|
+
# connection. An ack-aware client tags each outgoing update with an "id"
|
|
312
|
+
# and retains it until the matching `{ "ack" => id }` returns, retransmitting
|
|
313
|
+
# on a timer or reconnect; idempotent CRDT apply makes resends free. We ack
|
|
314
|
+
# only when the client supplied an id (so stock clients are unaffected) and
|
|
315
|
+
# the update was actually accepted -- recorded in audit mode, applied in fast
|
|
316
|
+
# mode. A gapped update gets no ack (it got a resync), so the client keeps
|
|
317
|
+
# retransmitting until the missing range lands and the update can integrate.
|
|
318
|
+
def sync_send_ack(id, outcome)
|
|
319
|
+
return if id.nil?
|
|
320
|
+
return unless %i[recorded applied].include?(outcome)
|
|
321
|
+
|
|
322
|
+
# Braces are load-bearing: a bare hash would bind to transmit's `via:`
|
|
323
|
+
# keyword instead of its positional data argument.
|
|
324
|
+
transmit({ "ack" => id })
|
|
243
325
|
end
|
|
244
326
|
|
|
245
327
|
# Single broadcast point for both paths (and presence removal), so the
|
|
@@ -314,19 +396,40 @@ module YrbLite
|
|
|
314
396
|
# changes are recorded durably before relay and then broadcast, and
|
|
315
397
|
# awareness is relayed best-effort. Echoing back to the sender is harmless,
|
|
316
398
|
# since the CRDT apply is idempotent.
|
|
399
|
+
#
|
|
400
|
+
# Returns an outcome symbol for the reliable-delivery ack: :recorded when a
|
|
401
|
+
# document update was durably recorded and relayed, :gap when it was
|
|
402
|
+
# rejected for a resync, :noop for everything else.
|
|
317
403
|
def sync_receive_store_backed(encoded, bytes)
|
|
318
404
|
case Sync.codec.message_kind(bytes)
|
|
319
405
|
when MSG_KIND_SYNC_STEP1
|
|
320
406
|
result = sync_load_doc.handle_sync_message(bytes)
|
|
321
407
|
sync_transmit(result[2]) if result
|
|
408
|
+
:noop
|
|
322
409
|
when MSG_KIND_UPDATE
|
|
323
410
|
update = Sync.codec.update_from_message(bytes)
|
|
324
|
-
return unless update
|
|
411
|
+
return :noop unless update
|
|
412
|
+
|
|
413
|
+
# Store mode keeps no warm replica, so to tell whether this update is
|
|
414
|
+
# causally ready we rebuild the doc from the store and check against it.
|
|
415
|
+
# That's an O(history) load per update (mitigated by snapshotting the
|
|
416
|
+
# store on the load path). A gappy update -- an earlier one was lost or
|
|
417
|
+
# its record failed -- is rejected and the client asked to resync,
|
|
418
|
+
# rather than written to the log as a permanently-pending entry.
|
|
419
|
+
doc = sync_load_doc
|
|
420
|
+
unless doc.update_ready?(update)
|
|
421
|
+
sync_transmit(doc.sync_step1)
|
|
422
|
+
return :gap
|
|
423
|
+
end
|
|
325
424
|
|
|
326
425
|
self.class.on_change&.call(@sync_key, update) # record before relay
|
|
327
426
|
sync_distribute(encoded)
|
|
427
|
+
:recorded
|
|
328
428
|
when MSG_KIND_AWARENESS
|
|
329
429
|
sync_distribute(encoded)
|
|
430
|
+
:noop
|
|
431
|
+
else
|
|
432
|
+
:noop
|
|
330
433
|
end
|
|
331
434
|
end
|
|
332
435
|
|
data/lib/yrb_lite/version.rb
CHANGED