fractor 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.
data/README.adoc ADDED
@@ -0,0 +1,755 @@
1
+ = Fractor: Function-driven Ractors framework
2
+
3
+ Fractor is a lightweight Ruby framework designed to simplify the process of
4
+ distributing computational work across multiple Ractors.
5
+
6
+ == Introduction
7
+
8
+ Fractor stands for *Function-driven Ractors framework*. It is a lightweight Ruby
9
+ framework designed to simplify the process of distributing computational work
10
+ across multiple Ractors (Ruby's actor-like concurrency model).
11
+
12
+ The primary goal of Fractor is to provide a structured way to define work,
13
+ process it in parallel using Ractors, and aggregate the results, while
14
+ abstracting away much of the boilerplate code involved in Ractor management and
15
+ communication.
16
+
17
+ == Installation
18
+
19
+ === Using RubyGems
20
+
21
+ [source,sh]
22
+ ----
23
+ gem install fractor
24
+ ----
25
+
26
+ === Using Bundler
27
+
28
+ Add this line to your application's Gemfile:
29
+
30
+ [source,ruby]
31
+ ----
32
+ gem 'fractor'
33
+ ----
34
+
35
+ And then execute:
36
+
37
+ [source,sh]
38
+ ----
39
+ bundle install
40
+ ----
41
+
42
+
43
+ === Key concepts
44
+
45
+ * *Function-driven:* You define the core processing logic by subclassing
46
+ `Fractor::Worker` and implementing the `process` method.
47
+
48
+ * *Parallel execution:* Work items are automatically distributed to available
49
+ worker Ractors for concurrent processing.
50
+
51
+ * *Result aggregation:* The framework collects both successful results and
52
+ errors from the workers.
53
+
54
+ * *Separation of concerns:* Keeps the framework logic (`fractor.rb`) separate
55
+ from the client's specific implementation (`sample.rb`).
56
+
57
+ == Scope
58
+
59
+ This document describes the design, implementation, and usage of the Fractor
60
+ framework. It provides detailed information about the framework's components,
61
+ their interactions, and how to use them to implement parallel processing in Ruby
62
+ applications.
63
+
64
+ [bibliography]
65
+ == Normative references
66
+
67
+ * [[[ruby-ractor,Ruby Ractor Documentation]]], https://docs.ruby-lang.org/en/master/Ractor.html
68
+
69
+ == Terms and definitions
70
+
71
+ === ractor
72
+
73
+ concurrent programming abstraction in Ruby that enables parallel execution
74
+ with thread safety
75
+
76
+ [.source]
77
+ <<ruby>>
78
+
79
+ === worker
80
+
81
+ component that processes work items to produce work results
82
+
83
+ === work item
84
+
85
+ unit of computation to be processed by a ractor
86
+
87
+ === work result
88
+
89
+ result of processing a work item, either successful or an error
90
+
91
+ === work item class
92
+
93
+ class that represents a work item, typically subclassing `Fractor::Work`
94
+
95
+ === worker class
96
+
97
+ class that represents a worker, typically subclassing `Fractor::Worker`
98
+
99
+ === wrapped ractor
100
+
101
+ component that manages a single ractor and its associated worker
102
+
103
+ === supervisor
104
+
105
+ component that manages the pool of workers and distributes work items
106
+
107
+ === result aggregator
108
+
109
+ component that collects and organizes work results from workers
110
+
111
+
112
+
113
+
114
+ == Core components
115
+
116
+ === General
117
+
118
+ The Fractor framework consists of the following main classes, all residing
119
+ within the `Fractor` module.
120
+
121
+
122
+ === Fractor::Worker
123
+
124
+ The abstract base class for defining how work should be processed.
125
+
126
+ Client code must subclass this and implement the `process(work)` method.
127
+
128
+ The `process` method receives a `Fractor::Work` object (or a subclass) and
129
+ should return a `Fractor::WorkResult` object.
130
+
131
+ === Fractor::Work
132
+
133
+ The abstract base class for representing a unit of work.
134
+
135
+ Typically holds the input data needed by the `Worker`.
136
+
137
+ Client code should subclass this to define specific types of work items.
138
+
139
+ === Fractor::WorkResult
140
+
141
+ A container object returned by the `Worker#process` method.
142
+
143
+ Holds either the successful `:result` of the computation or an `:error`
144
+ message if processing failed.
145
+
146
+ Includes a reference back to the original `:work` item.
147
+
148
+ Provides a `success?` method.
149
+
150
+ === Fractor::ResultAggregator
151
+
152
+ Collects and stores all `WorkResult` objects generated by the workers.
153
+
154
+ Separates results into `results` (successful) and `errors` arrays.
155
+
156
+ === Fractor::WrappedRactor
157
+
158
+ Manages an individual Ruby `Ractor`.
159
+
160
+ Instantiates the client-provided `Worker` subclass within the Ractor.
161
+
162
+ Handles receiving `Work` items, calling the `Worker#process` method, and
163
+ yielding `WorkResult` objects (or errors) back to the `Supervisor`.
164
+
165
+ === Fractor::Supervisor
166
+
167
+ The main orchestrator of the framework.
168
+
169
+ Initializes and manages a pool of `WrappedRactor` instances.
170
+
171
+ Manages a `work_queue` of input data.
172
+
173
+ Distributes work items (wrapped in the client's `Work` subclass) to available
174
+ Ractors.
175
+
176
+ Listens for results and errors from Ractors using `Ractor.select`.
177
+
178
+ Uses `ResultAggregator` to store outcomes.
179
+
180
+ Handles graceful shutdown on `SIGINT` (Ctrl+C).
181
+
182
+
183
+
184
+ == Quick start guide
185
+
186
+ === General
187
+
188
+ This quick start guide shows the minimum steps needed to get a simple parallel
189
+ execution working with Fractor.
190
+
191
+ === Step 1: Create a minimal Work class
192
+
193
+ The Work class represents a unit of work to be processed by a Worker. It
194
+ encapsulates the input data needed for processing.
195
+
196
+ [source,ruby]
197
+ ----
198
+ require 'fractor'
199
+
200
+ class MyWork < Fractor::Work
201
+ # The base class already provides input storage and basic functionality
202
+ # You can optionally override to_s for better debugging
203
+
204
+ def initialize(input)
205
+ super # This stores input in @input
206
+ # Add any additional initialization or replace @input with your own logic
207
+ end
208
+
209
+ def to_s
210
+ "MyWork: #{@input}"
211
+ end
212
+ end
213
+ ----
214
+
215
+ A Work is instantiated with the input data it will process this way:
216
+
217
+ [source,ruby]
218
+ ----
219
+ work_item = MyWork.new(42)
220
+ puts work_item.to_s # Output: MyWork: 42
221
+ ----
222
+
223
+
224
+ === Step 2: Create a minimal Worker class
225
+
226
+ The Worker class defines the processing logic for work items. Each Worker
227
+ instance runs within its own Ractor and processes Work objects sent to it.
228
+
229
+ It must implement the `process(work)` method, which takes a Work object as
230
+ input and returns a `Fractor::WorkResult` object.
231
+
232
+ The `process` method should handle both successful processing and error
233
+ conditions.
234
+
235
+ [source,ruby]
236
+ ----
237
+ class MyWorker < Fractor::Worker
238
+ def process(work)
239
+ # Your processing logic here
240
+ result = work.input * 2
241
+
242
+ # Return a success result
243
+ Fractor::WorkResult.new(result: result, work: work)
244
+ rescue => e
245
+ # Return an error result if something goes wrong
246
+ Fractor::WorkResult.new(error: e.message, work: work)
247
+ end
248
+ end
249
+ ----
250
+
251
+ The `process` method can perform any computation you need. In this example, it
252
+ multiplies the input by 2. If an error occurs, it catches the exception and
253
+ returns an error result.
254
+
255
+ === Step 3: Set up and run the Supervisor
256
+
257
+ The Supervisor class orchestrates the entire framework, managing worker Ractors,
258
+ distributing work, and collecting results.
259
+
260
+ It initializes a pool of Ractors, each running an instance of the Worker
261
+ class. The Supervisor handles the communication between the main thread and
262
+ the Ractors, including sending work items and receiving results.
263
+
264
+ The Supervisor also manages the work queue and the ResultAggregator, which
265
+ collects and organizes all results from the workers.
266
+
267
+ To set up the Supervisor, you need to specify the Worker and Work classes you
268
+ created earlier. You can also specify the number of parallel Ractors to use.
269
+ The default is 2, but you can increase this for more parallelism.
270
+
271
+ [source,ruby]
272
+ ----
273
+ # Create the supervisor
274
+ supervisor = Fractor::Supervisor.new(
275
+ worker_class: MyWorker,
276
+ work_class: MyWork,
277
+ num_workers: 4 # Number of parallel Ractors
278
+ )
279
+
280
+ # Add work items (raw data)
281
+ supervisor.add_work([1, 2, 3, 4, 5].map { |i| MyWork.new(i) })
282
+
283
+ # Run the processing
284
+ supervisor.run
285
+
286
+ # Access results
287
+ puts "Results: #{supervisor.results.results.map(&:result)}"
288
+ puts "Errors: #{supervisor.results.errors.size}"
289
+ ----
290
+
291
+ That's it! With these three simple steps, you have a working parallel processing
292
+ system using Fractor.
293
+
294
+
295
+ == Detailed guides
296
+
297
+ === Work class
298
+
299
+ ==== Purpose and responsibilities
300
+
301
+ The `Fractor::Work` class represents a unit of work to be processed by a Worker.
302
+ Its primary responsibility is to encapsulate the input data needed for
303
+ processing.
304
+
305
+ ==== Implementation requirements
306
+
307
+ At minimum, your Work subclass should:
308
+
309
+ . Inherit from `Fractor::Work`
310
+ . Pass the input data to the superclass constructor
311
+
312
+ [source,ruby]
313
+ ----
314
+ class MyWork < Fractor::Work
315
+ def initialize(input)
316
+ super(input) # This stores input in @input
317
+ # Add any additional initialization if needed
318
+ end
319
+ end
320
+ ----
321
+
322
+ ==== Advanced usage
323
+
324
+ You can extend your Work class to include additional data or methods:
325
+
326
+ [source,ruby]
327
+ ----
328
+ class ComplexWork < Fractor::Work
329
+ attr_reader :options
330
+
331
+ def initialize(input, options = {})
332
+ super(input)
333
+ @options = options
334
+ end
335
+
336
+ def high_priority?
337
+ @options[:priority] == :high
338
+ end
339
+
340
+ def to_s
341
+ "ComplexWork: #{@input} (#{@options[:priority]} priority)"
342
+ end
343
+ end
344
+ ----
345
+
346
+ [TIP]
347
+ ====
348
+ * Keep Work objects lightweight and serializable since they will be passed
349
+ between Ractors
350
+ * Implement a meaningful `to_s` method for better debugging
351
+ * Consider adding validation in the initializer to catch issues early
352
+ ====
353
+
354
+ === Worker class
355
+
356
+ ==== Purpose and responsibilities
357
+
358
+ The `Fractor::Worker` class defines the processing logic for work items. Each
359
+ Worker instance runs within its own Ractor and processes Work objects sent to
360
+ it.
361
+
362
+ ==== Implementation requirements
363
+
364
+ Your Worker subclass must:
365
+
366
+ . Inherit from `Fractor::Worker`
367
+ . Implement the `process(work)` method
368
+ . Return a `Fractor::WorkResult` object from the `process` method
369
+ . Handle both successful processing and error conditions
370
+
371
+ [source,ruby]
372
+ ----
373
+ class MyWorker < Fractor::Worker
374
+ def process(work)
375
+ # Process the work
376
+
377
+ if work.input < 0
378
+ return Fractor::WorkResult.new(
379
+ error: "Cannot process negative numbers",
380
+ work: work
381
+ )
382
+ end
383
+
384
+ # Normal processing...
385
+ result = work.input * 2
386
+
387
+ # Return a WorkResult
388
+ Fractor::WorkResult.new(result: result, work: work)
389
+ end
390
+ end
391
+ ----
392
+
393
+
394
+ ==== Error handling
395
+
396
+ The Worker class should handle two types of errors.
397
+
398
+
399
+ ===== Handled errors
400
+
401
+ These are expected error conditions that your code explicitly checks for.
402
+
403
+ [source,ruby]
404
+ ----
405
+ def process(work)
406
+ if work.input < 0
407
+ return Fractor::WorkResult.new(
408
+ error: "Cannot process negative numbers",
409
+ work: work
410
+ )
411
+ end
412
+
413
+ # Normal processing...
414
+ Fractor::WorkResult.new(result: calculated_value, work: work)
415
+ end
416
+ ----
417
+
418
+ === Unexpected errors caught by rescue
419
+
420
+ These are unexpected exceptions that may occur during processing. You should
421
+ catch these and convert them into error results.
422
+
423
+ [source,ruby]
424
+ ----
425
+ def process(work)
426
+ # Processing that might raise exceptions
427
+ result = complex_calculation(work.input)
428
+
429
+ Fractor::WorkResult.new(result: result, work: work)
430
+ rescue StandardError => e
431
+ # Catch and convert any unexpected exceptions to error results
432
+ Fractor::WorkResult.new(error: "An unexpected error occurred: #{e.message}", work: work)
433
+ end
434
+ ----
435
+
436
+ [TIP]
437
+ * Keep the `process` method focused on a single responsibility
438
+ * Use meaningful error messages that help diagnose issues
439
+ * Consider adding logging within the `process` method for debugging
440
+ * Ensure all paths return a valid `WorkResult` object
441
+
442
+ === WorkResult class
443
+
444
+ ==== Purpose and responsibilities
445
+
446
+ The `Fractor::WorkResult` class is a container that holds either the successful
447
+ result of processing or an error message, along with a reference to the original
448
+ work item.
449
+
450
+ ==== Creating results
451
+
452
+ To create a successful result:
453
+
454
+ [source,ruby]
455
+ ----
456
+ # For successful processing
457
+ Fractor::WorkResult.new(result: calculated_value, work: work_object)
458
+ ----
459
+
460
+ To create an error result:
461
+
462
+ [source,ruby]
463
+ ----
464
+ # For error conditions
465
+ Fractor::WorkResult.new(error: "Error message", work: work_object)
466
+ ----
467
+
468
+ ==== Checking result status
469
+
470
+ You can check if a result was successful:
471
+
472
+ [source,ruby]
473
+ ----
474
+ if work_result.success?
475
+ # Handle successful result
476
+ processed_value = work_result.result
477
+ else
478
+ # Handle error
479
+ error_message = work_result.error
480
+ end
481
+ ----
482
+
483
+ ==== Accessing original work
484
+
485
+ The original work item is always available:
486
+
487
+ [source,ruby]
488
+ ----
489
+ original_work = work_result.work
490
+ input_value = original_work.input
491
+ ----
492
+
493
+ === ResultAggregator class
494
+
495
+ ==== Purpose and responsibilities
496
+
497
+ The `Fractor::ResultAggregator` collects and organizes all results from the
498
+ workers, separating successful results from errors.
499
+
500
+ Completed work results may be order independent or order dependent.
501
+
502
+ * For order independent results, the results may be utilized (popped) as they
503
+ are received.
504
+
505
+ * For order dependent results, the results are aggregated in the order they
506
+ are received. The order of results is important for re-assembly or
507
+ further processing.
508
+
509
+ * For results that require aggregation, the `ResultsAggregator` is used to determine
510
+ whether the results are completed, which signify that all work items have
511
+ been processed and ready for further processing.
512
+
513
+
514
+ ==== Accessing results
515
+
516
+ To access successful results:
517
+
518
+ [source,ruby]
519
+ ----
520
+ # Get all successful results
521
+ successful_results = supervisor.results.results
522
+
523
+ # Extract just the result values
524
+ result_values = successful_results.map(&:result)
525
+ ----
526
+
527
+ To access errors:
528
+
529
+ [source,ruby]
530
+ ----
531
+ # Get all error results
532
+ error_results = supervisor.results.errors
533
+
534
+ # Extract error messages
535
+ error_messages = error_results.map(&:error)
536
+
537
+ # Get the work items that failed
538
+ failed_work_items = error_results.map(&:work)
539
+ ----
540
+
541
+
542
+ [TIP]
543
+ ====
544
+ * Check both successful results and errors after processing completes
545
+ * Consider implementing custom reporting based on the aggregated results
546
+ ====
547
+
548
+
549
+ === WrappedRactor class
550
+
551
+ ==== Purpose and responsibilities
552
+
553
+ The `Fractor::WrappedRactor` class manages an individual Ruby Ractor, handling
554
+ the communication between the Supervisor and the Worker instance running inside
555
+ the Ractor.
556
+
557
+ ==== Usage notes
558
+
559
+ This class is primarily used internally by the Supervisor, but understanding its
560
+ role helps with debugging:
561
+
562
+ * Each WrappedRactor creates and manages one Ractor
563
+ * The Worker instance lives inside the Ractor
564
+ * Work items are sent to the Ractor via the WrappedRactor's `send` method
565
+ * Results are yielded back to the Supervisor
566
+
567
+ ==== Error propagation
568
+
569
+ The WrappedRactor handles error propagation in two ways:
570
+
571
+ . Errors from the Worker's `process` method are wrapped in a WorkResult and
572
+ yielded back
573
+ . Unexpected errors in the Ractor itself are caught and logged
574
+
575
+ === Supervisor class
576
+
577
+ ==== Purpose and responsibilities
578
+
579
+ The `Fractor::Supervisor` class orchestrates the entire framework, managing
580
+ worker Ractors, distributing work, and collecting results.
581
+
582
+ ==== Configuration options
583
+
584
+ When creating a Supervisor, you can configure:
585
+
586
+ [source,ruby]
587
+ ----
588
+ supervisor = Fractor::Supervisor.new(
589
+ worker_class: MyWorker, # Required: Your Worker subclass
590
+ work_class: MyWork, # Required: Your Work subclass
591
+ num_workers: 4 # Optional: Number of Ractors (default: 2)
592
+ )
593
+ ----
594
+
595
+ ==== Adding work
596
+
597
+ You can add work items individually or in batches:
598
+
599
+ [source,ruby]
600
+ ----
601
+ # Add a single item
602
+ supervisor.add_work([42])
603
+
604
+ # Add multiple items
605
+ supervisor.add_work([1, 2, 3, 4, 5])
606
+
607
+ # Add complex items
608
+ supervisor.add_work([
609
+ {id: 1, data: "foo"},
610
+ {id: 2, data: "bar"}
611
+ ])
612
+ ----
613
+
614
+ ==== Running and monitoring
615
+
616
+ To start processing:
617
+
618
+ [source,ruby]
619
+ ----
620
+ # Start processing and block until complete
621
+ supervisor.run
622
+ ----
623
+
624
+ The Supervisor automatically handles:
625
+
626
+ * Starting the worker Ractors
627
+ * Distributing work items to available workers
628
+ * Collecting results and errors
629
+ * Graceful shutdown on completion or interruption (Ctrl+C)
630
+
631
+ ==== Accessing results
632
+
633
+ After processing completes:
634
+
635
+ [source,ruby]
636
+ ----
637
+ # Get the ResultAggregator
638
+ aggregator = supervisor.results
639
+
640
+ # Check counts
641
+ puts "Processed #{aggregator.results.size} items successfully"
642
+ puts "Encountered #{aggregator.errors.size} errors"
643
+
644
+ # Access successful results
645
+ aggregator.results.each do |result|
646
+ puts "Work item #{result.work.input} produced #{result.result}"
647
+ end
648
+
649
+ # Access errors
650
+ aggregator.errors.each do |error_result|
651
+ puts "Work item #{error_result.work.input} failed: #{error_result.error}"
652
+ end
653
+ ----
654
+
655
+ ==== Advanced usage patterns
656
+
657
+ ===== Custom work distribution
658
+
659
+ For more complex scenarios, you might want to prioritize certain work items:
660
+
661
+ [source,ruby]
662
+ ----
663
+ # Add high-priority items first
664
+ supervisor.add_work(high_priority_items)
665
+
666
+ # Run with just enough workers for high-priority items
667
+ supervisor.run
668
+
669
+ # Add and process lower-priority items
670
+ supervisor.add_work(low_priority_items)
671
+ supervisor.run
672
+ ----
673
+
674
+ ===== Handling large datasets
675
+
676
+ For very large datasets, consider processing in batches:
677
+
678
+ [source,ruby]
679
+ ----
680
+ large_dataset.each_slice(1000) do |batch|
681
+ supervisor.add_work(batch)
682
+ supervisor.run
683
+
684
+ # Process this batch's results before continuing
685
+ process_batch_results(supervisor.results)
686
+ end
687
+ ----
688
+
689
+ == Running the example
690
+
691
+ . Install the gem as described in the Installation section.
692
+
693
+ . Create a new Ruby file (e.g., `my_fractor_example.rb`) with your implementation:
694
+
695
+ [source,ruby]
696
+ ----
697
+ require 'fractor'
698
+
699
+ # Define your Work class
700
+ class MyWork < Fractor::Work
701
+ def to_s
702
+ "MyWork: #{@input}"
703
+ end
704
+ end
705
+
706
+ # Define your Worker class
707
+ class MyWorker < Fractor::Worker
708
+ def process(work)
709
+ if work.input == 5
710
+ # Return a Fractor::WorkResult for errors
711
+ return Fractor::WorkResult.new(error: "Error processing work #{work.input}", work: work)
712
+ end
713
+
714
+ calculated = work.input * 2
715
+ # Return a Fractor::WorkResult for success
716
+ Fractor::WorkResult.new(result: calculated, work: work)
717
+ end
718
+ end
719
+
720
+ # Create supervisor
721
+ supervisor = Fractor::Supervisor.new(
722
+ worker_class: MyWorker,
723
+ work_class: MyWork,
724
+ num_workers: 2
725
+ )
726
+
727
+ # Add work items (1..10)
728
+ supervisor.add_work((1..10).to_a)
729
+
730
+ # Run processing
731
+ supervisor.run
732
+
733
+ # Display results
734
+ puts "Results: #{supervisor.results.results.map(&:result).join(', ')}"
735
+ puts "Errors: #{supervisor.results.errors.map { |e| e.work.input }.join(', ')}"
736
+ ----
737
+
738
+ . Run the example from your terminal:
739
+
740
+ [source,sh]
741
+ ----
742
+ ruby my_fractor_example.rb
743
+ ----
744
+
745
+ You will see output showing Ractors starting, receiving work, processing it, and
746
+ the final aggregated results, including any errors encountered. Press `Ctrl+C`
747
+ during execution to test the graceful shutdown.
748
+
749
+
750
+
751
+ == Copyright and license
752
+
753
+ Copyright Ribose.
754
+
755
+ Licensed under the MIT License.