solid_terminator 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eebd10e8944a7298814632a848c9f0d9d858591384b0787c714a5f7e958d98a2
4
- data.tar.gz: 072a76c8bc904536248aa0eeb2b3ed7ed9a0bb3e9f37c24655112bcf0894b190
3
+ metadata.gz: 289534896466bc08b1f8a285c12c1ef406cd00a6d3941396cad7b366a20061b8
4
+ data.tar.gz: 8237d1db3ff4ce1e5e200ee97350936b38e62a52c9f170693d5b66d50daa9b3d
5
5
  SHA512:
6
- metadata.gz: 4d6bc234cdd8edf4427e378bf155425492b45100115705ef289fba803a63fff236c7458560b00057c2e18c17384809654bca333b4e8e323f6862095e6869c720
7
- data.tar.gz: 7928aa7bf48a113df6d440736556a24fb75b400e42221f57f9bda607ae952f61a4324aef4b15d4e9e5ee7eefba2b37975a22533bc2368b9f8f364f74f244781f
6
+ metadata.gz: 63bf381c2e3602eb6e42e373bcfa708eeab2e2c8f81fbd46eeba03f60869950501cb7670860d59e28208d6dd1983b42eddd15c4060c7f58f43147f6c58d7a7d1
7
+ data.tar.gz: 80eb8f57d9ff1dd4ff24658cd8575e6ebfb0a928dda0899f917f6a2da481fe6c33da6a62588858538b754a88155ccacf4791ca3d8d87582c2618370031a02b0d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-10-10
6
+
7
+ ### Upgrading
8
+
9
+ Run `bin/rails generate solid_terminator:update` and migrate. This adds the `arguments` column to `solid_queue_terminated_executions`.
10
+
11
+ ### Breaking
12
+
13
+ - Requires a new migration that adds `arguments` to `solid_queue_terminated_executions` (see Upgrading). Until it runs, terminated executions are recorded without their arguments and cannot be retried. Workers log an error at startup and on every termination while the migration is missing.
14
+ - `perform_now` on a terminated job no longer raises `SolidTerminator::JobTerminated`. `Terminable` now handles it with `rescue_from`, so the job returns normally and SolidQueue marks it finished.
15
+ - Removed `SolidTerminator::ClaimedExecutionPatch`. SolidTerminator no longer patches SolidQueue internals.
16
+
17
+ ### Added
18
+
19
+ - `bin/rails generate solid_terminator:update` copies only the migrations the app is missing, without touching the initializer.
20
+
21
+ ### Changed
22
+
23
+ - Depends on `activejob`, `activerecord` and `railties` instead of the whole `rails` gem.
24
+
25
+ ### Fixed
26
+
27
+ - `NameError: undefined local variable or method 'unblock_next_job'` raised after every job on solid_queue >= 1.5.0.
28
+ - `SolidQueue::TerminatedExecution#retry` failed once SolidQueue cleared the finished job (immediately with `preserve_finished_jobs = false`, otherwise after `clear_finished_jobs_after`). The job's arguments are now stored on the terminated execution.
29
+ - `SolidQueue::TerminatedExecution#retry` raised `NoMethodError: undefined method '[]' for nil` on executions recorded before 0.2.0 whose job was already cleared. It now raises `SolidQueue::TerminatedExecution::NotRetryable` with an explanation.
30
+ - A termination could hit the wrong job: if the targeted job finished just as the monitor signalled it, `JobTerminated` could land in the next job on the same worker thread, or interrupt cleanup and leave a stale registry entry that kept signalling that thread. The monitor now raises under the registry lock, and `Terminable` only accepts the signal while the job's own code runs.
31
+
5
32
  ## [0.1.0] - 2026-06-06
6
33
 
7
34
  - Initial release
data/README.md CHANGED
@@ -36,7 +36,7 @@ Run the install generator:
36
36
  bin/rails generate solid_terminator:install
