chrono_forge 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b16c027867c5aca8d3f168ccb252125feef563731af33f40a129bfa9bfb781c5
4
- data.tar.gz: 0ebd43e1896ea8df4ceb00c9c9af49c57a1e74d309149e817e43f9acfdfd5b8a
3
+ metadata.gz: cbb73c055f7439bc5e787d68a6d46bbb687143a85a3d57dcade8910aaae93916
4
+ data.tar.gz: 7b40ca8cc17398e695434c3e1ab61c7cfa12dfeb0ac941dd257daa18b633dbed
5
5
  SHA512:
6
- metadata.gz: c94edc3ed7339bf3c7304c54e547bb1f86bc098dda586ffa049cfaf2fc29efcc7b44cce9429789ee1c3c5559dd3dd75859b75e74a27587c2a804160fee600f42
7
- data.tar.gz: f7350954cace2c26ca57c7c0253a81d098a2eb655a5f283e6627eedec9d2b4acc11e0f58d3bebe47cd8a4c82907e1c4410d3655c43695ec8f02b8cc0af6a52c2
6
+ metadata.gz: 0a6db6125a515d250b19ba3bd59db16056973a0d23df3acf76b5341a7990f962694dec5b9732302a0a850844c62a97847640c289bdebcee0f684f691e214049f
7
+ data.tar.gz: 852d241932f2cfc28e48bb6713f79d0c5086dfe8f92b92828f6ef03943250c548cef331de43d208d756c209ea99633f3ab99229391dd8c207fed47b6e4bf31f4
data/README.md CHANGED
@@ -47,17 +47,45 @@ $ rails db:migrate
47
47
 
48
48
  ## 📋 Usage
49
49
 
50
+ ### Creating and Executing Workflows
51
+
52
+ ChronoForge workflows are ActiveJob classes that prepend the `ChronoForge::Executor` module. Each workflow can **only** accept keyword arguments:
53
+
54
+ ```ruby
55
+ # Define your workflow class
56
+ class OrderProcessingWorkflow < ApplicationJob
57
+ prepend ChronoForge::Executor
58
+
59
+ def perform(order_id:, customer_id:)
60
+ # Workflow steps...
61
+ end
62
+ end
63
+ ```
64
+
65
+ All workflows require a unique identifier when executed. This identifier is used to track and manage the workflow:
66
+
67
+ ```ruby
68
+ # Execute the workflow
69
+ OrderProcessingWorkflow.perform_later(
70
+ "order-123", # Unique workflow key
71
+ order_id: "order-134", # Custom kwargs
72
+ customer_id: "customer-456" # More custom kwargs
73
+ )
74
+ ```
75
+
50
76
  ### Basic Workflow Example
51
77
 
52
78
  Here's a complete example of a durable order processing workflow:
53
79
 
54
80
  ```ruby
55
81
  class OrderProcessingWorkflow < ApplicationJob
56
- include ChronoForge::Executor
82
+ prepend ChronoForge::Executor
83
+
84
+ def perform(order_id:)
85
+ @order_id = order_id
57
86
 
58
- def perform
59
87
  # Context can be used to pass and store data between executions
60
- context.set_once "order_id", SecureRandom.hex
88
+ context.set_once "execution_id", SecureRandom.hex
61
89
 
62
90
  # Wait until payment is confirmed
63
91
  wait_until :payment_confirmed?
@@ -75,16 +103,16 @@ class OrderProcessingWorkflow < ApplicationJob
75
103
  private
76
104
 
77
105
  def payment_confirmed?
78
- PaymentService.confirmed?(context["order_id"])
106
+ PaymentService.confirmed?(@order_id, context["execution_id"])
79
107
  end
80
108
 
81
109
  def process_order
82
- OrderProcessor.process(context["order_id"])
110
+ OrderProcessor.process(@order_id, context["execution_id"])
83
111
  context["processed_at"] = Time.current.iso8601
84
112
  end
85
113
 
86
114
  def complete_order
87
- OrderCompletionService.complete(context["order_id"])
115
+ OrderCompletionService.complete(@order_id, context["execution_id"])
88
116
  context["completed_at"] = Time.current.iso8601
89
117
  end
90
118
  end
@@ -92,6 +120,28 @@ end
92
120
 
93
121
  ### Core Workflow Features
94
122
 
123
+ #### 🚀 Executing Workflows
124
+
125
+ ChronoForge workflows are executed through ActiveJob's standard interface with a specific parameter structure:
126
+
127
+ ```ruby
128
+ # Perform the workflow immediately
129
+ OrderProcessingWorkflow.perform_now(
130
+ "order-123", # Unique workflow key
131
+ order_id: "O-123", # Custom parameter
132
+ customer_id: "C-456" # Another custom parameter
133
+ )
134
+
135
+ # Or queue it for background processing
136
+ OrderProcessingWorkflow.perform_later(
137
+ "order-123-async", # Unique workflow key
138
+ order_id: "O-124",
139
+ customer_id: "C-457"
140
+ )
141
+ ```
142
+
143
+ **Important:** Workflows must use keyword arguments only, not positional arguments.
144
+
95
145
  #### ⚡ Durable Execution
96
146
 
97
147
  The `durably_execute` method ensures operations are executed exactly once, even if the job fails and is retried:
@@ -147,13 +197,15 @@ if context.key?(:user_id)
147
197
  end
148
198
  ```
