solid_queue_autoscaler 1.0.13 → 1.0.15

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: bf4f38fa3806f153c03715b02554d1a82837da595fb9dadf4fb8063d6f52b3c8
4
- data.tar.gz: 3737a7de81ab147dbd8e38fc87a2f601c21ea4d895758418f4bd484b0483ea12
3
+ metadata.gz: bafb0cda485024f43bd0e73291bcb0bbbc7275e3f897d4a2acafbd6496140b98
4
+ data.tar.gz: 4b443c68ca28df2b3efd62a4cd7ae30f6cf53e978c941a1e60aa7ebd0c7befb0
5
5
  SHA512:
6
- metadata.gz: 1ef6933dfe8a7936ba524ebd2fc94f46b20b6545b2e008e6d5e2c5c4753f54f866b82ef84eb75b39aa9d49e69ca476bae9be36cfdfff9cd39f97a380627e38f0
7
- data.tar.gz: 14b04452165bac891d292dfdcb4dd7fb11993d6b5c772c43e658e2492437bcf7af2b9004f616b1caf9b73e606bb018855cd9f36be7b7a0909f336be59fc34952
6
+ metadata.gz: 943bd220740694c827afc4c3aede77cac6da3b419eda051c686b5fb07cfbb69da28b6b8d5882fcf61122bfc6991352e52b9dab2b7f05982333324efbce20205f
7
+ data.tar.gz: a201785b1ecc766f744a7d705f64dd1e7afaa611e004f3fb9f003a28a05a397fc412ec276a17b99c4bbad0cf89ed476884f510986e3f95e9f9385252d7e15d31
data/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.15] - 2025-01-30
11
+
12
+ ### Fixed
13
+ - **Fixed Heroku adapter 404 error when querying scaled-to-zero dynos** - When a dyno type is scaled to 0 and removed from Heroku's formation, the API returns 404. The adapter now handles this gracefully:
14
+ - `current_workers` returns 0 instead of raising an error when formation doesn't exist
15
+ - `scale` falls back to `batch_update` API to create the formation when `update` returns 404
16
+ - Added `create_formation` private method using Heroku's batch_update endpoint
17
+ - This enables full scale-to-zero support with `min_workers = 0`
18
+
19
+ ## [1.0.14] - 2025-01-18
20
+
21
+ ### Added
22
+ - **SQLite and MySQL support for advisory locks** - AdvisoryLock now supports multiple database adapters:
23
+ - PostgreSQL: Uses native `pg_try_advisory_lock/pg_advisory_unlock`
24
+ - MySQL/Trilogy: Uses `GET_LOCK/RELEASE_LOCK`
25
+ - SQLite: Uses table-based locking with auto-created locks table
26
+ - Other databases: Falls back to table-based locking
27
+ - Automatic adapter detection via `connection.adapter_name`
28
+ - Stale lock cleanup (locks older than 5 minutes are removed)
29
+ - Lock ownership tracking (`hostname:pid:thread_id`)
30
+
31
+ - **Comprehensive configuration tests** - Added 100+ tests across Rails and Sinatra dummy apps:
32
+ - Tests for ALL configuration options (job_queue, job_priority, scaling thresholds, cooldowns, etc.)
33
+ - Decision engine threshold tests verifying scaling logic
34
+ - End-to-end tests with mocked Heroku API verifying full scaling workflow
35
+ - Queue name and priority regression tests (prevents jobs going to wrong queue)
36
+
37
+ - **GitHub Actions integration test workflow** - New CI job that runs dummy app tests:
38
+ - Runs Rails dummy app tests (62 tests)
39
+ - Runs Sinatra dummy app tests (58 tests)
40
+ - Ensures queue name, priority, and E2E scaling tests pass before release
41
+
42
+ - **Release workflow now requires CI to pass** - Updated release.yml to use `workflow_run` trigger:
43
+ - Release only runs after CI workflow completes successfully
44
+ - All unit tests, integration tests, and linting must pass before publishing
45
+
46
+ ### Fixed
47
+ - **Fixed test pollution in autoscale_job_spec** - Changed from using RSpec's `described_class` (which caches class references) to dynamic constant lookup, preventing stale class reference issues when tests reload the AutoscaleJob class
48
+
49
+ ## [1.0.13] - 2025-01-17
50
+
51
+ ### Fixed
52
+ - **Fixed AutoscaleJob queue_name type mismatch** - Queue name is now converted to string when set via `apply_job_settings!`
53
+ - ActiveJob internally uses strings for queue names, but the configuration uses symbols
54
+ - This caused jobs to have symbol queue names (`:autoscaler`) instead of string (`"autoscaler"`)
55
+ - Now `apply_job_settings!` calls `.to_s` on the job_queue to ensure consistent string format
56
+
10
57
  ## [1.0.12] - 2025-01-17
