diff --git a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb new file mode 100644 index 0000000000000000000000000000000000000000..a3a421f8938d8d44c467182309e0ad466e60cf7d --- /dev/null +++ b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Mutations + module Admin + module SidekiqQueues + class DeleteJobs < BaseMutation + graphql_name 'AdminSidekiqQueuesDeleteJobs' + + ADMIN_MESSAGE = 'You must be an admin to use this mutation' + + Labkit::Context::KNOWN_KEYS.each do |key| + argument key, + GraphQL::STRING_TYPE, + required: false, + description: "Delete jobs matching #{key} in the context metadata" + end + + argument :queue_name, + GraphQL::STRING_TYPE, + required: true, + description: 'The name of the queue to delete jobs from' + + field :result, + Types::Admin::SidekiqQueues::DeleteJobsResponseType, + null: true, + description: 'Information about the status of the deletion request' + + def ready?(**args) + unless current_user&.admin? + raise Gitlab::Graphql::Errors::ResourceNotAvailable, ADMIN_MESSAGE + end + + super + end + + def resolve(args) + { + result: Gitlab::SidekiqQueue.new(args[:queue_name]).drop_jobs!(args, timeout: 30), + errors: [] + } + rescue Gitlab::SidekiqQueue::NoMetadataError + { + result: nil, + errors: ['No metadata provided'] + } + rescue Gitlab::SidekiqQueue::InvalidQueueError + raise Gitlab::Graphql::Errors::ResourceNotAvailable, "Queue #{args[:queue_name]} not found" + end + end + end + end +end diff --git a/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb b/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..69af9d463bb1419e8f4630b794216b8c44597868 --- /dev/null +++ b/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + module Admin + module SidekiqQueues + # We can't authorize against the value passed to this because it's + # a plain hash. + class DeleteJobsResponseType < BaseObject # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'DeleteJobsResponse' + description 'The response from the AdminSidekiqQueuesDeleteJobs mutation.' + + field :completed, + GraphQL::BOOLEAN_TYPE, + null: true, + description: 'Whether or not the entire queue was processed in time; if not, retrying the same request is safe' + + field :deleted_jobs, + GraphQL::INT_TYPE, + null: true, + description: 'The number of matching jobs deleted' + + field :queue_size, + GraphQL::INT_TYPE, + null: true, + description: 'The queue size after processing' + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 90e9e1ec0b9b69122f4a8083490c23304d45575d..d3c0d9732d240a42234cf7adc20af33911a56afc 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -6,6 +6,7 @@ class MutationType < BaseObject graphql_name 'Mutation' + mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle diff --git a/changelogs/unreleased/add-endpoint-to-remove-sidekiq-jobs-based-on-metadata.yml b/changelogs/unreleased/add-endpoint-to-remove-sidekiq-jobs-based-on-metadata.yml new file mode 100644 index 0000000000000000000000000000000000000000..09dea432cb3eabf1b4e74733daa32da08a429de0 --- /dev/null +++ b/changelogs/unreleased/add-endpoint-to-remove-sidekiq-jobs-based-on-metadata.yml @@ -0,0 +1,5 @@ +--- +title: Add admin API endpoint to delete Sidekiq jobs matching metadata +merge_request: 25998 +author: +type: added diff --git a/doc/api/admin_sidekiq_queues.md b/doc/api/admin_sidekiq_queues.md new file mode 100644 index 0000000000000000000000000000000000000000..73568581d7f772669da7292c57831a39cf7137b7 --- /dev/null +++ b/doc/api/admin_sidekiq_queues.md @@ -0,0 +1,47 @@ +# Admin Sidekiq queues API + +> **Note:** This feature was [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25998) in GitLab 12.9 + +Delete jobs from a Sidekiq queue that match the given +[metadata](../development/logging.md#logging-context-metadata-through-rails-or-grape-requests). + +The response has three fields: + +1. `deleted_jobs` - the number of jobs deleted by the request. +1. `queue_size` - the remaining size of the queue after processing the + request. +1. `completed` - whether or not the request was able to process the + entire queue in time. If not, retrying with the same parameters may + delete further jobs (including those added after the first request + was issued). + +This API endpoint is only available to admin users. + +``` +DELETE /admin/sidekiq/queues/:queue_name +``` + +| Attribute | Type | Required | Description | +| --------- | -------------- | -------- | ----------- | +| `queue_name` | string | yes | The name of the queue to delete jobs from | +| `user` | string | no | The username of the user who scheduled the jobs | +| `project` | string | no | The full path of the project where the jobs were scheduled from | +| `root_namespace` | string | no | The root namespace of the project | +| `subscription_plan` | string | no | The subscription plan of the root namespace (GitLab.com only) | +| `caller_id` | string | no | The endpoint or background job that schedule the job (for example: `ProjectsController#create`, `/api/:version/projects/:id`, `PostReceive`) | + +At least one attribute, other than `queue_name`, is required. + +```shell +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/admin/sidekiq/queues/authorized_projects?user=root +``` + +Example response: + +```json +{ + "completed": true, + "deleted_jobs": 7, + "queue_size": 14 +} +``` diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md index a20b903d534c8a3e0fb346196be3ccd44461996c..6a8394752c53d059f66d0648265a825ca7ef9cd3 100644 --- a/doc/api/api_resources.md +++ b/doc/api/api_resources.md @@ -105,41 +105,42 @@ The following API resources are available in the group context: The following API resources are available outside of project and group contexts (including `/users`): -| Resource | Available endpoints | -|:--------------------------------------------------|:------------------------------------------------------------------------| -| [Appearance](appearance.md) **(CORE ONLY)** | `/application/appearance` | -| [Applications](applications.md) | `/applications` | +| Resource | Available endpoints | +|:---------------------------------------------------|:------------------------------------------------------------------------| +| [Admin Sidekiq queues](admin_sidekiq_queues.md) | `/admin/sidekiq/queues/:queue_name` | +| [Appearance](appearance.md) **(CORE ONLY)** | `/application/appearance` | +| [Applications](applications.md) | `/applications` | | [Audit Events](audit_events.md) **(PREMIUM ONLY)** | `/audit_events` | -| [Avatar](avatar.md) | `/avatar` | -| [Broadcast messages](broadcast_messages.md) | `/broadcast_messages` | -| [Code snippets](snippets.md) | `/snippets` | -| [Custom attributes](custom_attributes.md) | `/users/:id/custom_attributes` (also available for groups and projects) | -| [Deploy keys](deploy_keys.md) | `/deploy_keys` (also available for projects) | -| [Events](events.md) | `/events`, `/users/:id/events` (also available for projects) | -| [Feature flags](features.md) | `/features` | -| [Geo Nodes](geo_nodes.md) **(PREMIUM ONLY)** | `/geo_nodes` | -| [Import repository from GitHub](import.md) | `/import/github` | -| [Issues](issues.md) | `/issues` (also available for groups and projects) | -| [Issues Statistics](issues_statistics.md) | `/issues_statistics` (also available for groups and projects) | -| [Keys](keys.md) | `/keys` | -| [License](license.md) **(CORE ONLY)** | `/license` | -| [Markdown](markdown.md) | `/markdown` | -| [Merge requests](merge_requests.md) | `/merge_requests` (also available for groups and projects) | -| [Namespaces](namespaces.md) | `/namespaces` | -| [Notification settings](notification_settings.md) | `/notification_settings` (also available for groups and projects) | -| [Pages domains](pages_domains.md) | `/pages/domains` (also available for projects) | -| [Projects](projects.md) | `/users/:id/projects` (also available for projects) | -| [Runners](runners.md) | `/runners` (also available for projects) | -| [Search](search.md) | `/search` (also available for groups and projects) | -| [Settings](settings.md) **(CORE ONLY)** | `/application/settings` | -| [Statistics](statistics.md) | `/application/statistics` | -| [Sidekiq metrics](sidekiq_metrics.md) | `/sidekiq` | -| [Suggestions](suggestions.md) | `/suggestions` | -| [System hooks](system_hooks.md) | `/hooks` | -| [Todos](todos.md) | `/todos` | -| [Users](users.md) | `/users` | -| [Validate `.gitlab-ci.yml` file](lint.md) | `/lint` | -| [Version](version.md) | `/version` | +| [Avatar](avatar.md) | `/avatar` | +| [Broadcast messages](broadcast_messages.md) | `/broadcast_messages` | +| [Code snippets](snippets.md) | `/snippets` | +| [Custom attributes](custom_attributes.md) | `/users/:id/custom_attributes` (also available for groups and projects) | +| [Deploy keys](deploy_keys.md) | `/deploy_keys` (also available for projects) | +| [Events](events.md) | `/events`, `/users/:id/events` (also available for projects) | +| [Feature flags](features.md) | `/features` | +| [Geo Nodes](geo_nodes.md) **(PREMIUM ONLY)** | `/geo_nodes` | +| [Import repository from GitHub](import.md) | `/import/github` | +| [Issues](issues.md) | `/issues` (also available for groups and projects) | +| [Issues Statistics](issues_statistics.md) | `/issues_statistics` (also available for groups and projects) | +| [Keys](keys.md) | `/keys` | +| [License](license.md) **(CORE ONLY)** | `/license` | +| [Markdown](markdown.md) | `/markdown` | +| [Merge requests](merge_requests.md) | `/merge_requests` (also available for groups and projects) | +| [Namespaces](namespaces.md) | `/namespaces` | +| [Notification settings](notification_settings.md) | `/notification_settings` (also available for groups and projects) | +| [Pages domains](pages_domains.md) | `/pages/domains` (also available for projects) | +| [Projects](projects.md) | `/users/:id/projects` (also available for projects) | +| [Runners](runners.md) | `/runners` (also available for projects) | +| [Search](search.md) | `/search` (also available for groups and projects) | +| [Settings](settings.md) **(CORE ONLY)** | `/application/settings` | +| [Statistics](statistics.md) | `/application/statistics` | +| [Sidekiq metrics](sidekiq_metrics.md) | `/sidekiq` | +| [Suggestions](suggestions.md) | `/suggestions` | +| [System hooks](system_hooks.md) | `/hooks` | +| [Todos](todos.md) | `/todos` | +| [Users](users.md) | `/users` | +| [Validate `.gitlab-ci.yml` file](lint.md) | `/lint` | +| [Version](version.md) | `/version` | ## Templates API resources diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 8d6fc859ca22293885cf77cae1b4fbc7d2ab56eb..643b956081ffa5e9bc98a621d25e42483c313542 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -38,6 +38,66 @@ type AddAwardEmojiPayload { errors: [String!]! } +""" +Autogenerated input type of AdminSidekiqQueuesDeleteJobs +""" +input AdminSidekiqQueuesDeleteJobsInput { + """ + Delete jobs matching caller_id in the context metadata + """ + callerId: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Delete jobs matching project in the context metadata + """ + project: String + + """ + The name of the queue to delete jobs from + """ + queueName: String! + + """ + Delete jobs matching root_namespace in the context metadata + """ + rootNamespace: String + + """ + Delete jobs matching subscription_plan in the context metadata + """ + subscriptionPlan: String + + """ + Delete jobs matching user in the context metadata + """ + user: String +} + +""" +Autogenerated return type of AdminSidekiqQueuesDeleteJobs +""" +type AdminSidekiqQueuesDeleteJobsPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + Information about the status of the deletion request + """ + result: DeleteJobsResponse +} + """ An emoji awarded by a user. """ @@ -601,6 +661,26 @@ type CreateSnippetPayload { snippet: Snippet } +""" +The response from the AdminSidekiqQueuesDeleteJobs mutation. +""" +type DeleteJobsResponse { + """ + Whether or not the entire queue was processed in time; if not, retrying the same request is safe + """ + completed: Boolean + + """ + The number of matching jobs deleted + """ + deletedJobs: Int + + """ + The queue size after processing + """ + queueSize: Int +} + """ A single design """ @@ -4772,6 +4852,7 @@ enum MoveType { type Mutation { addAwardEmoji(input: AddAwardEmojiInput!): AddAwardEmojiPayload + adminSidekiqQueuesDeleteJobs(input: AdminSidekiqQueuesDeleteJobsInput!): AdminSidekiqQueuesDeleteJobsPayload createDiffNote(input: CreateDiffNoteInput!): CreateDiffNotePayload createEpic(input: CreateEpicInput!): CreateEpicPayload createImageDiffNote(input: CreateImageDiffNoteInput!): CreateImageDiffNotePayload diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 4f2cbe81781102e8333b07f65d007c1352fc91a3..dbd16022d42d738624be592bae53e7b47b75cffa 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -19106,6 +19106,33 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "adminSidekiqQueuesDeleteJobs", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AdminSidekiqQueuesDeleteJobsInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AdminSidekiqQueuesDeleteJobsPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "createDiffNote", "description": null, @@ -19978,6 +20005,213 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "AdminSidekiqQueuesDeleteJobsPayload", + "description": "Autogenerated return type of AdminSidekiqQueuesDeleteJobs", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "result", + "description": "Information about the status of the deletion request", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DeleteJobsResponse", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DeleteJobsResponse", + "description": "The response from the AdminSidekiqQueuesDeleteJobs mutation.", + "fields": [ + { + "name": "completed", + "description": "Whether or not the entire queue was processed in time; if not, retrying the same request is safe", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedJobs", + "description": "The number of matching jobs deleted", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "queueSize", + "description": "The queue size after processing", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AdminSidekiqQueuesDeleteJobsInput", + "description": "Autogenerated input type of AdminSidekiqQueuesDeleteJobs", + "fields": null, + "inputFields": [ + { + "name": "user", + "description": "Delete jobs matching user in the context metadata", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "project", + "description": "Delete jobs matching project in the context metadata", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "rootNamespace", + "description": "Delete jobs matching root_namespace in the context metadata", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "subscriptionPlan", + "description": "Delete jobs matching subscription_plan in the context metadata", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "callerId", + "description": "Delete jobs matching caller_id in the context metadata", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "queueName", + "description": "The name of the queue to delete jobs from", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "AddAwardEmojiPayload", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 1a9be3f496b417423e9021d54a286bf5bc2785ef..dbcd4839829d612d6427a4350444dcc4ee960d1c 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -26,6 +26,16 @@ Autogenerated return type of AddAwardEmoji | `clientMutationId` | String | A unique identifier for the client performing the mutation. | | `errors` | String! => Array | Reasons why the mutation failed. | +## AdminSidekiqQueuesDeleteJobsPayload + +Autogenerated return type of AdminSidekiqQueuesDeleteJobs + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `result` | DeleteJobsResponse | Information about the status of the deletion request | + ## AwardEmoji An emoji awarded by a user. @@ -129,6 +139,16 @@ Autogenerated return type of CreateSnippet | `errors` | String! => Array | Reasons why the mutation failed. | | `snippet` | Snippet | The snippet after mutation | +## DeleteJobsResponse + +The response from the AdminSidekiqQueuesDeleteJobs mutation. + +| Name | Type | Description | +| --- | ---- | ---------- | +| `completed` | Boolean | Whether or not the entire queue was processed in time; if not, retrying the same request is safe | +| `deletedJobs` | Int | The number of matching jobs deleted | +| `queueSize` | Int | The queue size after processing | + ## Design A single design diff --git a/lib/api/admin/sidekiq.rb b/lib/api/admin/sidekiq.rb new file mode 100644 index 0000000000000000000000000000000000000000..a700bea0fd79d652e329cc61be78d7dbc3646d82 --- /dev/null +++ b/lib/api/admin/sidekiq.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module API + module Admin + class Sidekiq < Grape::API + before { authenticated_as_admin! } + + namespace 'admin' do + namespace 'sidekiq' do + namespace 'queues' do + desc 'Drop jobs matching the given metadata from the Sidekiq queue' + params do + Labkit::Context::KNOWN_KEYS.each do |key| + optional key, type: String, allow_blank: false + end + + at_least_one_of(*Labkit::Context::KNOWN_KEYS) + end + delete ':queue_name' do + result = + Gitlab::SidekiqQueue + .new(params[:queue_name]) + .drop_jobs!(declared_params, timeout: 30) + + present result + rescue Gitlab::SidekiqQueue::NoMetadataError + render_api_error!("Invalid metadata: #{declared_params}", 400) + rescue Gitlab::SidekiqQueue::InvalidQueueError + not_found!(params[:queue_name]) + end + end + end + end + end + end +end diff --git a/lib/api/api.rb b/lib/api/api.rb index b6c6ad10d304a0d469d10a86ae572fd7f8253b68..ca6e6de33429d7522242f4e9d4ea98917fb9b2d0 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -110,6 +110,7 @@ class API < Grape::API # Keep in alphabetical order mount ::API::AccessRequests + mount ::API::Admin::Sidekiq mount ::API::Appearance mount ::API::Applications mount ::API::Avatar diff --git a/lib/gitlab/sidekiq_queue.rb b/lib/gitlab/sidekiq_queue.rb new file mode 100644 index 0000000000000000000000000000000000000000..9e9ad3107f3323885aacdde0d12e893aefa48525 --- /dev/null +++ b/lib/gitlab/sidekiq_queue.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Gitlab + class SidekiqQueue + include Gitlab::Utils::StrongMemoize + + NoMetadataError = Class.new(StandardError) + InvalidQueueError = Class.new(StandardError) + + attr_reader :queue_name + + def initialize(queue_name) + @queue_name = queue_name + end + + def drop_jobs!(search_metadata, timeout:) + completed = false + deleted_jobs = 0 + + job_search_metadata = + search_metadata + .stringify_keys + .slice(*Labkit::Context::KNOWN_KEYS) + .transform_keys { |key| "meta.#{key}" } + .compact + + raise NoMetadataError if job_search_metadata.empty? + raise InvalidQueueError unless queue + + begin + Timeout.timeout(timeout) do + queue.each do |job| + next unless job_matches?(job, job_search_metadata) + + job.delete + deleted_jobs += 1 + end + + completed = true + end + rescue Timeout::Error + end + + { + completed: completed, + deleted_jobs: deleted_jobs, + queue_size: queue.size + } + end + + def queue + strong_memoize(:queue) do + # Sidekiq::Queue.new always returns a queue, even if it doesn't + # exist. + Sidekiq::Queue.all.find { |queue| queue.name == queue_name } + end + end + + def job_matches?(job, job_search_metadata) + job_search_metadata.all? { |key, value| job[key] == value } + end + end +end diff --git a/spec/lib/gitlab/sidekiq_queue_spec.rb b/spec/lib/gitlab/sidekiq_queue_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7a4d47563b6f654cc53a7ae6cc2a9353d6681386 --- /dev/null +++ b/spec/lib/gitlab/sidekiq_queue_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::SidekiqQueue do + around do |example| + Sidekiq::Queue.new('authorized_projects').clear + Sidekiq::Testing.disable!(&example) + Sidekiq::Queue.new('authorized_projects').clear + end + + def add_job(user) + Sidekiq::Client.push( + 'class' => 'AuthorizedProjectsWorker', + 'queue' => 'authorized_projects', + 'args' => [user.id], + 'meta.user' => user.username + ) + end + + describe '#drop_jobs!' do + shared_examples 'queue processing' do + let(:sidekiq_queue) { described_class.new('authorized_projects') } + let_it_be(:sidekiq_queue_user) { create(:user) } + + before do + add_job(create(:user)) + add_job(sidekiq_queue_user) + add_job(sidekiq_queue_user) + end + + context 'when the queue is not processed in time' do + before do + calls = 0 + + allow(sidekiq_queue).to receive(:job_matches?).and_wrap_original do |m, *args| + raise Timeout::Error if calls > 0 + + calls += 1 + m.call(*args) + end + end + + it 'returns a non-completion flag, the number of jobs deleted, and the remaining queue size' do + expect(sidekiq_queue.drop_jobs!(search_metadata, timeout: 10)) + .to eq(completed: false, + deleted_jobs: timeout_deleted, + queue_size: 3 - timeout_deleted) + end + end + + context 'when the queue is processed in time' do + it 'returns a completion flag, the number of jobs deleted, and the remaining queue size' do + expect(sidekiq_queue.drop_jobs!(search_metadata, timeout: 10)) + .to eq(completed: true, + deleted_jobs: no_timeout_deleted, + queue_size: 3 - no_timeout_deleted) + end + end + end + + context 'when there are no matching jobs' do + include_examples 'queue processing' do + let(:search_metadata) { { project: 1 } } + let(:timeout_deleted) { 0 } + let(:no_timeout_deleted) { 0 } + end + end + + context 'when there are matching jobs' do + include_examples 'queue processing' do + let(:search_metadata) { { user: sidekiq_queue_user.username } } + let(:timeout_deleted) { 1 } + let(:no_timeout_deleted) { 2 } + end + end + + context 'when there are no valid metadata keys passed' do + it 'raises NoMetadataError' do + add_job(create(:user)) + + expect { described_class.new('authorized_projects').drop_jobs!({ username: 'sidekiq_queue_user' }, timeout: 1) } + .to raise_error(described_class::NoMetadataError) + end + end + + context 'when the queue does not exist' do + it 'raises InvalidQueueError' do + expect { described_class.new('foo').drop_jobs!({ user: 'sidekiq_queue_user' }, timeout: 1) } + .to raise_error(described_class::InvalidQueueError) + end + end + end +end diff --git a/spec/requests/api/admin/sidekiq_spec.rb b/spec/requests/api/admin/sidekiq_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0fb8199eec657845de14c998d1dfdf41ca7ad191 --- /dev/null +++ b/spec/requests/api/admin/sidekiq_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Admin::Sidekiq do + let_it_be(:admin) { create(:admin) } + + describe 'DELETE /admin/sidekiq/queues/:queue_name' do + context 'when the user is not an admin' do + it 'returns a 403' do + delete api("/admin/sidekiq/queues/authorized_projects?user=#{admin.username}", create(:user)) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when the user is an admin' do + around do |example| + Sidekiq::Queue.new('authorized_projects').clear + Sidekiq::Testing.disable!(&example) + Sidekiq::Queue.new('authorized_projects').clear + end + + def add_job(user) + Sidekiq::Client.push( + 'class' => 'AuthorizedProjectsWorker', + 'queue' => 'authorized_projects', + 'args' => [user.id], + 'meta.user' => user.username + ) + end + + context 'valid request' do + it 'returns info about the deleted jobs' do + add_job(admin) + add_job(admin) + add_job(create(:user)) + + delete api("/admin/sidekiq/queues/authorized_projects?user=#{admin.username}", admin) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq('completed' => true, + 'deleted_jobs' => 2, + 'queue_size' => 1) + end + end + + context 'when no required params are provided' do + it 'returns a 400' do + delete api("/admin/sidekiq/queues/authorized_projects?user_2=#{admin.username}", admin) + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'when the queue does not exist' do + it 'returns a 404' do + delete api("/admin/sidekiq/queues/authorized_projects_2?user=#{admin.username}", admin) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb b/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9451587fac33235b752918c4d3acb8700ad9b0cf --- /dev/null +++ b/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Deleting Sidekiq jobs' do + include GraphqlHelpers + + let_it_be(:admin) { create(:admin) } + + let(:variables) { { user: admin.username, queue_name: 'authorized_projects' } } + let(:mutation) { graphql_mutation(:admin_sidekiq_queues_delete_jobs, variables) } + + def mutation_response + graphql_mutation_response(:admin_sidekiq_queues_delete_jobs) + end + + context 'when the user is not an admin' do + let(:current_user) { create(:user) } + + it_behaves_like 'a mutation that returns top-level errors', + errors: ['You must be an admin to use this mutation'] + end + + context 'when the user is an admin' do + let(:current_user) { admin } + + context 'valid request' do + around do |example| + Sidekiq::Queue.new('authorized_projects').clear + Sidekiq::Testing.disable!(&example) + Sidekiq::Queue.new('authorized_projects').clear + end + + def add_job(user) + Sidekiq::Client.push( + 'class' => 'AuthorizedProjectsWorker', + 'queue' => 'authorized_projects', + 'args' => [user.id], + 'meta.user' => user.username + ) + end + + it 'returns info about the deleted jobs' do + add_job(admin) + add_job(admin) + add_job(create(:user)) + + post_graphql_mutation(mutation, current_user: admin) + + expect(mutation_response['errors']).to be_empty + expect(mutation_response['result']).to eq('completed' => true, + 'deletedJobs' => 2, + 'queueSize' => 1) + end + end + + context 'when no required params are provided' do + let(:variables) { { queue_name: 'authorized_projects' } } + + it_behaves_like 'a mutation that returns errors in the response', + errors: ['No metadata provided'] + end + + context 'when the queue does not exist' do + let(:variables) { { user: admin.username, queue_name: 'authorized_projects_2' } } + + it_behaves_like 'a mutation that returns top-level errors', + errors: ['Queue authorized_projects_2 not found'] + end + end +end