markdown # 트리 계층구조에서의 일괄 반복 (컨셉 프로프)

GitLab에서의 그룹 계층구조는 루트 요소가 최상위 네임스페이스이고 하위 요소는 하위 그룹 또는 최근에 도입된 Namespaces::ProjectNamespace 레코드인 트리로 표현됩니다.

트리는 namespaces 테이블에 parent_id 열을 통해 구현됩니다. 이 열은 상위 네임스페이스 레코드를 가리킵니다. 최상위 네임스페이스에는 parent_id가 없습니다.

gitlab-org의 부분적인 계층구조:

flowchart TD A("gitlab-org (9979)") --- B("quality (2750817)") B --- C("engineering-productivity (16947798)") B --- D("performance-testing (9453799)") A --- F("charts (5032027)") A --- E("ruby (14018648)")

그룹 계층구조를 효율적으로 반복하는 것에는 여러 잠재적인 사용 사례가 있습니다. 특히 안정적이고 안전한 실행이 빠른 실행보다 중요한 백그라운드 작업에서 그룹 계층구조에 대해 쿼리를 수행해야 하는 경우가 그렇습니다. 일괄 반복은 더 많은 네트워크 왕복이 필요하지만 각 일괄은 유사한 성능 특성을 제공합니다.

일부 예시:

  • 각 하위 그룹에 대해 어떤 작업을 수행합니다.
  • 계층구조 내 각 프로젝트에 대해 어떤 작업을 수행합니다.
  • 계층구조 내 각 이슈에 대해 어떤 작업을 수행합니다.

문제 설명

그룹 계층구조가 커져서 하나의 쿼리로는 시간 내에로드할 수 없을 수 있습니다. 쿼리는 제한 시간 초과 오류로 실패할 것입니다.

매우 큰 그룹과 관련된 확장성 문제를 해결하려면 동일한 데이터를 다른 형식(de-normalization)으로 저장해야 합니다. 그러나 그룹 계층구조를로드할 수 없는 경우에는 정규화를 구현할 수 없을 것입니다.

정규화 기술 중 하나는 특정 그룹의 모든 하위 그룹 ID를 저장하는 것입니다. 이렇게 하면 그룹 및 해당 하위 그룹을로드해야 하는 쿼리를 가속화할 수 있습니다. 예시:

흐름도 TD A(1) --- B(2) A --- C(3) C --- D(4)
GROUP_ID DESCENDANT_GROUP_IDS
1 [2,3,4]
2 []
3 [4]
4 []

이 구조를 사용하면 모든 하위 그룹을 결정하는 데 데이터베이스에서 한 행만 읽으면 됩니다. 1000개의 그룹이 있는 계층구조의 경우, 이는 큰 차이를 만들 수 있습니다.

계층구조의 읽기 문제는 이러한 정규화로 해결됩니다. 그러나 여전히이 데이터를 테이블에 지속하는 방법을 찾아야 합니다. 그룹 및 계층구조가 매우 커진다면 이곳에서 단일 쿼리를 기대할 수 없습니다.

SELECT id FROM namespaces WHERE traversal_ids && ARRAY[9970]

위의 쿼리는 대형 그룹의 경우 시간 초과될 수 있으므로 데이터를 일괄로 처리해야 합니다.

트리에 일괄 논리를 구현하는 것은 우리가 이전에 살펴본 적이 없으며 fairly 복잡합니다. EachBatch 또는 find_in_batches 기반 솔루션이 작동하지 않는 이유는 다음과 같습니다:

  • 데이터(그룹 ID)가 계층구조에 정렬되지 않습니다.
  • 하위 그룹의 그룹이 최상위 그룹 ID에 대해 알지 못합니다.

알고리즘

일괄 쿼리는 재귀 CTE SQL 쿼리로 구현되어 있으며 한 일괄은 최대 N개의 행을 읽습니다. 트리 구조 때문에 N개의 행을 읽는다는 것이 반드시 N개의 그룹 ID를 읽는다는 것을 의미하지는 않을 수 있습니다. 트리가 비최적적으로 구조화된 경우 일괄이 N개 이하를 반환할 수 있지만 그 이상은 반환하지 않을 것입니다.

쿼리는 깊이 우선 알고리즘으로 구현되어 있습니다. 쿼리가 완료되면 다음 일괄용 정보를 충분히 반환해야 하기 때문에 깊이 우선 알고리즘을 구현하고 있습니다. GitLab에서는 트리의 깊이를 20으로 제한하고 있으며, 이는 최악의 경우 쿼리가 19개의 요소를 포함한 커서를 반환할 것을 의미합니다.

