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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c93259f7cd51c197d4203903e5f8b522e526e164a858f17992c538e0a0b1f6f4
4
- data.tar.gz: 46289b0f4544899a64a23c40f44a998667b399f1a9f0b6cbc1c9cbd63e7d613d
3
+ metadata.gz: 827ae0b501facdd048905f0f61de31f54386cc9a22633b33abb4f2214b715329
4
+ data.tar.gz: aa6e35a4476d57b59f2d837723804e3f752b287ab2885af579497ae43650eb20
5
5
  SHA512:
6
- metadata.gz: ab99efb5b5244556492419d8d646bd394f5e4f672729c1f6355571b0ab65c927122aa1833154150041fd550b0dc21a681a60f6bb5a952ac160fb05060e5609d5
7
- data.tar.gz: 7d66edea6a44ba686d825da4d370d3e38be94a28c9df7aafea20ee9e506c31300d5011b94cb6d0fa2b62f0e3bfea2599984ec185503b96912fc037d58b8ae9cd
6
+ metadata.gz: bcaf23e4c38a919d41176fd3e21ff52f519964913b6cf19c927e5954955f4923d2641e80a636e7fa9077f53f6c0330d98df66420f8bd800ba6b6ec3997a35efd
7
+ data.tar.gz: 374dabb00df41e74e0d9060a2914c3c66355a702e448175a9c315628dd4030838c288ea6fcf5b3185b6999e57a31a61f52366867ef838a82735e87a7ce611261
@@ -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
- ### 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).
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
- ### Fixed
26
+ Fixes from a full source review:
40
27
 
41
- Fixes from a full source review (a 0.2.4 was prepared but never published; its
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
@@ -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
- # :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)
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
- 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
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, :dropped_unhealable when a repeatedly-gapped update
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
- sync_handle_update(encoded, bytes)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Y
4
4
  module ActionCable
5
- VERSION = "0.3.0"
5
+ VERSION = "0.3.1"
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.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara