script_tracker 0.1.2 → 0.2.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: 18d372a25d4ace7ddcd9d7cd978e4dc328deb3cc9fd24bcc4ea47b3efbf7c2b9
4
- data.tar.gz: 42ad440f174f8e3fafdae807a898545893e631ae174fc3ad99a36737c65e38aa
3
+ metadata.gz: a326d388e66fd43a071121d79a3dec532912917e6fb1f904dcc7092ba4140271
4
+ data.tar.gz: a79e24b9834cbbc9c36c3f3a32b12999ae261a41901b0bacb991ba7ea06af762
5
5
  SHA512:
6
- metadata.gz: dc156bc040a0a421dc912e25163854ffae79ddf520d26e83d095a0f45905ee344882208b5559d6e9459a9d33565c9db3bf4bed54dae23aae61b09fd568eef0f4
7
- data.tar.gz: d42cd031ccd17efe53b9bb69b790b3dc0eddf90aacb368ac7d16f24a620aebee6ba6af4c6430f0f70249fa65e9aa962301423b60139b55ce1a09746421aea182
6
+ metadata.gz: bf6e2f7d6f7ccfc53f7e7e07cbf726305d39c264e7335371a1bfca66774df6839f2b5f7731808b827e6f54eeee276997a69d0ba5aa12b6a3447015e136bc44af
7
+ data.tar.gz: 6aa3f1c42500576c2dfc69b3772151937b258e3490f8a5da2abc77d436bb76611a5ed6344f769cb1ad038649e50220b04d8613b8c4e5da76f694aab9c5b4d8ae
data/CHANGELOG.md CHANGED
@@ -7,12 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2025-01-17
11
+
12
+ ### Added
13
+ - **Database advisory locks** for preventing concurrent script execution (PostgreSQL, MySQL, SQLite)
14
+ - `acquire_lock(filename)` - Manual lock acquisition
15
+ - `release_lock(filename)` - Manual lock release
16
+ - `with_advisory_lock(filename)` - Automatic lock management with block
17
+ - `check_timeout!(start_time, max_duration)` - Safer manual timeout checking
18
+ - RuboCop configuration with comprehensive rules
19
+ - RuboCop integration in CI/CD pipeline
20
+ - 14 new test cases for concurrency and timeout behavior
21
+ - Support for multi-database advisory locking strategies
22
+
23
+ ### Fixed
24
+ - **CRITICAL**: Removed broken pre-execution timeout check that never worked
25
+ - **CRITICAL**: Added concurrency protection to prevent data corruption from simultaneous script runs
26
+ - Fixed `load` usage with proper warning suppression to prevent constant redefinition warnings
27
+ - Fixed nested ternary operators in rake tasks for better readability
28
+ - Fixed line length issues throughout codebase
29
+ - All RuboCop offenses resolved (0 offenses)
30
+
31
+ ### Changed
32
+ - Documented risks of Ruby's `Timeout.timeout` with comprehensive warnings
33
+ - Improved code quality across all files (100% RuboCop compliant)
34
+ - Enhanced test coverage from 55 to 69 examples
35
+ - Improved error handling and logging
36
+ - Better numeric predicates usage (`.positive?` instead of `> 0`)
37
+ - Consistent string literal style (single quotes)
38
+
39
+ ### Security
40
+ - Concurrent execution protection prevents race conditions and data corruption
41
+ - Advisory locks work across PostgreSQL, MySQL, and SQLite
42
+ - Locks automatically released even on exceptions
43
+
44
+ ## [0.1.3] - 2025-01-17
45
+
46
+ ### Changed
47
+ - Simplified and streamlined README documentation
48
+ - Cleaned up CHANGELOG format
49
+
10
50
  ## [0.1.2] - 2025-01-17
11
51
 
12
52
  ### Fixed
13
- - Updated GitHub Actions workflows to use v4 actions (fix deprecated upload-artifact)
53
+ - Updated GitHub Actions workflows to use v4 actions
54
+ - Fixed GitHub release permissions
14
55
  - Refactored workflows to match successful gem release pattern
15
- - Fixed RubyGems authentication configuration
16
56
 
17
57
  ## [0.1.1] - 2025-01-17
18
58
 
@@ -22,29 +62,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
22
62
  - Manual release workflow via GitHub Actions UI
23
63
  - Local release script (`bin/release`)
24
64
 
25
- ### Changed
26
- - Improved workflow documentation in README
27
-
28
65
  ## [0.1.0] - 2025-01-17
29
66
 
30
67
  ### Added
31
- - Initial release of ScriptTracker
68
+ - Initial release
32
69
  - Script execution tracking with status management
33
70
  - Transaction support for script execution
