Skip to content

Cascade pipeline cancellation in multi-project pipeline

Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.

The problem

We are using Multi-Project Pipeline, with strategy:depend.

Both the triggering/upstream pipeline and the downstream pipeline jobs are interruptible.

When the Upstream pipeline is canceled while the downstream pipeline is still running, we want that the downstream pipeline to be canceled as well.

A use case is when the pipeline is set to "Auto-cancel redundant pipelines". If a new pipeline begun then we want that the entire stream will be canceled.
There is no point of keeping the downstream pipeline running.

Issue #273378 (closed) did this change for "parent/child pipeline" but not for "multi-project pipeline". See this comment.

The same should be done for "multi-project pipeline".

Proposal

When both the "upstream" pipeline and the "downstream" pipeline in a multi-project pipeline are interruptible and the upstream is canceled, the downstream will be canceled as well.

This should be applied to both:

  1. if the downstream pipeline was triggered by the trigger keyword
  2. if the downstream pipeline was triggered using the API

Note: we currently consider same-project child pipeline cancellations to be 'system' cancellations. We will need to authorize against the user who triggered the cancellation for each pipeline for multi-project downstreams.

Rough Implementation guide

diff --git a/app/services/ci/cancel_pipeline_service.rb b/app/services/ci/cancel_pipeline_service.rb
index 92eead3fdd14..dc847d9a6951 100644
--- a/app/services/ci/cancel_pipeline_service.rb
+++ b/app/services/ci/cancel_pipeline_service.rb
@@ -29,13 +29,22 @@ def initialize(
     def execute
       return permission_error_response unless can?(current_user, :cancel_pipeline, pipeline)

-      force_execute
+      cancel(current_user)
     end

     # This method should be used only when we want to always cancel the pipeline without
     # checking whether the current_user has permissions to do so, or when we don't have
     # a current_user available in the context.
     def force_execute
+      # We pass a nil user to child cancellations. We don't cancel children on forces at this time.
+      # since that is handled as part of the CancelRedundantPipelinesService.
+      cancel(nil)
+    end
+
+    private
+
+    attr_reader :pipeline, :current_user
+
+    # The user for the child cancellation check
+    def cancel(user)
       return ServiceResponse.error(message: 'No pipeline provided', reason: :no_pipeline) unless pipeline

       unless pipeline.cancelable?
@@ -54,15 +63,11 @@ def force_execute
         cancel_jobs(pipeline.cancelable_statuses)
       end

-      cancel_children if cascade_to_children?
+      cancel_children(user) if cascade_to_children?

       ServiceResponse.success
     end

-    private
-
-    attr_reader :pipeline, :current_user
-
     def log_pipeline_being_canceled
       Gitlab::AppJsonLogger.info(
         event: 'pipeline_cancel_running',
@@ -117,22 +122,23 @@ def permission_error_response
     # from `CancelRedundantPipelinesService`.
     # In the future, when "safe cancellation" is implemented as a regular cancellation feature,
     # we need to handle this case.
-    def cancel_children
+    def cancel_children(user)
       cancel_jobs(pipeline.bridges_in_self_and_project_descendants.cancelable)

       # For parent child-pipelines only (not multi-project)
       pipeline.all_child_pipelines.each do |child_pipeline|
         if execute_async?
-          ::Ci::CancelPipelineWorker.perform_async(
+          ::Ci::UserCancelPipelineWorker.perform_async(
             child_pipeline.id,
-            @auto_canceled_by_pipeline&.id
+            @auto_canceled_by_pipeline&.id,
+            user
           )
         else
           # cascade_to_children is false because we iterate through children
           # we also cancel bridges prior to prevent more children
           self.class.new(
             pipeline: child_pipeline.reset,
-            current_user: nil,
+            current_user: user,
             cascade_to_children: false,
             execute_async: execute_async?,
             auto_canceled_by_pipeline: @auto_canceled_by_pipeline

This should allow use to remove Ci::CancelPipelineWorker #452215.

Edited by 🤖 GitLab Bot 🤖