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 +7 -0
- data/lib/ration/backends/base.rb +35 -0
- data/lib/ration/backends/memory.rb +52 -0
- data/lib/ration/backends/postgres.rb +118 -0
- data/lib/ration/backends/redis.rb +130 -0
- data/lib/ration/configuration.rb +12 -0
- data/lib/ration/errors.rb +7 -0
- data/lib/ration/hub.rb +86 -0
- data/lib/ration/sse.rb +94 -0
- data/lib/ration/subscription.rb +87 -0
- data/lib/ration/version.rb +3 -0
- data/lib/ration.rb +52 -0
- metadata +81 -0
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
|
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
|
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: []
|