yrby-actioncable 0.2.3 → 0.3.0

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: 71d5dea23772a176b12bbe5cbfe75a2a8cbe3dfff8e8c73f909330ef9844084b
4
- data.tar.gz: 78f4d54167275c9e6e2fbade09e17e5c6d2cce7e7975e662ffa7e626fb03cdc1
3
+ metadata.gz: c93259f7cd51c197d4203903e5f8b522e526e164a858f17992c538e0a0b1f6f4
4
+ data.tar.gz: 46289b0f4544899a64a23c40f44a998667b399f1a9f0b6cbc1c9cbd63e7d613d
5
5
  SHA512:
6
- metadata.gz: b434c280c1574f2b34f3517c04b653efaf09ce1cdc279795356b6e2d23b6c68e3dcf92c5a7a42e74f59a43745ccacc305f8372fec143d038dbabf22b53a32a5c
7
- data.tar.gz: 790513cb6941bb5c9e6f6c3aaa1ce5496e1780fa093872ba867771a3bc3a51ed7b610ec0f291d69e102eee4ef5b8da0f5280cfe51e11b4162e6c97fc9a5879a6
6
+ metadata.gz: ab99efb5b5244556492419d8d646bd394f5e4f672729c1f6355571b0ab65c927122aa1833154150041fd550b0dc21a681a60f6bb5a952ac160fb05060e5609d5
7
+ data.tar.gz: 7d66edea6a44ba686d825da4d370d3e38be94a28c9df7aafea20ee9e506c31300d5011b94cb6d0fa2b62f0e3bfea2599984ec185503b96912fc037d58b8ae9cd
@@ -6,6 +6,60 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-07-01
10
+
11
+ ### Added
12
+ - **Unhealable-gap defense — on plain ActionCable AND AnyCable.** When the same
13
+ update is rejected as a causal gap repeatedly on one connection, the channel
14
+ now stops resyncing it forever and instead settles it and drops it. A gap that
15
+ survives repeated resyncs is unhealable — its missing dependency is gone for
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).
38
+
39
+ ### Fixed
40
+
41
+ Fixes from a full source review (a 0.2.4 was prepared but never published; its
42
+ changes ship here):
43
+
44
+ - **A lost-ack retry now re-broadcasts.** If the original attempt recorded the
45
+ update and then crashed (or the pub/sub broadcast failed) before
46
+ distributing, the retry was previously settled as `:applied` without
47
+ re-broadcasting — live subscribers stayed stale until their next full resync,
48
+ and nothing else could reach them. The retry now re-broadcasts before acking;
49
+ idempotent CRDT apply makes the duplicate free for every receiver.
50
+ - **A missing document key now fails closed.** Under a transport that doesn't
51
+ keep the channel instance alive across actions (AnyCable), an app that forgot
52
+ to pass `key` to `sync_receive` silently recorded updates under a nil key,
53
+ broadcast them to a stream no one subscribes to, and still acked them. The
54
+ frame now raises `Y::Error` instead.
55
+
56
+ ### Changed
57
+
58
+ - Raised the `yrby` floor to `>= 0.3.1`, whose `update_ready?` is exact
59
+ (trial-integration, not just per-client clocks). With an older core, a
60
+ cross-client-origin gap passed the ready check and the `update_advances?`
61
+ probe then acked-and-dropped real content.
62
+
9
63
  ## [0.2.3] - 2026-07-01
10
64
 
11
65
  ### Changed
data/README.md CHANGED
@@ -242,6 +242,21 @@ 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.
245
260
  - **`on_change` is at-least-once, and the durable guarantee is that replaying the
246
261
  log reconstructs the document.** Every update triggers `on_change` before it's acked or
247
262
  broadcast (record-before-distribute). If exactly-once updates matter for you, **you
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "y"
4
4
  require "base64"
5
+ require "digest"
5
6
 
6
7
  module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
7
8
  # y-websocket protocol over ActionCable.
@@ -51,8 +52,31 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
51
52
  # allocation/parse cost. Override per channel with `max_frame_bytes`.
52
53
  DEFAULT_MAX_FRAME_BYTES = 8 * 1024 * 1024
53
54
 
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
+
54
68
  def self.included(base)
55
69
  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)
56
80
  end
57
81
 
58
82
  module ClassMethods
@@ -91,6 +115,24 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
91
115
 
