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_id와 sf_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#
- 02:33:38.787Z — Sidekiq job 생성 및 enqueue (JID
7f0df490be496fb0a5145a54, args:["1290"]) - 02:33:38.937Z —
LineItemOpportunitySyncWorker#perform시작, "start syncing" 로그 - 02:33:40.939Z — Validation 실패 에러 로그 기록
- 02:33:41.306Z — Sidekiq job "done" 처리 (1.415초 소요, 에러는 rescue로 포착되어 Sidekiq 재시도 미발생)
Error Log#
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_info 후 save!를 호출하면, Rails의 기본 validation context가 실행되어 sf_opportunity_id와 sf_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:5—perform(line_item_id) - LineItem 조회:
app/workers/line_item_opportunity_sync_worker.rb:9—LineItem.find(line_item_id) - Salesforce API 호출:
app/workers/line_item_opportunity_sync_worker.rb:12—opportunity_client.select_all_feilds(line_item.sf_opportunity_id) - Failure point:
app/workers/line_item_opportunity_sync_worker.rb:15—line_item.save!
Worker는 Salesforce에서 가져온 전체 opportunity 데이터를 sf_opportunity_info에 저장하고 save!를 호출한다:
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이 적용된다:
validates :sf_opportunity_id, presence: true, unless: :revise_context?
validates :sf_opportunity_number, presence: true, unless: :revise_context?
def revise_context?
validation_context == :revise
end
revise_context?는 validation_context == :revise일 때만 true를 반환한다. Worker의 save!는 기본 context(nil)로 실행되므로 revise_context?는 false가 되어, sf_opportunity_id와 sf_opportunity_number의 presence validation이 강제 적용된다.
Line item이 revise! 경로를 통해 생성되는 경우, sf 필드 없이도 저장이 가능하다:
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도 동일하게 비어 있게 된다:
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! 이후에도 실행된다:
after_create :sync_salesforce_opportunity
이로 인해 sf 필드가 비어 있는 revised line item에 대해서도 worker가 호출되고, save! 시 기본 context validation에 걸리게 된다.
추가적으로, select_all_feilds에 nil(비어 있는 sf_opportunity_id)이 전달되면 Salesforce API 호출 자체도 실패할 수 있으나, 이 경우 validation error가 먼저 발생하지는 않는다 — API 에러가 먼저 발생하거나, API가 null을 반환한 후 save!에서 validation이 실패한다. 실제 로그에서는 validation error만 확인되므로, Salesforce API 호출은 성공했으나 save!에서 실패한 것이다.
Log Evidence#
Datadog에서 사용한 쿼리:
service:cupixworks-worker "sync line item" OR "Salesforce" @environment:production
service:cupixworks-worker "Sf opportunity" status:error
시작 로그 (02:33:38.937Z):
start syncing line item opportunities from Salesforce - line_item_id: 1290
- Class:
LineItemOpportunitySyncWorker, Function:perform - Request ID:
7f0df490be496fb0a5145a54
에러 로그 (02:33:40.939Z):
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):
{
"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_id와 sf_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_item의attributes.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이 생성되는 패턴 모니터링:
service:cupixworks-worker "Sf opportunity can't be blank" status:error
LineItemOpportunitySyncWorker실패율 모니터:
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 우회가 안전하다.