37
37
  ```
38
38
 
39
- This copies two migrations and an initializer. Then run migrations.
39
+ This copies three migrations and an initializer. Then run migrations.
40
40
 
41
41
  **Single-database apps** (SolidQueue shares the main database):
42
42
 
@@ -62,6 +62,22 @@ The generator creates two tables:
62
62
  | `solid_queue_terminations` | Termination signal — almost always empty |
63
63
  | `solid_queue_terminated_executions` | Audit trail of every terminated job |
64
64
 
65
+ ### Upgrading from 0.1
66
+
67
+ Run the update generator to copy only the migrations your app is missing, then run migrations as above:
68
+
69
+ ```bash
70
+ bin/rails generate solid_terminator:update
71
+ ```
72
+
73
+ It takes the same `--database` option as `install`. Run it after every upgrade; when nothing is missing it does nothing.
74
+
75
+ Until the migrations run, workers log an error at startup and on every termination that names this command.
76
+
77
+ The migration adds an `arguments` column to `solid_queue_terminated_executions` and copies it from jobs SolidQueue still has. Terminated executions whose job SolidQueue already cleared cannot be retried.
78
+
79
+ 0.2 also changes behaviour: `perform_now` on a terminated job no longer raises `SolidTerminator::JobTerminated`. See the [changelog](CHANGELOG.md).
80
+
65
81
  ## Usage
66
82
 
67
83
  ### Make a job terminable
@@ -159,6 +175,8 @@ SolidQueue::Job.where(class_name: 'MyJob').pluck(:active_job_id)
159
175
 
160
176
  When a job is terminated, SolidTerminator writes a `SolidQueue::TerminatedExecution` record so you have a queryable audit trail.
161
177
 
178
+ > **Note:** SolidQueue has no "terminated" state, so it records a terminated job as **finished**, the same as a job that succeeded. Mission Control and `SolidQueue::Job.finished` include terminated jobs. Use `SolidQueue::TerminatedExecution` to tell them apart.
179
+
162
180
  ### Query
163
181
 
164
182
  ```ruby
@@ -169,7 +187,7 @@ SolidQueue::TerminatedExecution.ordered
169
187
  SolidQueue::TerminatedExecution.where(queue_name: 'critical')
170
188
  ```
171
189
 
172
- Each record exposes: `job_id`, `queue_name`, `priority`, `terminated_at`.
190
+ Each record exposes: `job_id`, `queue_name`, `priority`, `arguments`, `terminated_at`.
173
191
 
174
192
  ### Retry a terminated job
175
193
 
@@ -179,7 +197,9 @@ Re-enqueue the original job with its original arguments:
179
197
  SolidQueue::TerminatedExecution.find_by(job_id: sq_job_id).retry
180
198
  ```
181
199
 
182
- This destroys the `TerminatedExecution` record and enqueues a new job in a single transaction.
200
+ This destroys the `TerminatedExecution` record and enqueues a new job in a single transaction. The job's arguments are stored on the `TerminatedExecution`, so retry still works after SolidQueue clears the finished job.
201
+
202
+ Executions recorded before 0.2.0 whose job was already cleared have no `arguments`. `retry` raises `SolidQueue::TerminatedExecution::NotRetryable` for them. Delete them instead.
183
203
 
184
204
  ## Configuration
185
205
 
@@ -252,7 +272,8 @@ Planned improvements — no particular order:
252
272
 
253
273
  ```bash
254
274
  bundle install
255
- bundle exec rspec # run tests
275
+ bundle exec rspec # run unit tests
276
+ bundle exec rspec integration # run against real SolidQueue models
256
277
  bundle exec rubocop # lint
257
278
  bundle exec rubocop -A # lint with auto-fix
258
279
  ```
@@ -27,6 +27,10 @@ module SolidTerminator
27
27
  migration_template \
28
28
  'create_solid_queue_terminated_executions.rb',
29
29
  File.join(target_migrations_dir, 'create_solid_queue_terminated_executions.rb')
30
+
31
+ migration_template \
32
+ 'add_arguments_to_solid_queue_terminated_executions.rb',
33
+ File.join(target_migrations_dir, 'add_arguments_to_solid_queue_terminated_executions.rb')
30
34
  end
31
35
 
