actioncable-enhanced-postgresql-adapter 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d4b7f64d6bbcae86b24c05c84cc7d182d260c7dd2c6b41300ee5de5e4a87d31a
4
+ data.tar.gz: ab38f315f5f5db4901c951c53e51d475da2fce45a50fc607cc2b56cb9e89b6c2
5
+ SHA512:
6
+ metadata.gz: 3e2d3ebd77fef8816c622f47075ab51e89eb6f10529e9bdd95c60d7d34b64bc2a19146cc1931c264ae57dd1c51b438e5e926cab1eacba29837f406811e9209bc
7
+ data.tar.gz: 81cc41a2e93e420069ff42f6deddb8766bea59e0689ced9c89d5c58e9809bff385c37e8e58660ed78b70012a92ce3ea22caece7033329243659be271a2eeaed3
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ 1.0.0
2
+
3
+ - Support > 8000 byte payloads
4
+ - Remove hard dependency on ActiveRecord
data/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # actioncable-enhanced-postgresql-adapter
2
+
3
+ This gem provides an enhanced PostgreSQL adapter for ActionCable. It is based on the original PostgreSQL adapter, but includes the following enhancements:
4
+ - Ability to broadcast payloads larger than 8000 bytes
5
+ - Not dependent on ActiveRecord (but can still integrate with it if available)
6
+
7
+ ### Approach
8
+
9
+ To overcome the 8000 bytes limit, we temporarily store large payloads in an [unlogged](https://www.crunchydata.com/blog/postgresl-unlogged-tables) database table named `action_cable_large_payloads`. The table is lazily created on first broadcast.
10
+
11
+ We then broadcast a payload in the style of `__large_payload:<encrypted-payload-id>`. The listener client then decrypts incoming ID's, fetches the original payload from the database, and replaces the temporary payload before invoking the subscriber callback.
12
+
13
+ ID encryption is done to prevent spoofing large payloads by manually broadcasting messages prefixed with `__large_payload:` with just an auto incrementing integer.
14
+
15
+ Note that payloads smaller than 8000 bytes are sent directly via NOTIFY, as per the original adapter.
16
+
17
+ ## Installation
18
+
19
+ Add this line to your application's Gemfile:
20
+
21
+ ```ruby
22
+ gem "actioncable-enhanced-postgresql-adapter", git: "https://github.com/reclaim-the-stack/actioncable-enhanced-postgresql-adapter"
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ In your `config/cable.yml` file, change the adapter for relevant environments to `enhanced_postgresql`:
28
+
29
+ ```yaml
30
+ development:
31
+ adapter: enhanced_postgresql
32
+
33
+ production:
34
+ adapter: enhanced_postgresql
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ The following configuration options are available:
40
+
41
+ - `payload_encryptor_secret` - The secret used to encrypt large payload ID's. Defaults to `Rails.application.secret_key_base` or the `SECRET_KEY_BASE` environment variable unless explicitly specified.
42
+ - `url` - Set this if you want to use a different database than the one provided by ActiveRecord. Must be a valid PostgreSQL connection string.
43
+ - `connection_pool_size` - Set this in conjunction with `url` to set the size of the postgres connection pool used for broadcasts. Defaults to `RAILS_MAX_THREADS` environment variable or falls back to 5.
44
+
45
+ ## Performance
46
+
47
+ For payloads smaller than 8000 bytes, which should cover the majority of cases, performance is identical to the original adapter.
48
+
49
+ When broadcasting large payloads, one has to consider the overhead of storing and fetching the payload from the database. For low frequency broadcasting, this overhead is likely negligible. But take care if you're doing very high frequency broadcasting.
50
+
51
+ Note that whichever ActionCable adapter you're using, sending large payloads with high frequency is an anti-pattern. Even Redis pub/sub has [limitations](https://redis.io/docs/reference/clients/#output-buffer-limits) to be aware of.
52
+
53
+ ### Cleanup of large payloads
54
+
55
+ Deletion of stale payloads (2 minutes or older) are triggered every 100 large payload inserts. We do this by looking at the incremental ID generated on insert and checking if it is evenly divisible by 100. This approach avoids having to manually schedule cleanup jobs while striking a balance between performance and cleanup frequency.
56
+
57
+ ## Development
58
+
59
+ - Clone repo
60
+ - `bundle install` to install dependencies
61
+ - `bundle exec ruby test/postgresql_test.rb` to run tests
@@ -0,0 +1,24 @@
1
+ Gem::Specification.new do |spec|
2
+ spec.name = "actioncable-enhanced-postgresql-adapter"
3
+ spec.version = "1.0.0"
4
+ spec.authors = ["David Backeus"]
5
+ spec.email = ["david.backeus@mynewsdesk.com"]
6
+
7
+ spec.summary = "ActionCable adapter for PostgreSQL that enhances the default."
8
+ spec.description = "Handles the 8000 byte limit for PostgreSQL NOTIFY payloads"
9
+ spec.homepage = "https://github.com/dbackeus/actioncable-enhanced-postgresql-adapter"
10
+ spec.license = "MIT"
11
+ spec.required_ruby_version = ">= 2.7.0"
12
+
13
+ spec.metadata = {
14
+ "homepage_uri" => spec.homepage,
15
+ "source_code_uri" => spec.homepage,
16
+ "changelog_uri" => "#{spec.homepage}/CHANGELOG.md"
17
+ }
18
+
19
+ spec.files = %w[README.md CHANGELOG.md actioncable-enhanced-postgresql-adapter.gemspec] + Dir["lib/**/*"]
20
+
21
+ spec.add_dependency "actioncable", ">= 6.0"
22
+ spec.add_dependency "connection_pool", ">= 2.2.5" # Ruby 2.7 compatible version
23
+ spec.add_dependency "pg", "~> 1.5"
24
+ end
@@ -0,0 +1,131 @@
1
+ # freeze_string_literal: true
2
+
3
+ require "action_cable/subscription_adapter/postgresql"
4
+ require "connection_pool"
5
+
6
+ module ActionCable
7
+ module SubscriptionAdapter
8
+ class EnhancedPostgresql < PostgreSQL
9
+ MAX_NOTIFY_SIZE = 7997 # documented as 8000 bytes, but there appears to be some overhead in transit
10
+ LARGE_PAYLOAD_PREFIX = "__large_payload:"
11
+ INSERTS_PER_DELETE = 100 # execute DELETE query every N inserts
12
+
13
+ LARGE_PAYLOADS_TABLE = "action_cable_large_payloads"
14
+ CREATE_LARGE_TABLE_QUERY = <<~SQL
15
+ CREATE UNLOGGED TABLE IF NOT EXISTS #{LARGE_PAYLOADS_TABLE} (
16
+ id SERIAL PRIMARY KEY,
17
+ payload TEXT NOT NULL,
18
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
19
+ )
20
+ SQL
21
+ CREATE_CREATED_AT_INDEX_QUERY = <<~SQL
22
+ CREATE INDEX IF NOT EXISTS index_action_cable_large_payloads_on_created_at
23
+ ON #{LARGE_PAYLOADS_TABLE} (created_at)
24
+ SQL
25
+ INSERT_LARGE_PAYLOAD_QUERY = "INSERT INTO #{LARGE_PAYLOADS_TABLE} (payload, created_at) VALUES ($1, CURRENT_TIMESTAMP) RETURNING id"
26
+ SELECT_LARGE_PAYLOAD_QUERY = "SELECT payload FROM #{LARGE_PAYLOADS_TABLE} WHERE id = $1"
27
+ DELETE_LARGE_PAYLOAD_QUERY = "DELETE FROM #{LARGE_PAYLOADS_TABLE} WHERE created_at < CURRENT_TIMESTAMP - INTERVAL '2 minutes'"
28
+
29
+ def initialize(*)
30
+ super
31
+
32
+ @url = @server.config.cable[:url]
33
+ @connection_pool_size = @server.config.cable[:connection_pool_size] || ENV["RAILS_MAX_THREADS"] || 5
34
+ end
35
+
36
+ def broadcast(channel, payload)
37
+ channel = channel_with_prefix(channel)
38
+
39
+ with_broadcast_connection do |pg_conn|
40
+ channel = pg_conn.escape_identifier(channel_identifier(channel))
41
+ payload = pg_conn.escape_string(payload)
42
+
43
+ if payload.bytesize > MAX_NOTIFY_SIZE
44
+ payload_id = insert_large_payload(pg_conn, payload)
45
+
46
+ if payload_id % INSERTS_PER_DELETE == 0
47
+ pg_conn.exec(DELETE_LARGE_PAYLOAD_QUERY)
48
+ end
49
+
50
+ # Encrypt payload_id to prevent simple integer ID spoofing
51
+ encrypted_payload_id = payload_encryptor.encrypt_and_sign(payload_id)
52
+
53
+ payload = "#{LARGE_PAYLOAD_PREFIX}#{encrypted_payload_id}"
54
+ end
55
+
56
+ pg_conn.exec("NOTIFY #{channel}, '#{payload}'")
57
+ end
58
+ end
59
+
60
+ def payload_encryptor
61
+ @payload_encryptor ||= begin
62
+ secret = @server.config.cable[:payload_encryptor_secret]
63
+ secret ||= Rails.application.secret_key_base if defined? Rails
64
+ secret ||= ENV["SECRET_KEY_BASE"]
65
+
66
+ raise ArgumentError, "Missing payload_encryptor_secret configuration for ActionCable EnhancedPostgresql adapter. You need to either explicitly configure it in cable.yml or set the SECRET_KEY_BASE environment variable." unless secret
67
+
68
+ secret_32_byte = Digest::SHA256.digest(secret)
69
+ ActiveSupport::MessageEncryptor.new(secret_32_byte)
70
+ end
71
+ end
72
+
73
+ def with_broadcast_connection(&block)
74
+ return super unless @url
75
+
76
+ connection_pool.with do |pg_conn|
77
+ yield pg_conn
78
+ end
79
+ end
80
+
81
+ # Called from the Listener thread
82
+ def with_subscriptions_connection(&block)
83
+ return super unless @url
84
+
85
+ pg_conn = PG::Connection.new(@url)
86
+ pg_conn.exec("SET application_name = #{pg_conn.escape_identifier(identifier)}")
87
+ yield pg_conn
88
+ ensure
89
+ pg_conn&.close
90
+ end
91
+
92
+ private
93
+
94
+ def connection_pool
95
+ @connection_pool ||= ConnectionPool.new(size: @connection_pool_size, timeout: 5) do
96
+ PG::Connection.new(@url)
97
+ end
98
+ end
99
+
100
+ def insert_large_payload(pg_conn, payload)
101
+ result = pg_conn.exec_params(INSERT_LARGE_PAYLOAD_QUERY, [payload])
102
+ result.first.fetch("id").to_i
103
+ rescue PG::UndefinedTable
104
+ pg_conn.exec(CREATE_LARGE_TABLE_QUERY)
105
+ pg_conn.exec(CREATE_CREATED_AT_INDEX_QUERY)
106
+ retry
107
+ end
108
+
109
+ # Override needed to ensure we reference our local Listener class
110
+ def listener
111
+ @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, @server.event_loop) }
112
+ end
113
+
114
+ class Listener < PostgreSQL::Listener
115
+ def invoke_callback(callback, message)
116
+ if message.start_with?(LARGE_PAYLOAD_PREFIX)
117
+ encrypted_payload_id = message.delete_prefix(LARGE_PAYLOAD_PREFIX)
118
+ payload_id = @adapter.payload_encryptor.decrypt_and_verify(encrypted_payload_id)
119
+
120
+ @adapter.with_broadcast_connection do |pg_conn|
121
+ result = pg_conn.exec_params(SELECT_LARGE_PAYLOAD_QUERY, [payload_id])
122
+ message = result.first.fetch("payload")
123
+ end
124
+ end
125
+
126
+ @event_loop.post { super }
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,2 @@
1
+ require_relative "action_cable/subscription_adapter/enhanced_postgresql"
2
+ require_relative "railtie" if defined? Rails::Railtie
data/lib/railtie.rb ADDED
@@ -0,0 +1,10 @@
1
+ class ActionCable::SubscriptionAdapter::EnhancedPostgresql
2
+ class Railtie < ::Rails::Railtie
3
+ initializer "action_cable.enhanced_postgresql_adapter" do
4
+ ActiveSupport.on_load(:active_record) do
5
+ large_payloads_table = ActionCable::SubscriptionAdapter::EnhancedPostgresql::LARGE_PAYLOADS_TABLE
6
+ ActiveRecord::SchemaDumper.ignore_tables << large_payloads_table
7
+ end
8
+ end
9
+ end
10
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: actioncable-enhanced-postgresql-adapter
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - David Backeus
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-11-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actioncable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: connection_pool
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 2.2.5
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 2.2.5
41
+ - !ruby/object:Gem::Dependency
42
+ name: pg
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.5'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.5'
55
+ description: Handles the 8000 byte limit for PostgreSQL NOTIFY payloads
56
+ email:
57
+ - david.backeus@mynewsdesk.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - README.md
64
+ - actioncable-enhanced-postgresql-adapter.gemspec
65
+ - lib/action_cable/subscription_adapter/enhanced_postgresql.rb
66
+ - lib/actioncable-enhanced-postgresql-adapter.rb
67
+ - lib/railtie.rb
68
+ homepage: https://github.com/dbackeus/actioncable-enhanced-postgresql-adapter
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ homepage_uri: https://github.com/dbackeus/actioncable-enhanced-postgresql-adapter
73
+ source_code_uri: https://github.com/dbackeus/actioncable-enhanced-postgresql-adapter
74
+ changelog_uri: https://github.com/dbackeus/actioncable-enhanced-postgresql-adapter/CHANGELOG.md
75
+ post_install_message:
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 2.7.0
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.4.10
91
+ signing_key:
92
+ specification_version: 4
93
+ summary: ActionCable adapter for PostgreSQL that enhances the default.
94
+ test_files: []