chrono_forge 0.0.1 → 0.0.2
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/Appraisals +0 -1
- data/gemfiles/rails_7.1.gemfile +1 -1
- data/gemfiles/rails_7.1.gemfile.lock +2 -1
- data/lib/chrono_forge/error_log.rb +36 -0
- data/lib/chrono_forge/execution_log.rb +47 -0
- data/lib/chrono_forge/executor/context.rb +68 -0
- data/lib/chrono_forge/executor/execution_tracker.rb +16 -0
- data/lib/chrono_forge/executor/lock_strategy.rb +44 -0
- data/lib/chrono_forge/executor/methods/durably_execute.rb +75 -0
- data/lib/chrono_forge/executor/methods/wait.rb +47 -0
- data/lib/chrono_forge/executor/methods/wait_until.rb +110 -0
- data/lib/chrono_forge/executor/methods.rb +9 -0
- data/lib/chrono_forge/executor/retry_strategy.rb +29 -0
- data/lib/chrono_forge/executor.rb +140 -0
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +50 -0
- data/lib/chrono_forge.rb +3 -5
- data/lib/generators/chrono_forge/install/USAGE +8 -0
- data/lib/generators/chrono_forge/install/install_generator.rb +24 -0
- data/lib/generators/chrono_forge/install/templates/install_chrono_forge.rb +63 -0
- metadata +31 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1f2655b7f5ea191bd4c80e36b5e6e55439ad04f8570fb29d36ce0f6dc66ce687
|
4
|
+
data.tar.gz: ce5509c4fc6ee82179c6f14be7bf887f41f09ec0f344ef976ba3ac4691305725
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: baca6968fedbded14682da7a4fb68b3d2f5f3da4228671358461c77245920787d0672fd4886ad000693a06bb8e22438d4f311b66dabfdbbd917dfad5d003cea2
|
7
|
+
data.tar.gz: 677a8ef2549511e7e03f18c45d34897ac0ceaaa25ea9a11300ecac711f552c92c074e56ea6453d21a1b58d0ba20aabdd859941bbb1e93eec8cf3b7e9a6c69b34
|
data/Appraisals
CHANGED
data/gemfiles/rails_7.1.gemfile
CHANGED
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# == Schema Information
|
4
|
+
#
|
5
|
+
# Table name: chrono_forge_error_logs
|
6
|
+
#
|
7
|
+
# id :integer not null, primary key
|
8
|
+
# backtrace :text
|
9
|
+
# context :json
|
10
|
+
# error_class :string
|
11
|
+
# error_message :text
|
12
|
+
# created_at :datetime not null
|
13
|
+
# updated_at :datetime not null
|
14
|
+
# workflow_id :integer not null
|
15
|
+
#
|
16
|
+
# Indexes
|
17
|
+
#
|
18
|
+
# index_chrono_forge_error_logs_on_workflow_id (workflow_id)
|
19
|
+
#
|
20
|
+
# Foreign Keys
|
21
|
+
#
|
22
|
+
# workflow_id (workflow_id => chrono_forge_workflows.id)
|
23
|
+
#
|
24
|
+
|
25
|
+
module ChronoForge
|
26
|
+
class ErrorLog < ActiveRecord::Base
|
27
|
+
self.table_name = "chrono_forge_error_logs"
|
28
|
+
|
29
|
+
belongs_to :workflow
|
30
|
+
|
31
|
+
# Cleanup method
|
32
|
+
def self.cleanup_old_logs(retention_period: 30.days)
|
33
|
+
where("created_at < ?", retention_period.ago).delete_all
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# == Schema Information
|
4
|
+
#
|
5
|
+
# Table name: chrono_forge_execution_logs
|
6
|
+
#
|
7
|
+
# id :integer not null, primary key
|
8
|
+
# attempts :integer default(0), not null
|
9
|
+
# completed_at :datetime
|
10
|
+
# error_class :string
|
11
|
+
# error_message :text
|
12
|
+
# last_executed_at :datetime
|
13
|
+
# metadata :json
|
14
|
+
# started_at :datetime
|
15
|
+
# state :integer default("pending"), not null
|
16
|
+
# step_name :string not null
|
17
|
+
# created_at :datetime not null
|
18
|
+
# updated_at :datetime not null
|
19
|
+
# workflow_id :integer not null
|
20
|
+
#
|
21
|
+
# Indexes
|
22
|
+
#
|
23
|
+
# idx_on_workflow_id_step_name_11bea8586e (workflow_id,step_name) UNIQUE
|
24
|
+
# index_chrono_forge_execution_logs_on_workflow_id (workflow_id)
|
25
|
+
#
|
26
|
+
# Foreign Keys
|
27
|
+
#
|
28
|
+
# workflow_id (workflow_id => chrono_forge_workflows.id)
|
29
|
+
#
|
30
|
+
module ChronoForge
|
31
|
+
class ExecutionLog < ActiveRecord::Base
|
32
|
+
self.table_name = "chrono_forge_execution_logs"
|
33
|
+
|
34
|
+
belongs_to :workflow
|
35
|
+
|
36
|
+
enum :state, %i[
|
37
|
+
pending
|
38
|
+
completed
|
39
|
+
failed
|
40
|
+
]
|
41
|
+
|
42
|
+
# Cleanup method
|
43
|
+
def self.cleanup_old_logs(retention_period: 30.days)
|
44
|
+
where("created_at < ?", retention_period.ago).delete_all
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
module ChronoForge
|
2
|
+
module Executor
|
3
|
+
class Context
|
4
|
+
class ValidationError < Error; end
|
5
|
+
|
6
|
+
ALLOWED_TYPES = [
|
7
|
+
String,
|
8
|
+
Integer,
|
9
|
+
Float,
|
10
|
+
TrueClass,
|
11
|
+
FalseClass,
|
12
|
+
NilClass,
|
13
|
+
Hash,
|
14
|
+
Array
|
15
|
+
]
|
16
|
+
|
17
|
+
def initialize(workflow)
|
18
|
+
@workflow = workflow
|
19
|
+
@context = workflow.context || {}
|
20
|
+
@dirty = false
|
21
|
+
end
|
22
|
+
|
23
|
+
def []=(key, value)
|
24
|
+
# Type and size validation
|
25
|
+
validate_value!(value)
|
26
|
+
|
27
|
+
@context[key.to_s] =
|
28
|
+
if value.is_a?(Hash) || value.is_a?(Array)
|
29
|
+
deep_dup(value)
|
30
|
+
else
|
31
|
+
value
|
32
|
+
end
|
33
|
+
|
34
|
+
@dirty = true
|
35
|
+
end
|
36
|
+
|
37
|
+
def [](key)
|
38
|
+
@context[key.to_s]
|
39
|
+
end
|
40
|
+
|
41
|
+
def save!
|
42
|
+
return unless @dirty
|
43
|
+
|
44
|
+
@workflow.update_column(:context, @context)
|
45
|
+
@dirty = false
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def validate_value!(value)
|
51
|
+
unless ALLOWED_TYPES.any? { |type| value.is_a?(type) }
|
52
|
+
raise ValidationError, "Unsupported context value type: #{value.inspect}"
|
53
|
+
end
|
54
|
+
|
55
|
+
# Optional: Add size constraints
|
56
|
+
if value.is_a?(String) && value.size > 64.kilobytes
|
57
|
+
raise ValidationError, "Context value too large"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def deep_dup(obj)
|
62
|
+
JSON.parse(JSON.generate(obj))
|
63
|
+
rescue
|
64
|
+
obj.dup
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module ChronoForge
|
2
|
+
module Executor
|
3
|
+
class ExecutionTracker
|
4
|
+
def self.track_error(workflow, error)
|
5
|
+
# Create a detailed error log
|
6
|
+
ErrorLog.create!(
|
7
|
+
workflow: workflow,
|
8
|
+
error_class: error.class.name,
|
9
|
+
error_message: error.message,
|
10
|
+
backtrace: error.backtrace.join("\n"),
|
11
|
+
context: workflow.context
|
12
|
+
)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module ChronoForge
|
2
|
+
module Executor
|
3
|
+
class LongRunningConcurrentExecutionError < Error; end
|
4
|
+
|
5
|
+
class ConcurrentExecutionError < Error; end
|
6
|
+
|
7
|
+
class LockStrategy
|
8
|
+
def self.acquire_lock(job_id, workflow, max_duration:)
|
9
|
+
ActiveRecord::Base.transaction do
|
10
|
+
# Find the workflow with a lock, considering stale locks
|
11
|
+
workflow = workflow.lock!
|
12
|
+
|
13
|
+
# Check for active execution
|
14
|
+
if workflow.locked_at && workflow.locked_at > max_duration.ago
|
15
|
+
raise ConcurrentExecutionError, "Job currently in progress"
|
16
|
+
end
|
17
|
+
|
18
|
+
# Atomic update of lock status
|
19
|
+
workflow.update_columns(
|
20
|
+
locked_by: job_id,
|
21
|
+
locked_at: Time.current,
|
22
|
+
state: :running
|
23
|
+
)
|
24
|
+
|
25
|
+
workflow
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.release_lock(job_id, workflow)
|
30
|
+
workflow = workflow.reload
|
31
|
+
if workflow.locked_by != job_id
|
32
|
+
raise LongRunningConcurrentExecutionError,
|
33
|
+
"#{self.class}(#{job_id}) executed longer than specified max_duration, " \
|
34
|
+
"allowing another instance(#{workflow.locked_by}) to acquire the lock."
|
35
|
+
end
|
36
|
+
|
37
|
+
columns = {locked_at: nil, locked_by: nil}
|
38
|
+
columns[:state] = :idle if workflow.running?
|
39
|
+
|
40
|
+
workflow.update_columns(columns)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module ChronoForge
|
2
|
+
module Executor
|
3
|
+
module Methods
|
4
|
+
module DurablyExecute
|
5
|
+
def durably_execute(method, **options)
|
6
|
+
# Create execution log
|
7
|
+
step_name = "durably_execute$#{method}"
|
8
|
+
execution_log = ExecutionLog.create_or_find_by!(
|
9
|
+
workflow: @workflow,
|
10
|
+
step_name: step_name
|
11
|
+
) do |log|
|
12
|
+
log.started_at = Time.current
|
13
|
+
end
|
14
|
+
|
15
|
+
# Return if already completed
|
16
|
+
return execution_log.metadata["result"] if execution_log.completed?
|
17
|
+
|
18
|
+
# Execute with error handling
|
19
|
+
begin
|
20
|
+
# Update execution log with attempt
|
21
|
+
execution_log.update!(
|
22
|
+
attempts: execution_log.attempts + 1,
|
23
|
+
last_executed_at: Time.current
|
24
|
+
)
|
25
|
+
|
26
|
+
# Execute the method
|
27
|
+
result = if method.is_a?(Symbol)
|
28
|
+
send(method)
|
29
|
+
else
|
30
|
+
method.call(@context)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Complete the execution
|
34
|
+
execution_log.update!(
|
35
|
+
state: :completed,
|
36
|
+
completed_at: Time.current,
|
37
|
+
metadata: {result: result}
|
38
|
+
)
|
39
|
+
result
|
40
|
+
rescue HaltExecutionFlow
|
41
|
+
raise
|
42
|
+
rescue => e
|
43
|
+
# Log the error
|
44
|
+
Rails.logger.error { "Error while durably executing #{method}: #{e.message}" }
|
45
|
+
self.class::ExecutionTracker.track_error(workflow, e)
|
46
|
+
|
47
|
+
# Optional retry logic
|
48
|
+
if execution_log.attempts < (options[:max_attempts] || 3)
|
49
|
+
# Reschedule with exponential backoff
|
50
|
+
backoff = (2**[execution_log.attempts || 1, 5].min).seconds
|
51
|
+
|
52
|
+
self.class
|
53
|
+
.set(wait: backoff)
|
54
|
+
.perform_later(
|
55
|
+
@workflow.key,
|
56
|
+
retry_method: method
|
57
|
+
)
|
58
|
+
|
59
|
+
# Halt current execution
|
60
|
+
halt_execution!
|
61
|
+
else
|
62
|
+
# Max attempts reached
|
63
|
+
execution_log.update!(
|
64
|
+
state: :failed,
|
65
|
+
error_message: e.message,
|
66
|
+
error_class: e.class.name
|
67
|
+
)
|
68
|
+
raise ExecutionFailedError, "#{step_name} failed after maximum attempts"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module ChronoForge
|
2
|
+
module Executor
|
3
|
+
module Methods
|
4
|
+
module Wait
|
5
|
+
def wait(duration, name, **options)
|
6
|
+
# Create execution log
|
7
|
+
execution_log = ExecutionLog.create_or_find_by!(
|
8
|
+
workflow: @workflow,
|
9
|
+
step_name: "wait$#{name}"
|
10
|
+
) do |log|
|
11
|
+
log.started_at = Time.current
|
12
|
+
log.metadata = {
|
13
|
+
wait_until: duration.from_now
|
14
|
+
}
|
15
|
+
end
|
16
|
+
|
17
|
+
# Return if already completed
|
18
|
+
return if execution_log.completed?
|
19
|
+
|
20
|
+
# Check if wait period has passed
|
21
|
+
if Time.current >= Time.parse(execution_log.metadata["wait_until"])
|
22
|
+
execution_log.update!(
|
23
|
+
attempts: execution_log.attempts + 1,
|
24
|
+
state: :completed,
|
25
|
+
completed_at: Time.current,
|
26
|
+
last_executed_at: Time.current
|
27
|
+
)
|
28
|
+
return
|
29
|
+
end
|
30
|
+
|
31
|
+
execution_log.update!(
|
32
|
+
attempts: execution_log.attempts + 1,
|
33
|
+
last_executed_at: Time.current
|
34
|
+
)
|
35
|
+
|
36
|
+
# Reschedule the job
|
37
|
+
self.class
|
38
|
+
.set(wait: duration)
|
39
|
+
.perform_later(@workflow.key)
|
40
|
+
|
41
|
+
# Halt current execution
|
42
|
+
halt_execution!
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
module ChronoForge
|
2
|
+
module Executor
|
3
|
+
class WaitConditionNotMet < ExecutionFailedError; end
|
4
|
+
|
5
|
+
module Methods
|
6
|
+
module WaitUntil
|
7
|
+
def wait_until(condition, **options)
|
8
|
+
# Default timeout and check interval
|
9
|
+
timeout = options[:timeout] || 10.seconds
|
10
|
+
check_interval = options[:check_interval] || 1.second
|
11
|
+
|
12
|
+
# Find or create execution log
|
13
|
+
step_name = "wait_until$#{condition}"
|
14
|
+
execution_log = ExecutionLog.create_or_find_by!(
|
15
|
+
workflow: @workflow,
|
16
|
+
step_name: step_name
|
17
|
+
) do |log|
|
18
|
+
log.started_at = Time.current
|
19
|
+
log.metadata = {
|
20
|
+
timeout_at: timeout.from_now,
|
21
|
+
check_interval: check_interval,
|
22
|
+
condition: condition.to_s
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
# Return if already completed
|
27
|
+
if execution_log.completed?
|
28
|
+
return execution_log.metadata["result"]
|
29
|
+
end
|
30
|
+
|
31
|
+
# Evaluate condition
|
32
|
+
begin
|
33
|
+
execution_log.update!(
|
34
|
+
attempts: execution_log.attempts + 1,
|
35
|
+
last_executed_at: Time.current
|
36
|
+
)
|
37
|
+
|
38
|
+
condition_met = if condition.is_a?(Proc)
|
39
|
+
condition.call(@context)
|
40
|
+
elsif condition.is_a?(Symbol)
|
41
|
+
send(condition)
|
42
|
+
else
|
43
|
+
raise ArgumentError, "Unsupported condition type"
|
44
|
+
end
|
45
|
+
rescue HaltExecutionFlow
|
46
|
+
raise
|
47
|
+
rescue => e
|
48
|
+
# Log the error
|
49
|
+
Rails.logger.error { "Error evaluating condition #{condition}: #{e.message}" }
|
50
|
+
self.class::ExecutionTracker.track_error(workflow, e)
|
51
|
+
|
52
|
+
# Optional retry logic
|
53
|
+
if (options[:retry_on] || []).include?(e.class)
|
54
|
+
# Reschedule with exponential backoff
|
55
|
+
backoff = (2**[execution_log.attempts || 1, 5].min).seconds
|
56
|
+
|
57
|
+
self.class
|
58
|
+
.set(wait: backoff)
|
59
|
+
.perform_later(
|
60
|
+
@workflow.key
|
61
|
+
)
|
62
|
+
|
63
|
+
# Halt current execution
|
64
|
+
halt_execution!
|
65
|
+
else
|
66
|
+
execution_log.update!(
|
67
|
+
state: :failed,
|
68
|
+
error_message: e.message,
|
69
|
+
error_class: e.class.name
|
70
|
+
)
|
71
|
+
raise ExecutionFailedError, "#{step_name} failed with an error: #{e.message}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Handle condition met
|
76
|
+
if condition_met
|
77
|
+
execution_log.update!(
|
78
|
+
state: :completed,
|
79
|
+
completed_at: Time.current,
|
80
|
+
metadata: execution_log.metadata.merge("result" => true)
|
81
|
+
)
|
82
|
+
return true
|
83
|
+
end
|
84
|
+
|
85
|
+
# Check for timeout
|
86
|
+
metadata = execution_log.metadata
|
87
|
+
if Time.current > metadata["timeout_at"]
|
88
|
+
execution_log.update!(
|
89
|
+
state: :failed,
|
90
|
+
metadata: metadata.merge("result" => nil)
|
91
|
+
)
|
92
|
+
Rails.logger.warn { "Timeout reached for condition #{condition}. Condition not met within the timeout period." }
|
93
|
+
raise WaitConditionNotMet, "Condition not met within timeout period"
|
94
|
+
end
|
95
|
+
|
96
|
+
# Reschedule with delay
|
97
|
+
self.class
|
98
|
+
.set(wait: check_interval)
|
99
|
+
.perform_later(
|
100
|
+
@workflow.key,
|
101
|
+
wait_condition: condition
|
102
|
+
)
|
103
|
+
|
104
|
+
# Halt current execution
|
105
|
+
halt_execution!
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module ChronoForge
|
2
|
+
module Executor
|
3
|
+
class RetryStrategy
|
4
|
+
BACKOFF_STRATEGY = [
|
5
|
+
1.second, # Initial retry
|
6
|
+
5.seconds, # Second retry
|
7
|
+
30.seconds, # Third retry
|
8
|
+
2.minutes, # Fourth retry
|
9
|
+
10.minutes # Final retry
|
10
|
+
]
|
11
|
+
|
12
|
+
def self.schedule_retry(workflow, attempt: 0)
|
13
|
+
wait_duration = BACKOFF_STRATEGY[attempt] || BACKOFF_STRATEGY.last
|
14
|
+
|
15
|
+
# Schedule with exponential backoff
|
16
|
+
workflow.job_klass.constantize
|
17
|
+
.set(wait: wait_duration)
|
18
|
+
.perform_later(
|
19
|
+
workflow.key,
|
20
|
+
attempt: attempt + 1
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.max_attempts
|
25
|
+
BACKOFF_STRATEGY.length
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
module ChronoForge
|
2
|
+
module Executor
|
3
|
+
class Error < StandardError; end
|
4
|
+
|
5
|
+
class ExecutionFailedError < Error; end
|
6
|
+
|
7
|
+
class ExecutionFlowControl < Error; end
|
8
|
+
|
9
|
+
class HaltExecutionFlow < ExecutionFlowControl; end
|
10
|
+
|
11
|
+
include Methods
|
12
|
+
|
13
|
+
def perform(key, attempt: 0, **kwargs)
|
14
|
+
# Prevent excessive retries
|
15
|
+
if attempt >= self.class::RetryStrategy.max_attempts
|
16
|
+
Rails.logger.error { "Max attempts reached for job #{key}" }
|
17
|
+
return
|
18
|
+
end
|
19
|
+
|
20
|
+
# Find or create job with comprehensive tracking
|
21
|
+
setup_workflow(key, kwargs)
|
22
|
+
|
23
|
+
begin
|
24
|
+
# Skip if workflow cannot be executed
|
25
|
+
return unless workflow.executable?
|
26
|
+
|
27
|
+
# Acquire lock with advanced concurrency protection
|
28
|
+
self.class::LockStrategy.acquire_lock(job_id, workflow, max_duration: max_duration)
|
29
|
+
|
30
|
+
# Execute core job logic
|
31
|
+
super(**workflow.kwargs.symbolize_keys)
|
32
|
+
|
33
|
+
# Mark as complete
|
34
|
+
complete_workflow!
|
35
|
+
rescue ExecutionFailedError => e
|
36
|
+
Rails.logger.error { "Execution step failed for #{key}" }
|
37
|
+
self.class::ExecutionTracker.track_error(workflow, e)
|
38
|
+
workflow.stalled!
|
39
|
+
nil
|
40
|
+
rescue HaltExecutionFlow
|
41
|
+
# Halt execution
|
42
|
+
Rails.logger.debug { "Execution halted for #{key}" }
|
43
|
+
nil
|
44
|
+
rescue ConcurrentExecutionError
|
45
|
+
# Graceful handling of concurrent execution
|
46
|
+
Rails.logger.warn { "Concurrent execution detected for job #{key}" }
|
47
|
+
nil
|
48
|
+
rescue => e
|
49
|
+
Rails.logger.error { "An error occurred during execution of #{key}" }
|
50
|
+
self.class::ExecutionTracker.track_error(workflow, e)
|
51
|
+
|
52
|
+
# Retry if applicable
|
53
|
+
if should_retry?(e, attempt)
|
54
|
+
self.class::RetryStrategy.schedule_retry(workflow, attempt: attempt)
|
55
|
+
else
|
56
|
+
workflow.failed!
|
57
|
+
end
|
58
|
+
ensure
|
59
|
+
context.save!
|
60
|
+
# Always release the lock
|
61
|
+
self.class::LockStrategy.release_lock(job_id, workflow)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def complete_workflow!
|
68
|
+
# Create an execution log for workflow completion
|
69
|
+
execution_log = ExecutionLog.create_or_find_by!(
|
70
|
+
workflow: workflow,
|
71
|
+
step_name: "$workflow_completion$"
|
72
|
+
) do |log|
|
73
|
+
log.started_at = Time.current
|
74
|
+
log.metadata = {
|
75
|
+
workflow_id: workflow.id
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
begin
|
80
|
+
execution_log.update!(
|
81
|
+
attempts: execution_log.attempts + 1,
|
82
|
+
last_executed_at: Time.current
|
83
|
+
)
|
84
|
+
|
85
|
+
workflow.completed_at = Time.current
|
86
|
+
workflow.completed!
|
87
|
+
|
88
|
+
# Mark execution log as completed
|
89
|
+
execution_log.update!(
|
90
|
+
state: :completed,
|
91
|
+
completed_at: Time.current
|
92
|
+
)
|
93
|
+
|
94
|
+
# Return the execution log for tracking
|
95
|
+
execution_log
|
96
|
+
rescue => e
|
97
|
+
# Log any completion errors
|
98
|
+
execution_log.update!(
|
99
|
+
state: :failed,
|
100
|
+
error_message: e.message,
|
101
|
+
error_class: e.class.name
|
102
|
+
)
|
103
|
+
raise
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def setup_workflow(key, kwargs)
|
108
|
+
@workflow = find_workflow(key, kwargs)
|
109
|
+
@context = Context.new(@workflow)
|
110
|
+
end
|
111
|
+
|
112
|
+
def find_workflow(key, kwargs)
|
113
|
+
Workflow.create_or_find_by!(key: key) do |workflow|
|
114
|
+
workflow.job_klass = self.class.to_s
|
115
|
+
workflow.kwargs = kwargs
|
116
|
+
workflow.started_at = Time.current
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def should_retry?(error, attempt_count)
|
121
|
+
attempt_count < 3
|
122
|
+
end
|
123
|
+
|
124
|
+
def halt_execution!
|
125
|
+
raise HaltExecutionFlow
|
126
|
+
end
|
127
|
+
|
128
|
+
def workflow
|
129
|
+
@workflow
|
130
|
+
end
|
131
|
+
|
132
|
+
def context
|
133
|
+
@context
|
134
|
+
end
|
135
|
+
|
136
|
+
def max_duration
|
137
|
+
10.minutes
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
data/lib/chrono_forge/version.rb
CHANGED
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# == Schema Information
|
4
|
+
#
|
5
|
+
# Table name: chrono_forge_workflows
|
6
|
+
#
|
7
|
+
# id :integer not null, primary key
|
8
|
+
# completed_at :datetime
|
9
|
+
# context :json not null
|
10
|
+
# job_klass :string not null
|
11
|
+
# key :string not null
|
12
|
+
# kwargs :json not null
|
13
|
+
# locked_at :datetime
|
14
|
+
# started_at :datetime
|
15
|
+
# state :integer default("idle"), not null
|
16
|
+
# created_at :datetime not null
|
17
|
+
# updated_at :datetime not null
|
18
|
+
#
|
19
|
+
# Indexes
|
20
|
+
#
|
21
|
+
# index_chrono_forge_workflows_on_key (key) UNIQUE
|
22
|
+
#
|
23
|
+
module ChronoForge
|
24
|
+
class Workflow < ActiveRecord::Base
|
25
|
+
self.table_name = "chrono_forge_workflows"
|
26
|
+
|
27
|
+
has_many :execution_logs
|
28
|
+
has_many :error_logs
|
29
|
+
|
30
|
+
enum :state, %i[
|
31
|
+
idle
|
32
|
+
running
|
33
|
+
completed
|
34
|
+
failed
|
35
|
+
stalled
|
36
|
+
]
|
37
|
+
|
38
|
+
# Cleanup method
|
39
|
+
def self.cleanup_old_logs(retention_period: 30.days)
|
40
|
+
where("created_at < ?", retention_period.ago).delete_all
|
41
|
+
end
|
42
|
+
|
43
|
+
# Serialization for metadata
|
44
|
+
serialize :metadata, coder: JSON
|
45
|
+
|
46
|
+
def executable?
|
47
|
+
idle? || running?
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/lib/chrono_forge.rb
CHANGED
@@ -1,13 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "chrono_forge/version"
|
4
|
-
|
5
3
|
require "zeitwerk"
|
6
|
-
require "
|
4
|
+
require "active_record"
|
5
|
+
require "active_job"
|
7
6
|
|
8
7
|
module Chronoforge
|
9
|
-
Loader = Zeitwerk::Loader.
|
10
|
-
loader.tag = File.basename(__FILE__, ".rb")
|
8
|
+
Loader = Zeitwerk::Loader.for_gem.tap do |loader|
|
11
9
|
loader.ignore("#{__dir__}/generators")
|
12
10
|
loader.setup
|
13
11
|
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/active_record/migration"
|
4
|
+
|
5
|
+
module ChronoForge
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
7
|
+
include ::ActiveRecord::Generators::Migration
|
8
|
+
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
10
|
+
|
11
|
+
def start
|
12
|
+
install_migrations
|
13
|
+
rescue => err
|
14
|
+
say "#{err.class}: #{err}\n#{err.backtrace.join("\n")}", :red
|
15
|
+
exit 1
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def install_migrations
|
21
|
+
migration_template "install_chrono_forge.rb", "install_chrono_forge.rb"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class InstallChronoForge < ActiveRecord::Migration[7.1]
|
4
|
+
def change
|
5
|
+
create_table :chrono_forge_workflows do |t|
|
6
|
+
t.string :key, null: false, index: {unique: true}
|
7
|
+
t.string :job_klass, null: false
|
8
|
+
|
9
|
+
if t.respond_to?(:jsonb)
|
10
|
+
t.jsonb :kwargs, null: false, default: {}
|
11
|
+
t.jsonb :options, null: false, default: {}
|
12
|
+
t.jsonb :context, null: false, default: {}
|
13
|
+
else
|
14
|
+
t.json :kwargs, null: false, default: {}
|
15
|
+
t.json :options, null: false, default: {}
|
16
|
+
t.json :context, null: false, default: {}
|
17
|
+
end
|
18
|
+
|
19
|
+
t.integer :state, null: false, default: 0
|
20
|
+
t.string :locked_by
|
21
|
+
t.datetime :locked_at
|
22
|
+
|
23
|
+
t.datetime :started_at
|
24
|
+
t.datetime :completed_at
|
25
|
+
|
26
|
+
t.timestamps
|
27
|
+
end
|
28
|
+
|
29
|
+
create_table :chrono_forge_execution_logs do |t|
|
30
|
+
t.references :workflow, null: false, foreign_key: {to_table: :chrono_forge_workflows}
|
31
|
+
t.string :step_name, null: false
|
32
|
+
t.integer :attempts, null: false, default: 0
|
33
|
+
t.datetime :started_at
|
34
|
+
t.datetime :last_executed_at
|
35
|
+
t.datetime :completed_at
|
36
|
+
if t.respond_to?(:jsonb)
|
37
|
+
t.jsonb :metadata
|
38
|
+
else
|
39
|
+
t.json :metadata
|
40
|
+
end
|
41
|
+
t.integer :state, null: false, default: 0
|
42
|
+
t.string :error_class
|
43
|
+
t.text :error_message
|
44
|
+
|
45
|
+
t.timestamps
|
46
|
+
t.index %i[workflow_id step_name], unique: true
|
47
|
+
end
|
48
|
+
|
49
|
+
create_table :chrono_forge_error_logs do |t|
|
50
|
+
t.references :workflow, null: false, foreign_key: {to_table: :chrono_forge_workflows}
|
51
|
+
t.string :error_class
|
52
|
+
t.text :error_message
|
53
|
+
t.text :backtrace
|
54
|
+
if t.respond_to?(:jsonb)
|
55
|
+
t.jsonb :context
|
56
|
+
else
|
57
|
+
t.json :context
|
58
|
+
end
|
59
|
+
|
60
|
+
t.timestamps
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: chrono_forge
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stefan Froelich
|
@@ -11,7 +11,21 @@ cert_chain: []
|
|
11
11
|
date: 2024-12-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activejob
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
16
30
|
requirements:
|
17
31
|
- - ">="
|
@@ -169,7 +183,22 @@ files:
|
|
169
183
|
- gemfiles/rails_7.1.gemfile
|
170
184
|
- gemfiles/rails_7.1.gemfile.lock
|
171
185
|
- lib/chrono_forge.rb
|
186
|
+
- lib/chrono_forge/error_log.rb
|
187
|
+
- lib/chrono_forge/execution_log.rb
|
188
|
+
- lib/chrono_forge/executor.rb
|
189
|
+
- lib/chrono_forge/executor/context.rb
|
190
|
+
- lib/chrono_forge/executor/execution_tracker.rb
|
191
|
+
- lib/chrono_forge/executor/lock_strategy.rb
|
192
|
+
- lib/chrono_forge/executor/methods.rb
|
193
|
+
- lib/chrono_forge/executor/methods/durably_execute.rb
|
194
|
+
- lib/chrono_forge/executor/methods/wait.rb
|
195
|
+
- lib/chrono_forge/executor/methods/wait_until.rb
|
196
|
+
- lib/chrono_forge/executor/retry_strategy.rb
|
172
197
|
- lib/chrono_forge/version.rb
|
198
|
+
- lib/chrono_forge/workflow.rb
|
199
|
+
- lib/generators/chrono_forge/install/USAGE
|
200
|
+
- lib/generators/chrono_forge/install/install_generator.rb
|
201
|
+
- lib/generators/chrono_forge/install/templates/install_chrono_forge.rb
|
173
202
|
- sig/chrono_forge.rbs
|
174
203
|
homepage: https://github.com/radioactive-labs/chrono_forge
|
175
204
|
licenses:
|