149
199
 
200
+ The context supports serializable Ruby objects (Hash, Array, String, Integer, Float, Boolean, and nil) and validates types automatically.
201
+
150
202
  ### 🛡️ Error Handling
151
203
 
152
204
  ChronoForge automatically tracks errors and provides configurable retry capabilities:
153
205
 
154
206
  ```ruby
155
207
  class MyWorkflow < ApplicationJob
156
- include ChronoForge::Executor
208
+ prepend ChronoForge::Executor
157
209
 
158
210
  private
159
211
 
@@ -198,14 +250,18 @@ class WorkflowTest < ActiveJob::TestCase
198
250
  include ChaoticJob::Helpers
199
251
 
200
252
  def test_workflow_completion
201
- # Enqueue the job
202
- OrderProcessingWorkflow.perform_later("order_123")
253
+ # Enqueue the job with a unique key and custom parameters
254
+ OrderProcessingWorkflow.perform_later(
255
+ "order-test-123",
256
+ order_id: "O-123",
257
+ customer_id: "C-456"
258
+ )
203
259
 
204
260
  # Perform all enqueued jobs
205
261
  perform_all_jobs
206
262
 
207
263
  # Assert workflow completed successfully
208
- workflow = ChronoForge::Workflow.last
264
+ workflow = ChronoForge::Workflow.find_by(key: "order-test-123")
209
265
  assert workflow.completed?
210
266
 
211
267
  # Check workflow context
@@ -232,6 +288,100 @@ ChronoForge is ideal for:
232
288
  - **Multi-step workflows** - Onboarding flows, approval processes, multi-stage jobs
233
289
  - **State machines with time-based transitions** - Document approval, subscription lifecycle
234
290
 
291
+ ## 🧠 Advanced State Management
292
+
293
+ ChronoForge workflows follow a sophisticated state machine model to ensure durability and fault tolerance. Understanding these states and transitions is essential for troubleshooting and recovery.
294
+
295
+ ### Workflow State Diagram
296
+
297
+ ```mermaid
298
+ stateDiagram-v2
299
+ [*] --> created: Workflow Created
300
+ created --> idle: Initial State
301
+ idle --> running: Job Started
302
+ running --> idle: Waiting
303
+ running --> completed: All Steps Completed
304
+ running --> failed: Max Retries Exhausted
305
+ running --> stalled: Unrecoverable Error
306
+ idle --> running: Resumed
307
+ stalled --> [*]: Requires Manual Intervention
308
+ failed --> [*]: Requires Manual Intervention
309
+ completed --> [*]: Workflow Succeeded
310
+ ```
311
+
312
+ ### State Descriptions
313
+
314
+ #### Created
315
+ - **Description**: Initial state when a workflow record is first created
316
+ - **Behavior**: Transitions immediately to idle state
317
+ - **Duration**: Momentary
318
+
319
+ #### Idle
320
+ - **Description**: The workflow is waiting to be processed or between processing steps
321
+ - **Behavior**: Not locked, available to be picked up by job processor
322
+ - **Duration**: Can be minutes to days, depending on wait conditions
323
+
324
+ #### Running
325
+ - **Description**: The workflow is actively being processed
326
+ - **Identifiers**: Has locked_at and locked_by values set
327
+ - **Behavior**: Protected against concurrent execution
328
+ - **Duration**: Should be brief unless performing long operations
329
+
330
+ #### Completed
331
+ - **Description**: The workflow has successfully executed all steps
332
+ - **Identifiers**: Has completed_at timestamp, state = "completed"
333
+ - **Behavior**: Final state, no further processing
334
+ - **Typical Exit Points**: All processing completed successfully
335
+
336
+ #### Failed
337
+ - **Description**: The workflow has failed after exhausting retry attempts
338
+ - **Identifiers**: Has failure-related data in error_logs, state = "failed"
339
+ - **Behavior**: No automatic recovery, requires manual intervention
340
+ - **Typical Exit Points**: Max retries exhausted, explicit failure, non-retryable error
341
+
342
+ #### Stalled
343
+ - **Description**: The workflow encountered an unrecoverable error but wasn't explicitly failed
344
+ - **Identifiers**: Not completed, not running, has errors in error_logs
345
+ - **Behavior**: Requires manual investigation and intervention
346
+ - **Typical Exit Points**: ExecutionFailedError, unexpected exceptions, system failures
347
+
348
+ ### Handling Different Workflow States
349
+
350
+ #### Recovering Stalled/Failed Workflows
351
+
352
+ ```ruby
353
+ workflow = ChronoForge::Workflow.find_by(key: "order-123")
354
+
355
+ if workflow.stalled? || workflow.failed?
356
+ job_class = workflow.job_class.constantize
357
+
358
+ # Retry immediately
359
+ job_class.retry_now(workflow.key)
360
+
361
+ # Or retry asynchronously
362
+ job_class.retry_later(workflow.key)
363
+ end
364
+ ```
365
+
366
+ #### Monitoring Running Workflows
367
+
368
+ Long-running workflows might indicate issues:
369
+
370
+ ```ruby
371
+ # Find workflows running for too long
372
+ long_running = ChronoForge::Workflow.where(state: :running)
373
+ .where('locked_at < ?', 30.minutes.ago)
374
+
375
+ long_running.each do |workflow|
376
+ # Log potential issues for investigation
377
+ Rails.logger.warn "Workflow #{workflow.key} has been running for >30 minutes"
378
+
379
+ # Optionally force unlock if you suspect deadlock
380
+ # CAUTION: Only do this if you're certain the job is stuck
381
+ # workflow.update!(locked_at: nil, locked_by: nil, state: :idle)
382
+ end
383
+ ```
384
+
235
385
  ## 🚀 Development
236
386
 
237
387
  After checking out the repo, run:
data/export.json CHANGED
@@ -5,7 +5,7 @@
5
5
  },
6
6
  {
7
7
  "path": "/Users/stefan/Documents/plutonium/chrono_forge/README.md",
8
- "contents": "# ChronoForge\n\nChronoForge is a Ruby gem that provides a robust framework for building durable, distributed workflows in Ruby on Rails applications. It offers a reliable way to handle long-running processes, state management, and error recovery.\n\n## Features\n\n- **Durable Execution**: Automatically tracks and recovers from failures during workflow execution\n- **State Management**: Built-in workflow state tracking with support for custom contexts\n- **Concurrency Control**: Advanced locking mechanisms to prevent concurrent execution of the same workflow\n- **Error Handling**: Comprehensive error tracking and retry strategies\n- **Execution Logging**: Detailed logging of workflow execution steps and errors\n- **Wait States**: Support for time-based waits and condition-based waiting\n- **Database-Backed**: All workflow state is persisted to the database for durability\n- **ActiveJob Integration**: Seamlessly works with any ActiveJob backend\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'chrono_forge'\n```\n\nThen execute:\n\n```bash\n$ bundle install\n```\n\nOr install it directly:\n\n```bash\n$ gem install chrono_forge\n```\n\nAfter installation, run the generator to create the necessary database migrations:\n\n```bash\n$ rails generate chrono_forge:install\n$ rails db:migrate\n```\n\n## Usage\n\n### Basic Workflow Example\n\nHere's a complete example of a durable order processing workflow:\n\n```ruby\nclass OrderProcessingWorkflow < ApplicationJob\n include ChronoForge::Executor\n\n def perform\n # Context can be used to pass and store data between executions\n context[\"order_id\"] = SecureRandom.hex\n\n # Wait until payment is confirmed\n wait_until :payment_confirmed?\n\n # Wait for potential fraud check\n wait 1.minute, :fraud_check_delay\n\n # Durably execute order processing\n durably_execute :process_order\n\n # Final steps\n complete_order\n end\n\n private\n\n def payment_confirmed?\n PaymentService.confirmed?(context[\"order_id\"])\n end\n\n def process_order\n context[\"processed_at\"] = Time.current.iso8601\n OrderProcessor.process(context[\"order_id\"])\n end\n\n def complete_order\n context[\"completed_at\"] = Time.current.iso8601\n OrderCompletionService.complete(context[\"order_id\"])\n end\nend\n```\n\n### Workflow Features\n\n#### Durable Execution\n\nThe `durably_execute` method ensures operations are executed exactly once:\n\n```ruby\n# Execute a method\ndurably_execute(:process_payment, max_attempts: 3)\n\n# Or with a block\ndurably_execute -> (ctx) {\n Payment.process(ctx[:payment_id])\n}\n```\n\n#### Wait States\n\nChronoForge supports both time-based and condition-based waits:\n\n```ruby\n# Wait for a specific duration\nwait 1.hour, :cooling_period\n\n# Wait until a condition is met\nwait_until :payment_processed, \n timeout: 1.hour,\n check_interval: 5.minutes\n```\n\n#### Workflow Context\n\nChronoForge provides a persistent context that survives job restarts:\n\n```ruby\n# Set context values\ncontext[:user_name] = \"John Doe\"\ncontext[:status] = \"processing\"\n\n# Read context values\nuser_name = context[:user_name]\n```\n\n### Error Handling\n\nChronoForge automatically tracks errors and provides retry capabilities:\n\n```ruby\nclass MyWorkflow < ApplicationJob\n include ChronoForge::Executor\n\n private\n\n def should_retry?(error, attempt_count)\n case error\n when NetworkError\n attempt_count < 5\n when ValidationError\n false # Don't retry validation errors\n else\n attempt_count < 3\n end\n end\nend\n```\n\n## Testing\n\nChronoForge 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. Here's how to set up your test environment:\n\n1. Add ChaoticJob to your Gemfile's test group:\n\n```ruby\ngroup :test do\n gem 'chaotic_job'\nend\n```\n\n2. Set up your test helper:\n\n```ruby\n# test_helper.rb\nrequire 'chrono_forge'\nrequire 'minitest/autorun'\nrequire 'chaotic_job'\n```\n\nExample test:\n\n```ruby\nclass WorkflowTest < ActiveJob::TestCase\n include ChaoticJob::Helpers\n\n def test_workflow_completion\n # Enqueue the job\n OrderProcessingWorkflow.perform_later(\"order_123\")\n \n # Perform all enqueued jobs\n perform_all_jobs\n \n # Assert workflow completed successfully\n workflow = ChronoForge::Workflow.last\n assert workflow.completed?\n \n # Check workflow context\n assert workflow.context[\"processed_at\"].present?\n assert workflow.context[\"completed_at\"].present?\n end\nend\n```\n\nChaoticJob provides several helpful methods for testing workflows:\n\n- `perform_all_jobs`: Processes all enqueued jobs, including those enqueued during job execution\n\nFor 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.\n\n\n## Database Schema\n\nChronoForge creates three main tables:\n\n1. `chrono_forge_workflows`: Stores workflow state and context\n2. `chrono_forge_execution_logs`: Tracks individual execution steps\n3. `chrono_forge_error_logs`: Records detailed error information\n\n## Development\n\nAfter checking out the repo, run:\n\n```bash\n$ bin/setup # Install dependencies\n$ bundle exec rake test # Run the tests\n$ bin/appraise # Run the full suite of appraisals\n$ bin/console # Start an interactive console\n```\n\nThe test suite uses SQLite by default and includes:\n- Unit tests for core functionality\n- Integration tests with ActiveJob\n- Example workflow implementations\n\n## Contributing\n\n1. Fork the repository\n2. Create your feature branch (`git checkout -b feature/my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin feature/my-new-feature`)\n5. Create a new Pull Request\n\nPlease include tests for any new features or bug fixes.\n\n## License\n\nThis gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n"
8
+ "contents": "# ChronoForge\n\n![Version](https://img.shields.io/badge/version-0.3.0-blue.svg)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\n> A robust framework for building durable, distributed workflows in Ruby on Rails applications\n\nChronoForge 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.\n\n## 🌟 Features\n\n- **Durable Execution**: Automatically tracks and recovers from failures during workflow execution\n- **State Management**: Built-in workflow state tracking with persistent context storage\n- **Concurrency Control**: Advanced locking mechanisms to prevent parallel execution of the same workflow\n- **Error Handling**: Comprehensive error tracking with configurable retry strategies\n- **Execution Logging**: Detailed logging of workflow steps and errors for visibility\n- **Wait States**: Support for time-based waits and condition-based waiting\n- **Database-Backed**: All workflow state is persisted to ensure durability\n- **ActiveJob Integration**: Compatible with all ActiveJob backends, though database-backed processors (like Solid Queue) provide the most reliable experience for long-running workflows\n\n## 📦 Installation\n\nAdd to your application's Gemfile:\n\n```ruby\ngem 'chrono_forge'\n```\n\nThen execute:\n\n```bash\n$ bundle install\n```\n\nOr install directly:\n\n```bash\n$ gem install chrono_forge\n```\n\nAfter installation, run the generator to create the necessary database migrations:\n\n```bash\n$ rails generate chrono_forge:install\n$ rails db:migrate\n```\n\n## 📋 Usage\n\n### Creating and Executing Workflows\n\nChronoForge workflows are ActiveJob classes that prepend the `ChronoForge::Executor` module. Each workflow can **only** accept keyword arguments:\n\n```ruby\n# Define your workflow class\nclass OrderProcessingWorkflow < ApplicationJob\n prepend ChronoForge::Executor\n \n def perform(order_id:, customer_id:)\n # Workflow steps...\n end\nend\n```\n\nAll workflows require a unique identifier when executed. This identifier is used to track and manage the workflow:\n\n```ruby\n# Execute the workflow\nOrderProcessingWorkflow.perform_later(\n \"order-123\", # Unique workflow key\n order_id: \"order-134\", # Custom kwargs\n customer_id: \"customer-456\" # More custom kwargs\n)\n```\n\n### Basic Workflow Example\n\nHere's a complete example of a durable order processing workflow:\n\n```ruby\nclass OrderProcessingWorkflow < ApplicationJob\n prepend ChronoForge::Executor\n\n def perform(order_id:)\n @order_id = order_id\n\n # Context can be used to pass and store data between executions\n context.set_once \"execution_id\", SecureRandom.hex\n\n # Wait until payment is confirmed\n wait_until :payment_confirmed?\n\n # Wait for potential fraud check\n wait 1.minute, :fraud_check_delay\n\n # Durably execute order processing\n durably_execute :process_order\n\n # Final steps\n complete_order\n end\n\n private\n\n def payment_confirmed?\n PaymentService.confirmed?(@order_id, context[\"execution_id\"])\n end\n\n def process_order\n OrderProcessor.process(@order_id, context[\"execution_id\"])\n context[\"processed_at\"] = Time.current.iso8601\n end\n\n def complete_order\n OrderCompletionService.complete(@order_id, context[\"execution_id\"])\n context[\"completed_at\"] = Time.current.iso8601\n end\nend\n```\n\n### Core Workflow Features\n\n#### 🚀 Executing Workflows\n\nChronoForge workflows are executed through ActiveJob's standard interface with a specific parameter structure:\n\n```ruby\n# Perform the workflow immediately\nOrderProcessingWorkflow.perform_now(\n \"order-123\", # Unique workflow key\n order_id: \"O-123\", # Custom parameter\n customer_id: \"C-456\" # Another custom parameter\n)\n\n# Or queue it for background processing\nOrderProcessingWorkflow.perform_later(\n \"order-123-async\", # Unique workflow key\n order_id: \"O-124\",\n customer_id: \"C-457\"\n)\n```\n\n**Important:** Workflows must use keyword arguments only, not positional arguments.\n\n#### Durable Execution\n\nThe `durably_execute` method ensures operations are executed exactly once, even if the job fails and is retried:\n\n```ruby\n# Execute a method\ndurably_execute(:process_payment, max_attempts: 3)\n\n# Or with a block\ndurably_execute -> (ctx) {\n Payment.process(ctx[:payment_id])\n}\n```\n\n#### ⏱️ Wait States\n\nChronoForge supports both time-based and condition-based waits:\n\n```ruby\n# Wait for a specific duration\nwait 1.hour, :cooling_period\n\n# Wait until a condition is met\nwait_until :payment_processed, \n timeout: 1.hour,\n check_interval: 5.minutes\n```\n\n#### 🔄 Workflow Context\n\nChronoForge provides a persistent context that survives job restarts. The context behaves like a Hash but with additional capabilities:\n\n```ruby\n# Set context values\ncontext[:user_name] = \"John Doe\"\ncontext[:status] = \"processing\"\n\n# Read context values\nuser_name = context[:user_name]\n\n# Using the fetch method (returns default if key doesn't exist)\nstatus = context.fetch(:status, \"pending\")\n\n# Set a value with the set method (alias for []=)\ncontext.set(:total_amount, 99.99)\n\n# Set a value only if the key doesn't already exist\ncontext.set_once(:created_at, Time.current.iso8601)\n\n# Check if a key exists\nif context.key?(:user_id)\n # Do something with the user ID\nend\n```\n\nThe context supports serializable Ruby objects (Hash, Array, String, Integer, Float, Boolean, and nil) and validates types automatically.\n\n### 🛡️ Error Handling\n\nChronoForge automatically tracks errors and provides configurable retry capabilities:\n\n```ruby\nclass MyWorkflow < ApplicationJob\n prepend ChronoForge::Executor\n\n private\n\n def should_retry?(error, attempt_count)\n case error\n when NetworkError\n attempt_count < 5 # Retry network errors up to 5 times\n when ValidationError\n false # Don't retry validation errors\n else\n attempt_count < 3 # Default retry policy\n end\n end\nend\n```\n\n## 🧪 Testing\n\nChronoForge 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:\n\n1. Add ChaoticJob to your Gemfile's test group:\n\n```ruby\ngroup :test do\n gem 'chaotic_job'\nend\n```\n\n2. Set up your test helper:\n\n```ruby\n# test_helper.rb\nrequire 'chrono_forge'\nrequire 'minitest/autorun'\nrequire 'chaotic_job'\n```\n\nExample test:\n\n```ruby\nclass WorkflowTest < ActiveJob::TestCase\n include ChaoticJob::Helpers\n\n def test_workflow_completion\n # Enqueue the job with a unique key and custom parameters\n OrderProcessingWorkflow.perform_later(\n \"order-test-123\",\n order_id: \"O-123\",\n customer_id: \"C-456\"\n )\n \n # Perform all enqueued jobs\n perform_all_jobs\n \n # Assert workflow completed successfully\n workflow = ChronoForge::Workflow.find_by(key: \"order-test-123\")\n assert workflow.completed?\n \n # Check workflow context\n assert workflow.context[\"processed_at\"].present?\n assert workflow.context[\"completed_at\"].present?\n end\nend\n```\n\n## 🗄️ Database Schema\n\nChronoForge creates three main tables:\n\n1. **chrono_forge_workflows**: Stores workflow state and context\n2. **chrono_forge_execution_logs**: Tracks individual execution steps\n3. **chrono_forge_error_logs**: Records detailed error information\n\n## 🔍 When to Use ChronoForge\n\nChronoForge is ideal for:\n\n- **Long-running business processes** - Order processing, account registration flows\n- **Processes requiring durability** - Financial transactions, data migrations\n- **Multi-step workflows** - Onboarding flows, approval processes, multi-stage jobs\n- **State machines with time-based transitions** - Document approval, subscription lifecycle\n\n## 🚀 Development\n\nAfter checking out the repo, run:\n\n```bash\n$ bin/setup # Install dependencies\n$ bundle exec rake test # Run the tests\n$ bin/appraise # Run the full suite of appraisals\n$ bin/console # Start an interactive console\n```\n\nThe test suite uses SQLite by default and includes:\n- Unit tests for core functionality\n- Integration tests with ActiveJob\n- Example workflow implementations\n\n## 👥 Contributing\n\n1. Fork the repository\n2. Create your feature branch (`git checkout -b feature/my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin feature/my-new-feature`)\n5. Create a new Pull Request\n\nPlease include tests for any new features or bug fixes.\n\n## 📜 License\n\nThis gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n"
9
9
  },
