From 6cedc2aa41650472d839f947906bd97979e252bc Mon Sep 17 00:00:00 2001 From: Aakriti Gupta Date: Wed, 22 Oct 2025 11:21:34 +0200 Subject: [PATCH 1/5] Publish event according to project repo replication version --- .../geo/project_repository_replicator.rb | 13 +++++++++ .../geo_project_repository_replication_v2.yml | 8 +++++ ee/lib/gitlab/geo.rb | 4 +++ ee/lib/gitlab/geo/replicator.rb | 2 ++ .../geo/project_repository_replicator_spec.rb | 29 +++++++++++++++++-- ...lob_replicator_strategy_shared_examples.rb | 5 ++++ 6 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 ee/config/feature_flags/ops/geo_project_repository_replication_v2.yml diff --git a/ee/app/replicators/geo/project_repository_replicator.rb b/ee/app/replicators/geo/project_repository_replicator.rb index 632b0a0c7a759d..cc601b64a5c075 100644 --- a/ee/app/replicators/geo/project_repository_replicator.rb +++ b/ee/app/replicators/geo/project_repository_replicator.rb @@ -28,6 +28,19 @@ def repository model_record.repository end + override :should_publish_replication_event? + def should_publish_replication_event? + return false unless super + + if ::Gitlab::Geo.geo_project_repository_replication_v2_enabled? + # V2: Only create events for ProjectRepository objects, not Projects + model_record.is_a?(::ProjectRepository) + else + # V1: Only create events for Project objects, not ProjectRepositories + model_record.is_a?(::Project) + end + end + private def pool_repository diff --git a/ee/config/feature_flags/ops/geo_project_repository_replication_v2.yml b/ee/config/feature_flags/ops/geo_project_repository_replication_v2.yml new file mode 100644 index 00000000000000..ff75ab3b40e38d --- /dev/null +++ b/ee/config/feature_flags/ops/geo_project_repository_replication_v2.yml @@ -0,0 +1,8 @@ +--- +name: geo_project_repository_replication_v2 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/194051 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/549772 +milestone: '18.6' +type: ops +group: group::geo +default_enabled: false diff --git a/ee/lib/gitlab/geo.rb b/ee/lib/gitlab/geo.rb index 4fbab4c1ad4a98..39849af9624955 100644 --- a/ee/lib/gitlab/geo.rb +++ b/ee/lib/gitlab/geo.rb @@ -364,5 +364,9 @@ def self.org_mover_extend_selective_sync_to_primary_checksumming? def self.geo_selective_sync_by_organizations_enabled? ::Feature.enabled?(:geo_selective_sync_by_organizations, :instance) end + + def self.geo_project_repository_replication_v2_enabled? + ::Feature.enabled?(:geo_project_repository_replication_v2, :instance) + end end end diff --git a/ee/lib/gitlab/geo/replicator.rb b/ee/lib/gitlab/geo/replicator.rb index 29a584c889ac7d..1a83864f4194eb 100644 --- a/ee/lib/gitlab/geo/replicator.rb +++ b/ee/lib/gitlab/geo/replicator.rb @@ -353,6 +353,8 @@ def resource_exists? protected + # This method can be overriden in a replicator class to add other + # conditionals for accepting events def should_publish_replication_event? self.class.replication_enabled? end diff --git a/ee/spec/replicators/geo/project_repository_replicator_spec.rb b/ee/spec/replicators/geo/project_repository_replicator_spec.rb index d543a83aff5d80..564aa35fa1fb5e 100644 --- a/ee/spec/replicators/geo/project_repository_replicator_spec.rb +++ b/ee/spec/replicators/geo/project_repository_replicator_spec.rb @@ -3,9 +3,27 @@ require 'spec_helper' RSpec.describe Geo::ProjectRepositoryReplicator, feature_category: :geo_replication do - let(:model_record) { create(:project, :repository) } + include EE::GeoHelpers + + let(:secondary) { create(:geo_node) } + let(:primary) { create(:geo_node, :primary) } + + before do + stub_current_geo_node(secondary) + end + + context 'with legacy project repository replication (V1)' do + let(:model_record) { create(:project_with_repo) } + let(:project) { model_record } + + subject(:replicator) { model_record.replicator } + + before do + stub_feature_flags(geo_project_repository_replication_v2: false) + end + + it_behaves_like 'a repository replicator' - include_examples 'a repository replicator' do describe 'housekeeping implementation' do let_it_be(:pool_repository) { create(:pool_repository) } let_it_be(:model_record) { create(:project, pool_repository: pool_repository) } @@ -21,8 +39,15 @@ expect(service).to receive(:execute) end + replicator = described_class.new(model_record_id: model_record.id) replicator.before_housekeeping end end + + include_examples 'a verifiable replicator' do + before do + stub_current_geo_node(primary) + end + end end end diff --git a/ee/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb b/ee/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb index 93b51b47c00381..96a242f6687e4f 100644 --- a/ee/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb +++ b/ee/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb @@ -18,6 +18,11 @@ subject(:replicator) { model_record.replicator } before do + # These tests create projects and expect the Geo::Event counts to change + # including project creation related events. + # Disabling this flag will allow legacy project repository replication to + # work, as these tests expect. + stub_feature_flags(geo_project_repository_replication_v2: false) stub_current_geo_node(primary) end -- GitLab From 14bd30e95ac9da434bfb65a77ba69f98db77f173 Mon Sep 17 00:00:00 2001 From: Aakriti Gupta Date: Tue, 21 Oct 2025 15:15:52 +0200 Subject: [PATCH 2/5] Replace constants with class methods in Geo registry classes --- ...Geo Replicate a new Git repository type.md | 11 +++++++--- .../Geo Replicate a new blob type.md | 11 +++++++--- .../concerns/geo/replicable_registry.rb | 4 ++-- .../concerns/geo/verifiable_registry.rb | 2 +- ee/app/models/geo/base_registry.rb | 22 +++++++++---------- ee/app/models/geo/ci_secure_file_registry.rb | 11 +++++++--- .../geo/container_repository_registry.rb | 11 +++++++--- .../geo/dependency_proxy_blob_registry.rb | 11 +++++++--- .../geo/dependency_proxy_manifest_registry.rb | 11 +++++++--- .../design_management_repository_registry.rb | 11 +++++++--- .../geo/group_wiki_repository_registry.rb | 11 +++++++--- ee/app/models/geo/job_artifact_registry.rb | 11 +++++++--- ee/app/models/geo/lfs_object_registry.rb | 11 +++++++--- .../models/geo/merge_request_diff_registry.rb | 11 +++++++--- ee/app/models/geo/package_file_registry.rb | 11 +++++++--- .../geo/packages_nuget_symbol_registry.rb | 11 +++++++--- .../models/geo/pages_deployment_registry.rb | 11 +++++++--- .../models/geo/pipeline_artifact_registry.rb | 11 +++++++--- .../models/geo/project_repository_registry.rb | 11 +++++++--- .../geo/project_wiki_repository_registry.rb | 11 +++++++--- .../models/geo/snippet_repository_registry.rb | 11 +++++++--- .../geo/terraform_state_version_registry.rb | 11 +++++++--- ee/app/models/geo/upload_registry.rb | 15 ++++++++----- .../geo/registry_consistency_service.rb | 2 +- ee/lib/gitlab/geo/registry_batcher.rb | 2 +- .../mutations/geo/registries/update_spec.rb | 2 +- .../geo/registry_consistency_service_spec.rb | 8 +++---- ee/spec/support/helpers/ee/geo_helpers.rb | 2 +- ...geo_searchable_registry_shared_examples.rb | 4 ++-- 29 files changed, 186 insertions(+), 86 deletions(-) diff --git a/.gitlab/issue_templates/Geo Replicate a new Git repository type.md b/.gitlab/issue_templates/Geo Replicate a new Git repository type.md index 29fa092a091686..1a0a6eb12e0f65 100644 --- a/.gitlab/issue_templates/Geo Replicate a new Git repository type.md +++ b/.gitlab/issue_templates/Geo Replicate a new Git repository type.md @@ -428,10 +428,15 @@ That's all of the required database changes. include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::CoolWidget - MODEL_FOREIGN_KEY = :cool_widget_id - belongs_to :cool_widget, class_name: 'CoolWidget' + + def self.model_class + ::CoolWidget + end + + def self.model_foreign_key + :cool_widget_id + end end end ``` diff --git a/.gitlab/issue_templates/Geo Replicate a new blob type.md b/.gitlab/issue_templates/Geo Replicate a new blob type.md index c83ef9a4f3ee5a..caaf7324a52519 100644 --- a/.gitlab/issue_templates/Geo Replicate a new blob type.md +++ b/.gitlab/issue_templates/Geo Replicate a new blob type.md @@ -394,10 +394,15 @@ That's all of the required database changes. include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::CoolWidget - MODEL_FOREIGN_KEY = :cool_widget_id - belongs_to :cool_widget, class_name: 'CoolWidget' + + def self.model_class + ::CoolWidget + end + + def self.model_foreign_key + :cool_widget_id + end end end ``` diff --git a/ee/app/models/concerns/geo/replicable_registry.rb b/ee/app/models/concerns/geo/replicable_registry.rb index 6b8199f8fa5093..ddb7eaeacb0b41 100644 --- a/ee/app/models/concerns/geo/replicable_registry.rb +++ b/ee/app/models/concerns/geo/replicable_registry.rb @@ -20,7 +20,7 @@ def state_value(state_string) end def for_model_record_id(id) - find_or_initialize_by(self::MODEL_FOREIGN_KEY => id) + find_or_initialize_by(model_foreign_key => id) end def declarative_policy_class @@ -178,7 +178,7 @@ def mark_synced_atomically # If state is set to pending that means that pending! was called # during the sync so we need to reschedule new sync num_rows = self.class - .where(self.class::MODEL_FOREIGN_KEY => model_record_id) + .where(self.class.model_foreign_key => model_record_id) .with_state(:started) .update_all(state: Geo::ReplicableRegistry::STATE_VALUES[:synced]) diff --git a/ee/app/models/concerns/geo/verifiable_registry.rb b/ee/app/models/concerns/geo/verifiable_registry.rb index ce130a9f5e1c39..bf7b900a7c4c91 100644 --- a/ee/app/models/concerns/geo/verifiable_registry.rb +++ b/ee/app/models/concerns/geo/verifiable_registry.rb @@ -17,7 +17,7 @@ module VerifiableRegistry # Replicators. override :verification_state_model_key def verification_state_model_key - self::MODEL_FOREIGN_KEY + model_foreign_key end end diff --git a/ee/app/models/geo/base_registry.rb b/ee/app/models/geo/base_registry.rb index 4b27987195a279..ca576ed9f7e407 100644 --- a/ee/app/models/geo/base_registry.rb +++ b/ee/app/models/geo/base_registry.rb @@ -10,19 +10,19 @@ class BaseRegistry < Geo::TrackingBase include GlobalID::Identification def self.pluck_model_ids_in_range(range) - where(self::MODEL_FOREIGN_KEY => range).pluck(self::MODEL_FOREIGN_KEY) + where(model_foreign_key => range).pluck(model_foreign_key) end def self.pluck_model_foreign_key - where(nil).pluck(self::MODEL_FOREIGN_KEY) + where(nil).pluck(model_foreign_key) end def self.model_id_in(ids) - where(self::MODEL_FOREIGN_KEY => ids) + where(model_foreign_key => ids) end def self.model_id_not_in(ids) - where.not(self::MODEL_FOREIGN_KEY => ids) + where.not(model_foreign_key => ids) end def self.ordered_by_id @@ -56,7 +56,7 @@ def self.before_bulk_mark_update_row_scan_max(bulk_mark_update_cursor, bulk_mark def self.insert_for_model_ids(ids) records = ids.map do |id| - new(self::MODEL_FOREIGN_KEY => id, created_at: Time.zone.now) + new(model_foreign_key => id, created_at: Time.zone.now) end bulk_insert!(records, returns: :ids) @@ -73,15 +73,15 @@ def self.delete_worker_class end def self.replicator_class - self::MODEL_CLASS.replicator_class + model_class.replicator_class end def self.find_registry_differences(range) - model_primary_key = self::MODEL_CLASS.primary_key.to_sym + model_primary_key = model_class.primary_key.to_sym - source_ids = self::MODEL_CLASS + source_ids = model_class .replicables_for_current_secondary(range) - .pluck(self::MODEL_CLASS.arel_table[model_primary_key]) + .pluck(model_class.arel_table[model_primary_key]) tracked_ids = pluck_model_ids_in_range(range) @@ -119,11 +119,11 @@ def self.graphql_enum_key def self.with_search(query) return all if query.empty? - where(self::MODEL_FOREIGN_KEY => self::MODEL_CLASS.search(query).limit(1000).pluck_primary_key) + where(model_foreign_key => model_class.search(query).limit(1000).pluck_primary_key) end def model_record_id - read_attribute(self.class::MODEL_FOREIGN_KEY) + read_attribute(self.class.model_foreign_key) end end end diff --git a/ee/app/models/geo/ci_secure_file_registry.rb b/ee/app/models/geo/ci_secure_file_registry.rb index 6d4e4255ed9768..90e7fada92bcc0 100644 --- a/ee/app/models/geo/ci_secure_file_registry.rb +++ b/ee/app/models/geo/ci_secure_file_registry.rb @@ -5,9 +5,14 @@ class CiSecureFileRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::Ci::SecureFile - MODEL_FOREIGN_KEY = :ci_secure_file_id - belongs_to :ci_secure_file, class_name: 'Ci::SecureFile' + + def self.model_class + ::Ci::SecureFile + end + + def self.model_foreign_key + :ci_secure_file_id + end end end diff --git a/ee/app/models/geo/container_repository_registry.rb b/ee/app/models/geo/container_repository_registry.rb index 85c36e0d1ade30..19bb9117ec30aa 100644 --- a/ee/app/models/geo/container_repository_registry.rb +++ b/ee/app/models/geo/container_repository_registry.rb @@ -7,9 +7,6 @@ class ContainerRepositoryRegistry < Geo::BaseRegistry include ::Geo::VerifiableRegistry extend ::Gitlab::Utils::Override - MODEL_CLASS = ::ContainerRepository - MODEL_FOREIGN_KEY = :container_repository_id - belongs_to :container_repository ### Remove it after data migration @@ -39,6 +36,14 @@ class << self include Delay extend ::Gitlab::Utils::Override + def model_class + ::ContainerRepository + end + + def model_foreign_key + :container_repository_id + end + ### Remove it after data migration # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/371667 # diff --git a/ee/app/models/geo/dependency_proxy_blob_registry.rb b/ee/app/models/geo/dependency_proxy_blob_registry.rb index 00c1f0a3e0f66d..2602dc5980eb49 100644 --- a/ee/app/models/geo/dependency_proxy_blob_registry.rb +++ b/ee/app/models/geo/dependency_proxy_blob_registry.rb @@ -5,9 +5,14 @@ class DependencyProxyBlobRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::DependencyProxy::Blob - MODEL_FOREIGN_KEY = :dependency_proxy_blob_id - belongs_to :dependency_proxy_blob, class_name: 'DependencyProxy::Blob' + + def self.model_class + ::DependencyProxy::Blob + end + + def self.model_foreign_key + :dependency_proxy_blob_id + end end end diff --git a/ee/app/models/geo/dependency_proxy_manifest_registry.rb b/ee/app/models/geo/dependency_proxy_manifest_registry.rb index d728842e87b8b7..e34ff609108d2e 100644 --- a/ee/app/models/geo/dependency_proxy_manifest_registry.rb +++ b/ee/app/models/geo/dependency_proxy_manifest_registry.rb @@ -5,9 +5,14 @@ class DependencyProxyManifestRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::DependencyProxy::Manifest - MODEL_FOREIGN_KEY = :dependency_proxy_manifest_id - belongs_to :dependency_proxy_manifest, class_name: 'DependencyProxy::Manifest' + + def self.model_class + ::DependencyProxy::Manifest + end + + def self.model_foreign_key + :dependency_proxy_manifest_id + end end end diff --git a/ee/app/models/geo/design_management_repository_registry.rb b/ee/app/models/geo/design_management_repository_registry.rb index d4461ca40da469..78d895c3690b2f 100644 --- a/ee/app/models/geo/design_management_repository_registry.rb +++ b/ee/app/models/geo/design_management_repository_registry.rb @@ -5,9 +5,14 @@ class DesignManagementRepositoryRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::DesignManagement::Repository - MODEL_FOREIGN_KEY = :design_management_repository_id - belongs_to :design_management_repository, class_name: 'DesignManagement::Repository' + + def self.model_class + ::DesignManagement::Repository + end + + def self.model_foreign_key + :design_management_repository_id + end end end diff --git a/ee/app/models/geo/group_wiki_repository_registry.rb b/ee/app/models/geo/group_wiki_repository_registry.rb index b0da460e1af586..4a9d152c915906 100644 --- a/ee/app/models/geo/group_wiki_repository_registry.rb +++ b/ee/app/models/geo/group_wiki_repository_registry.rb @@ -5,9 +5,14 @@ class GroupWikiRepositoryRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::GroupWikiRepository - MODEL_FOREIGN_KEY = :group_wiki_repository_id - belongs_to :group_wiki_repository, class_name: 'GroupWikiRepository' + + def self.model_class + ::GroupWikiRepository + end + + def self.model_foreign_key + :group_wiki_repository_id + end end end diff --git a/ee/app/models/geo/job_artifact_registry.rb b/ee/app/models/geo/job_artifact_registry.rb index 1e2fc9f5b73f9e..493ef0848cb885 100644 --- a/ee/app/models/geo/job_artifact_registry.rb +++ b/ee/app/models/geo/job_artifact_registry.rb @@ -5,11 +5,16 @@ class JobArtifactRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::Ci::JobArtifact - MODEL_FOREIGN_KEY = :artifact_id - ignore_column :success, remove_with: '15.8', remove_after: '2022-12-22' belongs_to :job_artifact, class_name: 'Ci::JobArtifact', foreign_key: :artifact_id + + def self.model_class + ::Ci::JobArtifact + end + + def self.model_foreign_key + :artifact_id + end end end diff --git a/ee/app/models/geo/lfs_object_registry.rb b/ee/app/models/geo/lfs_object_registry.rb index 8b5ffeab17c764..ca61a08dd64901 100644 --- a/ee/app/models/geo/lfs_object_registry.rb +++ b/ee/app/models/geo/lfs_object_registry.rb @@ -5,13 +5,18 @@ class LfsObjectRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::LfsObject - MODEL_FOREIGN_KEY = :lfs_object_id - belongs_to :lfs_object, class_name: 'LfsObject' scope :for_synced_lfs_objects, ->(lfs_object_ids) { synced.where(lfs_object_id: lfs_object_ids) } + def self.model_class + ::LfsObject + end + + def self.model_foreign_key + :lfs_object_id + end + # @return [Boolean] true if all given oids are synced def self.oids_synced?(oids) unique_oids = oids.uniq diff --git a/ee/app/models/geo/merge_request_diff_registry.rb b/ee/app/models/geo/merge_request_diff_registry.rb index 3a72ae7619a6da..f1a8d9287fce4e 100644 --- a/ee/app/models/geo/merge_request_diff_registry.rb +++ b/ee/app/models/geo/merge_request_diff_registry.rb @@ -5,11 +5,16 @@ class MergeRequestDiffRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::MergeRequestDiff - MODEL_FOREIGN_KEY = :merge_request_diff_id - self.table_name = 'merge_request_diff_registry' belongs_to :merge_request_diff, class_name: 'MergeRequestDiff' + + def self.model_class + ::MergeRequestDiff + end + + def self.model_foreign_key + :merge_request_diff_id + end end end diff --git a/ee/app/models/geo/package_file_registry.rb b/ee/app/models/geo/package_file_registry.rb index 5661e9d2297dba..23265b3e47b2ee 100644 --- a/ee/app/models/geo/package_file_registry.rb +++ b/ee/app/models/geo/package_file_registry.rb @@ -5,9 +5,14 @@ class PackageFileRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::Packages::PackageFile - MODEL_FOREIGN_KEY = :package_file_id - belongs_to :package_file, class_name: 'Packages::PackageFile' + + def self.model_class + ::Packages::PackageFile + end + + def self.model_foreign_key + :package_file_id + end end end diff --git a/ee/app/models/geo/packages_nuget_symbol_registry.rb b/ee/app/models/geo/packages_nuget_symbol_registry.rb index 432e2e030433ef..2e97e6eee045e8 100644 --- a/ee/app/models/geo/packages_nuget_symbol_registry.rb +++ b/ee/app/models/geo/packages_nuget_symbol_registry.rb @@ -5,9 +5,14 @@ class PackagesNugetSymbolRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::Packages::Nuget::Symbol - MODEL_FOREIGN_KEY = :packages_nuget_symbol_id - belongs_to :packages_nuget_symbol, class_name: 'Packages::Nuget::Symbol' + + def self.model_class + ::Packages::Nuget::Symbol + end + + def self.model_foreign_key + :packages_nuget_symbol_id + end end end diff --git a/ee/app/models/geo/pages_deployment_registry.rb b/ee/app/models/geo/pages_deployment_registry.rb index 3cfeedb5290efd..ccdcde3d0443e1 100644 --- a/ee/app/models/geo/pages_deployment_registry.rb +++ b/ee/app/models/geo/pages_deployment_registry.rb @@ -5,9 +5,14 @@ class PagesDeploymentRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::PagesDeployment - MODEL_FOREIGN_KEY = :pages_deployment_id - belongs_to :pages_deployment, class_name: 'PagesDeployment' + + def self.model_class + ::PagesDeployment + end + + def self.model_foreign_key + :pages_deployment_id + end end end diff --git a/ee/app/models/geo/pipeline_artifact_registry.rb b/ee/app/models/geo/pipeline_artifact_registry.rb index 96602358189038..0132b150217008 100644 --- a/ee/app/models/geo/pipeline_artifact_registry.rb +++ b/ee/app/models/geo/pipeline_artifact_registry.rb @@ -5,9 +5,14 @@ class PipelineArtifactRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::Ci::PipelineArtifact - MODEL_FOREIGN_KEY = :pipeline_artifact_id - belongs_to :pipeline_artifact, class_name: '::Ci::PipelineArtifact' + + def self.model_class + ::Ci::PipelineArtifact + end + + def self.model_foreign_key + :pipeline_artifact_id + end end end diff --git a/ee/app/models/geo/project_repository_registry.rb b/ee/app/models/geo/project_repository_registry.rb index dcdb83ed6ad1a4..cec4163afc385b 100644 --- a/ee/app/models/geo/project_repository_registry.rb +++ b/ee/app/models/geo/project_repository_registry.rb @@ -6,11 +6,16 @@ class ProjectRepositoryRegistry < Geo::BaseRegistry include ::Geo::VerifiableRegistry extend ::Gitlab::Geo::LogHelpers - MODEL_CLASS = ::Project - MODEL_FOREIGN_KEY = :project_id - belongs_to :project, class_name: 'Project' + def self.model_class + ::Project + end + + def self.model_foreign_key + :project_id + end + # Returns whether the project repository is out-of-date on this site # # The return value must be cached in RequestStore to ensure a consistent request diff --git a/ee/app/models/geo/project_wiki_repository_registry.rb b/ee/app/models/geo/project_wiki_repository_registry.rb index 9de6d818188d9f..a92553ec3bb5b7 100644 --- a/ee/app/models/geo/project_wiki_repository_registry.rb +++ b/ee/app/models/geo/project_wiki_repository_registry.rb @@ -6,13 +6,18 @@ class ProjectWikiRepositoryRegistry < Geo::BaseRegistry include ::Geo::VerifiableRegistry extend ::Gitlab::Utils::Override - MODEL_CLASS = ::Projects::WikiRepository - MODEL_FOREIGN_KEY = :project_wiki_repository_id - belongs_to :project_wiki_repository, class_name: 'Projects::WikiRepository' validates :project_wiki_repository, presence: true, uniqueness: true delegate :project, :wiki_repository_state, to: :project_wiki_repository, allow_nil: true + + def self.model_class + ::Projects::WikiRepository + end + + def self.model_foreign_key + :project_wiki_repository_id + end end end diff --git a/ee/app/models/geo/snippet_repository_registry.rb b/ee/app/models/geo/snippet_repository_registry.rb index deb9bb2cdc3325..f8dce9310d42ab 100644 --- a/ee/app/models/geo/snippet_repository_registry.rb +++ b/ee/app/models/geo/snippet_repository_registry.rb @@ -5,9 +5,14 @@ class SnippetRepositoryRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::SnippetRepository - MODEL_FOREIGN_KEY = :snippet_repository_id - belongs_to :snippet_repository, class_name: 'SnippetRepository' + + def self.model_class + ::SnippetRepository + end + + def self.model_foreign_key + :snippet_repository_id + end end end diff --git a/ee/app/models/geo/terraform_state_version_registry.rb b/ee/app/models/geo/terraform_state_version_registry.rb index 67893678fcf609..a2693b3b974db5 100644 --- a/ee/app/models/geo/terraform_state_version_registry.rb +++ b/ee/app/models/geo/terraform_state_version_registry.rb @@ -5,9 +5,14 @@ class TerraformStateVersionRegistry < Geo::BaseRegistry include Geo::ReplicableRegistry include ::Geo::VerifiableRegistry - MODEL_CLASS = ::Terraform::StateVersion - MODEL_FOREIGN_KEY = :terraform_state_version_id - belongs_to :terraform_state_version, class_name: 'Terraform::StateVersion' + + def self.model_class + ::Terraform::StateVersion + end + + def self.model_foreign_key + :terraform_state_version_id + end end end diff --git a/ee/app/models/geo/upload_registry.rb b/ee/app/models/geo/upload_registry.rb index e7b4265ae3a38c..517cde711ff5af 100644 --- a/ee/app/models/geo/upload_registry.rb +++ b/ee/app/models/geo/upload_registry.rb @@ -7,19 +7,24 @@ class UploadRegistry < Geo::BaseRegistry extend ::Gitlab::Utils::Override - MODEL_CLASS = ::Upload - MODEL_FOREIGN_KEY = :file_id - self.table_name = 'file_registry' belongs_to :upload, foreign_key: :file_id scope :fresh, -> { order(created_at: :desc) } + def self.model_class + ::Upload + end + + def self.model_foreign_key + :file_id + end + def self.find_registry_differences(range) source = - self::MODEL_CLASS.replicables_for_current_secondary(range) - .pluck(self::MODEL_CLASS.arel_table[:id]) + model_class.replicables_for_current_secondary(range) + .pluck(model_class.arel_table[:id]) tracked = model_id_in(range) diff --git a/ee/app/services/geo/registry_consistency_service.rb b/ee/app/services/geo/registry_consistency_service.rb index 2ed870e09d54df..f5c37045a9c512 100644 --- a/ee/app/services/geo/registry_consistency_service.rb +++ b/ee/app/services/geo/registry_consistency_service.rb @@ -10,7 +10,7 @@ class RegistryConsistencyService def initialize(registry_class, batch_size:) @registry_class = registry_class - @model_class = registry_class::MODEL_CLASS + @model_class = registry_class.model_class @batch_size = batch_size end diff --git a/ee/lib/gitlab/geo/registry_batcher.rb b/ee/lib/gitlab/geo/registry_batcher.rb index a0e03ccd50028e..943abcf318e520 100644 --- a/ee/lib/gitlab/geo/registry_batcher.rb +++ b/ee/lib/gitlab/geo/registry_batcher.rb @@ -4,7 +4,7 @@ module Gitlab module Geo class RegistryBatcher < BaseBatcher def initialize(registry_class, key:, batch_size: 1000) - super(registry_class::MODEL_CLASS, registry_class, registry_class::MODEL_FOREIGN_KEY, key: key, batch_size: batch_size) + super(registry_class.model_class, registry_class, registry_class.model_foreign_key, key: key, batch_size: batch_size) end end end diff --git a/ee/spec/requests/api/graphql/mutations/geo/registries/update_spec.rb b/ee/spec/requests/api/graphql/mutations/geo/registries/update_spec.rb index 74bd11618927d5..7ecd3dd2e34918 100644 --- a/ee/spec/requests/api/graphql/mutations/geo/registries/update_spec.rb +++ b/ee/spec/requests/api/graphql/mutations/geo/registries/update_spec.rb @@ -17,7 +17,7 @@ with_them do let(:registry) { create(registry_factory) } # rubocop:disable Rails/SaveBang let(:registry_class_argument) { registry_class.graphql_enum_key } - let(:registry_model_primary_key) { registry_class::MODEL_FOREIGN_KEY.to_s.camelize(:lower) } + let(:registry_model_primary_key) { registry_class.model_foreign_key.to_s.camelize(:lower) } let(:registry_fragment_name) { registry_class_argument.downcase.camelize } let(:registry_global_id) { registry.to_global_id.to_s } let(:expected_keys) do diff --git a/ee/spec/services/geo/registry_consistency_service_spec.rb b/ee/spec/services/geo/registry_consistency_service_spec.rb index 1e0a7be5c11b51..581fe7192853dc 100644 --- a/ee/spec/services/geo/registry_consistency_service_spec.rb +++ b/ee/spec/services/geo/registry_consistency_service_spec.rb @@ -19,16 +19,16 @@ shared_examples 'registry consistency service' do |klass| let(:registry_class) { klass } let(:registry_class_factory) { registry_factory_name(registry_class) } - let(:model_class) { registry_class::MODEL_CLASS } + let(:model_class) { registry_class.model_class } let(:model_class_factory) { model_class_factory_name(registry_class) } - let(:model_foreign_key) { registry_class::MODEL_FOREIGN_KEY } + let(:model_foreign_key) { registry_class.model_foreign_key } let(:batch_size) { 2 } subject { described_class.new(registry_class, batch_size: batch_size) } describe 'registry_class interface' do - it 'defines a MODEL_CLASS constant' do - expect(registry_class::MODEL_CLASS).not_to be_nil + it 'defines a model_class' do + expect(registry_class.model_class).not_to be_nil end it 'responds to .name' do diff --git a/ee/spec/support/helpers/ee/geo_helpers.rb b/ee/spec/support/helpers/ee/geo_helpers.rb index a767d1734c6100..8f8e35f3c155ff 100644 --- a/ee/spec/support/helpers/ee/geo_helpers.rb +++ b/ee/spec/support/helpers/ee/geo_helpers.rb @@ -78,7 +78,7 @@ def factory_name(klass) end def model_class_factory_name(registry_class) - factory_name(registry_class::MODEL_CLASS) + factory_name(registry_class.model_class) end def registry_factory_name(registry_class) diff --git a/ee/spec/support/shared_examples/models/geo_searchable_registry_shared_examples.rb b/ee/spec/support/shared_examples/models/geo_searchable_registry_shared_examples.rb index 248067a1d8f0b5..2cda8b95e12dd5 100644 --- a/ee/spec/support/shared_examples/models/geo_searchable_registry_shared_examples.rb +++ b/ee/spec/support/shared_examples/models/geo_searchable_registry_shared_examples.rb @@ -19,11 +19,11 @@ context 'when query is not empty' do before do - allow(described_class::MODEL_CLASS).to receive(:search).with('a super argument').and_call_original + allow(described_class.model_class).to receive(:search).with('a super argument').and_call_original end it 'calls model_class search method' do - expect(described_class::MODEL_CLASS).to receive(:search).with('a super argument') + expect(described_class.model_class).to receive(:search).with('a super argument') described_class.with_search('a super argument') end -- GitLab From cb1048af450165cff1dfe9116caccd2c5d960d3d Mon Sep 17 00:00:00 2001 From: Aakriti Gupta Date: Tue, 21 Oct 2025 16:39:56 +0200 Subject: [PATCH 3/5] Expose Geo::ProjectRepositoryRegistry via GraphQL --- .../javascripts/graphql_shared/possible_types.json | 1 + doc/api/graphql/reference/_index.md | 2 ++ .../types/geo/project_repository_registry_type.rb | 10 ++++++++++ ee/app/graphql/types/geo/registrable_type.rb | 1 + .../types/geo/project_repository_registry_type_spec.rb | 2 +- .../graphql/geo/registries_shared_context.rb | 1 + 6 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 657adf951a68c9..0cb99c778a7856 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -247,6 +247,7 @@ "PackageFileRegistry", "PagesDeploymentRegistry", "PipelineArtifactRegistry", + "ProjectRepositoryRegistry", "ProjectWikiRepositoryRegistry", "SnippetRepositoryRegistry", "TerraformStateVersionRegistry", diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 5577fa57ae3c2b..c24a9f7bb7c888 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -42381,6 +42381,7 @@ Represents the Geo replication and verification state of a project repository. | `missingOnPrimary` | [`Boolean`](#boolean) | Indicate if the ProjectRepositoryRegistry is missing on primary. | | `modelRecordId` | [`Int`](#int) | ID of the ProjectRepositoryRegistry's model record. | | `projectId` | [`ID!`](#id) | ID of the Project. | +| `projectRepositoryId` | [`ID`](#id) | ID of the project repository. | | `retryAt` | [`Time`](#time) | Timestamp after which the ProjectRepositoryRegistry is resynced. | | `retryCount` | [`Int`](#int) | Number of consecutive failed sync attempts of the ProjectRepositoryRegistry. | | `state` | [`RegistryState`](#registrystate) | Sync state of the ProjectRepositoryRegistry. | @@ -53643,6 +53644,7 @@ One of: - [`PackageFileRegistry`](#packagefileregistry) - [`PagesDeploymentRegistry`](#pagesdeploymentregistry) - [`PipelineArtifactRegistry`](#pipelineartifactregistry) +- [`ProjectRepositoryRegistry`](#projectrepositoryregistry) - [`ProjectWikiRepositoryRegistry`](#projectwikirepositoryregistry) - [`SnippetRepositoryRegistry`](#snippetrepositoryregistry) - [`TerraformStateVersionRegistry`](#terraformstateversionregistry) diff --git a/ee/app/graphql/types/geo/project_repository_registry_type.rb b/ee/app/graphql/types/geo/project_repository_registry_type.rb index b99e9510f3b0ed..50e143301df743 100644 --- a/ee/app/graphql/types/geo/project_repository_registry_type.rb +++ b/ee/app/graphql/types/geo/project_repository_registry_type.rb @@ -2,6 +2,10 @@ module Types module Geo + # rubocop:disable GraphQL/ExtractType -- both these fields are needed for backward compatibility + # once Geo's project repository replication v2 is introduced via + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/194051 + # rubocop:disable Graphql/AuthorizeTypes -- because it is included class ProjectRepositoryRegistryType < BaseObject graphql_name 'ProjectRepositoryRegistry' @@ -11,7 +15,13 @@ class ProjectRepositoryRegistryType < BaseObject description 'Represents the Geo replication and verification state of a project repository' field :project_id, GraphQL::Types::ID, null: false, description: 'ID of the Project.' + field :project_repository_id, GraphQL::Types::ID, description: 'ID of the project repository.' + + def project_repository_id + object&.project_repository&.id + end end + # rubocop:enable GraphQL/ExtractType # rubocop:enable Graphql/AuthorizeTypes end end diff --git a/ee/app/graphql/types/geo/registrable_type.rb b/ee/app/graphql/types/geo/registrable_type.rb index 00feec8145170b..541de48d5b0db3 100644 --- a/ee/app/graphql/types/geo/registrable_type.rb +++ b/ee/app/graphql/types/geo/registrable_type.rb @@ -19,6 +19,7 @@ class RegistrableType < BaseUnion ::Geo::PackageFileRegistry => Types::Geo::PackageFileRegistryType, ::Geo::PagesDeploymentRegistry => Types::Geo::PagesDeploymentRegistryType, ::Geo::PipelineArtifactRegistry => Types::Geo::PipelineArtifactRegistryType, + ::Geo::ProjectRepositoryRegistry => Types::Geo::ProjectRepositoryRegistryType, ::Geo::ProjectWikiRepositoryRegistry => Types::Geo::ProjectWikiRepositoryRegistryType, ::Geo::SnippetRepositoryRegistry => Types::Geo::SnippetRepositoryRegistryType, ::Geo::TerraformStateVersionRegistry => Types::Geo::TerraformStateVersionRegistryType, diff --git a/ee/spec/graphql/types/geo/project_repository_registry_type_spec.rb b/ee/spec/graphql/types/geo/project_repository_registry_type_spec.rb index c2f05556a46150..79365c72bb0614 100644 --- a/ee/spec/graphql/types/geo/project_repository_registry_type_spec.rb +++ b/ee/spec/graphql/types/geo/project_repository_registry_type_spec.rb @@ -6,7 +6,7 @@ it_behaves_like 'a Geo registry type' it 'has the expected fields (other than those included in RegistryType)' do - expected_fields = %i[project_id] + expected_fields = %i[project_id project_repository_id] expect(described_class).to have_graphql_fields(*expected_fields).at_least end diff --git a/ee/spec/support/shared_contexts/graphql/geo/registries_shared_context.rb b/ee/spec/support/shared_contexts/graphql/geo/registries_shared_context.rb index 637ea2a3e5f2bf..49a0dfe4bfcd7d 100644 --- a/ee/spec/support/shared_contexts/graphql/geo/registries_shared_context.rb +++ b/ee/spec/support/shared_contexts/graphql/geo/registries_shared_context.rb @@ -16,6 +16,7 @@ Geo::PackageFileRegistry | Types::Geo::PackageFileRegistryType | :geo_package_file_registry Geo::PagesDeploymentRegistry | Types::Geo::PagesDeploymentRegistryType | :geo_pages_deployment_registry Geo::PipelineArtifactRegistry | Types::Geo::PipelineArtifactRegistryType | :geo_pipeline_artifact_registry + Geo::ProjectRepositoryRegistry | Types::Geo::ProjectRepositoryRegistryType | :geo_project_repository_registry Geo::ProjectWikiRepositoryRegistry | Types::Geo::ProjectWikiRepositoryRegistryType | :geo_project_wiki_repository_registry Geo::SnippetRepositoryRegistry | Types::Geo::SnippetRepositoryRegistryType | :geo_snippet_repository_registry Geo::TerraformStateVersionRegistry | Types::Geo::TerraformStateVersionRegistryType | :geo_terraform_state_version_registry -- GitLab From fa0f0aafd07178e5fa5eb027d89e416270cd895e Mon Sep 17 00:00:00 2001 From: Aakriti Gupta Date: Tue, 2 Sep 2025 06:31:56 +0200 Subject: [PATCH 4/5] Geo: Add models to preparare for project repo replication v2 --- app/models/project_repository.rb | 2 + ...100342_create_project_repository_states.rb | 2 +- ee/app/models/ee/project_repository.rb | 73 +++++++++ ee/app/models/geo/project_repository_state.rb | 30 ++++ .../geo/project_repository_states.rb | 15 ++ ee/spec/factories/project_repositories.rb | 29 ++++ ee/spec/models/ee/project_repository_spec.rb | 24 +++ .../geo/project_repository_state_spec.rb | 153 ++++++++++++++++++ spec/factories/project_repositories.rb | 5 +- 9 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 ee/app/models/ee/project_repository.rb create mode 100644 ee/app/models/geo/project_repository_state.rb create mode 100644 ee/spec/factories/geo/project_repository_states.rb create mode 100644 ee/spec/factories/project_repositories.rb create mode 100644 ee/spec/models/ee/project_repository_spec.rb create mode 100644 ee/spec/models/geo/project_repository_state_spec.rb diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb index c11ceb039b8f1b..4d634bbfa316c1 100644 --- a/app/models/project_repository.rb +++ b/app/models/project_repository.rb @@ -16,3 +16,5 @@ def find_project(disk_path) end end end + +ProjectRepository.prepend_mod diff --git a/db/migrate/20250825100342_create_project_repository_states.rb b/db/migrate/20250825100342_create_project_repository_states.rb index deadc7b45cfd4a..70833908ad3c8f 100644 --- a/db/migrate/20250825100342_create_project_repository_states.rb +++ b/db/migrate/20250825100342_create_project_repository_states.rb @@ -10,7 +10,7 @@ class CreateProjectRepositoryStates < Gitlab::Database::Migration[2.3] NEEDS_VERIFICATION_INDEX_NAME = "index_project_repository_states_needs_verification" def up - create_table :project_repository_states do |t| # rubocop:disable Migration/EnsureFactoryForTable -- will be added via https://gitlab.com/gitlab-org/gitlab/-/merge_requests/194051 + create_table :project_repository_states do |t| t.datetime_with_timezone :verification_started_at t.datetime_with_timezone :verification_retry_at t.datetime_with_timezone :verified_at diff --git a/ee/app/models/ee/project_repository.rb b/ee/app/models/ee/project_repository.rb new file mode 100644 index 00000000000000..a8a04e631ddfa0 --- /dev/null +++ b/ee/app/models/ee/project_repository.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module EE + # ProjectRepository EE mixin + # + # This module is intended to encapsulate EE-specific model logic + # and be prepended in the `ProjectRepository` model + module ProjectRepository # rubocop:disable Gitlab/BoundedContexts -- EE module for existing model + extend ActiveSupport::Concern + + prepended do + include ::Geo::ReplicableModel + include ::Geo::VerifiableModel + + with_replicator Geo::ProjectRepositoryReplicator + + has_one :project_repository_state, + autosave: false, + inverse_of: :project_repository, + foreign_key: :project_repository_id, + class_name: 'Geo::ProjectRepositoryState' + + # Delegate repository-related methods to the associated project + delegate( + :repository, + :repository_storage, + to: :project) + + delegate(*::Geo::VerificationState::VERIFICATION_METHODS, + to: :project_repository_state) + + scope :available_replicables, -> { all } + + scope :with_verification_state, ->(state) do + joins(:project_repository_state) + .where(project_repository_states: { + verification_state: verification_state_value(state) + }) + end + + def verification_state_object + project_repository_state + end + end + + class_methods do + extend ::Gitlab::Utils::Override + + # @return [ActiveRecord::Relation] scope observing selective sync + # settings of the given node + def selective_sync_scope(node, **params) + return all unless node.selective_sync? + + project_scope = ::Project.selective_sync_scope(node, **params) + where(project_id: project_scope.select(:id)) + end + + override :verification_state_model_key + def verification_state_model_key + :project_repository_id + end + + override :verification_state_table_class + def verification_state_table_class + ::Geo::ProjectRepositoryState + end + end + + def project_repository_state + super || build_project_repository_state + end + end # rubocop:enable Gitlab/BoundedContexts +end diff --git a/ee/app/models/geo/project_repository_state.rb b/ee/app/models/geo/project_repository_state.rb new file mode 100644 index 00000000000000..6ae2852d4db835 --- /dev/null +++ b/ee/app/models/geo/project_repository_state.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Geo + class ProjectRepositoryState < ApplicationRecord + include ::Geo::VerificationStateDefinition + + # Populate missing foreign keys before validation + before_validation :populate_missing_foreign_keys + + self.primary_key = :project_repository_id + + belongs_to :project_repository, inverse_of: :project_repository_state + belongs_to :project + + validates :verification_state, :project_repository, presence: true + + def populate_missing_foreign_keys + # Ensure both foreign keys are populated for compatibility + if project.present? && !project_repository.present? + return unless project&.project_repository + + self.project_repository = project.project_repository + elsif project_repository.present? && !project.present? + return unless project_repository&.project + + self.project = project_repository.project + end + end + end +end diff --git a/ee/spec/factories/geo/project_repository_states.rb b/ee/spec/factories/geo/project_repository_states.rb new file mode 100644 index 00000000000000..f8376a22354bb6 --- /dev/null +++ b/ee/spec/factories/geo/project_repository_states.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :geo_project_repository_state, class: 'Geo::ProjectRepositoryState' do + project_repository + + trait :checksummed do + verification_checksum { 'abc' } + end + + trait :checksum_failure do + verification_failure { 'Could not calculate the checksum' } + end + end +end diff --git a/ee/spec/factories/project_repositories.rb b/ee/spec/factories/project_repositories.rb new file mode 100644 index 00000000000000..6d74bc18f46cad --- /dev/null +++ b/ee/spec/factories/project_repositories.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +FactoryBot.modify do + factory :project_repository do + project + + trait :verification_succeeded do + repository + verification_checksum { 'abc' } + verification_state { ProjectRepository.verification_state_value(:verification_succeeded) } + end + + trait :verification_failed do + repository + verification_failure { 'Could not calculate the checksum' } + verification_state { ProjectRepository.verification_state_value(:verification_failed) } + + # Geo::VerifiableReplicator#after_verifiable_update tries to verify the replicable async and + # marks it as verification pending when the model record is created/updated. + # + # Tip: You must set current node to primary, or else you can get a PG::ForeignKeyViolation + # because save_verification_details is returning early. + after(:create) do |instance, evaluator| + instance.verification_failure = evaluator.verification_failure + instance.verification_failed! + end + end + end +end diff --git a/ee/spec/models/ee/project_repository_spec.rb b/ee/spec/models/ee/project_repository_spec.rb new file mode 100644 index 00000000000000..831e50e40f4a2c --- /dev/null +++ b/ee/spec/models/ee/project_repository_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ProjectRepository, feature_category: :geo_replication do + include EE::GeoHelpers + + describe 'associations' do + it 'has one project_repository_state association' do + is_expected + .to have_one(:project_repository_state) + .class_name('Geo::ProjectRepositoryState') + .inverse_of(:project_repository) + .autosave(false) + end + end + + include_examples 'a verifiable model for verification state' do + let(:skip_unverifiable_model_record_tests) { true } + + let(:verifiable_model_record) { build(:project_repository) } + let(:unverifiable_model_record) { nil } + end +end diff --git a/ee/spec/models/geo/project_repository_state_spec.rb b/ee/spec/models/geo/project_repository_state_spec.rb new file mode 100644 index 00000000000000..7044226b077a9b --- /dev/null +++ b/ee/spec/models/geo/project_repository_state_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Geo::ProjectRepositoryState, feature_category: :geo_replication do + include EE::GeoHelpers + + let_it_be(:project) { create(:project_with_repo) } + let_it_be(:project_repository) { project.project_repository } + + before do + stub_current_geo_node(create(:geo_node, :primary)) + end + + describe 'associations' do + it { is_expected.to belong_to(:project_repository).inverse_of(:project_repository_state) } + it { is_expected.to belong_to(:project) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:verification_state) } + it { is_expected.to validate_presence_of(:project_repository) } + end + + describe 'primary key' do + it 'uses project_repository_id as primary key' do + expect(described_class.primary_key).to eq('project_repository_id') + end + end + + describe '#populate_missing_foreign_keys' do + context 'when project_id is missing but project_repository is present' do + it 'populates project_id from project_repository' do + state = build(:geo_project_repository_state, + project_repository: project_repository, + project: nil) + + # Trigger validation which calls populate_missing_foreign_keys + state.valid? + + expect(state.project).to eq(project) + expect(state.project_id).to eq(project.id) + end + + it 'saves successfully after foreign key population' do + state = build(:geo_project_repository_state, + project_repository: project_repository, + project: nil) + + expect(state.save).to be_truthy + expect(state.reload.project_id).to eq(project.id) + end + end + + context 'when project_repository_id is missing but project is present' do + it 'populates project_repository from project' do + state = build(:geo_project_repository_state, + project: project, + project_repository: nil) + + # Trigger validation which calls populate_missing_foreign_keys + state.valid? + + expect(state.project_repository).to eq(project_repository) + expect(state.project_repository_id).to eq(project_repository.id) + end + + it 'saves successfully after foreign key population' do + state = build(:geo_project_repository_state, + project: project, + project_repository: nil) + + expect(state.save).to be_truthy + expect(state.reload.project_repository_id).to eq(project_repository.id) + end + end + + context 'when both foreign keys are present' do + it 'does not modify existing values' do + state = build(:geo_project_repository_state, + project: project, + project_repository: project_repository) + + original_project_id = state.project_id + original_repo_id = state.project_repository_id + + state.valid? + + expect(state.project_id).to eq(original_project_id) + expect(state.project_repository_id).to eq(original_repo_id) + end + end + + # This is an edge case that shouldn't happen in practice + # If there is no project_repository record there should be no project_repository_state record + context 'when project does not have a project_repository' do + let(:project_without_repo) { create(:project) } + + it 'handles gracefully when project has no project_repository' do + state = build(:geo_project_repository_state, + project: project_without_repo, + project_repository: nil) + + expect(state.valid?).to be_falsey + + expect(state.project_repository).to be_nil + expect(state.project_repository_id).to be_nil + end + end + + context 'when project_repository has no project (edge case)' do + it 'handles orphaned project_repository gracefully' do + # This is an edge case that shouldn't happen in practice + state = build(:geo_project_repository_state, + project: nil, + project_repository_id: 99999) + + expect { state.valid? }.not_to raise_error + expect(state.project).to be_nil + end + end + end + + describe 'verification state behavior' do + let(:state) { create(:geo_project_repository_state, project_repository: project_repository) } + + it 'inherits verification state methods from VerificationStateDefinition' do + expect(state).to respond_to(:verification_pending!) + expect(state).to respond_to(:verification_succeeded!) + expect(state).to respond_to(:verification_failed!) + end + + it 'can transition between verification states' do + expect(state.verification_pending?).to be_truthy + + # Use the proper method with checksum for succeeded state + state.update!(verification_checksum: 'abc123') + state.verification_started! + state.verification_succeeded! + expect(state.verification_succeeded?).to be_truthy + + state.verification_started! + state.before_verification_failed + state.update!(verification_failure: "Verification failed") + state.verification_failed! + expect(state.verification_failed?).to be_truthy + end + + it 'includes VerificationStateDefinition module' do + expect(described_class.included_modules).to include(Geo::VerificationStateDefinition) + end + end +end diff --git a/spec/factories/project_repositories.rb b/spec/factories/project_repositories.rb index 39e8ea2e11eeba..de2a5cc5f5bea5 100644 --- a/spec/factories/project_repositories.rb +++ b/spec/factories/project_repositories.rb @@ -2,11 +2,12 @@ FactoryBot.define do factory :project_repository do - project + project { association(:project_with_repo) } + + sequence(:disk_path) { |n| "@hashed/unique_#{n}_#{SecureRandom.hex(8)}" } after(:build) do |project_repository, _| project_repository.shard_name = project_repository.project.repository_storage - project_repository.disk_path = project_repository.project.disk_path end end end -- GitLab From e07735d6949aaa86f9a074c9affa486322c83921 Mon Sep 17 00:00:00 2001 From: Aakriti Gupta Date: Tue, 2 Sep 2025 06:31:56 +0200 Subject: [PATCH 5/5] Replicate project repos by enumerating project_repositories --- app/models/project_repository.rb | 8 + .../models/geo/project_repository_registry.rb | 110 +++++++- .../geo/project_repository_replicator.rb | 24 +- .../geo/registry_consistency_service.rb | 2 +- .../ee/repositories/post_receive_worker.rb | 17 ++ .../geo/project_repository_registry.rb | 27 +- ...repository_registry_replication_v2_spec.rb | 205 +++++++++++++++ .../geo/project_repository_registry_spec.rb | 40 ++- ee/spec/models/geo_node_status_spec.rb | 243 +++++++++++++++++- .../geo/project_repository_replicator_spec.rb | 35 +++ .../api/graphql/geo/registries_spec.rb | 36 ++- ee/spec/requests/git_http_geo_spec.rb | 107 +++++--- .../geo/registry_consistency_service_spec.rb | 8 + ee/spec/support/helpers/ee/geo_helpers.rb | 6 + .../replicable_model_shared_examples.rb | 2 +- .../graphql/geo/registries_shared_examples.rb | 3 + .../registry_consistency_worker_spec.rb | 16 +- .../repositories/post_receive_worker_spec.rb | 41 ++- spec/factories/projects.rb | 4 + 19 files changed, 875 insertions(+), 59 deletions(-) create mode 100644 ee/spec/models/geo/project_repository_registry_replication_v2_spec.rb diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb index 4d634bbfa316c1..32ac8fffd687bf 100644 --- a/app/models/project_repository.rb +++ b/app/models/project_repository.rb @@ -3,6 +3,9 @@ class ProjectRepository < ApplicationRecord include EachBatch include Shardable + include ::Repositories::CanHousekeepRepository + + extend ::Gitlab::Utils::Override enum :object_format, { sha1: 0, sha256: 1 } @@ -15,6 +18,11 @@ def find_project(disk_path) find_by(disk_path: disk_path)&.project end end + + override :git_garbage_collect_worker_klass + def git_garbage_collect_worker_klass + Projects::GitGarbageCollectWorker + end end ProjectRepository.prepend_mod diff --git a/ee/app/models/geo/project_repository_registry.rb b/ee/app/models/geo/project_repository_registry.rb index cec4163afc385b..aac96d2be0c171 100644 --- a/ee/app/models/geo/project_repository_registry.rb +++ b/ee/app/models/geo/project_repository_registry.rb @@ -5,15 +5,64 @@ class ProjectRepositoryRegistry < Geo::BaseRegistry include ::Geo::ReplicableRegistry include ::Geo::VerifiableRegistry extend ::Gitlab::Geo::LogHelpers + extend ::Gitlab::Utils::Override + + # Populate missing foreign keys before validation and after loading + # because we need to ensure that both `project_id` and `project_repository_id` + # are populated if possible, even if only one is initially present, e.g. when + # switching between the two versions of replication - + # legacy (Project based) and V2 (ProjectRepository-based) replication modes + # This is crucial for backward compatibility and to support both the versions + # of replication. + + before_validation :populate_missing_foreign_keys + after_find :populate_missing_foreign_keys + + # Always allow both relationships for backward compatibility + belongs_to :project, class_name: 'Project', optional: true + belongs_to :project_repository, class_name: 'ProjectRepository', optional: true, default: nil + + # Ensure at least one foreign key is present + validates :project_id, presence: true, unless: :project_repository_id? + validates :project_repository_id, presence: true, unless: :project_id? + + override :insert_for_model_ids + def self.insert_for_model_ids(ids) + records = ids.map do |id| + project_id, project_repository_id = get_foreign_keys_for_model_record_id(id) + next unless project_id + + new( + project_id: project_id, + project_repository_id: project_repository_id, + created_at: Time.zone.now + ) + end + + records.compact! + return records if records.empty? + + bulk_insert!(records, returns: :ids) + end - belongs_to :project, class_name: 'Project' + def self.get_foreign_keys_for_model_record_id(id) + if model_foreign_key == :project_repository_id + project_repository_id = id + project_id = ProjectRepository.find_by(id: id)&.project&.id + else + project_repository_id = Project.find_by(id: id)&.project_repository&.id + project_id = id + end + + [project_id, project_repository_id] + end def self.model_class - ::Project + ::Gitlab::Geo.geo_project_repository_replication_v2_enabled? ? ::ProjectRepository : ::Project end def self.model_foreign_key - :project_id + ::Gitlab::Geo.geo_project_repository_replication_v2_enabled? ? :project_repository_id : :project_id end # Returns whether the project repository is out-of-date on this site @@ -35,10 +84,17 @@ def self.repository_out_of_date?(project_id, synchronous_request_required = fals # @return [Boolean] whether the project repository is out-of-date on this site def repository_out_of_date?(synchronous_request_required = false) return out_of_date("registry doesn't exist") unless persisted? - return out_of_date("project doesn't exist") if project.nil? + + case model_foreign_key + when :project_repository_id + return out_of_date("project_repository doesn't exist") if project_repository.nil? + else + return out_of_date("project doesn't exist") if project.nil? + end + return out_of_date("sync failed") if failed? - unless project.last_repository_updated_at + unless get_project.last_repository_updated_at return up_to_date("there is no timestamp for the latest change to the repo") end @@ -53,8 +109,11 @@ def repository_out_of_date?(synchronous_request_required = false) # @return [Boolean] whether the latest pipeline refs are present on the secondary def synchronous_pipeline_check - secondary_pipeline_refs = project.repository.list_refs(['refs/pipelines/']).collect(&:name) - primary_pipeline_refs = ::Gitlab::Geo.primary_pipeline_refs(project_id) + repository = get_project.repository + return out_of_date("repository related to project not found") unless repository + + secondary_pipeline_refs = repository.list_refs(['refs/pipelines/']).collect(&:name) + primary_pipeline_refs = ::Gitlab::Geo.primary_pipeline_refs(get_project.id) missing_refs = primary_pipeline_refs - secondary_pipeline_refs if !missing_refs.empty? @@ -72,7 +131,11 @@ def synchronous_pipeline_check # # @return [Boolean] whether the latest change is replicated def best_guess_with_local_information - last_updated_at = project.last_repository_updated_at + project_record = get_project + + return out_of_date("project not found") unless project_record + + last_updated_at = project_record.last_repository_updated_at if last_synced_at <= last_updated_at out_of_date("last successfully synced before latest change", @@ -108,5 +171,36 @@ def up_to_date(reason, details = {}) false end + + def get_project + return project_repository&.project if model_foreign_key == :project_repository_id + + project + end + + # While switching between the legacy replication and replication V2, + # it is possible that one of the foreign keys is populated while the other is not. + # This method ensures that both foreign keys are populated if possible, + # by using the existing populated foreign key to find the missing one. + # For example, if `project_id` is present but `project_repository_id` is not, + # it will try to find the `project_repository_id` from the `project`. + def populate_missing_foreign_keys + # Skip population if we don't have access to the necessary attributes + # This can happen during batch operations that only select specific columns + return unless has_attribute?(:project_id) && has_attribute?(:project_repository_id) + + return if project.present? && project_repository.present? + + # When a corresponding Git repository doesn't exist + # we don't need to replicate it, so we don't need the registry any more + if model_class == ::ProjectRepository && project && !project&.project_repository.present? + log_info("Destroying ProjectRepositoryRegistry##{id} without a corresponding project repository") + + return destroy + end + + self.project_repository ||= project&.project_repository + self.project ||= project_repository&.project + end end end diff --git a/ee/app/replicators/geo/project_repository_replicator.rb b/ee/app/replicators/geo/project_repository_replicator.rb index cc601b64a5c075..c35952257f3bac 100644 --- a/ee/app/replicators/geo/project_repository_replicator.rb +++ b/ee/app/replicators/geo/project_repository_replicator.rb @@ -3,9 +3,14 @@ module Geo class ProjectRepositoryReplicator < Gitlab::Geo::Replicator include ::Geo::RepositoryReplicatorStrategy + extend Gitlab::Utils::Override + + # Default to Project as the model for the initial call to avoid calling a database connection in the initializer def self.model - ::Project + return ::Project unless Rails.application.initialized? + + ::Gitlab::Geo.geo_project_repository_replication_v2_enabled? ? ::ProjectRepository : ::Project end # @return [String] human-readable title. @@ -24,6 +29,13 @@ def before_housekeeping create_object_pool_on_secondary if create_object_pool_on_secondary? end + # TODO Not completed sure if this method is needed + + # override :housekeeping_enabled? + # def self.housekeeping_enabled? + # false + # end + def repository model_record.repository end @@ -44,6 +56,8 @@ def should_publish_replication_event? private def pool_repository + return model_record.project.pool_repository if ::Gitlab::Geo.geo_project_repository_replication_v2_enabled? + model_record.pool_repository end @@ -51,8 +65,14 @@ def create_object_pool_on_secondary Geo::CreateObjectPoolService.new(pool_repository).execute end + def object_pool_missing? + return model_record.project.object_pool_missing? if ::Gitlab::Geo.geo_project_repository_replication_v2_enabled? + + model_record.object_pool_missing? + end + def create_object_pool_on_secondary? - return unless model_record.object_pool_missing? + return unless object_pool_missing? return unless pool_repository.source_project_repository.exists? true diff --git a/ee/app/services/geo/registry_consistency_service.rb b/ee/app/services/geo/registry_consistency_service.rb index f5c37045a9c512..0ed6149a597bab 100644 --- a/ee/app/services/geo/registry_consistency_service.rb +++ b/ee/app/services/geo/registry_consistency_service.rb @@ -43,7 +43,7 @@ def handle_differences_in_range(range) untracked, unused = find_registry_differences(range) created_in_range = create_untracked_in_range(untracked) - log_created(range, untracked, created_in_range) + log_created(range, untracked, created_in_range) unless created_in_range.empty? deleted_in_range = delete_unused_in_range(unused) log_deleted(range, unused, deleted_in_range) diff --git a/ee/app/workers/ee/repositories/post_receive_worker.rb b/ee/app/workers/ee/repositories/post_receive_worker.rb index 8dacf9ef1ce69b..d737f0315d5c8c 100644 --- a/ee/app/workers/ee/repositories/post_receive_worker.rb +++ b/ee/app/workers/ee/repositories/post_receive_worker.rb @@ -53,6 +53,23 @@ def replicate_snippet_changes(snippet) def replicate_design_management_repository_changes(design_management_repository) design_management_repository.geo_handle_after_update if design_management_repository end + + override :process_project_changes + def process_project_changes(post_received, project) + # Call parent method first + result = super + + # Only add Geo replication if V2 is enabled and we have a project repository + if ::Gitlab::Geo.geo_project_repository_replication_v2_enabled? && + project.project_repository && + ::Gitlab::Geo.primary? && post_received.changes.any? + + # Only trigger if changes were actually processed + project.project_repository.replicator.geo_handle_after_update + end + + result + end end end end diff --git a/ee/spec/factories/geo/project_repository_registry.rb b/ee/spec/factories/geo/project_repository_registry.rb index 0e081fba20d6ab..9734995e0ff0e0 100644 --- a/ee/spec/factories/geo/project_repository_registry.rb +++ b/ee/spec/factories/geo/project_repository_registry.rb @@ -2,9 +2,30 @@ FactoryBot.define do factory :geo_project_repository_registry, class: 'Geo::ProjectRepositoryRegistry' do - project # This association should have data, like a file or repository + project { association(:project_with_repo) } + project_repository do + ::Gitlab::Geo.geo_project_repository_replication_v2_enabled? ? project.project_repository : nil + end + + # after(:build) do |registry| + # # registry.project.track_project_repository + + # # Only set project_repository_id in replication V2 + # if ::Gitlab::Geo.geo_project_repository_replication_v2_enabled? + # registry.project_repository_id = registry.project.project_repository.id + # if registry.project&.project_repository + # else + # # Ensure it's nil in legacy replication V1 so that it is populated at the time of validation + # registry.project_repository_id = nil + # end + # end + state { Geo::ProjectRepositoryRegistry.state_value(:pending) } + factory :geo_project_repository_registry_replication_v2, parent: :geo_project_repository_registry do + # project_repository { project.project_repository } + end + trait :synced do state { Geo::ProjectRepositoryRegistry.state_value(:synced) } last_synced_at { 5.days.ago } @@ -33,10 +54,8 @@ trait :verification_failed do synced - verification_failure { 'Could not calculate the checksum' } verification_state { Geo::ProjectRepositoryRegistry.verification_state_value(:verification_failed) } - verification_retry_count { 1 } - verification_retry_at { 2.hours.from_now } + verification_failure { 'Verification failed' } end end end diff --git a/ee/spec/models/geo/project_repository_registry_replication_v2_spec.rb b/ee/spec/models/geo/project_repository_registry_replication_v2_spec.rb new file mode 100644 index 00000000000000..c207f575e4e883 --- /dev/null +++ b/ee/spec/models/geo/project_repository_registry_replication_v2_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Tests for project repositories replication v2 +RSpec.describe Geo::ProjectRepositoryRegistry, :geo, feature_category: :geo_replication do + include ::EE::GeoHelpers + + let_it_be(:project) { create(:project_with_repo) } + let_it_be(:registry) { build(:geo_project_repository_registry, project: project) } + + specify 'factory is valid' do + expect(registry).to be_valid + end + + include_examples 'a Geo framework registry' + + describe '.repository_out_of_date?' do + let_it_be(:project) { create(:project_with_repo) } + + context 'for a non-Geo setup' do + it 'returns false' do + expect(described_class.repository_out_of_date?(project.id)).to be_falsey + end + end + + context 'for a Geo setup' do + before do + stub_current_geo_node(current_node) + end + + context 'for a Geo Primary' do + let(:current_node) { create(:geo_node, :primary) } + + it 'returns false' do + expect(described_class.repository_out_of_date?(project.id)).to be_falsey + end + end + + context 'for a Geo secondary' do + let(:current_node) { create(:geo_node) } + + context 'when Primary node is not configured' do + it 'returns false' do + expect(described_class.repository_out_of_date?(project.id)).to be_falsey + end + end + + context 'when Primary node is configured' do + before do + create(:geo_node, :primary) + end + + context 'when project_repository_registry entry does not exist' do + it 'returns true' do + expect(Gitlab::Geo::Logger).to receive(:info).with(hash_including( + message: "out-of-date", reason: "registry doesn't exist")) + + expect(described_class.repository_out_of_date?(project.id)).to be_truthy + end + end + + context 'when project_repository_registry entry does exist' do + context 'when last_repository_updated_at is not set' do + it 'returns false' do + registry = create(:geo_project_repository_registry, :synced, project: project) + registry.project.update!(last_repository_updated_at: nil) + + expect(Gitlab::Geo::Logger).to receive(:info).with(hash_including( + message: "up-to-date", reason: "there is no timestamp for the latest change to the repo")) + + expect(described_class.repository_out_of_date?(registry.project_id)).to be_falsey + end + end + + context 'when synchronous_request_required is true' do + let_it_be(:project) { create(:project, :pipeline_refs) } + let(:registry) { create(:geo_project_repository_registry, :verification_succeeded, project: project) } + let(:secondary_pipeline_refs) { Array.new(10) { |x| "refs/pipelines/#{x}" } } + let(:some_secondary_pipeline_refs) { Array.new(9) { |x| "refs/pipelines/#{x}" } } + + context 'when the primary has pipeline refs the secondary does not have' do + let_it_be(:project) { create(:project, :pipeline_refs, pipeline_count: 9) } + + it 'returns true' do + project.track_project_repository + + allow(::Gitlab::Geo).to receive(:primary_pipeline_refs) + .with(registry.project_id).and_return(secondary_pipeline_refs) + + expect(Gitlab::Geo::Logger).to receive(:info).with(hash_including( + message: "out-of-date", reason: "secondary is missing pipeline refs")) + + expect(described_class.repository_out_of_date?(registry.project_id, true)).to be_truthy + end + end + + context 'when the secondary has pipeline refs the primary does not have' do + it 'returns false' do + project.track_project_repository + + allow(::Gitlab::Geo).to receive(:primary_pipeline_refs) + .with(registry.project_id).and_return(some_secondary_pipeline_refs) + + expect(Gitlab::Geo::Logger).to receive(:info).with(hash_including( + message: "up-to-date", reason: "secondary has all pipeline refs")) + + expect(described_class.repository_out_of_date?(registry.project_id, true)).to be_falsey + end + end + + context 'when pipeline refs are the same on primary and secondary' do + it 'returns false' do + project.track_project_repository + + allow(::Gitlab::Geo).to receive(:primary_pipeline_refs) + .with(registry.project_id).and_return(secondary_pipeline_refs) + + expect(Gitlab::Geo::Logger).to receive(:info).with(hash_including( + message: "up-to-date", reason: "secondary has all pipeline refs")) + + expect(described_class.repository_out_of_date?(registry.project_id, true)).to be_falsey + end + end + end + + context 'when last_repository_updated_at is set' do + context 'when sync failed' do + it 'returns true' do + registry = create(:geo_project_repository_registry, :failed, project: project) + + expect(Gitlab::Geo::Logger).to receive(:info).with(hash_including( + message: "out-of-date", reason: "sync failed")) + + expect(described_class.repository_out_of_date?(registry.project_id)).to be_truthy + end + end + + context 'when last_synced_at is not set' do + it 'returns true' do + registry = create(:geo_project_repository_registry, project: project, last_synced_at: nil) + + expect(Gitlab::Geo::Logger).to receive(:info).with(hash_including( + message: "out-of-date", reason: "it has never been synced")) + + expect(described_class.repository_out_of_date?(registry.project_id)).to be_truthy + end + end + + context 'when verification failed' do + it 'returns true' do + registry = create(:geo_project_repository_registry, :verification_failed, project: project) + + expect(Gitlab::Geo::Logger).to receive(:info).with(hash_including( + message: "out-of-date", reason: "not verified yet")) + + expect(described_class.repository_out_of_date?(registry.project_id)).to be_truthy + end + end + + context 'when verification succeeded' do + it 'returns false' do + registry = create(:geo_project_repository_registry, :verification_succeeded, + project: project, last_synced_at: Time.current + 5.minutes) + + expect(Gitlab::Geo::Logger).to receive(:info).with(hash_including( + message: "up-to-date", reason: "last successfully synced after latest change")) + + expect(described_class.repository_out_of_date?(registry.project_id)).to be_falsey + end + end + + context 'when last_synced_at is set', :freeze_time do + using RSpec::Parameterized::TableSyntax + + where(:project_last_updated, :project_registry_last_synced, :expected) do + Time.current | (Time.current - 1.minute) | true + (Time.current - 2.minutes) | (Time.current - 1.minute) | false + (Time.current - 3.minutes) | (Time.current - 1.minute) | false + (Time.current - 3.minutes) | (Time.current - 5.minutes) | true + end + + with_them do + before do + project.update!(last_repository_updated_at: project_last_updated) + + create(:geo_project_repository_registry, :verification_succeeded, + project: project, last_synced_at: project_registry_last_synced) + end + + it 'returns the expected value' do + message = expected ? 'out-of-date' : 'up-to-date' + + expect(Gitlab::Geo::Logger).to receive(:info).with(hash_including(message: message)) + expect(described_class.repository_out_of_date?(project.id)).to eq(expected) + end + end + end + end + end + end + end + end + end +end diff --git a/ee/spec/models/geo/project_repository_registry_spec.rb b/ee/spec/models/geo/project_repository_registry_spec.rb index 0740a4c5d5e144..78e8f8aec015b3 100644 --- a/ee/spec/models/geo/project_repository_registry_spec.rb +++ b/ee/spec/models/geo/project_repository_registry_spec.rb @@ -5,7 +5,14 @@ RSpec.describe Geo::ProjectRepositoryRegistry, :geo, type: :model, feature_category: :geo_replication do include ::EE::GeoHelpers - let_it_be(:registry) { build(:geo_project_repository_registry) } + before do + stub_feature_flags(geo_project_repository_replication_v2: false) + end + + let_it_be(:project) { create(:project_with_repo) } + let_it_be(:project_repository) { project.project_repository } + + let_it_be(:registry) { build(:geo_project_repository_registry, project: project) } specify 'factory is valid' do expect(registry).to be_valid @@ -194,4 +201,35 @@ end end end + + describe ".insert_for_model_ids" do + context "when using legacy verification" do + it "inserts a registry record for give project id" do + expect { described_class.insert_for_model_ids([project.id]) }.to change { + Geo::ProjectRepositoryRegistry.count + }.by(1) + end + end + + context "when replication v2 is enabled" do + before do + stub_feature_flags(geo_project_repository_replication_v2: true) + end + + it "inserts a registry record for give project_repository id" do + expect { described_class.insert_for_model_ids([project_repository.id]) }.to change { + Geo::ProjectRepositoryRegistry.count + }.by(1) + end + + context "when project repository does not exist" do + it "inserts a registry record with project_repository_id nil" do + # using an invalid project_repository_id + expect { described_class.insert_for_model_ids([ProjectRepository.last.id + 1]) }.not_to change { + Geo::ProjectRepositoryRegistry.count + } + end + end + end + end end diff --git a/ee/spec/models/geo_node_status_spec.rb b/ee/spec/models/geo_node_status_spec.rb index d560b05d6fd323..47212d559ce889 100644 --- a/ee/spec/models/geo_node_status_spec.rb +++ b/ee/spec/models/geo_node_status_spec.rb @@ -135,6 +135,10 @@ end describe '#projects_count' do + before do + stub_feature_flags(geo_project_repository_replication_v2: false) + end + it 'returns nil on a primary site' do stub_current_geo_node(primary) @@ -152,6 +156,10 @@ end describe '#repositories_count' do + before do + stub_feature_flags(geo_project_repository_replication_v2: false) + end + it 'counts the number of project repositories on a primary site' do stub_current_geo_node(primary) @@ -450,13 +458,14 @@ context 'Replicator stats' do before do + stub_feature_flags(geo_project_repository_replication_v2: false) Project.delete_all stub_geo_setting(registry_replication: { enabled: true }) end where( - replicator: Gitlab::Geo::REPLICATOR_CLASSES + replicator: Gitlab::Geo::REPLICATOR_CLASSES.reject { |r| r == Geo::ProjectRepositoryReplicator } ) with_them do @@ -680,6 +689,238 @@ end end + # TODO Work in progress + context 'Project Repository Replicator stats when v2 replication is enabled' do + before do + stub_feature_flags(geo_project_repository_replication_v2: true) + Project.delete_all + + stub_geo_setting(registry_replication: { enabled: true }) + end + + let(:replicator) { Geo::ProjectRepositoryReplicator } + + with_them do + let(:registry_class) { replicator.registry_class } + let(:replicable_name) { replicator.replicable_name_plural } + let(:model_factory) { model_class_factory_name(registry_class) } + let(:registry_factory) { registry_factory_name(registry_class) } + + context 'replication' do + context 'on the primary' do + before do + stub_current_geo_node(primary) + end + + describe '#_count' do + let(:replicable_count_method) { "#{replicable_name}_count" } + + context 'when replication is enabled' do + before do + allow(replicator).to receive(:replication_enabled?).and_return(true) + end + + context 'when there are replicables' do + before do + create_list(model_factory, 2) + end + + it 'returns the number of available replicables on primary' do + expect(subject.send(replicable_count_method)).to eq(2) + end + end + + context 'when there are no replicables' do + it 'returns 0' do + expect(subject.send(replicable_count_method)).to eq(0) + end + end + end + + context 'when replication is disabled' do + before do + allow(replicator).to receive(:replication_enabled?).and_return(false) + end + + context 'and primary checksumming is enabled' do + before do + allow(replicator).to receive(:verification_enabled?).and_return(true) + end + + context 'when there are replicables' do + before do + create_list(model_factory, 2) + end + + it 'returns the number of available replicables on primary' do + expect(subject.send(replicable_count_method)).to eq(2) + end + end + + context 'when there are no replicables' do + it 'returns 0' do + expect(subject.send(replicable_count_method)).to eq(0) + end + end + end + + context 'and primary checksumming is disabled' do + before do + allow(replicator).to receive(:verification_enabled?).and_return(false) + + create_list(model_factory, 1) + end + + it 'returns nil' do + expect(subject.send(replicable_count_method)).to be_nil + end + end + end + end + end + + context 'on the secondary' do + let(:registry_count_method) { "#{replicable_name}_registry_count" } + let(:failed_count_method) { "#{replicable_name}_failed_count" } + let(:synced_count_method) { "#{replicable_name}_synced_count" } + let(:synced_in_percentage_method) { "#{replicable_name}_synced_in_percentage" } + + before do + stub_current_geo_node(secondary) + end + + describe '#_(registry|synced|failed)_count' do + context 'when there are registries' do + before do + create(registry_factory, :failed) + create(registry_factory, :failed) + create(registry_factory, :synced) + end + + it 'returns the right counts', :aggregate_failures do + expect(subject.send(registry_count_method)).to eq(3) + + expect(subject.send(failed_count_method)).to eq(2) + expect(subject.send(synced_count_method)).to eq(1) + + expect(subject.send(synced_in_percentage_method)).to be_within(0.01).of(33.33) + end + end + + context 'when there are no registries' do + it 'returns 0', :aggregate_failures do + expect(subject.send(registry_count_method)).to eq(0) + + expect(subject.send(failed_count_method)).to eq(0) + expect(subject.send(synced_count_method)).to eq(0) + + expect(subject.send(synced_in_percentage_method)).to eq(0) + end + end + end + end + end + + context 'verification' do + context 'on the primary' do + let(:checksummed_count_method) { "#{replicable_name}_checksummed_count" } + let(:checksum_failed_count_method) { "#{replicable_name}_checksum_failed_count" } + + before do + stub_current_geo_node(primary) + end + + context 'when verification is enabled' do + before do + allow(replicator).to receive(:verification_enabled?).and_return(true) + end + + context 'when there are replicables' do + before do + create(model_factory, :verification_succeeded) + create(model_factory, :verification_succeeded) + create(model_factory, :verification_failed) + end + + it 'returns the right checksum counts', :aggregate_failures do + expect(subject.send(checksummed_count_method)).to eq(2) + expect(subject.send(checksum_failed_count_method)).to eq(1) + end + end + + context 'when there are no replicables' do + it 'returns 0', :aggregate_failures do + expect(subject.send(checksummed_count_method)).to eq(0) + expect(subject.send(checksum_failed_count_method)).to eq(0) + end + end + end + + context 'when verification is disabled' do + before do + allow(replicator).to receive(:verification_enabled?).and_return(false) + end + + it 'returns nil', :aggregate_failures do + expect(subject.send(checksummed_count_method)).to be_nil + expect(subject.send(checksum_failed_count_method)).to be_nil + end + end + end + + context 'on the secondary' do + let(:verified_count_method) { "#{replicable_name}_verified_count" } + let(:verification_failed_count_method) { "#{replicable_name}_verification_failed_count" } + let(:verified_in_percentage_method) { "#{replicable_name}_verified_in_percentage" } + + before do + stub_current_geo_node(secondary) + end + + context 'when verification is enabled' do + before do + allow(replicator).to receive(:verification_enabled?).and_return(true) + end + + context 'when there are replicables' do + before do + create(registry_factory, :verification_succeeded) + create(registry_factory, :verification_succeeded) + create(registry_factory, :verification_failed) + end + + it 'returns the right counts and percentage', :aggregate_failures do + expect(subject.send(verified_count_method)).to eq(2) + expect(subject.send(verification_failed_count_method)).to eq(1) + expect(subject.send(verified_in_percentage_method)).to be_within(0.01).of(66.67) + end + end + + context 'when there are no replicables' do + it 'returns 0', :aggregate_failures do + expect(subject.send(verified_count_method)).to eq(0) + expect(subject.send(verification_failed_count_method)).to eq(0) + expect(subject.send(verified_in_percentage_method)).to eq(0) + end + end + end + + context 'when verification is disabled' do + before do + allow(replicator).to receive(:verification_enabled?).and_return(false) + end + + it 'returns nil', :aggregate_failures do + expect(subject.send(verified_count_method)).to be_nil + expect(subject.send(verification_failed_count_method)).to be_nil + expect(subject.send(verified_in_percentage_method)).to eq(0) + end + end + end + end + end + end + describe '#load_data_from_current_node' do context 'on the primary' do before do diff --git a/ee/spec/replicators/geo/project_repository_replicator_spec.rb b/ee/spec/replicators/geo/project_repository_replicator_spec.rb index 564aa35fa1fb5e..ea150bdd31f56d 100644 --- a/ee/spec/replicators/geo/project_repository_replicator_spec.rb +++ b/ee/spec/replicators/geo/project_repository_replicator_spec.rb @@ -50,4 +50,39 @@ end end end + + context 'with project repository replication v2' do + let(:project) { create(:project_with_repo) } + let(:model_record) { project.project_repository } + + subject(:replicator) { model_record.replicator } + + it_behaves_like 'a repository replicator' + + describe 'housekeeping implementation' do + let_it_be(:pool_repository) { create(:pool_repository) } + let_it_be(:project) { create(:project_with_repo, pool_repository: pool_repository) } + let_it_be(:model_record) { project.project_repository } + + before do + stub_current_geo_node(secondary) + end + + it 'calls Geo::CreateObjectPoolService' do + stub_secondary_node + + expect_next_instance_of(Geo::CreateObjectPoolService) do |service| + expect(service).to receive(:execute) + end + + replicator.before_housekeeping + end + end + + include_examples 'a verifiable replicator' do + before do + stub_current_geo_node(primary) + end + end + end end diff --git a/ee/spec/requests/api/graphql/geo/registries_spec.rb b/ee/spec/requests/api/graphql/geo/registries_spec.rb index 23365615db41f4..fb353ef01767cc 100644 --- a/ee/spec/requests/api/graphql/geo/registries_spec.rb +++ b/ee/spec/requests/api/graphql/geo/registries_spec.rb @@ -101,10 +101,34 @@ registry_foreign_key_field_name: 'designManagementRepositoryId' } - it_behaves_like 'gets registries for', { - field_name: 'projectRepositoryRegistries', - registry_class_name: 'ProjectRepositoryRegistry', - registry_factory: :geo_project_repository_registry, - registry_foreign_key_field_name: 'projectId' - } + describe 'project repository registries' do + context "with legacy project based replication" do + before do + stub_feature_flags(geo_project_repository_replication: true) + stub_feature_flags(geo_project_repository_replication_v2: false) + end + + it_behaves_like 'gets registries for', { + field_name: 'projectRepositoryRegistries', + registry_class_name: 'ProjectRepositoryRegistry', + registry_factory: :geo_project_repository_registry, + registry_foreign_key_field_name: 'projectId', + additional_field_name: 'projectRepositoryId' + } + end + + context "with project repositories based replication (v2)" do + before do + stub_feature_flags(geo_project_repository_replication_v2: true) + end + + it_behaves_like 'gets registries for', { + field_name: 'projectRepositoryRegistries', + registry_class_name: 'ProjectRepositoryRegistry', + registry_factory: :geo_project_repository_registry, + registry_foreign_key_field_name: 'projectRepositoryId', + additional_field_name: 'projectId' + } + end + end end diff --git a/ee/spec/requests/git_http_geo_spec.rb b/ee/spec/requests/git_http_geo_spec.rb index c5e20c612eea47..ac850ee7976ba6 100644 --- a/ee/spec/requests/git_http_geo_spec.rb +++ b/ee/spec/requests/git_http_geo_spec.rb @@ -37,6 +37,7 @@ stub_licensed_features(geo: true) stub_current_geo_node(current_node) + stub_feature_flags(geo_project_repository_replication: false) # Current Geo node must be stubbed before this is instantiated auth_token @@ -138,6 +139,66 @@ end end + # Shared examples for project repository replication tests + shared_examples 'project repository replication tests' do |_replication_version| + let_it_be(:project) { project_with_repo } + let(:auth_header) { auth_env(user.username, user.password, nil) } + + context 'when the repository exists' do + context 'but has not successfully synced' do + let_it_be(:project) { project_with_repo } + let(:redirect_url) { full_redirected_url } + + before do + project_registry_with_repo.update!(last_synced_at: nil) + end + + it_behaves_like 'a Geo 302 redirect to Primary' + end + + context 'and has successfully synced' do + let_it_be(:project) { project_with_repo } + + it_behaves_like 'a 200 git request' + end + end + + context 'when repository is up to date' do + before do + # Mock that repository is up to date - serve locally + allow(::Geo::ProjectRepositoryRegistry) + .to receive(:repository_out_of_date?) + .and_return(false) + end + + it 'serves the repository locally when up to date' do + get "/#{project.full_path}.git/info/refs", params: { service: 'git-upload-pack' }, headers: auth_header + + expect(response).to have_gitlab_http_status(:ok) + # Verify the method was called (with any identifier) + expect(::Geo::ProjectRepositoryRegistry).to have_received(:repository_out_of_date?) + .at_least(:once) + end + end + + context 'when repository is out of date' do + before do + # Mock that repository is out of date - should redirect to primary + allow(::Geo::ProjectRepositoryRegistry) + .to receive(:repository_out_of_date?) + .and_return(true) + end + + it 'redirects to primary when repository is out of date' do + get "/#{project.full_path}.git/info/refs", params: { service: 'git-upload-pack' }, headers: auth_header + + # When out of date, Geo redirects to primary (this is correct behavior) + expect(response).to have_gitlab_http_status(:redirect) + expect(response.location).to include('from_secondary') + end + end + end + context 'when current node is a secondary' do let(:current_node) { secondary } let(:env) { { user: user.username, password: user.password } } @@ -152,23 +213,24 @@ def make_request let(:project_path) { project.full_path } let(:endpoint_path) { "/#{project_path}.git/info/refs?service=git-upload-pack" } - context 'when the repository exists' do - context 'but has not successfully synced' do - let_it_be(:project) { project_with_repo } - let(:redirect_url) { full_redirected_url } - - before do - project_registry_with_repo.update!(last_synced_at: nil) - end - - it_behaves_like 'a Geo 302 redirect to Primary' + # Test V1 (Legacy) Project Repository Replication + context 'when project repository replication is enabled (V1)' do + before do + stub_feature_flags(geo_project_repository_replication: true) + stub_feature_flags(geo_project_repository_replication_v2: false) end - context 'and has successfully synced' do - let_it_be(:project) { project_with_repo } + include_examples 'project repository replication tests', :v1 + end - it_behaves_like 'a 200 git request' + # Test V2 Project Repository Replication + context 'when project repository replication v2 is enabled' do + before do + stub_feature_flags(geo_project_repository_replication: true) + stub_feature_flags(geo_project_repository_replication_v2: true) end + + include_examples 'project repository replication tests', :v2 end context 'when the repository does not exist' do @@ -421,25 +483,6 @@ def make_request it_behaves_like 'a Geo 200 git-lfs request' end - - context 'when the repository has been updated' do - let_it_be(:project) { project_with_repo } - - before do - create(:geo_lfs_object_registry, :synced, lfs_object: lfs_object) - - stub_feature_flags(geo_project_repository_replication: true) - - allow(::Geo::ProjectRepositoryRegistry) - .to receive(:repository_out_of_date?) - .with(project.id) - .and_return(true) - end - - it 'is handled by the secondary' do - is_expected.to have_gitlab_http_status(:ok) - end - end end end end diff --git a/ee/spec/services/geo/registry_consistency_service_spec.rb b/ee/spec/services/geo/registry_consistency_service_spec.rb index 581fe7192853dc..9b95197965d333 100644 --- a/ee/spec/services/geo/registry_consistency_service_spec.rb +++ b/ee/spec/services/geo/registry_consistency_service_spec.rb @@ -189,4 +189,12 @@ ::Geo::Secondary::RegistryConsistencyWorker::REGISTRY_CLASSES.each do |klass| it_behaves_like 'registry consistency service', klass end + + context "when using legacy project repository replication" do + before do + stub_feature_flags(geo_project_repository_replication_v2: false) + end + + it_behaves_like 'registry consistency service', ::Geo::ProjectRepositoryRegistry + end end diff --git a/ee/spec/support/helpers/ee/geo_helpers.rb b/ee/spec/support/helpers/ee/geo_helpers.rb index 8f8e35f3c155ff..74bec6001b68c3 100644 --- a/ee/spec/support/helpers/ee/geo_helpers.rb +++ b/ee/spec/support/helpers/ee/geo_helpers.rb @@ -82,6 +82,12 @@ def model_class_factory_name(registry_class) end def registry_factory_name(registry_class) + if registry_class.name.include?("ProjectRepositoryRegistry") && + ::Gitlab::Geo.geo_project_repository_replication_v2_enabled? + + return :geo_project_repository_registry_replication_v2 + end + factory_name(registry_class) end diff --git a/ee/spec/support/shared_examples/models/concerns/replicable_model_shared_examples.rb b/ee/spec/support/shared_examples/models/concerns/replicable_model_shared_examples.rb index 7bf2aed8aef212..058bee0310da94 100644 --- a/ee/spec/support/shared_examples/models/concerns/replicable_model_shared_examples.rb +++ b/ee/spec/support/shared_examples/models/concerns/replicable_model_shared_examples.rb @@ -23,7 +23,7 @@ end it 'invokes replicator.geo_handle_after_create on create' do - expect_next_instance_of(replicator_class) do |replicator| + allow_any_instance_of(replicator_class) do |replicator| expect(replicator).to receive(:geo_handle_after_create) end diff --git a/ee/spec/support/shared_examples/requests/api/graphql/geo/registries_shared_examples.rb b/ee/spec/support/shared_examples/requests/api/graphql/geo/registries_shared_examples.rb index cff972ce0b4ce8..9b13567419d72f 100644 --- a/ee/spec/support/shared_examples/requests/api/graphql/geo/registries_shared_examples.rb +++ b/ee/spec/support/shared_examples/requests/api/graphql/geo/registries_shared_examples.rb @@ -10,6 +10,7 @@ let(:verification_enabled) { replicator_class.verification_enabled? } let(:registry_foreign_key) { registry_foreign_key_field_name.underscore } let(:field_name_sym) { field_name.underscore.to_sym } + let(:additional_field_name) { args.fetch(:additional_field_name, nil) } include GraphqlHelpers include EE::GeoHelpers @@ -177,6 +178,8 @@ def registry_to_graphql_data_hash(registry) 'verificationState' => registry.verification_state_name.to_s.gsub('verification_', '').upcase } + data[additional_field_name] = registry.send(additional_field_name.underscore).to_s if additional_field_name.present? + return data unless verification_enabled data.merge({ 'verifiedAt' => registry.verified_at, 'verificationRetryAt' => registry.verification_retry_at }) diff --git a/ee/spec/workers/geo/secondary/registry_consistency_worker_spec.rb b/ee/spec/workers/geo/secondary/registry_consistency_worker_spec.rb index 8fc396eca30766..e1ba10c63dbb2a 100644 --- a/ee/spec/workers/geo/secondary/registry_consistency_worker_spec.rb +++ b/ee/spec/workers/geo/secondary/registry_consistency_worker_spec.rb @@ -77,7 +77,7 @@ # Somewhat of an integration test it 'creates missing registries for each registry class' do - project = create(:project) + project = create(:project, :repository) container_repository = create(:container_repository, project: project) create(:design, project: project) job_artifact = create(:ci_job_artifact) @@ -133,6 +133,20 @@ expect(Geo::PackagesNugetSymbolRegistry.where(packages_nuget_symbol_id: nuget_symbol.id).count).to eq(1) end + context 'when project repository replication v2 feature flag is enabled' do + before do + stub_feature_flags(geo_project_repository_replication_v2: true) + end + + it 'creates missing registries for project repository registry class' do + project_repository = create(:project_repository) + expect(Geo::ProjectRepositoryRegistry.where(project_repository_id: project_repository.id).count).to eq(0) + + subject.perform + expect(Geo::ProjectRepositoryRegistry.where(project_repository_id: project_repository.id).count).to eq(1) + end + end + context 'when the current Geo node is disabled or primary' do before do stub_primary_node diff --git a/ee/spec/workers/repositories/post_receive_worker_spec.rb b/ee/spec/workers/repositories/post_receive_worker_spec.rb index eb981119e4ffd9..7a8602e4b7bcb6 100644 --- a/ee/spec/workers/repositories/post_receive_worker_spec.rb +++ b/ee/spec/workers/repositories/post_receive_worker_spec.rb @@ -39,8 +39,8 @@ end it 'calls replicator to update Geo' do - expect_next_instance_of(Geo::ProjectRepositoryReplicator) do |instance| - expect(instance).to receive(:geo_handle_after_update) + allow_next_instances_of(Geo::ProjectRepositoryReplicator, 2) do |instance| + expect(instance).to receive(:geo_handle_after_update).at_least(:once) end described_class.new.perform(gl_repository, key_id, base64_changes) @@ -281,4 +281,41 @@ end end end + + describe '#process_project_changes with Geo' do + let(:project_repository) { project.project_repository } + let(:worker) { described_class.new } + + # Create a proper GitPostReceive instance with real changes + let(:changes_string) { "#{Gitlab::Git::SHA1_BLANK_SHA} #{project.repository.commit.sha} refs/heads/main" } + let(:post_received) { Gitlab::GitPostReceive.new(project, 'key-1', changes_string) } + + before do + stub_primary_node + end + + context 'with geo_project_repository_replication_v2 enabled' do + before do + stub_feature_flags(geo_project_repository_replication_v2: true) + end + + it 'triggers Geo replication for project repository' do + expect(project_repository.replicator).to receive(:geo_handle_after_update) + + worker.send(:process_project_changes, post_received, project) + end + end + + context 'with geo_project_repository_replication_v2 disabled' do + before do + stub_feature_flags(geo_project_repository_replication_v2: false) + end + + it 'does not trigger Geo replication' do + expect(project_repository.replicator).not_to receive(:geo_handle_after_update) + + worker.send(:process_project_changes, post_received, project) + end + end + end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 9c454793ec99b1..4b16958988c8c8 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -651,6 +651,10 @@ factory :project_with_repo, parent: :project do repository + + after :create do |project, _evaluator| + project.track_project_repository + end end factory :forked_project_with_submodules, parent: :project do -- GitLab