sidekiq-assured-jobs 1.0.0 → 1.1.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 +35 -0
- data/README.md +67 -0
- data/examples/web_demo.rb +151 -0
- data/lib/sidekiq/assured_jobs/version.rb +1 -1
- data/lib/sidekiq/assured_jobs/web.rb +258 -0
- data/lib/sidekiq-assured-jobs.rb +139 -10
- data/web/assets/orphaned_jobs.css +313 -0
- data/web/views/orphaned_job.erb +296 -0
- data/web/views/orphaned_jobs.erb +301 -0
- metadata +21 -2
data/lib/sidekiq-assured-jobs.rb
CHANGED
@@ -10,12 +10,20 @@ require_relative "sidekiq/assured_jobs/version"
|
|
10
10
|
require_relative "sidekiq/assured_jobs/middleware"
|
11
11
|
require_relative "sidekiq/assured_jobs/worker"
|
12
12
|
|
13
|
+
# Optionally load web extension if Sidekiq::Web is available
|
14
|
+
begin
|
15
|
+
require "sidekiq/web"
|
16
|
+
require_relative "sidekiq/assured_jobs/web"
|
17
|
+
rescue LoadError
|
18
|
+
# Sidekiq::Web not available, skip web extension
|
19
|
+
end
|
20
|
+
|
13
21
|
module Sidekiq
|
14
22
|
module AssuredJobs
|
15
23
|
class Error < StandardError; end
|
16
24
|
|
17
25
|
class << self
|
18
|
-
attr_accessor :instance_id, :namespace, :heartbeat_interval, :heartbeat_ttl, :recovery_lock_ttl, :logger, :redis_options, :delayed_recovery_count, :delayed_recovery_interval
|
26
|
+
attr_accessor :instance_id, :namespace, :heartbeat_interval, :heartbeat_ttl, :recovery_lock_ttl, :logger, :redis_options, :delayed_recovery_count, :delayed_recovery_interval, :auto_recovery_enabled
|
19
27
|
|
20
28
|
def configure
|
21
29
|
yield self if block_given?
|
@@ -123,6 +131,100 @@ module Sidekiq
|
|
123
131
|
logger.error e.backtrace.join("\n")
|
124
132
|
end
|
125
133
|
|
134
|
+
# Web interface support methods
|
135
|
+
def get_orphaned_jobs_info
|
136
|
+
orphaned_jobs = []
|
137
|
+
|
138
|
+
redis_sync do |conn|
|
139
|
+
# Get all job keys and instance keys
|
140
|
+
job_keys = conn.keys(namespaced_key("jobs:*"))
|
141
|
+
instance_keys = conn.keys(namespaced_key("instance:*"))
|
142
|
+
|
143
|
+
# Extract live instance IDs
|
144
|
+
live_instances = instance_keys.map { |key| key.split(":").last }.to_set
|
145
|
+
|
146
|
+
job_keys.each do |job_key|
|
147
|
+
instance_id = job_key.split(":").last
|
148
|
+
unless live_instances.include?(instance_id)
|
149
|
+
# Get all job IDs for this dead instance
|
150
|
+
job_ids = conn.smembers(job_key)
|
151
|
+
|
152
|
+
job_ids.each do |jid|
|
153
|
+
job_data_key = namespaced_key("job:#{jid}")
|
154
|
+
job_payload = conn.get(job_data_key)
|
155
|
+
|
156
|
+
if job_payload
|
157
|
+
job_data = JSON.parse(job_payload)
|
158
|
+
job_data['instance_id'] = instance_id
|
159
|
+
job_data['orphaned_at'] = get_instance_last_heartbeat(instance_id, conn)
|
160
|
+
job_data['orphaned_duration'] = calculate_orphaned_duration(job_data['orphaned_at'])
|
161
|
+
orphaned_jobs << job_data
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
orphaned_jobs.sort_by { |job| job['orphaned_at'] || 0 }.reverse
|
169
|
+
end
|
170
|
+
|
171
|
+
def get_instances_status
|
172
|
+
instances = {}
|
173
|
+
|
174
|
+
redis_sync do |conn|
|
175
|
+
# Get live instances
|
176
|
+
instance_keys = conn.keys(namespaced_key("instance:*"))
|
177
|
+
instance_keys.each do |key|
|
178
|
+
instance_id = key.split(":").last
|
179
|
+
heartbeat = conn.get(key)
|
180
|
+
instances[instance_id] = {
|
181
|
+
status: 'alive',
|
182
|
+
last_heartbeat: heartbeat ? Time.at(heartbeat.to_f) : nil
|
183
|
+
}
|
184
|
+
end
|
185
|
+
|
186
|
+
# Get dead instances with orphaned jobs
|
187
|
+
job_keys = conn.keys(namespaced_key("jobs:*"))
|
188
|
+
job_keys.each do |job_key|
|
189
|
+
instance_id = job_key.split(":").last
|
190
|
+
unless instances[instance_id]
|
191
|
+
instances[instance_id] = {
|
192
|
+
status: 'dead',
|
193
|
+
last_heartbeat: get_instance_last_heartbeat(instance_id, conn),
|
194
|
+
orphaned_job_count: conn.scard(job_key)
|
195
|
+
}
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
instances
|
201
|
+
end
|
202
|
+
|
203
|
+
def get_orphaned_job_by_jid(jid)
|
204
|
+
redis_sync do |conn|
|
205
|
+
job_data_key = namespaced_key("job:#{jid}")
|
206
|
+
job_payload = conn.get(job_data_key)
|
207
|
+
|
208
|
+
if job_payload
|
209
|
+
job_data = JSON.parse(job_payload)
|
210
|
+
|
211
|
+
# Find which instance this job belongs to
|
212
|
+
job_keys = conn.keys(namespaced_key("jobs:*"))
|
213
|
+
job_keys.each do |job_key|
|
214
|
+
if conn.sismember(job_key, jid)
|
215
|
+
instance_id = job_key.split(":").last
|
216
|
+
job_data['instance_id'] = instance_id
|
217
|
+
job_data['orphaned_at'] = get_instance_last_heartbeat(instance_id, conn)
|
218
|
+
job_data['orphaned_duration'] = calculate_orphaned_duration(job_data['orphaned_at'])
|
219
|
+
break
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
job_data
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
126
228
|
def setup_sidekiq_hooks
|
127
229
|
return unless defined?(Sidekiq::VERSION)
|
128
230
|
|
@@ -141,16 +243,20 @@ module Sidekiq
|
|
141
243
|
# Start heartbeat system
|
142
244
|
setup_heartbeat
|
143
245
|
|
144
|
-
# Run orphan recovery on startup only
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
246
|
+
# Run orphan recovery on startup only (if enabled)
|
247
|
+
if auto_recovery_enabled
|
248
|
+
Thread.new do
|
249
|
+
sleep 5 # Give the server a moment to fully start
|
250
|
+
begin
|
251
|
+
reenqueue_orphans!
|
252
|
+
spinup_delayed_recovery_thread
|
253
|
+
rescue => e
|
254
|
+
logger.error "AssuredJobs startup orphan recovery failed: #{e.message}"
|
255
|
+
logger.error e.backtrace.join("\n")
|
256
|
+
end
|
153
257
|
end
|
258
|
+
else
|
259
|
+
logger.info "AssuredJobs auto-recovery is disabled"
|
154
260
|
end
|
155
261
|
end
|
156
262
|
|
@@ -199,6 +305,7 @@ module Sidekiq
|
|
199
305
|
@logger ||= Sidekiq.logger
|
200
306
|
@delayed_recovery_count ||= ENV.fetch("ASSURED_JOBS_DELAYED_RECOVERY_COUNT", "1").to_i
|
201
307
|
@delayed_recovery_interval ||= ENV.fetch("ASSURED_JOBS_DELAYED_RECOVERY_INTERVAL", "300").to_i
|
308
|
+
@auto_recovery_enabled ||= ENV.fetch("ASSURED_JOBS_AUTO_RECOVERY", "true").downcase == "true"
|
202
309
|
end
|
203
310
|
|
204
311
|
def setup_heartbeat
|
@@ -258,6 +365,28 @@ module Sidekiq
|
|
258
365
|
logger.debug "AssuredJobs recovery lock not acquired, another instance is handling recovery"
|
259
366
|
end
|
260
367
|
end
|
368
|
+
|
369
|
+
def get_instance_last_heartbeat(instance_id, conn = nil)
|
370
|
+
operation = proc do |redis_conn|
|
371
|
+
key = namespaced_key("instance:#{instance_id}")
|
372
|
+
heartbeat = redis_conn.get(key)
|
373
|
+
return heartbeat.to_f if heartbeat
|
374
|
+
|
375
|
+
# If no heartbeat found, estimate based on TTL
|
376
|
+
Time.now.to_f - heartbeat_ttl
|
377
|
+
end
|
378
|
+
|
379
|
+
if conn
|
380
|
+
operation.call(conn)
|
381
|
+
else
|
382
|
+
redis_sync(&operation)
|
383
|
+
end
|
384
|
+
end
|
385
|
+
|
386
|
+
def calculate_orphaned_duration(orphaned_at)
|
387
|
+
return nil unless orphaned_at
|
388
|
+
Time.now.to_f - orphaned_at.to_f
|
389
|
+
end
|
261
390
|
end
|
262
391
|
end
|
263
392
|
end
|
@@ -0,0 +1,313 @@
|
|
1
|
+
/* Orphaned Jobs Dashboard Styles */
|
2
|
+
|
3
|
+
/* Ensure consistent styling with Sidekiq's existing theme */
|
4
|
+
.orphaned-jobs-container {
|
5
|
+
background-color: #fff;
|
6
|
+
border-radius: 0.375rem;
|
7
|
+
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
8
|
+
}
|
9
|
+
|
10
|
+
/* Table styling */
|
11
|
+
.table-orphaned-jobs {
|
12
|
+
background-color: white;
|
13
|
+
border-collapse: separate;
|
14
|
+
border-spacing: 0;
|
15
|
+
}
|
16
|
+
|
17
|
+
.table-orphaned-jobs th {
|
18
|
+
background-color: #f8f9fa;
|
19
|
+
border-bottom: 2px solid #dee2e6;
|
20
|
+
font-weight: 600;
|
21
|
+
color: #495057;
|
22
|
+
padding: 0.75rem;
|
23
|
+
vertical-align: middle;
|
24
|
+
}
|
25
|
+
|
26
|
+
.table-orphaned-jobs td {
|
27
|
+
padding: 0.75rem;
|
28
|
+
vertical-align: middle;
|
29
|
+
border-bottom: 1px solid #dee2e6;
|
30
|
+
}
|
31
|
+
|
32
|
+
.table-orphaned-jobs tbody tr:hover {
|
33
|
+
background-color: #f8f9fa;
|
34
|
+
}
|
35
|
+
|
36
|
+
/* Status badges */
|
37
|
+
.status-badge {
|
38
|
+
display: inline-block;
|
39
|
+
padding: 0.25em 0.6em;
|
40
|
+
font-size: 0.75em;
|
41
|
+
font-weight: 700;
|
42
|
+
line-height: 1;
|
43
|
+
text-align: center;
|
44
|
+
white-space: nowrap;
|
45
|
+
vertical-align: baseline;
|
46
|
+
border-radius: 0.375rem;
|
47
|
+
}
|
48
|
+
|
49
|
+
.status-badge.alive {
|
50
|
+
color: #155724;
|
51
|
+
background-color: #d4edda;
|
52
|
+
border: 1px solid #c3e6cb;
|
53
|
+
}
|
54
|
+
|
55
|
+
.status-badge.dead {
|
56
|
+
color: #721c24;
|
57
|
+
background-color: #f8d7da;
|
58
|
+
border: 1px solid #f5c6cb;
|
59
|
+
}
|
60
|
+
|
61
|
+
.status-badge.orphaned {
|
62
|
+
color: #856404;
|
63
|
+
background-color: #fff3cd;
|
64
|
+
border: 1px solid #ffeaa7;
|
65
|
+
}
|
66
|
+
|
67
|
+
/* Action buttons */
|
68
|
+
.action-buttons {
|
69
|
+
display: flex;
|
70
|
+
gap: 0.25rem;
|
71
|
+
align-items: center;
|
72
|
+
}
|
73
|
+
|
74
|
+
.action-buttons .btn {
|
75
|
+
padding: 0.25rem 0.5rem;
|
76
|
+
font-size: 0.75rem;
|
77
|
+
line-height: 1.2;
|
78
|
+
border-radius: 0.25rem;
|
79
|
+
}
|
80
|
+
|
81
|
+
.btn-retry {
|
82
|
+
color: #856404;
|
83
|
+
background-color: #fff3cd;
|
84
|
+
border-color: #ffeaa7;
|
85
|
+
}
|
86
|
+
|
87
|
+
.btn-retry:hover {
|
88
|
+
color: #533f03;
|
89
|
+
background-color: #ffeaa7;
|
90
|
+
border-color: #ffdf7e;
|
91
|
+
}
|
92
|
+
|
93
|
+
.btn-delete {
|
94
|
+
color: #721c24;
|
95
|
+
background-color: #f8d7da;
|
96
|
+
border-color: #f5c6cb;
|
97
|
+
}
|
98
|
+
|
99
|
+
.btn-delete:hover {
|
100
|
+
color: #491217;
|
101
|
+
background-color: #f5c6cb;
|
102
|
+
border-color: #f1b0b7;
|
103
|
+
}
|
104
|
+
|
105
|
+
/* Instance status cards */
|
106
|
+
.instance-card {
|
107
|
+
border: 1px solid #dee2e6;
|
108
|
+
border-radius: 0.375rem;
|
109
|
+
padding: 1rem;
|
110
|
+
margin-bottom: 1rem;
|
111
|
+
background-color: #fff;
|
112
|
+
}
|
113
|
+
|
114
|
+
.instance-card.alive {
|
115
|
+
border-left: 4px solid #28a745;
|
116
|
+
}
|
117
|
+
|
118
|
+
.instance-card.dead {
|
119
|
+
border-left: 4px solid #dc3545;
|
120
|
+
}
|
121
|
+
|
122
|
+
.instance-header {
|
123
|
+
display: flex;
|
124
|
+
align-items: center;
|
125
|
+
justify-content: space-between;
|
126
|
+
margin-bottom: 0.5rem;
|
127
|
+
}
|
128
|
+
|
129
|
+
.instance-id {
|
130
|
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
131
|
+
font-size: 0.875rem;
|
132
|
+
background-color: #f8f9fa;
|
133
|
+
padding: 0.25rem 0.5rem;
|
134
|
+
border-radius: 0.25rem;
|
135
|
+
border: 1px solid #dee2e6;
|
136
|
+
}
|
137
|
+
|
138
|
+
.instance-meta {
|
139
|
+
font-size: 0.875rem;
|
140
|
+
color: #6c757d;
|
141
|
+
}
|
142
|
+
|
143
|
+
/* Bulk action controls */
|
144
|
+
.bulk-actions {
|
145
|
+
background-color: #f8f9fa;
|
146
|
+
border: 1px solid #dee2e6;
|
147
|
+
border-radius: 0.375rem;
|
148
|
+
padding: 1rem;
|
149
|
+
margin-bottom: 1rem;
|
150
|
+
}
|
151
|
+
|
152
|
+
.bulk-actions .form-check {
|
153
|
+
margin-bottom: 0;
|
154
|
+
}
|
155
|
+
|
156
|
+
.bulk-actions .btn-group {
|
157
|
+
margin-left: 1rem;
|
158
|
+
}
|
159
|
+
|
160
|
+
/* Job detail view */
|
161
|
+
.job-detail-container {
|
162
|
+
max-width: 1200px;
|
163
|
+
margin: 0 auto;
|
164
|
+
}
|
165
|
+
|
166
|
+
.job-detail-card {
|
167
|
+
border: 1px solid #dee2e6;
|
168
|
+
border-radius: 0.375rem;
|
169
|
+
margin-bottom: 1.5rem;
|
170
|
+
background-color: #fff;
|
171
|
+
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
172
|
+
}
|
173
|
+
|
174
|
+
.job-detail-header {
|
175
|
+
background-color: #f8f9fa;
|
176
|
+
border-bottom: 1px solid #dee2e6;
|
177
|
+
padding: 1rem 1.25rem;
|
178
|
+
border-radius: 0.375rem 0.375rem 0 0;
|
179
|
+
}
|
180
|
+
|
181
|
+
.job-detail-body {
|
182
|
+
padding: 1.25rem;
|
183
|
+
}
|
184
|
+
|
185
|
+
.job-detail-table {
|
186
|
+
margin-bottom: 0;
|
187
|
+
}
|
188
|
+
|
189
|
+
.job-detail-table th {
|
190
|
+
width: 30%;
|
191
|
+
font-weight: 600;
|
192
|
+
color: #495057;
|
193
|
+
background-color: transparent;
|
194
|
+
border-bottom: 1px solid #dee2e6;
|
195
|
+
padding: 0.5rem 0;
|
196
|
+
}
|
197
|
+
|
198
|
+
.job-detail-table td {
|
199
|
+
border-bottom: 1px solid #dee2e6;
|
200
|
+
padding: 0.5rem 0;
|
201
|
+
}
|
202
|
+
|
203
|
+
/* Code blocks */
|
204
|
+
.code-block {
|
205
|
+
background-color: #f8f9fa;
|
206
|
+
border: 1px solid #e9ecef;
|
207
|
+
border-radius: 0.375rem;
|
208
|
+
padding: 1rem;
|
209
|
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
210
|
+
font-size: 0.875rem;
|
211
|
+
line-height: 1.4;
|
212
|
+
overflow-x: auto;
|
213
|
+
max-height: 400px;
|
214
|
+
overflow-y: auto;
|
215
|
+
}
|
216
|
+
|
217
|
+
/* Error information */
|
218
|
+
.error-card {
|
219
|
+
border-color: #dc3545;
|
220
|
+
}
|
221
|
+
|
222
|
+
.error-card .job-detail-header {
|
223
|
+
background-color: #dc3545;
|
224
|
+
color: #fff;
|
225
|
+
border-bottom-color: #dc3545;
|
226
|
+
}
|
227
|
+
|
228
|
+
/* Responsive adjustments */
|
229
|
+
@media (max-width: 768px) {
|
230
|
+
.action-buttons {
|
231
|
+
flex-direction: column;
|
232
|
+
gap: 0.125rem;
|
233
|
+
}
|
234
|
+
|
235
|
+
.action-buttons .btn {
|
236
|
+
width: 100%;
|
237
|
+
text-align: center;
|
238
|
+
}
|
239
|
+
|
240
|
+
.instance-header {
|
241
|
+
flex-direction: column;
|
242
|
+
align-items: flex-start;
|
243
|
+
gap: 0.5rem;
|
244
|
+
}
|
245
|
+
|
246
|
+
.bulk-actions {
|
247
|
+
text-align: center;
|
248
|
+
}
|
249
|
+
|
250
|
+
.bulk-actions .btn-group {
|
251
|
+
margin-left: 0;
|
252
|
+
margin-top: 0.5rem;
|
253
|
+
}
|
254
|
+
}
|
255
|
+
|
256
|
+
/* Loading states */
|
257
|
+
.loading-spinner {
|
258
|
+
display: inline-block;
|
259
|
+
width: 1rem;
|
260
|
+
height: 1rem;
|
261
|
+
border: 0.125rem solid #f3f3f3;
|
262
|
+
border-top: 0.125rem solid #007bff;
|
263
|
+
border-radius: 50%;
|
264
|
+
animation: spin 1s linear infinite;
|
265
|
+
}
|
266
|
+
|
267
|
+
@keyframes spin {
|
268
|
+
0% { transform: rotate(0deg); }
|
269
|
+
100% { transform: rotate(360deg); }
|
270
|
+
}
|
271
|
+
|
272
|
+
/* Auto-refresh indicator */
|
273
|
+
.auto-refresh-indicator {
|
274
|
+
position: fixed;
|
275
|
+
top: 1rem;
|
276
|
+
right: 1rem;
|
277
|
+
background-color: #28a745;
|
278
|
+
color: #fff;
|
279
|
+
padding: 0.5rem 1rem;
|
280
|
+
border-radius: 0.375rem;
|
281
|
+
font-size: 0.875rem;
|
282
|
+
z-index: 1050;
|
283
|
+
opacity: 0;
|
284
|
+
transition: opacity 0.3s ease;
|
285
|
+
}
|
286
|
+
|
287
|
+
.auto-refresh-indicator.show {
|
288
|
+
opacity: 1;
|
289
|
+
}
|
290
|
+
|
291
|
+
/* Utility classes */
|
292
|
+
.text-truncate-12 {
|
293
|
+
max-width: 12ch;
|
294
|
+
overflow: hidden;
|
295
|
+
text-overflow: ellipsis;
|
296
|
+
white-space: nowrap;
|
297
|
+
}
|
298
|
+
|
299
|
+
.font-monospace {
|
300
|
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
301
|
+
}
|
302
|
+
|
303
|
+
.border-left-success {
|
304
|
+
border-left: 4px solid #28a745;
|
305
|
+
}
|
306
|
+
|
307
|
+
.border-left-danger {
|
308
|
+
border-left: 4px solid #dc3545;
|
309
|
+
}
|
310
|
+
|
311
|
+
.border-left-warning {
|
312
|
+
border-left: 4px solid #ffc107;
|
313
|
+
}
|