10
10
  {
11
11
  "path": "/Users/stefan/Documents/plutonium/chrono_forge/chrono_forge.gemspec",
@@ -61,11 +61,11 @@
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.2.0\"\nend\n"
64
+ "contents": "# frozen_string_literal: true\n\nmodule ChronoForge\n VERSION = \"0.3.1\"\nend\n"
65
65
  },
66
66
  {
67
67
  "path": "/Users/stefan/Documents/plutonium/chrono_forge/lib/chrono_forge/workflow.rb",
68
- "contents": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: chrono_forge_workflows\n#\n# id :integer not null, primary key\n# completed_at :datetime\n# context :json not null\n# job_class :string not null\n# key :string not null\n# kwargs :json not null\n# options :json not null\n# locked_at :datetime\n# started_at :datetime\n# state :integer default(\"idle\"), not null\n# created_at :datetime not null\n# updated_at :datetime not null\n#\n# Indexes\n#\n# index_chrono_forge_workflows_on_key (key) UNIQUE\n#\nmodule ChronoForge\n class Workflow < ActiveRecord::Base\n self.table_name = \"chrono_forge_workflows\"\n\n has_many :execution_logs, -> { order(id: :asc) }\n has_many :error_logs, -> { order(id: :asc) }\n\n enum :state, %i[\n idle\n running\n completed\n failed\n stalled\n ]\n\n # Serialization for metadata\n serialize :metadata, coder: JSON\n\n def executable?\n idle? || running?\n end\n\n def job_klass\n job_class.constantize\n end\n end\nend\n"
68
+ "contents": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: chrono_forge_workflows\n#\n# id :integer not null, primary key\n# completed_at :datetime\n# context :json not null\n# job_class :string not null\n# key :string not null\n# kwargs :json not null\n# options :json not null\n# locked_at :datetime\n# started_at :datetime\n# state :integer default(\"idle\"), not null\n# created_at :datetime not null\n# updated_at :datetime not null\n#\n# Indexes\n#\n# index_chrono_forge_workflows_on_key (key) UNIQUE\n#\nmodule ChronoForge\n class Workflow < ActiveRecord::Base\n self.table_name = \"chrono_forge_workflows\"\n\n has_many :execution_logs, -> { order(id: :asc) }, dependent: :destroy\n has_many :error_logs, -> { order(id: :asc) }, dependent: :destroy\n\n enum :state, %i[\n idle\n running\n completed\n failed\n stalled\n ]\n\n # Serialization for metadata\n serialize :metadata, coder: JSON\n\n def executable?\n idle? || running?\n end\n\n def job_klass\n job_class.constantize\n end\n end\nend\n"
69
69
  },
