chrono_forge 0.5.0 → 0.5.1
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 +3 -1
- data/lib/chrono_forge/executor/lock_strategy.rb +43 -29
- data/lib/chrono_forge/executor/methods/workflow_states.rb +1 -1
- data/lib/chrono_forge/executor.rb +21 -25
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge.rb +2 -2
- data/lib/generators/chrono_forge/install/templates/install_chrono_forge.rb +21 -21
- 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: 887025fd6483bb1d532ec4c78344a9cb716b40ed8aa23dc6e18ab4356e852d75
|
4
|
+
data.tar.gz: b6fbedf50eb2372424ca8bd7d4acf5471edb1ea676a1118180422df1e1306a27
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: eb618c3c16fbee88a5398a09ccc9a0859c9d7d6252285ed56cb37562977bff1042d68517313c305193c18ff2170055843c6a126c6f481051234cc12aba30b359
|
7
|
+
data.tar.gz: 5eda3316fcc204a40f6d5fa1654623e16c0c1cc9ddbbe3f1fb9dca5dcf06556df5195cefa97f5f13271214c4d9dde42c02c41513348eb06176a3fae7d5bbc61b
|
data/README.md
CHANGED
@@ -1,8 +1,10 @@
|
|
1
1
|
# ChronoForge
|
2
2
|
|
3
|
-

