개발 기록 2026-10-20 docs/devlog/2026-10-20-2-Triage-요청과-워커.md GitHub에서 보기

Triage 요청과 워커

목표

계약 5.5.1 의 Tickmark.Triage 컨텍스트와 Tickmark.Triage.TriageWorker 를 만든다. 사람이 finding 하나 또는 run 하나의 트리아지를 요청하면 Oban job 이 llm 큐에서 AI 클라이언트를 부르고, 제안을 ai_triages 에 저장한다. AI 는 제안만 하고 finding 의 status 는 건드리지 않는다. 모든 상태 변화는 감사 이벤트와 한 트랜잭션이고 브로드캐스트는 커밋 뒤에만 한다.

완료 조건은 MIX_TEST_PARTITION=2 mix precommit 통과와 mix.lock 무변경이다.

한 일

파일 역할
lib/tickmark/triage.ex request_finding_triage/2, request_triage/3, build_input/2, triage_finding/2, mark_failed/2, latest_triages/2
lib/tickmark/triage/triage_worker.ex llm 큐, max_attempts: 5, timeout 3분, backoff 10 * 2 ** attempt 초. triage_finding/1 결과를 job 결과로 바꾸는 handle_result/2
lib/tickmark/jobs/failure_handler.ex "Tickmark.Triage.TriageWorker" 분기 추가. 마지막 시도의 raise 와 timeout 을 mark_failed(id, :worker_crashed) 로 기록
test/tickmark/triage_test.exs 23개 (sandbox 22개 + sandbox 밖 동시성 1개)
test/tickmark/triage/triage_worker_test.exs 9개 (반환표 5개, perform 2개, 옵션 1개, snooze 확인 1개)
test/tickmark/jobs/failure_handler_test.exs 2개 추가 (마지막 시도 / 재시도 예정)

request_finding_triage/2 (계약 5.5.1 순서 그대로):

  1. id 형식 검사 -> finding 과 run 조회 -> Engagements.authorize(scope, engagement_id, :request_triage). 셋 중 어디서 걸려도 {:error, :forbidden} 이라 존재 여부가 새지 않는다.
  2. 트랜잭션: finding FOR UPDATE -> score < triage_threshold 면 :below_threshold -> 이미 queued 면 :already_queued -> triage_status_changeset(:queued) -> Oban.insert -> ai_triage.requested (actor user, payload entry_number).
  3. 커밋 뒤 {:finding_updated, %{finding_id, status, triage_status}} 와 audit_appended.

request_triage/3 (일괄):

  1. run 조회와 권한 확인.
  2. SELECT 로 후보를 고른다: score >= triage_threshold, triage_status == :none, 정렬은 score desc, journal_entries.total_debit desc, entry_number asc, limit 은 AI.config()[:max_findings_per_request] (100). limit: 옵션으로 바꿀 수 있다 (테스트용, 보고서에 기록).
  3. update_all 이 triage_status == :none 을 다시 확인하며 queued 로 바꾸고 바뀐 id 만 select 로 돌려준다. 후보 순서를 유지해 Oban job 을 insert_all 하고, 1건 이상이면 ai_triage.requested (subject test_run, payload enqueued) 를 Multi.merge 로 붙인다.
  4. 커밋 뒤 {:triage_requested, %{test_run_id, enqueued}} 는 0건이어도 보내고, 이벤트가 있을 때만 audit_appended 를 보낸다. finding 별 finding_updated 는 보내지 않는다.

triage_finding/2 (워커 전용):

  1. finding (journal_entry 와 lines, test_run 과 engagement) 과 rule_results 를 읽는다. queued 가 아니면 {:ok, :noop}, API 호출 없음.
  2. build_input/2 -> AI.input_hash/1 -> (finding_id, prompt_version, model, input_hash) 로 기존 행을 찾는다. 있으면 API 호출 없이 재사용한다.
  3. 없으면 트랜잭션 밖에서 client.triage(input) 을 부르고 System.monotonic_time/1 로 latency_ms 를 잰다. {:error, e} 는 DB 를 건드리지 않고 그대로 돌려준다.
  4. 트랜잭션: finding FOR UPDATE -> queued 아니면 :noop -> ai_triages insert (on_conflict: :nothing, 4컬럼 conflict_target, 충돌이면 키로 다시 읽어 reused: true) -> :succeeded -> ai_triage.completed (actor {:agent, "triage:" <> response_model}, payload ai_triage_id, risk_level, reused, entry_number).
  5. 커밋 뒤 finding_updated, audit_appended.

mark_failed/2: FOR UPDATE -> queued 일 때만 :failed 와 ai_triage.failed (actor {:system, "triage.triage_worker"}, payload reason, entry_number). 아니면 아무것도 쓰지 않는다. 항상 :ok. reason 표는 계약 5.5.1 그대로다 (rate_limited, transient, invalid_output, refusal, not_configured, request_rejected, worker_crashed).

latest_triages/2: DISTINCT ON (finding_id) 에 inserted_at desc, id desc 로 finding 당 최신 1건. members 를 조인해 scope 사용자가 속한 engagement 의 finding 만 돌려준다. 형식이 틀린 id 는 건너뛴다. 쿼리 1개.

TriageWorker.handle_result/2 반환표:

triage_finding/1 결과 job 상태 반환
{:ok, _} :ok
{:error, {:rate_limited, s}} meta["snoozed"] < 10 `{:snooze, s
{:error, {:rate_limited, _}} snoozed 10 이상, 마지막 시도 아님 {:error, :rate_limited}
{:error, {:rate_limited, _}} snoozed 10 이상, 마지막 시도 mark_failed 뒤 {:cancel, :rate_limited}
{:error, {:retryable, r}} 마지막 시도 아님 {:error, r}
{:error, {:retryable, r}} 마지막 시도 (attempt >= max_attempts) mark_failed 뒤 {:cancel, r}
{:error, {:fatal, r}} mark_failed 뒤 {:cancel, r}

결정과 트레이드오프

입력자의 모집단 내 전표 수는 넣지 않는다 (코디네이터 결정)

  • 도메인 4.1 의 context 는 금액 대 수행중요성 비율, 계정쌍 모집단 빈도, 입력자의 모집단 내 전표 수 세 가지를 든다. 앞의 둘은 finding 과 rule_results.details 로 만들 수 있지만 입력자 전표 수는 어느 룰도 details 에 남기지 않아 별도 쿼리가 필요했다.
  • orca orchestration ask 로 물었고 답은 "omit" 이다. 이유: (1) 합성 데이터의 입력자는 acct01-05 와 sys_batch 뿐이고 입력자 빈도 이상 주입이 없어 신호가 없다. (2) 모집단 통계는 룰 단계에서 계산해 details 에 남기는 것이 현재 설계다. triage 에서 따로 세면 S5 에 보이는 근거에 없는 값이 AI 입력에 들어간다. (3) 계약 시그니처를 바꾸지 않는다.
  • 그래서 build_input/2 는 finding (preload) 과 rule_results 만 받는 순수 함수다. context 는 entry_amount_to_materiality (총차변 / 수행중요성, 소수 4자리 문자열) 와 account_pair_population (rare_account_pair details 의 population_count 와 rare_pairs 복사) 두 키다. rare_account_pair 가 걸리지 않은 전표는 그 키를 null 로 두어 context 모양을 고정한다. 도메인 4.1 과 계약 7.4 는 메인이 고친다.

반환표 테스트를 위해 handle_result/2 를 공개하고 client: 옵션을 둔다

  • 테스트 환경의 AI 클라이언트는 Fake 고정이고 테스트 중 Application env 를 바꾸지 않는다는 규칙이 있다. 그러면 rate limit, transient, fatal 경로를 perform/1 로는 만들 수 없다.
  • 선택: perform/1 은 triage_finding/1 결과를 handle_result(result, job) 에 넘기기만 하고, 반환표는 handle_result/2 를 Oban.Testing.build_job/3 으로 만든 job (attempt, max_attempts, meta) 과 함께 직접 부른다. triage_finding/2 의 client: 옵션은 테스트 파일 안의 ErrorClient (behaviour 구현, 항상 {:retryable, :transport_error}) 로 "오류면 DB 무변경" 과 "재사용이면 API 호출 없음" 을 증명한다. ErrorClient.model/0 은 Fake 와 같은 id 를 돌려줘 저장된 Fake 제안의 키와 맞는다.
  • 대안: Mox 추가. 의존성 변경은 메인 소유라 피했고, behaviour 하나에 구현 둘이면 함수 인자로 충분하다. 운영 코드는 client: 를 넘기지 않는다.

일괄 요청의 중복 방지는 SELECT 뒤 조건부 UPDATE

  • 두 사람이 같은 run 에 동시에 일괄 요청하면 둘 다 같은 후보를 SELECT 한다. update_all 의 WHERE 에 triage_status == :none 을 다시 넣으면 PostgreSQL READ COMMITTED 에서 행 락을 기다린 UPDATE 가 커밋된 행으로 WHERE 를 다시 평가하므로, 먼저 커밋한 쪽이 가져간 finding 은 두 번째 쪽에서 0행이 된다. 돌아온 id 만 job 으로 만든다.
  • 후보 SELECT 의 순서를 유지하려고 update_all 결과를 MapSet 으로 걸러 후보 순서대로 job 을 넣는다. llm 큐 동시성이 2 라 점수 높은 전표가 먼저 돈다.
  • 대안: 후보마다 FOR UPDATE SKIP LOCKED. 더 정확하지만 finding 100건을 한 건씩 잠그는 코드가 길어지고 설명이 어렵다. 계약이 정한 두 문장 방식이 충분하다.
  • 이벤트는 1건 이상일 때만 남기므로 Multi.merge 로 finding_ids 결과를 보고 Oban.insert_all 과 AuditTrail.append 를 붙이거나 비운다.

재사용 판정은 트랜잭션 밖 조회 + on_conflict: :nothing

  • API 호출 전에 4컬럼 키로 기존 행을 찾아 있으면 호출을 건너뛴다 (비용 절약이 목적이라 트랜잭션 밖 조회로 충분하다).
  • 호출 뒤 저장할 때 같은 키가 그 사이 생겼을 수 있다 (같은 finding 의 job 이 둘 돌았을 때). on_conflict: :nothing 으로 넣고 키로 다시 읽어 id 가 다르면 reused: true 로 기록한다. binary_id 는 애플리케이션이 미리 만들므로 충돌 시 insert 가 돌려주는 struct 의 id 는 DB 에 없는 값이고, 그래서 다시 읽어야 한다.

락은 자식 행보다 먼저

  • ai_triages insert 는 FK 검사로 finding 행에 FOR KEY SHARE 를 잡고 이것은 FOR UPDATE 와 충돌한다 (라운드 3 의 교착과 같은 모양). 그래서 모든 경로가 finding FOR UPDATE -> 자식 행 순서다. finding 만 잠그려고 journal_entry 는 조인이 아니라 별도 preload 로 읽는다.

snooze 상한은 meta["snoozed"]

  • Oban 2.24 Basic 엔진의 snooze_job 은 state: scheduled, attempt - 1, meta.snoozed + 1 을 한 UPDATE 로 한다. 즉 snooze 는 시도 횟수를 소진하지 않고, 무한히 snooze 될 수 있다. 그래서 계약대로 meta["snoozed"] 10 을 상한으로 두고 그 뒤는 retryable 처럼 취급한다. 테스트가 엔진 함수를 직접 불러 세 값을 고정한다 (아래 학습 포인트).

그 밖의 결정

  • mark_failed/2 는 실제로 상태가 바뀔 때만 Logger.error 를 남긴다. 멱등 호출 (워커 뒤에 FailureHandler 가 또 부르는 경우) 은 조용히 :ok 다. payload 에는 분류된 reason 문자열만 들어가고 원본 오류는 로그에만 남는다.
  • timeout 3분은 클라이언트 receive_timeout 120초보다 길다. 느린 응답은 Oban 이 프로세스를 죽이는 대신 클라이언트 오류로 돌아와 반환표를 탄다. backoff 20, 40, 80, 160초로 다섯 번 시도하면 약 5분 안에 끝난다.
  • call_and_complete 에서 changeset 이 invalid 면 {:error, {:fatal, :invalid_output}} 으로 돌려준다. 클라이언트가 behaviour 계약 (절차 1-4개 등) 을 어겼을 때 MatchError 로 죽는 대신 invalid_output 으로 기록되게 하는 방어선이다.
  • request_triage/3 는 run 의 status 를 보지 않는다. 계약의 "실행 중인 run 은 0건" 은 실행 중에는 finding 이 없다는 사실에 기댄다.

막힌 점과 해결

  • build_input/2 의 "입력자 전표 수" 가 details 어디에도 없어 쿼리를 추가할지 물어야 했다. 백그라운드로 ask 를 띄워 두고 다른 읽기를 계속했고, 답 (omit) 이 온 뒤 구현을 시작해 기다리는 시간을 없앴다.
  • 동시성 테스트는 감사 이벤트를 남기면 뒷정리를 못 한다 (audit_events 는 트리거로 UPDATE/DELETE 금지, engagement FK 는 restrict). 그래서 "경쟁에서 진 쪽" 만 시험한다. 테스트 프로세스가 트랜잭션 안에서 update_all 로 finding 두 건을 queued 로 바꾸고 (이벤트 없음) 커밋하지 않은 채, Task 가 자기 연결로 request_triage/3 를 부른다. Task 의 SELECT 는 아직 none 을 보고 UPDATE 에서 행 락을 기다린다 (refute_receive :triage_requested, 500). 커밋하면 UPDATE 가 WHERE 를 다시 평가해 0행이고, 결과는 enqueued: 0, job 없음, 이벤트 없음이다. "이긴 쪽" 의 job 과 이벤트는 sandbox 안의 순차 두 번 요청 테스트 (4건 뒤 0건) 가 확인한다.
  • Decimal.round(4) 는 0.01 을 0.0100 으로 만든다. 자릿수가 고정되는 편이 해시 안정성에 좋아 그대로 두고 테스트 기대값을 맞췄다.

검증

실제 출력 (마지막 줄):

$ MIX_TEST_PARTITION=2 mix precommit
Finished in 7.1 seconds (5.8s async, 1.2s sync)
Result: 423 passed
  • 이번에 추가한 34개 (triage 23, triage_worker 9, failure_handler 2) 를 포함한다.
  • 컴파일 경고 0, mix.lock 무변경 (git status 에 없음), IO.inspect/dbg 없음.
  • 동시성 테스트만 5번 반복: Result: 1 passed, 22 excluded x 5.
  • 테스트 뒤 tickmark_test2 의 users, engagements, audit_events, findings, oban_jobs 행 수 모두 0.

Elixir 학습 포인트

  • Oban snooze 는 시도를 소진하지 않는다: deps/oban/lib/oban/engines/basic.ex 의 snooze_job 은 state = scheduled, scheduled_at = now + seconds, inc: [attempt: -1], meta = jsonb_set(meta, '{snoozed}', coalesce(snoozed, 0) + 1) 을 한 번에 한다. 테스트는 testing: :manual 로 DB 에 들어간 job 을 executing, attempt: 1, attempted_at: now 로 바꾼 뒤 Oban.Engine.snooze_job(Oban.config(), job, 30) 을 부르고 state == "scheduled", attempt == 0, meta == %{"snoozed" => 1} 을 확인한다. attempted_at 을 같이 맞춰야 하는 이유는 엔진의 ack 쿼리가 id, state in ["executing"], attempted_at 세 조건으로 "내가 잡은 그 실행" 인지 확인하기 때문이다.
  • Ecto.Multi.merge/2: 앞 단계 결과를 보고 뒤 단계를 붙일지 정할 때 쓴다. 넘긴 함수가 changes 를 받아 Multi 를 돌려주고, 빈 Multi.new() 를 돌려주면 아무것도 안 한다. request_triage/3 가 0건이면 job 도 이벤트도 없이 커밋한다.
  • Multi 안의 Oban.insert/3: Oban.insert(multi, :job, fn changes -> changeset end) 는 앞 단계 (갱신된 finding) 를 받아 job changeset 을 만든다. Oban.insert_all(multi, :jobs, list) 는 Multi.run 으로 감싸져 {:ok, jobs} 를 changes 에 넣는다.
  • on_conflict: :nothing 과 미리 만든 id: @primary_key {:id, :binary_id, autogenerate: true} 는 insert 전에 애플리케이션이 UUID 를 만든다. 충돌로 아무것도 안 넣어도 {:ok, struct} 가 돌아오고 그 id 는 DB 에 없다. 그래서 "정말 들어갔는지" 는 키로 다시 읽어 id 를 비교한다.
  • DISTINCT ON 을 Ecto 로: distinct: t.finding_id 에 order_by: [desc: t.inserted_at, desc: t.id] 를 주면 Ecto 가 ORDER BY t.finding_id, t.inserted_at DESC, t.id DESC 로 바꿔 finding 당 첫 행 (최신) 만 남긴다. 쿼리 한 번으로 "각 finding 의 최신 제안" 을 가져온다.
  • READ COMMITTED 의 UPDATE 재평가: 행 락을 기다리던 UPDATE 는 락이 풀리면 상대가 커밋한 새 행으로 WHERE 를 다시 평가한다. triage_status == :none 을 WHERE 에 두는 것만으로 "먼저 가져간 쪽이 이긴다" 가 된다. update_all 의 set: 은 스키마 타입으로 값을 dump 하므로 Ecto.Enum 필드에 :queued 원자를 그대로 쓸 수 있고, updated_at 은 자동으로 갱신되지 않아 직접 넣는다.
  • with 의 else 생략: with {:ok, result} <- client.triage(input, []) do ... end 에서 패턴이 안 맞으면 그 값 ({:error, e}) 이 그대로 반환값이 된다. "오류는 손대지 않고 그대로 돌려준다" 를 한 줄로 쓴다.
  • behaviour 로 클라이언트 주입: Tickmark.AI.Client 를 구현한 모듈을 함수 인자로 받으면 Application env 를 건드리지 않고 실패 경로를 시험할 수 있다. client.model(), client.triage(input, []) 처럼 변수에 든 모듈로 함수를 부르는 것이 Elixir 의 다형성이다.

다음 할 일

  • 메인: 도메인 4.1 과 계약 7.4 의 context 설명을 이 문서의 결정대로 고친다 (입력자 전표 수 제외, account_pair_population null 허용). 계약 5.5.1 의 "확인 필요 (snooze)" 를 확인 완료로 바꾼다.
  • 화면 (W3, S5): request_finding_triage/2 와 request_triage/3 를 버튼에 연결하고, latest_triages/2 로 목록에 최신 제안을 그린다. :finding_updated 의 triage_status 와 :triage_requested 의 enqueued 로 화면을 갱신한다.
  • 실제 Anthropic 클라이언트로 한 번 돌려 latency_ms, stop_reason, 토큰 수가 들어오는지 본다 (이번 라운드는 키 없이 Fake 만 돌렸다).
  • max_findings_per_request 기본값 100 은 설정값이라 테스트가 없다. 필요하면 config 를 읽는 테스트 하나를 추가한다.

관련 커밋

  • 8d1f488 feat(triage): 트리아지 요청, 실행, 실패 기록 컨텍스트와 워커 구현
  • 044d1d0 feat(jobs): FailureHandler 에 TriageWorker 분기 추가
  • 이 문서: docs(devlog): Triage 요청과 워커 기록