Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Job class search and the queue filter now match any part of the name, ignoring case (previously class search matched only from the start, case-sensitively, and the queue filter needed the exact name).

## [1.2.0] - 2026-09-04

### Added
Expand Down
16 changes: 10 additions & 6 deletions app/controllers/flightdeck/jobs/actions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ class ActionsController < Flightdeck::ApplicationController
include Toasts

def create
if params[:id].present?
act_on_single
elsif params[:scope] == "all"
act_on_all_matching
else
act_on_selected
# Fresh names, or "all matching" could skip a job with a brand-new class
# or queue.
KnownNames.fresh do
if params[:id].present?
act_on_single
elsif params[:scope] == "all"
act_on_all_matching
else
act_on_selected
end
end

respond_to do |format|
Expand Down
11 changes: 5 additions & 6 deletions app/models/flightdeck/jobs_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,11 @@ def apply_filters(relation)
relation = relation.joins(:job) if join_jobs?

relation = relation.where(jobs_table[:class_name].eq(class_name)) if class_name
relation = relation.where(queue_table[:queue_name].eq(queue_name)) if queue_name
relation = relation.where(jobs_table[:class_name].matches("#{escape_like(q)}%", nil, true)) if q
# Fragments resolve to exact names (see KnownNames): an indexed IN list.
if queue_name
relation = relation.where(queue_table[:queue_name].in(KnownNames.matching_queues(queue_name)))
end
relation = relation.where(jobs_table[:class_name].in(KnownNames.matching_classes(q))) if q

relation
end
Expand All @@ -225,9 +228,5 @@ def jobs_table = SolidQueue::Job.arel_table
def queue_table
own_queue_column? ? model.arel_table : jobs_table
end

def escape_like(value)
ActiveRecord::Base.sanitize_sql_like(value.to_s)
end
end
end
119 changes: 119 additions & 0 deletions app/models/flightdeck/known_names.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# frozen_string_literal: true

module Flightdeck
# The distinct queue and job class names Solid Queue has seen, used to turn a
# search fragment into an exact list of names.
#
# `LIKE '%frag%'` can't use an index, so every list, tab count and bulk
# action would scan the jobs table. Matching here, in Ruby, leaves SQL an
# indexed `IN (...)` and folds case the same on every adapter.
#
# Memoized per process rather than in Rails.cache, so a page's list and seven
# tab counts cost a couple of DISTINCTs per poll interval whatever the host's
# cache store. Freshness:
# * entries live one poll interval;
# * a fragment matching nothing re-reads first (at most every MISS_REFRESH);
# * `fresh { }` ignores entries read before it began.
module KnownNames
MISS_REFRESH = 1.0 # seconds
FRESH_KEY = :flightdeck_known_names_fresh_after

# `fresh` compares `sequence` (equal timestamps can't fool it), the TTL runs
# from `started_at`, and MISS_REFRESH from `finished_at`, so a slow read
# isn't due again the moment it lands.
Entry = Struct.new(:names, :sequence, :started_at, :finished_at)

COLUMNS = %i[queue_name class_name].freeze

@memo = {}
@sequence = 0
@lock = Mutex.new
# Held across a read, so concurrent threads share one DISTINCT.
@read_locks = COLUMNS.index_with { Mutex.new }

class << self
def matching_queues(fragment) = matching(:queue_name, fragment)
def matching_classes(fragment) = matching(:class_name, fragment)

def fresh
previous = Thread.current[FRESH_KEY]
Thread.current[FRESH_KEY] ||= @lock.synchronize { @sequence }
yield
ensure
Thread.current[FRESH_KEY] = previous
end

def reset!
@lock.synchronize { @memo.clear }
end

# Public so tests can pin it.
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)

private
# Case-insensitive substring match.
def matching(column, fragment)
needle = fragment.to_s.downcase
entry = current(column)
found = select(entry.names, needle)
return found if found.any? || cooling_down?(entry)

# The name may be newer than the entry.
select(refresh(column) { |latest| cooling_down?(latest) }.names, needle)
end

def select(names, needle)
names.select { |name| name.downcase.include?(needle) }
end

def current(column)
entry = stored(column)
valid?(entry) ? entry : refresh(column) { |latest| valid?(latest) }
end

def valid?(entry)
return false unless entry

fresh_after = Thread.current[FRESH_KEY]
return entry.sequence > fresh_after if fresh_after

now - entry.started_at < ttl
end

def cooling_down?(entry) = now - entry.finished_at < MISS_REFRESH

def stored(column) = @lock.synchronize { @memo[column] }

# A thread that waited takes the entry the one ahead of it read, if
# `good_enough`. Connection before lock: the reverse order deadlocks
# once the pool is exhausted.
def refresh(column, &good_enough)
SolidQueue::Job.connection_pool.with_connection do
@read_locks.fetch(column).synchronize do
latest = stored(column)
return latest if latest && good_enough.call(latest)

read(column)
end
end
end

def read(column)
sequence = @lock.synchronize { @sequence += 1 }
started_at = now
# Uncached, or a stale cached result would pass as a fresh read.
names = SolidQueue::Job.uncached { SolidQueue::Job.distinct.pluck(column) }.compact.sort
entry = Entry.new(names, sequence, started_at, now)

@lock.synchronize do
# Never let an older read replace a newer one.
existing = @memo[column]
@memo[column] = entry if existing.nil? || existing.sequence < entry.sequence
end
entry
end

def ttl = [ Flightdeck.config.poll_interval.to_f, 1.0 ].max
end
end
end
29 changes: 29 additions & 0 deletions test/integration/job_actions_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,35 @@ class Flightdeck::JobActionsTest < FlightdeckIntegrationTest
assert in_table?(SolidQueue::FailedExecution, other), "a job outside the filter must not be touched"
end

