solid_cable 2.0.2 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7179cc12b0e26e67a4cf8c7a3422143d6405d33806972e77e546f2643c47d4a
4
- data.tar.gz: bb2bf9cbeca60b7eebca95c3dc13951e7769b616b18663c65bdf4dd915e23ca0
3
+ metadata.gz: f06792b582165dee33117bc0433cf27e61d3d9e932765bced3d8cdb4891d0ba9
4
+ data.tar.gz: 81d50418ae04e5f99b82d57d13ec52fc293ec12a2bbd34f7d0b1f6cfcbaa0a9f
5
5
  SHA512:
6
- metadata.gz: ed13873bf8f5783d34946df8437687f2642fea10e74edd82cac2c266108311dcf52d709556a6916fe17c26428306bac931d031de6cacdb3f8dd470477339de56
7
- data.tar.gz: d1ffc9ad2042e5910298ac4afe3562bda426423938a2aa62472ee46694b5dbe48a340a55c7b2649b4f024c1f75f113fb9b551366f6a7768c692e58af10e114a9
6
+ metadata.gz: f79b6a5e853f86aba526fc5963039d4888659315d08604b72e202b167912e0b26ba8120c53a6b0f4cf9c41cbdc30f53c96f3714597e0b1e4c00363fd69d9bba9
7
+ data.tar.gz: 5106fd2c0d1f12c3cfd854bb55a1ec239eb85bd51282c2fe9905f973f041ead14798da8466a9abd19385e09f478a1ca596db639f1a2d77e8836bcad35e603d30
data/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Solid Cable is a database-backed Action Cable adapter that keeps messages in a table and continously polls for updates. This makes it possible to drop the common dependency on Redis, if it isn't needed for any other purpose. Despite polling, the performance of Solid Cable is comparable to Redis in most situations. And in all circumstances, it makes it easier to deploy Rails when Redis is no longer a required dependency for Action Cable functionality.
4
4
 
5
+ > [!NOTE]
6
+ > Solid Cable is primarily targeted at MySQL and SQLite but is tested to work
7
+ > with PostgreSQL. PostgreSQL has its own Action Cable adapter which utilizes
8
+ > the NOTIFY command for better performance. However, the PostgreSQL adapter
9
+ > does have an 8kb limit on its payload, so if you find yourself broadcasting
10
+ > large payloads, Solid Cable will work without a hitch.
5
11
 
6
12
  ## Installation
7
13
 
@@ -67,14 +73,140 @@ The options are:
67
73
  - `message_retention` - sets the retention time for messages kept in the database. Used as the cut-off when trimming is performed. (Defaults to 1.day)
68
74
  - `autotrim` - sets wether you want Solid Cable to handle autotrimming messages. (Defaults to true)
69
75
  - `silence_polling` - whether to silence Active Record logs emitted when polling (Defaults to true)
76
+ - `use_skip_locked` - whether to use `FOR UPDATE SKIP LOCKED` when performing trimming. This will be automatically detected in the future, and for now, you'd only need to set this to `false` if your database doesn't support it. For MySQL, that'd be versions < 8, and for PostgreSQL, versions < 9.5. If you use SQLite, this has no effect, as writes are sequential. (Defaults to true)
77
+ - `trim_batch_size` - the batch size to use when deleting old records (default: `100`)
78
+
70
79
 
71
80
  ## Trimming
72
81
 
73
- Messages are autotrimmed based upon the `message_retention` setting to determine how long messages are to be kept around. If no `message_retention` is given or parsing fails, it defaults to `1.day`. Messages are trimmed when a subscriber unsubscribes.
82
+ Messages are autotrimmed based upon the `message_retention` setting to determine how long messages are to be kept around. If no `message_retention` is given or parsing fails, it defaults to `1.day`. Messages are trimmed when a messsage is broadcast.
74
83
 