32
36
  def create_initializer
@@ -0,0 +1,18 @@
1
+ class AddArgumentsToSolidQueueTerminatedExecutions < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ add_column :solid_queue_terminated_executions, :arguments, :text
4
+
5
+ # Copy from jobs SolidQueue has not cleared yet; rows whose job is already gone stay NULL and cannot be retried.
6
+ execute <<~SQL
7
+ UPDATE solid_queue_terminated_executions
8
+ SET arguments = (
9
+ SELECT solid_queue_jobs.arguments FROM solid_queue_jobs
10
+ WHERE solid_queue_jobs.id = solid_queue_terminated_executions.job_id
11
+ )
12
+ SQL
13
+ end
14
+
15
+ def down
16
+ remove_column :solid_queue_terminated_executions, :arguments
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'install_generator'
4
+
5
+ module SolidTerminator
6
+ module Generators
7
+ class UpdateGenerator < InstallGenerator
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ remove_command :create_initializer
11
+
12
+ private
13
+
14
+ def migration_template(source, destination, config = {})
15
+ super(source, destination, config.merge(skip: true))
16
+ end
17
+ end
18
+ end
19
+ end
@@ -7,14 +7,15 @@ module SolidTerminator
7
7
  config.to_prepare do
8
8
  require 'solid_terminator/termination'
9
9
  require 'solid_terminator/terminated_execution'
10
- require 'solid_terminator/claimed_execution_patch'
11
- SolidQueue::ClaimedExecution.prepend(SolidTerminator::ClaimedExecutionPatch)
12
10
  end
13
11
 
14
12
  config.after_initialize do
15
13
  if defined?(SolidQueue)
16
- SolidQueue.on_worker_start { SolidTerminator.start_monitor }
17
- SolidQueue.on_worker_stop { SolidTerminator.stop_monitor }
14
+ SolidQueue.on_worker_start do
15
+ SolidQueue::TerminatedExecution.log_unless_migrated
16
+ SolidTerminator.start_monitor
17
+ end
18
+ SolidQueue.on_worker_stop { SolidTerminator.stop_monitor }
18
19
  end
19
20
  end
20
21
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidTerminator
4
+ # Inherits Exception, not StandardError, so a job's bare `rescue => e` cannot swallow termination.
4
5
  class JobTerminated < Exception; end # rubocop:disable Lint/InheritException
5
6
  end
@@ -70,16 +70,10 @@ module SolidTerminator
70
70
  end
71
71
 
72
72
  def signal_termination(termination)
73
- thread = ThreadRegistry.thread_for(termination.active_job_id)
74
- return if thread.nil?
75
-
76
- return clean_stale_entry(termination) unless thread.alive?
77
-
78
- logger.info("Raising termination on job #{termination.active_job_id}")
79
- begin
80
- thread.raise(JobTerminated, "Job #{termination.active_job_id} terminated")
81
- rescue ThreadError
82
- logger.warn("Thread for job #{termination.active_job_id} died before signal; will retry next poll")
73
+ job_id = termination.active_job_id
74
+ case ThreadRegistry.raise_in(job_id, JobTerminated, "Job #{job_id} terminated")
75
+ when :raised then logger.info("Raised termination on job #{job_id}")
76
+ when :dead then clean_stale_entry(termination)
83
77
  end
84
78
  # Row stays: job's ensure deletes it on clean exit; if the job swallows the exception
85
79
  # the row persists so the monitor retries next poll rather than silently giving up.
@@ -6,33 +6,47 @@ module SolidTerminator
6
6
 
7
7
  included do
8
8
  around_perform :with_termination_handling
9
+ # rescue_from rather than rescuing in around_perform: perform_now then returns normally, so
10
+ # SolidQueue marks the job finished, and after_perform callbacks from parent classes do not run.
11
+ rescue_from(JobTerminated) { |error| handle_job_terminated(error) }
9
12
  end
10
13
 
11
14
  private
12
15
 
