ntl-orchestra 0.9.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.
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