|
4
|
+
[](https://github.com/radioactive-labs/chrono_forge/actions/workflows/main.yml)
|
4
5
|
[](https://opensource.org/licenses/MIT)
|
5
6
|
|
7
|
+
|
6
8
|
> A robust framework for building durable, distributed workflows in Ruby on Rails applications
|
7
9
|
|
8
10
|
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.
|
@@ -5,42 +5,56 @@ module ChronoForge
|
|
5
5
|
class ConcurrentExecutionError < Error; end
|
6
6
|
|
7
7
|
class LockStrategy
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
8
|
+
class << self
|
9
|
+
def acquire_lock(job_id, workflow, max_duration:)
|
10
|
+
ActiveRecord::Base.transaction do
|
11
|
+
# Find the workflow with a lock, considering stale locks
|
12
|
+
workflow = workflow.lock!
|
13
|
+
|
14
|
+
ensure_executable!(workflow)
|
15
|
+
|
16
|
+
# Check for active execution
|
17
|
+
if workflow.locked_at && workflow.locked_at > max_duration.ago
|
18
|
+
raise ConcurrentExecutionError,
|
19
|
+
"ChronoForge:#{self.class}(#{key}) job(#{job_id}) failed to acquire lock. " \
|
20
|
+
"Currently being executed by job(#{workflow.locked_by})"
|
21
|
+
end
|
22
|
+
|
23
|
+
# Atomic update of lock status
|
24
|
+
workflow.update_columns(
|
25
|
+
locked_by: job_id,
|
26
|
+
locked_at: Time.current,
|
27
|
+
state: :running
|
28
|
+
)
|
29
|
+
|
30
|
+
Rails.logger.debug { "ChronoForge:#{self.class}(#{workflow.key}) job(#{job_id}) acquired lock." }
|
31
|
+
|
32
|
+
workflow
|
16
33
|
end
|
34
|
+
end
|
17
35
|
|
18
|
-
|
19
|
-
workflow.
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
Rails.logger.debug { "ChronoForge:#{self.class} job(#{job_id}) acquired lock for workflow(#{workflow.key})" }
|
36
|
+
def release_lock(job_id, workflow, force: false)
|
37
|
+
workflow = workflow.reload
|
38
|
+
if !force && workflow.locked_by != job_id
|
39
|
+
raise LongRunningConcurrentExecutionError,
|
40
|
+
"ChronoForge:#{self.class}(#{workflow.key}) job(#{job_id}) executed longer than specified max_duration, " \
|
41
|
+
"allowed job(#{workflow.locked_by}) to acquire the lock."
|
42
|
+
end
|
26
43
|
|
27
|
-
|
28
|
-
|
29
|
-
end
|
44
|
+
columns = {locked_at: nil, locked_by: nil}
|
45
|
+
columns[:state] = :idle if force || workflow.running?
|
30
46
|
|
31
|
-
|
32
|
-
workflow = workflow.reload
|
33
|
-
if !force && workflow.locked_by != job_id
|
34
|
-
raise LongRunningConcurrentExecutionError,
|
35
|
-
"ChronoForge:#{self.class} job(#{job_id}) executed longer than specified max_duration, " \
|
36
|
-
"allowed another instance job(#{workflow.locked_by}) to acquire the lock."
|
47
|
+
workflow.update_columns(columns)
|
37
48
|
end
|
38
49
|
|
39
|
-
|
40
|
-
columns[:state] = :idle if force || workflow.running?
|
41
|
-
|
50
|
+
private
|
42
51
|
|
43
|
-
workflow
|
52
|
+
def ensure_executable!(workflow)
|
53
|
+
# Raise error if workflow cannot be executed
|
54
|
+
unless workflow.executable?
|
55
|
+
raise NotExecutableError, "ChronoForge:#{workflow.class}(#{workflow.key}) is not in an executable state"
|
56
|
+
end
|
57
|
+
end
|
44
58
|
end
|
45
59
|
end
|
46
60
|
end
|
@@ -22,22 +22,22 @@ module ChronoForge
|
|
22
22
|
if !key.is_a?(String)
|
23
23
|
raise ArgumentError, "Workflow key must be a string as the first argument"
|
24
24
|
end
|
25
|
-
super
|
25
|
+
super
|
26
26
|
end
|
27
|
-
|
27
|
+
|
28
28
|
# Enforce expected signature for perform_later with key as first arg and keywords after
|
29
29
|
def perform_later(key, **kwargs)
|
30
30
|
if !key.is_a?(String)
|
31
31
|
raise ArgumentError, "Workflow key must be a string as the first argument"
|
32
32
|
end
|
33
|
-
super
|
33
|
+
super
|
34
34
|
end
|
35
|
-
|
35
|
+
|
36
36
|
# Add retry_now class method that calls perform_now with retry_workflow: true
|
37
37
|
def retry_now(key, **kwargs)
|
38
38
|
perform_now(key, retry_workflow: true, **kwargs)
|
39
39
|
end
|
40
|
-
|
40
|
+
|
41
41
|
# Add retry_later class method that calls perform_later with retry_workflow: true
|
42
42
|
def retry_later(key, **kwargs)
|
43
43
|
perform_later(key, retry_workflow: true, **kwargs)
|
@@ -52,8 +52,8 @@ module ChronoForge
|
|
52
52
|
return
|
53
53
|
end
|
54
54
|
|
55
|
-
# Find or create
|
56
|
-
setup_workflow(key, options, kwargs)
|
55
|
+
# Find or create workflow instance
|
56
|
+
setup_workflow!(key, options, kwargs)
|
57
57
|
|
58
58
|
# Handle retry parameter - unlock and continue execution
|
59
59
|
retry_workflow! if retry_workflow
|
@@ -62,37 +62,35 @@ module ChronoForge
|
|
62
62
|
lock_acquired = false
|
63
63
|
|
64
64
|
begin
|
65
|
-
# Raise error if workflow cannot be executed
|
66
|
-
unless workflow.executable?
|
67
|
-
raise NotExecutableError, "#{self.class}(#{key}) is not in an executable state"
|
68
|
-
end
|
69
|
-
|
70
65
|
# Acquire lock with advanced concurrency protection
|
71
66
|
@workflow = self.class::LockStrategy.acquire_lock(job_id, workflow, max_duration: max_duration)
|
72
67
|
lock_acquired = true
|
73
68
|
|
69
|
+
# Setup context
|
70
|
+
setup_context!
|
71
|
+
|
74
72
|
# Execute core job logic
|
75
73
|
super(**workflow.kwargs.symbolize_keys)
|
76
74
|
|
77
75
|
# Mark as complete
|
78
76
|
complete_workflow!
|
79
77
|
rescue ExecutionFailedError => e
|
80
|
-
Rails.logger.error { "ChronoForge:#{self.class}
|
78
|
+
Rails.logger.error { "ChronoForge:#{self.class}(#{key}) step execution failed" }
|
81
79
|
self.class::ExecutionTracker.track_error(workflow, e)
|
82
80
|
workflow.stalled!
|
83
81
|
nil
|
84
82
|
rescue HaltExecutionFlow
|
85
83
|
# Halt execution
|
86
|
-
Rails.logger.debug { "ChronoForge:#{self.class}
|
84
|
+
Rails.logger.debug { "ChronoForge:#{self.class}(#{key}) execution halted" }
|
87
85
|
nil
|
88
86
|
rescue ConcurrentExecutionError
|
89
87
|
# Graceful handling of concurrent execution
|
90
|
-
Rails.logger.warn { "ChronoForge:#{self.class} concurrent execution detected
|
88
|
+
Rails.logger.warn { "ChronoForge:#{self.class}(#{key}) concurrent execution detected" }
|
91
89
|
nil
|
92
90
|
rescue NotExecutableError
|
93
91
|
raise
|
94
92
|
rescue => e
|
95
|
-
Rails.logger.error { "ChronoForge:#{self.class}
|
93
|
+
Rails.logger.error { "ChronoForge:#{self.class}(#{key}) workflow execution failed" }
|
96
94
|
error_log = self.class::ExecutionTracker.track_error(workflow, e)
|
97
95
|
|
98
96
|
# Retry if applicable
|
@@ -102,8 +100,7 @@ module ChronoForge
|
|
102
100
|
fail_workflow! error_log
|
103
101
|
end
|
104
102
|
ensure
|
105
|
-
# Only release lock if we acquired it
|
106
|
-
if lock_acquired
|
103
|
+
if lock_acquired # Only release lock if we acquired it
|
107
104
|
context.save!
|
108
105
|
self.class::LockStrategy.release_lock(job_id, workflow)
|
109
106
|
end
|
@@ -112,19 +109,18 @@ module ChronoForge
|
|
112
109
|
|
113
110
|
private
|
114
111
|
|
115
|
-
def setup_workflow(key, options, kwargs)
|
116
|
-
@workflow =
|
117
|
-
@context = Context.new(@workflow)
|
118
|
-
end
|
119
|
-
|
120
|
-
def find_workflow(key, options, kwargs)
|
121
|
-
Workflow.create_or_find_by!(job_class: self.class.to_s, key: key) do |workflow|
|
112
|
+
def setup_workflow!(key, options, kwargs)
|
113
|
+
@workflow = Workflow.create_or_find_by!(job_class: self.class.to_s, key: key) do |workflow|
|
122
114
|
workflow.options = options
|
123
115
|
workflow.kwargs = kwargs
|
124
116
|
workflow.started_at = Time.current
|
125
117
|
end
|
126
118
|
end
|
127
119
|
|
120
|
+
def setup_context!
|
121
|
+
@context = Context.new(workflow)
|
122
|
+
end
|
123
|
+
|
128
124
|
def should_retry?(error, attempt_count)
|
129
125
|
attempt_count < 3
|
130
126
|
end
|
data/lib/chrono_forge/version.rb
CHANGED
data/lib/chrono_forge.rb
CHANGED
@@ -11,6 +11,6 @@ module ChronoForge
|
|
11
11
|
end
|
12
12
|
|
13
13
|
class Error < StandardError; end
|
14
|
-
|
15
|
-
def self.ApplicationRecord
|
14
|
+
|
15
|
+
def self.ApplicationRecord = defined?(::ApplicationRecord) ? ::ApplicationRecord : ActiveRecord::Base
|
16
16
|
end
|
@@ -28,22 +28,22 @@ class InstallChronoForge < ActiveRecord::Migration[7.1]
|
|
28
28
|
end
|
29
29
|
|
30
30
|
create_table :chrono_forge_execution_logs, id: primary_key_type do |t|
|
31
|
-
t.references :workflow, null: false,
|
32
|
-
|
33
|
-
|
31
|
+
t.references :workflow, null: false,
|
32
|
+
foreign_key: {to_table: :chrono_forge_workflows},
|
33
|
+
type: reference_type
|
34
34
|
|
35
35
|
t.string :step_name, null: false
|
36
36
|
t.integer :attempts, null: false, default: 0
|
37
37
|
t.datetime :started_at
|
38
38
|
t.datetime :last_executed_at
|
39
39
|
t.datetime :completed_at
|
40
|
-
|
40
|
+
|
41
41
|
if t.respond_to?(:jsonb)
|
42
42
|
t.jsonb :metadata
|
43
43
|
else
|
44
44
|
t.json :metadata
|
45
45
|
end
|
46
|
-
|
46
|
+
|
47
47
|
t.integer :state, null: false, default: 0
|
48
48
|
t.string :error_class
|
49
49
|
t.text :error_message
|
@@ -53,14 +53,14 @@ class InstallChronoForge < ActiveRecord::Migration[7.1]
|
|
53
53
|
end
|
54
54
|
|
55
55
|
create_table :chrono_forge_error_logs, id: primary_key_type do |t|
|
56
|
-
t.references :workflow, null: false,
|
57
|
-
|
58
|
-
|
59
|
-
|
56
|
+
t.references :workflow, null: false,
|
57
|
+
foreign_key: {to_table: :chrono_forge_workflows},
|
58
|
+
type: reference_type
|
59
|
+
|
60
60
|
t.string :error_class
|
61
61
|
t.text :error_message
|
62
62
|
t.text :backtrace
|
63
|
-
|
63
|
+
|
64
64
|
if t.respond_to?(:jsonb)
|
65
65
|
t.jsonb :context
|
66
66
|
else
|
@@ -76,29 +76,29 @@ class InstallChronoForge < ActiveRecord::Migration[7.1]
|
|
76
76
|
def primary_key_type
|
77
77
|
# Check if the application is configured to use UUIDs
|
78
78
|
if ActiveRecord.respond_to?(:default_id) && ActiveRecord.default_id.respond_to?(:to_s) &&
|
79
|
-
|
79
|
+
ActiveRecord.default_id.to_s.include?("uuid")
|
80
80
|
return :uuid
|
81
81
|
end
|
82
|
-
|
82
|
+
|
83
83
|
# Rails 6+ configuration style
|
84
|
-
if ActiveRecord.respond_to?(:primary_key_type) &&
|
85
|
-
|
84
|
+
if ActiveRecord.respond_to?(:primary_key_type) &&
|
85
|
+
ActiveRecord.primary_key_type.to_s == "uuid"
|
86
86
|
return :uuid
|
87
87
|
end
|
88
|
-
|
88
|
+
|
89
89
|
# Check application config
|
90
90
|
app_config = Rails.application.config.generators
|
91
91
|
if app_config.options.key?(:active_record) &&
|
92
|
-
|
93
|
-
|
92
|
+
app_config.options[:active_record].key?(:primary_key_type) &&
|
93
|
+
app_config.options[:active_record][:primary_key_type].to_s == "uuid"
|
94
94
|
return :uuid
|
95
95
|
end
|
96
|
-
|
96
|
+
|
97
97
|
# Default to traditional integer keys
|
98
|
-
|
98
|
+
:bigint
|
99
99
|
end
|
100
100
|
|
101
101
|
def reference_type
|
102
|
-
primary_key_type == :uuid ? :uuid : nil
|
102
|
+
(primary_key_type == :uuid) ? :uuid : nil
|
103
103
|
end
|
104
|
-
end
|
104
|
+
end
|
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.5.
|
4
|
+
version: 0.5.1
|
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-05-
|
11
|
+
date: 2025-05-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|