yrb-lite 0.1.0.beta1-aarch64-linux → 0.1.0.beta2-aarch64-linux
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 +31 -1
- data/README.md +50 -0
- data/lib/yrb_lite/3.4/yrb_lite.so +0 -0
- data/lib/yrb_lite/4.0/yrb_lite.so +0 -0
- data/lib/yrb_lite/sync.rb +113 -10
- data/lib/yrb_lite/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6af860531215edb4fdd223bdcf58532a1d402cf385dd5c4ec6249a4ad9d355aa
|
|
4
|
+
data.tar.gz: b2d188438e3bbf8ffbe89c53bb9e79bfe5fbe7bd181f1cbac5727c8c3b06582e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 01725e13976442ad4f41e45f169fca389a362756a1e12a865289d104f3c2c753f23b7befc4d816960a77118d3ca592b6d8b195166ccec950971a129fd3f330f1
|
|
7
|
+
data.tar.gz: 402e98762a194b1b69c8d2c5cb70996e499172def65ba99cf3922b9a24dcf2942981ac1ce2ad4f501f7795569f2a0322d4bb4f338ae865c16e837778a8040f23
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.0.beta2] - 2026-06-16
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Reliable delivery (opt-in, client-driven). A client may tag a document update
|
|
14
|
+
with an `"id"`; the server replies `{ "ack": <id> }` once the update has been
|
|
15
|
+
accepted (recorded in audit mode, applied in fast mode). This lets an
|
|
16
|
+
ack-aware client retain and retransmit an update until delivery is confirmed,
|
|
17
|
+
so an edit can't be silently lost on a flaky connection. Stock clients send no
|
|
18
|
+
`"id"`, never get acks, and behave exactly as before.
|
|
19
|
+
- A vendored, ack-aware `@y-rb/actioncable` provider in the demo
|
|
20
|
+
(`reliable_actioncable_provider.mjs`) that adds reliable delivery with
|
|
21
|
+
"sync-since-last-ack" framing (the unacknowledged tail is sent as one merged,
|
|
22
|
+
causally-complete delta), plus a minimal reference client and an intensive
|
|
23
|
+
message-loss stress test.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Causal-gap protection. The authoritative, fast, and store paths now reject a
|
|
28
|
+
document update that isn't causally ready -- one whose dependencies are
|
|
29
|
+
missing because an earlier update was lost in transit or its durable record
|
|
30
|
+
failed -- and ask the client to resync, instead of recording or relaying an
|
|
31
|
+
un-integrable update that would leave the log permanently pending. Adds native
|
|
32
|
+
`Doc#update_ready?`/`#pending?` (cheap, read-only checks) used to gate the
|
|
33
|
+
record-before-distribute path.
|
|
34
|
+
|
|
35
|
+
## [0.1.0.beta1]
|
|
36
|
+
|
|
9
37
|
### Added
|
|
10
38
|
|
|
11
39
|
- Thread-safe `YrbLite::Doc` and `YrbLite::Awareness` over `yrs` (magnus/rb-sys
|
|
@@ -26,4 +54,6 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
26
54
|
- Precompiled native gems for common platforms (no Rust toolchain needed to
|
|
27
55
|
install) via the cross-gem workflow.
|
|
28
56
|
|
|
29
|
-
[Unreleased]: https://github.com/jpcamara/yrb-lite/
|
|
57
|
+
[Unreleased]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta2...main
|
|
58
|
+
[0.1.0.beta2]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta1...v0.1.0.beta2
|
|
59
|
+
[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
|
|
Binary file
|
|
Binary file
|
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
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yrb-lite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.
|
|
4
|
+
version: 0.1.0.beta2
|
|
5
5
|
platform: aarch64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- JP Camara
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|