chrono_forge 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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:
|