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#
- 2026-04-24 00:18 KST — 최초 발생 (first_seen, Error Tracking issue 등록)
- 2026-08-01 00:05 KST — cluster 파일 기준 last_seen
- 2026-08-03 ~ 2026-08-04 — Datadog 로그에서 지속 재발 확인 (최근 14일 내 다수 발생, 아래 Log Evidence 참고)
- 2026-08-04 — RCA 수행
Error Log#
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_completion → origin.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-26—update액션이repository_instance.update(params)호출
def update
@model = repository_instance.update(params)
super
end
- Failure point:
app/repositories/editing_repository.rb:11-23—@model.save!가 deadlock 을 던짐. 참고로rescue StandardError는Mysql2::Error를ARG10001로 감싸지만, deadlock 은save!가 여는 트랜잭션 커밋/callback 시점에ActiveRecord::Deadlocked로 발생하며 로그의error.class도ActiveRecord::Deadlocked로 기록되었다 (503).
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!
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
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 로 간주하고 재시도한다:
# 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
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 쿼리 (재현 가능):
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 으로 발생한다:
{
"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 대기):
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-23의update내@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 발생 빈도 추이:
service:cupixworks-api "Deadlock found when trying to get lock"
- editings update 503 추이:
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 를 추가해야 하는 코드 결함이다.