deployangel 0.1.28 → 0.1.29

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: fde8310f8129827b4b759369146c2346542a776ab13ac91fe7001617e93a7ebb
4
- data.tar.gz: 15a216435704ea45fce03e0a1433d615dc28f6c07fdd6e33c90bebed23361779
3
+ metadata.gz: e62d94a1e10efd0431f1cfad4f7aad29f954481919a609f8112e5f517291eeb1
4
+ data.tar.gz: 408bcc12ae37d4f93e74e5e31ad6a8b921b30481cedf81f84055c07c62677af0
5
5
  SHA512:
6
- metadata.gz: e6e7ae24a04e3999af5865e2cf5be14054cdfc92b5644b1a4f5d8bd56c407faf874b122db23508bbcb3b268e461a89d68a16a3a235e166cf457e10e063a4c3f9
7
- data.tar.gz: 3e2bb9e11c4d8b35134fdb9088beabcaf0ba5b99b484c9e4b0e2dfc3b710aa544507b55f6a3a222c0446d4b6edbec0f935f0049f03e2039cc19e253767560530
6
+ metadata.gz: e26a0379ee22236add3b1742a3ef174ebd3f39def909d3e230b4fadce49b1f2a3f62daa41c2aa02e05e5c12e59a78b9d6c94b7436bca9b986c56b20053f49783
7
+ data.tar.gz: 9d7f93900e16f63f1ba593063a31b0d03eb3f218877c65ea6c5a9b13153bd9a3520c8e5555370f8d94e53d89741f0a709143483b8ffa196e0e2e23f9a42a4b31
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.29 (2026-10-16)
4
+
5
+ - Reports stuck work, so DeployAngel can fail a release in which a job hangs
6
+ or the queue keeps growing while every job still succeeds. Each minute's
7
+ payload says how many jobs are still running and how long the oldest of
8
+ each class has been running (ActiveJob on any adapter, and native Sidekiq
9
+ jobs). Sidekiq and Solid Queue worker processes also read the queue backlog,
10
+ the jobs ready to run and waiting, once a minute from the reporter thread:
11
+ one pipelined Redis call for Sidekiq, one `COUNT` of
12
+ `solid_queue_ready_executions` for Solid Queue. Web processes don't read
13
+ it. Needs a DeployAngel cloud that understands the new fields; older ones
14
+ ignore them.
15
+
3
16
  ## 0.1.28 (2026-10-12)
4
17
 
5
18
  - Clearer setup for coding agents, from a first-day rehearsal: `setup` says
data/README.md CHANGED
@@ -88,6 +88,10 @@ the dashboard says so.
88
88
  queue latency, per job class. Works with every ActiveJob adapter (Solid
89
89
  Queue, Sidekiq, GoodJob, ...) and with native Sidekiq jobs through a server
90
90
  middleware. Failures that `retry_on` or `discard_on` handle still count.
91
+ - Jobs still running at the end of each minute, and how long the oldest of
92
+ each class has been running, so a job that hangs is noticed. Sidekiq and
93
+ Solid Queue worker processes also send the queue backlog (how many jobs are
94
+ waiting), read once a minute off the request path.
91
95
  - Release identity, runtime versions, and a per-process instance ID.
92
96
 
93
97
  - Exceptions: a stable fingerprint, the exception class, the first line of the
@@ -86,6 +86,25 @@ module DeployAngel
86
86
  warn_once(:record_job, "DeployAngel failed to record a job: #{e.class}: #{e.message}")
87
87
  end
88
88
 
89
+ # Marks a job as running until #job_finished, so one that hangs is still
90
+ # reported. Returns the token to pass to it.
91
+ def job_started(job_class)
92
+ return unless @active
93
+
94
+ after_fork! if Process.pid != @pid
95
+ start_reporter
96
+ @running_jobs.start(job_class)
97
+ rescue StandardError => e
98
+ warn_once(:job_started, "DeployAngel failed to record a running job: #{e.class}: #{e.message}")
99
+ nil
100
+ end
101
+
102
+ def job_finished(token)
103
+ @running_jobs.finish(token) if token
104
+ rescue StandardError => e
105
+ warn_once(:job_finished, "DeployAngel failed to record a finished job: #{e.class}: #{e.message}")
106
+ end
107
+
89
108
  def record_discard(job_class:)