13
- def with_termination_handling
16
+ # JobTerminated is only delivered while the job's own code runs. During registration and cleanup
17
+ # it is deferred, so a signal cannot interrupt deregister and leave a stale entry that would make
18
+ # the monitor raise into the next job on this pool thread.
19
+ def with_termination_handling(&)
14
20
  current_thread = Thread.current
15
- st_logger.debug("Job #{job_id} registered for termination monitoring")
16
- ThreadRegistry.register(job_id)
17
- yield
18
- rescue JobTerminated => e
19
- on_job_terminated(current_thread, e)
20
- ensure
21
+ Thread.handle_interrupt(JobTerminated => :never) do
22
+ ThreadRegistry.register(job_id)
23
+ st_logger.debug("Job #{job_id} registered for termination monitoring")
24
+ Thread.handle_interrupt(JobTerminated => :immediate, &)
25
+ ensure
26
+ finish_termination_handling(current_thread)
27
+ end
28
+ end
29
+
30
+ def finish_termination_handling(current_thread)
21
31
  ThreadRegistry.deregister(job_id, current_thread)
22
32
  st_logger.debug("Job #{job_id} deregistered from termination monitoring")
23
33
  cleanup_termination_row
34
+ discard_pending_termination
24
35
  end
25
36
 
26
- def on_job_terminated(current_thread, error)
27
- # Deregister before cleanup so the monitor does not re-raise mid-cleanup.
28
- ThreadRegistry.deregister(job_id, current_thread)
29
- handle_job_terminated(error)
37
+ # A signal sent just before deregister is still queued. The job is over either way, so drop it
38
+ # instead of letting it fire after the mask lifts and mark a completed job as terminated.
39
+ # Unmasking delivers any queued signal; no Thread.pending_interrupt?(JobTerminated) guard
40
+ # because that call segfaults on Ruby 3.1 while the interrupt is pending.
41
+ def discard_pending_termination
42
+ Thread.handle_interrupt(JobTerminated => :immediate) { nil }
43
+ rescue JobTerminated
44
+ nil
30
45
  end
31
46
 
32
47
  def handle_job_terminated(error)
33
48
  st_logger.info("Job #{job_id} terminated: #{error.message}")
34
49
  SolidQueue::TerminatedExecution.record_termination(self)
35
- raise
36
50
  end
37
51
 
38
52
  def cleanup_termination_row
@@ -2,13 +2,31 @@
2
2
 
3
3
  module SolidQueue
4
4
  class TerminatedExecution < SolidQueue::Execution
5
+ class NotRetryable < StandardError; end
6
+
7
+ # Copied from the job so retry still works after SolidQueue clears the finished job.
8
+ serialize :arguments, coder: JSON
9
+
5
10
  scope :ordered, -> { order(terminated_at: :desc) }
6
11
 
12
+ # Without the column, `serialize` keeps arguments as a virtual attribute and saves silently drop them.
13
+ def self.log_unless_migrated
14
+ return if table_exists? && column_names.include?('arguments')
15
+
16
+ SolidTerminator.configuration.logger.error(
17
+ 'solid_queue_terminated_executions is missing migrations, so terminated jobs cannot be retried. ' \
18
+ 'Run `bin/rails generate solid_terminator:update` and migrate.'
19
+ )
20
+ end
21
+
7
22
  def self.record_termination(active_job)
8
23
  sq_job = SolidQueue::Job.find_by(active_job_id: active_job.job_id)
9
24
  return nil unless sq_job
10
25
 
11
- create!(job_id: sq_job.id, queue_name: sq_job.queue_name, priority: sq_job.priority, terminated_at: Time.current)
26
+ log_unless_migrated
27
+
28
+ create!(job_id: sq_job.id, queue_name: sq_job.queue_name, priority: sq_job.priority,
29
+ arguments: sq_job.arguments, terminated_at: Time.current)
12
30
  rescue StandardError => e
