yrby-actioncable 0.3.0 → 0.3.1
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-actioncable.md +16 -30
- data/README.md +0 -15
- data/lib/y/action_cable/sync.rb +29 -166
- data/lib/y/action_cable/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: 827ae0b501facdd048905f0f61de31f54386cc9a22633b33abb4f2214b715329
|
|
4
|
+
data.tar.gz: aa6e35a4476d57b59f2d837723804e3f752b287ab2885af579497ae43650eb20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bcaf23e4c38a919d41176fd3e21ff52f519964913b6cf19c927e5954955f4923d2641e80a636e7fa9077f53f6c0330d98df66420f8bd800ba6b6ec3997a35efd
|
|
7
|
+
data.tar.gz: 374dabb00df41e74e0d9060a2914c3c66355a702e448175a9c315628dd4030838c288ea6fcf5b3185b6999e57a31a61f52366867ef838a82735e87a7ce611261
|
data/CHANGELOG-actioncable.md
CHANGED
|
@@ -6,40 +6,26 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.1] - 2026-07-01
|
|
10
|
+
|
|
11
|
+
### Removed
|
|
12
|
+
|
|
13
|
+
- The unhealable-gap strike defense that shipped in 0.3.0. That release was
|
|
14
|
+
published prematurely, before the feature had been reviewed; 0.3.1 supersedes
|
|
15
|
+
it with the defense removed while review happens. 0.3.0 remains installable
|
|
16
|
+
and functional; the feature returns in a future release once reviewed.
|
|
17
|
+
|
|
9
18
|
## [0.3.0] - 2026-07-01
|
|
10
19
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
good (a permanently-orphaned pending struct) — and resyncing it endlessly just
|
|
17
|
-
feeds the client's retransmit loop (the "id-less frames several times a
|
|
18
|
-
second" failure mode). A *healable* gap heals within a resync or two, well
|
|
19
|
-
under the limit, so this only trips on genuinely-dead updates.
|
|
20
|
-
|
|
21
|
-
- The settle ack carries `"dropped" => true` so an aware client can tell
|
|
22
|
-
"durably recorded" from "abandoned" and surface the loss instead of
|
|
23
|
-
silently reporting synced (`yrby-client` raises it via
|
|
24
|
-
`onError(..., "ack-dropped")`; older clients ignore the extra key).
|
|
25
|
-
- Configurable via `gap_strike_limit` (default `3`; `nil` disables — always
|
|
26
|
-
resync). Limits below `2` raise `ArgumentError`: strike 1 must send a
|
|
27
|
-
resync before any drop can be justified.
|
|
28
|
-
- Strikes are tracked per update (SHA-256) per connection, guarded by a
|
|
29
|
-
mutex (ActionCable dispatches a connection's messages to a worker pool).
|
|
30
|
-
The table is bounded; at capacity a single lowest-count entry is evicted,
|
|
31
|
-
and only when inserting a new key — a client cycling distinct gaps can't
|
|
32
|
-
reset a tracked key's count. A gap that finally records frees its slot.
|
|
33
|
-
- Under AnyCable — where every RPC command gets a fresh channel instance —
|
|
34
|
-
the table is persisted via anycable-rails' `state_attr_accessor` (istate,
|
|
35
|
-
round-tripped through anycable-go), declared automatically when
|
|
36
|
-
anycable-rails is loaded. Verified end-to-end on both stacks
|
|
37
|
-
(`frontend/gap_strike.mjs` in the demo).
|
|
20
|
+
Published prematurely (see 0.3.1): shipped the unhealable-gap strike defense
|
|
21
|
+
(settle + drop a repeatedly-gapped update, `{ "ack" => id, "dropped" => true }`,
|
|
22
|
+
`gap_strike_limit`, istate-backed strikes under AnyCable) alongside the fixes
|
|
23
|
+
below. The fixes carry forward; the defense was withdrawn in 0.3.1 pending
|
|
24
|
+
review.
|
|
38
25
|
|
|
39
|
-
|
|
26
|
+
Fixes from a full source review:
|
|
40
27
|
|
|
41
|
-
|
|
42
|
-
changes ship here):
|
|
28
|
+
### Fixed
|
|
43
29
|
|
|
44
30
|
- **A lost-ack retry now re-broadcasts.** If the original attempt recorded the
|
|
45
31
|
update and then crashed (or the pub/sub broadcast failed) before
|
data/README.md
CHANGED
|
@@ -242,21 +242,6 @@ servers:
|
|
|
242
242
|
causal dependencies are already in the store (checked against `on_load`); a
|
|
243
243
|
causally-incomplete update triggers a resync instead, so the log always
|
|
244
244
|
rebuilds cleanly.
|
|
245
|
-
- **An unhealable gap is dropped, not resynced forever.** A resync heals a gap
|
|
246
|
-
whose missing dependency is still in flight. But a *permanently*-orphaned update
|
|
247
|
-
(its dependency is gone for good) stays gappy through every resync, and a client
|
|
248
|
-
retransmitting it would loop endlessly (server resyncs → client resends →
|
|
249
|
-
repeat). After `gap_strike_limit` rejections of the same update on one
|
|
250
|
-
connection (default 3, minimum 2), the channel settles it with
|
|
251
|
-
`{ "ack" => id, "dropped" => true }` and drops it instead of resyncing again —
|
|
252
|
-
breaking the loop while never dropping a *healable* gap (those heal within a
|
|
253
|
-
resync or two, and healing frees the strike). The `dropped` flag lets the
|
|
254
|
-
client surface the loss (`yrby-client` reports it via `onError`) instead of
|
|
255
|
-
silently showing synced. Set `gap_strike_limit nil` to disable. Works on both
|
|
256
|
-
transports: plain ActionCable keeps strikes on the channel instance; under
|
|
257
|
-
AnyCable (fresh instance per RPC command) they persist through
|
|
258
|
-
anycable-rails' `state_attr_accessor` (istate), declared automatically when
|
|
259
|
-
anycable-rails is loaded.
|
|
260
245
|
- **`on_change` is at-least-once, and the durable guarantee is that replaying the
|
|
261
246
|
log reconstructs the document.** Every update triggers `on_change` before it's acked or
|
|
262
247
|
broadcast (record-before-distribute). If exactly-once updates matter for you, **you
|
data/lib/y/action_cable/sync.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "y"
|
|
4
4
|
require "base64"
|
|
5
|
-
require "digest"
|
|
6
5
|
|
|
7
6
|
module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
8
7
|
# y-websocket protocol over ActionCable.
|
|
@@ -52,31 +51,8 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
52
51
|
# allocation/parse cost. Override per channel with `max_frame_bytes`.
|
|
53
52
|
DEFAULT_MAX_FRAME_BYTES = 8 * 1024 * 1024
|
|
54
53
|
|
|
55
|
-
# After this many times the *same* update is rejected as a causal gap on one
|
|
56
|
-
# connection, stop resyncing and instead settle it (ack) + drop it. A gap
|
|
57
|
-
# that survives repeated resyncs is unhealable — its missing dependency is
|
|
58
|
-
# gone for good (a permanently-orphaned pending struct) — and resyncing it
|
|
59
|
-
# forever just amplifies the client's retransmit loop. A healable gap heals
|
|
60
|
-
# within a resync or two, well under this. Override with `gap_strike_limit`;
|
|
61
|
-
# set to nil to disable (always resync). See `sync_gap_strike`.
|
|
62
|
-
DEFAULT_GAP_STRIKE_LIMIT = 3
|
|
63
|
-
|
|
64
|
-
# Cap on distinct gappy updates tracked per connection, so a client can't
|
|
65
|
-
# grow the strike table without bound by sending endless distinct gaps.
|
|
66
|
-
GAP_STRIKE_MAX_KEYS = 64
|
|
67
|
-
|
|
68
54
|
def self.included(base)
|
|
69
55
|
base.extend(ClassMethods)
|
|
70
|
-
# Durable strike state under AnyCable. Each AnyCable RPC command gets a
|
|
71
|
-
# fresh channel instance, so a plain ivar resets every message and the
|
|
72
|
-
# unhealable-gap drop would never trip. anycable-rails' state_attr_accessor
|
|
73
|
-
# persists the value into the subscription's istate, which anycable-go
|
|
74
|
-
# round-trips on every command — so strikes accumulate there too. On plain
|
|
75
|
-
# ActionCable (anycable-rails loaded but not serving) the accessor behaves
|
|
76
|
-
# like attr_accessor; without anycable-rails we fall back to an ivar.
|
|
77
|
-
# NOTE: anycable-rails must be loaded before the channel class is defined
|
|
78
|
-
# for this declaration to take effect (standard Bundler.require order).
|
|
79
|
-
base.state_attr_accessor :yrby_gap_strikes if base.respond_to?(:state_attr_accessor)
|
|
80
56
|
end
|
|
81
57
|
|
|
82
58
|
module ClassMethods
|
|
@@ -115,24 +91,6 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
115
91
|
|
|
116
92
|
superclass.respond_to?(:max_frame_bytes) ? superclass.max_frame_bytes : DEFAULT_MAX_FRAME_BYTES
|
|
117
93
|
end
|
|
118
|
-
|
|
119
|
-
# Number of times the same update may be rejected as a gap on one
|
|
120
|
-
# connection before it is settled + dropped instead of resynced again.
|
|
121
|
-
# Defaults to DEFAULT_GAP_STRIKE_LIMIT; set to nil to always resync.
|
|
122
|
-
# A limit below 2 is rejected: strike 1 must send a resync (the heal
|
|
123
|
-
# attempt) before any drop, or a first-sight healable gap would be
|
|
124
|
-
# settled with no recovery ever attempted.
|
|
125
|
-
def gap_strike_limit(limit = :__unset__)
|
|
126
|
-
# Combined reader/writer; the sentinel keeps nil a real value (disables the drop).
|
|
127
|
-
unless limit == :__unset__
|
|
128
|
-
raise ArgumentError, "gap_strike_limit must be nil or >= 2 (got #{limit.inspect})" if limit && limit < 2
|
|
129
|
-
|
|
130
|
-
@gap_strike_limit = limit
|
|
131
|
-
end
|
|
132
|
-
return @gap_strike_limit if defined?(@gap_strike_limit)
|
|
133
|
-
|
|
134
|
-
superclass.respond_to?(:gap_strike_limit) ? superclass.gap_strike_limit : DEFAULT_GAP_STRIKE_LIMIT
|
|
135
|
-
end
|
|
136
94
|
end
|
|
137
95
|
|
|
138
96
|
# Call from `subscribed`. Streams broadcasts for this document and
|
|
@@ -214,87 +172,11 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
214
172
|
# is already present in the durable store.
|
|
215
173
|
def sync_send_ack(id, outcome)
|
|
216
174
|
return if id.nil?
|
|
217
|
-
|
|
218
|
-
# stops retransmitting it (it was never going to integrate anywhere). The
|
|
219
|
-
# envelope carries "dropped" so the client can tell "durably recorded"
|
|
220
|
-
# from "abandoned" and surface it, instead of silently reporting synced
|
|
221
|
-
# over lost data. Clients that don't know the key ignore it.
|
|
222
|
-
return unless %i[recorded applied dropped_unhealable].include?(outcome)
|
|
175
|
+
return unless %i[recorded applied].include?(outcome)
|
|
223
176
|
|
|
224
177
|
# The braces are required: a bare hash would bind to transmit's `via:`
|
|
225
178
|
# keyword instead of its positional data argument.
|
|
226
|
-
|
|
227
|
-
transmit({ "ack" => id, "dropped" => true })
|
|
228
|
-
else
|
|
229
|
-
transmit({ "ack" => id })
|
|
230
|
-
end
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
# Count consecutive gap rejections of `update` on this connection, returning
|
|
234
|
-
# the new count. Keyed by update content (SHA-256) so independent gaps track
|
|
235
|
-
# separately — a slow-to-heal legit gap must not push an unrelated one toward
|
|
236
|
-
# the drop.
|
|
237
|
-
#
|
|
238
|
-
# Where the state lives:
|
|
239
|
-
# - Plain ActionCable reuses the channel instance across a connection's
|
|
240
|
-
# messages, so an ivar accumulates (guarded by a mutex: ActionCable
|
|
241
|
-
# dispatches messages to a worker pool, so two receives on one instance
|
|
242
|
-
# can run concurrently).
|
|
243
|
-
# - Under AnyCable each RPC command gets a fresh instance, so the table is
|
|
244
|
-
# persisted via anycable-rails' `state_attr_accessor` (istate), which
|
|
245
|
-
# anycable-go round-trips on every command. See `self.included`.
|
|
246
|
-
def sync_gap_strike(update)
|
|
247
|
-
key = Digest::SHA256.hexdigest(update)
|
|
248
|
-
sync_gap_strike_mutex.synchronize do
|
|
249
|
-
strikes = sync_read_gap_strikes
|
|
250
|
-
# Bound the table so endless *distinct* gaps can't grow it without
|
|
251
|
-
# limit. Evict a single lowest-count entry, and only when inserting a
|
|
252
|
-
# genuinely new key: clearing wholesale would let a client cycling 64
|
|
253
|
-
# distinct gaps reset every tracked strike (defense bypass) and would
|
|
254
|
-
# starve a legitimate hot key.
|
|
255
|
-
if !strikes.key?(key) && strikes.size >= GAP_STRIKE_MAX_KEYS
|
|
256
|
-
strikes.delete(strikes.min_by { |_, count| count }.first)
|
|
257
|
-
end
|
|
258
|
-
strikes[key] = strikes.fetch(key, 0) + 1
|
|
259
|
-
sync_write_gap_strikes(strikes)
|
|
260
|
-
strikes[key]
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
# A gap that finally records has healed: free its strike slot so it can't
|
|
265
|
-
# bias a future eviction and the table reflects only live gaps.
|
|
266
|
-
def sync_clear_gap_strike(update)
|
|
267
|
-
sync_gap_strike_mutex.synchronize do
|
|
268
|
-
strikes = sync_read_gap_strikes
|
|
269
|
-
next_key = Digest::SHA256.hexdigest(update)
|
|
270
|
-
if strikes.key?(next_key)
|
|
271
|
-
strikes.delete(next_key)
|
|
272
|
-
sync_write_gap_strikes(strikes)
|
|
273
|
-
end
|
|
274
|
-
end
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def sync_gap_strike_mutex
|
|
278
|
-
@sync_gap_strike_mutex ||= Mutex.new
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
# Strike-table storage: istate-backed under AnyCable (survives the
|
|
282
|
-
# per-command fresh instance), plain ivar otherwise. Values round-trip JSON
|
|
283
|
-
# under AnyCable, so the table is a plain Hash of hex-digest => Integer.
|
|
284
|
-
def sync_read_gap_strikes
|
|
285
|
-
if respond_to?(:yrby_gap_strikes)
|
|
286
|
-
(yrby_gap_strikes || {}).to_h { |k, v| [k.to_s, v.to_i] }
|
|
287
|
-
else
|
|
288
|
-
@gap_strikes || {}
|
|
289
|
-
end
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
def sync_write_gap_strikes(strikes)
|
|
293
|
-
if respond_to?(:yrby_gap_strikes=)
|
|
294
|
-
self.yrby_gap_strikes = strikes
|
|
295
|
-
else
|
|
296
|
-
@gap_strikes = strikes
|
|
297
|
-
end
|
|
179
|
+
transmit({ "ack" => id })
|
|
298
180
|
end
|
|
299
181
|
|
|
300
182
|
# Single broadcast point so relay semantics live in one place and tests can
|
|
@@ -379,8 +261,7 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
379
261
|
#
|
|
380
262
|
# Returns an outcome symbol for the reliable-delivery ack: :recorded when a
|
|
381
263
|
# document update was durably recorded and relayed, :gap when it was
|
|
382
|
-
# rejected for a resync, :
|
|
383
|
-
# is settled + dropped, :noop for everything else.
|
|
264
|
+
# rejected for a resync, :noop for everything else.
|
|
384
265
|
def sync_handle_frame(encoded, bytes)
|
|
385
266
|
sync_validate_required_hooks!
|
|
386
267
|
sync_validate_key!
|
|
@@ -391,7 +272,32 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
391
272
|
sync_transmit(result[2])
|
|
392
273
|
:noop
|
|
393
274
|
when MSG_KIND_UPDATE
|
|
394
|
-
|
|
275
|
+
update = Y.update_from_message(bytes)
|
|
276
|
+
return :noop unless update
|
|
277
|
+
|
|
278
|
+
# Rebuild from the store (O(history) per update; snapshot in on_load if
|
|
279
|
+
# that cost bites).
|
|
280
|
+
doc = sync_load_doc
|
|
281
|
+
|
|
282
|
+
# Don't record a causally-incomplete update; resync instead so the gap
|
|
283
|
+
# heals as one complete delta.
|
|
284
|
+
unless doc.update_ready?(update)
|
|
285
|
+
sync_request_resync(doc)
|
|
286
|
+
return :gap
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# A lost-ack retry: already recorded, so skip on_change — but DO
|
|
290
|
+
# re-broadcast. If the first attempt died between record and broadcast,
|
|
291
|
+
# this retry is the only path left to the live subscribers. Duplicate
|
|
292
|
+
# broadcasts are free (CRDT apply is idempotent).
|
|
293
|
+
unless doc.update_advances?(update)
|
|
294
|
+
sync_distribute(encoded)
|
|
295
|
+
return :applied
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
sync_record_change(update) # record before relay
|
|
299
|
+
sync_distribute(encoded)
|
|
300
|
+
:recorded
|
|
395
301
|
when MSG_KIND_AWARENESS
|
|
396
302
|
sync_distribute(encoded)
|
|
397
303
|
:noop
|
|
@@ -400,49 +306,6 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
400
306
|
end
|
|
401
307
|
end
|
|
402
308
|
|
|
403
|
-
# The document-update arm of sync_handle_frame: gate on causal readiness
|
|
404
|
-
# (with the unhealable-gap strike defense), skip-but-rebroadcast retries,
|
|
405
|
-
# and record-before-distribute for genuinely new content.
|
|
406
|
-
def sync_handle_update(encoded, bytes)
|
|
407
|
-
update = Y.update_from_message(bytes)
|
|
408
|
-
return :noop unless update
|
|
409
|
-
|
|
410
|
-
# Rebuild from the store (O(history) per update; snapshot in on_load if
|
|
411
|
-
# that cost bites).
|
|
412
|
-
doc = sync_load_doc
|
|
413
|
-
|
|
414
|
-
# Don't record a causally-incomplete update; resync so the gap heals as
|
|
415
|
-
# one complete delta. But a gap that survives repeated resyncs is
|
|
416
|
-
# unhealable (its missing dependency is gone for good) — resyncing it
|
|
417
|
-
# forever just feeds the client's retransmit loop. After
|
|
418
|
-
# `gap_strike_limit` rejections of the same update, settle it (ack via
|
|
419
|
-
# the :dropped_unhealable outcome) and drop it instead.
|
|
420
|
-
unless doc.update_ready?(update)
|
|
421
|
-
limit = self.class.gap_strike_limit
|
|
422
|
-
if limit && sync_gap_strike(update) >= limit
|
|
423
|
-
sync_log_drop(:info, "dropping unhealable gappy update after #{limit} strikes")
|
|
424
|
-
return :dropped_unhealable
|
|
425
|
-
end
|
|
426
|
-
sync_request_resync(doc)
|
|
427
|
-
return :gap
|
|
428
|
-
end
|
|
429
|
-
|
|
430
|
-
# A lost-ack retry: already recorded, so skip on_change — but DO
|
|
431
|
-
# re-broadcast. If the first attempt died between record and broadcast,
|
|
432
|
-
# this retry is the only path left to the live subscribers. Duplicate
|
|
433
|
-
# broadcasts are free (CRDT apply is idempotent).
|
|
434
|
-
unless doc.update_advances?(update)
|
|
435
|
-
sync_clear_gap_strike(update) # a formerly-gappy update that healed
|
|
436
|
-
sync_distribute(encoded)
|
|
437
|
-
return :applied
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
sync_record_change(update) # record before relay
|
|
441
|
-
sync_clear_gap_strike(update) # a gap that finally recorded has healed
|
|
442
|
-
sync_distribute(encoded)
|
|
443
|
-
:recorded
|
|
444
|
-
end
|
|
445
|
-
|
|
446
309
|
# Build a fresh document from the durable store (on_load). Callers validate
|
|
447
310
|
# the hooks first, so on_load is present; a nil state means a fresh document.
|
|
448
311
|
def sync_load_doc
|