ES /docs

failed to sync line item opportunities from Salesforce - line_item_id: 1290, error: Validation faile

RCA: failed to sync line item opportunities from Salesforce - line_item_id: 1290

Overview#

What Happened#

2026-04-29 02:33:40 UTC에 cupixworks-worker 서비스의 LineItemOpportunitySyncWorker가 line_item_id 1290에 대해 Salesforce opportunity 정보를 동기화하려다 ActiveRecord validation 실패로 에러가 발생했다. sf_opportunity_idsf_opportunity_number 필드가 비어 있어 save! 호출 시 presence validation에 걸렸다. ap-southeast-2 리전에서 1건 발생한 단발성 이슈이다.

Quick Facts#

Field Value
exception.class ActiveRecord::RecordInvalid
exception.message Validation failed: Sf opportunity can't be blank, Sf opportunity number can't be blank
top_frame app/workers/line_item_opportunity_sync_worker.rb:15
deploy production-ap-southeast-2-20260428T0228Z0-b5ccc284-cupixworks
env production, ap-southeast-2

Timeline#

  1. 02:33:38.787Z — Sidekiq job 생성 및 enqueue (JID 7f0df490be496fb0a5145a54, args: ["1290"])
  2. 02:33:38.937ZLineItemOpportunitySyncWorker#perform 시작, "start syncing" 로그
  3. 02:33:40.939Z — Validation 실패 에러 로그 기록
  4. 02:33:41.306Z — Sidekiq job "done" 처리 (1.415초 소요, 에러는 rescue로 포착되어 Sidekiq 재시도 미발생)

Error Log#

Datadog Logs

text
failed to sync line item opportunities from Salesforce - line_item_id: 1290, error: Validation failed: Sf opportunity can't be blank, Sf opportunity number can't be blank

Impact#

  • Service: cupixworks-worker
  • 발생 횟수: 1
  • 최초 발생: 2026-04-29T02:33:40.939Z
  • 최근 발생: 2026-04-29T02:33:40.939Z

line_item_id 1290의 sf_opportunity_info JSON 컬럼이 업데이트되지 않았다. 이 컬럼은 Salesforce opportunity의 전체 필드를 캐싱하는 용도이므로, 해당 line item에 대한 상세 opportunity 정보 조회 시 누락이 발생할 수 있다. 다만 line item 자체의 생성이나 핵심 Salesforce 필드(sf_opportunity_id, sf_opportunity_number)는 이 worker와 무관하게 factory에서 설정되므로, 비즈니스 영향은 제한적이다.

Root Cause Summary#

LineItemOpportunitySyncWorker는 line item 생성 후 비동기로 Salesforce opportunity 전체 필드를 가져와 sf_opportunity_info JSON 컬럼에 저장한다. Worker가 line_item.sf_opportunity_info = sf_opportunity_infosave!를 호출하면, Rails의 기본 validation context가 실행되어 sf_opportunity_idsf_opportunity_number에 대한 presence: true 검증이 수행된다. line_item_id 1290의 해당 필드가 비어 있었기 때문에 validation이 실패했다. 이는 해당 line item이 revise! 경로를 통해 생성되었을 가능성이 높다 — revise!save!(context: :revise)로 저장하여 sf 필드 validation을 건너뛰지만, after_create 콜백으로 호출되는 worker의 save!는 기본 context로 실행되어 동일한 validation을 우회하지 못한다.

Technical Analysis#

Code Path#

  • Entry point: app/workers/line_item_opportunity_sync_worker.rb:5perform(line_item_id)
  • LineItem 조회: app/workers/line_item_opportunity_sync_worker.rb:9LineItem.find(line_item_id)
  • Salesforce API 호출: app/workers/line_item_opportunity_sync_worker.rb:12opportunity_client.select_all_feilds(line_item.sf_opportunity_id)
  • Failure point: app/workers/line_item_opportunity_sync_worker.rb:15line_item.save!

Worker는 Salesforce에서 가져온 전체 opportunity 데이터를 sf_opportunity_info에 저장하고 save!를 호출한다:

app/workers/line_item_opportunity_sync_worker.rb:8-15ruby
begin
  line_item = ::LineItem.find(line_item_id)

  opportunity_client = Cupix::Salesforce::Client::Opportunity.new
  sf_opportunity_info = opportunity_client.select_all_feilds(line_item.sf_opportunity_id)

  line_item.sf_opportunity_info = sf_opportunity_info
  line_item.save!  # <-- 기본 validation context로 실행

