diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index ceaac224b329865185ebd92fd9adf2c734cb91a4..6f0d5280ddf22a56848687baae83f2dd25428099 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -397,6 +397,9 @@ def supported_keyset_orderings after_transition any => [:success] do |build| build.run_after_commit do PagesWorker.perform_async(:deploy, id) if build.pages_generator? + + # Cache successful builds with artifacts for quick retrieval + build.project.cache_successful_build(build.name, build.ref, build) if build.artifacts_file&.exists? end end diff --git a/app/models/project.rb b/app/models/project.rb index e6959736dadbda84b592306c7d99fed893235e4d..2a531e871d5593a9bea79b4c07f9c7b04bb5fb23 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1612,6 +1612,26 @@ def latest_successful_build_for_ref!(job_name, ref = default_branch) latest_successful_build_for_ref(job_name, ref) || raise(ActiveRecord::RecordNotFound, "Couldn't find job #{job_name}") end + # Cache key for successful build artifacts + def successful_build_cache_key(job_name, ref) + "project:#{id}:successful_build:#{ref}:#{job_name}" + end + + # Cache a successful build for quick retrieval + def cache_successful_build(job_name, ref, build) + cache_key = successful_build_cache_key(job_name, ref) + Rails.cache.write(cache_key, build.id, expires_in: 1.hour) + end + + # Retrieve cached successful build + def cached_successful_build(job_name, ref) + cache_key = successful_build_cache_key(job_name, ref) + build_id = Rails.cache.read(cache_key) + return unless build_id + + builds.find_by(id: build_id) + end + def latest_pipelines(ref: default_branch, sha: nil, limit: nil, source: nil) ref = ref.presence || default_branch sha ||= commit(ref)&.sha diff --git a/app/services/ci/job_artifacts/find_cached_artifact_service.rb b/app/services/ci/job_artifacts/find_cached_artifact_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..b929365656ed34d26d848c8972da42cb83adc68c --- /dev/null +++ b/app/services/ci/job_artifacts/find_cached_artifact_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Ci + module JobArtifacts + class FindCachedArtifactService + CACHE_EXPIRY = 30.minutes + PIPELINE_SEARCH_LIMIT = 100 + + def initialize(project, job_name, ref_name) + @project = project + @job_name = job_name + @ref_name = ref_name + end + + def execute + # Try Tier 1 cache first (application cache) + cached_build = @project.cached_successful_build(@job_name, @ref_name) + return cached_build if cached_build&.artifacts_file&.exists? + + # Tier 1 cache miss or artifacts no longer exist + # Try to find the build across all successful pipelines + find_latest_successful_build_across_pipelines + end + + private + + def find_latest_successful_build_across_pipelines + # Query to find the build across all successful pipelines + # This is expensive but necessary when Tier 1 cache misses + @project.ci_pipelines + .for_ref(@ref_name) + .success + .order_id_desc + .limit(PIPELINE_SEARCH_LIMIT) + .each do |pipeline| + build = pipeline.builds.by_name(@job_name).success.first + next unless build&.artifacts_file&.exists? + + # Cache the successful build for future requests + @project.cache_successful_build(@job_name, @ref_name, build) + return build + end + + nil + end + end + end +end diff --git a/doc/api/job_artifacts.md b/doc/api/job_artifacts.md index e885b7bdf3150ab6e8b12be80e0157d9e5954e9b..8275624a7247bffe57fe15262d74cdf8942c092c 100644 --- a/doc/api/job_artifacts.md +++ b/doc/api/job_artifacts.md @@ -88,9 +88,55 @@ Supported attributes: | `job` | string | Yes | The name of the job. | | `ref_name` | string | Yes | Branch or tag name in repository. HEAD or SHA references are not supported. For merge request pipelines, use `refs/merge-requests/:iid/head` instead of the branch name. | | `job_token` | string | No | CI/CD job token for multi-project pipelines. Premium and Ultimate only. | +| `search_all_successful_pipelines` | boolean | No | When `true`, searches across all successful pipelines instead of just the latest one. Uses caching to improve performance. Defaults to `false`. | If successful, returns [`200`](rest/troubleshooting.md#status-codes) and serves the artifacts file. +### Search across all successful pipelines + +By default, this endpoint searches for the job only in the latest successful pipeline. +If the job doesn't exist in that pipeline, a `404 Not Found` error is returned. + +To search for the job across all successful pipelines (not just the latest one), set the +`search_all_successful_pipelines` parameter to `true`: + +```shell +curl --location \ + --header "PRIVATE-TOKEN: " \ + --url "https://gitlab.example.com/api/v4/projects/1/jobs/artifacts/main/download?job=test&search_all_successful_pipelines=true" +``` + +**Performance and Caching:** + +When `search_all_successful_pipelines=true`: + +- The endpoint uses a two-tier caching mechanism to improve performance. +- Successful builds are cached for 1 hour after completion. +- Cache misses trigger a search across up to 100 recent successful pipelines. +- Found builds are automatically cached for future requests. + +**Use cases:** + +This parameter is useful when: + +- Your pipeline configuration changes and certain jobs don't run in every pipeline. +- You have dynamic pipeline configurations with conditional jobs. +- You need to reliably fetch artifacts from jobs that may not appear in the latest pipeline. + +**Example:** + +```shell +# Default behavior (backward compatible) - searches only latest successful pipeline +curl --location \ + --header "PRIVATE-TOKEN: " \ + --url "https://gitlab.example.com/api/v4/projects/1/jobs/artifacts/main/download?job=test" + +# New opt-in behavior - searches across all successful pipelines with caching +curl --location \ + --header "PRIVATE-TOKEN: " \ + --url "https://gitlab.example.com/api/v4/projects/1/jobs/artifacts/main/download?job=test&search_all_successful_pipelines=true" +``` + Example request: ```shell diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index 7c554d12ecb4a3ed157a4063ededdbe17df7706c..a2847a9e7829af3fca1fe7c219f1f6e9ca74666c 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -11027,6 +11027,13 @@ paths: only on Premium and Ultimate tiers. type: string required: false + - in: query + name: search_all_successful_pipelines + description: Search across all successful pipelines instead of just the latest + one. + type: boolean + default: false + required: false responses: '200': description: Download the artifacts archive from a job diff --git a/lib/api/ci/job_artifacts.rb b/lib/api/ci/job_artifacts.rb index 27b638ad24e0b33b10994185dbccf6c24b576338..028bbf7722600c8de3bbfb20d0afc221b04ad308 100644 --- a/lib/api/ci/job_artifacts.rb +++ b/lib/api/ci/job_artifacts.rb @@ -37,6 +37,8 @@ def audit_download(build, filename); end optional :job_token, type: String, desc: 'To be used with triggers for multi-project pipelines, ' \ 'available only on Premium and Ultimate tiers.' + optional :search_all_successful_pipelines, type: Boolean, default: false, + desc: 'Search across all successful pipelines instead of just the latest one.' end route_setting :authentication, job_token_allowed: true route_setting :authorization, job_token_policies: :read_jobs @@ -45,7 +47,22 @@ def audit_download(build, filename); end requirements: { ref_name: /.+/ } do authorize_download_artifacts! - latest_build = user_project.latest_successful_build_for_ref!(params[:job], params[:ref_name]) + if params[:search_all_successful_pipelines] + # New behavior: search across all successful pipelines with caching + service = ::Ci::JobArtifacts::FindCachedArtifactService.new( + user_project, + params[:job], + params[:ref_name] + ) + + latest_build = service.execute + + not_found!('Job') unless latest_build + else + # Default behavior: use existing logic (backward compatible) + latest_build = user_project.latest_successful_build_for_ref!(params[:job], params[:ref_name]) + end + authorize_read_job_artifacts!(latest_build) not_found! unless latest_build.artifacts_file&.exists?