pollen 0.1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +205 -0
- data/Rakefile +10 -0
- data/app/models/pollen/application_record.rb +7 -0
- data/app/models/pollen/stream.rb +15 -0
- data/db/migrate/20241125150719_create_pollen_streams.rb +14 -0
- data/lib/pollen/commands.rb +49 -0
- data/lib/pollen/configuration.rb +100 -0
- data/lib/pollen/controller.rb +17 -0
- data/lib/pollen/engine.rb +19 -0
- data/lib/pollen/errors.rb +10 -0
- data/lib/pollen/event_loop.rb +46 -0
- data/lib/pollen/fiber_body.rb +56 -0
- data/lib/pollen/middleware.rb +53 -0
- data/lib/pollen/partitioner.rb +15 -0
- data/lib/pollen/pusher.rb +61 -0
- data/lib/pollen/server.rb +103 -0
- data/lib/pollen/subscriber.rb +44 -0
- data/lib/pollen/version.rb +5 -0
- data/lib/pollen.rb +43 -0
- data/lib/tasks/pollen_tasks.rake +11 -0
- metadata +122 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 5e74b102b1d0c5428838a6c35792a1da697a36c9679a642ab349a1d66f11b05e
|
4
|
+
data.tar.gz: a9fb42c9e8b9f1bb3ef2534b44b5947cb1c6ce1138ebbee7aafddc99ac1e280c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 785f8d2ff608600db96f9d7b8e297568c626804a85a1e2f1ea2fb2f01dfd49e55c9923f3bbb7d92a7b889d54cc7052d37bb05a08f00fed55e5c09f8e8ba0e282
|
7
|
+
data.tar.gz: e247e3d5e15fa504db27316eb77ba1ebc82939b8ca197fd6b67dc2cd05bb09b2ef196df5085da6d8d5980e73314efa215d312e77506e04f90fe29991ea2e9d1c
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright Jef Mathiot
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,205 @@
|
|
1
|
+
# Pollen
|
2
|
+
|
3
|
+
An HTTP pubsub engine allowing clients to wait for long running tasks and get data updates
|
4
|
+
from the server.
|
5
|
+
|
6
|
+
## How it works
|
7
|
+
|
8
|
+
Pollen allows client applications to subscribe to streams of server-sent events.
|
9
|
+
|
10
|
+

|
11
|
+
|
12
|
+
When a client application wants to subscribe to a steam, it opens an HTTP connection with the
|
13
|
+
Pollen server. Pollen hijacks Rack requests corresponding to a specific route set (`/pollens/streams/:stream_id`)
|
14
|
+
and opens long running HTTP connections handled by an event loop and Ruby Fibers. All requests
|
15
|
+
outside this route are ignored by Pollen and forwarded to Rails.
|
16
|
+
|
17
|
+
On the server-side, processes, such as background jobs, use the Pollen controller to push updates
|
18
|
+
and handle the streams states. Communication between the controllers and the server is performed
|
19
|
+
via Redis Pub/Sub.
|
20
|
+
|
21
|
+
Thanks to the event loop and the usage of Ruby Fibers, Pollen can handle 10k+ concurrent connections
|
22
|
+
on a single CPU.
|
23
|
+
|
24
|
+

