simple_flow 0.1.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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.rubocop.yml +57 -0
  5. data/CHANGELOG.md +4 -0
  6. data/COMMITS.md +196 -0
  7. data/LICENSE +21 -0
  8. data/README.md +481 -0
  9. data/Rakefile +15 -0
  10. data/benchmarks/parallel_vs_sequential.rb +98 -0
  11. data/benchmarks/pipeline_overhead.rb +130 -0
  12. data/docs/api/middleware.md +468 -0
  13. data/docs/api/parallel-step.md +363 -0
  14. data/docs/api/pipeline.md +382 -0
  15. data/docs/api/result.md +375 -0
  16. data/docs/concurrent/best-practices.md +687 -0
  17. data/docs/concurrent/introduction.md +246 -0
  18. data/docs/concurrent/parallel-steps.md +418 -0
  19. data/docs/concurrent/performance.md +481 -0
  20. data/docs/core-concepts/flow-control.md +452 -0
  21. data/docs/core-concepts/middleware.md +389 -0
  22. data/docs/core-concepts/overview.md +219 -0
  23. data/docs/core-concepts/pipeline.md +315 -0
  24. data/docs/core-concepts/result.md +168 -0
  25. data/docs/core-concepts/steps.md +391 -0
  26. data/docs/development/benchmarking.md +443 -0
  27. data/docs/development/contributing.md +380 -0
  28. data/docs/development/dagwood-concepts.md +435 -0
  29. data/docs/development/testing.md +514 -0
  30. data/docs/getting-started/examples.md +197 -0
  31. data/docs/getting-started/installation.md +62 -0
  32. data/docs/getting-started/quick-start.md +218 -0
  33. data/docs/guides/choosing-concurrency-model.md +441 -0
  34. data/docs/guides/complex-workflows.md +440 -0
  35. data/docs/guides/data-fetching.md +478 -0
  36. data/docs/guides/error-handling.md +635 -0
  37. data/docs/guides/file-processing.md +505 -0
  38. data/docs/guides/validation-patterns.md +496 -0
  39. data/docs/index.md +169 -0
  40. data/examples/.gitignore +3 -0
  41. data/examples/01_basic_pipeline.rb +112 -0
  42. data/examples/02_error_handling.rb +178 -0
  43. data/examples/03_middleware.rb +186 -0
  44. data/examples/04_parallel_automatic.rb +221 -0
  45. data/examples/05_parallel_explicit.rb +279 -0
  46. data/examples/06_real_world_ecommerce.rb +288 -0
  47. data/examples/07_real_world_etl.rb +277 -0
  48. data/examples/08_graph_visualization.rb +246 -0
  49. data/examples/09_pipeline_visualization.rb +266 -0
  50. data/examples/10_concurrency_control.rb +235 -0
  51. data/examples/11_sequential_dependencies.rb +243 -0
  52. data/examples/12_none_constant.rb +161 -0
  53. data/examples/README.md +374 -0
  54. data/examples/regression_test/01_basic_pipeline.txt +38 -0
  55. data/examples/regression_test/02_error_handling.txt +92 -0
  56. data/examples/regression_test/03_middleware.txt +61 -0
  57. data/examples/regression_test/04_parallel_automatic.txt +86 -0
  58. data/examples/regression_test/05_parallel_explicit.txt +80 -0
  59. data/examples/regression_test/06_real_world_ecommerce.txt +53 -0
  60. data/examples/regression_test/07_real_world_etl.txt +58 -0
  61. data/examples/regression_test/08_graph_visualization.txt +429 -0
  62. data/examples/regression_test/09_pipeline_visualization.txt +305 -0
  63. data/examples/regression_test/10_concurrency_control.txt +96 -0
  64. data/examples/regression_test/11_sequential_dependencies.txt +86 -0
  65. data/examples/regression_test/12_none_constant.txt +64 -0
  66. data/examples/regression_test.rb +105 -0
  67. data/lib/simple_flow/dependency_graph.rb +120 -0
  68. data/lib/simple_flow/dependency_graph_visualizer.rb +326 -0
  69. data/lib/simple_flow/middleware.rb +36 -0
  70. data/lib/simple_flow/parallel_executor.rb +80 -0
  71. data/lib/simple_flow/pipeline.rb +405 -0
  72. data/lib/simple_flow/result.rb +88 -0
  73. data/lib/simple_flow/step_tracker.rb +58 -0
  74. data/lib/simple_flow/version.rb +5 -0
  75. data/lib/simple_flow.rb +41 -0
  76. data/mkdocs.yml +146 -0
  77. data/pipeline_graph.dot +51 -0
  78. data/pipeline_graph.html +60 -0
  79. data/pipeline_graph.mmd +19 -0
  80. metadata +127 -0
