wsdirector-core 0.0.1 → 1.0.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.
@@ -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.0"
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"