add "API" for job tag summaries

it is not a published API, as it is accessible only to site admins,
but it uses the API tooling to make frontend work easier

test plan:
 - /api/v1/jobs/tags/running should list running jobs
   grouped by tag, including `first_locked_at` which
   can be used to compute the longest runtime for any
   job with the tag
 - /api/v1/jobs/tags/queued should list runnable jobs
   that are not yet running, including `min_run_at`
   which can be used to compute the longest time
   any job with the tag has spent waiting
 - /api/v1/jobs/tags/future should list scheduled jobs
   grouped by tag, along with next_run_at
 - /api/v1/jobs/tags/failed does failed jobs, including
   last_failed_at for each tag

 - all endpoints should require site_admin read_jobs perms
 - endpoints should paginate like normal API endpoints

flag=jobs_v2
refs DE-1041

Change-Id: I2df4f62fe7a0580a54ef5c1f48c191c5167e5d42
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/285994
Reviewed-by: Aaron Ogata <aogata@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
QA-Review: Jeremy Stanley <jeremy@instructure.com>
Product-Review: Jeremy Stanley <jeremy@instructure.com>
This commit is contained in:
Jeremy Stanley 2022-02-22 16:38:46 -07:00
parent 7d0d80bdc8
commit ad55cdb323
3 changed files with 247 additions and 1 deletions

View File

@ -18,7 +18,7 @@
# with this program. If not, see <http://www.gnu.org/licenses/>.
class JobsV2Controller < ApplicationController
before_action :require_view_jobs, only: [:index]
before_action :require_view_jobs
before_action :set_site_admin_context, :set_navigation, only: [:index]
def require_view_jobs
@ -37,8 +37,71 @@ class JobsV2Controller < ApplicationController
end
end
# @{not an}API List queued jobs grouped by tag
#
# @argument order [String,"count"|"tag"|"min_run_at"]
# Sort column. Default is "min_run_at"
def queued_tags
scope = Delayed::Job.where("run_at <= now() AND (locked_by IS NULL OR locked_by = ?)", ::Delayed::Backend::Base::ON_HOLD_LOCKED_BY)
.select("count(*) AS count, tag, MIN(run_at) AS min_run_at")
.group(:tag)
.order(tags_order_clause("min_run_at ASC"))
tag_info = Api.paginate(scope, self, api_v1_jobs_tags_queued_url)
render json: tag_info.map { |info| { count: info.count, tag: info.tag, min_run_at: info.min_run_at } }
end
# @{not an}API List running jobs grouped by tag
#
# @argument order [String,"count"|"tag"|"first_locked_at"]
# Sort column. Default is "first_locked_at"
def running_tags
scope = Delayed::Job.running
.select("count(*) AS count, tag, MIN(locked_at) AS first_locked_at")
.group(:tag)
.order(tags_order_clause("first_locked_at ASC"))
tag_info = Api.paginate(scope, self, api_v1_jobs_tags_running_url)
render json: tag_info.map { |info| { count: info.count, tag: info.tag, first_locked_at: info.first_locked_at } }
end
# @{not an}API List future jobs grouped by tag
#
# @argument order [String,"count"|"tag"|"next_run_at"]
# Sort column. Default is "next_run_at"
def future_tags
scope = Delayed::Job.future
.select("count(*) AS count, tag, MIN(run_at) AS next_run_at")
.group(:tag)
.order(tags_order_clause("next_run_at ASC"))
tag_info = Api.paginate(scope, self, api_v1_jobs_tags_future_url)
render json: tag_info.map { |info| { count: info.count, tag: info.tag, next_run_at: info.next_run_at } }
end
# @{not an}API List failed jobs grouped by tag
#
# @argument order [String,"count"|"tag"|"last_failed_at"]
# Sort column. Default is "last_failed_at"
def failed_tags
scope = Delayed::Job::Failed
.select("count(*) AS count, tag, MAX(failed_at) AS last_failed_at")
.group(:tag)
.order(tags_order_clause("last_failed_at DESC"))
tag_info = Api.paginate(scope, self, api_v1_jobs_tags_failed_url)
render json: tag_info.map { |info| { count: info.count, tag: info.tag, last_failed_at: info.last_failed_at } }
end
protected
def tags_order_clause(default_order)
case params[:order]
when "tag"
"tag ASC"
when "count"
"count DESC"
else
default_order
end
end
def set_navigation
set_active_tab "jobs_v2"
add_crumb t("#crumbs.jobs_v2", "Jobs v2")

View File

@ -2410,6 +2410,15 @@ CanvasRails::Application.routes.draw do
get "jobs/:id", action: :show
post "jobs/batch_update", action: :batch_update
end
# jobs_v2 actually does do regular pagination, but the comments above
# otherwise still apply
scope(controller: :jobs_v2) do
get "jobs/tags/queued", action: :queued_tags, as: :jobs_tags_queued
get "jobs/tags/running", action: :running_tags, as: :jobs_tags_running
get "jobs/tags/future", action: :future_tags, as: :jobs_tags_future
get "jobs/tags/failed", action: :failed_tags, as: :jobs_tags_failed
end
end
# this is not a "normal" api endpoint in the sense that it is not documented

View File

