From ad55cdb323bc019d6cc41e9b406bb39e48d0415c Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Tue, 22 Feb 2022 16:38:46 -0700 Subject: [PATCH] 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 Tested-by: Service Cloud Jenkins QA-Review: Jeremy Stanley Product-Review: Jeremy Stanley --- app/controllers/jobs_v2_controller.rb | 65 +++++++++- config/routes.rb | 9 ++ spec/apis/v1/jobs_v2_api_spec.rb | 174 ++++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 spec/apis/v1/jobs_v2_api_spec.rb diff --git a/app/controllers/jobs_v2_controller.rb b/app/controllers/jobs_v2_controller.rb index 3364809632c..4fdc34dd17a 100644 --- a/app/controllers/jobs_v2_controller.rb +++ b/app/controllers/jobs_v2_controller.rb @@ -18,7 +18,7 @@ # with this program. If not, see . 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") diff --git a/config/routes.rb b/config/routes.rb index 917caec87f2..4313f258484 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/apis/v1/jobs_v2_api_spec.rb b/spec/apis/v1/jobs_v2_api_spec.rb new file mode 100644 index 00000000000..e040eae3d9d --- /dev/null +++ b/spec/apis/v1/jobs_v2_api_spec.rb @@ -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 . +# + +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