GitLab EventStore

배경

단일체인 GitLab 프로젝트는 점점 커지고 더 많은 도메인이 정의되고 있습니다. 그 결과로 이러한 도메인들은 시간적 결합으로 인해 서로 엉키게 됩니다.

상징적인 예는 PostReceive 워커입니다. 여기에서 많은 일들이 여러 도메인을 걸쳐 일어납니다. 새로운 동작이 새 커밋을 푸시하게 되면, PostReceive나 그 하위 구성요소인 Git::ProcessRefChangesService에서 코드를 어딘가에 추가합니다.

이러한 유형의 아키텍처는 다음과 같습니다:

  • 단일 책임 원칙 위반입니다.
  • 익숙하지 않은 코드베이스에 코드를 추가하는 위험을 증가시킵니다. 알지 못하는 뉘앙스로 인해 버그나 성능 저하가 발생할 수 있습니다.
  • 도메인 경계를 위반합니다. 특정 네임스페이스(예: Git::) 내에서 갑자기 다른 도메인의 클래스들이 관여합니다(Ci::MergeRequests:: 같은).

이벤트스토어란?

Gitlab:EventStore는 기존의 Sidekiq 워커와 현재 우리가 가지고 있는 관찰 기능을 기반으로 만든 기본적인 pub-sub 시스템입니다. 이 시스템을 사용하여 도메인을 모델링할 때 이벤트 기반 접근 방식을 적용하고 결합을 최소화합니다.

기본적으로 기존의 Sidekiq 워커를 그대로 사용하여 비동기 작업을 수행하지만 의존성을 뒤집습니다.

이벤트스토어 예시

CI 파이프라인이 생성되면 해당 파이프라인의 ref와 일치하는 모든 머지 요청에 대한 헤드 파이프라인을 업데이트합니다. 그러면 머지 요청은 최신 파이프라인의 상태를 표시할 수 있습니다.

이벤트스토어를 사용하지 않을 때

우리는 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

이벤트스토어를 사용할 때

Ci::CreatePipelineServiceCi::PipelineCreatedEvent라는 이벤트를 발행하고, 책임은 여기에서 끝납니다.

MergeRequests:: 도메인은 이 이벤트를 구독할 수 있도록 워커 MergeRequests::UpdateHeadPipelineWorker를 사용하여 구독할 수 있습니다. 따라서:

  • 부작용은 비동기적으로 예약되며, 주 도메인 이벤트를 발생시키는 주 비즈니스 트랜잭션에 영향을주지 않습니다.
  • 주 비즈니스 트랜잭션을 수정하지 않고도 더 많은 부작용을 추가할 수 있습니다.
  • 우리는 관련된 도메인이 무엇이며 소유권이 무엇인지 명확히 볼 수 있습니다.
  • 시스템에서 발생하는 이벤트를 명시적으로 선언했기 때문에 어떤 이벤트가 발생했는지 식별할 수 있습니다.

Gitlab::EventStore를 사용하면 구독자(Sidekiq 워커)와 도메인 이벤트의 스키마 간에는 여전히 결합이 있습니다. 이러한 결합의 수준은 다음과 같습니다:

  • 여러 구독자에 결합됩니다.
  • 구독자를 호출하는 여러 방법(조건부 호출 포함)이 있습니다.
  • 매개변수 전달의 여러 방법이 있습니다.
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를 설정할 수 있습니다.

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

이는 구독자들이 그저 Sidekiq 워커이기 때문에 다른 곳에서 작업 방식이 변경되는 것은 없다는 것을 의미합니다. 예를 들어: 워커(구독자)가 작업을 실행하지 못하면 잡은 Sidekiq에 다시 넣어 재시도됩니다.

이벤트스토어의 이점

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

이벤트스토어의 단점

  • 이벤트스토어는 Sidekiq 위에서 만들어졌습니다. 비록 Sidekiq 워커가 재시도와 지수 백오프를 지원하지만, 워커가 재시도 한계를 초과하거나 Sidekiq 잡이 손실되는 경우도 있습니다. 또한 사고, 재해 복구의 일부로써 Sidekiq 잡이 삭제 될 수 있습니다. 많은 중요한 GitLab 기능들이 Sidekiq의 내구성 가정을 기반으로 하고 있지만, 이는 어떤 중요한 데이터 무결성 기능에 대해서는 수용되지 않을 수 있습니다. 작업이 최종적으로 수행되었음을 확신해야하는 경우, Postgres에서 큐 메커니즘을 구현해야 할 수도 있습니다. 이는 Sidekiq 크론 워커에서 처리되는 일부 데이터를 삽입하고 몇 가지 작업을 수행 한 후 데이터베이스에서 processed로 표시됩니다. ::LooseForeignKeys::CleanupWorker::BatchedGitRefUpdates::ProjectCleanupWorker에서이 접근 방식의 예제를 볼 수 있습니다. ::Elastic::ProcessBookkeepingService를 사용하는 것처럼 Redis에서 안정적인 큐를 구현하는 전략도 있습니다. 코드베이스에 새로운 큐잉 패턴을 도입하는 경우 프로세스 초기 단계에서 메인테이너로부터 조언을 구하려고 할 것입니다.
  • 반대로, 로직이 주 비즈니스 트랜잭션의 일부로 처리되어야하며 부작용이 아닌 경우 이벤트스토어를 사용하지 않을 수 있습니다.
  • Sidekiq 워커는 기본적으로 제한되어 있지 않지만, 공유 리소스가 포화되는 위험이있는 경우 동시성 제한을 구성하는 것이 좋습니다.