70
70
  {
71
71
  "path": "/Users/stefan/Documents/plutonium/chrono_forge/lib/chrono_forge.rb",
@@ -22,20 +22,23 @@ module ChronoForge
22
22
  state: :running
23
23
  )
24
24
 
25
+ Rails.logger.debug { "ChronoForge:#{self.class} job(#{job_id}) acquired lock for workflow(#{workflow.key})" }
26
+
25
27
  workflow
26
28
  end
27
29
  end
28
30
 
29
- def self.release_lock(job_id, workflow)
31
+ def self.release_lock(job_id, workflow, force: false)
30
32
  workflow = workflow.reload
31
- if workflow.locked_by != job_id
33
+ if !force && workflow.locked_by != job_id
32
34
  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
+ "ChronoForge:#{self.class} job(#{job_id}) executed longer than specified max_duration, " \
36
+ "allowed another instance job(#{workflow.locked_by}) to acquire the lock."
35
37
  end
36
38
 
37
39
  columns = {locked_at: nil, locked_by: nil}
38
- columns[:state] = :idle if workflow.running?
40
+ columns[:state] = :idle if force || workflow.running?
41
+
39
42
 
40
43
  workflow.update_columns(columns)
41
44
  end
@@ -35,7 +35,7 @@ module ChronoForge
35
35
  state: :completed,
