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 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
+ ![Getting started](https://github.com/EverestHC-mySofie/pollen/blob/main/resources/pollen-getting-started.png?raw=true)
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
+ ![Event Loop](https://github.com/EverestHC-mySofie/pollen/blob/main/resources/pollen-event-loop.png?raw=true)
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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
6
+ load 'rails/tasks/engine.rake'
7
+
8
+ load 'rails/tasks/statistics.rake'
9
+
10
+ require 'bundler/gem_tasks'
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pollen
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pollen
4
+ VERSION = '0.1.0'
5
+ 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: []