save!는 기본 validation context에서 실행되므로 LineItem 모델의 모든 presence validation이 적용된다:

app/models/line_item.rb:16-17ruby
validates :sf_opportunity_id, presence: true, unless: :revise_context?
validates :sf_opportunity_number, presence: true, unless: :revise_context?
app/models/line_item.rb:29-31ruby
def revise_context?
  validation_context == :revise
end

revise_context?validation_context == :revise일 때만 true를 반환한다. Worker의 save!는 기본 context(nil)로 실행되므로 revise_context?false가 되어, sf_opportunity_idsf_opportunity_number의 presence validation이 강제 적용된다.

Line item이 revise! 경로를 통해 생성되는 경우, sf 필드 없이도 저장이 가능하다:

app/factories/line_item_factory.rb:49-63ruby
def revise!(params = {})
  check_required_revise_parameter(params)

  ActiveRecord::Base.transaction do
    line_item = LineItemRepository.new(current_user: self.current_user).show(params[:id])

    validate_expires_at_extension(line_item, params[:expires_at])

    revised_at = Time.current
    new_line_item = create_new_line_item(line_item, revised_at, params[:expires_at])
    disable_old_line_item(line_item, revised_at)

    new_line_item.save!(context: :revise)  # :revise context로 sf 필드 validation 건너뜀
    new_line_item
  end
end

create_new_line_item은 기존 line item의 attributes를 복사하지만, 원본 line item의 sf 필드가 비어 있었다면 새 line item도 동일하게 비어 있게 된다:

app/factories/line_item_factory.rb:81-88ruby
def create_new_line_item(line_item, started_at, expires_at)
  new_line_item = ::LineItem.new(line_item.attributes.except('id', 'created_at', 'updated_at', 'disabled_at', 'expires_at'))

  new_line_item.created_at = started_at
  new_line_item.billing_started_at = line_item.billing_started_at || line_item.quote.billing_started_at
  new_line_item.billing_expires_at = expires_at

  new_line_item
end

after_create :sync_salesforce_opportunity 콜백은 revise! 경로의 save! 이후에도 실행된다:

app/models/line_item.rb:25ruby
after_create :sync_salesforce_opportunity

이로 인해 sf 필드가 비어 있는 revised line item에 대해서도 worker가 호출되고, save! 시 기본 context validation에 걸리게 된다.

추가적으로, select_all_feildsnil(비어 있는 sf_opportunity_id)이 전달되면 Salesforce API 호출 자체도 실패할 수 있으나, 이 경우 validation error가 먼저 발생하지는 않는다 — API 에러가 먼저 발생하거나, API가 null을 반환한 후 save!에서 validation이 실패한다. 실제 로그에서는 validation error만 확인되므로, Salesforce API 호출은 성공했으나 save!에서 실패한 것이다.

Log Evidence#

Datadog에서 사용한 쿼리:

text
service:cupixworks-worker "sync line item" OR "Salesforce" @environment:production
text
service:cupixworks-worker "Sf opportunity" status:error

시작 로그 (02:33:38.937Z):

text
start syncing line item opportunities from Salesforce - line_item_id: 1290
  • Class: LineItemOpportunitySyncWorker, Function: perform
  • Request ID: 7f0df490be496fb0a5145a54

에러 로그 (02:33:40.939Z):

text
failed to sync line item opportunities from Salesforce - line_item_id: 1290, error: Validation failed: Sf opportunity can't be blank, Sf opportunity number can't be blank
  • Class: LineItemOpportunitySyncWorker, Function: perform
  • Request ID: 7f0df490be496fb0a5145a54

Sidekiq 완료 로그 (02:33:41.306Z):

json
{
  "class": "LineItemOpportunitySyncWorker",
  "jid": "7f0df490be496fb0a5145a54",
  "args": ["1290"],
  "queue": "default",
  "retry": true,
  "max_retries": 1,
  "job_status": "done",
  "duration": 1.415,
  "created_at": "2026-04-29T02:33:38.787Z",
  "enqueued_at": "2026-04-29T02:33:38.787Z",
  "completed_at": "2026-04-29T02:33:40.202Z"
}

