hivent 1.0.1

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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +19 -0
  3. data/.gitignore +14 -0
  4. data/.rspec +1 -0
  5. data/.rubocop.yml +1063 -0
  6. data/.ruby-version +1 -0
  7. data/.simplecov.template +1 -0
  8. data/.travis.yml +23 -0
  9. data/.version +1 -0
  10. data/Gemfile +4 -0
  11. data/LICENSE +21 -0
  12. data/README.md +196 -0
  13. data/bin/hivent +5 -0
  14. data/hivent.gemspec +34 -0
  15. data/lib/hivent.rb +32 -0
  16. data/lib/hivent/abstract_signal.rb +63 -0
  17. data/lib/hivent/cli/consumer.rb +60 -0
  18. data/lib/hivent/cli/runner.rb +50 -0
  19. data/lib/hivent/cli/start_option_parser.rb +53 -0
  20. data/lib/hivent/config.rb +22 -0
  21. data/lib/hivent/config/options.rb +51 -0
  22. data/lib/hivent/emitter.rb +41 -0
  23. data/lib/hivent/life_cycle_event_handler.rb +41 -0
  24. data/lib/hivent/redis/consumer.rb +82 -0
  25. data/lib/hivent/redis/extensions.rb +26 -0
  26. data/lib/hivent/redis/lua/consumer.lua +179 -0
  27. data/lib/hivent/redis/lua/producer.lua +27 -0
  28. data/lib/hivent/redis/producer.rb +24 -0
  29. data/lib/hivent/redis/redis.rb +14 -0
  30. data/lib/hivent/redis/signal.rb +36 -0
  31. data/lib/hivent/rspec.rb +11 -0
  32. data/lib/hivent/signal.rb +14 -0
  33. data/lib/hivent/spec.rb +11 -0
  34. data/lib/hivent/spec/matchers.rb +14 -0
  35. data/lib/hivent/spec/matchers/emit.rb +116 -0
  36. data/lib/hivent/spec/signal.rb +60 -0
  37. data/lib/hivent/version.rb +6 -0
  38. data/spec/codeclimate_helper.rb +5 -0
  39. data/spec/fixtures/cli/bootstrap_consumers.rb +7 -0
  40. data/spec/fixtures/cli/life_cycle_event_test.rb +8 -0
  41. data/spec/hivent/abstract_signal_spec.rb +161 -0
  42. data/spec/hivent/cli/consumer_spec.rb +68 -0
  43. data/spec/hivent/cli/runner_spec.rb +75 -0
  44. data/spec/hivent/cli/start_option_parser_spec.rb +48 -0
  45. data/spec/hivent/life_cycle_event_handler_spec.rb +38 -0
  46. data/spec/hivent/redis/consumer_spec.rb +348 -0
  47. data/spec/hivent/redis/signal_spec.rb +155 -0
  48. data/spec/hivent_spec.rb +100 -0
  49. data/spec/spec/matchers/emit_spec.rb +66 -0
  50. data/spec/spec/signal_spec.rb +72 -0
  51. data/spec/spec_helper.rb +27 -0
  52. data/spec/support/matchers/exit_with_code.rb +28 -0
  53. data/spec/support/stdout_helpers.rb +25 -0
  54. metadata +267 -0
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+ require 'optparse'
3
+ require "active_support"
4
+ require "active_support/core_ext"
5
+
6
+ require_relative "./start_option_parser"
7
+ require_relative "./consumer"
8
+
9
+ module Hivent
10
+
11
+ module CLI
12
+
13
+ class Runner
14
+
15
+ OPTION_PARSERS = {
16
+ start: StartOptionParser
17
+ }.freeze
18
+
19
+ def initialize(argv)
20
+ @argv = argv
21
+ @command = @argv.shift.to_s.to_sym
22
+ end
23
+
24
+ def run
25
+ if parser = OPTION_PARSERS[@command]
26
+ send(@command, parser.new(@command, @argv).parse)
27
+ else
28
+ puts help
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def start(options)
35
+ Consumer.run!(options)
36
+ end
37
+
38
+ def help
39
+ <<-EOS.strip_heredoc
40
+ Available COMMANDs are:
41
+ start : starts one or multiple the consumer
42
+ See 'hivent COMMAND --help' for more information on a specific command.
43
+ EOS
44
+ end
45
+
46
+ end
47
+
48
+ end
49
+
50
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ module Hivent
3
+
4
+ module CLI
5
+
6
+ class StartOptionParser
7
+
8
+ def initialize(command, argv)
9
+ @command = command
10
+ @argv = argv
11
+ end
12
+
13
+ def parse
14
+ return @options if @options
15
+ @options = {}
16
+
17
+ parser = OptionParser.new do |o|
18
+ o.banner = "Usage: hivent #{@command} [options]"
19
+
20
+ o.on('-r', '--require PATH', 'File to require to bootstrap consumers') do |arg|
21
+ @options[:require] = arg
22
+ end
23
+
24
+ o.on('-p', '--pid-dir DIR', 'Location of worker pid files') do |arg|
25
+ @options[:pid_dir] = arg
26
+ end
27
+ end
28
+
29
+ parser.parse(@argv)
30
+
31
+ validate_options
32
+
33
+ @options
34
+ end
35
+
36
+ def validate_options
37
+ if @options[:require].nil? || !File.exist?(@options[:require])
38
+ puts <<-EOS.strip_heredoc
39
+ =========================================================
40
+ Please point hivent to a Ruby file
41
+ to load your consumers with -r FILE.
42
+ =========================================================
43
+ EOS
44
+
45
+ exit(1)
46
+ end
47
+ end
48
+
49
+ end
50
+
51
+ end
52
+
53
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ require "hivent/life_cycle_event_handler"
3
+ require "hivent/config/options"
4
+
5
+ module Hivent
6
+
7
+ module Config
8
+
9
+ SUPPORTED_BACKENDS = [:redis].freeze
10
+
11
+ extend self
12
+ extend Options
13
+
14
+ option :client_id, validate: ->(value) { value.present? }
15
+ option :backend, validate: ->(value) { SUPPORTED_BACKENDS.include?(value.to_sym) }
16
+ option :endpoint
17
+ option :partition_count, default: 1, validate: ->(value) { value.is_a?(Integer) && value.positive? }
18
+ option :life_cycle_event_handler, default: LifeCycleEventHandler.new
19
+
20
+ end
21
+
22
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ module Hivent
3
+
4
+ module Config
5
+
6
+ module Options
7
+
8
+ class UnsupportedOptionError < StandardError; end
9
+
10
+ def defaults
11
+ @defaults ||= {}
12
+ end
13
+
14
+ def validators
15
+ @validators ||= {}
16
+ end
17
+
18
+ def option(name, options = {})
19
+ defaults[name] = settings[name] = options[:default]
20
+ validators[name] = options[:validate] || ->(_value) { true }
21
+
22
+ class_eval <<-RUBY
23
+ def #{name}
24
+ settings[#{name.inspect}]
25
+ end
26
+ def #{name}=(value)
27
+ unless validators[#{name.inspect.to_sym}].(value)
28
+ raise UnsupportedOptionError.new("Unsupported value " + value.inspect + " for option #{name.inspect}")
29
+ end
30
+
31
+ settings[#{name.inspect}] = value
32
+ end
33
+ def #{name}?
34
+ #{name}
35
+ end
36
+
37
+ def reset_#{name}
38
+ settings[#{name.inspect}] = defaults[#{name.inspect}]
39
+ end
40
+ RUBY
41
+ end
42
+
43
+ def settings
44
+ @settings ||= {}
45
+ end
46
+
47
+ end
48
+
49
+ end
50
+
51
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ module Hivent
3
+
4
+ class Emitter
5
+
6
+ include EventEmitter
7
+ attr_accessor :events
8
+
9
+ WILDCARD = :all
10
+
11
+ def initialize
12
+ @events = []
13
+ end
14
+
15
+ def broadcast(payload)
16
+ emittable_event_names(payload.with_indifferent_access).each do |emittable_event_name|
17
+ emit(emittable_event_name, payload)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def emittable_event_names(payload)
24
+ [
25
+ event_name(payload),
26
+ [event_name(payload), event_version(payload)].join(":"),
27
+ WILDCARD
28
+ ]
29
+ end
30
+
31
+ def event_name(payload)
32
+ payload[:meta].try(:[], :name)
33
+ end
34
+
35
+ def event_version(payload)
36
+ payload[:meta].try(:[], :version)
37
+ end
38
+
39
+ end
40
+
41
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ module Hivent
3
+
4
+ class LifeCycleEventHandler
5
+
6
+ # Invoked when a consumer worker starts and registers events and partion count.
7
+ #
8
+ # parameters:
9
+ # client_id: name of the application
10
+ # events: array of hashes for the registered events ([{ name: "my:event", version: 1 }, ...])
11
+ # partition_count: number of partitions registered for this application
12
+ def application_registered(client_id, events, partition_count)
13
+ # do nothing
14
+ end
15
+
16
+ # Invoked when an event has successfully been processed by all registered handlers
17
+ #
18
+ # parameters:
19
+ # event_name: name of the processed event
20
+ # event_version: version of the processed event
21
+ # payload: payload of the processed event
22
+ def event_processing_succeeded(event_name, event_version, payload)
23
+ # do nothing
24
+ end
25
+
26
+ # Invoked when processing an event failed. Either the payload could not be parsed as JSON or the payload did not
27
+ # contain all required information or an application error happend while processing in one of the registered
28
+ # handlers.
29
+ #
30
+ # parameters:
31
+ # exception: the exception that occurred
32
+ # payload: the parsed payload or nil if event payload was invalid JSON
33
+ # raw_payload: the original unparsed payload (String)
34
+ # dead_letter_queue_name: name of the dead letter queue this event has been sent to
35
+ def event_processing_failed(exception, payload, raw_payload, dead_letter_queue_name)
36
+ # do nothing
37
+ end
38
+
39
+ end
40
+
41
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+ module Hivent
3
+
4
+ module Redis
5
+
6
+ class Consumer
7
+
8
+ include Hivent::Redis::Extensions
9
+
10
+ LUA_CONSUMER = File.expand_path("../lua/consumer.lua", __FILE__)
11
+ # In milliseconds
12
+ SLEEP_TIME = 200
13
+ CONSUMER_TTL = 1000
14
+
15
+ def initialize(redis, service_name, name, life_cycle_event_handler)
16
+ @redis = redis
17
+ @service_name = service_name
18
+ @name = name
19
+ @stop = false
20
+ @life_cycle_event_handler = life_cycle_event_handler
21
+ end
22
+
23
+ def run!
24
+ consume while !@stop
25
+ end
26
+
27
+ def stop!
28
+ @stop = true
29
+ end
30
+
31
+ def queues
32
+ script(LUA_CONSUMER, @service_name, @name, CONSUMER_TTL)
33
+ end
34
+
35
+ def consume
36
+ to_process = items
37
+
38
+ to_process.each do |(queue, item)|
39
+ payload = nil
40
+ begin
41
+ payload = JSON.parse(item).with_indifferent_access
42
+
43
+ Hivent.emitter.broadcast(payload)
44
+
45
+ @life_cycle_event_handler.event_processing_succeeded(event_name(payload), event_version(payload), payload)
46
+ rescue => e
47
+ @redis.lpush(dead_letter_queue_name(queue), item)
48
+
49
+ @life_cycle_event_handler.event_processing_failed(e, payload, item, dead_letter_queue_name(queue))
50
+ end
51
+
52
+ @redis.rpop(queue)
53
+ end
54
+
55
+ Kernel.sleep(SLEEP_TIME.to_f / 1000) if to_process.empty?
56
+ end
57
+
58
+ private
59
+
60
+ def items
61
+ queues
62
+ .map { |queue| [queue, @redis.lindex(queue, -1)] }
63
+ .select { |(_queue, item)| item }
64
+ end
65
+
66
+ def event_name(payload)
67
+ payload["meta"].try(:[], "name")
68
+ end
69
+
70
+ def event_version(payload)
71
+ payload["meta"].try(:[], "version")
72
+ end
73
+
74
+ def dead_letter_queue_name(queue)
75
+ "#{queue}:dead_letter"
76
+ end
77
+
78
+ end
79
+
80
+ end
81
+
82
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ module Hivent
3
+
4
+ module Redis
5
+
6
+ module Extensions
7
+
8
+ LUA_CACHE = Hash.new { |h, k| h[k] = Hash.new }
9
+
10
+ def script(file, *args)
11
+ cache = LUA_CACHE[@redis.client.options[:url]]
12
+
13
+ sha = if cache.key?(file)
14
+ cache[file]
15
+ else
16
+ cache[file] = @redis.script("LOAD", File.read(file))
17
+ end
18
+
19
+ @redis.evalsha(sha, [], args)
20
+ end
21
+
22
+ end
23
+
24
+ end
25
+
26
+ end
@@ -0,0 +1,179 @@
1
+ local service_name = ARGV[1]
2
+ local consumer_name = ARGV[2]
3
+ local CONSUMER_TTL = ARGV[3]
4
+
5
+ -- Performs deep equality between two tables
6
+ local function table_eq(table1, table2)
7
+ local avoid_loops = {}
8
+ local function recurse(t1, t2)
9
+ -- compare value types
10
+ if type(t1) ~= type(t2) then return false end
11
+ -- Base case: compare simple values
12
+ if type(t1) ~= "table" then return t1 == t2 end
13
+ -- Now, on to tables.
14
+ -- First, let's avoid looping forever.
15
+ if avoid_loops[t1] then return avoid_loops[t1] == t2 end
16
+ avoid_loops[t1] = t2
17
+ -- Copy keys from t2
18
+ local t2keys = {}
19
+ local t2tablekeys = {}
20
+ for k, _ in pairs(t2) do
21
+ if type(k) == "table" then table.insert(t2tablekeys, k) end
22
+ t2keys[k] = true
23
+ end
24
+ -- Let's iterate keys from t1
25
+ for k1, v1 in pairs(t1) do
26
+ local v2 = t2[k1]
27
+ if type(k1) == "table" then
28
+ -- if key is a table, we need to find an equivalent one.
29
+ local ok = false
30
+ for i, tk in ipairs(t2tablekeys) do
31
+ if table_eq(k1, tk) and recurse(v1, t2[tk]) then
32
+ table.remove(t2tablekeys, i)
33
+ t2keys[tk] = nil
34
+ ok = true
35
+ break
36
+ end
37
+ end
38
+ if not ok then return false end
39
+ else
40
+ -- t1 has a key which t2 doesn't have, fail.
41
+ if v2 == nil then return false end
42
+ t2keys[k1] = nil
43
+ if not recurse(v1, v2) then return false end
44
+ end
45
+ end
46
+ -- if t2 has a key which t1 doesn't have, fail.
47
+ if next(t2keys) then return false end
48
+ return true
49
+ end
50
+ return recurse(table1, table2)
51
+ end
52
+
53
+ local function keepalive(service, consumer)
54
+ redis.call("SET", service .. ":" .. consumer .. ":alive", "true", "PX", CONSUMER_TTL)
55
+ redis.call("SADD", service .. ":consumers", consumer)
56
+ end
57
+
58
+ local function cleanup(service)
59
+ local consumer_index_key = service .. ":consumers"
60
+ local consumers = redis.call("SMEMBERS", consumer_index_key)
61
+
62
+ for _, consumer in ipairs(consumers) do
63
+ local consumer_status_key = service .. ":" .. consumer .. ":alive"
64
+ local alive = redis.call("GET", consumer_status_key)
65
+
66
+ if not alive then
67
+ redis.call("SREM", consumer_index_key, consumer)
68
+ end
69
+ end
70
+ end
71
+
72
+ local function distribute(consumers, partition_count)
73
+ local distribution = {}
74
+ local consumer_count = table.getn(consumers)
75
+ local remainder = partition_count % consumer_count
76
+
77
+ for i=1,consumer_count do
78
+ distribution[i] = math.floor(partition_count/consumer_count)
79
+ end
80
+
81
+ for i=1,remainder do
82
+ distribution[i] = distribution[i] + 1
83
+ end
84
+
85
+ return distribution
86
+ end
87
+
88
+ local function getdesiredstate(service_name, consumers, partition_count)
89
+ local state = {}
90
+ local distribution = distribute(consumers, partition_count)
91
+ local consumer_count = table.getn(consumers)
92
+ local assigned_partition_count = 0
93
+
94
+ for i=1,consumer_count do
95
+ state[consumers[i]] = {}
96
+
97
+ for j=1,distribution[i] do
98
+ table.insert(state[consumers[i]], 1, service_name .. ":" .. j + assigned_partition_count - 1)
99
+ end
100
+
101
+ assigned_partition_count = assigned_partition_count + distribution[i]
102
+ end
103
+
104
+ return state
105
+ end
106
+
107
+ local function getcurrentstate(service_name, consumers)
108
+ local state = {}
109
+
110
+ for _, consumer in ipairs(consumers) do
111
+ local assigned_key = service_name .. ":" .. consumer .. ":assigned"
112
+ state[consumer] = redis.call("LRANGE", assigned_key, 0, -1)
113
+ end
114
+
115
+ return state
116
+ end
117
+
118
+ local function states_match(state1, state2)
119
+ return table_eq(state1, state2)
120
+ end
121
+
122
+ local function all_free(workers)
123
+ local total_count = 0
124
+
125
+ for _, partitions in pairs(workers) do
126
+ total_count = total_count + table.getn(partitions)
127
+ end
128
+
129
+ return total_count == 0
130
+ end
131
+
132
+ local function save_state(service_name, state)
133
+ for worker, partitions in pairs(state) do
134
+ for _, partition in ipairs(partitions) do
135
+ redis.call("RPUSH", service_name .. ":" .. worker .. ":assigned", partition)
136
+ redis.call("EXPIRE", service_name .. ":" .. worker .. ":assigned", CONSUMER_TTL)
137
+ end
138
+ end
139
+ end
140
+
141
+ local function rebalance(service_name, consumer_name)
142
+ local consumers = redis.call("SMEMBERS", service_name .. ":consumers")
143
+ table.sort(consumers)
144
+ local partition_count = tonumber(redis.call("GET", service_name .. ":partition_count"))
145
+
146
+ local desired_state = getdesiredstate(service_name, consumers, partition_count)
147
+
148
+ local current_state = getcurrentstate(service_name, consumers)
149
+
150
+ local is_stable_state = states_match(desired_state, current_state)
151
+
152
+ if not is_stable_state then
153
+ if all_free(current_state) then
154
+ save_state(service_name, desired_state)
155
+
156
+ return desired_state[consumer_name]
157
+ else
158
+ redis.call("DEL", service_name .. ":" .. consumer_name .. ":assigned")
159
+ return {}
160
+ end
161
+ else
162
+ return desired_state[consumer_name]
163
+ end
164
+ end
165
+
166
+ local function heartbeat(service_name, consumer_name)
167
+ -- keep consumer alive
168
+ keepalive(service_name, consumer_name)
169
+
170
+ -- clean up dead consumers
171
+ cleanup(service_name)
172
+
173
+ -- rebalance
174
+ local new_config = rebalance(service_name, consumer_name)
175
+
176
+ return new_config
177
+ end
178
+
179
+ return heartbeat(service_name, consumer_name)