36
36
  completed_at: Time.current
37
37
  )
38
-
38
+
39
39
  # return nil
40
40
  nil
41
41
  rescue HaltExecutionFlow
@@ -0,0 +1,128 @@
1
+ module ChronoForge
2
+ module Executor
3
+ module Methods
4
+ module WorkflowStates
5
+ private
6
+
7
+ def complete_workflow!
8
+ # Create an execution log for workflow completion
9
+ execution_log = ExecutionLog.create_or_find_by!(
10
+ workflow: workflow,
11
+ step_name: "$workflow_completion$"
12
+ ) do |log|
13
+ log.started_at = Time.current
14
+ end
15
+
16
+ begin
17
+ execution_log.update!(
18
+ attempts: execution_log.attempts + 1,
19
+ last_executed_at: Time.current
20
+ )
21
+
22
+ workflow.completed_at = Time.current
23
+ workflow.completed!
24
+
25
+ # Mark execution log as completed
26
+ execution_log.update!(
27
+ state: :completed,
28
+ completed_at: Time.current
29
+ )
30
+
31
+ # Return the execution log for tracking
32
+ execution_log
33
+ rescue => e
34
+ # Log any errors
35
+ execution_log.update!(
36
+ state: :failed,
37
+ error_message: e.message,
38
+ error_class: e.class.name
39
+ )
40
+ raise
41
+ end
42
+ end
43
+
44
+ def fail_workflow!(error_log)
45
+ # Create an execution log for workflow failure
46
+ execution_log = ExecutionLog.create_or_find_by!(
47
+ workflow: workflow,
48
+ step_name: "$workflow_failure$#{error_log.id}"
49
+ ) do |log|
50
+ log.started_at = Time.current
51
+ log.metadata = {
52
+ error_log_id: error_log.id
53
+ }
54
+ end
55
+
56
+ begin
57
+ execution_log.update!(
58
+ attempts: execution_log.attempts + 1,
59
+ last_executed_at: Time.current
60
+ )
61
+
62
+ workflow.failed!
63
+
64
+ # Mark execution log as completed
65
+ execution_log.update!(
66
+ state: :completed,
67
+ completed_at: Time.current
68
+ )
69
+
70
+ # Return the execution log for tracking
71
+ execution_log
72
+ rescue => e
73
+ # Log any errors
74
+ execution_log.update!(
75
+ state: :failed,
76
+ error_message: e.message,
77
+ error_class: e.class.name
78
+ )
79
+ raise
80
+ end
81
+ end
82
+
83
+ def retry_workflow!
84
+ # Check if the workflow is stalled or failed
85
+ unless workflow.stalled? || workflow.failed?
86
+ raise WorkflowNotRetryableError, "Cannot retry workflow(#{workflow.key}) in #{workflow.state} state. Only stalled or failed workflows can be retried."
87
+ end
88
+
89
+ # Create an execution log for workflow retry
90
+ execution_log = ExecutionLog.create!(
91
+ workflow: workflow,
92
+ step_name: "$workflow_retry$#{Time.current.to_i}",
93
+ started_at: Time.current,
94
+ attempts: 1,
95
+ last_executed_at: Time.current,
96
+ metadata: {
97
+ previous_state: workflow.state,
98
+ requested_at: Time.current,
99
+ job_id: job_id
100
+ }
101
+ )
102
+
103
+ begin
104
+ # Release any existing locks
105
+ self.class::LockStrategy.release_lock(job_id, workflow, force: true)
106
+
107
+ # Mark execution log as completed
108
+ execution_log.update!(
109
+ state: :completed,
110
+ completed_at: Time.current
111
+ )
112
+
113
+ # Return the execution log for tracking
114
+ execution_log
115
+ rescue => e
116
+ # Log any errors
117
+ execution_log.update!(
118
+ state: :failed,
119
+ error_message: e.message,
120
+ error_class: e.class.name
121
+ )
122
+ raise
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -4,6 +4,7 @@ module ChronoForge
4
4
  include Methods::Wait
