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 +4 -4
- data/CHANGELOG.md +47 -14
- data/README.md +59 -221
- data/lib/script_tracker/base.rb +30 -15
- data/lib/script_tracker/executed_script.rb +63 -0
- data/lib/script_tracker/generators/install_generator.rb +11 -11
- data/lib/script_tracker/railtie.rb +1 -1
- data/lib/script_tracker/version.rb +1 -1
- data/lib/script_tracker.rb +6 -6
- data/tasks/script_tracker.rake +34 -9
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a326d388e66fd43a071121d79a3dec532912917e6fb1f904dcc7092ba4140271
|
|
4
|
+
data.tar.gz: a79e24b9834cbbc9c36c3f3a32b12999ae261a41901b0bacb991ba7ea06af762
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
8
|
-
* **Transaction Support
|
|
9
|
-
* **Status Management
|
|
10
|
-
* **Built-in Logging
|
|
11
|
-
* **Batch Processing
|
|
12
|
-
* **Timeout Support
|
|
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
|
|
16
|
+
Add to your Gemfile:
|
|
19
17
|
|
|
20
18
|
```ruby
|
|
21
19
|
gem 'script_tracker'
|
|
22
20
|
```
|
|
23
21
|
|
|
24
|
-
|
|
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
|
-
|
|
30
|
+
## Quick Start
|
|
55
31
|
|
|
56
|
-
|
|
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
|
|
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
|
|
49
|
+
|
|
50
|
+
log "Script completed"
|
|
88
51
|
end
|
|
89
52
|
end
|
|
90
53
|
end
|
|
91
54
|
```
|
|
92
55
|
|
|
93
|
-
###
|
|
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:
|
|
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
|
-
|
|
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
|
|
67
|
+
### Skip Script Conditionally
|
|
138
68
|
|
|
139
69
|
```ruby
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
222
|
-
* `log(message, level: :info)` - Log a message
|
|
223
|
-
* `log_progress(current, total
|
|
224
|
-
* `process_in_batches(relation, batch_size: 1000, &block)` - Process
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
140
|
+
### GitHub Actions (Recommended)
|
|
276
141
|
|
|
277
|
-
1. Go to
|
|
278
|
-
2.
|
|
279
|
-
3.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
+
### Local Release
|
|
318
156
|
|
|
319
157
|
```bash
|
|
320
|
-
|
|
158
|
+
bin/release 0.1.3
|
|
321
159
|
```
|
|
322
160
|
|
|
323
161
|
## Contributing
|
|
324
162
|
|
|
325
|
-
Bug reports and pull requests
|
|
163
|
+
Bug reports and pull requests welcome at https://github.com/a-abdellatif98/script_tracker.
|
|
326
164
|
|
|
327
165
|
## License
|
|
328
166
|
|
|
329
|
-
|
|
167
|
+
MIT License - see LICENSE file for details.
|
data/lib/script_tracker/base.rb
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
13
|
+
desc 'Creates ScriptTracker migration file and initializer'
|
|
14
14
|
|
|
15
15
|
class_option :uuid, type: :boolean, default: true,
|
|
16
|
-
|
|
16
|
+
desc: 'Use UUID for primary keys (requires database support)'
|
|
17
17
|
|
|
18
18
|
class_option :skip_migration, type: :boolean, default: false,
|
|
19
|
-
|
|
19
|
+
desc: 'Skip creating the migration file'
|
|
20
20
|
|
|
21
21
|
class_option :skip_initializer, type: :boolean, default: false,
|
|
22
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
42
|
+
template 'initializer.rb', 'config/initializers/script_tracker.rb'
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def create_scripts_directory
|
|
46
|
-
empty_directory
|
|
47
|
-
create_file
|
|
46
|
+
empty_directory 'lib/scripts'
|
|
47
|
+
create_file 'lib/scripts/.keep'
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def show_readme
|
|
51
|
-
readme
|
|
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? ?
|
|
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
|
|
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
|
data/lib/script_tracker.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
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
|
|
11
|
-
require_relative
|
|
10
|
+
require 'rails/generators'
|
|
11
|
+
require_relative 'script_tracker/generators/install_generator'
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
module ScriptTracker
|
data/tasks/script_tracker.rake
CHANGED
|
@@ -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)
|
|
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\') %>',
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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?
|
|
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.
|
|
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:
|