34
71
  - Built-in logging and progress tracking
35
72
  - Batch processing helpers
36
73
  - Timeout support for long-running scripts
37
74
  - Stale script cleanup functionality
38
- - Rake tasks for managing scripts:
39
- - `scripts:create` - Create new scripts
40
- - `scripts:run` - Run pending scripts
41
- - `scripts:status` - View script status
42
- - `scripts:rollback` - Rollback scripts
43
- - `scripts:cleanup` - Cleanup stale scripts
75
+ - Rake tasks for managing scripts
44
76
  - Comprehensive RSpec test suite
45
- - Full documentation and examples
46
77
 
47
- [Unreleased]: https://github.com/a-abdellatif98/script_tracker/compare/v0.1.2...HEAD
78
+ [Unreleased]: https://github.com/a-abdellatif98/script_tracker/compare/v0.2.0...HEAD
79
+ [0.2.0]: https://github.com/a-abdellatif98/script_tracker/compare/v0.1.3...v0.2.0
80
+ [0.1.3]: https://github.com/a-abdellatif98/script_tracker/compare/v0.1.2...v0.1.3
48
81
  [0.1.2]: https://github.com/a-abdellatif98/script_tracker/compare/v0.1.1...v0.1.2
49
82
  [0.1.1]: https://github.com/a-abdellatif98/script_tracker/compare/v0.1.0...v0.1.1
50
83
  [0.1.0]: https://github.com/a-abdellatif98/script_tracker/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,70 +1,35 @@
1
1
  # ScriptTracker
2
2
 
3
- ScriptTracker is a Ruby gem that provides a migration-like system for managing one-off scripts in Rails applications. It tracks script execution history, provides transaction support, and includes built-in logging and progress tracking.
3
+ A Ruby gem that provides a migration-like system for managing one-off scripts in Rails applications with execution tracking, transaction support, and built-in logging.
4
4
 
5
5
  ## Features
6
6
 
7
- * **Execution Tracking**: Automatically tracks which scripts have been run and their status
8
- * **Transaction Support**: Wraps script execution in database transactions
9
- * **Status Management**: Track scripts as success, failed, running, or skipped
10
- * **Built-in Logging**: Convenient logging methods with timestamps
11
- * **Batch Processing**: Helper methods for processing large datasets efficiently
12
- * **Timeout Support**: Configure custom timeouts for long-running scripts
13
- * **Stale Script Cleanup**: Automatically identify and cleanup stuck scripts
14
- * **Migration Generator**: Generate timestamped script files with templates
7
+ * **Execution Tracking** - Automatically tracks which scripts have been run and their status
8
+ * **Transaction Support** - Wraps script execution in database transactions
9
+ * **Status Management** - Track scripts as success, failed, running, or skipped
10
+ * **Built-in Logging** - Convenient logging methods with timestamps
11
+ * **Batch Processing** - Helper methods for processing large datasets efficiently
12
+ * **Timeout Support** - Configure custom timeouts for long-running scripts
15
13
 
16
14
  ## Installation
17
15
 
18
- Add this line to your application's Gemfile:
16
+ Add to your Gemfile:
19
17
 
20
18
  ```ruby
21
19
  gem 'script_tracker'
22
20
  ```
23
21
 
24
- And then execute:
22
+ Then run:
25
23
 
26
24
  ```bash
27
25
  bundle install
28
- ```
29
-
30
- Or install it yourself as:
31
-
32
- ```bash
33
- gem install script_tracker
34
- ```
35
-
36
- ## Setup
37
-
38
- 1. Install the gem using the generator:
39
-
40
- ```bash
41
26
  rails generate script_tracker:install
42
- ```
43
-
44
- This will:
45
- * Create a migration for the `executed_scripts` table
46
- * Create the `lib/scripts` directory for your scripts
47
-
48
- 2. Run the migration:
49
-
50
- ```bash
51
27
  rails db:migrate
52
28
  ```
53
29
 
54
- 3. (Optional) Configure the scripts directory in an initializer:
30
+ ## Quick Start
55
31
 
56
- ```ruby
57
- # config/initializers/script_tracker.rb
58
- ScriptTracker.configure do |config|
59
- config.scripts_path = Rails.root.join('lib', 'scripts')
60
- end
61
- ```
62
-
63
- ## Usage
64
-
65
- ### Creating a New Script
66
-
67
- Generate a new script with a descriptive name:
32
+ ### Create a Script
68
33
 
69
34
  ```bash
70
35
  rake scripts:create["update user preferences"]
@@ -73,136 +38,71 @@ rake scripts:create["update user preferences"]
73
38
  This creates a timestamped script file in `lib/scripts/` :
74
39
 
75
40
  ```ruby