90
109
  return unless @active
91
110
 
@@ -135,7 +154,9 @@ module DeployAngel
135
154
  def flush(include_current: false)
136
155
  return 0 unless @active
137
156
 
138
- @aggregator.drain(include_current: include_current, max_periods: config.max_queued_payloads).each do |period|
157
+ periods = @aggregator.drain(include_current: include_current, max_periods: config.max_queued_payloads)
158
+ add_stuck_work(periods.last, include_current) if periods.any?
159
+ periods.each do |period|
139
160
  @buffer.push(Transport.encode(Protocol.telemetry(period, instance: @instance, release: release,
140
161
  runtime: @runtime, capabilities: capabilities)))
141
162
  end
@@ -198,10 +219,24 @@ module DeployAngel
198
219
  end
199
220
 
200
221
  private
222
+ # What's still running, on the latest period only: as of its end, or
223
+ # now for the in-progress one at shutdown. The backlog is read here, in
224
+ # the reporter thread, but not at shutdown, which has seconds to send.
225
+ def add_stuck_work(period, include_current)
226
+ ended_at = [ period.started_at + Aggregator::PERIOD_SECONDS, @clock.call ].min
227
+ @running_jobs.snapshot(ended_at).each do |job_class, (count, oldest_ms)|
228
+ period.record_running(job_class, count, oldest_ms, config.max_routes)
229
+ end
230
+ period.queue_backlog = QueueBacklog.read unless include_current
231
+ rescue StandardError => e
232
+ warn_once(:stuck_work, "DeployAngel failed to read running jobs: #{e.class}: #{e.message}")
233
+ end
234
+
201
235
  def reset_process_state
202
236
  @pid = Process.pid
203
237
  @instance = Instance.new(env: @env, now: @clock.call)
204
238
  @aggregator = Aggregator.new(max_routes: config.max_routes, clock: @clock)
239
+ @running_jobs = RunningJobs.new(clock: @clock)
205
240
  @buffer = Buffer.new(config.max_queued_payloads)
206
241
  @paused_until = nil
207
242
  @metadata_sent = false
@@ -19,9 +19,16 @@ module DeployAngel
19
19
  end
20
20
  end
21
21
 
22
- JobStats = Struct.new(:processed, :failed, :discarded, :duration, :queue_latency) do
22
+ # running and oldest_running_ms: jobs still running as the period is
23
+ # sent, and how long the oldest had been running at its end.
24
+ JobStats = Struct.new(:processed, :failed, :discarded, :duration, :queue_latency, :running, :oldest_running_ms) do
23
25
  def self.empty
24
- new(0, 0, 0, Histogram.new, Histogram.new)
26
+ new(0, 0, 0, Histogram.new, Histogram.new, 0, nil)
27
+ end
28
+
29
+ def add_running(count, oldest_ms)
30
+ self.running += count
31
+ self.oldest_running_ms = [ oldest_running_ms, oldest_ms ].compact.max
25
32
  end
26
33
 
27
34
  def record(failed, discarded, duration_ms, queue_latency_ms)
@@ -54,6 +61,8 @@ module DeployAngel
54
61
  class Period
55
62
  attr_reader :started_at, :requests, :status_counts, :unhandled_exceptions, :histogram, :routes,
56
63
  :jobs, :job_classes, :exceptions, :exceptions_truncated, :checkpoints
64
+ # Jobs waiting in the job backend's queues, read as the period is sent.
65
+ attr_accessor :queue_backlog
57
66
 
58
67
  def initialize(started_at)