5
5
  include Methods::WaitUntil
6
6
  include Methods::DurablyExecute
7
+ include Methods::WorkflowStates
7
8
  end
8
9
  end
9
10
  end
@@ -8,24 +8,68 @@ module ChronoForge
8
8
 
9
9
  class HaltExecutionFlow < ExecutionFlowControl; end
10
10
 
11
+ class NotExecutableError < Error; end
12
+
13
+ class WorkflowNotRetryableError < NotExecutableError; end
14
+
11
15
  include Methods
12
16
 
13
- def perform(key, attempt: 0, options: {}, **kwargs)
17
+ # Add class methods
18
+ def self.prepended(base)
19
+ class << base
20
+ # Enforce expected signature for perform_now with key as first arg and keywords after
21
+ def perform_now(key, **kwargs)
22
+ if !key.is_a?(String)
23
+ raise ArgumentError, "Workflow key must be a string as the first argument"
24
+ end
25
+ super(key, **kwargs)
26
+ end
27
+
28
+ # Enforce expected signature for perform_later with key as first arg and keywords after
29
+ def perform_later(key, **kwargs)
30
+ if !key.is_a?(String)
31
+ raise ArgumentError, "Workflow key must be a string as the first argument"
32
+ end
33
+ super(key, **kwargs)
34
+ end
35
+
36
+ # Add retry_now class method that calls perform_now with retry_workflow: true
37
+ def retry_now(key, **kwargs)
38
+ perform_now(key, retry_workflow: true, **kwargs)
39
+ end
40
+
41
+ # Add retry_later class method that calls perform_later with retry_workflow: true
42
+ def retry_later(key, **kwargs)
43
+ perform_later(key, retry_workflow: true, **kwargs)
44
+ end
45
+ end
46
+ end
47
+
48
+ def perform(key, attempt: 0, retry_workflow: false, options: {}, **kwargs)
14
49
  # Prevent excessive retries
