ES /docs

EditingRepository#update — missing deadlock retry wrapper

RCA: Cupix::Errors::System — Mysql2::Error: Deadlock found when trying to get lock

Overview#

What Happened#

cupixworks-api (tesla) 의 PATCH /api/v1/editings/{id} (Api::V1::EditingsController#update) 요청이 InnoDB deadlock 으로 실패하고 있다. Editing#save! 가 하나의 트랜잭션 안에서 state_machine transition callback 을 통해 자기 자신뿐 아니라 관련 row (parent editing, review editing, editing_entities, reviewer) 를 함께 갱신하는데, 여러 editor 가 동시에 editing 을 수정할 때 lock 획득 순서가 엇갈리면서 MySQL 이 deadlock victim 을 골라 트랜잭션을 강제 롤백한다. Rails 는 이를 ActiveRecord::Deadlocked 로 감싸 503 응답을 반환한다. 51건이 약 3개월에 걸쳐 발생했고, 대부분 같은 초 안에 서로 다른 editing ID 로 몰려서 나타나는 전형적인 동시성 lock 충돌 패턴이다.

Quick Facts#

Field Value
exception.class ActiveRecord::Deadlocked (root: Mysql2::Error)
exception.message Mysql2::Error: Deadlock found when trying to get lock; try restarting transaction
top_frame app/repositories/editing_repository.rb:17 (@model.save!)
endpoint PATCH /api/v1/editings/{id} (Api::V1::EditingsController#update)
http_status 503
env production (service: cupixworks-api)

Affected Teams#

Team / Domain Error Count Impact
cupixworks-api / Editing (편집 워크플로우) 51 (3개월) editor 의 editing 저장 요청이 간헐적으로 503 으로 실패, 재시도 필요

Timeline#

  1. 2026-04-24 00:18 KST — 최초 발생 (first_seen, Error Tracking issue 등록)
  2. 2026-08-01 00:05 KST — cluster 파일 기준 last_seen
  3. 2026-08-03 ~ 2026-08-04 — Datadog 로그에서 지속 재발 확인 (최근 14일 내 다수 발생, 아래 Log Evidence 참고)
  4. 2026-08-04 — RCA 수행

Error Log#

Datadog Logs

text
Mysql2::Error: Deadlock found when trying to get lock; try restarting transaction

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 51
  • 최초 발생: 2026-04-24 00:18 KST
  • 최근 발생: 2026-08-01 00:05 KST (Datadog 로그상 2026-08-04 까지 재발 지속)

Root Cause Summary#

Editing#save!state_machine :state 의 transition callback 안에서 자기 자신 이외의 관련 row 를 같은 트랜잭션에서 갱신한다 — parent editing 및 review editing 상태 동기화(sync_parent_to_review, sync_review_completionorigin.public_send("#{to_state}_state!"), review.update!), reviewer 상태 갱신, has_many :editing_entities assign 등. 여러 editor 가 동시에 editing 을 PATCH 로 수정할 때 각 트랜잭션이 editings / editing_entities / reviewer row 에 대해 서로 다른 순서로 lock 을 잡으면 InnoDB 가 순환 대기를 감지하고 한쪽 트랜잭션을 deadlock victim 으로 롤백한다. EditingRepository#update (app/repositories/editing_repository.rb:11-23) 의 save! 경로에는 deadlock 재시도 로직이 없어서, 이 transient lock 충돌이 그대로 ActiveRecord::Deadlocked → 503 으로 사용자에게 전달된다. 같은 코드베이스의 인접 경로(editing_entity.rb, facility.rb)는 이미 deadlock 을 transient 로 간주하고 retry 하는 헬퍼를 갖고 있으므로, 이 경로에만 재시도가 빠진 것이 결함이다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/editings_controller.rb:22-26update 액션이 repository_instance.update(params) 호출
app/controllers/api/v1/editings_controller.rb:22-26ruby
  def update
    @model = repository_instance.update(params)

    super
  end
  • Failure point: app/repositories/editing_repository.rb:11-23@model.save! 가 deadlock 을 던짐. 참고로 rescue StandardErrorMysql2::ErrorARG10001 로 감싸지만, deadlock 은 save! 가 여는 트랜잭션 커밋/callback 시점에 ActiveRecord::Deadlocked 로 발생하며 로그의 error.classActiveRecord::Deadlocked 로 기록되었다 (503).
app/repositories/editing_repository.rb:11-23ruby
  def update(params = {})
    super

    set_parameters(params)

    begin
      @model.save!
    rescue StandardError => e
      raise Cupix::Errors::Parameter.new(code: 'ARG10001', reason: 'Invalid argument', message: e.message)
    end

    @model
  end
  • 트랜잭션 내 다중 row 갱신 지점: app/models/concerns/statable/editing.rb:184-204 — state transition 이 별도 editing row (review) 를 같은 트랜잭션에서 update!
app/models/concerns/statable/editing.rb:190-204ruby
        after_transition from: any, to: %i[done rejected] do |model, transition|
          next if model.editing_type == 'review'

          review = model.review_editing
          if review.present? && review.state != transition.to.to_s
            # ... logging ...
            review.update!(state: transition.to)
          end
        end
app/models/concerns/statable/editing.rb:215-237ruby
      def sync_review_completion(to_state)
        origin = self.parent
        return if origin.nil?
        # ...
        reviewer = origin.reviewers.find_by(user_id: self.editor_id)
        if reviewer.present? && to_state == :done
          reviewer.approved_state! if reviewer.respond_to?(:approved_state!)
        elsif reviewer.present?
          reviewer.rejected_state! if reviewer.respond_to?(:rejected_state!)
        end

        # Sync Origin Editing state
        origin.public_send("#{to_state}_state!")
      end
  • 기대 동작: 동시 편집이 발생해도 각 요청이 성공적으로 저장되어야 한다. Deadlock 은 InnoDB 가 lock 순환을 해소하기 위해 임의의 트랜잭션을 롤백하는 정상적이고 transient 한 상황이므로, 애플리케이션은 이를 재시도로 흡수해야 한다.
  • 실제 동작: save! 경로에 재시도가 없어 deadlock victim 요청이 곧바로 503 으로 실패한다.

인접 경로의 기존 처리 (precedent) — 같은 리포지토리는 이미 deadlock 을 transient 로 간주하고 재시도한다:

app/models/concerns/has_default/facility.rb:116-129ruby
      # Deadlocks during default-model creation are transient lock conflicts with
      # concurrent facility creation / background writers (TSLA-13664) — retrying the
      # single failed step resolves most of them.
      def with_deadlock_retry(attempts: 3)
        tries = 0
        begin
          tries += 1
          yield
        rescue ActiveRecord::Deadlocked
          raise if tries >= attempts

          sleep(0.1 * tries)
          retry
        end
      end
app/models/concerns/finalization/editing_entity.rb:16-35ruby
      def assign_editing_to_editing_entity
        retries = 0
        begin
          _do_assign_editing_to_editing_entity
        rescue ActiveRecord::LockWaitTimeout, ActiveRecord::Deadlocked => e
          retries += 1
          if retries <= LOCK_RETRY_MAX_ATTEMPTS
            delay = LOCK_RETRY_BASE_DELAY * (2**(retries - 1))
            # ... warn log ...
            sleep(delay)
            retry
          else
            # ... error log, raise ...
          end
        end
      end

Log Evidence#

사용한 Datadog 쿼리 (재현 가능):

text
service:cupixworks-api "Deadlock found when trying to get lock"

Representative Error 는 stale 하지 않다 — 최근(2026-08-03 ~ 2026-08-04) 로그도 동일한 메시지이며, 모두 PATCH /api/v1/editings/{id} (Api::V1::EditingsController#update) 에서 503 으로 발생한다:

json
{
  "timestamp": "2026-08-04 15:43:21",
  "status": "info",
  "message": "[503] PATCH /api/v1/editings/1338882 (Api::V1::EditingsController#update)",
  "error": {
    "message": "Mysql2::Error: Deadlock found when trying to get lock; try restarting transaction",
    "class": "ActiveRecord::Deadlocked"
  }
}

동시성 lock 충돌 패턴 — 서로 다른 editing ID 가 같은 초에 몰려서 deadlock (동시 편집 → 순환 lock 대기):

text
2026-08-03 17:08:25  [503] PATCH /api/v1/editings/5725
2026-08-03 17:08:25  [503] PATCH /api/v1/editings/5729
2026-08-03 17:08:24  [503] PATCH /api/v1/editings/5719
2026-08-03 17:08:24  [503] PATCH /api/v1/editings/5731
2026-08-03 17:08:24  [503] PATCH /api/v1/editings/5733
...
2026-07-31 09:44:11  [503] PATCH /api/v1/editings/218699
2026-07-31 09:44:11  [503] PATCH /api/v1/editings/218712
2026-07-31 09:44:11  [503] PATCH /api/v1/editings/218776

같은 초에 여러 다른 ID 가 동시에 deadlock 되는 것은 단일 row 재진입이 아니라 다수 concurrent 트랜잭션 간 lock 순환 임을 강하게 시사한다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 동시 editing 수정 시 save! 트랜잭션이 state_machine callback 으로 관련 row(parent/review/entities/reviewer)를 갱신하며 lock 순환 → InnoDB deadlock, 재시도 부재로 503 노출 같은 초에 서로 다른 editing ID 다수 deadlock (17:08:24-25, 09:44:11); statable/editing.rb:190-204 가 별도 editing row 를 트랜잭션 내 update!; editing_repository.rb:11-23 에 retry 없음; 인접 경로는 이미 with_deadlock_retry/retry 보유 Confirmed
H2 Representative Error 가 stale 하여 실제 최근 메시지가 다름 Error Tracking 이 first_seen 샘플을 pin 하는 특성 최근(2026-08-04) 로그 메시지가 representative 와 동일 (Deadlock found ...), 모두 editings update 경로 Rejected
H3 특정 잘못된 입력/스키마 문제로 인한 결정적 실패 (client 4xx 성) 응답이 503 (server), 메시지가 deadlock (transient lock), 같은 ID 가 재시도 시 성공 가능한 패턴 Rejected
H4 외부 의존성/인프라 장애 status-board 결과 active incident 없음(svc scope); DB 자체 다운이 아니라 정상 동작인 lock 충돌 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/repositories/editing_repository.rb:11-23update@model.save! 경로를 deadlock 재시도로 감싼다. 코드베이스에 이미 존재하는 with_deadlock_retry (facility) / LOCK_RETRY (editing_entity) 패턴을 재사용하여, ActiveRecord::Deadlocked (및 필요 시 ActiveRecord::LockWaitTimeout) 발생 시 짧은 backoff 후 소수 회(예: 3회) 재시도한다. editing update 는 PATCH 기반 상태 저장으로, 재시도가 중복 부작용을 만들지 않는 범위이므로 안전하다.
  • 재시도 소진 후에도 실패하면 현재처럼 예외를 전파하되, deadlock 은 ARG10001(Invalid argument, client error) 로 감싸는 대신 transient server 오류로 처리/로깅하는 것이 정확하다 (현재 rescue StandardError 가 의미를 왜곡함).

단기 개선 (1주 이내)#

  • deadlock 재시도 헬퍼를 공통 모듈(예: BaseRepository 또는 concern)로 승격하여 EditingRepository#update 및 유사 write 경로가 일관되게 사용하도록 한다. 현재 facility / editing_entity 에 중복 구현되어 있다.
  • retry 시 warn 레벨 로그를 남겨(성공적으로 흡수된 deadlock) 빈도를 관측 가능하게 한다. 최종 실패만 error 로 남긴다.

장기 개선 (재발 방지)#

  • state transition callback 이 같은 트랜잭션에서 갱신하는 관련 row 들의 lock 획득 순서를 일관되게 만든다 (예: 항상 parent → self → children, id 오름차순 등). 순서를 표준화하면 deadlock 발생 자체를 줄일 수 있다.
  • editing 저장 트랜잭션의 범위를 축소하거나, parent/review 동기화 같은 부작용을 가능한 경우 after_commit 비동기(worker) 로 분리해 lock 보유 시간을 단축한다.

Monitoring#

  • deadlock 발생 빈도 추이:
text
service:cupixworks-api "Deadlock found when trying to get lock"
  • editings update 503 추이:
text
service:cupixworks-api "PATCH /api/v1/editings" "[503]"

Risk Assessment#

  • Risk level: medium — 사용자 요청이 간헐적으로 503 실패하지만 데이터 손상은 없고 재시도로 성공 가능. 빈도는 낮음(3개월 51건)이나 동시 편집 증가 시 악화 가능.
  • 예상 복잡도: standard — 재시도 wrapper 적용은 기존 패턴 재사용으로 간단하나, lock 순서 표준화(장기)는 careful review 필요.

Noise Verdict#

bug — editing 저장의 정상적인 동시성 상황에서 발생하는 transient deadlock 을 인접 코드가 이미 하는 재시도 없이 그대로 503 으로 노출하므로, 재시도 wrapper 를 추가해야 하는 코드 결함이다.