rails_console_pro 0.1.2 → 0.1.3

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec_status +261 -240
  3. data/CHANGELOG.md +4 -0
  4. data/QUICK_START.md +8 -0
  5. data/README.md +16 -0
  6. data/docs/FORMATTING.md +5 -0
  7. data/docs/MODEL_STATISTICS.md +4 -0
  8. data/docs/OBJECT_DIFFING.md +6 -0
  9. data/docs/PROFILING.md +91 -0
  10. data/docs/QUEUE_INSIGHTS.md +82 -0
  11. data/docs/SCHEMA_INSPECTION.md +5 -0
  12. data/docs/SNIPPETS.md +71 -0
  13. data/lib/rails_console_pro/commands/jobs_command.rb +212 -0
  14. data/lib/rails_console_pro/commands/profile_command.rb +84 -0
  15. data/lib/rails_console_pro/commands/snippets_command.rb +141 -0
  16. data/lib/rails_console_pro/commands.rb +15 -0
  17. data/lib/rails_console_pro/configuration.rb +39 -0
  18. data/lib/rails_console_pro/format_exporter.rb +8 -0
  19. data/lib/rails_console_pro/global_methods.rb +12 -0
  20. data/lib/rails_console_pro/initializer.rb +23 -0
  21. data/lib/rails_console_pro/model_validator.rb +1 -1
  22. data/lib/rails_console_pro/printers/profile_printer.rb +180 -0
  23. data/lib/rails_console_pro/printers/queue_insights_printer.rb +150 -0
  24. data/lib/rails_console_pro/printers/snippet_collection_printer.rb +68 -0
  25. data/lib/rails_console_pro/printers/snippet_printer.rb +64 -0
  26. data/lib/rails_console_pro/profile_result.rb +109 -0
  27. data/lib/rails_console_pro/pry_commands.rb +106 -0
  28. data/lib/rails_console_pro/queue_insights_result.rb +110 -0
  29. data/lib/rails_console_pro/serializers/profile_serializer.rb +73 -0
  30. data/lib/rails_console_pro/services/profile_collector.rb +245 -0
  31. data/lib/rails_console_pro/services/queue_action_service.rb +176 -0
  32. data/lib/rails_console_pro/services/queue_insight_fetcher.rb +600 -0
  33. data/lib/rails_console_pro/services/snippet_repository.rb +191 -0
  34. data/lib/rails_console_pro/snippets/collection_result.rb +44 -0
  35. data/lib/rails_console_pro/snippets/single_result.rb +30 -0
  36. data/lib/rails_console_pro/snippets/snippet.rb +112 -0
  37. data/lib/rails_console_pro/snippets.rb +12 -0
  38. data/lib/rails_console_pro/version.rb +1 -1
  39. data/rails_console_pro.gemspec +1 -1
  40. metadata +26 -8
