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.
@@ -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
- Thread.new do
146
- sleep 5 # Give the server a moment to fully start
147
- begin
148
- reenqueue_orphans!
149
- spinup_delayed_recovery_thread
150
- rescue => e
151
- logger.error "AssuredJobs startup orphan recovery failed: #{e.message}"
152
- logger.error e.backtrace.join("\n")
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
+ }