11
58
 
12
59
  ### Fixed
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration for SolidQueueAutoscaler locks table.
4
+ # This table is used for advisory locking on databases that don't support
5
+ # native advisory locks (SQLite, etc.).
6
+ #
7
+ # NOTE: This migration is OPTIONAL. The locks table is automatically created
8
+ # when first needed. Only use this migration if you prefer to manage the
9
+ # table schema explicitly.
10
+ #
11
+ # For multi-database setups (SolidQueue in separate database):
12
+ # This migration should be placed in db/queue_migrate/ (or your queue DB's migration path)
13
+ # Run with: rails db:migrate:queue
14
+ #
15
+ # For single-database setups:
16
+ # Place in db/migrate/ and run: rails db:migrate
17
+ #
18
+ class CreateSolidQueueAutoscalerLocks < ActiveRecord::Migration<%= migration_version %>
19
+ def change
20
+ create_table :solid_queue_autoscaler_locks, id: false do |t|
21
+ t.string :lock_key, null: false, primary_key: true
22
+ t.integer :lock_id, null: false
23
+ t.datetime :locked_at, null: false
24
+ t.string :locked_by, null: false
25
+ end
26
+
27
+ # Index for cleanup of stale locks
28
+ add_index :solid_queue_autoscaler_locks, :locked_at
29
+ end
30
+ end
@@ -33,6 +33,13 @@ module SolidQueueAutoscaler
33
33
  formation['quantity']
34
34
  end
35
35
  rescue Excon::Error => e