@@ -0,0 +1,635 @@
1
+ # Error Handling Guide
2
+
3
+ SimpleFlow provides flexible mechanisms for handling errors, validating data, and controlling pipeline flow. This guide covers comprehensive error handling strategies.
4
+
5
+ ## Core Concepts
6
+
7
+ ### The Result Object
8
+
9
+ Every step receives and returns a `Result` object with three key components:
10
+
11
+ - **value**: The data being processed
12
+ - **context**: Metadata and contextual information
13
+ - **errors**: Accumulated error messages organized by category
14
+
15
+ ### Flow Control
16
+
17
+ Steps control execution flow using two methods:
18
+
19
+ - `continue(new_value)`: Proceed to next step with updated value
20
+ - `halt(new_value = nil)`: Stop pipeline execution
21
+
22
+ ## Basic Error Handling
23
+
24
+ ### Halting on Validation Failure
25
+
26
+ The simplest error handling pattern is to halt immediately when validation fails:
27
+
28
+ ```ruby
29
+ pipeline = SimpleFlow::Pipeline.new do
30
+ step ->(result) {
31
+ age = result.value
32
+
33
+ if age < 18
34
+ result.halt.with_error(:validation, "Must be 18 or older")
35
+ else
36
+ result.continue(age)
37
+ end
38
+ }
39
+
40
+ step ->(result) {
41
+ # This only runs if age >= 18
42
+ result.continue("Approved for age #{result.value}")
43
+ }
44
+ end
45
+
46
+ result = pipeline.call(SimpleFlow::Result.new(15))
47
+ result.continue? # => false
48
+ result.errors # => {:validation => ["Must be 18 or older"]}
49
+ ```
50
+
51
+ ### Checking Continue Status
52
+
53
+ Always check `continue?` to determine if pipeline completed successfully:
54
+
55
+ ```ruby
56
+ result = pipeline.call(initial_data)
57
+
58
+ if result.continue?
59
+ puts "Success: #{result.value}"
60
+ else
61
+ puts "Failed with errors: #{result.errors}"
62
+ end
63
+ ```
64
+
65
+ ## Error Accumulation
66
+
67
+ ### Collecting Multiple Errors
68
+
69
+ Instead of halting at the first error, collect all validation errors:
70
+
71
+ ```ruby
72
+ pipeline = SimpleFlow::Pipeline.new do
73
+ step ->(result) {
74
+ password = result.value
75
+
76
+ result = if password.length < 8
77
+ result.with_error(:password, "Must be at least 8 characters")
78
+ else
79
+ result
80
+ end
81
+
82
+ result = unless password =~ /[A-Z]/
83
+ result.with_error(:password, "Must contain uppercase letters")
84
+ else
85
+ result
86
+ end
87
+
88
+ result = unless password =~ /[0-9]/
89
+ result.with_error(:password, "Must contain numbers")
90
+ else
91
+ result
92
+ end
93
+
94
+ result.continue(password)
95
+ }
96
+
97
+ step ->(result) {
98
+ # Check if any errors were accumulated
99
+ if result.errors.any?
100
+ result.halt.with_error(:validation, "Password requirements not met")
101
+ else
102
+ result.continue(result.value)
103
+ end
104
+ }
105
+
106
+ step ->(result) {
107
+ # Only executes if no validation errors
108
+ result.continue("Password accepted")
109
+ }
110
+ end
111
+
112
+ result = pipeline.call(SimpleFlow::Result.new("weak"))
113
+ result.errors
114
+ # => {
115
+ # :password => [
116
+ # "Must be at least 8 characters",
117
+ # "Must contain uppercase letters",
118
+ # "Must contain numbers"
119
+ # ],
120
+ # :validation => ["Password requirements not met"]
121
+ # }
122
+ ```
123
+
124
+ ### Parallel Validation
125
+
126
+ Use parallel execution to run multiple validations concurrently:
127
+
128
+ ```ruby
129
+ pipeline = SimpleFlow::Pipeline.new do
130
+ # Validate multiple fields in parallel
131
+ step :validate_email, ->(result) {
132
+ unless valid_email?(result.value[:email])
133
+ result.with_error(:email, "Invalid email format")
134
+ end
135
+ result.continue(result.value)
136
+ }, depends_on: []
137
+
138
+ step :validate_phone, ->(result) {
139
+ unless valid_phone?(result.value[:phone])
140
+ result.with_error(:phone, "Invalid phone format")
141
+ end
142
+ result.continue(result.value)
143
+ }, depends_on: []
144
+
145
+ step :validate_age, ->(result) {
146
+ if result.value[:age] < 18
147
+ result.with_error(:age, "Must be 18 or older")
148
+ end
149
+ result.continue(result.value)
150
+ }, depends_on: []
151
+
152
+ # Check all validation results
153
+ step :verify_validations, ->(result) {
154
+ if result.errors.any?
155
+ result.halt(result.value)
156
+ else
157
+ result.continue(result.value)
158
+ end
159
+ }, depends_on: [:validate_email, :validate_phone, :validate_age]
160
+
161
+ # Only runs if all validations pass
162
+ step :create_account, ->(result) {
163
+ result.continue("Account created successfully")
164
+ }, depends_on: [:verify_validations]
165
+ end
166
+ ```
167
+
168
+ ## Error Categories
169
+
170
+ ### Organizing Errors by Type
171
+
172
+ Use symbols to categorize errors for better organization:
173
+
174
+ ```ruby
175
+ step :process_order, ->(result) {
176
+ order = result.value
177
+
178
+ # Business logic errors
179
+ if order[:total] > 10000
180
+ return result.halt.with_error(:business_rule, "Order exceeds maximum amount")
181
+ end
182
+
183
+ # Inventory errors
184
+ unless inventory_available?(order[:items])
185
+ return result.halt.with_error(:inventory, "Items out of stock")
186
+ end
187
+
188
+ # Payment errors
189
+ unless valid_payment?(order[:payment])
190
+ return result.halt.with_error(:payment, "Payment method declined")
191
+ end
192
+
193
+ result.continue(order)
194
+ }
195
+
196
+ # Access errors by category
197
+ result = pipeline.call(order_data)
198
+ result.errors[:business_rule] # => ["Order exceeds maximum amount"]
199
+ result.errors[:inventory] # => nil
200
+ result.errors[:payment] # => nil
201
+ ```
202
+
203
+ ### Multiple Errors Per Category
204
+
205
+ The `with_error` method appends to existing errors in a category:
206
+
207
+ ```ruby
208
+ step :validate_fields, ->(result) {
209
+ data = result.value
210
+ result_obj = result
211
+
212
+ if data[:name].nil?
213
+ result_obj = result_obj.with_error(:required, "Name is required")
214
+ end
215
+
216
+ if data[:email].nil?
217
+ result_obj = result_obj.with_error(:required, "Email is required")
218
+ end
219
+
220
+ if data[:phone].nil?
221
+ result_obj = result_obj.with_error(:required, "Phone is required")
222
+ end
223
+
224
+ result_obj.continue(data)
225
+ }
226
+
227
+ # result.errors[:required] => ["Name is required", "Email is required", "Phone is required"]
228
+ ```
229
+
230
+ ## Exception Handling
231
+
232
+ ### Rescuing Exceptions
233
+
234
+ Wrap external calls in exception handlers:
235
+
236
+ ```ruby
237
+ step :fetch_from_api, ->(result) {
238
+ begin
239
+ response = HTTP.get("https://api.example.com/data")
240
+ data = JSON.parse(response.body)
241
+ result.with_context(:api_data, data).continue(result.value)
242
+ rescue HTTP::Error => e
243
+ result.halt.with_error(:network, "API request failed: #{e.message}")
244
+ rescue JSON::ParserError => e
245
+ result.halt.with_error(:parse, "Invalid JSON response: #{e.message}")
246
+ rescue StandardError => e
247
+ result.halt.with_error(:unknown, "Unexpected error: #{e.message}")
248
+ end
249
+ }
250
+ ```
251
+
252
+ ### Retry Logic with Middleware
253
+
254
+ Implement retry logic using custom middleware:
255
+
256
+ ```ruby
257
+ class RetryMiddleware
258
+ def initialize(callable, max_retries: 3, retry_on: [StandardError])
259
+ @callable = callable
260
+ @max_retries = max_retries
261
+ @retry_on = Array(retry_on)
262
+ end
263
+
264
+ def call(result)
265
+ attempts = 0
266
+
267
+ begin
268
+ attempts += 1
269
+ @callable.call(result)
270
+ rescue *@retry_on => e
271
+ if attempts < @max_retries
272
+ sleep(attempts ** 2) # Exponential backoff
273
+ retry
274
+ else
275
+ result.halt.with_error(
276
+ :retry_exhausted,
277
+ "Failed after #{@max_retries} attempts: #{e.message}"
278
+ )
279
+ end
280
+ end
281
+ end
282
+ end
283
+
284
+ pipeline = SimpleFlow::Pipeline.new do
285
+ use_middleware RetryMiddleware, max_retries: 3, retry_on: [Net::HTTPError, Timeout::Error]
286
+
287
+ step ->(result) {
288
+ # This step will be retried up to 3 times on network errors
289
+ data = fetch_from_unreliable_api(result.value)
290
+ result.continue(data)
291
+ }
292
+ end
293
+ ```
294
+
295
+ ## Conditional Processing
296
+
297
+ ### Early Exit on Errors
298
+
299
+ Check for errors and exit early if found:
300
+
301
+ ```ruby
302
+ pipeline = SimpleFlow::Pipeline.new do
303
+ step :load_data, ->(result) {
304
+ begin
305
+ data = load_file(result.value)
306
+ result.continue(data)
307
+ rescue Errno::ENOENT
308
+ result.halt.with_error(:file, "File not found")
309
+ end
310
+ }
311
+
312
+ step :validate_data, ->(result) {
313
+ # Only runs if load_data succeeded
314
+ if invalid?(result.value)
315
+ result.halt.with_error(:validation, "Invalid data format")
316
+ else
317
+ result.continue(result.value)
318
+ end
319
+ }
320
+
321
+ step :process_data, ->(result) {
322
+ # Only runs if both previous steps succeeded
323
+ processed = transform(result.value)
324
+ result.continue(processed)
325
+ }
326
+ end
327
+ ```
328
+
329
+ ### Conditional Flow Based on Context
330
+
331
+ Use context to make decisions about flow:
332
+
333
+ ```ruby
334
+ pipeline = SimpleFlow::Pipeline.new do
335
+ step :check_user_role, ->(result) {
336
+ user = result.value
337
+ result.with_context(:role, user[:role]).continue(user)
338
+ }
339
+
340
+ step :authorize_action, ->(result) {
341
+ case result.context[:role]
342
+ when :admin
343
+ result.with_context(:authorized, true).continue(result.value)
344
+ when :user
345
+ if can_access?(result.value)
346
+ result.with_context(:authorized, true).continue(result.value)
347
+ else
348
+ result.halt.with_error(:auth, "Insufficient permissions")
349
+ end
350
+ else
351
+ result.halt.with_error(:auth, "Unknown role")
352
+ end
353
+ }
354
+
355
+ step :perform_action, ->(result) {
356
+ # Only executes if authorized
357
+ result.continue("Action completed")
358
+ }
359
+ end
360
+ ```
361
+
362
+ ## Error Recovery
363
+
364
+ ### Fallback Values
365
+
366
+ Provide fallback values when operations fail:
367
+
368
+ ```ruby
369
+ step :fetch_with_fallback, ->(result) {
370
+ begin
371
+ data = fetch_from_primary_api(result.value)
372
+ result.with_context(:source, :primary).continue(data)
373
+ rescue API::Error
374
+ # Try secondary source
375
+ begin
376
+ data = fetch_from_secondary_api(result.value)
377
+ result.with_context(:source, :secondary).continue(data)
378
+ rescue API::Error
379
+ # Use cached data as last resort
380
+ cached_data = fetch_from_cache(result.value)
381
+ if cached_data
382
+ result.with_context(:source, :cache).continue(cached_data)
383
+ else
384
+ result.halt.with_error(:data, "All data sources unavailable")
385
+ end
386
+ end
387
+ end
388
+ }
389
+ ```
390
+
391
+ ### Partial Success Handling
392
+
393
+ Continue processing even if some operations fail:
394
+
395
+ ```ruby
396
+ pipeline = SimpleFlow::Pipeline.new do
397
+ step :batch_process, ->(result) {
398
+ items = result.value
399
+ successful = []
400
+ failed = []
401
+
402
+ items.each do |item|
403
+ begin
404
+ processed = process_item(item)
405
+ successful << processed
406
+ rescue ProcessingError => e
407
+ failed << { item: item, error: e.message }
408
+ end
409
+ end
410
+
411
+ result_obj = result.continue(successful)
412
+
413
+ if failed.any?
414
+ result_obj = result_obj.with_context(:failed_items, failed)
415
+ result_obj = result_obj.with_error(:partial_failure, "#{failed.size} items failed to process")
416
+ end
417
+
418
+ result_obj
419
+ }
420
+
421
+ step :handle_results, ->(result) {
422
+ if result.context[:failed_items]
423
+ # Log failures but continue
424
+ log_failures(result.context[:failed_items])
425
+ end
426
+
427
+ result.continue("Processed #{result.value.size} items")
428
+ }
429
+ end
430
+ ```
431
+
432
+ ## Debugging Halted Pipelines
433
+
434
+ ### Using StepTracker
435
+
436
+ SimpleFlow's `StepTracker` adds context about where execution halted:
437
+
438
+ ```ruby
439
+ require 'simple_flow/step_tracker'
440
+
441
+ step_a = SimpleFlow::StepTracker.new(->(result) {
442
+ result.continue("Step A done")
443
+ })
444
+
445
+ step_b = SimpleFlow::StepTracker.new(->(result) {
446
+ result.halt.with_error(:failure, "Step B failed")
447
+ })
448
+
449
+ step_c = SimpleFlow::StepTracker.new(->(result) {
450
+ result.continue("Step C done")
451
+ })
452
+
453
+ pipeline = SimpleFlow::Pipeline.new do
454
+ step step_a
455
+ step step_b
456
+ step step_c
457
+ end
458
+
459
+ result = pipeline.call(SimpleFlow::Result.new(nil))
460
+ result.context[:halted_step] # => The step_b lambda
461
+ ```
462
+
463
+ ### Adding Debug Context
464
+
465
+ Add helpful debugging information to context:
466
+
467
+ ```ruby
468
+ step :debug_step, ->(result) {
469
+ result
470
+ .with_context(:step_name, "debug_step")
471
+ .with_context(:timestamp, Time.now)
472
+ .with_context(:input_size, result.value.size)
473
+ .continue(result.value)
474
+ }
475
+ ```
476
+
477
+ ## Validation Patterns
478
+
479
+ ### Schema Validation
480
+
481
+ Validate data structure before processing:
482
+
483
+ ```ruby
484
+ step :validate_schema, ->(result) {
485
+ data = result.value
486
+ required_fields = [:name, :email, :age]
487
+
488
+ missing = required_fields.reject { |field| data.key?(field) }
489
+
490
+ if missing.any?
491
+ result.halt.with_error(
492
+ :schema,
493
+ "Missing required fields: #{missing.join(', ')}"
494
+ )
495
+ else
496
+ result.continue(data)
497
+ end
498
+ }
499
+ ```
500
+
501
+ ### Type Validation
502
+
503
+ Check data types:
504
+
505
+ ```ruby
506
+ step :validate_types, ->(result) {
507
+ data = result.value
508
+ errors = []
509
+
510
+ unless data[:age].is_a?(Integer)
511
+ errors << "age must be an integer"
512
+ end
513
+
514
+ unless data[:email].is_a?(String)
515
+ errors << "email must be a string"
516
+ end
517
+
518
+ if errors.any?
519
+ result.halt.with_error(:type, errors.join(", "))
520
+ else
521
+ result.continue(data)
522
+ end
523
+ }
524
+ ```
525
+
526
+ ### Range Validation
527
+
528
+ Validate numeric ranges:
529
+
530
+ ```ruby
531
+ step :validate_ranges, ->(result) {
532
+ data = result.value
533
+
534
+ if data[:age] < 0 || data[:age] > 120
535
+ return result.halt.with_error(:range, "Age must be between 0 and 120")
536
+ end
537
+
538
+ if data[:quantity] < 1
539
+ return result.halt.with_error(:range, "Quantity must be at least 1")
540
+ end
541
+
542
+ result.continue(data)
543
+ }
544
+ ```
545
+
546
+ ## Real-World Example
547
+
548
+ Complete error handling in an order processing pipeline:
549
+
550
+ ```ruby
551
+ order_pipeline = SimpleFlow::Pipeline.new do
552
+ # Validate order structure
553
+ step :validate_order, ->(result) {
554
+ order = result.value
555
+ errors = []
556
+
557
+ errors << "Missing customer email" unless order[:customer][:email]
558
+ errors << "No items in order" if order[:items].empty?
559
+ errors << "Missing payment method" unless order[:payment][:card_token]
560
+
561
+ if errors.any?
562
+ result.halt.with_error(:validation, errors.join(", "))
563
+ else
564
+ result.with_context(:validated_at, Time.now).continue(order)
565
+ end
566
+ }, depends_on: []
567
+
568
+ # Check inventory with error handling
569
+ step :check_inventory, ->(result) {
570
+ begin
571
+ inventory_results = InventoryService.check_availability(result.value[:items])
572
+
573
+ if inventory_results.all? { |r| r[:available] }
574
+ result.with_context(:inventory_check, inventory_results).continue(result.value)
575
+ else
576
+ unavailable = inventory_results.reject { |r| r[:available] }
577
+ result.halt.with_error(
578
+ :inventory,
579
+ "Items unavailable: #{unavailable.map { |i| i[:product_id] }.join(', ')}"
580
+ )
581
+ end
582
+ rescue InventoryService::Error => e
583
+ result.halt.with_error(:service, "Inventory service error: #{e.message}")
584
+ end
585
+ }, depends_on: [:validate_order]
586
+
587
+ # Process payment with retries
588
+ step :process_payment, ->(result) {
589
+ total = result.context[:total]
590
+
591
+ payment_result = PaymentService.process_payment(
592
+ total,
593
+ result.value[:payment][:card_token]
594
+ )
595
+
596
+ case payment_result[:status]
597
+ when :success
598
+ result.with_context(:payment, payment_result).continue(result.value)
599
+ when :declined
600
+ result.halt.with_error(:payment, "Card declined: #{payment_result[:reason]}")
601
+ when :insufficient_funds
602
+ result.halt.with_error(:payment, "Insufficient funds")
603
+ else
604
+ result.halt.with_error(:payment, "Payment processing failed")
605
+ end
606
+ }, depends_on: [:check_inventory]
607
+ end
608
+
609
+ # Execute and handle errors
610
+ result = order_pipeline.call_parallel(order_data)
611
+
612
+ if result.continue?
613
+ puts "Order processed successfully"
614
+ send_confirmation_email(result.value)
615
+ else
616
+ puts "Order failed:"
617
+ result.errors.each do |category, messages|
618
+ puts " #{category}: #{messages.join(', ')}"
619
+ end
620
+
621
+ # Handle specific error types
622
+ if result.errors[:payment]
623
+ log_payment_failure(result.value)
624
+ elsif result.errors[:inventory]
625
+ notify_inventory_team(result.value)
626
+ end
627
+ end
628
+ ```
629
+
630
+ ## Related Documentation
631
+
632
+ - [Validation Patterns](validation-patterns.md) - Common validation strategies
633
+ - [Complex Workflows](complex-workflows.md) - Building sophisticated pipelines
634
+ - [Result API](../api/result.md) - Complete Result class reference
635
+ - [Pipeline API](../api/pipeline.md) - Pipeline class reference