GitLab EventStore

배경

단일체인으로 이루어진 GitLab 프로젝트가 점점 커지고 더 많은 도메인이 정의되고 있습니다. 이로 인해 이러한 도메인들이 시간적 결합으로 인해 서로 뒤얽히고 있습니다.

상징적인 예로는 PostReceive 워커가 여러 도메인을 거쳐 많은 작업이 발생하는 곳입니다. 새로운 커밋이 푸시되면 해당하는 새로운 동작이 반응되면, 우리는 PostReceive나 해당의 부분 구성요소(Git::ProcessRefChangesService 예를 들어) 어딘가에 코드를 추가합니다.

이러한 형태의 아키텍처는 다음과 같은 문제를 가지고 있습니다:

  • 단일 책임 원칙을 위반합니다.
  • 익숙하지 않은 코드베이스에 코드를 추가하는 것에 대한 리스크가 증가합니다. 알지 못하는 뉘앙스가 있을 수 있으며, 이는 버그나 성능 저하를 유발할 수 있습니다.
  • 도메인 경계를 위반합니다. 특정 네임스페이스(예: Git::) 내에서 갑자기 다른 도메인의 클래스들(예: Ci:: 또는 MergeRequests::)이 관여하는 것을 볼 수 있습니다.

EventStore란?

Gitlab:EventStore는 기존 Sidekiq 워커와 오늘날의 가시성을 기반으로 구축된 기본적인 발행-구독 시스템입니다. 우리는 이 시스템을 사용하여 도메인을 모델링할 때 이벤트 주도적 접근 방법을 적용하며 결합을 최소화합니다.

본질적으로 기존 Sidekiq 워커는 비동기 작업을 수행하기 위해 여전히 그대로 존재하지만 의존성이 역전됩니다.

EventStore 예제

CI 파이프라인이 생성될 때 해당 파이프라인의 ref와 일치하는 머지 리퀘스트에 대한 헤드 파이프라인을 업데이트합니다. 그러면 머지 리퀘스트에서 최신 파이프라인의 상태를 표시할 수 있습니다.

EventStore가 없는 경우

우리는 Ci::CreatePipelineService를 변경하고 파이프라인이 생성되었는지 확인하는 로직(예: if 문)을 추가합니다. 그런 다음 MergeRequests:: 도메인을 위한 몇 가지 부수 효과를 실행할 워커를 예약합니다.

이 스타일은 개방-폐쇄 원칙을 위반하고, 결합을 증가시키며, 다른 도메인에서 부수 효과 로직을 불필요하게 추가합니다.

graph LR subgraph ci[CI] cp[CreatePipelineService] end subgraph mr[MergeRequests] upw[UpdateHeadPipelineWorker] end subgraph no[Namespaces::Onboarding] pow[PipelinesOnboardedWorker] end cp -- perform_async --> upw cp -- perform_async --> pow

EventStore가 있는 경우

Ci::CreatePipelineServiceCi::PipelineCreatedEvent라는 이벤트를 발행하고 해당 책임은 여기서 멈춥니다.

MergeRequests:: 도메인은 이 이벤트를 구독하여 MergeRequests::UpdateHeadPipelineWorker라는 워커로, 그래서:

  • 부수 효과는 비동기로 예약되며, 도메인 이벤트를 발생시키는 주요 비즈니스 트랜잭션에 영향을 미치지 않습니다.
  • 주요 비즈니스 트랜잭션을 수정하지 않고 더 많은 부수 효과를 추가할 수 있습니다.
  • 관여하는 도메인이 무엇이고 그 소유권을 알 수 있습니다.
  • 명시적으로 선언되었기 때문에 시스템에서 어떤 이벤트가 발생하는지 알 수 있습니다.

Gitlab::EventStore를 사용하면 여전히 구독자(Sidekiq 워커)와 도메인 이벤트 스키마 간에 결합이 있습니다. 이러한 수준의 결합은 단일 트랜잭션(Ci::CreatePipelineService)이 다음과 같이 결합되어 있는 것보다 훨씬 작습니다:

  • 여러 구독자.
  • 구독자를 호출하는 여러 방법(조건적 호출 포함).
  • 매개변수 전달하는 여러 방법.