|
25
|
+
|
26
|
+
## Installation
|
27
|
+
|
28
|
+
Add this line to your application's Gemfile:
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
gem "pollen"
|
32
|
+
```
|
33
|
+
|
34
|
+
And then execute:
|
35
|
+
```bash
|
36
|
+
$ bundle
|
37
|
+
```
|
38
|
+
|
39
|
+
Or install it yourself as:
|
40
|
+
```bash
|
41
|
+
$ gem install pollen
|
42
|
+
```
|
43
|
+
|
44
|
+
To install the migrations into your Rails application:
|
45
|
+
|
46
|
+
```bash
|
47
|
+
rails pollen:install:migrations
|
48
|
+
```
|
49
|
+
|
50
|
+
## Usage
|
51
|
+
|
52
|
+
### Authenticate stream clients
|
53
|
+
|
54
|
+
We create a module that extracts access tokens from requests:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
# lib/token_extractor.rb
|
58
|
+
|
59
|
+
module TokenExtractor
|
60
|
+
class << self
|
61
|
+
def token(request)
|
62
|
+
pattern = /^Bearer /
|
63
|
+
header = request.get_header('HTTP_AUTHORIZATION')
|
64
|
+
return unless header&.match(pattern)
|
65
|
+
|
66
|
+
header.gsub(pattern, '')
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
```
|
71
|
+
|
72
|
+
### Start the pollen server
|
73
|
+
|
74
|
+
We then create an initializer to provide the Redis client and the authentication method to the
|
75
|
+
Pollen server. We also start the Pollen server if the environment variable `START_POLLEN` is
|
76
|
+
set to `true`.
|
77
|
+
|
78
|
+
```ruby
|
79
|
+
# config/initializers/pollen.rb
|
80
|
+
|
81
|
+
# Configuration shared by servers and controllers
|
82
|
+
Pollen.common.configure do |c|
|
83
|
+
c.redis Redis.new(url: "redis://127.0.0.1:6379")
|
84
|
+
end
|
85
|
+
|
86
|
+
Pollen.server.configure do |c|
|
87
|
+
c.authenticate do |request, env|
|
88
|
+
token = TokenExtractor.token(request)
|
89
|
+
next if token.blank?
|
90
|
+
|
91
|
+
AccessToken.find_by(token: token)&.user
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
Pollen.server.start! if ENV['START_POLLEN'] == 'true'
|
96
|
+
```
|
97
|
+
|
98
|
+
### Push events using the controller
|
99
|
+
|
100
|
+
Now, when the client application calls Rails to perform long-running task, such as generating
|
101
|
+
the quaterly report of the _World Company®_, a regular Rails controller authenticates the
|
102
|
+
user, creates a _Stream_ instance and enqueues a background job. It then renders the _Stream_.
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
# app/controllers/admin/reports_controller.rb
|
106
|
+
|
107
|
+
module Admin
|
108
|
+
class ReportsController < ActionController::API
|
109
|
+
before_action :authenticate_user
|
110
|
+
|
111
|
+
def create
|
112
|
+
stream = Pollen::Stream.create!(owner: current_user, timeout: 600)
|
113
|
+
GenerateQuarterlyReportJob.perform_later(stream)
|
114
|
+
render json: stream
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
```
|
119
|
+
|
120
|
+
When the client application gets the response, it uses the _Stream_ identifier to open a
|
121
|
+
connection to the Pollen server. It provides its access token so that the Pollen server
|
122
|
+
can authenticate the connection. As soon as the client has connected to the _Stream_, it
|
123
|
+
will get the initial status of the _Stream_ and regular heartbeats (every 10 seconds, by
|
124
|
+
default):
|
125
|
+
|
126
|
+
```bash
|
127
|
+
$ curl -N -H "Authorization: Bearer d87bdfe991fd4b892fc49e145c7dc8e38477b2ec08eee2aeb07441658a7a8c57" \
|
128
|
+
http://0.0.0.0:3000/pollen/streams/708100af-2eba-4db3-b0a2-1847abee202c
|
129
|
+
event: pending
|
130
|
+
event: heartbeat
|
131
|
+
event: heartbeat
|
132
|
+
```
|
133
|
+
|
134
|
+
The job performs the report generation and regularly notifies the client application using
|
135
|
+
the Pollen controller. Once the report is fully generated, the controller marks the stream
|
136
|
+
as completed and closes the connection:
|
137
|
+
|
138
|
+
```ruby
|
139
|
+
# app/jobs/generate_quaterly_report_job.rb
|
140
|
+
|
141
|
+
class GenerateQuarterlyReportJob < ApplicationJob
|
142
|
+
def perform(stream)
|
143
|
+
10.times do |i|
|
144
|
+
Pollen.controller.push!(stream, :update, { step: i }.to_json)
|
145
|
+
sleep 1
|
146
|
+
end
|
147
|
+
Report.create!
|
148
|
+
Pollen.controller.completed!(stream, report.to_json)
|
149
|
+
rescue
|
150
|
+
Pollen.controller.failed!(stream, {})
|
151
|
+
end
|
152
|
+
end
|
153
|
+
```
|
154
|
+
|
155
|
+
The client will see this stream of events before the server closes the connection:
|
156
|
+
|
157
|
+
```
|
158
|
+
event: heartbeat
|
159
|
+
event: update
|
160
|
+
data: {"step":0}
|
161
|
+
event: update
|
162
|
+
data: {"step":1}
|
163
|
+
event: heartbeat
|
164
|
+
event: update
|
165
|
+
data: {"step":2}
|
166
|
+
event: update
|
167
|
+
data: {"step":3}
|
168
|
+
event: update
|
169
|
+
data: {"step":4}
|
170
|
+
event: update
|
171
|
+
data: {"step":5}
|
172
|
+
event: update
|
173
|
+
data: {"step":6}
|
174
|
+
event: update
|
175
|
+
data: {"step":7}
|
176
|
+
event: heartbeat
|
177
|
+
event: update
|
178
|
+
data: {"step":8}
|
179
|
+
event: update
|
180
|
+
data: {"step":9}
|
181
|
+
event: completed
|
182
|
+
data: {"id":"702943d6-49e4-45ba-9a6f-630fadd3e2c7"}
|
183
|
+
event: terminated
|
184
|
+
```
|
185
|
+
|
186
|
+
### Delete old streams
|
187
|
+
|
188
|
+
Stream instances are stored in the database and, in high traffic environments,
|
189
|
+
may pile up to millions of records a day.
|
190
|
+
|
191
|
+
Pollen provides a `pollen:prune_streams` Rails task typically run from a scheduled
|
192
|
+
task such as a Cron job. As _Stream_ objects are basically _ActiveRecord_ models,
|
193
|
+
it is also possible to use plain-old ActiveRecord queries to delete oldest _Streams_.
|
194
|
+
|
195
|
+
### Configuration
|
196
|
+
|
197
|
+
See the [wiki](https://github.com/EverestHC-mySofie/pollen/wiki/Configuration) for configuration options.
|
198
|
+
|
199
|
+
## Contributing
|
200
|
+
|
201
|
+
Feel free to file an issue or create a pull request <3
|
202
|
+
|
203
|
+
## License
|
204
|
+
|
205
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
class Stream < ApplicationRecord
|
5
|
+
belongs_to :owner, class_name: Pollen.common.configuration.owner_class
|
6
|
+
|
7
|
+
enum :status, pending: 0, completed: 10, failed: 20
|
8
|
+
|
9
|
+
validates :timeout, numericality: { only_integer: true, greater_than: 0 }
|
10
|
+
|
11
|
+
before_validation(on: :create) do
|
12
|
+
self.id = SecureRandom.uuid unless id.present?
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreatePollenStreams < ActiveRecord::Migration[7.2]
|
4
|
+
def change
|
5
|
+
create_table :pollen_streams, id: false, primary_key: :id do |t|
|
6
|
+
t.string :id, primary_key: true
|
7
|
+
t.string :owner_id, index: true
|
8
|
+
t.text :payload
|
9
|
+
t.integer :status, null: false, default: 0
|
10
|
+
t.integer :timeout, null: false, default: 120
|
11
|
+
t.timestamps
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
module Commands
|
5
|
+
def completed!(stream_or_id, payload)
|
6
|
+
push!(stream_or_id, :completed, payload)
|
7
|
+
end
|
8
|
+
|
9
|
+
def failed!(stream_or_id, payload)
|
10
|
+
push!(stream_or_id, :failed, payload)
|
11
|
+
end
|
12
|
+
|
13
|
+
def push!(stream_or_id, event, payload)
|
14
|
+
check_redis!
|
15
|
+
stream = load_stream(stream_or_id)
|
16
|
+
stream.update!(status: %i[completed failed].include?(event) && event || :pending, payload:)
|
17
|
+
with_redis do |r|
|
18
|
+
r.publish("#{configuration.channel_prefix}:#{stream.id}", "#{event}:#{payload}")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def redis
|
25
|
+
configuration.redis
|
26
|
+
end
|
27
|
+
|
28
|
+
def with_redis(&block)
|
29
|
+
# Allow raw Redis clients or a client from the pool
|
30
|
+
configuration.redis.then(&block)
|
31
|
+
end
|
32
|
+
|
33
|
+
def check_redis!
|
34
|
+
return unless redis.nil?
|
35
|
+
|
36
|
+
raise Errors::InvalidConfiguration,
|
37
|
+
'Redis not set, please assign a Redis client or connection pool in controller configuration'
|
38
|
+
end
|
39
|
+
|
40
|
+
def load_stream(stream_or_id)
|
41
|
+
(stream_or_id.respond_to?(:id) && stream_or_id || Stream.find(stream_or_id)).tap do |stream|
|
42
|
+
unless stream.pending?
|
43
|
+
raise Errors::InvalidStreamStatus,
|
44
|
+
"Stream with id #{stream.id} is already #{stream.status}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
class Configuration
|
5
|
+
attr_reader :owner_class, :channel_prefix, :redis
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
super
|
9
|
+
@owner_class = nil
|
10
|
+
@channel_prefix = nil
|
11
|
+
@redis = nil
|
12
|
+
end
|
13
|
+
|
14
|
+
def assign_defaults
|
15
|
+
@owner_class = 'User'
|
16
|
+
@channel_prefix = 'pollen:streams'
|
17
|
+
end
|
18
|
+
|
19
|
+
def root
|
20
|
+
self
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class ServerConfiguration < Configuration
|
25
|
+
UUID_REGEXP = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
|
26
|
+
|
27
|
+
attr_reader :concurrency, :heartbeat, :route_regexp
|
28
|
+
attr_accessor :authenticator
|
29
|
+
|
30
|
+
def initialize
|
31
|
+
super
|
32
|
+
@concurrency = 1
|
33
|
+
@heartbeat = 5
|
34
|
+
@route_regexp = %r{^/pollen/streams/(#{UUID_REGEXP})}
|
35
|
+
@authenticator = ->(_, _) {}
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class ControllerConfiguration < Configuration
|
40
|
+
attr_reader :retention
|
41
|
+
|
42
|
+
def initialize
|
43
|
+
super
|
44
|
+
@retention = 24.hours
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
class CompositeConfiguration
|
49
|
+
def initialize(*configs)
|
50
|
+
@configs = configs
|
51
|
+
end
|
52
|
+
|
53
|
+
def method_missing(name, *_args, **_)
|
54
|
+
@configs.each do |config|
|
55
|
+
return config.send(name) if config.respond_to?(name) && !config.send(name).nil?
|
56
|
+
end
|
57
|
+
nil
|
58
|
+
end
|
59
|
+
|
60
|
+
def respond_to_missing?(name, _ = false)
|
61
|
+
@configs.map { |config| config.respond_to?(name) }.any?
|
62
|
+
end
|
63
|
+
|
64
|
+
def root
|
65
|
+
@configs.first
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
class ConfigurationBuilder
|
70
|
+
def initialize(configuration)
|
71
|
+
@configuration = configuration
|
72
|
+
end
|
73
|
+
|
74
|
+
def authenticate(&block)
|
75
|
+
@configuration.root.authenticator = block
|
76
|
+
end
|
77
|
+
|
78
|
+
def method_missing(name, *args, **_)
|
79
|
+
@configuration.root.instance_variable_set("@#{name}", *args)
|
80
|
+
end
|
81
|
+
|
82
|
+
def respond_to_missing?(_name, _ = false)
|
83
|
+
true
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
module Configurable
|
88
|
+
def configure
|
89
|
+
yield ConfigurationBuilder.new(configuration)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
class Common
|
94
|
+
include Configurable
|
95
|
+
|
96
|
+
def configuration
|
97
|
+
@configuration ||= Configuration.new.tap(&:assign_defaults)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
class Controller
|
5
|
+
include Configurable
|
6
|
+
include Commands
|
7
|
+
|
8
|
+
def configuration
|
9
|
+
return @configuration if @configuration
|
10
|
+
|
11
|
+
@configuration = CompositeConfiguration.new(
|
12
|
+
ControllerConfiguration.new,
|
13
|
+
Pollen.common.configuration
|
14
|
+
)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
if defined?(::Rails)
|
5
|
+
class Engine < ::Rails::Engine
|
6
|
+
isolate_namespace Pollen
|
7
|
+
|
8
|
+
initializer 'pollen.add_middleware' do |app|
|
9
|
+
app.middleware.insert_after ActionDispatch::Executor, Pollen::Middleware
|
10
|
+
end
|
11
|
+
|
12
|
+
config.generators do |g|
|
13
|
+
g.test_framework :rspec
|
14
|
+
g.fixture_replacement :factory_bot
|
15
|
+
g.factory_bot dir: 'spec/factories'
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
module Errors
|
5
|
+
class InvalidConfiguration < StandardError; end
|
6
|
+
class InvalidStreamStatus < StandardError; end
|
7
|
+
class AuthenticationFailure < StandardError; end
|
8
|
+
class StreamNotFound < StandardError; end
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
class EventLoop
|
5
|
+
def initialize
|
6
|
+
@fibers = {}
|
7
|
+
end
|
8
|
+
|
9
|
+
def proceed(incoming, queue)
|
10
|
+
incoming.each { |connection| start_fiber!(connection) }
|
11
|
+
collector = {}
|
12
|
+
@fibers.each do |stream_id, stream_fibers|
|
13
|
+
resume_stream_fibers(stream_id, stream_fibers, queue[stream_id], collector)
|
14
|
+
end
|
15
|
+
prune collector
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def resume_stream_fibers(stream_id, fibers, event, collector)
|
21
|
+
fibers.each do |fiber|
|
22
|
+
fiber.resume event
|
23
|
+
rescue FiberError
|
24
|
+
# The fiber is dead
|
25
|
+
collector[stream_id] ||= []
|
26
|
+
collector[stream_id] << fiber
|
27
|
+
Rails.logger.info "Closed connection for stream #{stream_id}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def prune(collector)
|
32
|
+
collector.each do |stream_id, stream_fibers|
|
33
|
+
stream_fibers.each do |fiber|
|
34
|
+
@fibers[stream_id].delete(fiber)
|
35
|
+
end
|
36
|
+
@fibers.delete(stream_id) if @fibers[stream_id].empty?
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def start_fiber!(connection)
|
41
|
+
fiber = Fiber.new { |conn| FiberBody.new.execute!(conn) }
|
42
|
+
@fibers[connection.stream_id] ||= []
|
43
|
+
@fibers[connection.stream_id] << fiber.tap { |f| f.resume(connection) }
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
class FiberBody
|
5
|
+
def initialize
|
6
|
+
@latest_heartbeat = Time.now.to_i
|
7
|
+
end
|
8
|
+
|
9
|
+
# TODO: Assign a Fiber scheduler to handle blocking kernel calls
|
10
|
+
def execute!(connection)
|
11
|
+
init!(connection)
|
12
|
+
pusher.push(connection.payload, event: connection.event)
|
13
|
+
loop! if pending?(connection.event)
|
14
|
+
pusher.push(nil, event: 'terminated')
|
15
|
+
pusher.close
|
16
|
+
rescue IOError
|
17
|
+
# The Fiber should die when it can't write to socket
|
18
|
+
end
|
19
|
+
|
20
|
+
def init!(connection)
|
21
|
+
@connection = connection
|
22
|
+
pusher.write_headers
|
23
|
+
end
|
24
|
+
|
25
|
+
def loop!
|
26
|
+
while Time.now.to_i < @connection.terminate_at
|
27
|
+
heartbeat!
|
28
|
+
event = Fiber.yield
|
29
|
+
next unless event
|
30
|
+
|
31
|
+
pusher.push(event[:data], event: event[:event])
|
32
|
+
@latest_heartbeat = Time.now.to_i
|
33
|
+
break if completed?(event[:event])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def completed?(event)
|
38
|
+
%w[completed failed].include?(event)
|
39
|
+
end
|
40
|
+
|
41
|
+
def pending?(event)
|
42
|
+
!completed?(event)
|
43
|
+
end
|
44
|
+
|
45
|
+
def heartbeat!
|
46
|
+
return unless Time.now.to_i > @latest_heartbeat + Pollen.server.configuration.heartbeat
|
47
|
+
|
48
|
+
pusher.comment
|
49
|
+
@latest_heartbeat = Time.now.to_i
|
50
|
+
end
|
51
|
+
|
52
|
+
def pusher
|
53
|
+
@pusher ||= Pusher.new(@connection.socket)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
class Middleware
|
5
|
+
UUID_REGEXP = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
|
6
|
+
|
7
|
+
def initialize(app)
|
8
|
+
@app = app
|
9
|
+
@server = Pollen.server
|
10
|
+
@route = @server.configuration.route_regexp
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(env)
|
14
|
+
request = Rack::Request.new(env)
|
15
|
+
if @server.started? && (stream_id = request.path.match(@route)&.captures&.first)
|
16
|
+
raise 'Unable to hijack HTTP connection' unless env['rack.hijack?']
|
17
|
+
|
18
|
+
authenticate_and_hijack(request, env, stream_id)
|
19
|
+
else
|
20
|
+
@app.call(env)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def authenticate_and_hijack(request, env, stream_id)
|
27
|
+
load_stream(stream_id, request, env).tap do |stream|
|
28
|
+
@server.accept(env['rack.hijack'].call, stream)
|
29
|
+
end
|
30
|
+
[200, {}, []]
|
31
|
+
rescue Errors::AuthenticationFailure
|
32
|
+
[401, {}, []]
|
33
|
+
rescue Errors::StreamNotFound
|
34
|
+
[404, {}, []]
|
35
|
+
end
|
36
|
+
|
37
|
+
def authenticate_owner(stream_id, request, env)
|
38
|
+
authenticator.call(request, env).tap do |owner|
|
39
|
+
raise Errors::AuthenticationFailure, "Unable to authenticate user on stream #{stream_id}" if owner.nil?
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def load_stream(stream_id, request, env)
|
44
|
+
Stream.find_by(owner: authenticate_owner(stream_id, request, env), id: stream_id).tap do |stream|
|
45
|
+
raise Errors::StreamNotFound, "Unable to find stream with ID #{stream_id}" if stream.nil?
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def authenticator
|
50
|
+
Pollen.server.configuration.authenticator
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
class Partitioner
|
5
|
+
attr_reader :concurrency
|
6
|
+
|
7
|
+
def initialize(configuration)
|
8
|
+
@configuration = configuration
|
9
|
+
end
|
10
|
+
|
11
|
+
def partition(uuid)
|
12
|
+
Digest::MD5.new.tap { |d| d.update uuid }.hexdigest[...2].to_i(16) % @configuration.concurrency
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
class IOError < StandardError; end
|
5
|
+
|
6
|
+
class Pusher
|
7
|
+
def initialize(socket)
|
8
|
+
@socket = socket
|
9
|
+
end
|
10
|
+
|
11
|
+
def write_headers
|
12
|
+
socket_puts headers.join("\n")
|
13
|
+
socket_puts
|
14
|
+
end
|
15
|
+
|
16
|
+
def comment
|
17
|
+
write_chunk [':', "\n"].compact.join("\n")
|
18
|
+
end
|
19
|
+
|
20
|
+
def push(payload, event:)
|
21
|
+
write_chunk ["event:#{event}", payload ? "data:#{payload}" : nil, "\n"].compact.join("\n")
|
22
|
+
end
|
23
|
+
|
24
|
+
def close
|
25
|
+
write_last_chunk
|
26
|
+
socket_close
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def write_chunk(data)
|
32
|
+
socket_puts [data.bytesize.to_s(16), data, ''].join("\r\n")
|
33
|
+
end
|
34
|
+
|
35
|
+
def write_last_chunk
|
36
|
+
socket_puts "0\r\n\r\n"
|
37
|
+
end
|
38
|
+
|
39
|
+
def headers
|
40
|
+
[
|
41
|
+
'HTTP/1.1 200',
|
42
|
+
'Content-Type: text/event-stream',
|
43
|
+
'Transfer-Encoding: chunked',
|
44
|
+
'X-Accel-Buffering: no',
|
45
|
+
'Cache-Control: no-cache'
|
46
|
+
]
|
47
|
+
end
|
48
|
+
|
49
|
+
def socket_puts(data = nil)
|
50
|
+
@socket.puts data
|
51
|
+
rescue StandardError
|
52
|
+
raise IOError
|
53
|
+
end
|
54
|
+
|
55
|
+
def socket_close
|
56
|
+
@socket.close
|
57
|
+
rescue StandardError
|
58
|
+
raise IOError
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pollen
|
4
|
+
class Executor
|
5
|
+
def initialize
|
6
|
+
@events = {}
|
7
|
+
@incoming = []
|
8
|
+
@mutex = Mutex.new
|
9
|
+
@event_loop = EventLoop.new
|
10
|
+
end
|
11
|
+
|
12
|
+
def start!
|
13
|
+
@thread = Thread.new do
|
14
|
+
start_loop
|
15
|
+
end
|
16
|
+
self
|
17
|
+
end
|
18
|
+
|
19
|
+
def push(stream_id, event, data)
|
20
|
+
Rails.logger.info "Pushing data for stream #{stream_id}, event: #{event}"
|
21
|
+
@mutex.synchronize do
|
22
|
+
@events[stream_id] = { event:, data: }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def accept(connection)
|
27
|
+
Rails.logger.info "Creating connection for stream #{connection.stream_id}"
|
28
|
+
@mutex.synchronize do
|
29
|
+
@incoming << connection
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def start_loop
|
36
|
+
loop do
|
37
|
+
next_tick
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def next_tick
|
42
|
+
incoming = nil
|
43
|
+
events = nil
|
44
|
+
@mutex.synchronize do
|
45
|
+
incoming = @incoming
|
46
|
+
events = @events
|
47
|
+
@events = {}
|
48
|
+
@incoming = []
|
49
|
+
end
|
50
|
+
proceed(incoming, events)
|
51
|
+
end
|
52
|
+
|
53
|
+
def proceed(incoming, events)
|
54
|
+
@event_loop.proceed(incoming, events).tap { sleep 0.01 }
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class Server
|
59
|
+
include Configurable
|
60
|
+
|
61
|
+
def initialize
|
62
|
+
@partitioner = Partitioner.new(configuration)
|
63
|
+
end
|
64
|
+
|
65
|
+
def start!
|
66
|
+
@executors = Array.new(configuration.concurrency).map do
|
67
|
+
Executor.new.start!
|
68
|
+
end
|
69
|
+
Subscriber.new(self).start!
|
70
|
+
@started = true
|
71
|
+
self
|
72
|
+
end
|
73
|
+
|
74
|
+
def started?
|
75
|
+
!!@started
|
76
|
+
end
|
77
|
+
|
78
|
+
def push(stream_id, *args)
|
79
|
+
executor(stream_id).push(stream_id, *args)
|
80
|
+
end
|
81
|
+
|
82
|
+
def accept(socket, stream)
|
83
|
+
executor(stream.id).accept(
|
84
|
+
IncomingConnection.new(stream.id, socket, stream.status, stream.payload, Time.now.to_i + stream.timeout)
|
85
|
+
)
|
86
|
+
end
|
87
|
+
|
88
|
+
def configuration
|
89
|
+
return @configuration if @configuration
|
90
|
+
|
91
|
+
@configuration = CompositeConfiguration.new(
|
92
|
+
ServerConfiguration.new,
|
93
|
+
Pollen.common.configuration
|
94
|
+
)
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def executor(stream_id)
|
100
|
+
@executors[@partitioner.partition(stream_id)]
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'redis'
|
4
|
+
|
5
|
+
module Pollen
|
6
|
+
class Subscriber
|
7
|
+
def initialize(server)
|
8
|
+
@server = server
|
9
|
+
end
|
10
|
+
|
11
|
+
def start!
|
12
|
+
if redis.nil?
|
13
|
+
raise Errors::InvalidConfiguration,
|
14
|
+
'Redis not set, please assign a Redis client in server configuration'
|
15
|
+
end
|
16
|
+
|
17
|
+
Thread.new { subscribe! }
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def subscribe!
|
23
|
+
redis.psubscribe("#{channel_prefix}:*") do |on|
|
24
|
+
on.pmessage do |_, channel, message|
|
25
|
+
event, data = message.split(':', 2)
|
26
|
+
stream_id = stream_id_from_channel(channel)
|
27
|
+
@server.push(stream_id, event, data) unless stream_id.blank? || event.blank?
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def stream_id_from_channel(channel)
|
33
|
+
/^#{channel_prefix}:(#{ServerConfiguration::UUID_REGEXP})/.match(channel)&.captures&.first
|
34
|
+
end
|
35
|
+
|
36
|
+
def channel_prefix
|
37
|
+
@channel_prefix ||= Pollen.server.configuration.channel_prefix
|
38
|
+
end
|
39
|
+
|
40
|
+
def redis
|
41
|
+
@redis ||= @server.configuration.redis
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/lib/pollen.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'pollen/commands'
|
4
|
+
require 'pollen/configuration'
|
5
|
+
require 'pollen/event_loop'
|
6
|
+
require 'pollen/errors'
|
7
|
+
require 'pollen/fiber_body'
|
8
|
+
require 'pollen/partitioner'
|
9
|
+
require 'pollen/pusher'
|
10
|
+
|
11
|
+
require 'pollen/subscriber'
|
12
|
+
require 'pollen/controller'
|
13
|
+
require 'pollen/middleware'
|
14
|
+
require 'pollen/server'
|
15
|
+
require 'pollen/engine'
|
16
|
+
|
17
|
+
module Pollen
|
18
|
+
class IncomingConnection
|
19
|
+
attr_reader :stream_id, :socket, :event, :payload, :terminate_at
|
20
|
+
|
21
|
+
def initialize(stream_id, socket, event, payload, terminate_at)
|
22
|
+
@stream_id = stream_id
|
23
|
+
@socket = socket
|
24
|
+
@event = event
|
25
|
+
@payload = payload
|
26
|
+
@terminate_at = terminate_at
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class << self
|
31
|
+
def server
|
32
|
+
@server ||= Server.new
|
33
|
+
end
|
34
|
+
|
35
|
+
def controller
|
36
|
+
@controller ||= Controller.new
|
37
|
+
end
|
38
|
+
|
39
|
+
def common
|
40
|
+
@common ||= Common.new
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
namespace :pollen do
|
4
|
+
task prune_streams: :environment do
|
5
|
+
scope = Pollen::Stream.where('created_at <= :time', time: Time.zone.now - Pollen.controller.configuration.retention)
|
6
|
+
scope.count.tap do |count|
|
7
|
+
scope.delete_all
|
8
|
+
puts "Pruned #{count} old streams"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
metadata
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pollen
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jef Mathiot
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-12-22 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 7.2.0
|
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.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: redis
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: factory_bot_rails
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec-rails
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
description: An HTTP Pub/Sub engine for Rails.
|
70
|
+
email:
|
71
|
+
- jeff.mathiot@gmail.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- MIT-LICENSE
|
77
|
+
- README.md
|
78
|
+
- Rakefile
|
79
|
+
- app/models/pollen/application_record.rb
|
80
|
+
- app/models/pollen/stream.rb
|
81
|
+
- db/migrate/20241125150719_create_pollen_streams.rb
|
82
|
+
- lib/pollen.rb
|
83
|
+
- lib/pollen/commands.rb
|
84
|
+
- lib/pollen/configuration.rb
|
85
|
+
- lib/pollen/controller.rb
|
86
|
+
- lib/pollen/engine.rb
|
87
|
+
- lib/pollen/errors.rb
|
88
|
+
- lib/pollen/event_loop.rb
|
89
|
+
- lib/pollen/fiber_body.rb
|
90
|
+
- lib/pollen/middleware.rb
|
91
|
+
- lib/pollen/partitioner.rb
|
92
|
+
- lib/pollen/pusher.rb
|
93
|
+
- lib/pollen/server.rb
|
94
|
+
- lib/pollen/subscriber.rb
|
95
|
+
- lib/pollen/version.rb
|
96
|
+
- lib/tasks/pollen_tasks.rake
|
97
|
+
homepage: https://github.com/everestHC-mySofie/pollen
|
98
|
+
licenses:
|
99
|
+
- MIT
|
100
|
+
metadata:
|
101
|
+
homepage_uri: https://github.com/everestHC-mySofie/pollen
|
102
|
+
source_code_uri: https://github.com/everestHC-mySofie/pollen
|
103
|
+
post_install_message:
|
104
|
+
rdoc_options: []
|
105
|
+
require_paths:
|
106
|
+
- lib
|
107
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
108
|
+
requirements:
|
109
|
+
- - ">="
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: 3.1.0
|
112
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - ">="
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: '0'
|
117
|
+
requirements: []
|
118
|
+
rubygems_version: 3.5.23
|
119
|
+
signing_key:
|
120
|
+
specification_version: 4
|
121
|
+
summary: An HTTP Pub/Sub engine for Rails.
|
122
|
+
test_files: []
|