59
68
  @started_at = started_at
@@ -97,6 +106,14 @@ module DeployAngel
97
106
  (@job_classes[key] ||= JobStats.empty).record(failed, discarded, duration_ms, queue_latency_ms)
98
107
  end
99
108
 
109
+ # A class with jobs running and none finished is listed too, so a job
110
+ # that hangs is reported.
111
+ def record_running(job_class, count, oldest_ms, max_classes)
112
+ @jobs.add_running(count, oldest_ms)
113
+ key = @job_classes.key?(job_class) || @job_classes.size < max_classes - 1 ? job_class : OTHER_ROUTE
114
+ (@job_classes[key] ||= JobStats.empty).add_running(count, oldest_ms)
115
+ end
116
+
100
117
  def record_discard(job_class, max_classes)
101
118
  @jobs.record_discard
102
119
  key = @job_classes.key?(job_class) || @job_classes.size < max_classes - 1 ? job_class : OTHER_ROUTE
@@ -32,7 +32,7 @@ module DeployAngel
32
32
  end,
33
33
  "exceptions" => period.exceptions.values.map { |e| e.merge("sources" => e["sources"].to_h).compact },
34
34
  "exceptions_truncated" => period.exceptions_truncated,
35
- "jobs" => job_stats(period.jobs),
35
+ "jobs" => job_stats(period.jobs).merge({ "queue_backlog" => period.queue_backlog }.compact),
36
36
  "job_classes" => period.job_classes.map { |key, stats| { "key" => key }.merge(job_stats(stats)) },
37
37
  # http and job are always sent, 0 when none, so the cloud can tell
38
38
  # this agent from one that doesn't say where checkpoints happened.
@@ -42,14 +42,18 @@ module DeployAngel
42
42
  }
43
43
  end
44
44
 
45
+ # running and oldest_running_ms only when jobs were running.
45
46
  def job_stats(stats)