15
50
  if attempt >= self.class::RetryStrategy.max_attempts
16
- Rails.logger.error { "Max attempts reached for job #{key}" }
51
+ Rails.logger.error { "ChronoForge:#{self.class} max attempts reached for job workflow(#{key})" }
17
52
  return
18
53
  end
19
54
 
20
55
  # Find or create job with comprehensive tracking
21
56
  setup_workflow(key, options, kwargs)
22
57
 
58
+ # Handle retry parameter - unlock and continue execution
59
+ retry_workflow! if retry_workflow
60
+
61
+ # Track if we acquired the lock
62
+ lock_acquired = false
63
+
23
64
  begin
24
- # Skip if workflow cannot be executed
25
- return unless workflow.executable?
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
26
69
 
27
70
  # Acquire lock with advanced concurrency protection
28
- self.class::LockStrategy.acquire_lock(job_id, workflow, max_duration: max_duration)
71
+ @workflow = self.class::LockStrategy.acquire_lock(job_id, workflow, max_duration: max_duration)
72
+ lock_acquired = true
29
73
 
30
74
  # Execute core job logic
31
75
  super(**workflow.kwargs.symbolize_keys)
@@ -33,20 +77,22 @@ module ChronoForge
33
77
  # Mark as complete
34
78
  complete_workflow!
35
79
  rescue ExecutionFailedError => e
36
- Rails.logger.error { "Execution step failed for #{key}" }
80
+ Rails.logger.error { "ChronoForge:#{self.class} execution step failed for workflow(#{key})" }
37
81
  self.class::ExecutionTracker.track_error(workflow, e)
38
82
  workflow.stalled!
39
83
  nil
40
84
  rescue HaltExecutionFlow
41
85
  # Halt execution
42
- Rails.logger.debug { "Execution halted for #{key}" }
86
+ Rails.logger.debug { "ChronoForge:#{self.class} execution halted for workflow(#{key})" }
43
87
  nil
44
88
  rescue ConcurrentExecutionError
45
89
  # Graceful handling of concurrent execution
46
- Rails.logger.warn { "Concurrent execution detected for job #{key}" }
90
+ Rails.logger.warn { "ChronoForge:#{self.class} concurrent execution detected for job #{key}" }
47
91
  nil
92
+ rescue NotExecutableError
93
+ raise
48
94
  rescue => e
49
- Rails.logger.error { "An error occurred during execution of #{key}" }
95
+ Rails.logger.error { "ChronoForge:#{self.class} an error occurred during execution of workflow(#{key})" }
50
96
  error_log = self.class::ExecutionTracker.track_error(workflow, e)
51
97
 
52
98
  # Retry if applicable
@@ -56,90 +102,16 @@ module ChronoForge
56
102
  fail_workflow! error_log
57
103
  end
58
104
  ensure
59
- context.save!
60
- # Always release the lock
61
- self.class::LockStrategy.release_lock(job_id, workflow)
105
+ # Only release lock if we acquired it
106
+ if lock_acquired
107
+ context.save!
108
+ self.class::LockStrategy.release_lock(job_id, workflow)
109
+ end
62
110
  end
63
111
  end
64
112
 
65
113
  private
66
114
 
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
- end
75
-
76
- begin
77
- execution_log.update!(
78
- attempts: execution_log.attempts + 1,
79
- last_executed_at: Time.current
80
- )
81
-
82
- workflow.completed_at = Time.current
83
- workflow.completed!
84
-
85
- # Mark execution log as completed
86
- execution_log.update!(
87
- state: :completed,
88
- completed_at: Time.current
89
- )
90
-
91
- # Return the execution log for tracking
92
- execution_log
93
- rescue => e
94
- # Log any completion errors
95
- execution_log.update!(
96
- state: :failed,
97
- error_message: e.message,
98
- error_class: e.class.name
99
- )
100
- raise
101
- end
102
- end
103
-
104
- def fail_workflow!(error_log)
105
- # Create an execution log for workflow failure
106
- execution_log = ExecutionLog.create_or_find_by!(
107
- workflow: workflow,
108
- step_name: "$workflow_failure$"
109
- ) do |log|
110
- log.started_at = Time.current
111
- log.metadata = {
112
- error_log_id: error_log.id
113
- }
114
- end
115
-
116
- begin
117
- execution_log.update!(
118
- attempts: execution_log.attempts + 1,
119
- last_executed_at: Time.current
120
- )
121
-
122
- workflow.failed!
123
-
124
- # Mark execution log as completed
125
- execution_log.update!(
126
- state: :completed,
127
- completed_at: Time.current
128
- )
129
-
130
- # Return the execution log for tracking
131
- execution_log
132
- rescue => e
133
- # Log any completion errors
134
- execution_log.update!(
135
- state: :failed,
136
- error_message: e.message,
137
- error_class: e.class.name
138
- )
139
- raise
140
- end
141
- end
142
-
143
115
  def setup_workflow(key, options, kwargs)
144
116
  @workflow = find_workflow(key, options, kwargs)
145
117
  @context = Context.new(@workflow)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChronoForge
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -25,8 +25,8 @@ module ChronoForge
25
25
  class Workflow < ActiveRecord::Base
26
26
  self.table_name = "chrono_forge_workflows"
27
27
 
28
- has_many :execution_logs, -> { order(id: :asc) }
29
- has_many :error_logs, -> { order(id: :asc) }
28
+ has_many :execution_logs, -> { order(id: :asc) }, dependent: :destroy
29
+ has_many :error_logs, -> { order(id: :asc) }, dependent: :destroy
30
30
 
31
31
  enum :state, %i[
32
32
  idle
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.3.0
4
+ version: 0.4.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-28 00:00:00.000000000 Z
11
+ date: 2025-04-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -196,6 +196,7 @@ files:
196
196
  - lib/chrono_forge/executor/methods/durably_execute.rb
197
197
  - lib/chrono_forge/executor/methods/wait.rb
198
198
  - lib/chrono_forge/executor/methods/wait_until.rb
199
+ - lib/chrono_forge/executor/methods/workflow_states.rb
199
200
  - lib/chrono_forge/executor/retry_strategy.rb
200
201
  - lib/chrono_forge/version.rb
201
202
  - lib/chrono_forge/workflow.rb