graph LR subgraph ci[CI] cp[CreatePipelineService] cp -- publish --> e[PipelineCreateEvent] end subgraph mr[MergeRequests] upw[UpdateHeadPipelineWorker] end subgraph no[Namespaces::Onboarding] pow[PipelinesOnboardedWorker] end upw -. subscribe .-> e pow -. subscribe .-> e

각 구독자는 본인이 Sidekiq 워커인만큼, 책임 있는 작업 유형과 관련된 어떤 속성이든지 정의할 수 있습니다. 예를 들어, 한 구독자는 urgency: high를 정의하고, 다른 구독자는 덜 중요한 urgency: low를 정의할 수 있습니다.

EventStore는 사실 부수 효과를 비즈니스 트랜잭션과 분리하는 데 도움이 되는 추상화에 불과합니다. 이벤트가 발행되면 EventStore는 각 구독하는 워커에 대해 perform_async를 호출하여 이벤트 정보를 인수로 전달합니다. 이는 사실상 각 구독자의 큐에 Sidekiq 작업을 예약하는 것입니다.

이는 구독자들이 단순히 Sidekiq 워커이기 때문에 다른 변경 사항이 없음을 의미합니다. 예를 들어, 한 워커(구독자)가 작업을 실행하는 데 실패하면, 작업은 재시도되도록 Sidekiq에 다시 넣습니다.

EventStore의 이점

  • 구독자(Sidekiq 워커)는 중요한 부수 효과인 경우 작업 가중치를 변경하여 더 빨리 실행되도록 설정할 수 있습니다.
  • 부수 효과가 비동기적으로 실행되도록 강제하는 것은 자동적으로 시스템의 다른 도메인이 주요 비즈니스 트랜잭션에 영향을 미치지 않고 이벤트에 구독할 수 있도록 합니다.

이벤트 정의

이벤트(Event) 객체는 바운드된 컨텍스트에서 발생한 도메인 이벤트를 나타냅니다. 다른 바운드된 컨텍스트에게 무엇인가 발생했음을 통지하고, 그에 따라 반응할 수 있도록 이벤트를 발행하여 알립니다.

과거에 발생한 사건을 나타내는 이름으로 app/events/<namespace>/ 아래에 새 이벤트 클래스를 정의하세요:

class Ci::PipelineCreatedEvent < Gitlab::EventStore::Event
  def schema
    {
      'type' => 'object',
      'required' => ['pipeline_id'],
      'properties' => {
        'pipeline_id' => { 'type' => 'integer' },
        'ref' => { 'type' => 'string' }
      }
    }
  end
end

