Module: RoundhouseUi::JobSetBrowsing
- Extended by:
- ActiveSupport::Concern
- Included in:
- DeadController, QueuesController, RetriesController, ScheduledController
- Defined in:
- app/controllers/concerns/roundhouse_ui/job_set_browsing.rb
Overview
Shared search + pagination over a Sidekiq job set (dead, retry, scheduled). Keeps the controllers from duplicating the scan/filter/window logic.
Defined Under Namespace
Classes: Matched
Constant Summary collapse
- PER_PAGE =
25- MAX_QUERY_LENGTH =
The longest needle worth honouring. A megabyte of
qagainst twenty thousand entries took five seconds in one request — the substring scan is linear in both, so the needle is a free multiplier on someone else's CPU. Longer than any error message anyone searches for, and checked before any comparison runs (the same ordering as MAX_JOB_CLASS_NAME in lib/roundhouse_ui.rb). The parser owns the bound now — one definition, checked before any character of the input is inspected. Kept as a name because tests and the read-only guard refer to it. FilterQuery::MAX_LENGTH
- BULK_CAP =
safety ceiling on a single match-set action
1_000- NO_FILTER =
"Refused: a bulk action needs a filter. Without one it would act on " \ "every job in the set, which is not what this control is for.".freeze
- FILTER_KEYS =
Every filter, once per request, rather than re-derived in each action. The queue filter was already assigned in seven places across three controllers; class and error would have made that twenty-one, and a browse that read one filter while its bulk counterpart read another is the failure this whole file is arranged to prevent. Every filter this concern understands. One list, so "all of them" is a thing the code can say.
Read from all five, written back as one.
?q=is now the whole filter —q=class=EmbeddingWorker error=KeyError stripe— so a link, a form and a bookmark carry a single value that either travels or doesn't. It used to be five, and "the confirm form carried four of the five" is precisely how a dry run listing two jobs deleted five. The other four survive as a read-only legacy shape (FilterQuery.from_params), because somebody has them bookmarked. %i[q tag queue class error].freeze
Instance Method Summary collapse
-
#active_filters ⇒ Object
The filters currently in force, as URL params.
- #browse(set, query, page, per = PER_PAGE, tag: nil) ⇒ Object
- #bulk_apply(set, query, op, cap = BULK_CAP, tag: nil) ⇒ Object
-
#bulk_filter_present?(query, tag) ⇒ Boolean
Apply an op ("retry"/"delete") to every entry matching the query, capped at BULK_CAP.
- #bulk_matches(set, query, cap = BULK_CAP, tag: nil) ⇒ Object
-
#bulk_refusal_reason ⇒ Object
Why a bulk action was not authorised, in the words to show the operator.
-
#class_filter ⇒ Object
class=anderror=— the pair behind "find more like this", and typeable. -
#entry_matches?(entry, query, tags = Tags::EMPTY) ⇒ Boolean
Tag values are part of the haystack, so typing a squad name finds its jobs without reaching for the structured filter.
-
#entry_selected?(entry, query, tag, cache) ⇒ Boolean
Both the browse and bulk paths run every candidate through this, so the rows an operator sees are exactly the rows a bulk action will touch — including when a tag value is what matched the free-text search.
- #entry_tagged?(tags, key, value) ⇒ Boolean
- #entry_tags(entry, cache) ⇒ Object
- #error_filter ⇒ Object
-
#filter ⇒ Object
The parsed filter, never nil.
-
#load_filters ⇒ Object
One parse per request, and everything else reads off it.
-
#query_refused? ⇒ Boolean
A refused query selects nothing, rather than being truncated to something shorter that would select MORE.
-
#queue_filter ⇒ Object
?queue=name— exact match, so clicking a queue pill or picking one from the palette narrows to that queue. -
#tag_cache_for(_tag) ⇒ Object
Shares the request memo with TagsHelper — controller ivars carry into the view, so an entry resolved while scanning is not resolved again when its badge renders.
-
#tag_filter ⇒ Object
Returns [entries_for_page, has_next?].
Instance Method Details
#active_filters ⇒ Object
The filters currently in force, as URL params. THE single serialization point: every URL and form that must preserve the filter starts from all of it and names only what it changes, so dropping one is not expressible.
This was hand-enumerated at six sites. Adding the class/error pair updated three of them, and the confirm form was one of the three that were missed — so a dry run listing two jobs POSTed a request that deleted five, and reported "Deleted 5 matching job(s)" as if that had been approved. The comment above bulk_matches promises the dry run and the action "cannot disagree about what matching means". They could, because they were handed different filters.
57 58 59 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 57 def active_filters { q: filter.to_s.presence }.compact end |
#browse(set, query, page, per = PER_PAGE, tag: nil) ⇒ Object
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 93 def browse(set, query, page, per = PER_PAGE, tag: nil) start = (page - 1) * per jobs = [] has_next = false matched = 0 cache = tag_cache_for(tag) set.each do |entry| next unless entry_selected?(entry, query, tag, cache) if matched < start matched += 1 elsif jobs.size < per jobs << entry matched += 1 else has_next = true break end end [ jobs, has_next ] end |
#bulk_apply(set, query, op, cap = BULK_CAP, tag: nil) ⇒ Object
184 185 186 187 188 189 190 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 184 def bulk_apply(set, query, op, cap = BULK_CAP, tag: nil) found = bulk_matches(set, query, cap, tag: tag) return found if found.unfiltered found.entries.each { |entry| op == "delete" ? entry.delete : entry.retry } found end |
#bulk_filter_present?(query, tag) ⇒ Boolean
Apply an op ("retry"/"delete") to every entry matching the query, capped at BULK_CAP. Entries are collected first, then acted on — mutating a Sidekiq set mid-iteration skips entries. Returns [count_acted_on, capped?]. The same scan the action runs, stopped one step early, so a dry run and the action it confirms cannot disagree about what "matching" means. Is any filter active? The single source of truth for "this bulk action has a scope". tags_helper's any_filter? delegates here rather than recomputing it — the view and the route disagreeing is exactly how the hole below happened.
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 125 def bulk_filter_present?(query, tag) return false if query_refused? # A DEGRADED query dropped a facet it could not use. Browse proceeds on what # survived — that is the point of dropping rather than refusing — but what # survived selects a SUPERSET of what was typed. `tag=garbage queue=default` # becomes `queue=default`, so a Delete here would take the whole queue while # the operator believed the tag narrowed it too. Browse and bulk still read # one identical filter; bulk just declines to act on a widened one. return false if filter.degraded? # Reads the SAME parse entry_selected? reads. It used to read @class_filter and # friends while the predicate read the FilterQuery, and the two diverged the # moment wildcards moved the predicate over: a class-filtered bulk delete found # a filter here, found none in the predicate, and took every row in the set. # Caught by test_the_class_filter_alone_still_spares_a_longer_name. Those ivars # are gone now, so the two cannot be given different answers. query.present? || !tag.nil? || filter.any_facets? end |
#bulk_matches(set, query, cap = BULK_CAP, tag: nil) ⇒ Object
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 153 def bulk_matches(set, query, cap = BULK_CAP, tag: nil) # An unfiltered bulk action selects EVERY entry: entry_selected? finds no # filter to fail, `"".present?` is false, and `return true if tag.nil?` does # the rest. So POST /dead/bulk_all with nothing but op=delete emptied the set, # up to the cap, and reported "Deleted 50 matching job(s)" as if that were # the request. Verified against a real Redis before this guard existed. # # The comment above bulk_all claimed it was "only offered when a filter is # active" — and it was only OFFERED that way. The button was hidden by the # view while the route stayed open. The guard belongs here, at the one place # both the dry run and the action pass through, not in a before_action that # the next destructive action can forget to add. unless bulk_filter_present?(query, tag) return Matched.new(entries: [], capped: false, unfiltered: true, reason: bulk_refusal_reason) end entries = [] capped = false cache = tag_cache_for(tag) set.each do |entry| next unless entry_selected?(entry, query, tag, cache) entries << entry if entries.size >= cap capped = true break end end Matched.new(entries: entries, capped: capped) end |
#bulk_refusal_reason ⇒ Object
Why a bulk action was not authorised, in the words to show the operator.
145 146 147 148 149 150 151 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 145 def bulk_refusal_reason return "Refused: that search was not understood, so it selects nothing. #{filter.}" if query_refused? return "Refused: #{filter.notes.join(' ')} Fix the search and the bulk actions come back — " \ "acting now would touch every job the dropped filter would have excluded." if filter.degraded? NO_FILTER end |
#class_filter ⇒ Object
class= and error= — the pair behind "find more like this", and typeable.
Exact unless you add a %: these render the "delete all matching" buttons
beneath them, and a bare substring would select jobs whose ARGUMENTS merely
mention the class.
Class is compared unwrapped, so the filter means the same string the row displays and the same one the Errors page groups by — one definition of "the same problem" across the console.
226 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 226 def class_filter = filter.klass |
#entry_matches?(entry, query, tags = Tags::EMPTY) ⇒ Boolean
Tag values are part of the haystack, so typing a squad name finds its jobs without reaching for the structured filter. Safe to widen here only because browse and bulk_apply share this predicate — if they diverged, a search would show one set of rows and "delete all matching" would act on another.
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 263 def entry_matches?(entry, query, = Tags::EMPTY) needle = query.downcase # Queue matches on equality, not substring: typing a queue name should # find its jobs, but this predicate also drives bulk_apply, so "default" # must never additionally select "default_low". return true if entry.queue.to_s.downcase == needle # The unwrapped class is added rather than substituted. Since bulk actions # run through this same predicate, replacing would silently empty a saved # or habitual "JobWrapper" query — safe in direction, but a bulk query # that used to select thousands would quietly select none. Appending only # ever widens, and the real class name starts working. item = entry.item cheap = [ entry.klass, RoundhouseUi.unwrapped_class(entry.klass, item), entry.jid, item["error_class"], item["error_message"], *.values ] return true if cheap.any? { |hay| hay.to_s.downcase.include?(needle) } # Arguments are searched REDACTED — exactly as they are displayed. # # Searching the raw values turned this box into an oracle. The UI masks # api_token, but `q=sk_live_S` matched and `q=sk_live_X` did not, so a secret # could be read out one character at a time by someone who can see the console # and not the secrets — which is the whole population redact_args exists for. # A sixteen-character token falls in a couple of hundred queries, and the same # needle scopes a bulk delete, so the oracle worked through the dry run too. # # Also computed last, and only if the cheap fields missed. It used to be built # eagerly into the array above, so every entry paid for stringifying its # arguments whether or not anything else had already matched. Redaction.apply(entry.args).to_s.downcase.include?(needle) end |
#entry_selected?(entry, query, tag, cache) ⇒ Boolean
Both the browse and bulk paths run every candidate through this, so the rows an operator sees are exactly the rows a bulk action will touch — including when a tag value is what matched the free-text search.
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 195 def entry_selected?(entry, query, tag, cache) return false if query_refused? # Compared through the filter, not with ==, so `class=Roundhouse%` narrows here # exactly as it does on the dry run and the bulk action — all three read this # one predicate. Without a `%` it is still a plain equality: `queue=default` # must never also select `default_low`. return false unless filter.matches_facet?(:queue, entry.queue) return false unless filter.matches_facet?(:klass, RoundhouseUi.unwrapped_class(entry.klass, entry.item)) return false unless filter.matches_facet?(:error, entry.item["error_class"]) = (entry, cache) return false if query.present? && !entry_matches?(entry, query, ) return true if tag.nil? entry_tagged?(, tag) end |
#entry_tagged?(tags, key, value) ⇒ Boolean
230 231 232 233 234 235 236 237 238 239 240 241 242 243 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 230 def entry_tagged?(, (key, value)) # A declared vocabulary is authoritative: filtering on a key the host # never declared matches nothing rather than everything. declared = Tags.filters return false if declared && !declared.key?(key) # `tag=squad:plat%` wildcards the VALUE only. The key stays exact, because it # is checked against the declared vocabulary above and a wildcarded key would # walk straight past that check. pattern = FilterQuery::Pattern.for(value) return pattern.match?([key.to_s]) if pattern Tags.match?(, key, value) end |
#entry_tags(entry, cache) ⇒ Object
245 246 247 248 249 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 245 def (entry, cache) return Tags::EMPTY unless RoundhouseUi. Tags.for(klass: entry.klass, item: entry.item, cache: cache) end |
#error_filter ⇒ Object
228 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 228 def error_filter = filter.error |
#filter ⇒ Object
The parsed filter, never nil. entry_selected? is reachable without going through load_filters — the real-Redis tests drive bulk_apply directly, and so could a future action — and a NoMethodError inside the scan predicate is a 500 on a page that was only browsing. FilterQuery.none matches everything, which is the same thing "no filter" has always meant here; bulk stays gated because bulk_filter_present? finds nothing to narrow on.
78 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 78 def filter = @filter ||= FilterQuery.none |
#load_filters ⇒ Object
One parse per request, and everything else reads off it. The ivars below are kept because entry_selected? and TagsHelper are written against them; they are now views onto @filter rather than five independent reads of params, so "the browse read one filter while its bulk counterpart read another" is no longer a shape this code can take.
66 67 68 69 70 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 66 def load_filters @filter = FilterQuery.from_params(params) @query = @filter.text @tag = @filter.tag_pair end |
#query_refused? ⇒ Boolean
A refused query selects nothing, rather than being truncated to something shorter that would select MORE. Truncation is the tempting fix and the wrong one: it silently widens, and this predicate drives Delete.
83 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 83 def query_refused? = filter.invalid? |
#queue_filter ⇒ Object
?queue=name — exact match, so clicking a queue pill or picking one from
the palette narrows to that queue. Exact rather than substring because
this feeds bulk_apply too, and "default" must never also select
"default_low".
216 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 216 def queue_filter = filter.queue |
#tag_cache_for(_tag) ⇒ Object
Shares the request memo with TagsHelper — controller ivars carry into the view, so an entry resolved while scanning is not resolved again when its badge renders. Tags.for picks the key: class name normally, jid in per-job mode.
255 256 257 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 255 def tag_cache_for(_tag) @rh_tag_cache ||= {} end |
#tag_filter ⇒ Object
Returns [entries_for_page, has_next?]. Scans only far enough to fill the
requested page plus one (to know if a next page exists) — never loads the
whole set, so a 50k dead set stays cheap to page through.
tag=key:value inside ?q= — exact, against a host-defined tag (ADR 0002).
Tag values are also in the free-text haystack; safe only because browse and
bulk_apply share this one predicate.
91 |
# File 'app/controllers/concerns/roundhouse_ui/job_set_browsing.rb', line 91 def tag_filter = filter.tag_pair |