75
- Autotrimming can negatively impact performance depending on your workload because it is doing a delete on ubsubscribe. If
84
+ Autotrimming can negatively impact performance slightly depending on your workload because it is potentially doing a delete on broadcast. If
76
85
  you would prefer, you can disable autotrimming by setting `autotrim: false` and you can manually enqueue the job later, `SolidCable::TrimJob.perform_later`, or run it on a recurring interval out of band.
77
86
 
87
+
88
+ ## Upgrading
89
+
90
+ If you have already installed Solid Cable < 3 and are upgrading to version 3,
91
+ run `solid_cable:update` to install a new migration.
92
+
93
+
94
+ ## Benchmarks
95
+
96
+ Inside the `bench` directory there is a minimal Rails app that is used to benchmark.
97
+ You are welcome to update the config/deploy.yml file to point to your own server
98
+ if you want to deploy the app to your own server and run benchmarks.
99
+
100
+ To benchmark we use [k6](https://k6.io). Most of the setup was gotten from this
101
+ [article](https://evilmartians.com/chronicles/real-time-stress-anycable-k6-websockets-and-yabeda).
102
+ 1. Install k6
103
+ 1. Install xk6-cable by running `xk6 build --with
104
+ github.com/anycable/xk6-cable`. This will output a custom k6 binary.
105
+ 1. Run the load test with `./k6 run loadtest.js`
106
+ - This script takes a variety of ENV variables:
107
+ - WS_URL: The url to send websocket connections
108
+ - MAX: The number of virtual users to hit the server with
109
+ - TIME: The duration of the load test
110
+ - MESSAGES_NUM: The number of messages each VU will send to the server
111
+
112
+
113
+ #### Results
114
+
115
+ Our loadtest is run on a Hetzner CCX13, with a MESSAGES_NUM of 5, and a TIME of 90.
116
+
117
+ ##### SQLite
118
+
119
+ With a polling interval of 0.1 seconds and autotrimming enabled.
120
+
121
+ 100 VUs
122
+ ```
123
+ rtt..................: avg=135.82ms min=50ms med=138ms max=357ms p(90)=174ms p(95)=195ms
124
+ ws_connecting........: avg=205.81ms min=149.35ms med=199.01ms max=509.48ms p(90)=254.04ms p(95)=261.77ms
125
+ ```
126
+ 250 VUs
127
+ ```
128
+ rtt..................: avg=146.24ms min=50ms med=144ms max=435ms p(90)=209ms p(95)=234.04ms
129
+ ws_connecting........: avg=222.15ms min=146.47ms med=208.57ms max=1.3s p(90)=263.6ms p(95)=284.18ms
130
+ ```
131
+ 500 VUs
132
+ ```
133
+ rtt..................: avg=271.79ms min=48ms med=205ms max=1.15s p(90)=558ms p(95)=660ms
134
+ ws_connecting........: avg=248.81ms min=145.89ms med=221.89ms max=1.38s p(90)=290.41ms p(95)=322.2ms
135
+ ```
136
+ 750 VUs
137
+ ```
138
+ rtt..................: avg=548.27ms min=51ms med=438ms max=5.19s p(90)=1.18s p(95)=1.29s
139
+ ws_connecting........: avg=266.37ms min=144.06ms med=224.93ms max=2.33s p(90)=298ms p(95)=342.87ms
140
+ ```
141
+
142
+ With trimming disabled
143
+
144
+ 250 VUs
145
+ ```
146
+ rtt..................: avg=139.47ms min=48ms med=142ms max=807ms p(90)=189ms p(95)=214ms
147
+ ws_connecting........: avg=212.58ms min=146.19ms med=196.25ms max=1.25s p(90)=255.74ms p(95)=272.44ms
148
+ ```
149
+
150
+ With a polling interval of 0.01 seconds it becomes comparable to Redis
151
+
152
+ 250 VUs
153
+ ```
154
+ rtt..................: avg=84.22ms min=43ms med=69ms max=416ms p(90)=137ms p(95)=150ms
155
+ ws_connecting........: avg=219.37ms min=144.71ms med=200.77ms max=2.17s p(90)=265.23ms p(95)=290.83ms
156
+ ```
157
+
158
+ ##### Redis
159
+
160
+ This instance was hosted on the same machine.
161
+
162
+ 100 VUs
163
+ ```
164
+ rtt..................: avg=68.95ms min=41ms med=56ms max=6.23s p(90)=114ms p(95)=129ms
165
+ ws_connecting........: avg=211.09ms min=153.23ms med=195.69ms max=1.44s p(90)=258.1ms p(95)=272.23ms
166
+ ```
167
+ 250 VUs
168
+ ```
169
+ rtt..................: avg=69.32ms min=40ms med=56ms max=645ms p(90)=119ms p(95)=135ms
170
+ ws_connecting........: avg=212.95ms min=142.92ms med=196.31ms max=1.25s p(90)=260.25ms p(95)=273.49ms
171
+ ```
172
+ 500 VUs
173
+ ```
174
+ rtt..................: avg=87.5ms min=40ms med=67ms max=839ms p(90)=149ms p(95)=176ms
175
+ ws_connecting........: avg=242.62ms min=142.03ms med=213.76ms max=2.34s p(90)=291.25ms p(95)=324.04ms
176
+ ```
177
+ 750 VUs
178
+ ```
179
+ rtt..................: avg=162.54ms min=39ms med=123ms max=2.26s p(90)=343.1ms p(95)=438ms
180
+ ws_connecting........: avg=353.08ms min=143ms med=264.15ms max=2.73s p(90)=541.36ms p(95)=1.15s
181
+ ```
182
+
183
+
184
+ ##### MySQL
185
+
186
+ With a polling interval of 0.1 seconds and autotrimming enabled. This instance
187
+ was also hosted on the same machine.
188
+
189
+ 100 VUs
190
+ ```
191
+ rtt..................: avg=136.02ms min=51ms med=137ms max=877ms p(90)=168.1ms p(95)=198ms
192
+ ws_connecting........: avg=207.76ms min=151.93ms med=196.74ms max=1.21s p(90)=249.91ms p(95)=260.37ms
193
+ ```
194
+ 250 VUs
195
+ ```
196
+ rtt..................: avg=159.33ms min=51ms med=149ms max=559ms p(90)=236ms p(95)=263ms
197
+ ws_connecting........: avg=232.38ms min=151.6ms med=218.09ms max=1.38s p(90)=287.99ms p(95)=324.6ms
198
+ ```
199
+ 500 VUs
200
+ ```
201
+ rtt..................: avg=441.07ms min=51ms med=312ms max=2.29s p(90)=931ms p(95)=1.07s
202
+ ws_connecting........: avg=256.73ms min=152.23ms med=231.02ms max=2.31s p(90)=305.69ms p(95)=340.83ms
203
+ ```
204
+ 750 VUs
205
+ ```
206
+ rtt..................: avg=822.08ms min=51ms med=732ms max=5.05s p(90)=1.76s p(95)=1.97s
207
+ ws_connecting........: avg=278.08ms min=146.66ms med=236.35ms max=2.37s p(90)=318.17ms p(95)=374.98ms
208
+ ```
209
+
78
210
  ## License
79
211
 
80
212
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -3,7 +3,25 @@
3
3
  module SolidCable
4
4
  class TrimJob < ActiveJob::Base
5
5
  def perform
6
- ::SolidCable::Message.trimmable.delete_all
6
+ return unless trim?
7
+
8
+ ::SolidCable::Message.transaction do
9
+ ids = ::SolidCable::Message.trimmable.non_blocking_lock.
10
+ limit(trim_batch_size).pluck(:id)
11
+ ::SolidCable::Message.where(id: ids).delete_all
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def trim_batch_size
18
+ ::SolidCable.trim_batch_size
19
+ end
20
+
21
+ def trim?
22
+ expires_per_write = (1 / trim_batch_size.to_f) * ::SolidCable.trim_chance
23
+
24
+ rand < (expires_per_write - expires_per_write.floor)
7
25
  end
8
26
  end
9
27
  end
@@ -6,7 +6,24 @@ module SolidCable
6
6
  where(created_at: ..::SolidCable.message_retention.ago)
7
7
  }
8
8
  scope :broadcastable, lambda { |channels, last_id|
9
- where(channel: channels).where(id: (last_id + 1)..).order(:id)
9
+ where(channel_hash: channel_hashes_for(channels)).
10
+ where(id: (last_id + 1)..).order(:id)
10
11
  }
12
+
13
+ class << self
14
+ def broadcast(channel, payload)
15
+ insert({ channel:, payload:, channel_hash: channel_hash_for(channel) })
16
+ end
17
+
18
+ def channel_hashes_for(channels)
19
+ channels.map { |channel| channel_hash_for(channel) }
20
+ end
21
+
22
+ # Need to unpack this as a signed integer since Postgresql and SQLite
23
+ # don't support unsigned integers
24
+ def channel_hash_for(channel)
25
+ Digest::SHA256.digest(channel.to_s).unpack1("q>")
26
+ end
27
+ end
11
28
  end
12
29
  end
@@ -5,5 +5,13 @@ module SolidCable
5
5
  self.abstract_class = true
6
6
 
7
7
  connects_to(**SolidCable.connects_to) if SolidCable.connects_to.present?
8
+
9
+ def self.non_blocking_lock
10
+ if SolidCable.use_skip_locked
11
+ lock(Arel.sql("FOR UPDATE SKIP LOCKED"))
12
+ else
13
+ lock
14
+ end
15
+ end
8
16
  end
9
17
  end
@@ -15,7 +15,9 @@ module ActionCable
15
15
  end
16
16
 
17
17
  def broadcast(channel, payload)
18
- ::SolidCable::Message.insert({ channel:, payload: })
18
+ ::SolidCable::Message.broadcast(channel, payload)
19
+
20
+ ::SolidCable::TrimJob.perform_now if ::SolidCable.autotrim?
19
21
  end
20
22
 
21
23
  def subscribe(channel, callback, success_callback = nil)
@@ -68,8 +70,6 @@ module ActionCable
68
70
 
69
71
  def remove_channel(channel)
70
72
  channels.delete(channel)
71
-
72
- ::SolidCable::TrimJob.perform_now if ::SolidCable.autotrim?
73
73
  end
74
74
 
75
75
  def invoke_callback(*)
@@ -1,3 +1,7 @@
1
+ # Async adapter only works within the same process, so for manually triggering cable updates from a console,
2
+ # and seeing results in the browser, you must do so from the web console (running inside the dev process),
3
+ # not a terminal started via bin/rails console! Add "console" to any action or "<%= console %>" in any view
4
+ # to make the web console appear.
1
5
  development:
2
6
  adapter: async
3
7
 
@@ -2,12 +2,13 @@
2
2
 
3
3
  ActiveRecord::Schema[7.1].define(version: 1) do
4
4
  create_table "solid_cable_messages", force: :cascade do |t|
5
- t.text "channel"
6
- t.text "payload"
5
+ t.binary "channel", limit: 1024, null: false
6
+ t.binary "payload", limit: 536870912, null: false
7
7
  t.datetime "created_at", null: false
8
8
  t.datetime "updated_at", null: false
9
- t.index ["channel"], name: "index_solid_cable_messages_on_channel",
10
- length: 500
9
+ t.integer "channel_hash", limit: 8, null: false
10
+ t.index ["channel"], name: "index_solid_cable_messages_on_channel"
11
+ t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
11
12
  t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
12
13
  end
13
14
  end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Updates Solid Cable migrations
3
+
4
+ Example:
5
+ bin/rails generate solid_cable:update
6
+
7
+ This will perform the following:
8
+ Installs new Solid Cable migrations
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateCompactChannel < ActiveRecord::Migration[7.2]
4
+ def change
5
+ change_column :solid_cable_messages, :channel, :binary, limit: 1024, null: false
6
+ add_column :solid_cable_messages, :channel_hash, :integer, limit: 8, if_not_exists: true
7
+ add_index :solid_cable_messages, :channel_hash, if_not_exists: true
8
+ change_column :solid_cable_messages, :payload, :binary, limit: 536_870_912, null: false
9
+
10
+ SolidCable::Message.find_each do |msg|
11
+ msg.update(channel_hash: SolidCable::Message.channel_hash_for(msg.channel))
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ class SolidCable::UpdateGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ def copy_files
12
+ migration_template "db/migrate/create_compact_channel.rb",
13
+ "db/cable_migrate/create_compact_channel.rb"
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidCable
4
- VERSION = "2.0.2"
4
+ VERSION = "3.0.0"
5
5
  end
data/lib/solid_cable.rb CHANGED
@@ -11,7 +11,7 @@ module SolidCable
11
11
  end
12
12
 
13
13
  def silence_polling?
14
- !!cable_config.silence_polling
14
+ cable_config.silence_polling != false
15
15
  end
16
16
 
17
17
  def polling_interval
@@ -26,6 +26,27 @@ module SolidCable
26
26
  cable_config.autotrim != false
27
27
  end
28
28
 
29
+ def trim_batch_size
30
+ if (size = cable_config.trim_batch_size.to_i) < 1
31
+ 100
32
+ else
33
+ size
34
+ end
35
+ end
36
+
37
+ def use_skip_locked
38
+ cable_config.use_skip_locked != false
39
+ end
40
+
41
+ # For every write that we do, we attempt to delete trim_chance times as
42
+ # many records. This ensures there is downward pressure on the cache size
43
+ # while there is valid data to delete. Read this as 'every time the trim job
44
+ # runs theres a trim_multiplier chance this trims'. Adjust number to make it
45
+ # more or less likely to trim.
46
+ def trim_chance
47
+ 10
48
+ end
49
+
29
50
  private
30
51
 
31
52
  def cable_config
@@ -3,6 +3,10 @@
3
3
  desc "Copy over the schema and set cable adapter for Solid Cable"
4
4
  namespace :solid_cable do
5
5
  task :install do
6
- Rails::Command.invoke :generate, ["solid_cable:install"]
6
+ Rails::Command.invoke :generate, [ "solid_cable:install" ]
7
+ end
8
+
9
+ task :update do
10
+ Rails::Command.invoke :generate, [ "solid_cable:update" ]
7
11
  end
8
12
  end
metadata CHANGED
@@ -1,17 +1,59 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_cable
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.2
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Pezza
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-09-12 00:00:00.000000000 Z
11
+ date: 2024-09-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: rails
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activejob
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: actioncable
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.2'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: railties
15
57
  requirement: !ruby/object:Gem::Requirement
16
58
  requirements:
17
59
  - - ">="
@@ -42,6 +84,9 @@ files:
42
84
  - lib/generators/solid_cable/install/install_generator.rb
43
85
  - lib/generators/solid_cable/install/templates/config/cable.yml
44
86
  - lib/generators/solid_cable/install/templates/db/cable_schema.rb
87
+ - lib/generators/solid_cable/update/USAGE
88
+ - lib/generators/solid_cable/update/templates/db/migrate/create_compact_channel.rb
89
+ - lib/generators/solid_cable/update/update_generator.rb
45
90
  - lib/solid_cable.rb
46
91
  - lib/solid_cable/engine.rb
47
92
  - lib/solid_cable/railtie.rb
@@ -52,7 +97,7 @@ licenses:
52
97
  - MIT
53
98
  metadata:
54
99
  homepage_uri: http://github.com/npezza93/solid_cable
55
- source_code_uri: http://github.com/npezza93/solid_cable
100
+ source_code_uri: http://github.com/rails/solid_cable
56
101
  rubygems_mfa_required: 'true'
57
102
  post_install_message:
58
103
  rdoc_options: []