wsdirector-core 0.0.1 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wsdirector/protocols/base"
4
+ require "wsdirector/protocols/action_cable"
5
+ require "wsdirector/protocols/phoenix"
6
+
7
+ module WSDirector
8
+ ID2CLASS = {
9
+ "base" => "Base",
10
+ "action_cable" => "ActionCable",
11
+ "phoenix" => "Phoenix"
12
+ }.freeze
13
+
14
+ module Protocols # :nodoc:
15
+ # Raised when received not expected message
16
+ class UnmatchedExpectationError < WSDirector::Error; end
17
+
18
+ # Raised when received message is unexpected
19
+ class UnexpectedMessageError < WSDirector::Error; end
20
+
21
+ # Raised when nothing has been received
22
+ class NoMessageError < WSDirector::Error; end
23
+
24
+ class << self
25
+ def get(id)
26
+ class_name = if ID2CLASS.key?(id)
27
+ ID2CLASS.fetch(id)
28
+ else
29
+ id
30
+ end
31
+
32
+ const_get(class_name)
33
+ rescue NameError
34
+ raise Error, "Unknown protocol: #{id}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ # Handle results from all clients from the group
5
+ class Result
6
+ attr_reader :group, :errors
7
+
8
+ def initialize(group)
9
+ @group = group
10
+ @errors = Concurrent::Array.new
11
+
12
+ @all = Concurrent::AtomicFixnum.new(0)
13
+ @failures = Concurrent::AtomicFixnum.new(0)
14
+
15
+ @sampling_mutex = Mutex.new
16
+ @sampling_counter = Hash.new { |h, k| h[k] = 0 }
17
+ end
18
+
19
+ # Called when client successfully finished it's work
20
+ def succeed
21
+ all.increment
22
+ end
23
+
24
+ # Called when client failed
25
+ def failed(error_message)
26
+ errors << error_message
27
+ all.increment
28
+ failures.increment
29
+ end
30
+
31
+ def success?
32
+ failures.value.zero?
33
+ end
34
+
35
+ def total_count
36
+ all.value
37
+ end
38
+
39
+ def failures_count
40
+ failures.value
41
+ end
42
+
43
+ def track_sample(id, max)
44
+ sampling_mutex.synchronize do
45
+ return false if sampling_counter[id] >= max
46
+
47
+ sampling_counter[id] += 1
48
+ true
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :all, :success, :failures, :sampling_counter, :sampling_mutex
55
+ end
56
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ # Holds all results for all groups of clients
5
+ class ResultsHolder
6
+ def initialize
7
+ @groups = Concurrent::Map.new
8
+ end
9
+
10
+ def success?
11
+ @groups.values.all?(&:success?)
12
+ end
13
+
14
+ def groups
15
+ @groups.values
16
+ end
17
+
18
+ def print_summary(printer: $stdout, colorize: false)
19
+ single_group = groups.size == 1
20
+
21
+ @groups.each do |group, result|
22
+ color = result.success? ? :green : :red
23
+ prefix = single_group ? "" : "Group #{group}: "
24
+
25
+ msg = "#{prefix}#{result.total_count} clients, #{result.failures_count} failures\n"
26
+ msg = msg.colorize(color) if colorize
27
+
28
+ printer.puts(msg)
29
+
30
+ unless result.success?
31
+ print_errors(result.errors, printer: printer, colorize: colorize)
32
+ printer.puts "\n"
33
+ end
34
+ end
35
+ end
36
+
37
+ def <<(result)
38
+ @groups[result.group] = result
39
+ end
40
+
41
+ private
42
+
43
+ def print_errors(errors, printer:, colorize:)
44
+ printer.puts "\n"
45
+
46
+ errors.each.with_index do |error, i|
47
+ msg = "#{i + 1}) #{error}\n"
48
+ msg = msg.colorize(:red) if colorize
49
+
50
+ printer.puts msg
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wsdirector/scenario_reader"
4
+ require "wsdirector/clients_holder"
5
+ require "wsdirector/results_holder"
6
+ require "wsdirector/result"
7
+ require "wsdirector/task"
8
+ require "wsdirector/ext/deep_dup"
9
+
10
+ module WSDirector
11
+ def self.run(scenario, scale: 1, connection_options: {}, locals: {}, **options)
12
+ scenario = ScenarioReader.parse(scenario, scale:, connection_options:, locals:)
13
+ Runner.new(scenario, scale:, **options).execute
14
+ end
15
+
16
+ # Initiates all clients as a separate tasks (=threads)
17
+ class Runner
18
+ using WSDirector::Ext::DeepDup
19
+
20
+ def initialize(scenario, url:, scale: 1, sync_timeout: 5, logger: nil, colorize: false)
21
+ @scenario = scenario
22
+ @url = url
23
+ @scale = scale
24
+ @logger = logger
25
+ @colorize = colorize
26
+ @total_count = scenario["total"]
27
+ @global_holder = ClientsHolder.new(total_count, sync_timeout:)
28
+ @results_holder = ResultsHolder.new
29
+ end
30
+
31
+ def execute
32
+ Thread.abort_on_exception = true
33
+
34
+ num = 0
35
+
36
+ tasks = scenario["clients"].flat_map do |client_config|
37
+ name = client_config.fetch("name")
38
+ result = Result.new(name)
39
+ results_holder << result
40
+
41
+ Array.new(client_config.fetch("multiplier")) do
42
+ num += 1
43
+ id = "#{name}_#{num}"
44
+ Thread.new do
45
+ Task.new(client_config.deep_dup, id:, colorize:, global_holder:, result:, scale:, logger:)
46
+ .run(url)
47
+ end
48
+ end
49
+ end
50
+
51
+ tasks.each(&:join)
52
+
53
+ results_holder
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :scenario, :url, :scale, :total_count, :global_holder, :results_holder, :logger, :colorize
59
+ end
60
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+ require "wsdirector/ext/deep_dup"
6
+
7
+ module WSDirector
8
+ # Read and parse different scenarios
9
+ class ScenarioReader
10
+ using WSDirector::Ext::DeepDup
11
+
12
+ include WSDirector::Utils
13
+
14
+ class << self
15
+ def parse(scenario, **options)
16
+ new(**options).parse(scenario)
17
+ end
18
+ end
19
+
20
+ attr_reader :default_connections_options, :locals
21
+
22
+ def initialize(scale: 1, connection_options: {}, locals: {})
23
+ @scale = scale
24
+ @default_connections_options = connection_options
25
+ @locals = locals
26
+ end
27
+
28
+ def parse(scenario)
29
+ contents =
30
+ if scenario.is_a?(String)
31
+ if File.file?(scenario)
32
+ parse_file(scenario)
33
+ else
34
+ parse_from_str(scenario).then do
35
+ next _1 if _1.is_a?(Array)
36
+ [_1]
37
+ end
38
+ end.flatten
39
+ else
40
+ scenario
41
+ end
42
+
43
+ contents.map! do |item|
44
+ item.is_a?(String) ? {item => {}} : item
45
+ end
46
+
47
+ if contents.first&.key?("client")
48
+ contents = transform_with_loop(contents, multiple: true)
49
+ parse_multiple_scenarios(contents)
50
+ else
51
+ contents = transform_with_loop(contents)
52
+ {"total" => 1, "clients" => [parse_simple_scenario(contents)]}
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ JSON_FILE_FORMAT = /.+.(json)\z/
59
+ private_constant :JSON_FILE_FORMAT
60
+
61
+ def parse_from_str(contents)
62
+ JSON.parse(contents)
63
+ rescue JSON::ParserError
64
+ parse_yaml(contents)
65
+ end
66
+
67
+ def parse_file(file)
68
+ if file.match?(JSON_FILE_FORMAT)
69
+ JSON.parse(File.read(file))
70
+ else
71
+ parse_yaml(file)
72
+ end
73
+ end
74
+
75
+ def parse_yaml(path)
76
+ contents = File.file?(path) ? File.read(path) : path
77
+
78
+ if defined?(ERB)
79
+ contents = ERB.new(contents).result(erb_context)
80
+ end
81
+
82
+ ::YAML.load(contents, aliases: true, permitted_classes: [Date, Time, Regexp]) || {}
83
+ rescue ArgumentError
84
+ ::YAML.load(contents) || {}
85
+ end
86
+
87
+ def handle_steps(steps)
88
+ steps.flat_map.with_index do |step, id|
89
+ if step.is_a?(Hash)
90
+ type, data = step.to_a.first
91
+
92
+ data["sample"] = [1, parse_multiplier(data["sample"])].max if data["sample"]
93
+
94
+ multiplier = parse_multiplier(data.delete("multiplier") || "1")
95
+ Array.new(multiplier) { {"type" => type, "id" => id}.merge(data) }
96
+ else
97
+ {"type" => step, "id" => id}
98
+ end
99
+ end
100
+ end
101
+
102
+ def parse_simple_scenario(
103
+ steps,
104
+ multiplier: 1, name: "default", ignore: nil, protocol: "base",
105
+ connection_options: {}
106
+ )
107
+ {
108
+ "multiplier" => multiplier,
109
+ "steps" => handle_steps(steps),
110
+ "name" => name,
111
+ "ignore" => ignore,
112
+ "protocol" => protocol,
113
+ "connection_options" => default_connections_options.merge(connection_options)
114
+ }
115
+ end
116
+
117
+ def parse_multiple_scenarios(definitions)
118
+ total_count = 0
119
+ clients = definitions.map.with_index do |client, i|
120
+ _, client = client.to_a.first
121
+ multiplier = parse_multiplier(client.delete("multiplier") || "1")
122
+ name = client.delete("name") || (i + 1).to_s
123
+ connection_options = client.delete("connection_options") || {}
124
+ total_count += multiplier
125
+ ignore = parse_ignore(client.fetch("ignore", nil))
126
+ protocol = client.fetch("protocol", "base")
127
+
128
+ parse_simple_scenario(
129
+ client.fetch("actions", []),
130
+ multiplier:,
131
+ name:,
132
+ ignore:,
133
+ protocol:,
134
+ connection_options:
135
+ )
136
+ end
137
+ {"total" => total_count, "clients" => clients}
138
+ end
139
+
140
+ def transform_with_loop(contents, multiple: false)
141
+ contents.flat_map do |content|
142
+ loop_data = content.dig("client", "loop") || content.dig("loop")
143
+ next content unless loop_data
144
+
145
+ loop_multiplier = parse_multiplier(loop_data["multiplier"] || "1")
146
+
147
+ if multiple
148
+ content["client"]["actions"] = (loop_data["actions"] * loop_multiplier).map(&:deep_dup)
149
+ content
150
+ else
151
+ loop_data["actions"] * loop_multiplier
152
+ end
153
+ end
154
+ end
155
+
156
+ def parse_ignore(str)
157
+ return unless str
158
+
159
+ Array(str)
160
+ end
161
+
162
+ def erb_context
163
+ binding.then do |b|
164
+ locals.each do
165
+ b.local_variable_set(_1, _2)
166
+ end
167
+
168
+ b
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ # Collect ws frame and dump them into a YAML file (or JSON)
5
+ class Snapshot
6
+ def initialize
7
+ @steps = []
8
+ @last_timestamp = nil
9
+ end
10
+
11
+ def <<(frame)
12
+ record_gap!
13
+ steps << {"send" => {"data" => frame}}
14
+ end
15
+
16
+ def to_yml
17
+ ::YAML.dump(steps)
18
+ end
19
+
20
+ def to_json
21
+ steps.to_json
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :steps, :last_timestamp
27
+
28
+ def record_gap!
29
+ prev_timestamp = last_timestamp
30
+ @last_timestamp = Time.now
31
+ return unless prev_timestamp
32
+
33
+ delay = last_timestamp - prev_timestamp
34
+ steps << {"sleep" => {"time" => delay}}
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wsdirector/client"
4
+ require "wsdirector/protocols"
5
+
6
+ module WSDirector
7
+ # Single client operator
8
+ class Task
9
+ attr_reader :global_holder, :client
10
+
11
+ def initialize(config, id:, global_holder:, result:, scale:, logger:, colorize: false)
12
+ @id = id
13
+ @logger = logger
14
+ @ignore = config.fetch("ignore")
15
+ @steps = config.fetch("steps")
16
+ @connection_options = config.fetch("connection_options").transform_keys(&:to_sym)
17
+ @global_holder = global_holder
18
+ @result = result
19
+
20
+ protocol_class = Protocols.get(config.fetch("protocol", "base"))
21
+ @protocol = protocol_class.new(self, scale:, logger:, id:, color: color_for_id(id, colorize))
22
+ end
23
+
24
+ def run(url)
25
+ connect!(url, **@connection_options)
26
+
27
+ steps.each(&protocol)
28
+
29
+ result.succeed
30
+ rescue Error => e
31
+ result.failed(e.message)
32
+ end
33
+
34
+ def sampled?(step)
35
+ return true unless step["sample"]
36
+
37
+ id, max = step["id"], step["sample"]
38
+
39
+ result.track_sample(id, max)
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :steps, :result, :protocol, :id, :logger, :ignore
45
+
46
+ def connect!(url, **options)
47
+ protocol.init_client(url:, id:, ignore:, **options)
48
+ end
49
+
50
+ def color_for_id(id, colorize)
51
+ return unless colorize
52
+
53
+ String.colors[id.hash % String.colors.size]
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ module Utils # :nodoc:
5
+ MULTIPLIER_FORMAT = /^[-+*\/\\\d ]+$/
6
+
7
+ attr_reader :scale
8
+
9
+ def parse_multiplier(str)
10
+ prepared = str.to_s.gsub(":scale", scale.to_s)
11
+ raise WSDirector::Error, "Unknown multiplier format: #{str}" unless
12
+ MULTIPLIER_FORMAT.match?(prepared)
13
+
14
+ eval(prepared) # rubocop:disable Security/Eval
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ VERSION = "1.0.1"
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby-next"
4
+
5
+ require "ruby-next/language/setup"
6
+ RubyNext::Language.setup_gem_load_path(transpile: true)
7
+
8
+ require "wsdirector/cli"
data/lib/wsdirector.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "yaml"
5
+ require "json"
6
+
7
+ require "ruby-next"
8
+
9
+ require "ruby-next/language/setup"
10
+ RubyNext::Language.setup_gem_load_path(transpile: true)
11
+
12
+ require "wsdirector/version"
13
+ require "wsdirector/utils"
14
+
15
+ # Command line tool for testing websocket servers using scenarios.
16
+ module WSDirector
17
+ class Error < StandardError
18
+ end
19
+ end
20
+
21
+ require "wsdirector/runner"
22
+ require "wsdirector/snapshot"