chrono_forge 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/README.md +60 -39
- data/export.json +4 -4
- data/export.rb +1 -1
- data/lib/chrono_forge/executor/context.rb +43 -11
- data/lib/chrono_forge/executor/methods/durably_execute.rb +3 -1
- data/lib/chrono_forge/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b16c027867c5aca8d3f168ccb252125feef563731af33f40a129bfa9bfb781c5
|
4
|
+
data.tar.gz: 0ebd43e1896ea8df4ceb00c9c9af49c57a1e74d309149e817e43f9acfdfd5b8a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c94edc3ed7339bf3c7304c54e547bb1f86bc098dda586ffa049cfaf2fc29efcc7b44cce9429789ee1c3c5559dd3dd75859b75e74a27587c2a804160fee600f42
|
7
|
+
data.tar.gz: f7350954cace2c26ca57c7c0253a81d098a2eb655a5f283e6627eedec9d2b4acc11e0f58d3bebe47cd8a4c82907e1c4410d3655c43695ec8f02b8cc0af6a52c2
|
data/README.md
CHANGED
@@ -1,21 +1,26 @@
|
|
1
1
|
# ChronoForge
|
2
2
|
|
3
|
-
|
3
|
+

|
4
|
+
[](https://opensource.org/licenses/MIT)
|
4
5
|
|
5
|
-
|
6
|
+
> A robust framework for building durable, distributed workflows in Ruby on Rails applications
|
7
|
+
|
8
|
+
ChronoForge provides a powerful solution for handling long-running processes, managing state, and recovering from failures in your Rails applications. Built on top of ActiveJob, it ensures your critical business processes remain resilient and traceable.
|
9
|
+
|
10
|
+
## 🌟 Features
|
6
11
|
|
7
12
|
- **Durable Execution**: Automatically tracks and recovers from failures during workflow execution
|
8
|
-
- **State Management**: Built-in workflow state tracking with
|
9
|
-
- **Concurrency Control**: Advanced locking mechanisms to prevent
|
10
|
-
- **Error Handling**: Comprehensive error tracking
|
11
|
-
- **Execution Logging**: Detailed logging of workflow
|
13
|
+
- **State Management**: Built-in workflow state tracking with persistent context storage
|
14
|
+
- **Concurrency Control**: Advanced locking mechanisms to prevent parallel execution of the same workflow
|
15
|
+
- **Error Handling**: Comprehensive error tracking with configurable retry strategies
|
16
|
+
- **Execution Logging**: Detailed logging of workflow steps and errors for visibility
|
12
17
|
- **Wait States**: Support for time-based waits and condition-based waiting
|
13
|
-
- **Database-Backed**: All workflow state is persisted to
|
14
|
-
- **ActiveJob Integration**:
|
18
|
+
- **Database-Backed**: All workflow state is persisted to ensure durability
|
19
|
+
- **ActiveJob Integration**: Compatible with all ActiveJob backends, though database-backed processors (like Solid Queue) provide the most reliable experience for long-running workflows
|
15
20
|
|
16
|
-
## Installation
|
21
|
+
## 📦 Installation
|
17
22
|
|
18
|
-
Add
|
23
|
+
Add to your application's Gemfile:
|
19
24
|
|
20
25
|
```ruby
|
21
26
|
gem 'chrono_forge'
|
@@ -27,7 +32,7 @@ Then execute:
|
|
27
32
|
$ bundle install
|
28
33
|
```
|
29
34
|
|
30
|
-
Or install
|
35
|
+
Or install directly:
|
31
36
|
|
32
37
|
```bash
|
33
38
|
$ gem install chrono_forge
|
@@ -40,7 +45,7 @@ $ rails generate chrono_forge:install
|
|
40
45
|
$ rails db:migrate
|
41
46
|
```
|
42
47
|
|
43
|
-
## Usage
|
48
|
+
## 📋 Usage
|
44
49
|
|
45
50
|
### Basic Workflow Example
|
46
51
|
|
@@ -52,7 +57,7 @@ class OrderProcessingWorkflow < ApplicationJob
|
|
52
57
|
|
53
58
|
def perform
|
54
59
|
# Context can be used to pass and store data between executions
|
55
|
-
context
|
60
|
+
context.set_once "order_id", SecureRandom.hex
|
56
61
|
|
57
62
|
# Wait until payment is confirmed
|
58
63
|
wait_until :payment_confirmed?
|
@@ -74,22 +79,22 @@ class OrderProcessingWorkflow < ApplicationJob
|
|
74
79
|
end
|
75
80
|
|
76
81
|
def process_order
|
77
|
-
context["processed_at"] = Time.current.iso8601
|
78
82
|
OrderProcessor.process(context["order_id"])
|
83
|
+
context["processed_at"] = Time.current.iso8601
|
79
84
|
end
|
80
85
|
|
81
86
|
def complete_order
|
82
|
-
context["completed_at"] = Time.current.iso8601
|
83
87
|
OrderCompletionService.complete(context["order_id"])
|
88
|
+
context["completed_at"] = Time.current.iso8601
|
84
89
|
end
|
85
90
|
end
|
86
91
|
```
|
87
92
|
|
88
|
-
### Workflow Features
|
93
|
+
### Core Workflow Features
|
89
94
|
|
90
|
-
#### Durable Execution
|
95
|
+
#### ⚡ Durable Execution
|
91
96
|
|
92
|
-
The `durably_execute` method ensures operations are executed exactly once:
|
97
|
+
The `durably_execute` method ensures operations are executed exactly once, even if the job fails and is retried:
|
93
98
|
|
94
99
|
```ruby
|
95
100
|
# Execute a method
|
@@ -101,7 +106,7 @@ durably_execute -> (ctx) {
|
|
101
106
|
}
|
102
107
|
```
|
103
108
|
|
104
|
-
#### Wait States
|
109
|
+
#### ⏱️ Wait States
|
105
110
|
|
106
111
|
ChronoForge supports both time-based and condition-based waits:
|
107
112
|
|
@@ -115,9 +120,9 @@ wait_until :payment_processed,
|
|
115
120
|
check_interval: 5.minutes
|
116
121
|
```
|
117
122
|
|
118
|
-
#### Workflow Context
|
123
|
+
#### 🔄 Workflow Context
|
119
124
|
|
120
|
-
ChronoForge provides a persistent context that survives job restarts:
|
125
|
+
ChronoForge provides a persistent context that survives job restarts. The context behaves like a Hash but with additional capabilities:
|
121
126
|
|
122
127
|
```ruby
|
123
128
|
# Set context values
|
@@ -126,11 +131,25 @@ context[:status] = "processing"
|
|
126
131
|
|
127
132
|
# Read context values
|
128
133
|
user_name = context[:user_name]
|
134
|
+
|
135
|
+
# Using the fetch method (returns default if key doesn't exist)
|
136
|
+
status = context.fetch(:status, "pending")
|
137
|
+
|
138
|
+
# Set a value with the set method (alias for []=)
|
139
|
+
context.set(:total_amount, 99.99)
|
140
|
+
|
141
|
+
# Set a value only if the key doesn't already exist
|
142
|
+
context.set_once(:created_at, Time.current.iso8601)
|
143
|
+
|
144
|
+
# Check if a key exists
|
145
|
+
if context.key?(:user_id)
|
146
|
+
# Do something with the user ID
|
147
|
+
end
|
129
148
|
```
|
130
149
|
|
131
|
-
### Error Handling
|
150
|
+
### 🛡️ Error Handling
|
132
151
|
|
133
|
-
ChronoForge automatically tracks errors and provides retry capabilities:
|
152
|
+
ChronoForge automatically tracks errors and provides configurable retry capabilities:
|
134
153
|
|
135
154
|
```ruby
|
136
155
|
class MyWorkflow < ApplicationJob
|
@@ -141,19 +160,19 @@ class MyWorkflow < ApplicationJob
|
|
141
160
|
def should_retry?(error, attempt_count)
|
142
161
|
case error
|
143
162
|
when NetworkError
|
144
|
-
attempt_count < 5
|
163
|
+
attempt_count < 5 # Retry network errors up to 5 times
|
145
164
|
when ValidationError
|
146
165
|
false # Don't retry validation errors
|
147
166
|
else
|
148
|
-
attempt_count < 3
|
167
|
+
attempt_count < 3 # Default retry policy
|
149
168
|
end
|
150
169
|
end
|
151
170
|
end
|
152
171
|
```
|
153
172
|
|
154
|
-
## Testing
|
173
|
+
## 🧪 Testing
|
155
174
|
|
156
|
-
ChronoForge is designed to be easily testable using [ChaoticJob](https://github.com/fractaledmind/chaotic_job), a testing framework that makes it simple to test complex job workflows
|
175
|
+
ChronoForge is designed to be easily testable using [ChaoticJob](https://github.com/fractaledmind/chaotic_job), a testing framework that makes it simple to test complex job workflows:
|
157
176
|
|
158
177
|
1. Add ChaoticJob to your Gemfile's test group:
|
159
178
|
|
@@ -196,22 +215,24 @@ class WorkflowTest < ActiveJob::TestCase
|
|
196
215
|
end
|
197
216
|
```
|
198
217
|
|
199
|
-
|
218
|
+
## 🗄️ Database Schema
|
200
219
|
|
201
|
-
|
202
|
-
|
203
|
-
For testing with specific job processing libraries like Sidekiq or Delayed Job, you can still use their respective testing modes, but ChaoticJob is recommended for testing ChronoForge workflows as it better handles the complexities of nested job scheduling and wait states.
|
220
|
+
ChronoForge creates three main tables:
|
204
221
|
|
222
|
+
1. **chrono_forge_workflows**: Stores workflow state and context
|
223
|
+
2. **chrono_forge_execution_logs**: Tracks individual execution steps
|
224
|
+
3. **chrono_forge_error_logs**: Records detailed error information
|
205
225
|
|
206
|
-
##
|
226
|
+
## 🔍 When to Use ChronoForge
|
207
227
|
|
208
|
-
ChronoForge
|
228
|
+
ChronoForge is ideal for:
|
209
229
|
|
210
|
-
|
211
|
-
|
212
|
-
|
230
|
+
- **Long-running business processes** - Order processing, account registration flows
|
231
|
+
- **Processes requiring durability** - Financial transactions, data migrations
|
232
|
+
- **Multi-step workflows** - Onboarding flows, approval processes, multi-stage jobs
|
233
|
+
- **State machines with time-based transitions** - Document approval, subscription lifecycle
|
213
234
|
|
214
|
-
## Development
|
235
|
+
## 🚀 Development
|
215
236
|
|
216
237
|
After checking out the repo, run:
|
217
238
|
|
@@ -227,7 +248,7 @@ The test suite uses SQLite by default and includes:
|
|
227
248
|
- Integration tests with ActiveJob
|
228
249
|
- Example workflow implementations
|
229
250
|
|
230
|
-
## Contributing
|
251
|
+
## 👥 Contributing
|
231
252
|
|
232
253
|
1. Fork the repository
|
233
254
|
2. Create your feature branch (`git checkout -b feature/my-new-feature`)
|
@@ -237,6 +258,6 @@ The test suite uses SQLite by default and includes:
|
|
237
258
|
|
238
259
|
Please include tests for any new features or bug fixes.
|
239
260
|
|
240
|
-
## License
|
261
|
+
## 📜 License
|
241
262
|
|
242
263
|
This gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/export.json
CHANGED
@@ -13,7 +13,7 @@
|
|
13
13
|
},
|
14
14
|
{
|
15
15
|
"path": "/Users/stefan/Documents/plutonium/chrono_forge/export.rb",
|
16
|
-
"contents": "require \"json\"\nrequire \"find\"\n\ndef export_files_to_json(directory, extensions, output_file, exceptions = [])\n # Convert extensions to lowercase for case-insensitive matching\n extensions = extensions.map(&:downcase)\n\n # Array to store file data\n files_data = []\n\n # Find all files in directory and subdirectories\n Find.find(directory) do |path|\n # Skip if not a file\n next unless File.file?(path)\n next if exceptions.any? { |exception| path.include?(exception) }\n\n # Check if file extension matches any in our list\n ext = File.extname(path).downcase[1
|
16
|
+
"contents": "require \"json\"\nrequire \"find\"\n\ndef export_files_to_json(directory, extensions, output_file, exceptions = [])\n # Convert extensions to lowercase for case-insensitive matching\n extensions = extensions.map(&:downcase)\n\n # Array to store file data\n files_data = []\n\n # Find all files in directory and subdirectories\n Find.find(directory) do |path|\n # Skip if not a file\n next unless File.file?(path)\n next if exceptions.any? { |exception| path.include?(exception) }\n\n # Check if file extension matches any in our list\n ext = File.extname(path).downcase[1..] # Remove the leading dot\n next unless extensions.include?(ext)\n\n puts path\n\n begin\n # Read file contents\n contents = File.read(path)\n\n # Add to our array\n files_data << {\n \"path\" => path,\n \"contents\" => contents\n }\n rescue => e\n puts \"Error reading file #{path}: #{e.message}\"\n end\n end\n\n # Write to JSON file\n File.write(output_file, JSON.pretty_generate(files_data))\n\n puts \"Successfully exported #{files_data.length} files to #{output_file}\"\nend\n\n# Example usage (uncomment and modify as needed):\ndirectory = \"/Users/stefan/Documents/plutonium/chrono_forge\"\nexceptions = [\"/.github/\", \"/.vscode/\", \"gemfiles\", \"pkg\", \"node_modules\"]\nextensions = [\"rb\", \"md\", \"yml\", \"yaml\", \"gemspec\"]\noutput_file = \"export.json\"\nexport_files_to_json(directory, extensions, output_file, exceptions)\n"
|
17
17
|
},
|
18
18
|
{
|
19
19
|
"path": "/Users/stefan/Documents/plutonium/chrono_forge/lib/chrono_forge/error_log.rb",
|
@@ -25,7 +25,7 @@
|
|
25
25
|
},
|
26
26
|
{
|
27
27
|
"path": "/Users/stefan/Documents/plutonium/chrono_forge/lib/chrono_forge/executor/context.rb",
|
28
|
-
"contents": "module ChronoForge\n module Executor\n class Context\n class ValidationError < Error; end\n\n ALLOWED_TYPES = [\n String,\n Integer,\n Float,\n TrueClass,\n FalseClass,\n NilClass,\n Hash,\n Array\n ]\n\n def initialize(workflow)\n @workflow = workflow\n @context = workflow.context || {}\n @dirty = false\n end\n\n def []=(key, value)\n #
|
28
|
+
"contents": "module ChronoForge\n module Executor\n class Context\n class ValidationError < Error; end\n\n ALLOWED_TYPES = [\n String,\n Integer,\n Float,\n TrueClass,\n FalseClass,\n NilClass,\n Hash,\n Array\n ]\n\n def initialize(workflow)\n @workflow = workflow\n @context = workflow.context || {}\n @dirty = false\n end\n\n def []=(key, value)\n set_value(key, value)\n end\n\n def [](key)\n get_value(key)\n end\n\n # Fetches a value from the context\n # Returns the value if the key exists, otherwise returns the default value\n def fetch(key, default = nil)\n key?(key) ? get_value(key) : default\n end\n\n # Sets a value in the context\n # Alias for the []= method\n def set(key, value)\n set_value(key, value)\n end\n\n # Sets a value in the context only if the key doesn't already exist\n # Returns true if the value was set, false otherwise\n def set_once(key, value)\n return false if key?(key)\n\n set_value(key, value)\n true\n end\n\n def key?(key)\n @context.key?(key.to_s)\n end\n\n def save!\n return unless @dirty\n\n @workflow.update_column(:context, @context)\n @dirty = false\n end\n\n private\n\n def set_value(key, value)\n validate_value!(value)\n\n @context[key.to_s] =\n if value.is_a?(Hash) || value.is_a?(Array)\n deep_dup(value)\n else\n value\n end\n\n @dirty = true\n end\n\n def get_value(key)\n @context[key.to_s]\n end\n\n def validate_value!(value)\n unless ALLOWED_TYPES.any? { |type| value.is_a?(type) }\n raise ValidationError, \"Unsupported context value type: #{value.inspect}\"\n end\n\n # Optional: Add size constraints\n if value.is_a?(String) && value.size > 64.kilobytes\n raise ValidationError, \"Context value too large\"\n end\n end\n\n def deep_dup(obj)\n JSON.parse(JSON.generate(obj))\n rescue\n obj.dup\n end\n end\n end\nend\n"
|
29
29
|
},
|
30
30
|
{
|
31
31
|
"path": "/Users/stefan/Documents/plutonium/chrono_forge/lib/chrono_forge/executor/execution_tracker.rb",
|
@@ -37,7 +37,7 @@
|
|
37
37
|
},
|
38
38
|
{
|
39
39
|
"path": "/Users/stefan/Documents/plutonium/chrono_forge/lib/chrono_forge/executor/methods/durably_execute.rb",
|
40
|
-
"contents": "module ChronoForge\n module Executor\n module Methods\n module DurablyExecute\n def durably_execute(method, **options)\n # Create execution log\n step_name = \"durably_execute$#{method}\"\n execution_log = ExecutionLog.create_or_find_by!(\n workflow: @workflow,\n step_name: step_name\n ) do |log|\n log.started_at = Time.current\n end\n\n # Return if already completed\n return if execution_log.completed?\n\n # Execute with error handling\n begin\n # Update execution log with attempt\n execution_log.update!(\n attempts: execution_log.attempts + 1,\n last_executed_at: Time.current\n )\n\n # Execute the method\n if method.is_a?(Symbol)\n send(method)\n else\n method.call(@context)\n end\n\n # Complete the execution\n execution_log.update!(\n state: :completed,\n completed_at: Time.current\n )\n
|
40
|
+
"contents": "module ChronoForge\n module Executor\n module Methods\n module DurablyExecute\n def durably_execute(method, **options)\n # Create execution log\n step_name = \"durably_execute$#{method}\"\n execution_log = ExecutionLog.create_or_find_by!(\n workflow: @workflow,\n step_name: step_name\n ) do |log|\n log.started_at = Time.current\n end\n\n # Return if already completed\n return if execution_log.completed?\n\n # Execute with error handling\n begin\n # Update execution log with attempt\n execution_log.update!(\n attempts: execution_log.attempts + 1,\n last_executed_at: Time.current\n )\n\n # Execute the method\n if method.is_a?(Symbol)\n send(method)\n else\n method.call(@context)\n end\n\n # Complete the execution\n execution_log.update!(\n state: :completed,\n completed_at: Time.current\n )\n \n # return nil\n nil\n rescue HaltExecutionFlow\n raise\n rescue => e\n # Log the error\n Rails.logger.error { \"Error while durably executing #{method}: #{e.message}\" }\n self.class::ExecutionTracker.track_error(workflow, e)\n\n # Optional retry logic\n if execution_log.attempts < (options[:max_attempts] || 3)\n # Reschedule with exponential backoff\n backoff = (2**[execution_log.attempts || 1, 5].min).seconds\n\n self.class\n .set(wait: backoff)\n .perform_later(\n @workflow.key,\n retry_method: method\n )\n\n # Halt current execution\n halt_execution!\n else\n # Max attempts reached\n execution_log.update!(\n state: :failed,\n error_message: e.message,\n error_class: e.class.name\n )\n raise ExecutionFailedError, \"#{step_name} failed after maximum attempts\"\n end\n end\n end\n end\n end\n end\nend\n"
|
41
41
|
},
|
42
42
|
{
|
43
43
|
"path": "/Users/stefan/Documents/plutonium/chrono_forge/lib/chrono_forge/executor/methods/wait.rb",
|
@@ -61,7 +61,7 @@
|
|
61
61
|
},
|
62
62
|
{
|
63
63
|
"path": "/Users/stefan/Documents/plutonium/chrono_forge/lib/chrono_forge/version.rb",
|
64
|
-
"contents": "# frozen_string_literal: true\n\nmodule ChronoForge\n VERSION = \"0.
|
64
|
+
"contents": "# frozen_string_literal: true\n\nmodule ChronoForge\n VERSION = \"0.2.0\"\nend\n"
|
65
65
|
},
|
66
66
|
{
|
67
67
|
"path": "/Users/stefan/Documents/plutonium/chrono_forge/lib/chrono_forge/workflow.rb",
|
data/export.rb
CHANGED
@@ -15,7 +15,7 @@ def export_files_to_json(directory, extensions, output_file, exceptions = [])
|
|
15
15
|
next if exceptions.any? { |exception| path.include?(exception) }
|
16
16
|
|
17
17
|
# Check if file extension matches any in our list
|
18
|
-
ext = File.extname(path).downcase[1
|
18
|
+
ext = File.extname(path).downcase[1..] # Remove the leading dot
|
19
19
|
next unless extensions.include?(ext)
|
20
20
|
|
21
21
|
puts path
|
@@ -21,21 +21,36 @@ module ChronoForge
|
|
21
21
|
end
|
22
22
|
|
23
23
|
def []=(key, value)
|
24
|
-
|
25
|
-
|
24
|
+
set_value(key, value)
|
25
|
+
end
|
26
26
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
else
|
31
|
-
value
|
32
|
-
end
|
27
|
+
def [](key)
|
28
|
+
get_value(key)
|
29
|
+
end
|
33
30
|
|
34
|
-
|
31
|
+
# Fetches a value from the context
|
32
|
+
# Returns the value if the key exists, otherwise returns the default value
|
33
|
+
def fetch(key, default = nil)
|
34
|
+
key?(key) ? get_value(key) : default
|
35
35
|
end
|
36
36
|
|
37
|
-
|
38
|
-
|
37
|
+
# Sets a value in the context
|
38
|
+
# Alias for the []= method
|
39
|
+
def set(key, value)
|
40
|
+
set_value(key, value)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Sets a value in the context only if the key doesn't already exist
|
44
|
+
# Returns true if the value was set, false otherwise
|
45
|
+
def set_once(key, value)
|
46
|
+
return false if key?(key)
|
47
|
+
|
48
|
+
set_value(key, value)
|
49
|
+
true
|
50
|
+
end
|
51
|
+
|
52
|
+
def key?(key)
|
53
|
+
@context.key?(key.to_s)
|
39
54
|
end
|
40
55
|
|
41
56
|
def save!
|
@@ -47,6 +62,23 @@ module ChronoForge
|
|
47
62
|
|
48
63
|
private
|
49
64
|
|
65
|
+
def set_value(key, value)
|
66
|
+
validate_value!(value)
|
67
|
+
|
68
|
+
@context[key.to_s] =
|
69
|
+
if value.is_a?(Hash) || value.is_a?(Array)
|
70
|
+
deep_dup(value)
|
71
|
+
else
|
72
|
+
value
|
73
|
+
end
|
74
|
+
|
75
|
+
@dirty = true
|
76
|
+
end
|
77
|
+
|
78
|
+
def get_value(key)
|
79
|
+
@context[key.to_s]
|
80
|
+
end
|
81
|
+
|
50
82
|
def validate_value!(value)
|
51
83
|
unless ALLOWED_TYPES.any? { |type| value.is_a?(type) }
|
52
84
|
raise ValidationError, "Unsupported context value type: #{value.inspect}"
|
data/lib/chrono_forge/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: chrono_forge
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stefan Froelich
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-04-
|
11
|
+
date: 2025-04-28 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|