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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73c6ed02c103b7647be2d6052ff660490088bff3e1dba3a82105f8fb42ffecab
4
- data.tar.gz: c308cb9c4e426992b1cbc31b2a870dc3ab3863c3a0217949b7744c4bc4c48d91
3
+ metadata.gz: fdc68c7b4936a2e6598441f2def20050e456a543f44de6ecd35aeb1a894cdcc7
4
+ data.tar.gz: 5a5ffbf2c1df1fc41d7eba424579459587d46e1e38db0606b79efefb5d5321c0
5
5
  SHA512:
6
- metadata.gz: 8eb77739b34c860f961a850a70ed39f778f0711d83fda715c8447af1c7d81625472697142ce5aaf8b8a339c408992a6ada2d8a176cf03b1145afc62bd5f7aca5
7
- data.tar.gz: 8886214b02d90bcbe44958deaa0b1b73bd0aa4707f185f34868f5444e99ca8e4ed02dce4100486543fb7dec82d609996de6f90f81acf12fbf1217466115ba985
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/commits/main
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
@@ -12,7 +12,7 @@ crate-type = ["cdylib"]
12
12
  [dependencies]
13
13
  magnus = "0.8"
14
14
  rb-sys = "0.9"
15
- yrs = { version = "0.21", features = ["sync"] }
15
+ yrs = { version = "0.27", features = ["sync"] }
16
16
  serde_json = "1.0"
17
17
 
18
18
  [dev-dependencies]
@@ -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 yrs::{Doc, ReadTxn, Transact};
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: `yrs::sync::Awareness` keeps client states in a `DashMap`
22
- /// and exposes everything through `&self`, so it's built for multi-threaded
23
- /// server use.
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/Sync, this fails the build
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` are fine: both types are `Sync` (asserted above).
46
- /// - Any doc lock it takes must be acquired and released inside the closure, so
47
- /// we never reacquire the GVL while holding a yrs lock and can't deadlock on
48
- /// lock order.
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
- if update.state_vector().is_empty() && update.delete_set().is_empty() {
157
- return Ok(None); // no-op (e.g. the empty SyncStep2 in an opening handshake)
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().copied());
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.doc().client_id()
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.doc().guid().to_string()
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 txn = awareness.doc().transact();
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 txn = awareness.doc().transact();
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 txn = awareness.doc().transact();
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
- .set_local_state(value)
450
- .map_err(|e| runtime_error(e.to_string()))?;
451
- Ok(())
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
- awareness
458
- .local_state::<serde_json::Value>()
459
- .map(|v| v.to_string())
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 update = awareness
472
- .update()
473
- .map_err(|e| runtime_error(e.to_string()))?;
474
- let msg = Message::Awareness(update);
475
- Ok(binary_string(&msg.encode_v1()))
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 mut txn = awareness.doc().transact_mut();
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
- if awareness.meta(id).is_some() {
537
- awareness.remove_state(id);
538
- removed.push(id);
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
- return sync_receive_store_backed(m, bytes) if self.class.sync_backend == :store
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, m, bytes)
176
+ sync_apply_authoritative(awareness, encoded, bytes)
160
177
  else
161
- sync_apply_fast(awareness, m, bytes, kind)
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
- sync_persist if kind == MSG_KIND_UPDATE
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
- modified = Sync.lock_for(@sync_key).synchronize do
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 false unless update
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
- true
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
- sync_persist if modified
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YrbLite
4
- VERSION = "0.1.0.beta1"
4
+ VERSION = "0.1.0.beta3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrb-lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.beta1
4
+ version: 0.1.0.beta3
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara