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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9da8de8f1664f4234f1a87cd69b1f32a2ccf96ce8501be4ef0e5f9585ceb7417
4
- data.tar.gz: afe72783ec386e3aa9b5f3b79e468c83eb5bc2dd144634f797a882760a4f6eac
3
+ metadata.gz: 887025fd6483bb1d532ec4c78344a9cb716b40ed8aa23dc6e18ab4356e852d75
4
+ data.tar.gz: b6fbedf50eb2372424ca8bd7d4acf5471edb1ea676a1118180422df1e1306a27
5
5
  SHA512:
6
- metadata.gz: b24ef7eb8e7b2c2a07a368efde14bdfb54c4e846b1da9681013d23f3159693497f42d373aea3e85b6db340e737a03d907e8d745e4a3424843ecad7c8daa6e4b6
7
- data.tar.gz: 742063a706c815f8f9a568fe2ee40f774d1510c3d46f2cdc90dd5f83304b8feeeb937c363d9f07ae19f2d8fbb75dcc33db57e9eecedc714f0a9f6dba19113da0
6
+ metadata.gz: eb618c3c16fbee88a5398a09ccc9a0859c9d7d6252285ed56cb37562977bff1042d68517313c305193c18ff2170055843c6a126c6f481051234cc12aba30b359
7
+ data.tar.gz: 5eda3316fcc204a40f6d5fa1654623e16c0c1cc9ddbbe3f1fb9dca5dcf06556df5195cefa97f5f13271214c4d9dde42c02c41513348eb06176a3fae7d5bbc61b
data/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # ChronoForge
2
2
 
3
- ![Version](https://img.shields.io/badge/version-0.3.0-blue.svg)
3
+ ![Version](https://img.shields.io/badge/version-0.5.1-blue.svg)
4
+ [![Ruby](https://github.com/radioactive-labs/chrono_forge/actions/workflows/main.yml/badge.svg)](https://github.com/radioactive-labs/chrono_forge/actions/workflows/main.yml)
4
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- 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"
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
- # 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
- 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
- workflow
28
- end
29
- end
44
+ columns = {locked_at: nil, locked_by: nil}
45
+ columns[:state] = :idle if force || workflow.running?
30
46
 
31
- def self.release_lock(job_id, workflow, force: false)
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
- columns = {locked_at: nil, locked_by: nil}
40
- columns[:state] = :idle if force || workflow.running?
41
-
50
+ private
42
51
 
43
- workflow.update_columns(columns)
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
@@ -3,7 +3,7 @@ module ChronoForge
3
3
  module Methods
4
4
  module WorkflowStates
5
5
  private
6
-
6
+
7
7
  def complete_workflow!
8
8
  # Create an execution log for workflow completion
9
9
  execution_log = ExecutionLog.create_or_find_by!(
@@ -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(key, **kwargs)
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(key, **kwargs)
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 job with comprehensive tracking
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} execution step failed for workflow(#{key})" }
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} execution halted for workflow(#{key})" }
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 for job #{key}" }
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} an error occurred during execution of workflow(#{key})" }
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 = find_workflow(key, options, kwargs)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChronoForge
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
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() = defined?(::ApplicationRecord) ? ::ApplicationRecord : ActiveRecord::Base
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
- foreign_key: {to_table: :chrono_forge_workflows},
33
- type: reference_type
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
- foreign_key: {to_table: :chrono_forge_workflows},
58
- type: reference_type
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
- ActiveRecord.default_id.to_s.include?('uuid')
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
- ActiveRecord.primary_key_type.to_s == 'uuid'
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
- app_config.options[:active_record].key?(:primary_key_type) &&
93
- app_config.options[:active_record][:primary_key_type].to_s == 'uuid'
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
- return :bigint
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.0
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-01 00:00:00.000000000 Z
11
+ date: 2025-05-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord