Testing 실행 경로
- 작성일: 2026-10-19
- 작성: 워커 A (Claude
claude-fable-5-1, effort xhigh), 브랜치feature/m2-testing-runner, WBS 2.1 - 관련 문서: 시스템 구조와 데이터 모델 (12.1 라운드 3), WBS와 마일스톤
목표
계약 5.3, 5.4, 6장의 테스트 실행 경로를 만든다. 사람이 실행을 시작하면 룰마다 Oban job 이 하나씩 돌고, 마지막 룰이 끝날 때 전표별 finding 을 집계해 실행을 마감한다. 룰이 실패해도 실행은 반드시 끝나고, 모든 상태 변화는 커밋 뒤에만 화면에 알린다.
완료 조건은 MIX_TEST_PARTITION=2 mix precommit 통과와 mix.lock 무변경이다.
한 일
| 파일 | 역할 |
|---|---|
lib/tickmark/testing/runner.ex |
start_test_run/3, run_rule/2, fail_rule/3. 실행 시작, 룰 하나의 마감, 실행 마감(finding 집계)과 브로드캐스트 |
lib/tickmark/testing/rule_worker.ex |
rules 큐, max_attempts: 3, timeout 5분. Testing.run_rule/2 결과를 job 결과로 바꾼다 |
lib/tickmark/jobs/failure_handler.ex |
[:oban, :job, :exception] 핸들러. 마지막 시도의 raise 와 timeout 을 mark_import_failed/2, fail_rule/3 로 기록한다 |
test/tickmark/testing/runner_test.exs |
23개 (sandbox 22개 + sandbox 밖 동시성 1개) |
test/tickmark/testing/rule_worker_test.exs |
4개 |
test/tickmark/jobs/failure_handler_test.exs |
7개 |
start_test_run/3 흐름 (계약 5.3):
Engagements.authorize(scope, engagement_id, :start_test_run).- import 를 다시 읽어
status != :succeeded면{:error, :import_not_ready}. 화면이 들고 있던 struct 는 오래됐을 수 있다. :all은Rules.ids(). 빈 목록, 모르는 id, 중복 id 는ArgumentError. 사용자 입력이 아니라 프로그래밍 오류라서 반환값 대신 raise 다.- run id 를 미리 만들고
Ecto.Multi하나로test_runsinsert,test_run_rulesinsert_all(params 는module.default_params()스냅샷, statusqueued),test_run.started감사 이벤트,Oban.insert_all로 룰 수만큼 RuleWorker job. partial unique index 위반은 changeset 의constraint_name을 보고{:error, :run_in_progress}로 바꾼다. - 커밋 뒤
Engagements.broadcast로{:test_run_updated, ...}.
run_rule/2 흐름 (계약 5.4):
- 룰 행과 run 을 읽어 룰이 terminal 이거나 run 이 running 이 아니면
{:ok, :noop}. 모르는 룰 id 는{:error, :unknown_rule}, 형식이 틀린 run id 는{:ok, :noop}. queued -> running을 짧은 트랜잭션으로 커밋한다.started_at은COALESCE(started_at, now)라서 재시도에도 첫 시작 시각이 남는다. 커밋 뒤:rule_progress,:test_run_updated브로드캐스트.- 룰은 트랜잭션 밖에서
RuleContext(engagement preload, 저장된 params) 로 돌린다. {:ok, hits}는 한 트랜잭션에서 run 행FOR UPDATE->mark_rule(pending 행만 갱신, 0행이면 롤백하고{:ok, :noop}) ->rule_results1000건씩 insert_all(score 는module.weight()) -> pending 수 세기 -> pending 0 이고 run 이 running 이면finalize.{:skip, reason}은 insert 없이 같은 순서,{:error, reason}은 DB 를 건드리지 않고 그대로 반환, raise 는 잡지 않는다.finalize는rule_results를journal_entry_id로 묶어insert_all(Finding, query)로 넣는다.hit_count = count,score = LEAST(100, SUM(score)),rule_ids = ARRAY_AGG(rule_id ORDER BY rule_id). 실패 룰이 없으면completed, 있으면completed_with_errors.test_run.completed감사 이벤트를 남긴다.- 커밋 뒤
:rule_progress,:test_run_updated, 마감했으면:run_completed순서로 보낸다.
fail_rule/3 은 같은 lock -> mark -> count -> finalize 순서로 failed 를 쓰고 error 에 inspect(reason) 500자를 남기며 test_run_rule.failed 이벤트(payload 는 rule_id 만)를 기록한다. 이미 terminal 이면 아무것도 쓰지 않는다. 항상 :ok 다.
결정과 트레이드오프
락을 rule_results insert 보다 먼저 잡는다 (계약 5.4 순서 수정 요청)
- 계약은 "rule_results insert_all -> run FOR UPDATE -> mark_rule" 순서였다. PostgreSQL 은 자식 행을 insert 할 때 FK 검사로 부모 행(
test_runs)에FOR KEY SHARE를 잡고,FOR KEY SHARE는FOR UPDATE와 충돌한다. 두 룰이 동시에 끝나면 둘 다 KEY SHARE 를 쥔 채 상대가 놓기를 기다리는 교착이 된다. PostgreSQL 이 1초 뒤 한쪽을 죽이고 Oban 이 재시도하므로 결과는 맞지만, 재시도 하나가 매번 낭비된다. - 선택:
FOR UPDATE->mark_rule-> insert -> count -> finalize. 코디네이터에게 물어 승인받았다. 락을 쥔 시간이 insert 만큼 길어지지만 같은 run 의 다른 룰만 기다리고, 읽기는 MVCC 라 막히지 않는다. - 대안:
FOR NO KEY UPDATE로 insert 를 병렬로 두는 방법. KEY SHARE 와 충돌하지 않아 교착은 없지만 "락 먼저" 보다 설명하기 어렵다. 면접에서 한 줄로 설명할 수 있는 쪽을 골랐다. - mark 를 insert 보다 앞에 둔 이유: 0행(이미 terminal)이면 hits 를 넣기 전에 롤백한다.
running 전환은 별도 트랜잭션
- 룰이 수십 초 돌 수 있으므로 running 전환을 먼저 커밋하고 룰은 트랜잭션 밖에서 돌린다. 화면은 "돌고 있음" 을 바로 보고, 룰이 도는 동안 run 행에 락이 없다.
- 대가: 룰이
{:error, _}를 돌려주거나 raise 하면 running 인 채 남는다. 재시도는 pending(queued 또는 running) 행을 다시 running 으로 갱신하므로 문제없고, 마지막 시도는fail_rule/3이 마감한다.
{:error, reason} 은 DB 를 건드리지 않는다
- 재시도 대상이므로 룰 행을 그대로 두고 reason 을 worker 에 돌려준다. worker 가 마지막 시도인지 판단해
fail_rule/3을 부른다. - 대안으로 run_rule 안에서 바로 failed 를 쓰는 안은 "일시적 오류도 한 번에 실패" 가 되어 버렸다.
finding 집계를 DB 에서 한다
insert_all(Finding, query)로 rule_results 를 GROUP BY 해서 넣는다. 10만 전표 x 7 룰이어도 애플리케이션 메모리로 끌어오지 않는다.id,status,triage_status는 DB 기본값에 맡긴다.- 점수 상한 100 은
LEAST(100, SUM(score)), 룰 id 정렬은ARRAY_AGG(... ORDER BY ...)로 SQL 에서 끝낸다. 테스트가 "alpha_rule", "approval_control_gap", "zeta" 순서와 60+90+25 -> 100 을 확인한다.
FailureHandler 는 마지막 시도만 본다
[:oban, :job, :exception]은 매 실패 시도마다 온다.attempt >= max_attempts일 때만 worker 문자열로 분기한다. 모르는 worker 는 무시한다(M3 의 Triage worker 는 그때 추가).{:error, _}를 돌려준 마지막 시도는 worker 가 먼저fail_rule/3을 부른 뒤{:cancel, _}로 끝내므로 핸들러까지 오지 않는다({:cancel, _}는:stop이벤트). raise 와 timeout 만 핸들러 몫이다. 둘 다 호출돼도fail_rule/3과mark_import_failed/2가 멱등이라 안전하다.- 핸들러 본문은
catch kind, reason으로 감싸 Logger 만 남긴다. telemetry 는 raise 한 핸들러를 떼어 버리므로, 한 번 터지면 이후 실패가 영영 기록되지 않는다.
RuleWorker: max_attempts 3, timeout 5분
- 3회: 1회면 일시적 DB 오류에도 바로 실패하고, 5회 이상이면 결정적 오류(룰 버그)에 수 분을 낭비한다. 기본 backoff(지수)로 3회면 대략 1분 안에 끝난다. ImportWorker 와 같은 값이라 설명도 하나로 된다.
- 5분: 룰 하나는 import 하나에 대한 읽기 쿼리 몇 개다. 10만 전표 기준 수 초가 정상이고 5분을 넘으면 쿼리가 잘못된 것이다. ImportWorker(10분) 보다 짧게 둔 이유는 적재와 달리 쓰기가 거의 없어서다. Oban 은 timeout 에
TimeoutError로 프로세스를 죽이고 이는 exception 이벤트로 핸들러에 온다.
동시성 테스트를 sandbox 밖에서 돌린다
- Ecto sandbox 는 테스트 하나를 트랜잭션 하나로 감싸고 마지막에 롤백한다. 다른 연결은 그 안의 행을 볼 수 없고, "커밋 뒤에야 보인다" 와 "락을 기다린다" 를 보여줄 수 없다. 그래서
runner_test.exs안에RunnerConcurrencyTest모듈을async: false로 따로 두고Sandbox.checkout(Repo, sandbox: false)로 진짜 연결을 쓴다. - 시나리오: 테스트 프로세스가 run 행을
FOR UPDATE로 잡은 채 Task 두 개(각자 sandbox 없는 연결)로run_rule/2를 돌린다. 둘 다 running 전환까지는 커밋하지만(:rule_progressrunning 2건 수신) 마감은 못 한다(refute_receivesucceeded/skipped). 락을 놓으면 둘이 차례로 끝나고done값은 1 과 2 로 하나씩 나온다. - 세 번째 룰(
test_error)을 queued 로 남겨 마감이 일어나지 않게 했다. 마감하면audit_events가 생기는데 그 테이블은 트리거로 UPDATE/DELETE 를 막아 뒷정리가 불가능하다. 같은 이유로 run 생성도start_test_run/3대신 fixture 로 했다(test_run.started이벤트를 남기지 않기 위해).on_exit에서 결과, 룰, run, 전표, import, engagement, user 순서로 지운다. 테스트 중간에 실패해도 감사 이벤트만 없으면 정리된다. - 교착 재현 테스트는 만들지 않았다(코디네이터 답변). "finalize 가 정확히 한 번" 은 sandbox 안에서 룰 두 개를 차례로 끝내는 테스트가
test_run.completed1건과:run_completed1건으로 확인한다.
그 밖의 결정
start_test_run/3의 룰 id 중복은ArgumentError다. 그냥 두면test_run_rules의 unique 제약에 걸려 changeset 오류가 돌아오는데, 사용자에게 보일 오류가 아니다.fail_rule/3의error는inspect(reason)이라 문자열 reason 도 따옴표를 포함한 채 500자로 잘린다. 계약대로 개발자용 값이다.:test_run_updated는 running 전환, 룰 마감, 실행 마감 때마다 보낸다. 목록 화면은 finding_count 와 status 만 보므로 같은 값이 여러 번 가도 무해하다.
막힌 점과 해결
- 첫 실행에서 점수 상한 테스트와 동시성 테스트가
Decimal.new(nil)로 터졌다. 두 테스트는 run 을start_test_run/3이 아니라 fixture 로 만들어test_run_rules.params가%{}였고,approval_control_gap이params["approval_min_amount"]를 읽다 nil 을 만났다. 테스트 helper 가Rules.fetch/1로default_params()를 넣게 고쳤다. 덕분에 "params 스냅샷이 없으면 룰이 못 돈다" 는 사실도 확인했다. Oban.insert_all(multi, name, changesets)의 3인자 clause 가 있는지 확실하지 않아deps/oban/lib/oban.ex에서%Multi{}패턴 clause 를 확인한 뒤 썼다.- FailureHandler 의 "raise 해도 떼어지지 않는다" 테스트는 핸들러를 어떻게 터뜨릴지가 문제였다.
spawn_monitor로 띄운 프로세스는$callers가 없어 sandbox 연결을 못 받으므로fail_rule/3의Repo호출이DBConnection.OwnershipError로 raise 한다. 그걸 그대로 썼다.Task는$callers를 세팅해 연결을 물려받으므로 쓸 수 없었다.
검증
실제 출력(마지막 줄):
$ MIX_TEST_PARTITION=2 mix precommit
Finished in 5.5 seconds (4.8s async, 0.7s sync)
Result: 298 passed
- 이번 라운드에서 추가한 34개(runner 23, rule_worker 4, failure_handler 7)를 포함한다. 기존 264개는 그대로 통과한다.
- 컴파일 경고 0,
mix.lock무변경(git status에 없음). - 동시성 테스트만 5번 반복:
Result: 1 passed, 22 excludedx 5. - 테스트 뒤
tickmark_test2의test_runs,users,audit_events,engagements행 수 모두 0 (sandbox 밖 테스트의 뒷정리 확인).
Elixir 학습 포인트
SELECT ... FOR UPDATE와 READ COMMITTED: PostgreSQL 기본 격리 수준(READ COMMITTED)에서는 "pending 수를 센다" 와 "0 이면 마감한다" 사이에 다른 트랜잭션이 끼어들 수 있다. 두 룰이 동시에 끝나면 둘 다 pending 1 을 보거나 둘 다 0 을 봐서 마감이 0번 또는 2번 된다. run 행을FOR UPDATE로 잡으면 같은 run 의 마감 트랜잭션이 한 줄로 서고, 뒤에 온 쪽은 앞쪽이 커밋한 상태를 보고 센다. 락은 트랜잭션 끝까지 유지된다. 한 가지 더: FK 가 있는 자식 행 insert 는 부모 행에FOR KEY SHARE를 잡고 이것은FOR UPDATE와 충돌한다. 그래서 락을 insert 보다 먼저 잡아야 교착이 없다.Ecto.Multi와Repo.transact/1: Multi 는 트랜잭션 안에서 할 일을 이름 붙인 단계로 쌓아 두는 자료구조고,Repo.transact/1이 그것을 한 트랜잭션에서 실행한다. 실패하면{:error, 단계이름, 값, 지금까지의_changes}로 어느 단계가 왜 실패했는지 온다.start_test_run/3은:test_run단계의 changeset 에서constraint_name을 보고:run_in_progress로 바꾼다. 함수 버전Repo.transact(fn -> ... end)는{:ok, v}면 커밋,{:error, r}면 롤백이라close_rule처럼 "0행이면 롤백" 이 자연스럽다.- Oban 재시도와 cancel:
perform/1이{:error, _}를 돌려주거나 raise 하면 Oban 이 backoff 뒤 다시 돌린다(attempt가 1씩 는다).max_attempts에 닿으면discarded가 된다.{:cancel, _}은 "더 돌려도 소용없다" 는 뜻으로 즉시cancelled로 끝내며:stop이벤트가 난다. 그래서 마지막 시도의{:error, _}는 먼저 도메인에 실패를 기록하고{:cancel, _}로 끝낸다. - telemetry 핸들러 분리 규칙:
:telemetry.attach/4로 붙인 핸들러가 raise 하면 telemetry 가 그 핸들러를 떼어 낸다(한 핸들러가 다른 핸들러까지 막지 않도록). 그래서 핸들러 안은 반드시 잡고 로그만 남긴다. 같은 id 로 두 번 붙이면{:error, :already_exists}가 오므로attach/0은 그것을:ok로 취급한다. - Ecto sandbox 와
$callers: sandbox 는 테스트 프로세스가 연결 주인이고Task는$callers로 주인을 찾아 같은 연결을 쓴다. 연결이 하나라 Task 와 테스트가 동시에 쿼리하면 직렬화된다. 진짜 동시성을 보려면 프로세스마다Sandbox.checkout(Repo, sandbox: false)로 자기 연결을 받아야 하고, 그때는 롤백이 없으니 뒷정리를 직접 한다. insert_all과 query source:Repo.insert_all(Finding, query)는INSERT ... SELECT가 된다. select 의 key 는 스키마 필드명이어야 하고,^run.id같은 바인딩은type(^v, :binary_id)로 타입을 알려 줘야 한다. 리스트로 넣을 때와 달리inserted_at이 자동으로 들어가지 않아 select 에 직접 넣었다.
다음 할 일
- 메인 세션: 계약 5.4 성공 경로 순서를 "run FOR UPDATE -> mark_rule -> rule_results insert_all -> pending count -> finalize" 로 고친다.
- 워커 B 의 룰 7종이 합쳐지면
:all테스트가 자동으로 그 룰들을 포함한다. 룰마다 params 를 쓰는 방식이 다르면RuleContext.params스냅샷이 맞는지 다시 본다. - 화면(워커 C)에서
:rule_progress의done/total과:run_completed를 받아 진행률을 그린다. - M3: FailureHandler 에 Triage worker 분기를 추가한다.
관련 커밋
1d28488feat(testing): 룰 실행 경로 구현2436619feat(jobs): 마지막 시도 실패를 기록하는 telemetry 핸들러- 이 문서: docs(devlog): Testing 실행 경로 기록