# Pinned clock: a slow run mustn't outlast the TTL and pass without `fresh`.
test "applying to all matching sees job classes newer than the search memo" do
create_failed_job(class_name: "Screenshots::OldJob")

with_known_names_clock do
get_fd "/flightdeck/jobs?state=failed&q=screenshots" # warms the names memo
newcomer = create_failed_job(class_name: "Screenshots::NewJob")

post_fd "/flightdeck/jobs/retry", params: { scope: "all", state: "failed", q: "screenshots" }

assert in_table?(SolidQueue::ReadyExecution, newcomer), "a job whose class is newer than the memo was skipped"
assert_equal 0, SolidQueue::FailedExecution.count
end
end

test "applying to all matching sees queues newer than the search memo" do
create_failed_job(queue_name: "within_5_minutes")

with_known_names_clock do
get_fd "/flightdeck/jobs?state=failed&queue_name=within" # warms the names memo
newcomer = create_failed_job(queue_name: "within_1_minute")

post_fd "/flightdeck/jobs/discard", params: { scope: "all", state: "failed", queue_name: "within" }

refute SolidQueue::Job.exists?(newcomer.id), "a job whose queue is newer than the memo was skipped"
assert_equal 0, SolidQueue::FailedExecution.count
end
end

test "applying to all matching stops at the configured limit and reports the remainder" do
Flightdeck.config.bulk_action_limit = 2
5.times { create_failed_job }
Expand Down
4 changes: 2 additions & 2 deletions test/integration/jobs_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ class Flightdeck::JobsTest < FlightdeckIntegrationTest
assert_select ".fd-chip", text: /clear filters/
end

test "search narrows the list by job class prefix" do
test "search narrows the list by a fragment of the job class" do
match = create_ready_job(class_name: "Billing::ChargeJob")
create_ready_job(class_name: "WebhookJob")

get_fd "/flightdeck/jobs?state=ready&q=Billing"
get_fd "/flightdeck/jobs?state=ready&q=charge"

assert_equal [ match.id ], job_ids_in_response
end
Expand Down
54 changes: 50 additions & 4 deletions test/jobs_query_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,65 @@ class Flightdeck::JobsQueryTest < ActiveSupport::TestCase
assert_equal 1, query.count
end

test "search is a prefix match on the job class" do
test "search is a case-insensitive substring match on the job class" do
match = create_ready_job(class_name: "Billing::ChargeJob")
create_ready_job(class_name: "WebhookJob")

assert_equal [ match.id ], Flightdeck::JobsQuery.new(state: :ready, q: "Billing").rows.map(&:id)
assert_empty Flightdeck::JobsQuery.new(state: :ready, q: "ChargeJob").rows
assert_equal [ match.id ], Flightdeck::JobsQuery.new(state: :ready, q: "chargejob").rows.map(&:id)
assert_empty Flightdeck::JobsQuery.new(state: :ready, q: "Refund").rows
end

test "search escapes LIKE wildcards instead of treating them as a pattern" do
create_ready_job(class_name: "AlphaJob")
test "queue filter is a case-insensitive substring match" do
a = create_failed_job(queue_name: "within_5_minutes")
b = create_ready_job(queue_name: "within_1_minute")
create_ready_job(queue_name: "default")

assert_equal [ a.id ], Flightdeck::JobsQuery.new(state: :failed, queue_name: "5_MIN").rows.map(&:id)
assert_equal [ b.id ], Flightdeck::JobsQuery.new(state: :ready, queue_name: "within").rows.map(&:id)
end

test "search treats wildcard characters literally" do
create_ready_job(class_name: "AlphaJob", queue_name: "low")
create_ready_job(class_name: "Under_scoreJob", queue_name: "a_b")

assert_empty Flightdeck::JobsQuery.new(state: :ready, q: "%").rows
assert_empty Flightdeck::JobsQuery.new(state: :ready, q: "_lphaJob").rows
assert_equal 1, Flightdeck::JobsQuery.new(state: :ready, q: "r_s").count
assert_empty Flightdeck::JobsQuery.new(state: :ready, queue_name: "l_w").rows
assert_empty Flightdeck::JobsQuery.new(state: :ready, queue_name: "\\").rows
end

test "search folds case beyond ASCII" do
job = create_ready_job(class_name: "ÉmailJob", queue_name: "Ünicode")

assert_equal [ job.id ], Flightdeck::JobsQuery.new(state: :ready, q: "émail").rows.map(&:id)
assert_equal [ job.id ], Flightdeck::JobsQuery.new(state: :ready, queue_name: "ünI").rows.map(&:id)
end

test "fragments resolve to an exact IN list, never a LIKE scan" do
create_ready_job(class_name: "Billing::ChargeJob", queue_name: "within_5_minutes")
create_ready_job(class_name: "WebhookJob", queue_name: "default")

sql = capture_sql do
query = Flightdeck::JobsQuery.new(state: :ready, q: "charge", queue_name: "5_min")
query.rows
query.count
end

filtered = sql.grep(/solid_queue_ready_executions/)
assert filtered.any?
filtered.each { |statement| refute_match(/\bLIKE\b/i, statement) }
assert filtered.any? { |statement| statement.include?("Billing::ChargeJob") }
end

test "a fragment matching no known name returns nothing in every state" do
create_failed_job(class_name: "AlphaJob", queue_name: "critical")

Flightdeck::JobsQuery::STATES.each do |state|
assert_equal 0, Flightdeck::JobsQuery.new(state: state, q: "Nope").count, state
assert_equal 0, Flightdeck::JobsQuery.new(state: state, queue_name: "nope").count, state
end
end

test "keyset pagination walks pages without overlap or gaps" do
Expand Down
Loading