ntl-orchestra 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +16 -0
  3. data/Gemfile +11 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +539 -0
  6. data/Rakefile +21 -0
  7. data/bin/rake +16 -0
  8. data/lib/orchestra/conductor.rb +119 -0
  9. data/lib/orchestra/configuration.rb +12 -0
  10. data/lib/orchestra/dsl/nodes.rb +72 -0
  11. data/lib/orchestra/dsl/object_adapter.rb +134 -0
  12. data/lib/orchestra/dsl/operations.rb +108 -0
  13. data/lib/orchestra/errors.rb +44 -0
  14. data/lib/orchestra/node/output.rb +61 -0
  15. data/lib/orchestra/node.rb +130 -0
  16. data/lib/orchestra/operation.rb +49 -0
  17. data/lib/orchestra/performance.rb +137 -0
  18. data/lib/orchestra/recording.rb +83 -0
  19. data/lib/orchestra/run_list.rb +171 -0
  20. data/lib/orchestra/thread_pool.rb +163 -0
  21. data/lib/orchestra/util.rb +98 -0
  22. data/lib/orchestra/version.rb +3 -0
  23. data/lib/orchestra.rb +35 -0
  24. data/orchestra.gemspec +26 -0
  25. data/test/examples/fizz_buzz.rb +32 -0
  26. data/test/examples/invitation_service.rb +118 -0
  27. data/test/integration/multithreading_test.rb +38 -0
  28. data/test/integration/recording_telemetry_test.rb +86 -0
  29. data/test/integration/replayable_operation_test.rb +53 -0
  30. data/test/lib/console.rb +103 -0
  31. data/test/lib/test_runner.rb +19 -0
  32. data/test/support/telemetry_recorder.rb +49 -0
  33. data/test/test_helper.rb +16 -0
  34. data/test/unit/conductor_test.rb +25 -0
  35. data/test/unit/dsl_test.rb +122 -0
  36. data/test/unit/node_test.rb +122 -0
  37. data/test/unit/object_adapter_test.rb +100 -0
  38. data/test/unit/operation_test.rb +224 -0
  39. data/test/unit/run_list_test.rb +131 -0
  40. data/test/unit/thread_pool_test.rb +105 -0
  41. data/test/unit/util_test.rb +20 -0
  42. data/tmp/.keep +0 -0
  43. metadata +159 -0