@ -0,0 +1,174 @@
# frozen_string_literal: true
#
# Copyright (C) 2022 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative "../api_spec_helper"
describe "Jobs V2 API", type: :request do
include Api
describe "#queued_tags" do
it "requires site admin" do
api_call(:get, "/api/v1/jobs/tags/queued",
{ controller: "jobs_v2", action: "queued_tags", format: "json" },
{}, {}, expected_status: 401)
end
context "as site admin" do
before :once do
site_admin_user
::Kernel.delay(run_at: 1.hour.ago).sleep(1)
::Kernel.delay(run_at: 2.hours.ago).p
::Kernel.delay(run_at: 1.day.ago).sleep(1)
# fake a held job to make sure it does appear
Delayed::Job.last.update locked_by: ::Delayed::Backend::Base::ON_HOLD_LOCKED_BY
# fake a running job and a held job to be sure it doesn't appear
::Kernel.delay(run_at: 3.hours.ago).puts
Delayed::Job.last.update locked_by: "foo", locked_at: 1.hour.ago
end
it "returns queued jobs sorted by oldest" do
json = api_call(:get, "/api/v1/jobs/tags/queued",
{ controller: "jobs_v2", action: "queued_tags", format: "json" },
{}, {}, expected_status: 200)
expect(json.size).to eq 2
expect(json[0]["tag"]).to eq "Kernel.sleep"
expect(json[0]["count"]).to eq 2
expect(Time.zone.parse(json[0]["min_run_at"])).to be_within(1.minute).of(1.day.ago)
expect(json[1]["tag"]).to eq "Kernel.p"
expect(json[1]["count"]).to eq 1
expect(Time.zone.parse(json[1]["min_run_at"])).to be_within(1.minute).of(2.hours.ago)
end
it "sorts by tag" do
json = api_call(:get, "/api/v1/jobs/tags/queued?order=tag",
{ controller: "jobs_v2", action: "queued_tags", format: "json", order: "tag" },
{}, {}, expected_status: 200)
expect(json.size).to eq 2
expect(json[0]["tag"]).to eq "Kernel.p"
expect(json[1]["tag"]).to eq "Kernel.sleep"
end
it "sorts by count" do
::Kernel.delay(run_at: 2.days.ago).puts
json = api_call(:get, "/api/v1/jobs/tags/queued?order=count",
{ controller: "jobs_v2", action: "queued_tags", format: "json", order: "count" },
{}, {}, expected_status: 200)
expect(json.size).to eq 3
expect(json[0]["tag"]).to eq "Kernel.sleep"
end
it "paginates" do
json = api_call(:get, "/api/v1/jobs/tags/queued?per_page=1",
{ controller: "jobs_v2", action: "queued_tags", format: "json", per_page: "1" },
{}, {}, expected_status: 200)
expect(json.map { |e| e["tag"] }).to eq ["Kernel.sleep"]
links = Api.parse_pagination_links(response.headers["Link"])
next_link = links.find { |link| link[:rel] == "next" }
json = api_call(:get, next_link[:uri].path,
{ controller: "jobs_v2", action: "queued_tags", format: "json", per_page: "1", page: "2" },
{}, {}, expected_status: 200)
expect(json.map { |e| e["tag"] }).to eq ["Kernel.p"]
end
end
end
describe "#running_tags" do
before :once do
site_admin_user
# fake some running jobs
::Kernel.delay.sleep 1
Delayed::Job.last.update locked_at: 1.hour.ago, locked_by: "me"
::Kernel.delay.sleep 1
Delayed::Job.last.update locked_at: 2.hours.ago, locked_by: "me"
::Kernel.delay.p
Delayed::Job.last.update locked_at: 30.minutes.ago, locked_by: "foo"
# and a fake held job, to ensure it doesn't appear here
::Kernel.delay.puts
Delayed::Job.last.update locked_by: ::Delayed::Backend::Base::ON_HOLD_LOCKED_BY
end
it "returns running jobs" do
json = api_call(:get, "/api/v1/jobs/tags/running",
{ controller: "jobs_v2", action: "running_tags", format: "json" },
{}, {}, expected_status: 200)
expect(json.size).to eq 2
expect(json[0]["tag"]).to eq "Kernel.sleep"
expect(json[0]["count"]).to eq 2
expect(Time.zone.parse(json[0]["first_locked_at"])).to be_within(1.minute).of(2.hours.ago)
expect(json[1]["tag"]).to eq "Kernel.p"
expect(json[1]["count"]).to eq 1
expect(Time.zone.parse(json[1]["first_locked_at"])).to be_within(1.minute).of(30.minutes.ago)
end
end
describe "#future_tags" do
before :once do
site_admin_user
::Kernel.delay(run_at: 1.hour.from_now).sleep(1)
::Kernel.delay(run_at: 1.day.from_now).p
end
it "returns future tags sorted by next up" do
json = api_call(:get, "/api/v1/jobs/tags/future",
{ controller: "jobs_v2", action: "future_tags", format: "json" },
{}, {}, expected_status: 200)
expect(json.size).to eq 2
expect(json[0]["tag"]).to eq "Kernel.sleep"
expect(json[0]["count"]).to eq 1
expect(Time.zone.parse(json[0]["next_run_at"])).to be_within(1.minute).of(1.hour.from_now)
expect(json[1]["tag"]).to eq "Kernel.p"
expect(Time.zone.parse(json[1]["next_run_at"])).to be_within(1.minute).of(1.day.from_now)
expect(json[1]["count"]).to eq 1
end
end
describe "#failed_tags" do
before :once do
site_admin_user
Timecop.travel(1.day.ago) do
::Kernel.delay.raise "uh oh"
run_jobs
end
Timecop.travel(1.hour.ago) do
::Kernel.delay.raise "oops"
run_jobs
end
end
it "returns failed jobs sorted by most recent" do
json = api_call(:get, "/api/v1/jobs/tags/failed",
{ controller: "jobs_v2", action: "failed_tags", format: "json" },
{}, {}, expected_status: 200)
expect(json.size).to eq 1
expect(json[0]["tag"]).to eq "Kernel.raise"
expect(json[0]["count"]).to eq 2
expect(Time.zone.parse(json[0]["last_failed_at"])).to be_within(1.minute).of(1.hour.ago)
end
end
end