너비 우선 트리 탐색 알고리즘을 구현하는 것은 실용적이지 않습니다. 그룹은 무제한의 하위 요소를 가질 수 있기 때문에 거대한 커서가 생성될 수 있습니다.

  1. 현재 처리 중인 그룹 ID(최상위 그룹 ID), 두 배열(트리 깊이 및 수집된 ID)을 포함한 초기화 행을 생성합니다.
  2. 행을 재귀적으로 처리하고 다음 중 하나를 수행합니다(조건이 일치하는 경우):
    • 첫 번째 하위 네임스페이스를로드하고 리프 노드에 있는 경우 현재 처리 중인 네임스페이스 ID를 업데이트합니다(가지 아래로 이동).
    • 현재 깊이의 다음 네임스페이스 레코드를로드하고 남은 행이 있는 경우.
    • 한 수준 위로 이동하여 더 높은 수준의 행을 처리합니다.
  3. 읽은 수가 LIMIT (일괄 크기)에 도달할 때까지 처리를 계속합니다.
  4. 커서와 수집된 레코드 ID를 포함하는 마지막으로 처리된 행을 찾습니다.
WITH RECURSIVE result AS (
  (
    SELECT
      9970 AS current_id, /* 현재 처리 중인 네임스페이스 ID */
      ARRAY[9970]::int[] AS depth, /* 커서 */
      ARRAY[9970]::int[] AS ids,  /* 수집된 ID들 */
      1::bigint AS reads,
      'initialize' AS action
  ) UNION ALL
  (
    WITH cte AS ( /* 결과 CTE 여러 번 참조하는 속임수 */
      select * FROM result
    )
    SELECT * FROM (
      (
        SELECT /* 가지 아래로 이동 */
          namespaces.id,
          cte.depth || namespaces.id,
          cte.ids || namespaces.id,
          cte.reads + 1,
          'walkdown'
        FROM namespaces, cte
        WHERE
        namespaces.parent_id = cte.current_id
        ORDER BY namespaces.id ASC
        LIMIT 1
      ) UNION ALL
      (
        SELECT /* 현재 깊이에서 다음 요소 찾기 */
          namespaces.id,
          cte.depth[:array_length(cte.depth, 1) - 1] || namespaces.id,
          cte.ids || namespaces.id,
          cte.reads + 1,
          'next'
        FROM namespaces, cte
        WHERE
        namespaces.parent_id = cte.depth[array_length(cte.depth, 1) - 1] AND
        namespaces.id > cte.depth[array_length(cte.depth, 1)]
        ORDER BY namespaces.id ASC
        LIMIT 1
      ) UNION ALL
      (
        SELECT /* 현재 수준의 처리가 완료되면 한 노드를 위로 이동 */
          cte.current_id,
          cte.depth[:array_length(cte.depth, 1) - 1],
          cte.ids,
          cte.reads + 1,
          'jump'
        FROM cte
        WHERE cte.depth <> ARRAY[]::int[]
        LIMIT 1
      )
    ) next_row LIMIT 1
  )
)
SELECT current_id, depth, ids, action
FROM result
 current_id |    depth     |          ids           |   action
------------+--------------+------------------------+------------
         24 | {24}         | {24}                   | initialize
         25 | {24,25}      | {24,25}                | walkdown
         26 | {24,26}      | {24,25,26}             | next
        112 | {24,112}     | {24,25,26,112}         | next
        113 | {24,113}     | {24,25,26,112,113}     | next
        114 | {24,113,114} | {24,25,26,112,113,114} | walkdown
        114 | {24,113}     | {24,25,26,112,113,114} | jump
        114 | {24}         | {24,25,26,112,113,114} | jump
        114 | {}           | {24,25,26,112,113,114} | jump

참고: 그룹 계층구조에서 모든 네임스페이스 ID를 찾는 데 이 쿼리를 사용하는 것은 현재의 traversal_ids 열을 기반으로 한 self_and_descendants 구현보다 느릴 가능성이 높습니다. 위의 쿼리는 그룹 계층구조에 대한 일괄 반복을 구현할 때에만 사용해야 합니다.

루비에서 기본적인 일괄 구현:

class NamespaceEachBatch
  def initialize(namespace_id:, cursor: nil)
    @namespace_id = namespace_id
    @cursor = cursor || { current_id: namespace_id, depth: [namespace_id] }
  end

  def each_batch(of: 500)
    current_cursor = cursor.dup

    first_iteration = true
    loop do
      new_cursor, ids = load_batch(cursor: current_cursor, of: of, first_iteration: first_iteration)
      first_iteration = false
      current_cursor = new_cursor

      yield ids

      break if new_cursor[:depth].empty?
    end
  end

  private

  # yields array of namespace ids
  def load_batch(cursor:, of:, first_iteration: false)
    recursive_cte = Gitlab::SQL::RecursiveCTE.new(:result,
      union_args: { remove_order: false, remove_duplicates: false })

    ids = first_iteration ? namespace_id.to_s : ""

    recursive_cte << Namespace.select(
      Arel.sql(Integer(cursor.fetch(:current_id)).to_s).as('current_id'),
      Arel.sql("ARRAY[#{cursor.fetch(:depth).join(',')}]::int[]").as('depth'),
      Arel.sql("ARRAY[#{ids}]::int[]").as('ids'),
      Arel.sql("1::bigint AS count")
    ).from('(VALUES (1)) AS does_not_matter').limit(1)

    cte = Gitlab::SQL::CTE.new(:cte, Namespace.select('*').from('result'))

    union_query = Namespace.with(cte.to_arel).from_union(
      walk_down,
      next_elements,
      up_one_level,
      remove_duplicates: false,
      remove_order: false
    ).select('current_id', 'depth', 'ids', 'count').limit(1)

    recursive_cte << union_query

    scope = Namespace.with
      .recursive(recursive_cte.to_arel)
      .from(recursive_cte.alias_to(Namespace.arel_table))
      .limit(of)
    row = Namespace.from(scope.arel.as('namespaces')).order(count: :desc).limit(1).first

    [
      { current_id: row[:current_id], depth: row[:depth] },
      row[:ids]
    ]
  end

  attr_reader :namespace_id, :cursor

  def walk_down
    Namespace.select(
      Arel.sql('namespaces.id').as('current_id'),
      Arel.sql('cte.depth || namespaces.id').as('depth'),
      Arel.sql('cte.ids || namespaces.id').as('ids'),
      Arel.sql('cte.count + 1').as('count')
    ).from('cte, LATERAL (SELECT id FROM namespaces WHERE parent_id = cte.current_id ORDER BY id LIMIT 1) namespaces')
  end

  def next_elements
    Namespace.select(
      Arel.sql('namespaces.id').as('current_id'),
      Arel.sql('cte.depth[:array_length(cte.depth, 1) - 1] || namespaces.id').as('depth'),
      Arel.sql('cte.ids || namespaces.id').as('ids'),
      Arel.sql('cte.count + 1').as('count')
    ).from('cte, LATERAL (SELECT id FROM namespaces WHERE namespaces.parent_id = cte.depth[array_length(cte.depth, 1) - 1] AND namespaces.id > cte.depth[array_length(cte.depth, 1)] ORDER BY id LIMIT 1) namespaces')
  end

  def up_one_level
    Namespace.select(
      Arel.sql('cte.current_id').as('current_id'),
      Arel.sql('cte.depth[:array_length(cte.depth, 1) - 1]').as('depth'),
      Arel.sql('cte.ids').as('ids'),
      Arel.sql('cte.count + 1').as('count')
    ).from('cte')
      .where('cte.depth <> ARRAY[]::int[]')
      .limit(1)
  end
end

iterator = NamespaceEachBatch.new(namespace_id: 9970)
all_ids = []
iterator.each_batch do |ids|
  all_ids.concat(ids)
end

# 테스트
puts all_ids.count
puts all_ids.sort == Namespace.where('traversal_ids && ARRAY[9970]').pluck(:id).sort

예시 일괄 쿼리:

```sql SELECT “namespaces”.* FROM ( WITH RECURSIVE “result” AS (( SELECT 15847356 AS current_id, ARRAY[9970, 12061481, 12128714, 12445111, 15847356]::int[] AS depth, ARRAY[]::int[] AS ids, 1::bigint AS count FROM ( VALUES (1)) AS does_not_matter LIMIT 1) UNION ALL ( WITH “cte” AS MATERIALIZED ( SELECT * FROM result ) SELECT current_id, depth, ids, count FROM (( SELECT namespaces.id AS current_id, cte.depth || namespaces.id AS depth, cte.ids || namespaces.id AS ids, cte.count + 1 AS count FROM cte, LATERAL ( SELECT id FROM namespaces WHERE parent_id = cte.current_id ORDER BY id LIMIT 1 ) namespaces ) UNION ALL ( SELECT namespaces.id AS current_id, cte.depth[:array_length( cte.depth, 1 ) - 1] || namespaces.id AS depth, cte.ids || namespaces.id AS ids, cte.count + 1 AS count FROM cte, LATERAL ( SELECT id FROM namespaces WHERE namespaces.parent_id = cte.depth[array_length( cte.depth, 1 ) - 1] AND namespaces.id > cte.depth[array_length( cte.depth, 1 )] ORDER BY id LIMIT 1 ) namespaces ) UNION ALL ( SELECT cte.current_id AS current_id, cte.depth[:array_length( cte.depth, 1