@@ -0,0 +1,600 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsolePro
4
+ module Services
5
+ class QueueInsightFetcher
6
+ DEFAULT_LIMIT = 20
7
+
8
+ def initialize(queue_adapter = nil)
9
+ @queue_adapter = queue_adapter
10
+ end
11
+
12
+ def fetch(limit: DEFAULT_LIMIT, queue: nil)
13
+ adapter = build_adapter(@queue_adapter || detect_active_job_adapter)
14
+ return nil unless adapter
15
+
16
+ safe_limit = normalize_limit(limit)
17
+ adapter.fetch(limit: safe_limit, queue: queue)
18
+ end
19
+
20
+ private
21
+
22
+ def detect_active_job_adapter
23
+ return unless defined?(ActiveJob::Base)
24
+
25
+ ActiveJob::Base.queue_adapter
26
+ rescue StandardError
27
+ nil
28
+ end
29
+
30
+ def build_adapter(queue_adapter)
31
+ if sidekiq_adapter?(queue_adapter)
32
+ Adapters::Sidekiq.new(queue_adapter)
33
+ elsif solid_queue_adapter?(queue_adapter)
34
+ Adapters::SolidQueue.new(queue_adapter)
35
+ elsif sidekiq_present?
36
+ Adapters::Sidekiq.new(queue_adapter)
37
+ elsif solid_queue_present?
38
+ Adapters::SolidQueue.new(queue_adapter)
39
+ elsif queue_adapter
40
+ Adapters::Generic.new(queue_adapter)
41
+ else
42
+ Adapters::Generic.new(ActiveJob::Base.queue_adapter) if active_job_loaded?
43
+ end
44
+ end
45
+
46
+ def sidekiq_adapter?(adapter)
47
+ adapter.class.name == 'ActiveJob::QueueAdapters::SidekiqAdapter' &&
48
+ defined?(::Sidekiq)
49
+ end
50
+
51
+ def solid_queue_adapter?(adapter)
52
+ adapter.class.name == 'ActiveJob::QueueAdapters::SolidQueueAdapter' &&
53
+ defined?(::SolidQueue)
54
+ end
55
+
56
+ def sidekiq_present?
57
+ defined?(::Sidekiq) && defined?(::Sidekiq::Queue)
58
+ end
59
+
60
+ def solid_queue_present?
61
+ defined?(::SolidQueue) && defined?(::SolidQueue::Job)
62
+ end
63
+
64
+ def active_job_loaded?
65
+ defined?(ActiveJob::Base)
66
+ end
67
+
68
+ def normalize_limit(limit)
69
+ value = limit.to_i
70
+ return DEFAULT_LIMIT if value <= 0
71
+
72
+ [value, 200].min
73
+ end
74
+
75
+ module Adapters
76
+ class Base
77
+ attr_reader :queue_adapter
78
+
79
+ def initialize(queue_adapter)
80
+ @queue_adapter = queue_adapter
81
+ end
82
+
83
+ def fetch(limit:, queue:)
84
+ payload = gather(limit: limit, queue: queue)
85
+ build_result(**payload.merge(limit: limit))
86
+ end
87
+
88
+ protected
89
+
90
+ def gather(limit:, queue:)
91
+ {
92
+ enqueued_jobs: [],
93
+ retry_jobs: [],
94
+ recent_executions: [],
95
+ meta: {},
96
+ warnings: []
97
+ }
98
+ end
99
+
100
+ def build_result(enqueued_jobs:, retry_jobs:, recent_executions:, meta:, warnings:, limit:)
101
+ QueueInsightsResult.new(
102
+ adapter_name: adapter_name,
103
+ adapter_type: adapter_type,
104
+ enqueued_jobs: Array(enqueued_jobs).first(limit),
105
+ retry_jobs: Array(retry_jobs).first(limit),
106
+ recent_executions: Array(recent_executions).first(limit),
107
+ meta: meta || {},
108
+ warnings: Array(warnings),
109
+ captured_at: current_time
110
+ )
111
+ end
112
+
113
+ def adapter_name
114
+ queue_adapter.class.name
115
+ end
116
+
117
+ def adapter_type
118
+ nil
119
+ end
120
+
121
+ def job_summary(**attrs)
122
+ QueueInsightsResult::JobSummary.new(**attrs)
123
+ end
124
+
125
+ def execution_summary(**attrs)
126
+ QueueInsightsResult::ExecutionSummary.new(**attrs)
127
+ end
128
+
129
+ def current_time
130
+ if Time.respond_to?(:zone) && Time.zone
131
+ Time.zone.now
132
+ else
133
+ Time.now
134
+ end
135
+ end
136
+
137
+ def safe_execute(warnings, default: nil)
138
+ yield
139
+ rescue StandardError => e
140
+ warnings << "#{adapter_name}: #{e.message}"
141
+ default
142
+ end
143
+
144
+ def present?(value)
145
+ case value
146
+ when nil
147
+ false
148
+ when String
149
+ !value.empty?
150
+ else
151
+ value.respond_to?(:empty?) ? !value.empty? : true
152
+ end
153
+ end
154
+ end
155
+
156
+ class Sidekiq < Base
157
+ def adapter_name
158
+ "Sidekiq"
159
+ end
160
+
161
+ def adapter_type
162
+ "ActiveJob Adapter"
163
+ end
164
+
165
+ protected
166
+
167
+ def gather(limit:, queue:)
168
+ warnings = []
169
+ unless ensure_sidekiq_api_loaded
170
+ warnings << "Sidekiq API is not available. Require 'sidekiq/api' in your console session."
171
+ return {
172
+ enqueued_jobs: [],
173
+ retry_jobs: [],
174
+ recent_executions: [],
175
+ meta: {},
176
+ warnings: warnings
177
+ }
178
+ end
179
+ {
180
+ enqueued_jobs: safe_execute(warnings, default: []) { fetch_enqueued_jobs(limit, queue) },
181
+ retry_jobs: safe_execute(warnings, default: []) { fetch_retry_jobs(limit, queue) },
182
+ recent_executions: safe_execute(warnings, default: []) { fetch_recent_executions(limit, queue) },
183
+ meta: safe_execute(warnings, default: {}) { fetch_meta },
184
+ warnings: warnings
185
+ }
186
+ end
187
+
188
+ private
189
+
190
+ def ensure_sidekiq_api_loaded
191
+ return true if defined?(::Sidekiq::Queue)
192
+
193
+ require 'sidekiq/api'
194
+ defined?(::Sidekiq::Queue)
195
+ rescue LoadError
196
+ false
197
+ end
198
+
199
+ def fetch_enqueued_jobs(limit, queue)
200
+ queues = sidekiq_queues(queue)
201
+ jobs = []
202
+
203
+ queues.each do |sidekiq_queue|
204
+ sidekiq_queue.each do |job|
205
+ jobs << build_sidekiq_job(job)
206
+ break if jobs.size >= limit
207
+ end
208
+ break if jobs.size >= limit
209
+ end
210
+
211
+ jobs.compact
212
+ end
213
+
214
+ def fetch_retry_jobs(limit, queue)
215
+ return [] unless defined?(::Sidekiq::RetrySet)
216
+
217
+ ::Sidekiq::RetrySet.new.take(limit).map do |job|
218
+ next if queue && job.queue != queue
219
+
220
+ build_sidekiq_job(job)
221
+ end.compact
222
+ end
223
+
224
+ def fetch_recent_executions(limit, queue)
225
+ return [] unless defined?(::Sidekiq::Workers)
226
+
227
+ workers = ::Sidekiq::Workers.new
228
+ entries = []
229
+
230
+ workers.each do |process_id, thread_id, work|
231
+ next if queue && work['queue'] != queue
232
+
233
+ entries << build_worker_execution(process_id, thread_id, work)
234
+ break if entries.size >= limit
235
+ end
236
+
237
+ entries
238
+ end
239
+
240
+ def fetch_meta
241
+ return {} unless defined?(::Sidekiq::Stats)
242
+
243
+ stats = ::Sidekiq::Stats.new
244
+
245
+ {
246
+ enqueued: stats.enqueued,
247
+ processed: stats.processed,
248
+ failed: stats.failed,
249
+ retries: stats.retry_size,
250
+ scheduled: stats.scheduled_size,
251
+ dead: stats.dead_size
252
+ }
253
+ end
254
+
255
+ def sidekiq_queues(queue)
256
+ if queue
257
+ [::Sidekiq::Queue.new(queue)]
258
+ elsif ::Sidekiq::Queue.respond_to?(:all)
259
+ ::Sidekiq::Queue.all
260
+ else
261
+ [::Sidekiq::Queue.new("default")]
262
+ end
263
+ end
264
+
265
+ def build_sidekiq_job(job)
266
+ item = job.respond_to?(:item) ? job.item : {}
267
+ job_class = job.respond_to?(:display_class) ? job.display_class : item['class'] || job.klass rescue nil
268
+ args = job.respond_to?(:args) ? job.args : item['args']
269
+ job_id = job.respond_to?(:jid) ? job.jid : item['jid']
270
+
271
+ job_summary(
272
+ id: job_id,
273
+ job_class: job_class,
274
+ queue: job.respond_to?(:queue) ? job.queue : item['queue'],
275
+ args: args,
276
+ enqueued_at: extract_timestamp(job, item, 'enqueued_at'),
277
+ scheduled_at: extract_timestamp(job, item, 'at'),
278
+ attempts: item['retry_count'] || item['attempts'],
279
+ error: item['error_message'],
280
+ metadata: build_job_metadata(item)
281
+ )
282
+ end
283
+
284
+ def build_worker_execution(process_id, thread_id, work)
285
+ payload = work['payload'] || {}
286
+ started_at = work['run_at']
287
+ runtime_ms = if started_at
288
+ (current_time.to_f - started_at.to_f) * 1000.0
289
+ end
290
+
291
+ execution_summary(
292
+ id: payload['jid'] || "#{process_id}:#{thread_id}",
293
+ job_class: payload['class'],
294
+ queue: work['queue'],
295
+ started_at: started_at,
296
+ runtime_ms: runtime_ms,
297
+ worker: payload['worker'],
298
+ hostname: process_id,
299
+ metadata: {
300
+ thread: thread_id,
301
+ tags: payload['tags']
302
+ }.compact
303
+ )
304
+ end
305
+
306
+ def extract_timestamp(job, item, key)
307
+ return job.public_send(key) if job.respond_to?(key)
308
+ item[key] || item[key.to_s]
309
+ rescue StandardError
310
+ nil
311
+ end
312
+
313
+ def build_job_metadata(item)
314
+ metadata = {}
315
+ metadata[:wrapped] = item['wrapped'] if item['wrapped']
316
+ metadata[:priority] = item['priority'] if item.key?('priority')
317
+ metadata[:queue_latency_ms] = (item['enqueued_at'] && item['created_at']) ? (item['created_at'] - item['enqueued_at']) * 1000.0 : nil
318
+ metadata.compact
319
+ end
320
+ end
321
+
322
+ class SolidQueue < Base
323
+ def adapter_name
324
+ "SolidQueue"
325
+ end
326
+
327
+ def adapter_type
328
+ "ActiveJob Adapter"
329
+ end
330
+
331
+ protected
332
+
333
+ def gather(limit:, queue:)
334
+ warnings = []
335
+ {
336
+ enqueued_jobs: safe_execute(warnings, default: []) { fetch_ready_jobs(limit, queue) },
337
+ retry_jobs: safe_execute(warnings, default: []) { fetch_retry_jobs(limit, queue) },
338
+ recent_executions: safe_execute(warnings, default: []) { fetch_recent_executions(limit, queue) },
339
+ meta: safe_execute(warnings, default: {}) { fetch_meta },
340
+ warnings: warnings
341
+ }
342
+ end
343
+
344
+ private
345
+
346
+ def fetch_ready_jobs(limit, queue)
347
+ relation = solid_queue_jobs_scope(:ready)
348
+ relation = apply_queue_filter(relation, queue)
349
+ sample_relation(relation, limit).map { |job| build_solid_queue_job(job) }
350
+ end
351
+
352
+ def fetch_retry_jobs(limit, queue)
353
+ relation = if SolidQueue::Job.respond_to?(:retryable)
354
+ SolidQueue::Job.retryable
355
+ elsif SolidQueue::Job.respond_to?(:failed)
356
+ SolidQueue::Job.failed
357
+ else
358
+ nil
359
+ end
360
+ return [] unless relation
361
+
362
+ relation = apply_queue_filter(relation, queue)
363
+ sample_relation(relation, limit).map { |job| build_solid_queue_job(job) }
364
+ end
365
+
366
+ def fetch_recent_executions(limit, queue)
367
+ execution_relation = solid_queue_execution_relation(queue)
368
+ return [] unless execution_relation
369
+
370
+ sample_relation(execution_relation, limit).map { |execution| build_execution(execution) }
371
+ end
372
+
373
+ def fetch_meta
374
+ if defined?(SolidQueue::Statistics) && SolidQueue::Statistics.respond_to?(:snapshot)
375
+ SolidQueue::Statistics.snapshot.slice(
376
+ :ready_jobs,
377
+ :scheduled_jobs,
378
+ :running_jobs,
379
+ :retryable_jobs,
380
+ :failed_jobs
381
+ )
382
+ else
383
+ {}
384
+ end
385
+ end
386
+
387
+ def solid_queue_jobs_scope(scope_name)
388
+ if SolidQueue::Job.respond_to?(scope_name)
389
+ SolidQueue::Job.public_send(scope_name)
390
+ elsif SolidQueue::Job.respond_to?(:where)
391
+ SolidQueue::Job.where(state: scope_name.to_s)
392
+ end
393
+ end
394
+
395
+ def apply_queue_filter(relation, queue)
396
+ return relation unless queue && relation
397
+
398
+ if relation.respond_to?(:for_queue)
399
+ relation.for_queue(queue)
400
+ elsif relation.respond_to?(:where)
401
+ relation.where(queue_name: queue)
402
+ else
403
+ relation
404
+ end
405
+ end
406
+
407
+ def sample_relation(relation, limit)
408
+ return [] unless relation
409
+
410
+ if relation.respond_to?(:limit)
411
+ relation.limit(limit).to_a
412
+ elsif relation.respond_to?(:take)
413
+ Array(relation.take(limit))
414
+ else
415
+ Array(relation).first(limit)
416
+ end
417
+ end
418
+
419
+ def build_solid_queue_job(job)
420
+ job_summary(
421
+ id: safe_attr(job, :id),
422
+ job_class: safe_attr(job, :class_name) || safe_attr(job, :job_class),
423
+ queue: safe_attr(job, :queue_name) || "default",
424
+ args: safe_attr(job, :arguments),
425
+ enqueued_at: safe_attr(job, :enqueued_at) || safe_attr(job, :created_at),
426
+ scheduled_at: safe_attr(job, :scheduled_at),
427
+ attempts: safe_attr(job, :attempts) || safe_attr(job, :attempt),
428
+ error: safe_attr(job, :last_error),
429
+ metadata: build_job_metadata(job)
430
+ )
431
+ end
432
+
433
+ def build_job_metadata(job)
434
+ metadata = {}
435
+ metadata[:priority] = safe_attr(job, :priority) if safe_attr(job, :priority)
436
+ metadata[:singleton] = safe_attr(job, :singleton) if safe_attr(job, :singleton)
437
+ metadata.compact
438
+ end
439
+
440
+ def solid_queue_execution_relation(queue)
441
+ execution_class = if defined?(SolidQueue::Execution)
442
+ SolidQueue::Execution
443
+ elsif defined?(SolidQueue::CompletedExecution)
444
+ SolidQueue::CompletedExecution
445
+ end
446
+ return unless execution_class
447
+
448
+ relation = if execution_class.respond_to?(:order)
449
+ execution_class.order(created_at: :desc)
450
+ else
451
+ execution_class
452
+ end
453
+
454
+ if queue && relation.respond_to?(:where)
455
+ relation = relation.where(queue_name: queue)
456
+ end
457
+
458
+ relation
459
+ end
460
+
461
+ def build_execution(execution)
462
+ execution_summary(
463
+ id: safe_attr(execution, :id),
464
+ job_class: safe_attr(execution, :job_class) || safe_attr(execution, :class_name),
465
+ queue: safe_attr(execution, :queue_name),
466
+ started_at: safe_attr(execution, :started_at) || safe_attr(execution, :created_at),
467
+ runtime_ms: extract_runtime(execution),
468
+ worker: safe_attr(execution, :worker_id) || safe_attr(execution, :worker_name),
469
+ hostname: safe_attr(execution, :host),
470
+ metadata: build_execution_metadata(execution)
471
+ )
472
+ end
473
+
474
+ def build_execution_metadata(execution)
475
+ metadata = {}
476
+ metadata[:status] = safe_attr(execution, :status) if safe_attr(execution, :status)
477
+ metadata[:attempts] = safe_attr(execution, :attempts) if safe_attr(execution, :attempts)
478
+ metadata.compact
479
+ end
480
+
481
+ def extract_runtime(execution)
482
+ runtime = safe_attr(execution, :duration) || safe_attr(execution, :duration_ms)
483
+ return runtime if runtime
484
+
485
+ finished_at = safe_attr(execution, :finished_at)
486
+ started_at = safe_attr(execution, :started_at)
487
+ return unless finished_at && started_at
488
+
489
+ ((finished_at.to_f - started_at.to_f) * 1000.0).round(2)
490
+ rescue StandardError
491
+ nil
492
+ end
493
+
494
+ def safe_attr(object, method_name)
495
+ return unless object.respond_to?(method_name)
496
+
497
+ object.public_send(method_name)
498
+ rescue StandardError
499
+ nil
500
+ end
501
+ end
502
+
503
+ class Generic < Base
504
+ def adapter_type
505
+ "ActiveJob Adapter"
506
+ end
507
+
508
+ protected
509
+
510
+ def gather(limit:, queue:)
511
+ warnings = []
512
+
513
+ {
514
+ enqueued_jobs: safe_execute(warnings, default: []) { collect_active_job_enqueued(limit, queue) },
515
+ retry_jobs: [],
516
+ recent_executions: safe_execute(warnings, default: []) { collect_active_job_performed(limit, queue) },
517
+ meta: {},
518
+ warnings: warnings
519
+ }
520
+ end
521
+
522
+ private
523
+
524
+ def collect_active_job_enqueued(limit, queue)
525
+ return [] unless queue_adapter.respond_to?(:enqueued_jobs)
526
+
527
+ queue_adapter.enqueued_jobs.first(limit).map do |entry|
528
+ build_active_job_entry(entry, queue)
529
+ end.compact
530
+ end
531
+
532
+ def collect_active_job_performed(limit, queue)
533
+ return [] unless queue_adapter.respond_to?(:performed_jobs)
534
+
535
+ queue_adapter.performed_jobs.last(limit).reverse.map do |entry|
536
+ build_active_job_execution(entry, queue)
537
+ end.compact
538
+ end
539
+
540
+ def build_active_job_entry(entry, queue)
541
+ payload = normalize_payload(entry)
542
+ return if queue && payload[:queue] != queue
543
+
544
+ job_summary(
545
+ id: payload[:job_id],
546
+ job_class: payload[:job_class],
547
+ queue: payload[:queue],
548
+ args: payload[:arguments],
549
+ enqueued_at: payload[:enqueued_at],
550
+ scheduled_at: payload[:scheduled_at],
551
+ metadata: payload[:metadata]
552
+ )
553
+ end
554
+
555
+ def build_active_job_execution(entry, queue)
556
+ payload = normalize_payload(entry)
557
+ return if queue && payload[:queue] != queue
558
+
559
+ execution_summary(
560
+ id: payload[:job_id],
561
+ job_class: payload[:job_class],
562
+ queue: payload[:queue],
563
+ started_at: payload[:performed_at] || payload[:enqueued_at],
564
+ runtime_ms: payload[:runtime_ms],
565
+ metadata: payload[:metadata]
566
+ )
567
+ end
568
+
569
+ def normalize_payload(entry)
570
+ if entry.respond_to?(:to_h)
571
+ entry = entry.to_h
572
+ end
573
+
574
+ {
575
+ job_id: entry[:job_id] || entry[:'job_id'] || entry['job_id'],
576
+ job_class: entry[:job_class] || entry[:'job_class'] || entry['job_class'] || entry[:class] || entry['class'],
577
+ queue: entry[:queue] || entry[:'queue'] || entry['queue'],
578
+ arguments: entry[:arguments] || entry[:args] || entry['arguments'] || entry['args'],
579
+ enqueued_at: entry[:enqueued_at] || entry['enqueued_at'],
580
+ scheduled_at: entry[:scheduled_at] || entry['scheduled_at'],
581
+ performed_at: entry[:performed_at] || entry['performed_at'],
582
+ runtime_ms: entry[:runtime_ms] || entry['runtime_ms'],
583
+ metadata: extract_metadata(entry)
584
+ }
585
+ end
586
+
587
+ def extract_metadata(entry)
588
+ meta = {}
589
+ meta[:provider_job_id] = entry[:provider_job_id] || entry['provider_job_id'] if present?(entry[:provider_job_id] || entry['provider_job_id'])
590
+ meta[:priority] = entry[:priority] || entry['priority'] if present?(entry[:priority] || entry['priority'])
591
+ meta[:executions] = entry[:executions] || entry['executions'] if present?(entry[:executions] || entry['executions'])
592
+ meta.compact
593
+ end
594
+ end
595
+ end
596
+ end
597
+ end
598
+ end
599
+
600
+