13
31
  SolidTerminator.configuration.logger.error(
14
32
  "Failed to record termination for job #{active_job.job_id}: #{e.class}: #{e.message}"
@@ -17,7 +35,13 @@ module SolidQueue
17
35
  end
18
36
 
19
37
  def retry
20
- active_job = ActiveJob::Base.deserialize(job.arguments)
38
+ # Rows terminated before 0.2.0 whose job was already cleared got no arguments from the backfill.
39
+ if arguments.nil?
40
+ raise NotRetryable, "Cannot retry terminated execution #{id} (job #{job_id}): its arguments were not " \
41
+ 'recorded. It was terminated before solid_terminator 0.2.0 and its job is gone.'
42
+ end
43
+
44
+ active_job = ActiveJob::Base.deserialize(arguments)
21
45
  transaction do
22
46
  destroy!
23
47
  active_job.enqueue
@@ -22,6 +22,21 @@ module SolidTerminator
22
22
  @mutex.synchronize { @registry[active_job_id] }
23
23
  end
24
24
 
25
+ # Raises under the lock so the job cannot deregister in between and let its pool thread
26
+ # pick up another job, which would then receive the exception instead.
27
+ def raise_in(active_job_id, exception_class, message)
28
+ @mutex.synchronize do
29
+ thread = @registry[active_job_id]
30
+ return :not_registered unless thread
31
+ return :dead unless thread.alive?
32
+
33
+ thread.raise(exception_class, message)
34
+ :raised
35
+ end
36
+ rescue ThreadError
37
+ :dead
38
+ end
39
+
25
40
  def active_job_ids
26
41
  @mutex.synchronize { @registry.keys.dup }
27
42
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidTerminator
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
metadata CHANGED
@@ -1,17 +1,45 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_terminator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Meszaros Istvan-Abel
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-07 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: rails
14
+ name: activejob
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: railties
15
43
  requirement: !ruby/object:Gem::Requirement
16
44
  requirements:
17
45
  - - ">="
@@ -49,11 +77,12 @@ files:
49
77
  - LICENSE.txt
50
78
  - README.md
51
79
  - lib/generators/solid_terminator/install_generator.rb
80
+ - lib/generators/solid_terminator/templates/add_arguments_to_solid_queue_terminated_executions.rb
52
81
  - lib/generators/solid_terminator/templates/create_solid_queue_terminated_executions.rb
53
82
  - lib/generators/solid_terminator/templates/create_solid_queue_terminations.rb
54
83
  - lib/generators/solid_terminator/templates/initializer.rb
84
+ - lib/generators/solid_terminator/update_generator.rb
55
85
  - lib/solid_terminator.rb
56
- - lib/solid_terminator/claimed_execution_patch.rb
57
86
  - lib/solid_terminator/configuration.rb
58
87
  - lib/solid_terminator/engine.rb
59
88
  - lib/solid_terminator/job_terminated.rb
@@ -71,7 +100,7 @@ metadata:
71
100
  source_code_uri: https://github.com/IstvanMs/solid_terminator
72
101
  bug_tracker_uri: https://github.com/IstvanMs/solid_terminator/issues
73
102
  changelog_uri: https://github.com/IstvanMs/solid_terminator/blob/main/CHANGELOG.md
74
- post_install_message:
103
+ post_install_message:
75
104
  rdoc_options: []
76
105
  require_paths:
77
106
  - lib
@@ -87,7 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
87
116
  version: '0'
88
117
  requirements: []
89
118
  rubygems_version: 3.3.27
90
- signing_key:
119
+ signing_key:
91
120
  specification_version: 4
92
121
  summary: Terminate specific in-progress SolidQueue jobs without stopping the worker
93
122
  test_files: []
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SolidTerminator
4
- module ClaimedExecutionPatch
5
- def perform
6
- result = execute
7
- handle_result(result)
8
- ensure
9
- unblock_next_job
10
- end
11
-
12
- private
13
-
14
- def handle_result(result)
15
- if result.success?
16
- finished
17
- elsif result.error.is_a?(SolidTerminator::JobTerminated)
18
- finish_terminated
19
- else
20
- failed_with(result.error)
21
- raise result.error
22
- end
23
- end
24
-
25
- def finish_terminated
26
- transaction do
27
- job.finished!
28
- destroy!
29
- end
30
- end
31
- end
32
- end