ration 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: 16eee91f9339a1e635c1913995515056c4e154bc1b8e5a657917591e0aaf033d
4
+ data.tar.gz: 4f059b3b5a90c4c8dcd72075ba231488248c391aebb528a916eef511e33ce20b
5
+ SHA512:
6
+ metadata.gz: 3be362124e709b25d33df4a9f7997aac2c592c689c6c98d7e27fb825da626af4749dccaf3c3f15244ff50d9e6a1aa5cc181c6553d74f5f333aacf88f8396d42a
7
+ data.tar.gz: f467fa48b7727e23a3b9f799ac218aea86589da4270a8f60e2cb0506c0b739f88718fa9f7671699aeac455d4ac0c0de43376e67373e3c27b3eab756f47bfacdb
@@ -0,0 +1,35 @@
1
+ module Ration
2
+ module Backends
3
+ class Base
4
+ DEFAULT_MAX_PAYLOAD_BYTES = 6 * 1024
5
+
6
+ def publish(event)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def on_event(&block)
11
+ @on_event = block
12
+ end
13
+
14
+ def start
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def stop
19
+ raise NotImplementedError
20
+ end
21
+
22
+ protected
23
+
24
+ def emit(event)
25
+ @on_event&.call(event)
26
+ end
27
+
28
+ def check_payload_size!(payload, limit)
29
+ return if payload.bytesize <= limit
30
+
31
+ raise PayloadTooLarge, "Payload is #{payload.bytesize} bytes, max is #{limit}"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,52 @@
1
+ require 'json'
2
+ require 'logger'
3
+
4
+ module Ration
5
+ module Backends
6
+ class Memory < Base
7
+ def initialize(max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES, sync: false, logger: nil)
8
+ super()
9
+ @max_payload_bytes = max_payload_bytes
10
+ @sync = sync
11
+ @logger = logger || Logger.new($stderr)
12
+ @queue = nil
13
+ @thread = nil
14
+ end
15
+
16
+ def publish(event)
17
+ check_payload_size!(event.to_json, @max_payload_bytes)
18
+
19
+ if @sync
20
+ emit(event)
21
+ else
22
+ @queue.push(event)
23
+ end
24
+ end
25
+
26
+ def start
27
+ return if @sync
28
+ return if @thread
29
+
30
+ @queue = Queue.new
31
+ @thread = Thread.new {
32
+ while (event = @queue.pop)
33
+ begin
34
+ emit(event)
35
+ rescue => e
36
+ @logger.error("Ration::Backends::Memory listener error: #{e.class}: #{e.message}")
37
+ end
38
+ end
39
+ }
40
+ end
41
+
42
+ def stop
43
+ return if @sync
44
+
45
+ @queue&.close
46
+ @thread&.join
47
+ @queue = nil
48
+ @thread = nil
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,118 @@
1
+ require 'json'
2
+ require 'logger'
3
+ require 'pg'
4
+
5
+ module Ration
6
+ module Backends
7
+ class Postgres < Base
8
+ DEFAULT_CHANNEL = 'ration'
9
+ INITIAL_BACKOFF_SECONDS = 1
10
+ MAX_BACKOFF_SECONDS = 30
11
+ DEFAULT_POLL_INTERVAL = 1.0
12
+
13
+ def initialize(
14
+ url:,
15
+ channel: DEFAULT_CHANNEL,
16
+ max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES,
17
+ poll_interval: DEFAULT_POLL_INTERVAL,
18
+ publish_with: nil,
19
+ logger: nil
20
+ )
21
+ super()
22
+ @url = url
23
+ @channel = channel
24
+ @max_payload_bytes = max_payload_bytes
25
+ @poll_interval = poll_interval
26
+ @publish_with = publish_with
27
+ @logger = logger || Logger.new($stderr)
28
+ @thread = nil
29
+ @stop = false
30
+ end
31
+
32
+ def publish(event)
33
+ payload = event.to_json
34
+ check_payload_size!(payload, @max_payload_bytes)
35
+
36
+ if @publish_with
37
+ @publish_with.call(@channel, payload)
38
+ else
39
+ conn = PG.connect(@url)
40
+ begin
41
+ conn.exec_params('SELECT pg_notify($1, $2)', [@channel, payload])
42
+ ensure
43
+ conn.close
44
+ end
45
+ end
46
+ end
47
+
48
+ def start
49
+ return if @thread
50
+
51
+ @stop = false
52
+ initial_conn = connect_and_listen
53
+ @thread = Thread.new { run_loop(initial_conn) }
54
+ end
55
+
56
+ def stop
57
+ @stop = true
58
+ @thread&.join
59
+ @thread = nil
60
+ end
61
+
62
+ private
63
+
64
+ def connect_and_listen
65
+ conn = PG.connect(@url)
66
+ conn.exec("LISTEN #{conn.escape_identifier(@channel)}")
67
+ conn
68
+ end
69
+
70
+ def run_loop(conn)
71
+ current = conn
72
+
73
+ until @stop
74
+ begin
75
+ listen_loop(current)
76
+ rescue PG::Error => e
77
+ @logger.error("Ration::Backends::Postgres listener error: #{e.class}: #{e.message}")
78
+ current&.close
79
+ current = reconnect_with_backoff
80
+ end
81
+ end
82
+
83
+ current&.close
84
+ end
85
+
86
+ def reconnect_with_backoff
87
+ backoff = INITIAL_BACKOFF_SECONDS
88
+
89
+ until @stop
90
+ sleep(backoff)
91
+ break if @stop
92
+
93
+ begin
94
+ return connect_and_listen
95
+ rescue PG::Error => e
96
+ @logger.error("Ration::Backends::Postgres reconnect failed: #{e.class}: #{e.message}")
97
+ backoff = [backoff * 2, MAX_BACKOFF_SECONDS].min
98
+ end
99
+ end
100
+
101
+ nil
102
+ end
103
+
104
+ def listen_loop(conn)
105
+ until @stop
106
+ conn.wait_for_notify(@poll_interval) do |_channel, _pid, payload|
107
+ begin
108
+ event = JSON.parse(payload, symbolize_names: true)
109
+ emit(event)
110
+ rescue JSON::ParserError => e
111
+ @logger.error("Ration::Backends::Postgres received invalid JSON: #{e.message}")
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,130 @@
1
+ require 'json'
2
+ require 'logger'
3
+ require 'redis-client'
4
+
5
+ module Ration
6
+ module Backends
7
+ class Redis < Base
8
+ DEFAULT_CHANNEL = 'ration'
9
+ INITIAL_BACKOFF_SECONDS = 1
10
+ MAX_BACKOFF_SECONDS = 30
11
+ DEFAULT_POLL_INTERVAL = 1.0
12
+
13
+ def initialize(
14
+ url:,
15
+ channel: DEFAULT_CHANNEL,
16
+ max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES,
17
+ poll_interval: DEFAULT_POLL_INTERVAL,
18
+ publish_with: nil,
19
+ logger: nil
20
+ )
21
+ super()
22
+ @url = url
23
+ @channel = channel
24
+ @max_payload_bytes = max_payload_bytes
25
+ @poll_interval = poll_interval
26
+ @publish_with = publish_with
27
+ @logger = logger || Logger.new($stderr)
28
+ @config = RedisClient.config(url: url)
29
+ @thread = nil
30
+ @stop = false
31
+ end
32
+
33
+ def publish(event)
34
+ payload = event.to_json
35
+ check_payload_size!(payload, @max_payload_bytes)
36
+
37
+ if @publish_with
38
+ @publish_with.call(@channel, payload)
39
+ else
40
+ client = @config.new_client
41
+ begin
42
+ client.call('PUBLISH', @channel, payload)
43
+ ensure
44
+ client.close
45
+ end
46
+ end
47
+ end
48
+
49
+ def start
50
+ return if @thread
51
+
52
+ @stop = false
53
+ initial = subscribe_client
54
+ @thread = Thread.new { run_loop(initial) }
55
+ end
56
+
57
+ def stop
58
+ @stop = true
59
+ @thread&.join
60
+ @thread = nil
61
+ end
62
+
63
+ private
64
+
65
+ def subscribe_client
66
+ client = @config.new_client
67
+ pubsub = client.pubsub
68
+ pubsub.call('SUBSCRIBE', @channel)
69
+ pubsub
70
+ end
71
+
72
+ def run_loop(initial)
73
+ current = initial
74
+
75
+ until @stop
76
+ begin
77
+ listen_loop(current)
78
+ rescue RedisClient::Error => e
79
+ @logger.error("Ration::Backends::Redis listener error: #{e.class}: #{e.message}")
80
+ close_quietly(current)
81
+ current = reconnect_with_backoff
82
+ end
83
+ end
84
+
85
+ close_quietly(current)
86
+ end
87
+
88
+ def reconnect_with_backoff
89
+ backoff = INITIAL_BACKOFF_SECONDS
90
+
91
+ until @stop
92
+ sleep(backoff)
93
+ break if @stop
94
+
95
+ begin
96
+ return subscribe_client
97
+ rescue RedisClient::Error => e
98
+ @logger.error("Ration::Backends::Redis reconnect failed: #{e.class}: #{e.message}")
99
+ backoff = [backoff * 2, MAX_BACKOFF_SECONDS].min
100
+ end
101
+ end
102
+
103
+ nil
104
+ end
105
+
106
+ def listen_loop(pubsub)
107
+ until @stop
108
+ event = pubsub.next_event(@poll_interval)
109
+ next if event.nil?
110
+
111
+ type, _channel, payload = event
112
+ next unless type == 'message'
113
+
114
+ begin
115
+ parsed = JSON.parse(payload, symbolize_names: true)
116
+ emit(parsed)
117
+ rescue JSON::ParserError => e
118
+ @logger.error("Ration::Backends::Redis received invalid JSON: #{e.message}")
119
+ end
120
+ end
121
+ end
122
+
123
+ def close_quietly(pubsub)
124
+ pubsub&.close
125
+ rescue RedisClient::Error
126
+ # already broken; ignore
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,12 @@
1
+ require 'logger'
2
+
3
+ module Ration
4
+ class Configuration
5
+ attr_accessor :backend, :logger
6
+
7
+ def initialize
8
+ @backend = nil
9
+ @logger = Logger.new($stderr)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ module Ration
2
+ class Error < StandardError; end
3
+
4
+ class PayloadTooLarge < Error; end
5
+
6
+ class NotConfigured < Error; end
7
+ end
data/lib/ration/hub.rb ADDED
@@ -0,0 +1,86 @@
1
+ require 'concurrent'
2
+
3
+ module Ration
4
+ class Hub
5
+ def initialize(backend:, logger:)
6
+ @backend = backend
7
+ @logger = logger
8
+ @subscriptions = Concurrent::Map.new
9
+ @started = false
10
+ @start_mutex = Mutex.new
11
+ end
12
+
13
+ def publish(event)
14
+ ensure_started
15
+ @backend.publish(event)
16
+ end
17
+
18
+ def subscribe(max:, filter: nil, on_overflow: :close)
19
+ ensure_started
20
+
21
+ sub = Subscription.new(
22
+ max: max,
23
+ filter: filter,
24
+ on_overflow: on_overflow,
25
+ logger: @logger
26
+ )
27
+ @subscriptions[sub.id] = sub
28
+
29
+ return sub unless block_given?
30
+
31
+ begin
32
+ yield sub
33
+ ensure
34
+ unsubscribe(sub)
35
+ end
36
+ end
37
+
38
+ def unsubscribe(sub)
39
+ @subscriptions.delete(sub.id)
40
+ sub.close
41
+ end
42
+
43
+ def stop
44
+ @start_mutex.synchronize do
45
+ @subscriptions.each_value(&:close)
46
+ @subscriptions.clear
47
+ @backend.stop if @started
48
+ @started = false
49
+ end
50
+ end
51
+
52
+ def subscription_count
53
+ @subscriptions.size
54
+ end
55
+
56
+ private
57
+
58
+ def ensure_started
59
+ return if @started
60
+
61
+ @start_mutex.synchronize do
62
+ return if @started
63
+
64
+ @backend.on_event {|event| deliver(event) }
65
+ @backend.start
66
+ @started = true
67
+ end
68
+ end
69
+
70
+ def deliver(event)
71
+ closed_ids = []
72
+
73
+ @subscriptions.each_pair do |id, sub|
74
+ begin
75
+ sub.deliver(event)
76
+ rescue => e
77
+ @logger.error("Ration delivery error for subscription #{id}: #{e.class}: #{e.message}")
78
+ end
79
+
80
+ closed_ids << id if sub.closed?
81
+ end
82
+
83
+ closed_ids.each {|id| @subscriptions.delete(id) }
84
+ end
85
+ end
86
+ end
data/lib/ration/sse.rb ADDED
@@ -0,0 +1,94 @@
1
+ require 'json'
2
+
3
+ module Ration
4
+ module SSE
5
+ DEFAULT_ID_FROM = ->(event) { event[:id] }
6
+
7
+ class << self
8
+ def event(data:, event: nil, id: nil, retry_ms: nil)
9
+ raise ArgumentError, 'data is required' if data.nil?
10
+
11
+ lines = []
12
+
13
+ if event
14
+ ensure_field_value_safe!(event.to_s, 'event')
15
+ lines << "event: #{event}"
16
+ end
17
+
18
+ if id
19
+ ensure_field_value_safe!(id.to_s, 'id')
20
+ lines << "id: #{id}"
21
+ end
22
+
23
+ unless retry_ms.nil?
24
+ unless retry_ms.is_a?(Integer) && retry_ms >= 0
25
+ raise ArgumentError, 'retry_ms must be a non-negative Integer'
26
+ end
27
+
28
+ lines << "retry: #{retry_ms}"
29
+ end
30
+
31
+ payload = data.is_a?(String) ? data : data.to_json
32
+ payload.split("\n", -1).each do |line|
33
+ lines << "data: #{line}"
34
+ end
35
+
36
+ lines.join("\n") + "\n\n"
37
+ end
38
+
39
+ def comment(text = '')
40
+ ensure_no_newline!(text.to_s, 'comment')
41
+ ": #{text}\n\n"
42
+ end
43
+
44
+ def ping
45
+ ": ping\n\n"
46
+ end
47
+
48
+ def stream(
49
+ subscription,
50
+ output,
51
+ heartbeat: 15,
52
+ since: nil,
53
+ id_from: DEFAULT_ID_FROM
54
+ )
55
+ raise ArgumentError, 'block required' unless block_given?
56
+
57
+ last = since
58
+
59
+ subscription.each_event(timeout: heartbeat) do |event|
60
+ if event.nil?
61
+ output << ping if heartbeat
62
+ next
63
+ end
64
+
65
+ if last
66
+ id = id_from.call(event)
67
+ next if id.nil? || id <= last
68
+
69
+ last = id
70
+ end
71
+
72
+ framed = yield(event)
73
+ output << framed if framed
74
+ end
75
+
76
+ last
77
+ end
78
+
79
+ private
80
+
81
+ def ensure_field_value_safe!(value, field)
82
+ if value.match?(/[\r\n\0]/)
83
+ raise ArgumentError, "#{field} must not contain newlines or NULL characters"
84
+ end
85
+ end
86
+
87
+ def ensure_no_newline!(value, field)
88
+ if value.match?(/[\r\n]/)
89
+ raise ArgumentError, "#{field} must not contain newlines"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,87 @@
1
+ require 'securerandom'
2
+
3
+ module Ration
4
+ class Subscription
5
+ OVERFLOW_POLICIES = %i[close drop_oldest].freeze
6
+
7
+ attr_reader :id
8
+
9
+ def initialize(max:, filter: nil, on_overflow: :close, logger:)
10
+ unless OVERFLOW_POLICIES.include?(on_overflow)
11
+ raise ArgumentError, "Unknown on_overflow: #{on_overflow.inspect} (expected one of #{OVERFLOW_POLICIES.inspect})"
12
+ end
13
+
14
+ @id = SecureRandom.uuid
15
+ @queue = SizedQueue.new(max)
16
+ @filter = filter
17
+ @on_overflow = on_overflow
18
+ @logger = logger
19
+ end
20
+
21
+ def pop(timeout: nil)
22
+ @queue.pop(timeout: timeout)
23
+ end
24
+
25
+ def each_event(timeout: nil)
26
+ return enum_for(:each_event, timeout: timeout) unless block_given?
27
+
28
+ until closed?
29
+ event = pop(timeout: timeout)
30
+ break if closed?
31
+
32
+ yield event
33
+ end
34
+
35
+ self
36
+ end
37
+
38
+ def closed?
39
+ @queue.closed?
40
+ end
41
+
42
+ def close
43
+ @queue.close
44
+ end
45
+
46
+ def deliver(event)
47
+ return if closed?
48
+ return unless passes_filter?(event)
49
+
50
+ begin
51
+ @queue.push(event, true)
52
+ rescue ThreadError
53
+ handle_overflow(event)
54
+ rescue ClosedQueueError
55
+ # closed concurrently; nothing to do
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def passes_filter?(event)
62
+ return true if @filter.nil?
63
+
64
+ @filter.call(event)
65
+ rescue => e
66
+ @logger.error("Ration filter raised, closing subscription #{@id}: #{e.class}: #{e.message}")
67
+ close
68
+ false
69
+ end
70
+
71
+ def handle_overflow(event)
72
+ case @on_overflow
73
+ when :close
74
+ close
75
+ when :drop_oldest
76
+ begin
77
+ @queue.pop(true)
78
+ @queue.push(event, true)
79
+ rescue ThreadError
80
+ # race: another thread popped first, or queue got closed; ignore
81
+ rescue ClosedQueueError
82
+ # closed concurrently; ignore
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,3 @@
1
+ module Ration
2
+ VERSION = '0.1.0'
3
+ end
data/lib/ration.rb ADDED
@@ -0,0 +1,52 @@
1
+ require_relative 'ration/version'
2
+ require_relative 'ration/errors'
3
+ require_relative 'ration/configuration'
4
+ require_relative 'ration/subscription'
5
+ require_relative 'ration/hub'
6
+ require_relative 'ration/backends/base'
7
+ require_relative 'ration/backends/memory'
8
+
9
+ module Ration
10
+ class << self
11
+ def configure
12
+ yield config
13
+
14
+ if config.backend.nil?
15
+ raise NotConfigured, 'No backend configured. Set config.backend to a Ration::Backends::* instance.'
16
+ end
17
+
18
+ @hub&.stop
19
+ @hub = Hub.new(backend: config.backend, logger: config.logger)
20
+ end
21
+
22
+ def config
23
+ @config ||= Configuration.new
24
+ end
25
+
26
+ def publish(event)
27
+ hub.publish(event)
28
+ end
29
+
30
+ def subscribe(**kwargs, &block)
31
+ hub.subscribe(**kwargs, &block)
32
+ end
33
+
34
+ def unsubscribe(sub)
35
+ hub.unsubscribe(sub)
36
+ end
37
+
38
+ def reset!
39
+ @hub&.stop
40
+ @hub = nil
41
+ @config = nil
42
+ end
43
+
44
+ private
45
+
46
+ def hub
47
+ raise NotConfigured, 'Ration is not configured. Call Ration.configure first.' unless @hub
48
+
49
+ @hub
50
+ end
51
+ end
52
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ration
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Keita Urashima
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: concurrent-ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: logger
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.6'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.6'
40
+ description: 'A backend-agnostic event distribution layer for Rails SSE: a single
41
+ listener thread per process receives events from a pub/sub backend (Postgres LISTEN/NOTIFY,
42
+ Redis Pub/Sub, etc.) and fans them out to per-connection bounded queues.'
43
+ email:
44
+ - ursm@ursm.jp
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - lib/ration.rb
50
+ - lib/ration/backends/base.rb
51
+ - lib/ration/backends/memory.rb
52
+ - lib/ration/backends/postgres.rb
53
+ - lib/ration/backends/redis.rb
54
+ - lib/ration/configuration.rb
55
+ - lib/ration/errors.rb
56
+ - lib/ration/hub.rb
57
+ - lib/ration/sse.rb
58
+ - lib/ration/subscription.rb
59
+ - lib/ration/version.rb
60
+ homepage: https://github.com/ursm/ration
61
+ licenses:
62
+ - MIT
63
+ metadata: {}
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '3.3'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 4.0.10
79
+ specification_version: 4
80
+ summary: Per-process pub/sub fan-out for Rails SSE
81
+ test_files: []