36
+ # Handle 404 gracefully - formation doesn't exist means 0 workers
37
+ # This happens when a dyno type is scaled to 0 and removed from formation
38
+ if e.respond_to?(:response) && e.response&.status == 404
39
+ logger&.debug("[Autoscaler] Formation '#{process_type}' not found, treating as 0 workers")
40
+ return 0
41
+ end
42
+
36
43
  raise HerokuAPIError.new(
37
44
  "Failed to get formation info: #{e.message}",
38
45
  status_code: e.respond_to?(:response) ? e.response&.status : nil,
@@ -51,6 +58,12 @@ module SolidQueueAutoscaler
51
58
  end
52
59
  quantity
53
60
  rescue Excon::Error => e
61
+ # Handle 404 by trying to create the formation via batch_update
62
+ # This happens when scaling up a dyno type that was previously scaled to 0
63
+ if e.respond_to?(:response) && e.response&.status == 404
64
+ return create_formation(quantity)
65
+ end
66
+
54
67
  raise HerokuAPIError.new(
55
68
  "Failed to scale #{process_type} to #{quantity}: #{e.message}",
56
69
  status_code: e.respond_to?(:response) ? e.response&.status : nil,
@@ -84,6 +97,31 @@ module SolidQueueAutoscaler
84
97
 
85
98
  private
86
99
 
100
+ # Creates a formation that doesn't exist using batch_update.
101
+ # This is needed when scaling up a dyno type that was previously scaled to 0.
102
+ #
103
+ # @param quantity [Integer] desired worker count
104
+ # @return [Integer] the new worker count
105
+ # @raise [HerokuAPIError] if the API call fails
106
+ def create_formation(quantity)
107
+ logger&.info("[Autoscaler] Formation '#{process_type}' not found, creating with quantity #{quantity}")
108
+
109
+ with_retry(RETRYABLE_ERRORS, retryable_check: method(:retryable_error?)) do
110
+ client.formation.batch_update(app_name, {
111
+ updates: [
112
+ { type: process_type, quantity: quantity }
113
+ ]
114
+ })
115
+ end
116
+ quantity
117
+ rescue Excon::Error => e
118
+ raise HerokuAPIError.new(
119
+ "Failed to create formation #{process_type} with quantity #{quantity}: #{e.message}",
120
+ status_code: e.respond_to?(:response) ? e.response&.status : nil,
121
+ response_body: e.respond_to?(:response) ? e.response&.body : nil
122
+ )
123
+ end
124
+
87
125
  # Determines if an error should be retried.
88
126
  # Retries timeouts and 5xx errors, but not 4xx client errors.
89
127
  def retryable_error?(error)
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'zlib'
4
+ require 'socket'
4
5
 
5
6
  module SolidQueueAutoscaler
6
- # PostgreSQL advisory lock wrapper for singleton enforcement.
7
+ # Advisory lock wrapper for singleton enforcement.
8
+ # Supports both PostgreSQL (native advisory locks) and SQLite (table-based locks).
7
9
  #
8
- # IMPORTANT: PgBouncer Compatibility Warning
9
- # ==========================================
10
+ # IMPORTANT: PgBouncer Compatibility Warning (PostgreSQL only)
11
+ # ============================================================
10
12
  # PostgreSQL advisory locks are connection-scoped (session-level locks).
11
13
  # If you're using PgBouncer in transaction pooling mode, advisory locks
12
14
  # will NOT work correctly because:
@@ -24,6 +26,10 @@ module SolidQueueAutoscaler
24
26
  # lock acquisition always failing, PgBouncer is likely the cause.
25
27
  #
26
28
  class AdvisoryLock
29
+ LOCKS_TABLE_NAME = 'solid_queue_autoscaler_locks'
30
+ # Stale lock timeout - locks older than this are considered abandoned (5 minutes)
31
+ STALE_LOCK_TIMEOUT_SECONDS = 300
32
+
27
33
  attr_reader :lock_key, :timeout
28
34
 
29
35
  def initialize(lock_key: nil, timeout: nil, config: nil)
@@ -31,6 +37,7 @@ module SolidQueueAutoscaler
31
37
  @lock_key = lock_key || @config.lock_key
32
38
  @timeout = timeout || @config.lock_timeout_seconds
33
39
  @lock_acquired = false
40
+ @strategy = nil
34
41
  end
35
42
 
36
43
  def with_lock
@@ -43,20 +50,14 @@ module SolidQueueAutoscaler
43
50
  def try_lock
44
51
  return false if @lock_acquired
45
52
 
46
- result = connection.select_value(
47
- "SELECT pg_try_advisory_lock(#{lock_id})"
48
- )
49
- @lock_acquired = [true, 't'].include?(result)
53
+ @lock_acquired = lock_strategy.try_lock
50
54
  @lock_acquired
51
55
  end
52
56
 
53
57
  def acquire!
54
58
  return true if @lock_acquired
55
59
 
56
- result = connection.select_value(
57
- "SELECT pg_try_advisory_lock(#{lock_id})"
58
- )
59
- @lock_acquired = [true, 't'].include?(result)
60
+ @lock_acquired = lock_strategy.try_lock
60
61
 
61
62
  raise LockError, "Could not acquire advisory lock '#{lock_key}' (id: #{lock_id})" unless @lock_acquired
62
63
 
@@ -66,7 +67,7 @@ module SolidQueueAutoscaler
66
67
  def release
67
68
  return false unless @lock_acquired
68
69
 
69
- connection.execute("SELECT pg_advisory_unlock(#{lock_id})")
70
+ lock_strategy.release
70
71
  @lock_acquired = false
71
72
  true
72
73
  end
@@ -87,5 +88,160 @@ module SolidQueueAutoscaler
87
88
  hash & 0x7FFFFFFF
88
89
  end
89
90
  end
91
+
92
+ def lock_strategy
93
+ @strategy ||= create_lock_strategy
94
+ end
95
+
96
+ def create_lock_strategy
97
+ adapter_name = connection.adapter_name.downcase
98
+
99
+ case adapter_name
100
+ when /postgresql/, /postgis/
101
+ PostgreSQLLockStrategy.new(connection: connection, lock_id: lock_id, lock_key: lock_key)
102
+ when /sqlite/
103
+ SQLiteLockStrategy.new(connection: connection, lock_id: lock_id, lock_key: lock_key)
104
+ when /mysql/, /trilogy/
105
+ MySQLLockStrategy.new(connection: connection, lock_id: lock_id, lock_key: lock_key)
106
+ else
107
+ # Fall back to table-based locking for unknown adapters
108
+ TableBasedLockStrategy.new(connection: connection, lock_id: lock_id, lock_key: lock_key)
109
+ end
110
+ end
111
+
112
+ # Base class for lock strategies
113
+ class BaseLockStrategy
114
+ def initialize(connection:, lock_id:, lock_key:)
115
+ @connection = connection
116
+ @lock_id = lock_id
117
+ @lock_key = lock_key
118
+ end
119
+
120
+ def try_lock
121
+ raise NotImplementedError, "#{self.class} must implement #try_lock"
122
+ end
123
+
124
+ def release
125
+ raise NotImplementedError, "#{self.class} must implement #release"
126
+ end
127
+
128
+ protected
129
+
130
+ attr_reader :connection, :lock_id, :lock_key
131
+ end
132
+
133
+ # PostgreSQL native advisory locks
134
+ class PostgreSQLLockStrategy < BaseLockStrategy
135
+ def try_lock
136
+ result = connection.select_value(
137
+ "SELECT pg_try_advisory_lock(#{lock_id})"
138
+ )
139
+ [true, 't'].include?(result)
140
+ end
141
+
142
+ def release
143
+ connection.execute("SELECT pg_advisory_unlock(#{lock_id})")
144
+ true
145
+ end
146
+ end
147
+
148
+ # MySQL named locks (GET_LOCK/RELEASE_LOCK)
149
+ class MySQLLockStrategy < BaseLockStrategy
150
+ def try_lock
151
+ # MySQL GET_LOCK returns 1 on success, 0 if timeout, NULL on error
152
+ result = connection.select_value(
153
+ "SELECT GET_LOCK(#{connection.quote(lock_key)}, 0)"
154
+ )
155
+ result == 1
156
+ end
157
+
158
+ def release
159
+ connection.execute("SELECT RELEASE_LOCK(#{connection.quote(lock_key)})")
160
+ true
161
+ end
162
+ end
163
+
164
+ # Table-based locking for databases without native advisory lock support
165
+ # Uses a simple locks table with INSERT/DELETE for lock management
166
+ class TableBasedLockStrategy < BaseLockStrategy
167
+ def try_lock
168
+ ensure_locks_table_exists!
169
+ cleanup_stale_locks!
170
+
171
+ # Try to insert a lock record
172
+ begin
173
+ connection.execute(<<~SQL)
174
+ INSERT INTO #{quoted_table_name} (lock_key, lock_id, locked_at, locked_by)
175
+ VALUES (#{connection.quote(lock_key)}, #{lock_id}, #{connection.quote(Time.now.utc.iso8601)}, #{connection.quote(lock_owner)})
176
+ SQL
177
+ true
178
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::StatementInvalid => e
179
+ # Lock already held by another process
180
+ # StatementInvalid catches SQLite's UNIQUE constraint violation
181
+ return false if e.message.include?('UNIQUE') || e.message.include?('duplicate')
182
+
183
+ raise
184
+ end
185
+ end
186
+
187
+ def release
188
+ return true unless table_exists?
189
+
190
+ connection.execute(<<~SQL)
191
+ DELETE FROM #{quoted_table_name}
192
+ WHERE lock_key = #{connection.quote(lock_key)}
193
+ AND locked_by = #{connection.quote(lock_owner)}
194
+ SQL
195
+ true
196
+ end
197
+
198
+ private
199
+
200
+ def ensure_locks_table_exists!
201
+ return if table_exists?
202
+
203
+ create_locks_table!
204
+ end
205
+
206
+ def table_exists?
207
+ @table_exists ||= connection.table_exists?(LOCKS_TABLE_NAME)
208
+ end
209
+
210
+ def create_locks_table!
211
+ connection.execute(<<~SQL)
212
+ CREATE TABLE IF NOT EXISTS #{quoted_table_name} (
213
+ lock_key VARCHAR(255) NOT NULL PRIMARY KEY,
214
+ lock_id INTEGER NOT NULL,
215
+ locked_at DATETIME NOT NULL,
216
+ locked_by VARCHAR(255) NOT NULL
217
+ )
218
+ SQL
219
+ @table_exists = true
220
+ end
221
+
222
+ def cleanup_stale_locks!
223
+ # Remove locks older than STALE_LOCK_TIMEOUT_SECONDS
224
+ stale_threshold = (Time.now.utc - STALE_LOCK_TIMEOUT_SECONDS).iso8601
225
+ connection.execute(<<~SQL)
226
+ DELETE FROM #{quoted_table_name}
227
+ WHERE locked_at < #{connection.quote(stale_threshold)}
228
+ SQL
229
+ end
230
+
231
+ def quoted_table_name
232
+ connection.quote_table_name(LOCKS_TABLE_NAME)
233
+ end
234
+
235
+ def lock_owner
236
+ # Unique identifier for this process/thread
237
+ @lock_owner ||= "#{Socket.gethostname}:#{Process.pid}:#{Thread.current.object_id}"
238
+ end
239
+ end
240
+
241
+ # SQLite table-based locking (SQLite doesn't have advisory locks)
242
+ # Defined after TableBasedLockStrategy since it inherits from it
243
+ class SQLiteLockStrategy < TableBasedLockStrategy
244
+ # Inherits all behavior from TableBasedLockStrategy
245
+ end
90
246
  end
91
247
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidQueueAutoscaler
4
- VERSION = '1.0.13'
4
+ VERSION = '1.0.15'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue_autoscaler
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.13
4
+ version: 1.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - reillyse
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-17 00:00:00.000000000 Z
11
+ date: 2026-01-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -122,6 +122,20 @@ dependencies:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
124
  version: '3.18'
125
+ - !ruby/object:Gem::Dependency
126
+ name: sqlite3
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
125
139
  description: A control plane for Solid Queue on Heroku that automatically scales worker
126
140
  dynos based on queue depth, job latency, and throughput. Uses PostgreSQL advisory
127
141
  locks for singleton behavior and the Heroku Platform API for scaling.
@@ -143,6 +157,7 @@ files:
143
157
  - lib/generators/solid_queue_autoscaler/migration_generator.rb
144
158
  - lib/generators/solid_queue_autoscaler/templates/README
145
159
  - lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_events.rb.erb
160
+ - lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_locks.rb.erb
146
161
  - lib/generators/solid_queue_autoscaler/templates/create_solid_queue_autoscaler_state.rb.erb
147
162
  - lib/generators/solid_queue_autoscaler/templates/initializer.rb
148
163
  - lib/solid_queue_autoscaler.rb