ES /docs

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#

  1. 2026-05-26 03:43:21Z — 최초 latency spike 감지 (ap-southeast-2)
  2. 2026-05-26 05:43:23Z — 마지막 slow request 기록
  3. 2026-05-26T06:00Z — error-sweeper가 latency 클러스터 생성

Error Log#

Datadog Logs

json
{
  "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 (update action)
  • Repository update: app/repositories/admin/editing_repository.rb:13-25
  • Parameter setting: app/concerns/parameter/editing.rb:10-22 (update_state)
  • Model save: editing.rb triggers callback chain
  • Failure point (latency): Multiple synchronous callbacks

1. Controller → Repository → save!#

app/repositories/admin/editing_repository.rb:13-25ruby
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 변경 시)#

app/models/editing.rb:43-86ruby
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)#

app/models/editing.rb:33ruby
after_commit :recalculate_priority_score, if: -> { saved_change_to_editor_id? && editor_id.present? }
app/services/editing_priority_scorer.rb:53-61ruby
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)#

app/models/concerns/searchable.rb:16-18ruby
after_commit on: [:update] do
  _update_document   # synchronous Elasticsearch partial update
end

as_indexed_jsonEditingSerializer를 통해 user, editor, team, workspace, facility, level, category, workarea, reviewers, stat 등 많은 연관 객체를 직렬화한다.

5. State machine transition — entity iteration#

app/models/concerns/statable/editing.rb:83-111ruby
# before_transition callback
editing_entities.untrashed.each { |entity| entity.state! }  # N+1 per entity

"done" 상태 전환 시 모든 editing_entities를 순회하면서 개별 entity에 state 변경을 호출한다.

Log Evidence#

Datadog 쿼리:

text
service:cupixworks-api @http.url_details.path:"/api/v1/admin/editings/*" @http.method:PATCH env:production

ap-southeast-2 리전의 대표적 고지연 요청:

json
{
  "editing_id": 189734,
  "duration_ms": 5968,
  "db_ms": 329,
  "view_ms": 0.08,
  "params": { "state": "done" },
  "user_agent": "Retool/2.0"
}
json
{
  "editing_id": 189661,
  "duration_ms": 2061,
  "db_ms": 169,
  "params": { "state": "done" }
}
json
{
  "editing_id": 189736,
  "duration_ms": 1578,
  "db_ms": 153,
  "params": { "state": "done" }
}

핵심 패턴: duration_msdb_ms의 차이가 1000~5600ms에 달하며, 이 시간이 application-layer 콜백(ES 인덱싱, state machine, priority 재계산)에 소비됨.

동시간대 Elasticsearch 동기화 실패 로그:

text
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)#

  1. recalculate_priority_score를 비동기 Worker로 이동 (app/models/editing.rb:33)

    • after_commit에서 직접 계산하는 대신 RecalculatePriorityScoreWorker.perform_async(id)로 Sidekiq에 위임
    • 이것만으로 cascading save cycle이 제거되어 ES 인덱싱 1회 절약
  2. set_assigned_atbulk_operation!을 비동기로 전환 (app/models/editing.rb:43-86)

    • Capture.bulk_operation!Pointcloud.bulk_operation! 호출을 after_commit에서 Worker로 위임
    • 동기적 ES HTTP 호출이 요청 시간에서 제거됨

단기 개선 (1주 이내)#

  1. .map(&:entity_id).pluck(:entity_id) 변환 (editing.rb:43-86)

    • 불필요한 ActiveRecord 객체 로드를 제거하여 메모리 사용량과 DB 시간 절감
  2. State machine transition의 entity iteration을 bulk 처리로 변경 (statable/editing.rb:83-111)

    • editing_entities.untrashed.each { |e| e.state! } 대신 batch update + 단일 ES bulk operation으로 전환

장기 개선 (재발 방지)#

  1. Elasticsearch 인덱싱을 완전 비동기로 전환

    • after_commit :_update_documentReindexWorker.perform_async(id)로 교체
    • 요청 응답 시간에서 ES 네트워크 호출을 완전히 분리
  2. Callback 체인 감사 및 정리

    • save! 한 번에 트리거되는 콜백 수를 모니터링하는 instrumentation 추가
    • 동기적 외부 호출(ES, DataWarehouse)을 모두 비동기 패턴으로 마이그레이션

Monitoring#

  • Datadog APM에서 resource_name:Api::V1::Admin::EditingsController#update의 p95 latency 알림 설정 (임계값: 1000ms)
  • 쿼리 예시:
text
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 계산의 비동기화는 사용자에게 보이는 값의 갱신 지연을 고려해야 함.