data/lib/orchestra.rb ADDED
@@ -0,0 +1,35 @@
1
+ require "forwardable"
2
+ require "invokr"
3
+ require "observer"
4
+ require "securerandom"
5
+
6
+ module Orchestra
7
+ extend self
8
+
9
+ def configure &block
10
+ Configuration.module_eval &block
11
+ end
12
+
13
+ def define &block
14
+ builder = DSL::Operations::Builder.new
15
+ DSL::Operations::Context.evaluate builder, &block
16
+ builder.build_operation
17
+ end
18
+
19
+ def perform operation, inputs = {}
20
+ Conductor.new.perform operation, inputs
21
+ end
22
+
23
+ def replay_recording operation, store, input = {}
24
+ store = Util.recursively_symbolize store
25
+ input = input.merge store[:input]
26
+ svc_recordings = store[:service_recordings]
27
+ Recording.replay operation, input, svc_recordings
28
+ end
29
+
30
+ load File.expand_path('../orchestra/errors.rb', __FILE__)
31
+ end
32
+
33
+ Dir[File.expand_path '../orchestra/**/*.rb', __FILE__].each do |rb_file|
34
+ load rb_file
35
+ end
data/orchestra.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
4
+ require 'orchestra/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "ntl-orchestra"
8
+ spec.version = Orchestra::VERSION
9
+ spec.authors = ["ntl"]
10
+ spec.email = ["nathanladd+github@gmail.com"]
11
+ spec.summary = %q{Orchestrate complex operations with ease.}
12
+ spec.description = %q{Orchestra is an orchestration framework for designing complex operations in an object oriented fashion.}
13
+ spec.homepage = "https://github.com/ntl/orchestra"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = []
18
+ spec.test_files = spec.files.grep(%r{^test/})
19
+ spec.require_paths = %w(lib)
20
+
21
+ spec.add_dependency "invokr"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.6"
24
+ spec.add_development_dependency "pry"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ end
@@ -0,0 +1,32 @@
1
+ module Examples
2
+ FizzBuzz = Orchestra.define do
3
+ node :make_array do
4
+ depends_on :up_to
5
+ provides :array
6
+ perform do
7
+ up_to.times.to_a
8
+ end
9
+ end
10
+
11
+ node :apply_fizzbuzz do
12
+ iterates_over :array
13
+ provides :fizzbuzz
14
+ perform do |num|
15
+ next if num == 0 # filter 0 from the output
16
+ str = ''
17
+ str << "Fizz" if num % 3 == 0
18
+ str << "Buzz" if num % 5 == 0
19
+ str << num.to_s if str.empty?
20
+ str
21
+ end
22
+ end
23
+
24
+ finally :print do
25
+ depends_on :io
26
+ iterates_over :fizzbuzz
27
+ perform do |str|
28
+ io.puts str
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,118 @@
1
+ module Examples
2
+ InvitationService = Orchestra.define do
3
+ DEFAULT_MESSAGE = "I would really love for you to try out MyApp."
4
+ ROBOT_FOLLOWER_THRESHHOLD = 500
5
+
6
+ node :fetch_followers do
7
+ depends_on :account_name, :http
8
+ provides :followers
9
+ perform do
10
+ json = http.get "flutter.io", "/users/#{account_name}/followers"
11
+ JSON.parse json
12
+ end
13
+ end
14
+
15
+ node :fetch_blacklist do
16
+ depends_on :db
17
+ provides :blacklist
18
+ perform do
19
+ rows = db.execute "SELECT account_name FROM blacklists"
20
+ rows.map do |row| row.fetch 0 end
21
+ end
22
+ end
23
+
24
+ node :remove_blacklisted_followers do
25
+ depends_on :blacklist
26
+ modifies :followers
27
+ perform do
28
+ followers.reject! do |follower|
29
+ account_name = follower.fetch 'username'
30
+ blacklist.include? account_name
31
+ end
32
+ end
33
+ end
34
+
35
+ node :filter_robots do
36
+ depends_on :http
37
+ modifies :followers, :collection => true
38
+ perform do |follower|
39
+ account_name = follower.fetch 'username'
40
+ json = http.get "flutter.io", "/users/#{account_name}"
41
+ account = JSON.load json
42
+ next unless account['following'] > ROBOT_FOLLOWER_THRESHHOLD
43
+ next unless account['following'] > (account['followers'] / 2)
44
+ follower
45
+ end
46
+ end
47
+
48
+ finally :deliver_emails do
49
+ depends_on :smtp, :message => DEFAULT_MESSAGE
50
+ iterates_over :followers
51
+ perform do |follower|
52
+ email = follower.fetch 'email_address'
53
+ smtp.deliver message, :to => email
54
+ end
55
+ end
56
+ end
57
+
58
+ module InvitationService::TestSetup
59
+ private
60
+
61
+ def build_example_database
62
+ db = SQLite3::Database.new ':memory:'
63
+ db.execute <<-SQL
64
+ CREATE TABLE blacklists (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ account_name VARCHAR(255)
67
+ )
68
+ SQL
69
+ db.execute 'INSERT INTO blacklists(account_name) VALUES("mister_ed")'
70
+ db.execute 'INSERT INTO blacklists(account_name) VALUES("palpatine4")'
71
+ db
72
+ end
73
+
74
+ def build_example_smtp
75
+ SMTPCatcher.new
76
+ end
77
+
78
+ def stub_accounts_requests
79
+ requests = {
80
+ 'mister_ed' => { 'following' => 5192, 'followers' => 4820 },
81
+ 'captain_sheridan' => { 'following' => 12840, 'followers' => 523 },
82
+ }
83
+
84
+ requests.each do |account_name, response|
85
+ stub = stub_request :get, "http://flutter.io/users/#{account_name}"
86
+ stub.to_return :body => response.to_json
87
+ stub.times 1
88
+ stub
89
+ end
90
+ end
91
+
92
+ def stub_followers_request
93
+ response = [
94
+ { 'username' => 'mister_ed', 'email_address' => 'ed@mistered.com' },
95
+ { 'username' => 'captain_sheridan', 'email_address' => 'captain_sheridan@babylon5.earth.gov' },
96
+ ]
97
+
98
+ followers_stub = stub_request :get, "http://flutter.io/users/realntl/followers"
99
+ followers_stub.to_return :body => response.to_json
100
+ followers_stub.times 1
101
+ followers_stub
102
+ end
103
+
104
+ class SMTPCatcher
105
+ attr :delivered
106
+
107
+ def initialize
108
+ @delivered = {}
109
+ end
110
+
111
+ def deliver message, args = {}
112
+ recipient, _ = Orchestra::Util.extract_key_args args, :to
113
+ delivered[recipient] = message
114
+ end
115
+ end
116
+
117
+ end
118
+ end
@@ -0,0 +1,38 @@
1
+ class MultithreadingTest < Minitest::Test
2
+ CustomError = Class.new StandardError
3
+
4
+ def setup
5
+ @operation = Orchestra.define do
6
+ node :map_thread_ids do
7
+ iterates_over :list
8
+ provides :thread_ids
9
+ perform do |item|
10
+ raise CustomError, "blow up" if item == :blow_up
11
+ Thread.current.object_id
12
+ end
13
+ end
14
+
15
+ self.result = :thread_ids
16
+ end
17
+
18
+ @conductor = Orchestra::Conductor.new
19
+ @conductor.thread_count = 5
20
+ end
21
+
22
+ def test_multithreading
23
+ list = (1..100).to_a
24
+
25
+ thread_ids = @conductor.perform @operation, :list => list
26
+
27
+ assert thread_ids.uniq.size > 2, "performance must be spread across threads"
28
+ end
29
+
30
+ def test_exception_during_multithreading
31
+ list = (1..100).to_a
32
+ list[23] = :blow_up
33
+
34
+ assert_raises CustomError do
35
+ @conductor.perform @operation, :list => list
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,86 @@
1
+ class RecordingTelemetryTest < Minitest::Test
2
+ def test_recording_telemetry
3
+ output = StringIO.new
4
+ telemetry = {}
5
+
6
+ perform_with_telemetry telemetry, output
7
+
8
+ assert_equal_telemetry expected_telemetry, telemetry
9
+ end
10
+
11
+ private
12
+
13
+ def assert_equal_telemetry expected, actual
14
+ expected.keys.each do |key|
15
+ assert_equal expected[key], actual[key]
16
+ end
17
+ end
18
+
19
+ def expected_telemetry
20
+ {
21
+ :input => { :up_to => 16 },
22
+ :movements => {
23
+ :make_array => {
24
+ :input => { :up_to => 16 },
25
+ :output => { :array => [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15] },
26
+ },
27
+ :apply_fizzbuzz => {
28
+ :input => { :array => [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15] },
29
+ :output => {
30
+ :fizzbuzz => [
31
+ "1",
32
+ "2",
33
+ "Fizz",
34
+ "4",
35
+ "Buzz",
36
+ "Fizz",
37
+ "7",
38
+ "8",
39
+ "Fizz",
40
+ "Buzz",
41
+ "11",
42
+ "Fizz",
43
+ "13",
44
+ "14",
45
+ "FizzBuzz",
46
+ ],
47
+ },
48
+ },
49
+ :print => {
50
+ :input => {
51
+ :fizzbuzz => [
52
+ "1",
53
+ "2",
54
+ "Fizz",
55
+ "4",
56
+ "Buzz",
57
+ "Fizz",
58
+ "7",
59
+ "8",
60
+ "Fizz",
61
+ "Buzz",
62
+ "11",
63
+ "Fizz",
64
+ "13",
65
+ "14",
66
+ "FizzBuzz",
67
+ ],
68
+ },
69
+ :output => { :print => [] },
70
+ }
71
+ },
72
+ :output => [],
73
+ }
74
+ end
75
+
76
+ def perform_with_telemetry telemetry, io
77
+ conductor = Orchestra::Conductor.new :io => io
78
+
79
+ conductor.add_observer TelemetryRecorder.new telemetry
80
+
81
+ conductor.perform(
82
+ Examples::FizzBuzz,
83
+ :up_to => 16,
84
+ )
85
+ end
86
+ end
@@ -0,0 +1,53 @@
1
+ class ReplayableOperationTest < Minitest::Test
2
+ include Examples::InvitationService::TestSetup
3
+
4
+ def test_replaying_an_operation_from_a_previous_recording
5
+ # Perform the operation against real services, saving a recording
6
+ recording = perform_for_real
7
+
8
+ # Write the recording out to a file. In this case, a StringIO is used for
9
+ # simplicity, and we serialize into JSON
10
+ file = StringIO.new
11
+ file.write JSON.dump recording
12
+ file.rewind
13
+
14
+ # Replay the operation, directing SMTP to an alternative service object
15
+ smtp_service = build_example_smtp
16
+ Orchestra.replay_recording(
17
+ Examples::InvitationService,
18
+ JSON.load(file.read),
19
+ :smtp => smtp_service,
20
+ )
21
+
22
+ # While replaying, the operation delivered all email using the alterantive
23
+ # SMTP service object we passed in
24
+ assert_equal(
25
+ ["captain_sheridan@babylon5.earth.gov"],
26
+ smtp_service.delivered.keys,
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def perform_for_real
33
+ mock_smtp = build_example_smtp
34
+ db = build_example_database
35
+ stub_followers_request
36
+ stub_accounts_requests
37
+
38
+ conductor = Orchestra::Conductor.new(
39
+ :db => db,
40
+ :http => Net::HTTP,
41
+ :smtp => mock_smtp,
42
+ )
43
+
44
+ recording = conductor.record(
45
+ Examples::InvitationService,
46
+ :account_name => 'realntl',
47
+ )
48
+
49
+ db.close
50
+
51
+ recording.to_h
52
+ end
53
+ end
@@ -0,0 +1,103 @@
1
+ module Console
2
+ extend self
3
+
4
+ def load
5
+ ENV['N'] ||= '4'
6
+ Bundler.require "debugger_#{RUBY_ENGINE}"
7
+ define_reload
8
+ define_rake
9
+ load_examples
10
+ clean_backtraces
11
+ puts <<-MESSAGE
12
+ Debug console. Type `rake` to run the test suite. `bin/rake` also works for
13
+ develompent environments that rely on binstubs.
14
+
15
+ The example operations located in `test/examples` are loaded for you. To try one
16
+ out:
17
+
18
+ [1] pry(Orchestra)> Orchestra.perform Examples::FizzBuzz, :up_to => 16, :io => $stdout
19
+
20
+ See the README.md as well as the examples themselves for more information. You
21
+ will need to reload the examples after raking (or invoking reload!)
22
+ MESSAGE
23
+ end
24
+
25
+ def load_examples
26
+ Dir["test/examples/**/*.rb"].each do |example_rb| Kernel.load example_rb end
27
+ end
28
+
29
+ def define_reload
30
+ Pry::Commands.block_command "reload!", "Reload gem code" do
31
+ puts "Reloading..."
32
+ Object.send :remove_const, :Examples if Object.const_defined? :Examples
33
+ Object.send :remove_const, :Orchestra if Object.const_defined? :Orchestra
34
+ Kernel.load "lib/orchestra.rb"
35
+ Console.load_examples
36
+ orchestra = Object.const_get :Orchestra
37
+ _pry_.binding_stack.push orchestra.__binding__
38
+ _pry_.binding_stack.shift
39
+ end
40
+ end
41
+
42
+ def define_rake
43
+ Pry::Commands.create_command %r{(?:bin/)?rake}, keep_retval: true do
44
+ description "Run the test suite"
45
+
46
+ def process
47
+ run "reload!"
48
+
49
+ status = TestRunner.run_in_subprocess
50
+
51
+ build_passed = status.exitstatus == 0
52
+ operator = args.shift
53
+ run_system_command(build_passed, operator, args) if operator
54
+ build_passed
55
+ end
56
+
57
+ def run_system_command build_passed, operator, system_args
58
+ if operator == '&&'
59
+ system *system_args if build_passed
60
+ elsif operator == '||' && !build_passed
61
+ system *system_args unless build_passed
62
+ else
63
+ raise ArgumentError, "Must supply either '&&' or '||' operators, followed by a shell command"
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ def clean_backtraces
70
+ if RUBY_ENGINE == "rbx"
71
+ Exception.class_eval do
72
+ def render_with_filtering *args
73
+ io = args[1] || STDERR
74
+ args[1] = BacktraceFiltering::Rubinius.new io
75
+ render_without_filtering *args
76
+ end
77
+ alias_method :render_without_filtering, :render
78
+ alias_method :render, :render_with_filtering
79
+ end
80
+ end
81
+ end
82
+
83
+ module BacktraceFiltering
84
+ REGEX = %r{(?:lib/orchestra|test)}i
85
+
86
+ class Rubinius
87
+ def initialize io
88
+ @io = io
89
+ end
90
+
91
+ def puts message = "\n"
92
+ dont_filter_yet = true
93
+ message.each_line do |line|
94
+ if line.match %r{ at }
95
+ next unless dont_filter_yet or line.match REGEX
96
+ dont_filter_yet = false
97
+ end
98
+ @io.puts line
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,19 @@
1
+ module TestRunner
2
+ extend self
3
+
4
+ def run_in_subprocess
5
+ pid = fork do run end
6
+ _, status = Process.wait2 pid
7
+ status
8
+ end
9
+
10
+ def run
11
+ Bundler.require :test
12
+ load 'test/test_helper.rb'
13
+ tests = Dir["test/**/*_test.rb"]
14
+ tests.select! do |test| test == ENV['TEST'] end if ENV['TEST']
15
+ tests.each &method(:load)
16
+ argv = (ENV['TESTOPTS'] || '').split %r{[[:space:]]+}
17
+ exit Minitest.run argv
18
+ end
19
+ end
@@ -0,0 +1,49 @@
1
+ class TelemetryRecorder
2
+ def initialize store
3
+ @store = store
4
+ @current_operation = nil
5
+ @embedded = false
6
+ end
7
+
8
+ def update message, *payload
9
+ method = "handle_#{message}"
10
+ public_send method, *payload if respond_to? method
11
+ end
12
+
13
+ def embedded?
14
+ @embedded
15
+ end
16
+
17
+ def handle_operation_entered operation_name, input
18
+ return if embedded?
19
+ @nodes = Hash.new do |hsh, key| hsh[key] = {} end
20
+ @store.update(
21
+ :input => input,
22
+ :movements => @nodes,
23
+ :performance_name => operation_name,
24
+ :service_calls => [],
25
+ )
26
+ @embedded = true
27
+ end
28
+
29
+ def handle_operation_exited operation_name, output
30
+ @store[:output] = output
31
+ @embedded = false
32
+ end
33
+
34
+ def handle_node_entered name, input
35
+ @nodes[name][:input] = input
36
+ end
37
+
38
+ def handle_node_exited name, output
39
+ @nodes[name][:output] = output
40
+ end
41
+
42
+ def handle_error_raised error
43
+ @store[:error] = error
44
+ end
45
+
46
+ def handle_service_accessed service_name, record
47
+ @store[:service_calls].<< record.merge :service => service_name
48
+ end
49
+ end
@@ -0,0 +1,16 @@
1
+ require "json"
2
+ require "minitest/hell"
3
+ require "minitest/mock"
4
+ require "stringio"
5
+ require "webmock/minitest"
6
+
7
+ Dir['test/examples/**/*.rb'].each &method(:load) unless defined? Examples
8
+ Dir['test/support/**/*.rb'].each &method(:load)
9
+
10
+ WebMock.disable_net_connect!
11
+
12
+ class Minitest::Test
13
+ def before_setup
14
+ Orchestra::Configuration.reset
15
+ end
16
+ end
@@ -0,0 +1,25 @@
1
+ class ConductorTest < Minitest::Test
2
+ def test_defaults_to_single_thread
3
+ conductor = Orchestra::Conductor.new
4
+
5
+ assert_equal 1, conductor.thread_count
6
+ end
7
+
8
+ def test_configuring_thread_pool_globally
9
+ Orchestra.configure do |defaults|
10
+ defaults.thread_count = 5
11
+ end
12
+
13
+ conductor = Orchestra::Conductor.new
14
+
15
+ assert_equal 5, conductor.thread_count
16
+ end
17
+
18
+ def test_configuring_thread_pool_on_an_instance
19
+ conductor = Orchestra::Conductor.new
20
+
21
+ conductor.thread_count = 5
22
+
23
+ assert_equal 5, conductor.thread_count
24
+ end
25
+ end