76
- # lib/scripts/20231117120000_update_user_preferences.rb
77
41
  module Scripts
78
42
  class UpdateUserPreferences < ScriptTracker::Base
79
43
  def self.execute
80
- log "Starting script: update user preferences"
81
-
82
- # Your script logic here
44
+ log "Starting script"
45
+
83
46
  User.find_each do |user|
84
47
  user.update!(preferences: { theme: 'dark' })
85
48
  end
86
-
87
- log "Script completed successfully"
49
+
50
+ log "Script completed"
88
51
  end
89
52
  end
90
53
  end
91
54
  ```
92
55
 
93
- ### Running Scripts
94
-
95
- Run all pending (not yet executed) scripts:
96
-
97
- ```bash
98
- rake scripts:run
99
- ```
100
-
101
- ### Checking Script Status
102
-
103
- View the status of all scripts:
56
+ ### Run Scripts
104
57
 
105
58
  ```bash
106
- rake scripts:status
59
+ rake scripts:run # Run all pending scripts
60
+ rake scripts:status # View script status
61
+ rake scripts:rollback[filename] # Rollback a script
62
+ rake scripts:cleanup # Cleanup stale scripts
107
63
  ```
108
64
 
109
- Output:
110
-
111
- ```
112
- Scripts:
113
- [SUCCESS] 20231117120000_update_user_preferences.rb (2.5s)
114
- [PENDING] 20231117130000_cleanup_old_data.rb
115
- ```
116
-
117
- ### Rolling Back a Script
118
-
119
- Remove a script from execution history (allows it to be run again):
120
-
121
- ```bash
122
- rake scripts:rollback[20231117120000_update_user_preferences.rb]
123
- ```
124
-
125
- ### Cleaning Up Stale Scripts
126
-
127
- Mark scripts stuck in "running" status as failed:
128
-
129
- ```bash
130
- rake scripts:cleanup
131
- ```
132
-
133
- ## Advanced Features
134
-
135
- ### Skipping Scripts
65
+ ## Advanced Usage
136
66
 
137
- Skip a script execution conditionally:
67
+ ### Skip Script Conditionally
138
68
 
139
69
  ```ruby
140
- module Scripts
141
- class ConditionalUpdate < ScriptTracker::Base
142
- def self.execute
143
- if User.where(needs_update: true).count.zero?
144
- skip! "No users need updating"
145
- end
146
-
147
- # Your script logic here
148
- end
149
- end
70
+ def self.execute
71
+ skip! "No users need updating" if User.where(needs_update: true).count.zero?
72
+ # Your logic here
150
73
  end
151
74
  ```
152
75
 
153
76
  ### Custom Timeout
154
77
 
155
- Override the default 5-minute timeout:
156
-
157
78
  ```ruby
158
- module Scripts
159
- class LongRunningScript < ScriptTracker::Base
160
- def self.timeout
161
- 3600 # 1 hour in seconds
162
- end
163
-
164
- def self.execute
165
- # Long-running logic here
166
- end
167
- end
79
+ def self.timeout
80
+ 3600 # 1 hour in seconds
168
81
  end
169
82
  ```
170
83
 
171
84
  ### Batch Processing
172
85
 
173
- Process large datasets efficiently:
174
-
175
86
  ```ruby
176
- module Scripts
177
- class ProcessUsers < ScriptTracker::Base
178
- def self.execute
179
- users = User.where(processed: false)
180
-
181
- process_in_batches(users, batch_size: 1000) do |user|
182
- user.update!(processed: true)
183
- end
184
- end
87
+ def self.execute
88
+ users = User.where(processed: false)
89
+ process_in_batches(users, batch_size: 1000) do |user|
90
+ user.update!(processed: true)
185
91
  end
186
92
  end
187
93
  ```
188
94
 
189
95
  ### Progress Logging
190
96
 
191
- Track progress during execution:
192
-
193
97
  ```ruby
194
- module Scripts
195
- class DataMigration < ScriptTracker::Base
196
- def self.execute
197
- total = User.count
198
- processed = 0
199
-
200
- User.find_each do |user|
201
- # Process user
202
- processed += 1
203
- log_progress(processed, total) if (processed % 100).zero?
204
- end
205
- end
98
+ def self.execute
99
+ total = User.count
100
+ processed = 0
101
+
102
+ User.find_each do |user|
103
+ # Process user
104
+ processed += 1
105
+ log_progress(processed, total) if (processed % 100).zero?
206
106
  end
207
107
  end
