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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: e510c2671333a04041a77dc8c6a745fd4e6f2e58
4
+ data.tar.gz: 0c874a17df26a2f97e942feb60713b68c5648cd2
5
+ SHA512:
6
+ metadata.gz: e4beddf07b80930847e43228307e3065170a0fbb041e3215c28f89ef3ec9e50ce7d2a1d190f238e7c524caaec760a3dfdb6e085fb51d4bb118a97ba95bb55f8e
7
+ data.tar.gz: 2c60866f23f0722cb01b96d6aa71ac717267ada8d4fb5dd17ab897749ddc9dc59b23a306dd2599f9015682e9721a59076c5e60ff161d0475df76d06de24e3ddc
data/.gitignore ADDED
@@ -0,0 +1,16 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ *.bundle
11
+ *.so
12
+ *.o
13
+ *.a
14
+ mkmf.log
15
+ /vendor
16
+ .ruby-version
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in orchestra.gemspec
4
+ gemspec
5
+
6
+ group :test do
7
+ gem "minitest"
8
+ gem "minitest-red_green"
9
+ gem "sqlite3"
10
+ gem "webmock"
11
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 ntl
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,539 @@
1
+ # Orchestra
2
+
3
+ Seamlessly chain multiple command or query objects together with a simple, lightweight framework.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'ntl-orchestra'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install ntl-orchestra
20
+
21
+ ## Usage
22
+
23
+ Here's a simple example without a lot of context:
24
+
25
+ ```ruby
26
+ operation = Orchestra.define do
27
+ node :make_array do
28
+ depends_on :up_to
29
+ provides :array
30
+ perform do
31
+ limit.times.to_a
32
+ end
33
+ end
34
+
35
+ node :apply_fizzbuzz do
36
+ iterates_over :array
37
+ provides :fizzbuzz
38
+ perform do |num|
39
+ next if num == 0 # filter 0 from the output
40
+ str = ''
41
+ str << "Fizz" if num.mod 3 == 0
42
+ str << "Buzz" if num.mod 5 == 0
43
+ str << num.to_s if str.empty?
44
+ str
45
+ end
46
+ end
47
+
48
+ finally do
49
+ iterates_over :fizzbuzz
50
+ perform do |str|
51
+ puts str
52
+ end
53
+ end
54
+ end
55
+
56
+ Orchestra.perform operation, :up_to => 31
57
+ ```
58
+
59
+ There is an easy way to take this gem for a test drive. Clone the repo, and at the project root:
60
+
61
+ ```sh
62
+ bin/rake console
63
+ ```
64
+
65
+ You can run the gem's tests from within the console with `rake`:
66
+
67
+ ```sh
68
+ [1] pry(Orchestra)> rake
69
+ Run options: --seed 59938
70
+
71
+ # Running:
72
+
73
+ ................................................
74
+
75
+ Finished in 0.379958s, 126.3298 runs/s, 1526.4845 assertions/s.
76
+
77
+ 48 runs, 580 assertions, 0 failures, 0 errors, 0 skips
78
+ => true
79
+ ```
80
+
81
+ Also, you can access the examples:
82
+
83
+ ```ruby
84
+ [1] pry(Orchestra)> Orchestra.perform FizzBuzz, :up_to => 31
85
+ [1] pry(Orchestra)> Orchestra.perform InvitationService, :account_name => 'realntl`
86
+ ```
87
+
88
+ ## Why?
89
+
90
+ Suppose your application, MyApp, allows users to email an invitation to share the app with all the users' followers on a popular social microblogging network. However, the application also maintains an internal database of known blacklist users who never wish to be emailed. In addition, the application uses a simple heuristic algorithm to filter out bots. From the users' perspective, this is all one feature, but it would be difficult to pack into a single class. A straightforward implementation might look something like this:
91
+
92
+ ```ruby
93
+ class InvitationService
94
+ DEFAULT_MESSAGE = "I would really love for you to try out MyApp."
95
+ ROBOT_FOLLOWER_THRESHHOLD = 500
96
+
97
+ attr :user, :message
98
+
99
+ def initialize user, message = DEFAULT_MESSAGE
100
+ @user = user
101
+ @message = message
102
+ end
103
+
104
+ def call
105
+ target_emails.each do |follower|
106
+ EmailDelivery.send message, :to => follower
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def target_emails
113
+ filtered_followers.map do |account_name|
114
+ account = FlutterAPI.get_account account_name
115
+ account['email']
116
+ end
117
+ end
118
+
119
+ def filtered_followers
120
+ @filtered_followers ||= filter_robots(raw_followers - blacklisted_followers)
121
+ end
122
+
123
+ def raw_followers
124
+ @raw_followers ||= FlutterAPI.get_followers account_name
125
+ end
126
+
127
+ def blacklisted_followers
128
+ @blacklisted_followers ||= Blacklist.pluck :flutter_account_name
129
+ end
130
+
131
+ def filter_robots list
132
+ list.reject! do |account_name|
133
+ account = FlutterAPI.get_account account_name
134
+ next unless account['following'] > ROBOT_FOLLOWER_THRESHHOLD
135
+ account['following'] > (account['followers'] / 2)
136
+ end
137
+ end
138
+ end
139
+ ```
140
+
141
+ Despite appearing to conform to many popular conventions, so-called "service objects" such as `InvitationService` often prove extremely painful to work with and maintain, for a litany of reasons. The primary problem is that visibility into the flow of logic through the object has been sacrificed in order to reduce the surface area of the public API. Initially, during development, this appears to be a win, since smaller public APIs mean both less coupling *and* an easier interface for the next programmer to learn. However, suppose `FlutterAPI.get_account` starts returning hashes without a `'following'` key; this will cause `#filter_robots` to begin failing without any obvious reason why. To make matters worse, in order to discover where in the process the exception occurred, you have to reverse engineer the order of the operations by walking through all the memoization in your mind.
142
+
143
+ Another problem is that because `InvitationService` directly calls out to external services through `FlutterAPI` (an HTTP gatewary) and `Blacklist` (an `ActiveRecord` class), the only way to determine exactly what happened during an invokation of `InvitationService#call` is to know exactly what those API calls returned. This often means having to pull down production databases and API credentials in order to debug specific failure cases. And that doesn't even begin to scratch the surface of how painful an object like `InvitationService` is to test. You have to shove a bunch of fabricated records into a database and mock all of the API calls to `FlutterAPI`.
144
+
145
+ As your application matures, you'll find that your test environment begins diverging significantly from your production environment. Confidence in your test suite drops, and you have to resort to either pulling down production state or logging into a rails console on a production system in order to debug problems. The difficulty of write automated tests for this "service object" and the need to constantly invoke the code within a production context are actually two facets of the same problem -- your code is coupled to your environment.
146
+
147
+ Objects like `InvitationService` are not fun to work with.
148
+
149
+ ## Wiring up an orchestration
150
+
151
+ Here is a simple translation of the above `InvitationService` into an orchestration:
152
+
153
+ ```ruby
154
+ InvitationService = Orchestra.define do
155
+ DEFAULT_MESSAGE = "I would really love for you to try out MyApp."
156
+ ROBOT_FOLLOWER_THRESHHOLD = 500
157
+
158
+ node :fetch_followers do
159
+ depends_on :account_name
160
+ provides :followers
161
+ perform do
162
+ FlutterAPI.get_account account_name
163
+ end
164
+ end
165
+
166
+ node :fetch_blacklist do
167
+ provides :blacklist
168
+ perform do
169
+ Blacklist.pluck :flutter_account_name
170
+ end
171
+ end
172
+
173
+ node :remove_blacklisted_followers do
174
+ depends_on :blacklist
175
+ modifies :followers
176
+ perform do
177
+ followers.reject! do |follower|
178
+ account_name = follower.fetch 'username'
179
+ blacklist.include? account_name
180
+ end
181
+ end
182
+ end
183
+
184
+ node :filter_robots do
185
+ modifies :followers, :collection => true
186
+ perform do |follower|
187
+ account_name = follower.fetch 'username'
188
+ account = FlutterAPI.get_account account_name
189
+ next unless account['following'] > ROBOT_FOLLOWER_THRESHHOLD
190
+ next unless account['following'] > (account['followers'] / 2)
191
+ follower
192
+ end
193
+ end
194
+
195
+ finally :deliver_emails do
196
+ depends_on :message => DEFAULT_MESSAGE
197
+ iterates_over :followers
198
+ perform do |follower|
199
+ EmailDelivery.send message, :to => follower
200
+ end
201
+ end
202
+ end
203
+ ```
204
+
205
+ At first sight, that very likely appears to be a giant pile of ruby DSL goop. And you would be correct. I'll show you how to plug in POROs in a bit, and there are some further improvements that will make the indirection worth while. For now, here is how you would perform this command:
206
+
207
+ ```ruby
208
+ # Use the default message
209
+ Orchestra.perform InvitationService, :account_name => 'realntl'
210
+
211
+ # Override the default message
212
+ Orchestra.perform InvitationService, :account_name => 'realntl', :message => 'Say cheese!'
213
+ ```
214
+
215
+ There's a lot more to learn, but let's start by building an understanding of the DSL.
216
+
217
+ ## Breaking down the DSL: Nodes
218
+
219
+ An *operation* is a collection of nodes that each individually take part in producing some larger behavior. Each node represents one step, or stage, in the whole process. A node, essentially, accepts input, processes, and provides output. Let's look at the first node, called `:fetch_followers`.
220
+
221
+ ```ruby
222
+ node :fetch_followers do
223
+ depends_on :account_name
224
+ provides :followers
225
+ perform do
226
+ FlutterAPI.get_account account_name
227
+ end
228
+ end
229
+ ```
230
+
231
+ This node depends on something called an `account_name`. This means you must supply `:account_name` when you perform the operation, otherwise, `:fetch_followers` won't work. `orchestra` ensures that your performance can't commence without the required input:
232
+
233
+ ```ruby
234
+ # Raises Orchestra::MissingInputError: Missing input :account_name
235
+ operation.perform
236
+ # Works correctly
237
+ operation.perform :account_name => 'realntl'
238
+ ```
239
+
240
+ Dependencies can be optional. In the above example, `deliver_emails` defaults the `:message` input to `DEFAULT_MESSAGE`.
241
+
242
+ Often, you will need the output of one operation to feed into the input of another. The annotations `depends_on`, `iterates_over`, and `modifies` all describe the inputs, and `provides` describes the output. When `provides` is omitted, the name of the output is set to match the name of the node. Orchestra actually uses the annotations to sort the ordering of the nodes at runtime to ensure that all dependencies are satisfied. In the above example, `remove_blacklisted_followers` would not execute before `fetch_blacklist`, no matter where their definitions were placed within the operation. Orchestra detects that `remove_blacklisted_followers` *depends on* `blacklist`, and `fetch_blacklist` actually provides a `blacklist`, so it knows to run `fetch_blacklist` before `remove_blacklisted_followers`. Similarly, if you had your own list of blacklisted account names lying around, you could bypass the `fetch_blacklist` node altogether, since there is no sense fetching a `blacklist` when you've already got one:
243
+
244
+ ```ruby
245
+ # Never invokes fetch_blacklist
246
+ operation.perform :account_name => 'realntl', :blacklist => %w(dhh unclebobmartin)
247
+ ```
248
+
249
+ This allows your operations to be reused in cases where some of the dependencies can already be satisfied.
250
+
251
+ Finally, the `modifies` annotiation deserves some explaining. When a node merely mutates an input, you are certainly welcome to declare distinct `depends_on` and `modifies` annotations:
252
+
253
+ ```ruby
254
+ node :remove_blacklisted_followers do
255
+ depends_on :blacklist, :followers
256
+ provides :followers
257
+ end
258
+ ```
259
+
260
+ However, `modifies` simply condenses the two into one. The following example is identical to the previous:
261
+
262
+ ```ruby
263
+ node :remove_blacklisted_followers do
264
+ depends_on :blacklist
265
+ modifies :followers
266
+ end
267
+ ```
268
+
269
+ ## Breaking down the DSL: the operation itself
270
+
271
+ Configuring the operation is rather simple. You define the various nodes, and then specify the result. There are three ways to specify the result.
272
+
273
+ The first is very straightforward:
274
+
275
+ ```ruby
276
+ Orchestra.define do
277
+ node :foo do
278
+ depends_on :bar
279
+ provides :foo # optional, since the node is called :foo
280
+ perform do … end
281
+ end
282
+
283
+ self.result = :foo
284
+ end
285
+ ```
286
+
287
+ The second is just a shortened form of the first:
288
+
289
+ ```ruby
290
+ Orchestra.define do
291
+ # Define a node called :foo and make it the result
292
+ result :foo do
293
+ depends_on :bar
294
+ perform do … end
295
+ end
296
+ end
297
+ ```
298
+
299
+ The third is a minor variation of the second. The only difference is that the operation will always return `true`. `finally` makes sense for operations that perform side effects (e.g. Command objects), wherease `result` will make sense for queries.
300
+
301
+ ```ruby
302
+ Orchestra.define do
303
+ finally :foo do
304
+ depends_on :bar
305
+ perform do … end
306
+ end
307
+ end
308
+ ```
309
+
310
+ ## Hooking in POROs
311
+
312
+ You can also hook up POROs to operations as nodes. This is important both to manage complex nodes as well as leveraging existing objects in the system. The `filter_robots` node could be expressed as a PORO rather easily:
313
+
314
+ ```ruby
315
+ node FilterRobots, :iterates_over => :followers, :collection => true
316
+
317
+ class FilterRobots
318
+ def initialize followers
319
+ @followers = followers
320
+ end
321
+
322
+ def perform follower
323
+ account_name = follower.fetch 'account_name'
324
+ account = FlutterAPI.get_account account_name
325
+ next unless account['following'] > ROBOT_FOLLOWER_THRESHHOLD
326
+ next unless account['following'] > (account['followers'] / 2)
327
+ account_name
328
+ end
329
+ end
330
+ ```
331
+
332
+ Orchestra infers the dependencies from `FilterRobots#initialize`, and automatically instantiates the object for you during the performance. You can alter the name of the method:
333
+
334
+ ```ruby
335
+ node MyPoro, :method => :call
336
+ ```
337
+
338
+ You can also hook into singletons like `Module` (or a `Class` that implements `self.perform`):
339
+
340
+ ```ruby
341
+ node MySingleton, :method => :invoke
342
+
343
+ module MySingleton
344
+ def self.invoke … end
345
+ end
346
+ ```
347
+
348
+ By default, the name of the provision will be inferred from the object name.
349
+
350
+ ## Multithreading
351
+
352
+ Two of the nodes in the `InvitationService` orchestration -- `filter_robots` and `deliver_emails` -- actually operate on *collections*. `deliver_emails` indicates that `:followers` is a collection by using the `iterates_over` annotation instead of `depends_on`. In fact, the two annotations are identical *except* that `iterates_over` indicates that the dependency is in fact going to be a list. Collections can be defined on a `modifies` annotation, as well, by supplying `:collection => true` as in the case of `filter_robots`.
353
+
354
+ When nodes iterate over collections, Orchestra invokes `perform do … end` block once for each item in the collection passed in. It also spreads out each invokation across a thread pool. By default, there is only one thread in the thread pool. You can reconfigure that globally in an initializer of some kind:
355
+
356
+ ```ruby
357
+ Orchestra.configure do
358
+ # Thread pools will spin up five threads
359
+ self.thread_count = 5
360
+ end
361
+ ```
362
+
363
+ These collections can operate as filters; the output list is a mapping of the input list *transformed* by the `perform` block. When the `perform` block returns `nil`, the output shrinks by one element. Consider the FizzBuzz example at the top of this document. Notice that `0` doesn't get printed out. This is because the `perform` block in `apply_fizzbuzz` returned nil when the `num` was zero. `nil` values get `compact`'ed.
364
+
365
+ ## Invoking an operation through a conductor
366
+
367
+ Now that you understand how to define operations, we can do some cool things with them. First, though, we need to change the way we invoke operations. Let's instantiate a `Conductor`, and have *that* perform our operations for us:
368
+
369
+ ```ruby
370
+ conductor = Orchestra::Conductor.new
371
+ conductor.perform InvitationService, :account_name => 'realntl'
372
+ ```
373
+
374
+ What did that buy us? First, we can configure the size of the thread pool specifically for this conductor:
375
+
376
+ ```ruby
377
+ conductor.thread_count = 5
378
+ ```
379
+
380
+ Second, we can inject *services* into our operation. Our operation needs to be modified such that our database connections and API access are passed in as dependencies:
381
+
382
+ ```ruby
383
+ node :fetch_followers do
384
+ depends_on :account_name, :flutter_api
385
+ provides :followers
386
+ perform do
387
+ flutter_api.get_account account_name
388
+ end
389
+ end
390
+
391
+ # and
392
+
393
+ node :fetch_blacklist do
394
+ depends_on :blacklist_table
395
+ provides :blacklist
396
+ perform do
397
+ blacklist_table.pluck :flutter_account_name
398
+ end
399
+ end
400
+ ```
401
+
402
+ Now we can teach the conductor how to supply the services.
403
+
404
+ ```ruby
405
+ conductor = Orchestra::Conductor.new(
406
+ :flutter_api => FlutterAPI,
407
+ :blacklist_table => Blacklist,
408
+ )
409
+ ```
410
+
411
+ We can also override the conductor's service registry by supplying them into the performance itself, as we do any other dependency like `account_name`:
412
+
413
+ ```ruby
414
+ conductor.perform InvitationService, :account_name => 'realntl', :blacklist_table => mock
415
+ ```
416
+
417
+ What did this buy us? Two big things. We can now attach *observers* to the performance, and we can actually record all calls in and out of the `flutter_api` and `blacklist_table` services. The former allows us to share the internal operation of the performance with the rest of the system without breaking encapsulation, and the latter allows us to actually replay the operation against recorded snapshots of live performances.
418
+
419
+ Additionally, you can pass the `conductor` into nodes. In this way you can embed one orchestration into another:
420
+
421
+ ```ruby
422
+ inner_operation = Orchestra.define do
423
+ result :foo do
424
+ provides :bar
425
+ perform do
426
+ bar * 2
427
+ end
428
+ end
429
+ end
430
+
431
+ outer_operation = Orchestra.define do
432
+ result :baz do
433
+ depends_on :conductor
434
+ provides :qux
435
+ perform do
436
+ conductor.perform inner_operation
437
+ end
438
+ end
439
+ end
440
+
441
+ conductor = Conductor.new
442
+ conductor.perform outer_operation
443
+ ```
444
+
445
+ To shorten this, the inner operation can be "mounted" inside the outer operation:
446
+
447
+ ```ruby
448
+ inner_operation = Orchestra.define do
449
+ result :foo do
450
+ provides :bar
451
+ perform do
452
+ bar * 2
453
+ end
454
+ end
455
+ end
456
+
457
+ outer_operation = Orchestra.define do
458
+ result inner_operation
459
+ end
460
+ ```
461
+
462
+ ## Observing a performance
463
+
464
+ You can attach observers to any `Conductor`:
465
+
466
+ ```ruby
467
+ conductor.add_observer MyObserver
468
+
469
+ class MyObserver
470
+ def update event_name, *args
471
+ case event_name
472
+ when :operation_entered then "Hello"
473
+ when :operation_exited then "World!"
474
+ when :node_entered then "Hello from within a node"
475
+ when :node_exited then "Goodbye from within a node"
476
+ when :error_raised then "Ruh roh!"
477
+ end
478
+ end
479
+ end
480
+ ```
481
+
482
+ The arguments passed to `update` will vary based on the event:
483
+
484
+ | Event | First argument | Second argument |
485
+ | ------------------------ | ------------------------------------ | --------------------------------- |
486
+ | `:operation_entered` | The name of the operation starting | Input going into the operation |
487
+ | `:operation_exited` | The name of the operation finishing | Output of the operation |
488
+ | `:node_entered` | The name of the node | Input going into the node |
489
+ | `:node_exited` | The name of the node | Output of the node |
490
+ | `:error_raised` | The error itself | `nil` |
491
+
492
+ Embedded performances will inherit the observers of the outer operation.
493
+
494
+ ## Recording and playing back services
495
+
496
+ The final main feature of Orchestra is the ability to record the service calls throughout an operation. These recordings can then be used to replay operations. This could be helpful, for instance, to attach to exceptions in your exception logging service so that programmers can replay failed performances on their development environments. In addition, these recordings could be used to drive integration testing. Thus, instead of using separate tools such as like ActiveRecord fixtures, FactoryGirl, and VCR for every service dependency, you can test your operations with one single setup artifact.
497
+
498
+ You can record a performance on any `Conductor` by calling `#record` instead of `#perform`:
499
+
500
+ ```ruby
501
+ recording = conductor.record InvitationService, :account_name => 'realntl'
502
+ recording.output # <-- the usual output is attached to the recording itself
503
+ ```
504
+
505
+ And a recording can be replayed:
506
+
507
+ ```ruby
508
+ Orchestra.replay_recording InvitationService, recording
509
+ ```
510
+
511
+ You can override the inputs passed in when replaying:
512
+
513
+ ```ruby
514
+ Orchestra.replay_recording InvitationService, recording, :account_name => "dhh"
515
+ ```
516
+
517
+ If you want to serialize/persist the recording, just use `JSON.dump`:
518
+
519
+ ```ruby
520
+ json = JSON.dump recording
521
+ File.write "/tmp/recording.json", json
522
+ ```
523
+
524
+ You can replay the recording using `JSON.load`:
525
+
526
+ ```ruby
527
+ json = File.read "tmp/recording.json"
528
+ recording = JSON.load json
529
+ Orchestra.replay_recording InvitationService, recording
530
+ ```
531
+
532
+
533
+ ## Contributing
534
+
535
+ 1. Fork it ( https://github.com/[my-github-username]/orchestra/fork )
536
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
537
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
538
+ 4. Push to the branch (`git push origin my-new-feature`)
539
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ require "rake/testtask"
2
+ require "bundler/gem_tasks"
3
+
4
+ Dir["test/lib/**/*.rb"].each &method(:load)
5
+ Dir["test/lib/tasks/**/*.rake"].each &method(:load)
6
+
7
+ task :env do
8
+ require 'bundler'
9
+ Bundler.setup
10
+ Bundler.require :default, :development
11
+ end
12
+
13
+ desc "Open a development console"
14
+ task :console => :env do
15
+ Console.load
16
+ Orchestra.pry
17
+ end
18
+
19
+ task :test => :env do TestRunner.run end
20
+
21
+ task :default => :test
data/bin/rake ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # This file was generated by Bundler.
4
+ #
5
+ # The application 'rake' is installed as part of a gem, and
6
+ # this file is here to facilitate running it.
7
+ #
8
+
9
+ require 'pathname'
10
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile",
11
+ Pathname.new(__FILE__).realpath)
12
+
13
+ require 'rubygems'
14
+ require 'bundler/setup'
15
+
16
+ load Gem.bin_path('rake', 'rake')