diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5b018..06b63ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Fixed + +- Activity chart no longer crashes the dashboard on multi-database setups where Solid Queue runs on a different engine than the host app's primary database (e.g. a MySQL primary with a dedicated PostgreSQL queue database via `config.solid_queue.connects_to`). The SQL dialect for the time-bucketing query is now detected from the Solid Queue model's own connection instead of `ActiveRecord::Base`, so a `PG::UndefinedFunction: function unix_timestamp(...) does not exist` error is no longer raised. Single-database and same-engine setups are unaffected. (#42) + ## [2.2.0] - 2026-06-24 ### Added diff --git a/app/services/solid_queue_monitor/chart_data_service.rb b/app/services/solid_queue_monitor/chart_data_service.rb index 0866c8e..8c0db2d 100644 --- a/app/services/solid_queue_monitor/chart_data_service.rb +++ b/app/services/solid_queue_monitor/chart_data_service.rb @@ -61,7 +61,7 @@ def build_buckets(start_time, bucket_seconds) # This works identically on PostgreSQL and SQLite. def bucket_counts(model, column, start_time, end_time, interval, exclude_nil: false) start_epoch = start_time.to_i - expr = bucket_index_expr(column, start_epoch, interval) + expr = bucket_index_expr(model, column, start_epoch, interval) scope = model.where(column => start_time..end_time) scope = scope.where.not(column => nil) if exclude_nil @@ -82,18 +82,23 @@ def fill_buckets(buckets, index_counts) # PostgreSQL: CAST((EXTRACT(EPOCH FROM col) - start) / interval AS INTEGER) # SQLite: CAST((CAST(strftime('%s', col) AS INTEGER) - start) / interval AS INTEGER) # MySQL: CAST((UNIX_TIMESTAMP(col) - start) / interval AS SIGNED) - def bucket_index_expr(column, start_epoch, interval_seconds) - if adapter?('sqlite') + # + # The adapter is detected from the model's own connection, not + # ActiveRecord::Base's. Solid Queue is commonly hosted on a dedicated + # database (config.solid_queue.connects_to), which may use a different + # engine than the host app's primary connection. + def bucket_index_expr(model, column, start_epoch, interval_seconds) + if adapter?(model, 'sqlite') "CAST((CAST(strftime('%s', #{column}) AS INTEGER) - #{start_epoch}) / #{interval_seconds} AS INTEGER)" - elsif adapter?('mysql') || adapter?('trilogy') + elsif adapter?(model, 'mysql') || adapter?(model, 'trilogy') "FLOOR((UNIX_TIMESTAMP(#{column}) - #{start_epoch}) / #{interval_seconds})" else "FLOOR((EXTRACT(EPOCH FROM #{column}) - #{start_epoch}) / #{interval_seconds})::integer" end end - def adapter?(name) - ActiveRecord::Base.connection.adapter_name.downcase.include?(name) + def adapter?(model, name) + model.connection.adapter_name.downcase.include?(name) end end end diff --git a/spec/services/solid_queue_monitor/chart_data_service_spec.rb b/spec/services/solid_queue_monitor/chart_data_service_spec.rb index 94fec5e..a6412e3 100644 --- a/spec/services/solid_queue_monitor/chart_data_service_spec.rb +++ b/spec/services/solid_queue_monitor/chart_data_service_spec.rb @@ -134,6 +134,53 @@ end end + describe 'database adapter detection (multi-database safety)' do + let(:service) { described_class.new } + + # Builds the bucket-index SQL for a model whose connection reports the given + # adapter, independent of the test database engine. + def expr_for(adapter_name) + connection = instance_double(ActiveRecord::ConnectionAdapters::AbstractAdapter, + adapter_name: adapter_name) + model = class_double(SolidQueue::Job, connection: connection) + service.send(:bucket_index_expr, model, 'created_at', 1000, 3600) + end + + it 'uses PostgreSQL EXTRACT(EPOCH ...) syntax for a Postgres connection' do + expect(expr_for('PostgreSQL')) + .to eq('FLOOR((EXTRACT(EPOCH FROM created_at) - 1000) / 3600)::integer') + end + + it 'uses MySQL UNIX_TIMESTAMP syntax for a MySQL connection' do + expect(expr_for('Mysql2')).to include('UNIX_TIMESTAMP(created_at)') + end + + it 'uses MySQL syntax for a Trilogy connection' do + expect(expr_for('Trilogy')).to include('UNIX_TIMESTAMP(created_at)') + end + + it 'uses SQLite strftime syntax for a SQLite connection' do + expect(expr_for('SQLite')).to include("strftime('%s', created_at)") + end + + context 'when Solid Queue runs on a different engine than the primary database' do + # Regression for issue #42: MySQL primary + dedicated PostgreSQL queue + # database (config.solid_queue.connects_to). The dialect must follow the + # Solid Queue model's connection, not ActiveRecord::Base's. + before do + primary = instance_double(ActiveRecord::ConnectionAdapters::AbstractAdapter, + adapter_name: 'Mysql2') + allow(ActiveRecord::Base).to receive(:connection).and_return(primary) + end + + it 'builds Postgres SQL when the queue model is on Postgres' do + expr = expr_for('PostgreSQL') + expect(expr).to include('EXTRACT(EPOCH FROM created_at)') + expect(expr).not_to include('UNIX_TIMESTAMP') + end + end + end + describe 'constants' do it 'defines all time ranges' do expect(described_class::TIME_RANGES.keys).to eq(%w[15m 30m 1h 3h 6h 12h 1d 3d 1w])