208
108
  ```
@@ -211,49 +111,22 @@ end
211
111
 
212
112
  ### ScriptTracker:: Base
213
113
 
214
- Base class for all scripts.
215
-
216
114
  **Class Methods:**
217
-
218
- * `execute` - Implement this method with your script logic (required)
219
- * `run` - Execute the script with transaction and error handling
115
+ * `execute` - Implement with your script logic (required)
220
116
  * `timeout` - Override to set custom timeout (default: 300 seconds)
221
- * `skip!(reason)` - Skip script execution with optional reason
222
- * `log(message, level: :info)` - Log a message with timestamp
223
- * `log_progress(current, total, message = nil)` - Log progress percentage
224
- * `process_in_batches(relation, batch_size: 1000, &block)` - Process records in batches
117
+ * `skip!(reason)` - Skip script execution
118
+ * `log(message, level: :info)` - Log a message
119
+ * `log_progress(current, total)` - Log progress percentage
120
+ * `process_in_batches(relation, batch_size: 1000, &block)` - Process in batches
225
121
 
226
122
  ### ScriptTracker:: ExecutedScript
227
123
 
228
- ActiveRecord model for tracking script execution.
229
-
230
- **Scopes:**
231
-
232
- * `successful` - Scripts that completed successfully
233
- * `failed` - Scripts that failed
234
- * `running` - Scripts currently running
235
- * `skipped` - Scripts that were skipped
236
- * `completed` - Scripts that finished (success or failed)
237
- * `ordered` - Order by execution time ascending
238
- * `recent_first` - Order by execution time descending
124
+ **Scopes:** `successful` , `failed` , `running` , `skipped` , `completed` , `ordered` , `recent_first`
239
125
 
240
126
  **Class Methods:**
241
-
242
- * `executed?(filename)` - Check if a script has been executed
243
- * `mark_as_running(filename)` - Mark a script as running
127
+ * `executed?(filename)` - Check if script has been executed
244
128
  * `cleanup_stale_running_scripts(older_than: 1.hour.ago)` - Clean up stale scripts
245
129
 
246
- **Instance Methods:**
247
-
248
- * `mark_success!(output, duration)` - Mark as successful
249
- * `mark_failed!(error, duration)` - Mark as failed
250
- * `mark_skipped!(output, duration)` - Mark as skipped
251
- * `success?`, `failed?`, `running?`, `skipped?` - Status predicates
252
- * `formatted_duration` - Human-readable duration
253
- * `formatted_output` - Truncated output text
254
- * `timeout_seconds` - Get timeout value
255
- * `timed_out?` - Check if script has timed out
256
-
257
130
  ## Rake Tasks
258
131
 
259
132
  * `rake scripts:create[description]` - Create a new script
@@ -262,68 +135,33 @@ ActiveRecord model for tracking script execution.
262
135
  * `rake scripts:rollback[filename]` - Rollback a script
263
136
  * `rake scripts:cleanup` - Cleanup stale running scripts
264
137
 
265
- ## Development
266
-
267
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
268
-
269
- To install this gem onto your local machine, run `bundle exec rake install` .
270
-
271
- ### Releasing a New Version
272
-
273
- #### Option 1: GitHub Actions (Recommended)
138
+ ## Releasing
274
139
 
275
- The easiest way to release is using GitHub Actions:
140
+ ### GitHub Actions (Recommended)
276
141
 
277
- 1. Go to the **Actions** tab in GitHub
278
- 2. Select **Release** workflow
279
- 3. Click **Run workflow**
280
- 4. Enter the version number (e.g., `0.1.1`)
281
- 5. Click **Run workflow**
142
+ 1. Go to **Actions** **Release** workflow
143
+ 2. Click **Run workflow**
144
+ 3. Enter version number (e.g., `0.1.3`)
282
145
 
283
- The workflow will:
284
- * Run all tests across multiple Ruby versions
285
- * Update version and CHANGELOG
286
- * Build the gem
287
- * Create git tag
288
- * Push to git
289
- * Push to RubyGems automatically
290
-
291
- **Required Secrets**: Set `RUBYGEMS_AUTH_TOKEN` in GitHub repository secrets.
292
-
293
- #### Option 2: Local Release Script
294
-
295
- To release locally, use the release script:
146
+ Or push a tag:
296
147
 
297
148
  ```bash