46
- {
47
+ fields = {
47
48
  "processed" => stats.processed,
48
49
  "failed" => stats.failed,
49
50
  "discarded" => stats.discarded,
50
51
  "duration_histogram" => stats.duration.to_protocol,
51
52
  "queue_latency_histogram" => stats.queue_latency.to_protocol
52
53
  }
54
+ return fields unless stats.running.positive?
55
+
56
+ fields.merge("running" => stats.running, "oldest_running_ms" => stats.oldest_running_ms)
53
57
  end
54
58
 
55
59
  def runtime(framework: nil, framework_version: nil)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # The jobs this process is running now, so each payload can say how long
5
+ # the oldest of each class has been running: a job that hangs never
6
+ # finishes, so it never shows up in the counts. Starting and finishing is
7
+ # a hash update under a mutex.
8
+ class RunningJobs
9
+ # More than any process runs at once; past it, new jobs go untracked
10
+ # rather than growing memory.
11
+ MAX = 1_000
12
+
13
+ def initialize(clock:)
14
+ @clock = clock
15
+ @jobs = {}.compare_by_identity
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ # Returns the token to pass to #finish.
20
+ def start(job_class)
21
+ token = Object.new
22
+ @mutex.synchronize { @jobs[token] = [ job_class.to_s, @clock.call ] if @jobs.size < MAX }
23
+ token
24
+ end
25
+
26
+ def finish(token)
27
+ @mutex.synchronize { @jobs.delete(token) }
28
+ end
29
+
30
+ # { job_class => [running, oldest_running_ms] } for jobs that started by
31
+ # at and are still running.
32
+ def snapshot(at)
33
+ @mutex.synchronize do
34
+ @jobs.each_value.with_object({}) do |(job_class, started_at), running|
35
+ next if started_at > at
36
+
37
+ age_ms = ((at - started_at) * 1000).round
38
+ count, oldest = running[job_class]
39
+ running[job_class] = [ count.to_i + 1, [ oldest.to_i, age_ms ].max ]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # Reads how many jobs are ready to run and waiting in the job backend's
5
+ # queues. Only job worker processes read it, once a minute from the
6
+ # reporter thread as they send telemetry, never in a request or job. Every
7
+ # worker sees the same queues, and the cloud keeps the largest reading.
8
+ module QueueBacklog
9
+ module_function
10
+
11
+ # Solid Queue runs each worker in its own process, forked by the
12
+ # supervisor, so it's marked as each one starts.
13
+ def install
14
+ return if @installed || !defined?(::SolidQueue) || !::SolidQueue.respond_to?(:on_worker_start)
15
+
16
+ @installed = true
17
+ ::SolidQueue.on_worker_start { worker!(:solid_queue) }
18
+ rescue StandardError
19
+ nil
20
+ end
21
+
22
+ # Called where the backend says this process works its queues: Sidekiq's
23
+ # server configuration, or a Solid Queue worker starting.
24
+ def worker!(backend)
25
+ @backend = backend
26
+ end
27
+
28
+ def backend
29
+ @backend
30
+ end
31
+
32
+ # Jobs waiting, or nil when this process doesn't work a queue it can read.
33
+ def read
34
+ case @backend
35
+ when :sidekiq then sidekiq
36
+ when :solid_queue then solid_queue
37
+ end
38
+ rescue StandardError, LoadError
39
+ nil
40
+ end
41
+
42
+ # One pipelined call for every queue's length.
43
+ def sidekiq
44
+ require "sidekiq/api"
45
+ ::Sidekiq::Stats.new.enqueued
46
+ end
47
+
48
+ # Ready executions only: scheduled and blocked jobs aren't waiting on a
49
+ # worker. The connection goes straight back to the pool.
50
+ def solid_queue
51
+ model = ::SolidQueue::ReadyExecution
52
+ model.connection_pool.with_connection { model.count }
53
+ end
54
+ end
55
+ end
@@ -12,12 +12,14 @@ module DeployAngel
12
12
  module ActiveJob
13
13
  STATE = :@__deployangel_job_state
14
14
  PREVIOUS_CONTEXT = :@__deployangel_previous_context
15
+ RUNNING = :@__deployangel_running
15
16
 
16
17
  # Marks the fiber as running a job for as long as perform.active_job
17
- # lasts, so checkpoints recorded by the job say so. ActiveSupport calls
18
- # finish from an ensure, so the previous context (an HTTP request, for
19
- # a job performed inline) comes back even when the job raises. It is
20
- # kept on the job, so only a perform that started here restores.
18
+ # lasts, so checkpoints recorded by the job say so, and the job as
19
+ # running, so one that hangs is reported. ActiveSupport calls finish
20
+ # from an ensure, so the previous context (an HTTP request, for a job
21
+ # performed inline) comes back even when the job raises. Both are kept
22
+ # on the job, so only a perform that started here finishes them.
21
23
  module ContextListener
22
24
  module_function
23
25
 
@@ -26,6 +28,7 @@ module DeployAngel
26
28
  return unless job && DeployAngel.recording?
27
29
 
28
30
  job.instance_variable_set(PREVIOUS_CONTEXT, ExecutionContext.enter(ExecutionContext::JOB))
31
+ job.instance_variable_set(RUNNING, DeployAngel.job_started(job.class.name))
29
32
  rescue StandardError
30
33
  nil
31
34
  end
@@ -35,6 +38,7 @@ module DeployAngel
35
38
  return unless job&.instance_variable_defined?(PREVIOUS_CONTEXT)
36
39
 
37
40
  ExecutionContext.restore(job.remove_instance_variable(PREVIOUS_CONTEXT))
41
+ DeployAngel.job_finished(job.remove_instance_variable(RUNNING)) if job.instance_variable_defined?(RUNNING)
38
42
  rescue StandardError
39
43
  nil
40
44
  end
@@ -12,6 +12,7 @@ module DeployAngel
12
12
  initializer "deployangel.jobs" do
13
13
  ActiveSupport.on_load(:active_job) { DeployAngel::Rails::ActiveJob.install }
14
14
  DeployAngel::Sidekiq.install
15
+ DeployAngel::QueueBacklog.install
15
16
  end
16
17
 
17
18
  # Handled reports (Rails.error.handle / Rails.error.report) are shown for
@@ -17,6 +17,7 @@ module DeployAngel
17
17
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
18
  latency = DeployAngel::Sidekiq.queue_latency_ms(job)
19
19
  previous_context = ExecutionContext.enter(ExecutionContext::JOB)
20
+ running = DeployAngel.job_started(job["class"])
20
21
  begin
21
22
  yield
22
23
  rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
@@ -25,6 +26,7 @@ module DeployAngel
25
26
  raise
26
27
  ensure
27
28
  ExecutionContext.restore(previous_context)
29
+ DeployAngel.job_finished(running)
28
30
  end
29
31
  record(job, started, latency, failed: false)
30
32
  end
@@ -46,6 +48,7 @@ module DeployAngel
46
48
  @installed = true
47
49
  DeployAngel.add_capability("jobs")
48
50
  ::Sidekiq.configure_server do |config|
51
+ QueueBacklog.worker!(:sidekiq)
49
52
  config.server_middleware { |chain| chain.add(ServerMiddleware) }
50
53
  # Jobs that exhausted their retries are discarded.
51
54
  config.death_handlers << ->(job, _error) { record_death(job) } if config.respond_to?(:death_handlers)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.28"
4
+ VERSION = "0.1.29"
5
5
  end
data/lib/deployangel.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "deployangel/core/fingerprint"
8
8
  require_relative "deployangel/core/release"
9
9
  require_relative "deployangel/core/instance"
10
10
  require_relative "deployangel/core/aggregator"
11
+ require_relative "deployangel/core/running_jobs"
11
12
  require_relative "deployangel/core/protocol"
12
13
  require_relative "deployangel/core/execution_context"
13
14
  require_relative "deployangel/core/buffer"
@@ -57,6 +58,14 @@ module DeployAngel
57
58
  agent&.record_job(**attributes)
58
59
  end
59
60
 
61
+ def job_started(job_class)
62
+ agent&.job_started(job_class)
63
+ end
64
+
65
+ def job_finished(token)
66
+ agent&.job_finished(token)
67
+ end
68
+
60
69
  def record_exception(exception, **options)
61
70
  agent&.record_exception(exception, **options)
62
71
  end
@@ -128,6 +137,7 @@ require_relative "deployangel/rails/error_subscriber"
128
137
  require_relative "deployangel/tasks"
129
138
  require_relative "deployangel/metadata"
130
139
  require_relative "deployangel/rails/metadata"
140
+ require_relative "deployangel/queue_backlog"
131
141
  require_relative "deployangel/sidekiq"
132
142
  require_relative "deployangel/apartment"
133
143
  require_relative "deployangel/rails/railtie" if defined?(::Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deployangel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.28
4
+ version: 0.1.29
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -45,6 +45,7 @@ files:
45
45
  - lib/deployangel/core/protocol.rb
46
46
  - lib/deployangel/core/redaction.rb
47
47
  - lib/deployangel/core/release.rb
48
+ - lib/deployangel/core/running_jobs.rb
48
49
  - lib/deployangel/core/transport.rb
49
50
  - lib/deployangel/credentials.rb
50
51
  - lib/deployangel/doctor.rb
@@ -52,6 +53,7 @@ files:
52
53
  - lib/deployangel/fork_hook.rb
53
54
  - lib/deployangel/mcp.rb
54
55
  - lib/deployangel/metadata.rb
56
+ - lib/deployangel/queue_backlog.rb
55
57
  - lib/deployangel/rack/http.rb
56
58
  - lib/deployangel/rails/active_job.rb
57
59
  - lib/deployangel/rails/error_subscriber.rb