fractor 0.1.7 → 0.1.9
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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +97 -39
- data/README.adoc +137 -0
- data/lib/fractor/persistent_work_queue.rb +218 -0
- data/lib/fractor/queue_persister.rb +253 -0
- data/lib/fractor/result_cache.rb +274 -0
- data/lib/fractor/supervisor.rb +1 -1
- data/lib/fractor/version.rb +1 -1
- data/lib/fractor/work.rb +9 -2
- data/lib/fractor/wrapped_ractor3.rb +11 -6
- data/lib/fractor/wrapped_ractor4.rb +11 -6
- data/lib/fractor.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b4ea06ce5fb4dc056cd294dcadeac07cca24610a0b995e5f910b3ef0075e0ea
|
|
4
|
+
data.tar.gz: 1adc8b962143e4713259b18c5545ab39ee0a62b11db5880b0389ff64449a27b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 31800cb4767762f1bdd1fec315cda164fd5effb3fd72bb327b4a30788ac41b2b2cd0ec3bcf2548e82fe69ccf97150007222e81c875bcb5f78c01d4e28162ef03
|
|
7
|
+
data.tar.gz: 728316725154c5fde32f2bb8fb6ab584b26814ad463af43c4928a333083ed34e6d66dc781c6edad82128644f0f6b6197c31303aa6353ec586aeaa81212dd911d
|
data/.rubocop_todo.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# This configuration was generated by
|
|
2
2
|
# `rubocop --auto-gen-config`
|
|
3
|
-
# on 2026-01-22
|
|
3
|
+
# on 2026-01-22 09:15:54 UTC using RuboCop version 1.82.1.
|
|
4
4
|
# The point is for the user to remove these configuration records
|
|
5
5
|
# one by one as the offenses are removed from the code base.
|
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
|
@@ -11,47 +11,86 @@ Gemspec/RequiredRubyVersion:
|
|
|
11
11
|
Exclude:
|
|
12
12
|
- 'fractor.gemspec'
|
|
13
13
|
|
|
14
|
-
# Offense count:
|
|
14
|
+
# Offense count: 1
|
|
15
15
|
# This cop supports safe autocorrection (--autocorrect).
|
|
16
16
|
# Configuration parameters: EnforcedStyle, IndentationWidth.
|
|
17
17
|
# SupportedStyles: with_first_argument, with_fixed_indentation
|
|
18
18
|
Layout/ArgumentAlignment:
|
|
19
19
|
Exclude:
|
|
20
|
-
- '
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
- 'lib/fractor/queue_persister.rb'
|
|
21
|
+
|
|
22
|
+
# Offense count: 1
|
|
23
|
+
# This cop supports safe autocorrection (--autocorrect).
|
|
24
|
+
# Configuration parameters: EnforcedStyle, IndentationWidth.
|
|
25
|
+
# SupportedStyles: with_first_element, with_fixed_indentation
|
|
26
|
+
Layout/ArrayAlignment:
|
|
27
|
+
Exclude:
|
|
28
|
+
- 'lib/fractor/queue_persister.rb'
|
|
23
29
|
|
|
24
30
|
# Offense count: 2
|
|
25
31
|
# This cop supports safe autocorrection (--autocorrect).
|
|
26
|
-
# Configuration parameters:
|
|
27
|
-
|
|
28
|
-
Layout/BlockAlignment:
|
|
32
|
+
# Configuration parameters: IndentationWidth.
|
|
33
|
+
Layout/AssignmentIndentation:
|
|
29
34
|
Exclude:
|
|
30
|
-
- '
|
|
31
|
-
- '
|
|
35
|
+
- 'lib/fractor/queue_persister.rb'
|
|
36
|
+
- 'lib/fractor/result_cache.rb'
|
|
37
|
+
|
|
38
|
+
# Offense count: 1
|
|
39
|
+
# This cop supports safe autocorrection (--autocorrect).
|
|
40
|
+
Layout/ClosingParenthesisIndentation:
|
|
41
|
+
Exclude:
|
|
42
|
+
- 'spec/fractor/persistent_work_queue_spec.rb'
|
|
43
|
+
|
|
44
|
+
# Offense count: 1
|
|
45
|
+
# This cop supports safe autocorrection (--autocorrect).
|
|
46
|
+
# Configuration parameters: EnforcedStyle, IndentationWidth.
|
|
47
|
+
# SupportedStyles: consistent, consistent_relative_to_receiver, special_for_inner_method_call, special_for_inner_method_call_in_parentheses
|
|
48
|
+
Layout/FirstArgumentIndentation:
|
|
49
|
+
Exclude:
|
|
50
|
+
- 'spec/fractor/persistent_work_queue_spec.rb'
|
|
51
|
+
|
|
52
|
+
# Offense count: 2
|
|
53
|
+
# This cop supports safe autocorrection (--autocorrect).
|
|
54
|
+
# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle.
|
|
55
|
+
# SupportedHashRocketStyles: key, separator, table
|
|
56
|
+
# SupportedColonStyles: key, separator, table
|
|
57
|
+
# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit
|
|
58
|
+
Layout/HashAlignment:
|
|
59
|
+
Exclude:
|
|
60
|
+
- 'spec/fractor/persistent_work_queue_spec.rb'
|
|
61
|
+
- 'spec/fractor/work_timeout_spec.rb'
|
|
32
62
|
|
|
33
|
-
# Offense count:
|
|
63
|
+
# Offense count: 470
|
|
34
64
|
# This cop supports safe autocorrection (--autocorrect).
|
|
35
65
|
# Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
|
|
36
66
|
# URISchemes: http, https
|
|
37
67
|
Layout/LineLength:
|
|
38
68
|
Enabled: false
|
|
39
69
|
|
|
40
|
-
# Offense count:
|
|
70
|
+
# Offense count: 1
|
|
71
|
+
# This cop supports safe autocorrection (--autocorrect).
|
|
72
|
+
# Configuration parameters: EnforcedStyle.
|
|
73
|
+
# SupportedStyles: symmetrical, new_line, same_line
|
|
74
|
+
Layout/MultilineMethodCallBraceLayout:
|
|
75
|
+
Exclude:
|
|
76
|
+
- 'spec/fractor/persistent_work_queue_spec.rb'
|
|
77
|
+
|
|
78
|
+
# Offense count: 6
|
|
41
79
|
# This cop supports safe autocorrection (--autocorrect).
|
|
42
80
|
# Configuration parameters: AllowInHeredoc.
|
|
43
81
|
Layout/TrailingWhitespace:
|
|
44
82
|
Exclude:
|
|
45
|
-
- '
|
|
46
|
-
- '
|
|
47
|
-
- 'spec/fractor/
|
|
83
|
+
- 'lib/fractor/queue_persister.rb'
|
|
84
|
+
- 'lib/fractor/result_cache.rb'
|
|
85
|
+
- 'spec/fractor/persistent_work_queue_spec.rb'
|
|
86
|
+
- 'spec/fractor/work_timeout_spec.rb'
|
|
48
87
|
|
|
49
88
|
# Offense count: 1
|
|
50
89
|
Lint/BinaryOperatorWithIdenticalOperands:
|
|
51
90
|
Exclude:
|
|
52
91
|
- 'spec/fractor/priority_work_spec.rb'
|
|
53
92
|
|
|
54
|
-
# Offense count:
|
|
93
|
+
# Offense count: 20
|
|
55
94
|
# Configuration parameters: AllowedMethods.
|
|
56
95
|
# AllowedMethods: enums
|
|
57
96
|
Lint/ConstantDefinitionInBlock:
|
|
@@ -60,7 +99,10 @@ Lint/ConstantDefinitionInBlock:
|
|
|
60
99
|
- 'spec/examples/performance_monitoring_spec.rb'
|
|
61
100
|
- 'spec/examples/priority_work_example_spec.rb'
|
|
62
101
|
- 'spec/fractor/integration_spec.rb'
|
|
102
|
+
- 'spec/fractor/persistent_work_queue_spec.rb'
|
|
103
|
+
- 'spec/fractor/result_cache_spec.rb'
|
|
63
104
|
- 'spec/fractor/work_spec.rb'
|
|
105
|
+
- 'spec/fractor/work_timeout_spec.rb'
|
|
64
106
|
- 'spec/fractor/worker_timeout_spec.rb'
|
|
65
107
|
- 'spec/fractor/workflow/execution_strategy_spec.rb'
|
|
66
108
|
|
|
@@ -77,10 +119,11 @@ Lint/HashCompareByIdentity:
|
|
|
77
119
|
- 'lib/fractor/work_distribution_manager.rb'
|
|
78
120
|
- 'spec/fractor/work_distribution_manager_spec.rb'
|
|
79
121
|
|
|
80
|
-
# Offense count:
|
|
122
|
+
# Offense count: 4
|
|
81
123
|
# Configuration parameters: AllowedParentClasses.
|
|
82
124
|
Lint/MissingSuper:
|
|
83
125
|
Exclude:
|
|
126
|
+
- 'lib/fractor/queue_persister.rb'
|
|
84
127
|
- 'spec/fractor/wrapped_ractor3_spec.rb'
|
|
85
128
|
|
|
86
129
|
# Offense count: 5
|
|
@@ -91,7 +134,7 @@ Lint/RescueException:
|
|
|
91
134
|
- 'lib/fractor/wrapped_ractor3.rb'
|
|
92
135
|
- 'lib/fractor/wrapped_ractor4.rb'
|
|
93
136
|
|
|
94
|
-
# Offense count:
|
|
137
|
+
# Offense count: 2
|
|
95
138
|
# This cop supports safe autocorrection (--autocorrect).
|
|
96
139
|
# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions.
|
|
97
140
|
# NotImplementedExceptions: NotImplementedError
|
|
@@ -99,9 +142,8 @@ Lint/UnusedMethodArgument:
|
|
|
99
142
|
Exclude:
|
|
100
143
|
- 'lib/fractor/workflow.rb'
|
|
101
144
|
- 'lib/fractor/wrapped_ractor3.rb'
|
|
102
|
-
- 'spec/fractor/wrapped_ractor3_spec.rb'
|
|
103
145
|
|
|
104
|
-
# Offense count:
|
|
146
|
+
# Offense count: 89
|
|
105
147
|
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
|
|
106
148
|
Metrics/AbcSize:
|
|
107
149
|
Enabled: false
|
|
@@ -110,24 +152,24 @@ Metrics/AbcSize:
|
|
|
110
152
|
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
|
|
111
153
|
# AllowedMethods: refine
|
|
112
154
|
Metrics/BlockLength:
|
|
113
|
-
Max:
|
|
155
|
+
Max: 110
|
|
114
156
|
|
|
115
|
-
# Offense count:
|
|
157
|
+
# Offense count: 64
|
|
116
158
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
117
159
|
Metrics/CyclomaticComplexity:
|
|
118
160
|
Enabled: false
|
|
119
161
|
|
|
120
|
-
# Offense count:
|
|
162
|
+
# Offense count: 149
|
|
121
163
|
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
|
|
122
164
|
Metrics/MethodLength:
|
|
123
|
-
Max:
|
|
165
|
+
Max: 122
|
|
124
166
|
|
|
125
167
|
# Offense count: 9
|
|
126
168
|
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
|
|
127
169
|
Metrics/ParameterLists:
|
|
128
170
|
Max: 9
|
|
129
171
|
|
|
130
|
-
# Offense count:
|
|
172
|
+
# Offense count: 49
|
|
131
173
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
132
174
|
Metrics/PerceivedComplexity:
|
|
133
175
|
Enabled: false
|
|
@@ -185,21 +227,12 @@ RSpec/ContextWording:
|
|
|
185
227
|
- 'spec/fractor/supervisor_spec.rb'
|
|
186
228
|
- 'spec/fractor_spec.rb'
|
|
187
229
|
|
|
188
|
-
# Offense count:
|
|
230
|
+
# Offense count: 28
|
|
189
231
|
# Configuration parameters: IgnoredMetadata.
|
|
190
232
|
RSpec/DescribeClass:
|
|
191
233
|
Enabled: false
|
|
192
234
|
|
|
193
|
-
# Offense count:
|
|
194
|
-
# This cop supports unsafe autocorrection (--autocorrect-all).
|
|
195
|
-
# Configuration parameters: SkipBlocks, EnforcedStyle, OnlyStaticConstants.
|
|
196
|
-
# SupportedStyles: described_class, explicit
|
|
197
|
-
RSpec/DescribedClass:
|
|
198
|
-
Exclude:
|
|
199
|
-
- 'spec/fractor/worker_spec.rb'
|
|
200
|
-
- 'spec/fractor/worker_timeout_spec.rb'
|
|
201
|
-
|
|
202
|
-
# Offense count: 510
|
|
235
|
+
# Offense count: 542
|
|
203
236
|
# Configuration parameters: CountAsOne.
|
|
204
237
|
RSpec/ExampleLength:
|
|
205
238
|
Max: 47
|
|
@@ -246,14 +279,17 @@ RSpec/InstanceVariable:
|
|
|
246
279
|
- 'spec/examples/workflow/dead_letter_queue_workflow_spec.rb'
|
|
247
280
|
- 'spec/fractor/integration_spec.rb'
|
|
248
281
|
|
|
249
|
-
# Offense count:
|
|
282
|
+
# Offense count: 22
|
|
250
283
|
RSpec/LeakyConstantDeclaration:
|
|
251
284
|
Exclude:
|
|
252
285
|
- 'spec/examples/error_reporting_spec.rb'
|
|
253
286
|
- 'spec/examples/performance_monitoring_spec.rb'
|
|
254
287
|
- 'spec/examples/priority_work_example_spec.rb'
|
|
255
288
|
- 'spec/fractor/integration_spec.rb'
|
|
289
|
+
- 'spec/fractor/persistent_work_queue_spec.rb'
|
|
290
|
+
- 'spec/fractor/result_cache_spec.rb'
|
|
256
291
|
- 'spec/fractor/work_spec.rb'
|
|
292
|
+
- 'spec/fractor/work_timeout_spec.rb'
|
|
257
293
|
- 'spec/fractor/worker_timeout_spec.rb'
|
|
258
294
|
- 'spec/fractor/workflow/execution_strategy_spec.rb'
|
|
259
295
|
|
|
@@ -265,17 +301,19 @@ RSpec/MessageSpies:
|
|
|
265
301
|
- 'spec/fractor/main_loop_handler_spec.rb'
|
|
266
302
|
- 'spec/fractor/shutdown_handler_spec.rb'
|
|
267
303
|
|
|
268
|
-
# Offense count:
|
|
304
|
+
# Offense count: 8
|
|
269
305
|
RSpec/MultipleDescribes:
|
|
270
306
|
Exclude:
|
|
271
307
|
- 'spec/examples/workflow/simplified_workflow_spec.rb'
|
|
308
|
+
- 'spec/fractor/persistent_work_queue_spec.rb'
|
|
309
|
+
- 'spec/fractor/work_timeout_spec.rb'
|
|
272
310
|
- 'spec/fractor/worker_timeout_spec.rb'
|
|
273
311
|
- 'spec/fractor/workflow/dead_letter_queue_spec.rb'
|
|
274
312
|
- 'spec/fractor/workflow/execution_strategy_spec.rb'
|
|
275
313
|
- 'spec/fractor/workflow/retry_config_spec.rb'
|
|
276
314
|
- 'spec/fractor/workflow/retry_strategy_spec.rb'
|
|
277
315
|
|
|
278
|
-
# Offense count:
|
|
316
|
+
# Offense count: 635
|
|
279
317
|
RSpec/MultipleExpectations:
|
|
280
318
|
Max: 12
|
|
281
319
|
|
|
@@ -321,9 +359,29 @@ Security/Eval:
|
|
|
321
359
|
Exclude:
|
|
322
360
|
- 'lib/fractor/cli.rb'
|
|
323
361
|
|
|
362
|
+
# Offense count: 1
|
|
363
|
+
Security/MarshalLoad:
|
|
364
|
+
Exclude:
|
|
365
|
+
- 'lib/fractor/queue_persister.rb'
|
|
366
|
+
|
|
367
|
+
# Offense count: 2
|
|
368
|
+
# This cop supports safe autocorrection (--autocorrect).
|
|
369
|
+
Style/MultilineIfModifier:
|
|
370
|
+
Exclude:
|
|
371
|
+
- 'lib/fractor/queue_persister.rb'
|
|
372
|
+
- 'lib/fractor/result_cache.rb'
|
|
373
|
+
|
|
324
374
|
# Offense count: 1
|
|
325
375
|
# Configuration parameters: AllowedMethods.
|
|
326
376
|
# AllowedMethods: respond_to_missing?
|
|
327
377
|
Style/OptionalBooleanParameter:
|
|
328
378
|
Exclude:
|
|
329
379
|
- 'lib/fractor/workflow/job.rb'
|
|
380
|
+
|
|
381
|
+
# Offense count: 2
|
|
382
|
+
# This cop supports safe autocorrection (--autocorrect).
|
|
383
|
+
# Configuration parameters: EnforcedStyleForMultiline.
|
|
384
|
+
# SupportedStylesForMultiline: comma, consistent_comma, diff_comma, no_comma
|
|
385
|
+
Style/TrailingCommaInArguments:
|
|
386
|
+
Exclude:
|
|
387
|
+
- 'spec/examples/workflow/retry_workflow_spec.rb'
|
data/README.adoc
CHANGED
|
@@ -82,6 +82,143 @@ puts "Results: #{supervisor.results.results.map(&:result)}"
|
|
|
82
82
|
# => Results: [2, 4, 6]
|
|
83
83
|
----
|
|
84
84
|
|
|
85
|
+
=== Timeout Configuration
|
|
86
|
+
|
|
87
|
+
Fractor supports flexible timeout configuration at three levels:
|
|
88
|
+
|
|
89
|
+
[cols="1,2,4"]
|
|
90
|
+
|===
|
|
91
|
+
|Level |Syntax |Description
|
|
92
|
+
|
|
93
|
+
|Global
|
|
94
|
+
|`Fractor.configure`
|
|
95
|
+
|Sets default timeout for all workers
|
|
96
|
+
|
|
97
|
+
|Worker
|
|
98
|
+
|`Worker.timeout` class method
|
|
99
|
+
|Sets timeout for all work processed by a worker
|
|
100
|
+
|
|
101
|
+
|Work item
|
|
102
|
+
|`Work.new(input, timeout: N)`
|
|
103
|
+
|Overrides worker timeout for specific work item
|
|
104
|
+
|===
|
|
105
|
+
|
|
106
|
+
[source,ruby]
|
|
107
|
+
----
|
|
108
|
+
# 1. Global default (optional)
|
|
109
|
+
Fractor.configure do |config|
|
|
110
|
+
config.default_worker_timeout = 60 # 60 seconds
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# 2. Worker-level timeout
|
|
114
|
+
class FastWorker < Fractor::Worker
|
|
115
|
+
timeout 10 # 10 second timeout for this worker
|
|
116
|
+
|
|
117
|
+
def process(work)
|
|
118
|
+
Fractor::WorkResult.new(result: work.input * 2, work: work)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# 3. Per-work-item timeout (overrides worker timeout)
|
|
123
|
+
class MyWork < Fractor::Work
|
|
124
|
+
def initialize(value, timeout: nil)
|
|
125
|
+
super({ value: value }, timeout: timeout)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
supervisor = Fractor::Supervisor.new(
|
|
130
|
+
worker_pools: [{ worker_class: FastWorker }]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Mix work items with different timeouts
|
|
134
|
+
fast_work = MyWork.new(1, timeout: 5) # 5 seconds
|
|
135
|
+
normal_work = MyWork.new(2) # uses worker's 10s timeout
|
|
136
|
+
slow_work = MyWork.new(3, timeout: 30) # 30 seconds
|
|
137
|
+
|
|
138
|
+
supervisor.add_work_items([fast_work, normal_work, slow_work])
|
|
139
|
+
supervisor.run
|
|
140
|
+
----
|
|
141
|
+
|
|
142
|
+
When a timeout occurs, the work item is marked as failed with `error_category: :timeout` and can be retried if using the workflow system.
|
|
143
|
+
|
|
144
|
+
=== Queue Persistence
|
|
145
|
+
|
|
146
|
+
For critical applications, work queues can be persisted to disk for crash recovery:
|
|
147
|
+
|
|
148
|
+
[source,ruby]
|
|
149
|
+
----
|
|
150
|
+
# Create a persistent queue with automatic saving
|
|
151
|
+
queue = Fractor::PersistentWorkQueue.new("data/queue.json")
|
|
152
|
+
|
|
153
|
+
# Add work items - automatically saved
|
|
154
|
+
queue << MyWork.new(data1)
|
|
155
|
+
queue << MyWork.new(data2)
|
|
156
|
+
|
|
157
|
+
# Use with ContinuousServer for crash recovery
|
|
158
|
+
server = Fractor::ContinuousServer.new(
|
|
159
|
+
worker_pools: [{ worker_class: MyWorker }],
|
|
160
|
+
work_queue: queue
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Load any previous work items on startup
|
|
164
|
+
queue.load
|
|
165
|
+
|
|
166
|
+
server.run
|
|
167
|
+
----
|
|
168
|
+
|
|
169
|
+
Supported persistence formats:
|
|
170
|
+
* **JSON** (default) - Human-readable, widely compatible
|
|
171
|
+
* **YAML** - More readable than JSON
|
|
172
|
+
* **Marshal** - Binary format, faster but Ruby-specific
|
|
173
|
+
|
|
174
|
+
=== Result Caching
|
|
175
|
+
|
|
176
|
+
For expensive, deterministic operations, Fractor provides a result cache to avoid redundant processing of identical work items:
|
|
177
|
+
|
|
178
|
+
[source,ruby]
|
|
179
|
+
----
|
|
180
|
+
# Create a cache with TTL (time-to-live)
|
|
181
|
+
cache = Fractor::ResultCache.new(ttl: 300) # 5 minutes
|
|
182
|
+
|
|
183
|
+
# Or with size limit
|
|
184
|
+
cache = Fractor::ResultCache.new(max_size: 1000)
|
|
185
|
+
|
|
186
|
+
# Or with memory limit
|
|
187
|
+
cache = Fractor::ResultCache.new(max_memory: 1024 * 1024) # 1MB
|
|
188
|
+
|
|
189
|
+
# Use the cache
|
|
190
|
+
result = cache.get(work) do
|
|
191
|
+
# This block only runs if work is not cached
|
|
192
|
+
expensive_operation(work)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Check if work is cached
|
|
196
|
+
if cache.has?(work)
|
|
197
|
+
puts "Result is cached!"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Manual cache operations
|
|
201
|
+
cache.set(work, result) # Store a result
|
|
202
|
+
cache.invalidate(work) # Remove a cached result
|
|
203
|
+
cache.clear # Remove all cached results
|
|
204
|
+
|
|
205
|
+
# Get cache statistics
|
|
206
|
+
stats = cache.stats
|
|
207
|
+
puts "Cache hit rate: #{stats[:hit_rate]}%"
|
|
208
|
+
----
|
|
209
|
+
|
|
210
|
+
The cache generates consistent keys based on:
|
|
211
|
+
* Work class name
|
|
212
|
+
* Work input data
|
|
213
|
+
* Work timeout (if set)
|
|
214
|
+
|
|
215
|
+
This means identical work items with the same input and timeout will share cached results.
|
|
216
|
+
|
|
217
|
+
Cache eviction policies:
|
|
218
|
+
* **TTL** - Entries expire after a configured time
|
|
219
|
+
* **LRU** - Least-recently-used entries are evicted when max_size is reached
|
|
220
|
+
* **Memory-based** - Entries are evicted when max_memory is reached
|
|
221
|
+
|
|
85
222
|
== Key features
|
|
86
223
|
|
|
87
224
|
* *Function-driven*: Define processing logic by subclassing `Fractor::Worker`
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "work_queue"
|
|
4
|
+
require_relative "queue_persister"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Fractor
|
|
8
|
+
# A work queue with persistence support.
|
|
9
|
+
# Automatically saves queue state to disk for crash recovery.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# queue = PersistentWorkQueue.new("data/queue.json")
|
|
13
|
+
# queue << work_item # Automatically saved
|
|
14
|
+
#
|
|
15
|
+
# @example With custom persister
|
|
16
|
+
# persister = QueuePersister::YAMLPersister.new("data/queue.yml")
|
|
17
|
+
# queue = PersistentWorkQueue.new(persister: persister)
|
|
18
|
+
class PersistentWorkQueue < WorkQueue
|
|
19
|
+
attr_reader :persister
|
|
20
|
+
|
|
21
|
+
# Initialize a persistent work queue.
|
|
22
|
+
#
|
|
23
|
+
# @param path_or_persister [String, QueuePersister::Base] File path or persister instance
|
|
24
|
+
# @param auto_save [Boolean] Automatically save after each enqueue
|
|
25
|
+
# @param save_interval [Integer, nil] Seconds between auto-saves (nil = disabled)
|
|
26
|
+
def initialize(path_or_persister = nil, auto_save: true, save_interval: nil)
|
|
27
|
+
super()
|
|
28
|
+
|
|
29
|
+
@persister = case path_or_persister
|
|
30
|
+
when String
|
|
31
|
+
QueuePersister::JSONPersister.new(path_or_persister)
|
|
32
|
+
when QueuePersister::Base, nil
|
|
33
|
+
path_or_persister
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"path_or_persister must be a String or QueuePersister::Base"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@auto_save = @persister ? auto_save : false
|
|
40
|
+
@save_interval = save_interval
|
|
41
|
+
@dirty = false
|
|
42
|
+
@save_thread = nil
|
|
43
|
+
|
|
44
|
+
# Start auto-save thread if interval is specified
|
|
45
|
+
start_save_thread if @save_interval
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Add a work item to the queue with automatic persistence.
|
|
49
|
+
#
|
|
50
|
+
# @param work_item [Fractor::Work] The work item to add
|
|
51
|
+
# @return [void]
|
|
52
|
+
def enqueue(work_item)
|
|
53
|
+
super
|
|
54
|
+
@dirty = true
|
|
55
|
+
save if @auto_save
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Alias for enqueue
|
|
59
|
+
#
|
|
60
|
+
# @param work_item [Fractor::Work] The work item to add
|
|
61
|
+
# @return [void]
|
|
62
|
+
def <<(work_item)
|
|
63
|
+
enqueue(work_item)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Retrieve multiple work items from the queue.
|
|
67
|
+
# Marks queue as dirty for persistence.
|
|
68
|
+
#
|
|
69
|
+
# @param max_items [Integer] Maximum number of items to retrieve
|
|
70
|
+
# @return [Array<Fractor::Work>] Array of work items
|
|
71
|
+
def dequeue_batch(max_items = 10)
|
|
72
|
+
items = super
|
|
73
|
+
@dirty = true if items.any?
|
|
74
|
+
items
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Save the current queue state to disk.
|
|
78
|
+
#
|
|
79
|
+
# @return [Boolean] true if saved successfully
|
|
80
|
+
def save
|
|
81
|
+
return false unless @persister
|
|
82
|
+
|
|
83
|
+
# Get all items from the queue without removing them
|
|
84
|
+
items = peek_all
|
|
85
|
+
@persister.save(items)
|
|
86
|
+
@dirty = false
|
|
87
|
+
true
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
warn "Failed to save queue: #{e.message}"
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Load queue state from disk, restoring previous work items.
|
|
94
|
+
#
|
|
95
|
+
# @return [Integer] Number of items restored
|
|
96
|
+
def load
|
|
97
|
+
return 0 unless @persister
|
|
98
|
+
|
|
99
|
+
items = @persister.load
|
|
100
|
+
return 0 unless items
|
|
101
|
+
|
|
102
|
+
count = 0
|
|
103
|
+
items.each do |item|
|
|
104
|
+
# Convert hash back to Work object if needed
|
|
105
|
+
work = if item.is_a?(Hash)
|
|
106
|
+
deserialize_work(item)
|
|
107
|
+
else
|
|
108
|
+
item
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@queue << work if work.is_a?(Fractor::Work)
|
|
112
|
+
count += 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@dirty = false
|
|
116
|
+
count
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
warn "Failed to load queue: #{e.message}"
|
|
119
|
+
0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Clear the queue and remove persisted data.
|
|
123
|
+
#
|
|
124
|
+
# @return [Boolean] true if cleared successfully
|
|
125
|
+
def clear
|
|
126
|
+
@queue.clear
|
|
127
|
+
@persister&.clear
|
|
128
|
+
@dirty = false
|
|
129
|
+
true
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
warn "Failed to clear queue: #{e.message}"
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check if the queue has unsaved changes.
|
|
136
|
+
#
|
|
137
|
+
# @return [Boolean] true if there are unsaved changes
|
|
138
|
+
def dirty?
|
|
139
|
+
@dirty
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Save and close the queue, cleaning up resources.
|
|
143
|
+
#
|
|
144
|
+
# @return [void]
|
|
145
|
+
def close
|
|
146
|
+
save if @dirty
|
|
147
|
+
stop_save_thread if @save_thread
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# Peek at all items in the queue without removing them.
|
|
153
|
+
#
|
|
154
|
+
# @return [Array<Fractor::Work>] All work items in the queue
|
|
155
|
+
def peek_all
|
|
156
|
+
items = []
|
|
157
|
+
# Thread::Queue doesn't have a peek method, so we need to
|
|
158
|
+
# temporarily remove items, collect them, and put them back
|
|
159
|
+
temp = []
|
|
160
|
+
until @queue.empty?
|
|
161
|
+
item = @queue.pop(true)
|
|
162
|
+
temp << item
|
|
163
|
+
items << item
|
|
164
|
+
end
|
|
165
|
+
# Put items back
|
|
166
|
+
temp.each { |item| @queue << item }
|
|
167
|
+
items
|
|
168
|
+
rescue ThreadError
|
|
169
|
+
# Queue became empty during iteration
|
|
170
|
+
items
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Deserialize a work item from a hash.
|
|
174
|
+
#
|
|
175
|
+
# @param hash [Hash] The serialized work item
|
|
176
|
+
# @return [Fractor::Work, nil] The deserialized work item
|
|
177
|
+
def deserialize_work(hash)
|
|
178
|
+
return nil unless hash.is_a?(Hash)
|
|
179
|
+
|
|
180
|
+
# Extract class name and data (handle both string and symbol keys for JSON compatibility)
|
|
181
|
+
hash.delete(:_class) || hash.delete("_class")
|
|
182
|
+
input_data = hash.delete(:_input) || hash.delete("_input")
|
|
183
|
+
timeout = hash.delete(:_timeout) || hash.delete("_timeout")
|
|
184
|
+
|
|
185
|
+
# For simplicity, always use the base Work class with stored input
|
|
186
|
+
# This ensures correct deserialization without complex reflection
|
|
187
|
+
if timeout
|
|
188
|
+
Fractor::Work.new(input_data, timeout: timeout)
|
|
189
|
+
else
|
|
190
|
+
Fractor::Work.new(input_data)
|
|
191
|
+
end
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
warn "Failed to deserialize work item: #{e.message}"
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Start the auto-save thread.
|
|
198
|
+
#
|
|
199
|
+
# @return [void]
|
|
200
|
+
def start_save_thread
|
|
201
|
+
@save_thread = Thread.new do
|
|
202
|
+
loop do
|
|
203
|
+
sleep @save_interval
|
|
204
|
+
save if @dirty
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
@save_thread.name = "PersistentWorkQueue-auto-save" if @save_thread.respond_to?(:name=)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Stop the auto-save thread.
|
|
211
|
+
#
|
|
212
|
+
# @return [void]
|
|
213
|
+
def stop_save_thread
|
|
214
|
+
@save_thread&.kill
|
|
215
|
+
@save_thread = nil
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Fractor
|
|
7
|
+
# Persistence strategies for work queues.
|
|
8
|
+
# Provides different backends for saving and loading queue state.
|
|
9
|
+
module QueuePersister
|
|
10
|
+
# Base class for queue persisters.
|
|
11
|
+
#
|
|
12
|
+
# @abstract Subclasses must implement {#save} and {#load}
|
|
13
|
+
class Base
|
|
14
|
+
# Save work items to persistent storage.
|
|
15
|
+
#
|
|
16
|
+
# @abstract
|
|
17
|
+
# @param items [Array<Fractor::Work>] Work items to save
|
|
18
|
+
# @return [Boolean] true if saved successfully
|
|
19
|
+
def save(_items)
|
|
20
|
+
raise NotImplementedError, "Subclasses must implement #save"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Load work items from persistent storage.
|
|
24
|
+
#
|
|
25
|
+
# @abstract
|
|
26
|
+
# @return [Array<Hash>, nil] Serialized work items, or nil if no data
|
|
27
|
+
def load
|
|
28
|
+
raise NotImplementedError, "Subclasses must implement #load"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Clear persisted data.
|
|
32
|
+
#
|
|
33
|
+
# @abstract
|
|
34
|
+
# @return [Boolean] true if cleared successfully
|
|
35
|
+
def clear
|
|
36
|
+
raise NotImplementedError, "Subclasses must implement #clear"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
protected
|
|
40
|
+
|
|
41
|
+
# Serialize a work item to a hash.
|
|
42
|
+
#
|
|
43
|
+
# @param work [Fractor::Work] The work item to serialize
|
|
44
|
+
# @return [Hash] Serialized work item
|
|
45
|
+
def serialize_work(work)
|
|
46
|
+
hash = {
|
|
47
|
+
_class: work.class.name,
|
|
48
|
+
_input: work.input,
|
|
49
|
+
}
|
|
50
|
+
if work.respond_to?(:timeout) && !work.timeout.nil?
|
|
51
|
+
hash[:_timeout] =
|
|
52
|
+
work.timeout
|
|
53
|
+
end
|
|
54
|
+
hash
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# JSON file persister.
|
|
59
|
+
# Stores queue state as a JSON array.
|
|
60
|
+
class JSONPersister < Base
|
|
61
|
+
# Initialize a JSON persister.
|
|
62
|
+
#
|
|
63
|
+
# @param path [String] Path to the JSON file
|
|
64
|
+
# @param pretty [Boolean] Format JSON with indentation
|
|
65
|
+
def initialize(path, pretty: true)
|
|
66
|
+
@path = path
|
|
67
|
+
@pretty = pretty
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Save work items to JSON file.
|
|
71
|
+
#
|
|
72
|
+
# @param items [Array<Fractor::Work>] Work items to save
|
|
73
|
+
# @return [Boolean] true if saved successfully
|
|
74
|
+
def save(items)
|
|
75
|
+
ensure_directory_exists
|
|
76
|
+
|
|
77
|
+
serialized = items.map { |work| serialize_work(work) }
|
|
78
|
+
json = @pretty ? JSON.pretty_generate(serialized) : JSON.generate(serialized)
|
|
79
|
+
|
|
80
|
+
File.write(@path, json)
|
|
81
|
+
true
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
warn "Failed to save to #{@path}: #{e.message}"
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Load work items from JSON file.
|
|
88
|
+
#
|
|
89
|
+
# @return [Array<Hash>, nil] Serialized work items, or nil if file doesn't exist
|
|
90
|
+
def load
|
|
91
|
+
return nil unless File.exist?(@path)
|
|
92
|
+
|
|
93
|
+
json = File.read(@path)
|
|
94
|
+
return nil if json.strip.empty?
|
|
95
|
+
|
|
96
|
+
JSON.parse(json)
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
warn "Failed to load from #{@path}: #{e.message}"
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Clear the JSON file.
|
|
103
|
+
#
|
|
104
|
+
# @return [Boolean] true if cleared successfully
|
|
105
|
+
def clear
|
|
106
|
+
File.delete(@path) if File.exist?(@path)
|
|
107
|
+
true
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
warn "Failed to clear #{@path}: #{e.message}"
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Ensure the directory for the file exists.
|
|
116
|
+
#
|
|
117
|
+
# @return [void]
|
|
118
|
+
def ensure_directory_exists
|
|
119
|
+
dir = File.dirname(@path)
|
|
120
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# YAML file persister.
|
|
125
|
+
# Stores queue state as a YAML document.
|
|
126
|
+
class YAMLPersister < Base
|
|
127
|
+
# Initialize a YAML persister.
|
|
128
|
+
#
|
|
129
|
+
# @param path [String] Path to the YAML file
|
|
130
|
+
def initialize(path)
|
|
131
|
+
@path = path
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Save work items to YAML file.
|
|
135
|
+
#
|
|
136
|
+
# @param items [Array<Fractor::Work>] Work items to save
|
|
137
|
+
# @return [Boolean] true if saved successfully
|
|
138
|
+
def save(items)
|
|
139
|
+
ensure_directory_exists
|
|
140
|
+
|
|
141
|
+
serialized = items.map { |work| serialize_work(work) }
|
|
142
|
+
yaml = YAML.dump(serialized)
|
|
143
|
+
|
|
144
|
+
File.write(@path, yaml)
|
|
145
|
+
true
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
warn "Failed to save to #{@path}: #{e.message}"
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Load work items from YAML file.
|
|
152
|
+
#
|
|
153
|
+
# @return [Array<Hash>, nil] Serialized work items, or nil if file doesn't exist
|
|
154
|
+
def load
|
|
155
|
+
return nil unless File.exist?(@path)
|
|
156
|
+
|
|
157
|
+
yaml = File.read(@path)
|
|
158
|
+
return nil if yaml.strip.empty?
|
|
159
|
+
|
|
160
|
+
YAML.safe_load(yaml,
|
|
161
|
+
permitted_classes: [Symbol, Hash, String, Integer, Float, TrueClass,
|
|
162
|
+
FalseClass, NilClass])
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
warn "Failed to load from #{@path}: #{e.message}"
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Clear the YAML file.
|
|
169
|
+
#
|
|
170
|
+
# @return [Boolean] true if cleared successfully
|
|
171
|
+
def clear
|
|
172
|
+
File.delete(@path) if File.exist?(@path)
|
|
173
|
+
true
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
warn "Failed to clear #{@path}: #{e.message}"
|
|
176
|
+
false
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
# Ensure the directory for the file exists.
|
|
182
|
+
#
|
|
183
|
+
# @return [void]
|
|
184
|
+
def ensure_directory_exists
|
|
185
|
+
dir = File.dirname(@path)
|
|
186
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Marshal file persister.
|
|
191
|
+
# Uses Ruby's Marshal for binary serialization.
|
|
192
|
+
# Note: Marshal is not secure and should not be used with untrusted data.
|
|
193
|
+
class MarshalPersister < Base
|
|
194
|
+
# Initialize a Marshal persister.
|
|
195
|
+
#
|
|
196
|
+
# @param path [String] Path to the Marshal file
|
|
197
|
+
def initialize(path)
|
|
198
|
+
@path = path
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Save work items using Marshal.
|
|
202
|
+
#
|
|
203
|
+
# @param items [Array<Fractor::Work>] Work items to save
|
|
204
|
+
# @return [Boolean] true if saved successfully
|
|
205
|
+
def save(items)
|
|
206
|
+
ensure_directory_exists
|
|
207
|
+
|
|
208
|
+
serialized = items.map { |work| serialize_work(work) }
|
|
209
|
+
data = Marshal.dump(serialized)
|
|
210
|
+
|
|
211
|
+
File.binwrite(@path, data)
|
|
212
|
+
true
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
warn "Failed to save to #{@path}: #{e.message}"
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Load work items using Marshal.
|
|
219
|
+
#
|
|
220
|
+
# @return [Array<Hash>, nil] Serialized work items, or nil if file doesn't exist
|
|
221
|
+
def load
|
|
222
|
+
return nil unless File.exist?(@path)
|
|
223
|
+
|
|
224
|
+
data = File.binread(@path)
|
|
225
|
+
Marshal.load(data)
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
warn "Failed to load from #{@path}: #{e.message}"
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Clear the Marshal file.
|
|
232
|
+
#
|
|
233
|
+
# @return [Boolean] true if cleared successfully
|
|
234
|
+
def clear
|
|
235
|
+
File.delete(@path) if File.exist?(@path)
|
|
236
|
+
true
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
warn "Failed to clear #{@path}: #{e.message}"
|
|
239
|
+
false
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
# Ensure the directory for the file exists.
|
|
245
|
+
#
|
|
246
|
+
# @return [void]
|
|
247
|
+
def ensure_directory_exists
|
|
248
|
+
dir = File.dirname(@path)
|
|
249
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Fractor
|
|
7
|
+
# Caches work results to avoid redundant processing of identical work items.
|
|
8
|
+
# Useful for expensive, deterministic operations.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# cache = Fractor::ResultCache.new
|
|
12
|
+
# cached = cache.get(work) { work.process }
|
|
13
|
+
#
|
|
14
|
+
# @example With TTL
|
|
15
|
+
# cache = Fractor::ResultCache.new(ttl: 300) # 5 minutes
|
|
16
|
+
class ResultCache
|
|
17
|
+
attr_reader :hits, :misses
|
|
18
|
+
|
|
19
|
+
# Initialize a new result cache.
|
|
20
|
+
#
|
|
21
|
+
# @param ttl [Integer, nil] Time-to-live for cache entries in seconds (nil = no expiration)
|
|
22
|
+
# @param max_size [Integer, nil] Maximum number of entries (nil = unlimited)
|
|
23
|
+
# @param max_memory [Integer, nil] Maximum memory usage in bytes (nil = unlimited)
|
|
24
|
+
def initialize(ttl: nil, max_size: nil, max_memory: nil)
|
|
25
|
+
@cache = {}
|
|
26
|
+
@timestamps = {}
|
|
27
|
+
@access_times = {}
|
|
28
|
+
@ttl = ttl
|
|
29
|
+
@max_size = max_size
|
|
30
|
+
@max_memory = max_memory
|
|
31
|
+
@current_memory = 0
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@hits = 0
|
|
34
|
+
@misses = 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get the current cache size.
|
|
38
|
+
#
|
|
39
|
+
# @return [Integer] Number of entries in the cache
|
|
40
|
+
def size
|
|
41
|
+
@mutex.synchronize { @cache.size }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get a cached result or compute and cache it.
|
|
45
|
+
#
|
|
46
|
+
# @param work [Fractor::Work] The work item to process
|
|
47
|
+
# @yield Block to compute the result if not cached
|
|
48
|
+
# @return [WorkResult, Object] The cached or computed result
|
|
49
|
+
def get(work)
|
|
50
|
+
key = generate_key(work)
|
|
51
|
+
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
# Check if we have a valid cached result
|
|
54
|
+
if @cache.key?(key) && !expired?(key)
|
|
55
|
+
@hits += 1
|
|
56
|
+
@access_times[key] = Time.now
|
|
57
|
+
return @cache[key]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@misses += 1
|
|
61
|
+
|
|
62
|
+
# Compute the result
|
|
63
|
+
result = yield
|
|
64
|
+
|
|
65
|
+
# Cache the result
|
|
66
|
+
cache_entry(key, result)
|
|
67
|
+
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Check if a work item has a cached result.
|
|
73
|
+
#
|
|
74
|
+
# @param work [Fractor::Work] The work item to check
|
|
75
|
+
# @return [Boolean] true if a valid cached result exists
|
|
76
|
+
def has?(work)
|
|
77
|
+
key = generate_key(work)
|
|
78
|
+
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
@cache.key?(key) && !expired?(key)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Store a result in the cache.
|
|
85
|
+
#
|
|
86
|
+
# @param work [Fractor::Work] The work item
|
|
87
|
+
# @param result [WorkResult, Object] The result to cache
|
|
88
|
+
# @return [void]
|
|
89
|
+
def set(work, result)
|
|
90
|
+
key = generate_key(work)
|
|
91
|
+
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
cache_entry(key, result)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Invalidate a cached result.
|
|
98
|
+
#
|
|
99
|
+
# @param work [Fractor::Work] The work item to invalidate
|
|
100
|
+
# @return [Boolean] true if a cached result was removed
|
|
101
|
+
def invalidate(work)
|
|
102
|
+
key = generate_key(work)
|
|
103
|
+
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
if @cache.key?(key)
|
|
106
|
+
remove_entry(key)
|
|
107
|
+
true
|
|
108
|
+
else
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Clear all cached results.
|
|
115
|
+
#
|
|
116
|
+
# @return [void]
|
|
117
|
+
def clear
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
@cache.clear
|
|
120
|
+
@timestamps.clear
|
|
121
|
+
@access_times.clear
|
|
122
|
+
@current_memory = 0
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Get cache statistics.
|
|
127
|
+
#
|
|
128
|
+
# @return [Hash] Cache statistics
|
|
129
|
+
def stats
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
total = @hits + @misses
|
|
132
|
+
hit_rate = total.positive? ? (@hits.to_f / total * 100).round(2) : 0
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
size: @cache.size,
|
|
136
|
+
hits: @hits,
|
|
137
|
+
misses: @misses,
|
|
138
|
+
hit_rate: hit_rate,
|
|
139
|
+
current_memory: @current_memory,
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Remove expired entries from the cache.
|
|
145
|
+
#
|
|
146
|
+
# @return [Integer] Number of entries removed
|
|
147
|
+
def cleanup_expired
|
|
148
|
+
@mutex.synchronize do
|
|
149
|
+
expired_keys = @cache.keys.select { |key| expired?(key) }
|
|
150
|
+
expired_keys.each { |key| remove_entry(key) }
|
|
151
|
+
expired_keys.size
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
# Generate a cache key for a work item.
|
|
158
|
+
#
|
|
159
|
+
# @param work [Fractor::Work] The work item
|
|
160
|
+
# @return [String] The cache key
|
|
161
|
+
def generate_key(work)
|
|
162
|
+
# Create a deterministic key from the work item
|
|
163
|
+
data = {
|
|
164
|
+
class: work.class.name,
|
|
165
|
+
input: work.input,
|
|
166
|
+
}
|
|
167
|
+
if work.respond_to?(:timeout) && !work.timeout.nil?
|
|
168
|
+
data[:timeout] =
|
|
169
|
+
work.timeout
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Use SHA256 hash for consistent, collision-resistant keys
|
|
173
|
+
Digest::SHA256.hexdigest(JSON.dump(data))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Check if a cache entry is expired.
|
|
177
|
+
#
|
|
178
|
+
# @param key [String] The cache key
|
|
179
|
+
# @return [Boolean] true if the entry is expired
|
|
180
|
+
def expired?(key)
|
|
181
|
+
return false unless @ttl
|
|
182
|
+
|
|
183
|
+
timestamp = @timestamps[key]
|
|
184
|
+
return true unless timestamp
|
|
185
|
+
|
|
186
|
+
Time.now - timestamp > @ttl
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Cache a result entry.
|
|
190
|
+
#
|
|
191
|
+
# @param key [String] The cache key
|
|
192
|
+
# @param result [Object] The result to cache
|
|
193
|
+
# @return [void]
|
|
194
|
+
def cache_entry(key, result)
|
|
195
|
+
# Evict oldest entry if max_size reached
|
|
196
|
+
evict_lru if @max_size && @cache.size >= @max_size
|
|
197
|
+
|
|
198
|
+
# Estimate memory usage
|
|
199
|
+
estimated_size = estimate_size(result)
|
|
200
|
+
|
|
201
|
+
# Evict entries if max_memory reached
|
|
202
|
+
evict_by_memory(estimated_size) if @max_memory
|
|
203
|
+
|
|
204
|
+
@cache[key] = result
|
|
205
|
+
@timestamps[key] = Time.now
|
|
206
|
+
@access_times[key] = Time.now
|
|
207
|
+
@current_memory += estimated_size
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Remove an entry from the cache.
|
|
211
|
+
#
|
|
212
|
+
# @param key [String] The cache key
|
|
213
|
+
# @return [void]
|
|
214
|
+
def remove_entry(key)
|
|
215
|
+
result = @cache.delete(key)
|
|
216
|
+
@timestamps.delete(key)
|
|
217
|
+
@access_times.delete(key)
|
|
218
|
+
|
|
219
|
+
if result
|
|
220
|
+
@current_memory -= estimate_size(result)
|
|
221
|
+
@current_memory = 0 if @current_memory.negative?
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Evict the least-recently-used entry.
|
|
226
|
+
#
|
|
227
|
+
# @return [void]
|
|
228
|
+
def evict_lru
|
|
229
|
+
return if @cache.empty?
|
|
230
|
+
|
|
231
|
+
# Find the entry with the oldest access time
|
|
232
|
+
lru_key = @access_times.min_by { |_, time| time }&.first
|
|
233
|
+
remove_entry(lru_key) if lru_key
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Evict entries to free memory.
|
|
237
|
+
#
|
|
238
|
+
# @param required_size [Integer] The size needed
|
|
239
|
+
# @return [void]
|
|
240
|
+
def evict_by_memory(required_size)
|
|
241
|
+
return unless @max_memory
|
|
242
|
+
|
|
243
|
+
while @current_memory + required_size > @max_memory && @cache.any?
|
|
244
|
+
evict_lru
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Estimate the memory size of an object.
|
|
249
|
+
#
|
|
250
|
+
# @param obj [Object] The object to measure
|
|
251
|
+
# @return [Integer] Estimated size in bytes
|
|
252
|
+
def estimate_size(obj)
|
|
253
|
+
# Rough estimation based on object inspection
|
|
254
|
+
case obj
|
|
255
|
+
when String
|
|
256
|
+
obj.bytesize
|
|
257
|
+
when Hash
|
|
258
|
+
obj.sum { |k, v| estimate_size(k.to_s) + estimate_size(v) }
|
|
259
|
+
when Array
|
|
260
|
+
obj.sum { |v| estimate_size(v) }
|
|
261
|
+
when Fractor::WorkResult
|
|
262
|
+
# Base size + result + error
|
|
263
|
+
size = 100 # Base object overhead
|
|
264
|
+
size += estimate_size(obj.result) if obj.result
|
|
265
|
+
size += estimate_size(obj.error) if obj.error
|
|
266
|
+
size
|
|
267
|
+
else
|
|
268
|
+
100 # Default estimate for unknown objects
|
|
269
|
+
end
|
|
270
|
+
rescue StandardError
|
|
271
|
+
100 # Fallback estimate
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
data/lib/fractor/supervisor.rb
CHANGED
|
@@ -318,7 +318,7 @@ module Fractor
|
|
|
318
318
|
# This is critical for Ruby 4.0 where workers need explicit work distribution
|
|
319
319
|
if @work_distribution_manager
|
|
320
320
|
distributed = @work_distribution_manager.distribute_to_idle_workers
|
|
321
|
-
puts "Distributed initial work to #{distributed} idle workers (work_queue.size: #{@work_queue.size})" if @debug
|
|
321
|
+
puts "Distributed initial work to #{distributed} idle workers (work_queue.size: #{@work_queue.size})" if @debug
|
|
322
322
|
end
|
|
323
323
|
|
|
324
324
|
# Start timer thread for continuous mode to periodically check work sources
|
data/lib/fractor/version.rb
CHANGED
data/lib/fractor/work.rb
CHANGED
|
@@ -4,10 +4,16 @@ module Fractor
|
|
|
4
4
|
# Base class for defining work items.
|
|
5
5
|
# Contains the input data for a worker.
|
|
6
6
|
class Work
|
|
7
|
-
attr_reader :input
|
|
7
|
+
attr_reader :input, :timeout
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
# Initializes a new work item.
|
|
10
|
+
#
|
|
11
|
+
# @param input [Object] The input data for the worker
|
|
12
|
+
# @param timeout [Numeric, nil] Optional per-work-item timeout in seconds.
|
|
13
|
+
# If nil, uses the worker's default timeout.
|
|
14
|
+
def initialize(input, timeout: nil)
|
|
10
15
|
@input = input
|
|
16
|
+
@timeout = timeout
|
|
11
17
|
end
|
|
12
18
|
|
|
13
19
|
def to_s
|
|
@@ -23,6 +29,7 @@ module Fractor
|
|
|
23
29
|
"@input=#{@input.inspect}",
|
|
24
30
|
"@type=#{input.class.name}",
|
|
25
31
|
]
|
|
32
|
+
details << "@timeout=#{@timeout.inspect}" if @timeout
|
|
26
33
|
details.join(" ")
|
|
27
34
|
end
|
|
28
35
|
end
|
|
@@ -63,21 +63,26 @@ module Fractor
|
|
|
63
63
|
RactorLogger.debug("Received work #{work.inspect}", ractor_name: name)
|
|
64
64
|
|
|
65
65
|
begin
|
|
66
|
-
# Get the timeout for this
|
|
67
|
-
|
|
66
|
+
# Get the timeout for this specific work item
|
|
67
|
+
# Priority: work.timeout > worker.timeout (nil means no timeout)
|
|
68
|
+
work_timeout = if work.respond_to?(:timeout) && !work.timeout.nil?
|
|
69
|
+
work.timeout
|
|
70
|
+
else
|
|
71
|
+
worker.timeout
|
|
72
|
+
end
|
|
68
73
|
|
|
69
74
|
# Process the work with timeout if configured
|
|
70
75
|
# Note: Ruby's Timeout.timeout uses threads which don't work with Ractors.
|
|
71
76
|
# We measure execution time and raise timeout error afterward if exceeded.
|
|
72
|
-
result = if
|
|
77
|
+
result = if work_timeout
|
|
73
78
|
start_time = Time.now
|
|
74
79
|
process_result = worker.process(work)
|
|
75
80
|
elapsed = Time.now - start_time
|
|
76
|
-
if elapsed >
|
|
81
|
+
if elapsed > work_timeout
|
|
77
82
|
# Raise a timeout error after the fact
|
|
78
83
|
# Note: This is a post-facto timeout check - the work has already completed
|
|
79
84
|
raise Timeout::Error,
|
|
80
|
-
"execution timed out after #{elapsed}s (limit: #{
|
|
85
|
+
"execution timed out after #{elapsed}s (limit: #{work_timeout}s)"
|
|
81
86
|
end
|
|
82
87
|
|
|
83
88
|
process_result
|
|
@@ -99,7 +104,7 @@ module Fractor
|
|
|
99
104
|
rescue Timeout::Error => e
|
|
100
105
|
# Handle timeout errors as retriable errors
|
|
101
106
|
RactorLogger.warn(
|
|
102
|
-
"Timed out after #{
|
|
107
|
+
"Timed out after #{work_timeout}s processing work #{work.inspect}", ractor_name: name
|
|
103
108
|
)
|
|
104
109
|
error_result = Fractor::WorkResult.new(
|
|
105
110
|
error: "Worker timeout: #{e.message}",
|
|
@@ -89,21 +89,26 @@ module Fractor
|
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
begin
|
|
92
|
-
# Get the timeout for this
|
|
93
|
-
|
|
92
|
+
# Get the timeout for this specific work item
|
|
93
|
+
# Priority: work.timeout > worker.timeout (nil means no timeout)
|
|
94
|
+
work_timeout = if work.respond_to?(:timeout) && !work.timeout.nil?
|
|
95
|
+
work.timeout
|
|
96
|
+
else
|
|
97
|
+
worker.timeout
|
|
98
|
+
end
|
|
94
99
|
|
|
95
100
|
# Process the work with timeout if configured
|
|
96
101
|
# Note: Ruby's Timeout.timeout uses threads which don't work with Ractors.
|
|
97
102
|
# We measure execution time and raise timeout error afterward if exceeded.
|
|
98
|
-
result = if
|
|
103
|
+
result = if work_timeout
|
|
99
104
|
start_time = Time.now
|
|
100
105
|
process_result = worker.process(work)
|
|
101
106
|
elapsed = Time.now - start_time
|
|
102
|
-
if elapsed >
|
|
107
|
+
if elapsed > work_timeout
|
|
103
108
|
# Raise a timeout error after the fact
|
|
104
109
|
# Note: This is a post-facto timeout check - the work has already completed
|
|
105
110
|
raise Timeout::Error,
|
|
106
|
-
"execution timed out after #{elapsed}s (limit: #{
|
|
111
|
+
"execution timed out after #{elapsed}s (limit: #{work_timeout}s)"
|
|
107
112
|
end
|
|
108
113
|
|
|
109
114
|
process_result
|
|
@@ -132,7 +137,7 @@ module Fractor
|
|
|
132
137
|
rescue Timeout::Error => e
|
|
133
138
|
# Handle timeout errors as retriable errors
|
|
134
139
|
RactorLogger.warn(
|
|
135
|
-
"Timed out after #{
|
|
140
|
+
"Timed out after #{work_timeout}s processing work #{work.inspect}", ractor_name: name
|
|
136
141
|
)
|
|
137
142
|
error_result = Fractor::WorkResult.new(
|
|
138
143
|
error: "Worker timeout: #{e.message}",
|
data/lib/fractor.rb
CHANGED
|
@@ -7,6 +7,8 @@ require_relative "fractor/logger"
|
|
|
7
7
|
require_relative "fractor/work"
|
|
8
8
|
require_relative "fractor/work_result"
|
|
9
9
|
require_relative "fractor/work_queue"
|
|
10
|
+
require_relative "fractor/queue_persister"
|
|
11
|
+
require_relative "fractor/persistent_work_queue"
|
|
10
12
|
require_relative "fractor/priority_work"
|
|
11
13
|
require_relative "fractor/priority_work_queue"
|
|
12
14
|
require_relative "fractor/wrapped_ractor"
|
|
@@ -32,6 +34,7 @@ require_relative "fractor/error_statistics"
|
|
|
32
34
|
require_relative "fractor/error_report_generator"
|
|
33
35
|
require_relative "fractor/error_formatter"
|
|
34
36
|
require_relative "fractor/execution_tracer"
|
|
37
|
+
require_relative "fractor/result_cache"
|
|
35
38
|
|
|
36
39
|
# Optional: CLI (only load if Thor is available)
|
|
37
40
|
begin
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fractor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ronald Tse
|
|
@@ -194,9 +194,12 @@ files:
|
|
|
194
194
|
- lib/fractor/performance_metrics_collector.rb
|
|
195
195
|
- lib/fractor/performance_monitor.rb
|
|
196
196
|
- lib/fractor/performance_report_generator.rb
|
|
197
|
+
- lib/fractor/persistent_work_queue.rb
|
|
197
198
|
- lib/fractor/priority_work.rb
|
|
198
199
|
- lib/fractor/priority_work_queue.rb
|
|
200
|
+
- lib/fractor/queue_persister.rb
|
|
199
201
|
- lib/fractor/result_aggregator.rb
|
|
202
|
+
- lib/fractor/result_cache.rb
|
|
200
203
|
- lib/fractor/shutdown_handler.rb
|
|
201
204
|
- lib/fractor/signal_handler.rb
|
|
202
205
|
- lib/fractor/supervisor.rb
|