298
- bin/release 0.1.1
149
+ git tag -a v0.1.3 -m "Release version 0.1.3"
150
+ git push origin v0.1.3
299
151
  ```
300
152
 
301
- The release script will:
302
- * Run all tests to ensure everything passes
303
- * Update the version in `lib/script_tracker/version.rb`
304
- * Update `CHANGELOG.md` with the new version
305
- * Build the gem
306
- * Create a git tag
307
- * Push to git (if remote configured)
308
- * Push to RubyGems
309
-
310
- **Note**: The script requires:
311
- * All tests passing
312
- * No uncommitted changes
313
- * RubyGems credentials configured (`gem credentials`)
314
-
315
- #### Option 3: Rake Task
153
+ **Required:** Set `RUBYGEMS_AUTH_TOKEN` in GitHub repository secrets.
316
154
 
317
- Alternatively, you can use the rake task:
155
+ ### Local Release
318
156
 
319
157
  ```bash
320
- bundle exec rake release
158
+ bin/release 0.1.3
321
159
  ```
322
160
 
323
161
  ## Contributing
324
162
 
325
- Bug reports and pull requests are welcome on GitHub at https://github.com/a-abdellatif98/script_tracker.
163
+ Bug reports and pull requests welcome at https://github.com/a-abdellatif98/script_tracker.
326
164
 
327
165
  ## License
328
166
 
329
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
167
+ MIT License - see LICENSE file for details.
@@ -3,6 +3,7 @@
3
3
  module ScriptTracker
4
4
  class Base
5
5
  class ScriptSkipped < StandardError; end
6
+ class ScriptTimeoutError < StandardError; end
6
7
 
7
8
  class << self
8
9
  # Default timeout: 5 minutes
@@ -14,23 +15,21 @@ module ScriptTracker
14
15
  300 # 5 minutes in seconds
15
16
  end
16
17
 
17
- def run(executed_script_record = nil)
18
+ def run(_executed_script_record = nil)
18
19
  require 'timeout'
19
20
  start_time = Time.current
20
21
  timeout_seconds = timeout
21
22
 
22
- # Check timeout before execution if we have a record
23
- if executed_script_record&.timed_out?
24
- duration = Time.current - start_time
25
- error_message = "Script timed out after #{timeout_seconds} seconds"
26
- log(error_message, level: :error)
27
- return { success: false, skipped: false, output: error_message, duration: duration }
28
- end
29
-
30
23
  begin
31
24
  result = nil
32
25
 
33
26
  # Wrap execution in timeout if specified
27
+ # WARNING: Ruby's Timeout.timeout has known issues and can interrupt code at any point,
28
+ # potentially leaving resources in inconsistent states. Consider these alternatives:
29
+ # 1. Implement timeout logic within your script using Time.current checks
30
+ # 2. Use database statement_timeout for PostgreSQL
31
+ # 3. Monitor script execution time and kill from outside if needed
32
+ # This timeout is provided as a last resort safety measure.
34
33
  if timeout_seconds && timeout_seconds > 0
35
34
  Timeout.timeout(timeout_seconds, ScriptTimeoutError) do
36
35
  result = execute_with_transaction
@@ -48,7 +47,7 @@ module ScriptTracker
48
47
  duration = Time.current - start_time
49
48
  output = e.message.presence || 'Script was skipped (no action needed)'
50
49
  { success: false, skipped: true, output: output, duration: duration }
51
- rescue ScriptTimeoutError => e
50
+ rescue ScriptTimeoutError
52
51
  duration = Time.current - start_time
53
52
  error_message = "Script execution exceeded timeout of #{timeout_seconds} seconds"
54
53
  log(error_message, level: :error)
@@ -68,8 +67,6 @@ module ScriptTracker
68
67
  end
69
68
  end
70
69
 
71
- class ScriptTimeoutError < StandardError; end
72
-
73
70
  def execute
74
71
  raise NotImplementedError, 'Subclasses must implement the execute method'
75
72
  end
@@ -88,14 +85,15 @@ module ScriptTracker
88
85
 
89
86
  def log_progress(current, total, message = nil)
90
87
  percentage = ((current.to_f / total) * 100).round(2)
91
- msg = message ? "#{message} (#{current}/#{total} - #{percentage}%)" : "Progress: #{current}/#{total} (#{percentage}%)"
88
+ progress_detail = "(#{current}/#{total} - #{percentage}%)"
89
+ msg = message ? "#{message} #{progress_detail}" : "Progress: #{current}/#{total} (#{percentage}%)"
92
90
  log(msg)
93
91
  end
94
92
 
95
93
  def process_in_batches(relation, batch_size: 1000, &block)
96
94
  total = relation.count
97
95
  log("There are #{total} records to process")
98
- return 0 if total.zero?
96
+ return 0 if total == 0
99
97
 
100
98
  processed = 0
101
99
  log("Processing #{total} records in batches of #{batch_size}")
@@ -103,11 +101,28 @@ module ScriptTracker
103
101
  block.call(record)
104
102
  processed += 1
105
103
  log_interval = [batch_size, (total * 0.1).to_i].max
106
- log_progress(processed, total) if (processed % log_interval).zero?
104
+ log_progress(processed, total) if (processed % log_interval) == 0
107
105
  end
108
106
  log_progress(processed, total, 'Completed')
109
107
  processed
110
108
  end
109
+
110
+ # Check if execution has exceeded timeout (safer alternative to Timeout.timeout)
111
+ # Use this inside your scripts for manual timeout checking:
112
+ #
113
+ # def self.execute
114
+ # start_time = Time.current
115
+ # User.find_each do |user|
116
+ # check_timeout!(start_time, timeout)
117
+ # # Process user...
118
+ # end
119
+ # end
120
+ def check_timeout!(start_time, max_duration = timeout)
121
+ elapsed = Time.current - start_time
122
+ return unless max_duration&.positive? && elapsed > max_duration
123
+
124
+ raise ScriptTimeoutError, "Script execution exceeded #{max_duration} seconds (elapsed: #{elapsed.round(2)}s)"
125
+ end
111
126
  end
112
127
  end
113
128
  end
@@ -6,6 +6,7 @@ module ScriptTracker
6
6
 
7
7
  # Constants
8
8
  DEFAULT_TIMEOUT = 300 # 5 minutes in seconds
9
+ LOCK_KEY_PREFIX = 0x5343525054 # 'SCRPT' in hex for script tracker locks
9
10
 
10
11
  # Validations
11
12
  validates :filename, presence: true, uniqueness: true
@@ -45,6 +46,68 @@ module ScriptTracker
45
46
  count
46
47
  end
47
48
 
49
+ # Advisory lock methods for preventing concurrent execution
50
+ def self.with_advisory_lock(filename)
51
+ lock_acquired = acquire_lock(filename)
52
+ return { success: false, locked: false } unless lock_acquired
53
+
54
+ begin
55
+ yield
56
+ ensure
57
+ release_lock(filename)
58
+ end
59
+ end
60
+
61
+ def self.acquire_lock(filename)
62
+ lock_id = generate_lock_id(filename)
63
+
64
+ case connection.adapter_name.downcase
65
+ when 'postgresql'
66
+ # Use PostgreSQL advisory locks (non-blocking)
67
+ result = connection.execute("SELECT pg_try_advisory_lock(#{lock_id})").first
68
+ [true, 't'].include?(result['pg_try_advisory_lock'])
69
+ when 'mysql', 'mysql2', 'trilogy'
70
+ # Use MySQL named locks (timeout: 0 for non-blocking)
71
+ result = connection.execute("SELECT GET_LOCK('script_tracker_#{lock_id}', 0) AS locked").first
72
+ result['locked'] == 1 || result[0] == 1
73
+ else
74
+ # Fallback: use database record with unique constraint
75
+ # This will raise an exception if script is already running
76
+ begin
77
+ exists?(filename: filename, status: 'running') == false
78
+ rescue ActiveRecord::RecordNotUnique
79
+ false
80
+ end
81
+ end
82
+ rescue StandardError => e
83
+ Rails.logger&.warn("Failed to acquire lock for #{filename}: #{e.message}")
84
+ false
85
+ end
86
+
87
+ def self.release_lock(filename)
88
+ lock_id = generate_lock_id(filename)
89
+
90
+ case connection.adapter_name.downcase
91
+ when 'postgresql'
92
+ connection.execute("SELECT pg_advisory_unlock(#{lock_id})")
93
+ when 'mysql', 'mysql2', 'trilogy'
94
+ connection.execute("SELECT RELEASE_LOCK('script_tracker_#{lock_id}')")
95
+ else
96
+ # No-op for fallback strategy
97
+ true
98
+ end
99
+ rescue StandardError => e
100
+ Rails.logger&.warn("Failed to release lock for #{filename}: #{e.message}")
101
+ false
102
+ end
103
+
104
+ def self.generate_lock_id(filename)
105
+ # Generate a consistent integer ID from filename for advisory locks
106
+ # Using CRC32 to convert string to integer
107
+ require 'zlib'
108
+ (LOCK_KEY_PREFIX << 32) | (Zlib.crc32(filename) & 0xFFFFFFFF)
109
+ end
110
+
48
111
  # Instance methods
49
112
  def mark_success!(output_text = nil, execution_duration = nil)
50
113
  update!(
@@ -10,16 +10,16 @@ module ScriptTracker
10
10
  include Rails::Generators::Migration
11
11
 
12
12
  source_root File.expand_path('templates', __dir__)
13
- desc "Creates ScriptTracker migration file and initializer"
13
+ desc 'Creates ScriptTracker migration file and initializer'
14
14
 
15
15
  class_option :uuid, type: :boolean, default: true,
16
- desc: "Use UUID for primary keys (requires database support)"
16
+ desc: 'Use UUID for primary keys (requires database support)'
17
17
 
18
18
  class_option :skip_migration, type: :boolean, default: false,
19
- desc: "Skip creating the migration file"
19
+ desc: 'Skip creating the migration file'
20
20
 
21
21
  class_option :skip_initializer, type: :boolean, default: false,
22
- desc: "Skip creating the initializer file"
22
+ desc: 'Skip creating the initializer file'
23
23
 
24
24
  def self.next_migration_number(dirname)
25
25
  next_migration_number = current_migration_number(dirname) + 1
@@ -30,8 +30,8 @@ module ScriptTracker
30
30
  return if options[:skip_migration]
31
31
 
32
32
  migration_template(
33
- "create_executed_scripts.rb.erb",
34
- "db/migrate/create_executed_scripts.rb",
33
+ 'create_executed_scripts.rb.erb',
34
+ 'db/migrate/create_executed_scripts.rb',
35
35
  migration_version: migration_version
36
36
  )
37
37
  end
@@ -39,16 +39,16 @@ module ScriptTracker
39
39
  def create_initializer
40
40
  return if options[:skip_initializer]
41
41
 
42
- template "initializer.rb", "config/initializers/script_tracker.rb"
42
+ template 'initializer.rb', 'config/initializers/script_tracker.rb'
43
43
  end
44
44
 
45
45
  def create_scripts_directory
46
- empty_directory "lib/scripts"
47
- create_file "lib/scripts/.keep"
46
+ empty_directory 'lib/scripts'
47
+ create_file 'lib/scripts/.keep'
48
48
  end
49
49
 
50
50
  def show_readme
51
- readme "README" if behavior == :invoke
51
+ readme 'README' if behavior == :invoke
52
52
  end
53
53
 
54
54
  private
@@ -62,7 +62,7 @@ module ScriptTracker
62
62
  end
63
63
 
64
64
  def primary_key_type
65
- use_uuid? ? ":uuid" : "true"
65
+ use_uuid? ? ':uuid' : 'true'
66
66
  end
67
67
  end
68
68
  end
@@ -9,7 +9,7 @@ module ScriptTracker
9
9
  Dir.glob("#{path}/../../tasks/**/*.rake").each { |f| load f }
10
10
  end
11
11
 
12
- initializer "script_tracker.configure" do |app|
12
+ initializer 'script_tracker.configure' do |app|
13
13
  app.config.script_tracker = ActiveSupport::OrderedOptions.new
14
14
  app.config.script_tracker.scripts_path = app.root.join('lib', 'scripts')
15
15
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ScriptTracker
4
- VERSION = "0.1.2"
4
+ VERSION = '0.2.0'
5
5
  end
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "script_tracker/version"
4
- require "script_tracker/base"
5
- require "script_tracker/executed_script"
6
- require "script_tracker/railtie" if defined?(Rails::Railtie)
3
+ require 'script_tracker/version'
4
+ require 'script_tracker/base'
5
+ require 'script_tracker/executed_script'
6
+ require 'script_tracker/railtie' if defined?(Rails::Railtie)
7
7
 
8
8
  # Load generators for Rails
9
9
  if defined?(Rails)
10
- require "rails/generators"
11
- require_relative "script_tracker/generators/install_generator"
10
+ require 'rails/generators'
11
+ require_relative 'script_tracker/generators/install_generator'
12
12
  end
13
13
 
14
14
  module ScriptTracker
@@ -17,14 +17,15 @@ namespace :scripts do
17
17
  scripts_dir = ScriptTracker.scripts_path
18
18
  file_path = scripts_dir.join(filename)
19
19
 
20
- FileUtils.mkdir_p(scripts_dir) unless Dir.exist?(scripts_dir)
20
+ FileUtils.mkdir_p(scripts_dir)
21
21
 
22
22
  template = File.read(File.expand_path('../templates/script_template.rb', __dir__))
23
+ timestamp_str = Time.current.strftime('%Y-%m-%d %H:%M:%S')
23
24
  content = template.gsub('<%= filename %>', filename)
24
25
  .gsub('<%= description %>', description)
25
26
  .gsub('<%= class_name %>', class_name)
26
27
  .gsub('<%= Time.current.year %>', Time.current.year.to_s)
27
- .gsub('<%= Time.current.strftime(\'%Y-%m-%d %H:%M:%S\') %>', Time.current.strftime('%Y-%m-%d %H:%M:%S'))
28
+ .gsub('<%= Time.current.strftime(\'%Y-%m-%d %H:%M:%S\') %>', timestamp_str)
28
29
 
29
30
  File.write(file_path, content)
30
31
 
@@ -43,7 +44,7 @@ namespace :scripts do
43
44
 
44
45
  unless Dir.exist?(scripts_dir)
45
46
  puts "Error: Scripts directory does not exist: #{scripts_dir}"
46
- puts "Please run: rails generate script_tracker:install"
47
+ puts 'Please run: rails generate script_tracker:install'
47
48
  exit 1
48
49
  end
49
50
 
@@ -63,9 +64,17 @@ namespace :scripts do
63
64
  script_name = File.basename(script_path)
64
65
  puts "[#{index + 1}/#{pending_scripts.count}] Running #{script_name}..."
65
66
 
66
- begin
67
- # Load script file
67
+ # Try to acquire lock for this script
68
+ lock_result = ScriptTracker::ExecutedScript.with_advisory_lock(script_name) do
69
+ # Load script file using `load` (not `require`) because:
70
+ # 1. Scripts are one-off files that should be loaded fresh each time
71
+ # 2. Allows script modifications to be picked up without restart
72
+ # 3. Scripts are not part of Rails autoload paths
73
+ # Suppress warnings about already initialized constants
74
+ original_verbosity = $VERBOSE
75
+ $VERBOSE = nil
68
76
  load script_path
77
+ $VERBOSE = original_verbosity
69
78
 
70
79
  # Extract and validate class name
71
80
  class_name = script_name.gsub(/^\d+_/, '').gsub('.rb', '').camelize
@@ -128,12 +137,22 @@ namespace :scripts do
128
137
  puts "Error: Syntax error in script #{script_name}: #{e.message}"
129
138
  failed_count += 1
130
139
  rescue StandardError => e
131
- duration = Time.current - start_time rescue 0
140
+ duration = begin
141
+ (Time.current - start_time)
142
+ rescue StandardError
143
+ 0
144
+ end
132
145
  error_message = "#{e.class}: #{e.message}"
133
146
  executed_script&.mark_failed!(error_message, duration)
134
147
  puts "Error: #{e.message}\n\n"
135
148
  failed_count += 1
136
149
  end
150
+
151
+ # Check if lock was not acquired
152
+ if lock_result == { success: false, locked: false }
153
+ puts "Skipped: Another process is already running #{script_name}\n\n"
154
+ skipped_count += 1
155
+ end
137
156
  end
138
157
 
139
158
  puts "Summary: #{success_count} succeeded, #{failed_count} failed, #{skipped_count} skipped"
@@ -147,7 +166,7 @@ namespace :scripts do
147
166
 
148
167
  unless Dir.exist?(scripts_dir)
149
168
  puts "Error: Scripts directory does not exist: #{scripts_dir}"
150
- puts "Please run: rails generate script_tracker:install"
169
+ puts 'Please run: rails generate script_tracker:install'
151
170
  exit 1
152
171
  end
153
172
 
@@ -156,7 +175,7 @@ namespace :scripts do
156
175
 
157
176
  if script_files.empty?
158
177
  puts "\nNo scripts found in #{scripts_dir}"
159
- puts "Create a script with: rake scripts:create[\"description\"]"
178
+ puts 'Create a script with: rake scripts:create["description"]'
160
179
  exit 0
161
180
  end
162
181
 
@@ -164,7 +183,13 @@ namespace :scripts do
164
183
  script_files.each do |file|
165
184
  filename = File.basename(file)
166
185
  if (script = executed_scripts[filename])
167
- status_icon = script.success? ? '[SUCCESS]' : script.failed? ? '[FAILED]' : script.skipped? ? '[SKIPPED]' : '[RUNNING]'
186
+ status_icon = if script.success?
187
+ '[SUCCESS]'
188
+ elsif script.failed?
189
+ '[FAILED]'
190
+ else
191
+ script.skipped? ? '[SKIPPED]' : '[RUNNING]'
192
+ end
168
193
  puts " #{status_icon} #{filename} (#{script.formatted_duration})"
169
194
  else
170
195
  puts " [PENDING] #{filename}"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: script_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ahmed Abd El-Latif
@@ -124,6 +124,7 @@ metadata:
124
124
  homepage_uri: https://github.com/a-abdellatif98/script_tracker
125
125
  source_code_uri: https://github.com/a-abdellatif98/script_tracker
126
126
  changelog_uri: https://github.com/a-abdellatif98/script_tracker/blob/main/CHANGELOG.md
127
+ rubygems_mfa_required: 'true'
127
128
  post_install_message:
128
129
  rdoc_options: []
129
130
  require_paths: