Api::V1::Admin::EditingsController#update (avg 1373ms, max 1651ms)
RCA: Api::V1::Admin::EditingsController#update Latency
Overview#
What Happened#
2026-05-26 03:43~05:43 UTC 동안 ap-southeast-2 리전의 cupixworks-api 서비스에서 Api::V1::Admin::EditingsController#update 엔드포인트가 평균 1373ms, 최대 1651ms의 응답 지연을 보였다. Retool 자동화가 Editing 레코드의 state를 "done"으로 전환하는 과정에서 동기적 Elasticsearch 인덱싱과 cascading 콜백이 요청 시간의 70-90%를 소비한 것이 원인이다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::Admin::EditingsController#update |
| top_frame | app/models/editing.rb:43 (set_assigned_at) |
| env | production, ap-southeast-2 |
| avg_duration | 1373ms |
| max_duration | 1651ms (cluster), 5968ms (broader window) |
Timeline#
- 2026-05-26 03:43:21Z — 최초 latency spike 감지 (ap-southeast-2)
- 2026-05-26 05:43:23Z — 마지막 slow request 기록
- 2026-05-26T06:00Z — error-sweeper가 latency 클러스터 생성
Error Log#
{
"resource_name": "Api::V1::Admin::EditingsController#update",
"service": "cupixworks-api",
"occurrences": 3,
"avg_ms": 1373,
"max_ms": 1651,
"sample_trace_id": "8954293936870673007"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 3 (클러스터 기준), 동일 시간대 18+ requests 영향
- 최초 발생: 2026-05-26T03:43:21.467Z
- 최근 발생: 2026-05-26T05:43:23.301Z
- 영향 범위: Retool 자동화 및 브라우저 사용자의 Editing state 전환 요청 전체. us-west-2, eu-central-1에서도 동일 패턴 확인됨 (최대 7365ms).
Root Cause Summary#
EditingsController#update에서 state를 "done"으로 전환할 때, model.save! 호출이 동기적으로 실행되는 무거운 콜백 체인을 트리거한다. DB 시간은 전체 응답의 10-30%에 불과하며, 나머지 70-90%는 (1) Elasticsearch _update_document 동기 HTTP 호출, (2) recalculate_priority_score after_commit에서 update!를 재호출하여 두 번째 ES 인덱싱 사이클 유발, (3) set_assigned_at 콜백의 동기적 bulk_operation! 호출, (4) state machine transition 중 모든 editing_entities에 대한 N+1 반복 처리에 소비된다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/admin/editings_controller.rb:31(updateaction) - Repository update:
app/repositories/admin/editing_repository.rb:13-25 - Parameter setting:
app/concerns/parameter/editing.rb:10-22(update_state) - Model save:
editing.rbtriggers callback chain - Failure point (latency): Multiple synchronous callbacks
1. Controller → Repository → save!#
def update(params = {})
super # BaseRepository#update (permission, billing check)
set_parameters(params) # Parameter::Editing concern
@model.save! # triggers ALL callbacks
@model
end
set_parameters에서 update_state(params[:state])를 호출하면 state machine이 동작하며, save!는 모든 before_update, around_save, after_commit 콜백을 동기적으로 실행한다.
2. set_assigned_at 콜백 (editor_id 변경 시)#
before_update :set_assigned_at, if: -> { editor_id_changed? && editor_id.present? }
def set_assigned_at
capture_entity_ids = editing_entities.where(entity_type: 'Capture').map(&:entity_id)
pointcloud_entity_ids = editing_entities.where(entity_type: 'Pointcloud').map(&:entity_id)
Capture.where(id: capture_entity_ids).update_all(...)
Pointcloud.where(id: pointcloud_entity_ids).update_all(...)
# DataWarehouse: find_each per capture → save_partial_json_to_file_in_worker
Capture.bulk_operation!(capture_entity_ids, 'update') # synchronous ES bulk
Pointcloud.bulk_operation!(pointcloud_entity_ids, 'update') # synchronous ES bulk
end
.map(&:entity_id) 대신 .pluck(:entity_id)를 사용하지 않아 전체 ActiveRecord 객체를 로드하며, bulk_operation!은 동기적 Elasticsearch HTTP 호출이다.
3. recalculate_priority_score (after_commit — cascading save)#
after_commit :recalculate_priority_score, if: -> { saved_change_to_editor_id? && editor_id.present? }
def calculate
editing = Editing.find(editing_id) # redundant reload
user = User.find(editor_id)
score = compute_score(editing, user)
editing.update!(priority_score: score) # TRIGGERS SECOND full save cycle
end
editing.update!가 다시 _update_document (Elasticsearch)와 DataWarehouse 콜백을 트리거하므로, 하나의 요청에서 ES 인덱싱이 2회 이상 발생한다.
4. Elasticsearch _update_document (after_commit)#
after_commit on: [:update] do
_update_document # synchronous Elasticsearch partial update
end
as_indexed_json은 EditingSerializer를 통해 user, editor, team, workspace, facility, level, category, workarea, reviewers, stat 등 많은 연관 객체를 직렬화한다.
5. State machine transition — entity iteration#
# before_transition callback
editing_entities.untrashed.each { |entity| entity.state! } # N+1 per entity
"done" 상태 전환 시 모든 editing_entities를 순회하면서 개별 entity에 state 변경을 호출한다.
Log Evidence#
Datadog 쿼리:
service:cupixworks-api @http.url_details.path:"/api/v1/admin/editings/*" @http.method:PATCH env:production
ap-southeast-2 리전의 대표적 고지연 요청:
{
"editing_id": 189734,
"duration_ms": 5968,
"db_ms": 329,
"view_ms": 0.08,
"params": { "state": "done" },
"user_agent": "Retool/2.0"
}
{
"editing_id": 189661,
"duration_ms": 2061,
"db_ms": 169,
"params": { "state": "done" }
}
{
"editing_id": 189736,
"duration_ms": 1578,
"db_ms": 153,
"params": { "state": "done" }
}
핵심 패턴: duration_ms와 db_ms의 차이가 1000~5600ms에 달하며, 이 시간이 application-layer 콜백(ES 인덱싱, state machine, priority 재계산)에 소비됨.
동시간대 Elasticsearch 동기화 실패 로그:
service:cupixworks-api "NotFound - attributes_in_database" @class:Editing
_update_document 콜백 실행 중 레코드 상태가 변경되어 ES 동기화 실패가 간헐적으로 발생. 이는 동기적 ES 호출이 요청 내에서 실행됨을 확인하는 증거이다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 동기적 Elasticsearch 인덱싱 + cascading save 콜백이 latency의 주요 원인 | db_ms 대비 duration 70-90% 차이, _update_document 로그, recalculate_priority_score에서 update! 재호출 확인 (코드: editing_priority_scorer.rb:61) |
— | Confirmed |
| H2 | ap-southeast-2 리전의 네트워크 지연 또는 DB replication lag | 해당 리전에서만 클러스터 감지됨 | us-west-2에서도 동일 패턴 확인 (7365ms), db_ms는 정상 범위 (89-491ms) | Rejected |
| H3 | N+1 쿼리로 인한 DB 과부하 | set_assigned_at에서 .map(&:entity_id), stat_transition에서 entity 반복 |
db_ms가 전체의 10-30%에 불과하여 주요 원인은 아님 | Partially confirmed (보조 요인) |
| H4 | Retool 자동화의 동시 대량 요청으로 인한 lock contention | 같은 시간대에 다수의 "done" 전환 요청 존재 | 각 요청이 서로 다른 editing_id를 대상으로 하며 row lock 경합 증거 없음 | Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
-
recalculate_priority_score를 비동기 Worker로 이동 (app/models/editing.rb:33)after_commit에서 직접 계산하는 대신RecalculatePriorityScoreWorker.perform_async(id)로 Sidekiq에 위임- 이것만으로 cascading save cycle이 제거되어 ES 인덱싱 1회 절약
-
set_assigned_at의bulk_operation!을 비동기로 전환 (app/models/editing.rb:43-86)Capture.bulk_operation!와Pointcloud.bulk_operation!호출을 after_commit에서 Worker로 위임- 동기적 ES HTTP 호출이 요청 시간에서 제거됨
단기 개선 (1주 이내)#
-
.map(&:entity_id)→.pluck(:entity_id)변환 (editing.rb:43-86)- 불필요한 ActiveRecord 객체 로드를 제거하여 메모리 사용량과 DB 시간 절감
-
State machine transition의 entity iteration을 bulk 처리로 변경 (
statable/editing.rb:83-111)editing_entities.untrashed.each { |e| e.state! }대신 batch update + 단일 ES bulk operation으로 전환
장기 개선 (재발 방지)#
-
Elasticsearch 인덱싱을 완전 비동기로 전환
after_commit :_update_document를ReindexWorker.perform_async(id)로 교체- 요청 응답 시간에서 ES 네트워크 호출을 완전히 분리
-
Callback 체인 감사 및 정리
save!한 번에 트리거되는 콜백 수를 모니터링하는 instrumentation 추가- 동기적 외부 호출(ES, DataWarehouse)을 모두 비동기 패턴으로 마이그레이션
Monitoring#
- Datadog APM에서
resource_name:Api::V1::Admin::EditingsController#update의 p95 latency 알림 설정 (임계값: 1000ms) - 쿼리 예시:
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::admin::editingscontroller_update} by {region} > 1000
- Elasticsearch
_update_document호출 횟수를 요청당 metric으로 추적하여 cascading save 감지
Risk Assessment#
- Risk level: medium
- 예상 복잡도: standard — 콜백을 Worker로 이동하는 패턴은 기존 코드베이스에서 이미 사용 중 (
LogEditingStateWorker,SavePartialJsonToFileWorker등). 그러나 priority score 계산의 비동기화는 사용자에게 보이는 값의 갱신 지연을 고려해야 함.