ES /docs

ActiveRecord::LockWaitTimeout: Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction

RCA: ActiveRecord::LockWaitTimeout in SQA editing release

Overview#

이 클러스터는 cupixworks-mysql2 어댑터 이름으로 수집됐지만, 실제 발생 지점은 cupixworks-worker(tesla)의 Cupix::EditingSplitService.release_single_editing 이다. SQA(siteinsights) editing 을 split 후 :waiting 에서 :ready 로 올리는 과정에서 with_lock(SELECT ... FOR UPDATE) 이 병행 split/stamp/merge 와 경합해 lock wait timeout 이 난다. 이 예외는 코드에서 rescue 되어 backoff 재시도로 이어지며, 재시도 예산 소진 시 5분 주기 cron 이 다시 회수한다.

What Happened#

2026-08-04 최근 발생분에서 SQA editing release 워커가 Editing 행 잠금을 얻지 못해 Mysql2::Error::TimeoutError: Lock wait timeout exceeded 로 실패했다. 워커는 이를 lock_contention 사유로 rescue 하고 backoff 재시도하며, 2회 시도 후 "Release abandoned" error 로그를 남기고 멈춘다. 멈춘 editing 은 5분 주기 release_stuck_split_editings cron 이 다시 release 큐에 넣어 회수하므로 데이터는 유실되지 않는다.

Quick Facts#

Field Value
exception.class ActiveRecord::LockWaitTimeout (Mysql2::Error::TimeoutError)
exception.message Lock wait timeout exceeded; try restarting transaction
top_frame app/services/cupix/editing_split_service.rb:318
runtime Ruby on Rails (Sidekiq worker), MySQL InnoDB
env production (cupixworks-worker)

Affected Teams#

Team / Domain Error Count Impact
SiteInsights (SQA) editing split 클러스터 누적 299 (여러 변형 합산) Editing 이 :ready 승격이 일시 지연됨. cron sweep 으로 자동 회수, 영구 유실 없음

Timeline#

  1. 2025-11-15 12:38 KST — Error Tracking 이슈 최초 감지 (Representative Error 는 bare Mysql2::Error::TimeoutError).
  2. 2026-08-04 14:48 KST — editing 82176 lock_contention 로 2회 시도 후 abandoned.
  3. 2026-08-04 15:08 KST — editing 82187 lock_contention 로 2회 시도 후 abandoned.
  4. 2026-08-04 15:57 KST — 클러스터 last_seen.
  5. 2026-08-04 이후Editing released to :ready after split info 로그로 회수 확인 (자동 복구).

Error Log#

Datadog Logs

text
Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction

실제 최근(last_seen 근방) 발생 메시지는 Representative Error 와 다르다. Error Tracking 이 여러 변형(lock_contention, unexpected_error, empty_shell_deferred)을 한 이슈로 묶고 오래된 bare 메시지를 Representative 로 고정했다. 분석은 아래 최근 메시지 기준으로 수행했다.

text
Release abandoned for editing 82187 (lock_contention) after 2 attempts: Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction

Impact#

  • Service: cupixworks-mysql2 (실제 발생: cupixworks-worker / tesla)
  • 발생 횟수: 299 (여러 변형 합산)
  • 최초 발생: 2025-11-15 12:38 KST
  • 최근 발생: 2026-08-04 15:57 KST

Root Cause Summary#

SQA(siteinsights) editing 을 split 한 뒤 Cupix::EditingSplitService.release_single_editing 이 해당 Editing 행에 with_lock(SELECT ... FOR UPDATE) 을 걸고 :waiting:ready 상태 전이를 원자적으로 수행한다. 같은 Editing 행을 건드리는 병행 split reconcile / stamp consolidation / merge 경로와 경합하면 InnoDB 가 innodb_lock_wait_timeout 을 초과해 Mysql2::Error::TimeoutError 를 던진다. 이 예외는 rescue ActiveRecord::LockWaitTimeout, ActiveRecord::Deadlocked 로 잡혀 _reschedule_release 가 backoff 재시도로 재큐한다. ReleaseSplitEditingWorker::MAX_TOTAL_ATTEMPTS = 3 이라 attempt 2 에서 "Release abandoned" error 로그를 남기고 fast 재시도 체인을 종료한다. 이는 종착점이 아니며, 5분 주기 release_stuck_split_editings cron 이 :waiting 에 남은 editing 을 attempt 0 으로 다시 release 큐에 넣어 회수한다. 즉 이 error 로그는 일시적 lock 경합에 대한 설계된 backstop 이며, 최근 TSLA-13736 작업이 재시도 폭주("retry storm")를 이미 제거했다.

Technical Analysis#

Code Path#

  • Entry point: ReleaseSplitEditingWorker#performCupix::EditingSplitService.release_single_editing
  • Lock 획득 및 상태 전이: app/services/cupix/editing_split_service.rb:221 (Phase 1 row lock), :297 (Phase 3 row lock)
  • Failure point: app/services/cupix/editing_split_service.rb:318 (rescue ActiveRecord::LockWaitTimeout, ActiveRecord::Deadlocked)
  • Terminal error 로그: app/services/cupix/editing_split_service.rb:595 (_reschedule_release)

Phase 3 에서 gate 재확인 후 ready_state! 를 호출한다. 이 블록 전체가 with_lock 안에서 실행되어 병행 경로와 경합한다.

app/services/cupix/editing_split_service.rb:297-316ruby
        editing.with_lock do
          editing.reload
          return unless editing.cycle_state_created?
          return unless editing.state_waiting?

          count = ::ElementTrace.joins(:element)
                                .where(editing_id: editing.id, purpose: PURPOSE_STATUS_UPDATE)
                                .unarchived
                                .merge(::Element.unarchived)
                                .distinct.count(:element_id)
          return if count.zero?
          return if count > MAX_ELEMENTS_PER_EDITING

          editing.ready_state!
          Cupix::Logger.info('Editing released to :ready after split',
                             class: name, function: __method__,
                             editing_id: editing.id, element_count: count, attempt: attempt)
        end

lock wait timeout 은 아래에서 lock_contention 사유로 rescue 되어 재큐된다.

app/services/cupix/editing_split_service.rb:318-323ruby
      rescue ActiveRecord::LockWaitTimeout, ActiveRecord::Deadlocked => e
        _reschedule_release(
          editing,
          attempt,
          e,
          reason: 'lock_contention',
          source_editing_ids: source_editing_ids
        )

_reschedule_release 는 attempt 가 한계에 도달하면 error 로그를 남기고 종료한다. 주석이 이 로그가 종착점이 아님(cron 이 attempt 0 으로 다시 release)을 명시한다.

app/services/cupix/editing_split_service.rb:591-604ruby
      def _reschedule_release(editing, attempt, error, reason:, source_editing_ids: nil)
        source_editing_ids = Array(source_editing_ids).compact.uniq
        next_attempt = attempt + 1
        if next_attempt >= ReleaseSplitEditingWorker::MAX_TOTAL_ATTEMPTS
          Cupix::Logger.error("Release abandoned for editing #{editing&.id} (#{reason}) after #{attempt} attempts: #{error.message}",
                              class: name, function: __method__,
                              editing_id: editing&.id, attempt: attempt,
                              reason: reason, source_editing_ids: source_editing_ids)
          return
        end

        delay = [30 * (2**attempt), 300].min.seconds

MAX_TOTAL_ATTEMPTS = 3 이므로 attempt 2 (next_attempt 3) 에서 "after 2 attempts" 로그가 나온다. 이는 로그 문구 "after 2 attempts" 와 일치한다.

cron safety net 은 :waiting 에서 10분 이상 정체된 editing 을 다시 release 큐에 넣는다.

lib/cupix/cron/editing.rb:39-52ruby
            else
              # Hand the hardened release path to Sidekiq instead of running it inline:
              # ReleaseSplitEditingWorker.enqueue also applies the per-editing claim, so a
              # slow release cannot be re-armed by the next tick while it is still running.
              enqueued = ::ReleaseSplitEditingWorker.enqueue(editing.id).present?
              enqueued ? stats[:release_enqueued] += 1 : stats[:suppressed] += 1
            end

기대 동작: lock 경합은 transient 이므로 재시도/cron 으로 회수되어 editing 이 :ready 에 도달한다. 실제 동작: 로그상 회수가 정상 작동 중이며(아래 Log Evidence 참조) editing 유실 증거는 없다.

Log Evidence#

사용한 Datadog 쿼리:

text
status:error "lock_contention"
text
status:warn "Retrying release for editing"
text
service:cupixworks-worker "Editing released to :ready"

최근 lock_contention error 발생 (last_seen 근방):

text
2026-08-04 15:08:34  error  Release abandoned for editing 82187 (lock_contention) after 2 attempts: Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction
2026-08-04 14:48:59  error  Release abandoned for editing 82176 (lock_contention) after 2 attempts: Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction
2026-08-04 15:54:02  error  Release abandoned for editing 82179 (lock_contention) after 2 attempts: Mysql2::Error: Deadlock found when trying to get lock; try restarting transaction

동일 이슈에 묶인 다른 변형 (Representative Error 와 무관):

text
2026-08-04 16:47:13  error  Release abandoned for editing 37892 (unexpected_error) after 2 attempts: Elasticsearch bulk partial failure on elements
2026-08-04 16:47:09  error  Release abandoned for editing 82162 (empty_shell_deferred) after 2 attempts: empty shell is not safe to delete yet