92
116
  superclass.respond_to?(:max_frame_bytes) ? superclass.max_frame_bytes : DEFAULT_MAX_FRAME_BYTES
93
117
  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
94
136
  end
95
137
 
96
138
  # Call from `subscribed`. Streams broadcasts for this document and
@@ -172,11 +214,87 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
172
214
  # is already present in the durable store.
173
215
  def sync_send_ack(id, outcome)
174
216
  return if id.nil?
175
- return unless %i[recorded applied].include?(outcome)
217
+ # :dropped_unhealable settles a permanently-orphaned retry so the client
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)
176
223
 
177
224
  # The braces are required: a bare hash would bind to transmit's `via:`
178
225
  # keyword instead of its positional data argument.
179
- transmit({ "ack" => id })
226
+ if outcome == :dropped_unhealable
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
180
298
  end
181
299
 
182
300
  # Single broadcast point so relay semantics live in one place and tests can
@@ -240,6 +358,20 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
240
358
  "that never happened, and a cold load would lose the edit."
241
359
  end
242
360
 
361
+ # Fail closed when no document key is set (typically: AnyCable rebuilt the
362
+ # channel instance and the app forgot to pass `key` to sync_receive).
363
+ # Proceeding would record under nil, broadcast to a stream nobody
364
+ # subscribes to, and still ack — the client believes the edit was
365
+ # delivered when it reached no one.
366
+ def sync_validate_key!
367
+ return unless @sync_key.nil? || @sync_key.empty?
368
+
369
+ raise Y::Error,
370
+ "Y::ActionCable::Sync has no document key. Call sync_subscribed(key) in " \
371
+ "subscribed, and pass the key to sync_receive(data, key) when the transport " \
372
+ "doesn't keep the channel instance alive across actions (e.g. AnyCable)."
373
+ end
374
+
243
375
  # Stateless per message: any process can handle any document. A client's
244
376
  # SyncStep1 is answered from the store, document changes are recorded durably
245
377
  # before relay and then broadcast, and awareness is relayed best-effort.
@@ -247,9 +379,11 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
247
379
  #
248
380
  # Returns an outcome symbol for the reliable-delivery ack: :recorded when a
249
381
  # document update was durably recorded and relayed, :gap when it was
250
- # rejected for a resync, :noop for everything else.
382
+ # rejected for a resync, :dropped_unhealable when a repeatedly-gapped update
383
+ # is settled + dropped, :noop for everything else.
251
384
  def sync_handle_frame(encoded, bytes)
252
385
  sync_validate_required_hooks!
386
+ sync_validate_key!
253
387
 
254
388
  case Y.message_kind(bytes)
255
389
  when MSG_KIND_SYNC_STEP1
@@ -257,27 +391,7 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
257
391
  sync_transmit(result[2])
258
392
  :noop
259
393
  when MSG_KIND_UPDATE
260
- update = Y.update_from_message(bytes)
261
- return :noop unless update
262
-
263
- # Rebuild from the store (O(history) per update; snapshot in on_load if
264
- # that cost bites).
265
- doc = sync_load_doc
266
-
267
- # Don't record a causally-incomplete update; resync instead so the gap
268
- # heals as one complete delta.
269
- unless doc.update_ready?(update)
270
- sync_request_resync(doc)
271
- return :gap
272
- end
273
-
274
- # Skip a lost-ack retry the store already has. Best-effort, not
275
- # cross-process exactly-once (see "Delivery guarantees" in the README).
276
- return :applied unless doc.update_advances?(update)
277
-
278
- sync_record_change(update) # record before relay
279
- sync_distribute(encoded)
280
- :recorded
394
+ sync_handle_update(encoded, bytes)
281
395
  when MSG_KIND_AWARENESS
282
396
  sync_distribute(encoded)
283
397
  :noop
@@ -286,6 +400,49 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
286
400
  end
287
401
  end
288
402
 
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
+
289
446
  # Build a fresh document from the durable store (on_load). Callers validate
290
447
  # the hooks first, so on_load is present; a nil state means a fresh document.
291
448
  def sync_load_doc
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Y
4
4
  module ActionCable
5
- VERSION = "0.2.3"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrby-actioncable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 0.3.0
32
+ version: 0.3.1
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 0.3.0
39
+ version: 0.3.1
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: actioncable
42
42
  requirement: !ruby/object:Gem::Requirement