이벤트 정의

Event 객체는 bounded context에서 발생한 도메인 이벤트를 나타냅니다. 생산자는 이벤트를 발행함으로써 다른 bounded context에 어떤 일이 발생했음을 알릴 수 있으며, 이를 통해 반응할 수 있습니다. 이벤트는 <도메인_객체><동작>Event와 같이 명명되어야 합니다. 여기서 동작은 과거 시제이어야 하며, 예를 들어 AddReviewerEvent가 아닌 ReviewerAddedEvent로 사용되어야 합니다. bounded context에 따라 명백히 알 수 있는 경우에는 domain_object를 생략할 수 있습니다. 예를 들어 MergeRequest::MergeRequestApprovedEvent 대신 MergeRequest::ApprovedEvent로 사용할 수 있습니다.

좋은 이벤트 지침

이벤트는 API나 UI와 마찬가지로 공개 인터페이스입니다. 구독자의 요구 사항을 충족시키기 위해 새로운 이벤트가 필요한지 제품 및 디자인 동료들과 협력하세요. 가능한 경우 새로운 이벤트는 다음 원칙을 준수해야 합니다.

  • 의미론적: 이벤트는 bounded context 내에서 발생한 사항을 설명해야 하며, 구독자를 위한 의도된 동작을 설명해서는 안 됩니다.
  • 구체적: 이벤트는 과도하게 정확하지 않으면서도 좁은 범위로 정의되어야 합니다. 이는 구독자가 수행해야 하는 이벤트 필터링 및 구독해야 하는 고유 이벤트 수를 최소화합니다. 추가 정보를 전달하기 위해 속성을 사용하는 것을 고려해보세요.
  • 범위 지정: 이벤트는 해당 bounded context에 범위를 지정해야 합니다. bounded context에 포함되지 않은 도메인 객체에 대한 이벤트를 발행하는 것은 피하세요.

예시

원칙 좋은 예 나쁜 예
의미론적 MergeRequest::ApprovedEvent MergeRequest::NotifyAuthorEvent
구체적 MergeRequest::ReviewerAddedEvent • MergeRequest::ChangedEvent
• MergeRequest::CodeownerAddedAsReviewerEvent
범위 지정 MergeRequest::CreatedEvent Project::MergeRequestCreatedEvent

이벤트 스키마 작성

과거에 발생한 사항을 나타내는 이름으로 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의 유효한 형식이어야 하는 스키마는 JSONSchemer 젬에서 유효성을 검사합니다. 발행자가 구독자와 계약을 따르도록 즉시 이벤트 객체를 초기화할 때 유효성 검사가 진행됩니다.

가능한 경우 선택적 속성을 사용하며, 이는 스키마 변경에 대해 적은 롤아웃을 필요로 합니다. 그러나 이벤트 주체의 고유 식별자로 required 속성을 사용할 수도 있습니다. 예를 들어:

  • 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 큐에 지속됩니다. 따라서 배포 중에는 스키마의 두 버전이 있을 수 있습니다.

스키마를 변경하면 결국 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 })
)

이벤트는 가능한 경우 관련 Service 클래스에서 디스패치해야 합니다. 상태 머신 전환과 같이 모델에서 이벤트를 게시할 수 있는 몇 가지 예외 사항이 있을 수 있습니다. 예를 들어, 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 메서드를 사용하도록 전환하며, perform_async 대신 사용합니다.

이 기술은 많은 이벤트를 발행하고 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

Best practices

  • CE 및 EE 분리 및 호환성 유지:
    • 이벤트 클래스를 정의하고 이벤트를 항상 발생하는 코드(CE 또는 EE)와 같은 곳에서 발행합니다.
      • 이벤트가 CE 기능의 결과로 발생하는 경우, 이벤트 클래스는 CE에서 정의되고 발행되어야 합니다. 마찬가지로, 이벤트가 EE 기능의 결과로 발생하는 경우, 이벤트 클래스는 EE에서 정의되고 발행되어야 합니다.
    • 의존하는 기능이 있는 코드(CE 또는 EE)에서 의존하는 이벤트를 정의합니다.
      • CE에서 발행된 이벤트(예: Projects::ProjectCreatedEvent)와 이 이벤트에 의존하는 구독자를 EE에서 정의할 수 있습니다(예: Security::SyncSecurityPolicyWorker).
  • 같은 바운더리 컨텍스트(최상위 루비 네임스페이스) 내에서 이벤트 클래스를 정의하고 이벤트를 발행합니다.
    • 주어진 바운더리 컨텍스트는 자신의 컨텍스트와 관련된 이벤트만 발행해야 합니다.
  • 이벤트에 구독할 때 신호 대 노이즈 비율을 평가하세요. 구독자 내에서 처리 및 무시하는 이벤트의 수는 어떻습니까? 일부 이벤트 중 일부에만 관심이 있는 경우 조건부 디스패치를 사용하는 것을 고려하세요. 조건부 디스패치와 동기적 검사를 실행하는 것 또는 잠재적으로 중복되는 워커를 예약하는 것 사이의 균형을 유지하세요.