JSON schema(https://json-schema.org/specification)과 동일해야 하는 스키마는 JSONSchemer 젬에 의해 유효성이 검사됩니다. 발행자가 구독자와의 계약을 따르도록 하기 위해 이벤트 객체를 초기화할 때 즉시 유효성이 검사됩니다.

최대한 선택적인 속성을 사용하여야 합니다. 이로 인해 스키마 변경에 대한 롤아웃이 적어집니다. 그러나 이벤트 주체의 고유 식별자로 필수 속성을 사용할 수 있습니다. 예를 들어:

  • Ci::PipelineCreatedEvent의 경우 pipeline_id는 필수 속성이 될 수 있습니다.
  • Projects::ProjectDeletedEvent의 경우 project_id는 필수 속성이 될 수 있습니다.

구독자가 필요로 하는 속성만을 발행하세요. 특정 구독자를 위해 페이로드를 맞추지 말아야 합니다. 페이로드는 이벤트를 완전히 나타내야 하며 느슨하게 관련된 속성을 포함해서는 안 됩니다. 예를 들어:

Ci::PipelineCreatedEvent.new(data: {
  pipeline_id: pipeline.id,
  # 모든 구독자가 머지 요청 ID를 필요로하지 않는 한,
  # 이는 구독자가 가져올 수있는 데이터입니다.
  merge_request_ids: pipeline.all_merge_requests.pluck(:id)
})

더 많은 속성을 포함하여 이벤트를 발행하면 구독자에게 먼저 필요한 데이터를 제공할 수 있습니다. 그렇지 않으면 구독자들은 데이터베이스에서 추가 데이터를 가져와야 할 수 있습니다. 그러나 이는 스키마의 지속적인 변경으로 이어질 수 있으며 단일 진실의 소스를 나타내지 않을 수 있는 속성을 추가하는 것을 의미할 수 있습니다. 이 기술은 주로 성능 최적화 용도로 사용하는 것이 좋습니다. 예를 들어, 이벤트가 데이터베이스에서 동일한 데이터를 모두 검색하는 많은 구독자가있는 경우에.

스키마 업데이트

스키마 변경에는 여러 롤아웃이 필요합니다. 새 버전이 배포되는 동안:

  • 기존 발행자는 이전 버전을 사용하여 이벤트를 발행할 수 있습니다.
  • 기존 구독자는 이전 버전을 사용하여 이벤트를 소비할 수 있습니다.
  • 이벤트는 Sidekiq 대기열에 작업 인수로 유지되므로 배포 중에 스키마의 2 개 버전이 있을 수 있습니다.

스키마를 변경하는 것은 궁극적으로 Sidekiq 인수에 영향을 미칩니다. 여러 롤아웃에 대한 자세한 내용은 Sidekiq 스타일 가이드를 참조하세요.

속성 추가

  1. 롤아웃 1:
    • 새 속성을 선택적으로 추가합니다 (required 아님).
    • 구독자를 업데이트하여 새 속성을 포함하거나 포함하지 않는 이벤트를 소비할 수 있도록합니다.
  2. 롤아웃 2:
    • 발행자를 변경하여 새 속성을 제공하도록합니다.
  3. 롤아웃 3: (속성이 required이어야하는 경우):
    • 스키마를 변경하고 구독자 코드를 항상 예상하도록합니다.

속성 제거

  1. 롤아웃 1:
    • 속성이 required이면 선택적으로 만듭니다.
    • 구독자를 업데이트하여 속성을 항상 예상하지 않도록합니다.
  2. 롤아웃 2:
    • 이벤트 발행에서 속성을 제거합니다.
    • 구독자에서 속성을 처리하는 코드를 제거합니다.

기타 변경 사항

속성 이름 변경과 같은 기타 변경 사항의 경우 동일한 단계를 사용합니다:

  1. 이전 속성을 제거
  2. 새 속성을 추가

이벤트 발행

이전 예제에서 이벤트를 발행하려면:

Gitlab::EventStore.publish(
  Ci::PipelineCreatedEvent.new(data: { pipeline_id: pipeline.id })
)

이벤트는 가능한 경우 관련 서비스 클래스에서 발송되어야 합니다. 일부 예외가 있으며, 모델이 상태 기계 전이와 같은 이벤트를 발행하는 것을 허용할 수 있습니다. 예를 들어, Ci::BuildFinishedWorker를 예약하여 여러 부작용 집합을 실행하는 대신 Ci::BuildFinishedEvent를 발행하여 다른 도메인이 비동기적으로 반응할 수 있도록할 수 있습니다.

ActiveRecord 콜백은 도메인 이벤트를 나타내기에는 너무 저수준입니다. 이것들은 더 많은 데이터베이스 레코드 변경을 나타냅니다. 적절한 경우가 있을 수 있지만,이러한 예외를 고려해야합니다.

구독자 만들기

구독자는 Gitlab::EventStore::Subscriber 모듈을 포함하는 Sidekiq 워커입니다. 이 모듈은perform 메서드를 처리하고 handle_event 메서드를 통해 이벤트를 안전하게 처리하는 더 좋은 추상화를 제공합니다. 예를 들어:

module MergeRequests
  class UpdateHeadPipelineWorker
    include Gitlab::EventStore::Subscriber

    def handle_event(event)
      Ci::Pipeline.find_by_id(event.data[:pipeline_id]).try do |pipeline|
        # ...
      end
    end
  end
end

이벤트에 가입자 등록

lib/gitlab/event_store.rb에서 워커를 특정 이벤트에 가입시키려면 Gitlab::EventStore.configure! 메서드에 다음과 같이 라인을 추가하면 됩니다:

note
새로운 워커는 카나리 배포와의 호환성을 보장하기 위해 피쳐 플래그로 소개되어야 합니다.
module Gitlab
  module EventStore
    def self.configure!(store)
      # ...

      store.subscribe ::Sbom::ProcessTransferEventsWorker, to: ::Projects::ProjectTransferedEvent,
        if: ->(event) do
          actor = ::Project.actor_from_id(event.data[:project_id])
          Feature.enabled?(:sync_project_archival_status_to_sbom_occurrences, actor)
        end

      # ...
    end
  end
end

EE 코드베이스에만 정의된 워커는 ee/lib/ee/gitlab/event_store.rb에서 가입을 선언함으로써 동일한 방식으로 이벤트에 가입할 수 있습니다.

구독은 Rails 앱이 로드될 때 메모리에 저장되며 즉시 동결됩니다. 런타임에서 구독을 수정하는 것은 불가능합니다.

이벤트 조건부 디스패치

구독은 이벤트를 수락할 조건을 지정할 수 있습니다.

store.subscribe ::MergeRequests::UpdateHeadPipelineWorker,
  to: ::Ci::PipelineCreatedEvent,
  if: -> (event) { event.data[:merge_request_id].present? }

이것은 이벤트 스토어에게 조건이 충족되면 Ci::PipelineCreatedEvent를 구독자에게 디스패치하도록 지시합니다.

이 기술은 구독자가 작은 이벤트 하위 집합에 관심이 있는 경우 Sidekiq 작업을 예약하는 것을 피할 수 있습니다.

경고: 조건부 디스패치를 사용할 때는 실행이 각 이벤트가 게시될 때마다 동기적으로 실행되기 때문에 저렴한 조건만 포함해야 합니다.

복잡한 조건의 경우에는 모든 이벤트를 구독한 다음 구독자 워커의 handle_event 메서드에서 로직을 처리하는 것이 가장 좋습니다.

이벤트 지연 디스패치

구독은 이벤트를 수신할 시기를 지정할 수 있습니다.

store.subscribe ::MergeRequests::UpdateHeadPipelineWorker,
  to: ::Ci::PipelineCreatedEvent,
  delay: 1.minute

delay 매개변수는 이벤트의 디스패치를 구독자 Sidekiq 워커의 perform_in 메서드를 사용하도록 전환합니다. 이 기술은 많은 이벤트를 게시하고 Sidekiq 중복을 활용하는 데 유용합니다.

이벤트 그룹 게시

일부 시나리오에서는 단일 비즈니스 트랜잭션에서 동일한 유형의 여러 이벤트를 게시합니다. 이는 각 이벤트에 대해 작업을 호출하여 Sidekiq에 부하를 추가합니다. 이런 경우에는 Gitlab::EventStore.publish_group를 호출하여 그룹의 이벤트를 게시할 수 있습니다. 이 메서드는 유사한 유형의 이벤트 배열을 허용합니다. 기본적으로 구독자 워커는 최대 10개의 이벤트 그룹을 받지만, 이는 구독을 생성할 때 group_size 매개변수를 정의하여 구성할 수 있습니다. 구성된 group_size에 따라 구독자에게 배치별로 이벤트가 배치됩니다. 그룹 수가 100을 초과하는 경우 10초의 지연으로 각 그룹을 예약하여 Sidekiq에 부하를 줄일 수 있습니다.

store.subscribe ::Security::RefreshProjectPoliciesWorker,
  to: ::ProjectAuthorizations::AuthorizationsChangedEvent,
  delay: 1.minute,
  group_size: 25

구독자 워커의 handle_event 메서드는 그룹의 각 이벤트에 대해 호출됩니다.

테스트

발행자 테스트

발행자의 책임은 이벤트가 올바르게 발행되었는지를 확인하는 것입니다.

이벤트가 올바르게 발행되었는지 테스트하려면 RSpec 매처 :publish_event를 사용할 수 있습니다:

it '프로젝트 ID와 네임스페이스 ID가 포함된 ProjectDeleted 이벤트를 발행합니다' do
  expected_data = { project_id: project.id, namespace_id: project.namespace_id }

  # 이 매처는 블록이 호출될 때, 블록이 예상 이벤트와 데이터를 게시하는지를 확인합니다.
  expect { destroy_project(project, user, {}) }
    .to publish_event(Projects::ProjectDeletedEvent)
    .with(expected_data)
end

:publish_event 매처 안에도 여러 매처를 구성할 수 있습니다. 이는 새로운 레코드를 생성한 후에 이벤트가 특정 유형의 값과 함께 생성되었는지를 단언하고 싶을 때 유용합니다. 이것은 미리 알 수 없는 값에 대해 이벤트가 생성되었는지를 확인하고자 할 때 유용합니다.

it '프로젝트 ID와 네임스페이스 ID가 포함된 ProjectCreatedEvent를 발행합니다' do
  # 프로젝트 ID는 `create_project`가 호출될 때 생성됩니다.
  expected_data = { project_id: kind_of(Numeric), namespace_id: group_id }

  expect { create_project(user, name: 'Project', path: 'project', namespace_id: group_id) }
    .to publish_event(Projects::ProjectCreatedEvent)
    .with(expected_data)
end

여러 이벤트를 게시할 때 비게시된 이벤트도 확인할 수 있습니다.

it '프로젝트 ID와 네임스페이스 ID가 포함된 ProjectCreatedEvent을 발행합니다' do
  # `create_project`가 호출될 때 프로젝트 ID가 생성됩니다.
  expected_data = { project_id: kind_of(Numeric), namespace_id: group_id }

  expect { create_project(user, name: 'Project', path: 'project', namespace_id: group_id) }
    .to publish_event(Projects::ProjectCreatedEvent)
    .with(expected_data)
    .and not_publish_event(Projects::ProjectDeletedEvent)
end

구독자 테스트

구독자는 발행된 이벤트를 올바르게 소비할 수 있어야 합니다. 이를 위해 우리는 구독자를 테스트하는 방법을 표준화하기 위해 도우미 및 공유 예시를 추가했습니다.

RSpec.describe MergeRequests::UpdateHeadPipelineWorker do
  let(:pipeline_created_event) { Ci::PipelineCreatedEvent.new(data: ({ pipeline_id: pipeline.id })) }

  # 이 공유 예시는 이벤트가 발행되고 현재 구독자(`described_class`)에 의해 올바르게 처리되는지를 보장합니다. 또한 워커가 멱등성을 가지도록 합니다.
  it_behaves_like 'subscribes to event' do
    let(:event) { pipeline_created_event }
  end

  # 이 공유 예시는 발행된 이벤트가 무시되도록 보장합니다. 이는 조건부 디스패치 테스트에 유용할 수 있습니다.
  it_behaves_like 'ignores the published event' do
    let(:event) { pipeline_created_event }
  end

  it '어떤 동작을 수행합니다' do
    # 이 도우미는 `perform`을 직접 실행하여 `handle_event`가 올바르게 호출되는지 보장합니다.
    consume_event(subscriber: described_class, event: pipeline_created_event)

    # 기대값 실행
  end
end

최선의 실천법

  • [CE & EE separation and compatibility] 유지:
    • 이벤트 클래스를 정의하고 이벤트를 항상 발생하는 코드(CE 또는 EE)와 함께 게시합니다.
      • 이벤트가 CE 기능의 결과로 발생하는 경우, 이벤트 클래스는 반드시 CE에서 정의 및 게시되어야 합니다. 마찬가지로 EE 기능의 결과로 이벤트가 발생하는 경우, 이벤트 클래스는 반드시 EE에서 정의 및 게시되어야 합니다.
    • 이벤트에 따라 종속되는 구독자를 해당 종속 기능이 존재하는 코드(CE 또는 EE)와 함께 정의합니다.
      • CE에서 발행된 이벤트(예: Projects::ProjectCreatedEvent) 및 이 이벤트에 종속되는 구독자를 EE(예: Security::SyncSecurityPolicyWorker)에서 정의할 수 있습니다.
  • 이벤트 클래스를 동일한 바운디드 컨텍스트(상위 레벨 Ruby 네임스페이스) 내에서 정의하고 이벤트를 발행합니다.
    • 특정한 바운디드 컨텍스트는 해당 컨텍스트에 관련된 이벤트만을 발행해야 합니다.
  • 이벤트를 구독할 때 신호 대 잡음 비율을 평가하세요. 구독자 내에서 처리하는 이벤트 수 대 무시하는 이벤트 수는 얼마나 되는지 생각해 보세요. 관심 있는 일부 이벤트만을 사용하려면 조건부 디스패치를 사용하는 것을 고려해 보세요. 조건부 디스패치로 동기적인 확인을 실행하거나 잠재적으로 중복되는 워커를 예약하는 것 사이의 균형을 유지하세요.