주목할 점: Sidekiq job status가 "done"으로 기록되었다. Worker 내부에서 rescue StandardError로 에러를 포착하고 로그만 남기기 때문에, Sidekiq 관점에서는 job이 성공적으로 완료된 것으로 처리된다. 따라서 retry: true, max_retries: 1 설정에도 불구하고 재시도가 발생하지 않는다.

36시간 범위로 확장 검색한 결과, 동일한 "Sf opportunity" validation 에러는 이 1건만 확인되었다. 다른 line item에서는 동일 이슈가 발생하지 않았다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 revise! 경로로 생성된 line item의 sf 필드가 비어 있어, worker의 save!에서 기본 context validation 실패 revise!save!(context: :revise)로 sf 필드 validation을 건너뜀 (line_item_factory.rb:61). Worker의 save!는 기본 context로 실행 (line_item_opportunity_sync_worker.rb:15). 에러 메시지가 정확히 sf_opportunity_idsf_opportunity_number 두 필드 모두 blank인 것과 일치. 원본 line item에서 sf 필드가 비어 있었는지 직접 확인 불가 (DB 접근 필요). Confirmed
H2 Salesforce API가 opportunity 정보를 반환하지 못해 sf_opportunity_info에 nil이 저장되고, 이로 인해 validation 실패 select_all_feilds에 nil opportunity_id가 전달되면 API 에러 발생 가능 에러 메시지가 Salesforce API 에러가 아닌 ActiveRecord validation 에러임. API 에러였다면 "Fail to select salesforce opportunity resource" 메시지가 먼저 로그에 남았을 것. Worker에서는 sf_opportunity_info만 변경하고 save!를 호출하므로, sf_opportunity_id/sf_opportunity_number는 이미 DB에 있던 값 사용. Rejected
H3 create! 경로에서 Salesforce API 호출이 빈 값을 반환하여 sf 필드가 blank 상태로 저장됨 create! 경로에서 opportunity_info['Id']가 nil일 수 있음 create!validate! (기본 context)를 호출하므로 (line_item_factory.rb:44), sf 필드가 blank이면 생성 자체가 실패함. 이 line item이 존재한다는 것은 create! 시에는 sf 필드가 있었거나 revise! 경로로 생성되었다는 의미. Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/workers/line_item_opportunity_sync_worker.rb:15에서 save! 대신 save!(validate: false) 또는 update_column(:sf_opportunity_info, sf_opportunity_info)을 사용하여 validation 우회. 이 worker는 sf_opportunity_info 한 개 컬럼만 업데이트하므로 전체 validation을 실행할 필요가 없다.
  • 또는 save!(context: :revise)를 사용하여 sf 필드 validation을 건너뛸 수도 있으나, update_column이 더 명확한 의도 표현.

단기 개선 (1주 이내)#

  • revise! 경로에서 원본 line item의 sf 필드를 새 line item에 명시적으로 복사하는 로직 검증. create_new_line_itemattributes.except(...) 로직이 sf 필드를 포함하는지 확인 필요 — 현재 코드상으로는 포함되지만, 원본 자체에 sf 필드가 없는 케이스 방어 필요.
  • Worker의 에러 처리 개선: 현재 rescue StandardError로 모든 에러를 삼키고 로그만 남기므로, Sidekiq 재시도가 발생하지 않음. 일시적 에러(네트워크, API rate limit)는 재시도 가능하도록 특정 에러만 catch하고 나머지는 raise하는 방식 고려.

장기 개선 (재발 방지)#

  • LineItemOpportunitySyncWorker가 단일 컬럼만 업데이트하는 목적이라면, update_column 사용을 표준 패턴으로 도입. 전체 model validation을 트리거하지 않아 의도치 않은 side effect 방지.
  • after_create 콜백이 revise! 경로에서도 실행되는 것이 의도된 동작인지 검토. Revised line item은 이미 원본의 sf 필드를 복사하므로 Salesforce 재동기화가 불필요할 수 있음. 필요한 경우에만 worker를 호출하도록 조건부 콜백(if: 조건) 추가 고려.

Monitoring#

  • sf_opportunity_id가 blank인 line item이 생성되는 패턴 모니터링:
text
service:cupixworks-worker "Sf opportunity can't be blank" status:error
  • LineItemOpportunitySyncWorker 실패율 모니터:
text
service:cupixworks-worker "failed to sync line item opportunities from Salesforce" status:error

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial — save!update_column으로 변경하는 1줄 수정. sf_opportunity_info 컬럼만 업데이트하는 worker이므로 전체 validation 우회가 안전하다.