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 +7 -0
- data/CHANGELOG.md +4 -0
- data/README.md +61 -0
- data/actioncable-enhanced-postgresql-adapter.gemspec +24 -0
- data/lib/action_cable/subscription_adapter/enhanced_postgresql.rb +131 -0
- data/lib/actioncable-enhanced-postgresql-adapter.rb +2 -0
- data/lib/railtie.rb +10 -0
- metadata +94 -0
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
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
|
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: []
|