자동 재시도 및 회수 증거 (같은 시간대):

text
2026-08-04 16:46:17  warn  Retrying release for editing 37905 (unexpected_error) in 60s: ...
2026-08-04 16:45:37  info  Editing released to :ready after split   (function: release_single_editing)
2026-08-04 16:25:56  info  Editing released to :ready after split   (function: release_single_editing)

Editing released to :ready after split info 로그가 반복적으로 나타나 재시도/cron 회수 경로가 실제로 editing 을 성공적으로 승격시키고 있음을 확인했다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Editing 행에 대한 병행 split/stamp/merge 경합으로 with_lock lock wait timeout 이 발생하며, 이는 rescue 후 재시도/cron 으로 회수되는 transient 조건이다 editing_split_service.rb:297-318 with_lock + rescue LockWaitTimeout; 로그 Release abandoned (lock_contention) after 2 attempts; Editing released to :ready 회수 로그 Confirmed
H2 재시도 로직이 무한 재큐되어 job storm 을 유발하는 실제 버그다 과거 QA 에서 28 editing 이 6시간 동안 ~97k job 생성 (코드 주석 editing_split_service.rb:583-589) TSLA-13736 이 attempt 를 monotonic 하게 만들고 durable retry 를 제거해 이미 수정됨. 현재 MAX_TOTAL_ATTEMPTS = 3 로 체인이 종료됨 Rejected
H3 "Release abandoned" 가 종착점이라 editing 이 :waiting 에 영구 정체된다 error 레벨 로그, "giving up" 계열 문구 _reschedule_release 주석이 abandoned 는 종착점 아님을 명시; 5분 cron release_stuck_split_editings 가 attempt 0 으로 재release; released to :ready 로그로 회수 확인 Rejected
H4 cupixworks-mysql2 DB 어댑터/인프라 장애 클러스터 service 필드가 cupixworks-mysql2 어댑터는 예외 표면화 지점일 뿐 원인 아님. 예외는 특정 SQA 코드 경로의 InnoDB row lock 경합; status board active 인시던트 없음 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 별도 코드 변경 불필요. 최근 발생하는 lock_contention (및 unexpected_error, empty_shell_deferred) 사유의 "Release abandoned" 는 설계된 backstop 이고 cron 이 회수하므로 데이터 무결성 문제 없음.

단기 개선 (1주 이내)#

  • _reschedule_release 의 terminal 로그 레벨 재검토 (app/services/cupix/editing_split_service.rb:595). abandoned 가 종착점이 아니라 cron 이 회수한다는 점을 코드 주석이 이미 명시하므로, lock_contention 처럼 transient 이고 self-healing 인 사유에 대해서는 error 대신 warn 이 Error Tracking noise 를 줄인다. unexpected_error(Elasticsearch bulk partial failure) 는 별도 근본 원인이 있을 수 있으므로 error 유지 여부를 분리 판단할 것.
  • Error Tracking 이슈 그룹핑 재검토: 하나의 이슈에 lock_contention / unexpected_error / empty_shell_deferred 가 섞여 Representative Error 가 stale 하다. 사유별로 분리하면 진짜 회귀 신호를 놓치지 않는다.

장기 개선 (재발 방지)#

  • lock 경합 빈도가 상승 추세라면 SQA split 경로의 lock 획득 순서/범위를 재점검 (TSLA-13736 이 이미 lock scope 를 좁힘). 회수까지 걸리는 지연을 SLO 로 모니터링해 cron 의존이 지연을 키우지 않는지 확인.

Monitoring#

lock_contention abandoned 발생 추이:

text
sum:trace.rails.request.errors{service:cupixworks-worker} by {resource_name}.as_count()

editing release 성공(회수) 대비 abandoned 비율을 로그 기반으로 추적하려면 아래 log-based metric 을 신설해 timeseries widget 에 올린다:

text
sum:logs.editing_release.abandoned{reason:lock_contention}.as_count()
text
sum:logs.editing_release.ready{service:cupixworks-worker}.as_count()

두 metric 이 상수적으로 균형을 이루면(abandoned 후 ready 로 회수) self-healing 이 정상이라는 신호다. abandoned 가 ready 를 지속 초과하면 회귀로 판단해 알림한다.

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial (로그 레벨 downgrade 는 1줄 수준, 코드 로직 변경 없음)

Noise Verdict#

noise — lock_contention 으로 인한 "Release abandoned" 는 일시적 row lock 경합에 대한 설계된 backstop 이며 5분 cron 이 editing 을 자동 회수(released to :ready 로그로 확인)하므로, 코드 수정이 필요 없는 로그 노이즈다.