U-대기열 — 규칙
절차는 business-logic-model.md, 타입과 자리는 domain-entities.md 에 있다.
여기는 지켜야 하는 것과 그 이유다.
1. 대기열에 드는 조건
R1. 409 만 대기열에 든다. 422 는 그대로 FAILED 다
409 후보는 있는데 전부 점유됨 일시적이다. 기다리면 풀린다 -> QUEUED
422 요구를 만족할 노드가 함대에 없다 영구다. 계약을 고쳐야 한다 -> FAILED
두 코드의 뜻이 원래 그렇게 갈려 있었다 (internal/match/match.go 의 상수
주석). 대기열이 하는 일은 409 의 재시도를 호출자에게서 걷어오는 것이고,
422 를 대기열에 넣으면 고쳐야 할 계약이 조용히 영영 기다린다.
폭 상한 거절도 422 다 — t=0 에 이미 넘는 요구는 다시 내도 같기 때문이다.
승격 시점의 폭 초과는 다르다 (R10).
R2. 409 를 내는 자리 둘이 같은 대접을 받는다
internal/api/api.go:419 매처의 Reject{Code: CodeAllBusy}
internal/api/api.go:474 CreateRun 의 ErrNodeTaken
호출자는 둘을 구별할 수단이 없고 둘 다 「지금은 자리가 없다」이다. 하나만
대기열에 넣으면 완료 조건 「409 가 QUEUED 가 된다」가 절반만 참이 되고,
경쟁에서 진 제출만 호출자가 재시도해야 한다.
ErrNodeTaken 을 그렇게 다뤄도 안전한 이유 — 그 오류는 임대 기본키 충돌이고
CreateRun 의 트랜잭션이 통째로 롤백된 뒤에 온다. 남은 행이 없으므로
손으로 풀 것 없이 QUEUED 로 다시 만들면 된다.
R3. 재제출은 200 이다. 202 는 새로 QUEUED 가 되는 순간만 — NFR-I7
submit 머리의 GetRun 이 그것을 이미 한다. QUEUED Run 을 다시 제출하면
그 행이 나오므로 200 이다. 202 는 행이 없다가 생기는 그 한 번이다.
CreateQueuedRun 이 유일 제약에 걸리면(같은 run_id 가 동시에 둘 들어온
경우) 다시 읽어 200 으로 답한다 — CreateRun 이 그 경우를 처리하는 방식과
같다.
R4. QUEUED Run 은 아무것도 안 쥔다
임대 없다. 그래서 Reap 이 안 쓸어간다
node_id 빈 문자열. 그래서 아무 노드도 못 집는다
runAcquires 안 부른다. 자원을 잡는 일이다
raiseAsks 안 부른다. 사람을 부르는 일이다
기다리면서 쥐고 있는 것이 안티패턴의 교과서다 — raiseAsks 의 주석이
ASKED 단계에 대해 같은 말을 한다. 대기 상태가 자원을 쥐면 대기열이 교착의
원인이 된다.
R5. steps 는 제출 시점에 만든다
미루면 두 가지가 깨진다.
구조 검증이 늦어진다 st.Kind() 와 contract.NeedsOf 가 제출에 동기로 붙는다.
미루면 202 를 받은 Run 이 승격 시점에 구조 오류로 죽고
그때는 보고할 호출자가 없다
S2 가 못 그린다 작업 그래프가 대기 중에도 그려진다 (design/README.md §3.2).
steps 가 없으면 그릴 것이 없다
Records.Open 도 제출 시점이다 — Cancel 이 sealRecord 를 부르므로
(internal/store/reap.go:207) 대기 중 취소가 봉인까지 닿으려면 그때 이미
열려 있어야 한다.
2. 승격
R6. 도착 순서로 전부 훑는다. 앞이 못 가도 뒤를 안 막는다
ListQueued 가 ORDER BY created_at, run_id 로 낸다. run_id 가 동점을
가른다 — 같은 밀리초에 들어온 둘의 순서가 실행마다 달라지면 승격이 결정적이지
않다.
머리에서 멈추지 않는다. 보드를 기다리는 Run 이 머리에 있으면 전혀 다른
능력을 기다리는 뒤의 Run 이 자원이 비어 있어도 못 간다. 그것은 공정성 정책이
아니라 결함이다.
FIFO 의 뜻은 같은 자원을 다투는 것들 사이의 도착 순서다.
R7. 승격은 Run 하나마다 독립 트랜잭션이다
하나가 실패해도 나머지를 계속 본다. 실패는 로그에 남기고 넘어간다.
훑기 하나를 한 트랜잭션으로 묶으면 Run 하나의 실패가 이미 승격된 것들을
되돌린다.
R8. 상태 전이는 조건부 갱신이다
UPDATE runs SET state='RUNNING', assigned=$2
WHERE run_id=$1 AND state='QUEUED'
AND state='QUEUED' 가 동시 승격을 막는다. 두 훑기가 같은 Run 을 동시에
보면 뒤의 것이 행 잠금에서 기다리다가, 앞이 커밋한 뒤 조건을 다시 재고 0행을
받는다. 0행이면 롤백하고 조용히 넘어간다 — 남이 먼저 옮겼거나 그 사이
취소된 것이고, 둘 다 정상이다.
새 잠금을 손으로 짜지 않는다.
R9. 노드 경쟁은 임대 기본키가 막는다
leases.node_id 가 기본키다 (I1 의 스키마 강제). 경쟁하는 쪽이 INSERT
에서 충돌하고 롤백이 이미 잡은 것을 전부 되돌린다 — CreateRun 이 I5 를
얻는 그 기계와 같다. 손으로 해제할 것이 없다.
그래서 WakeQueued 는 재진입 안전이고 동시 호출 안전이다.
같은 훑기 안에서는 승격한 Run 의 노드를 blocked 에 더해 뒤의 Run 이 다시
안 고르게 한다. 기본키가 여전히 마지막 방어선이지만, 그것에 기대는 것과
설계로 피하는 것은 다르다.
R10. 승격 시 폭 초과는 QUEUED 로 둔다
t=0 의 폭 초과가 영구인 이유는 「다시 내도 같다」이다. 승격 시점의 초과는
그렇지 않다 — 셈이 그때 고른 노드 조합에 달렸고, 한 노드가 두 역할을 맡으면
중복이 접혀 셈이 줄기 때문이다 (api.go 의 seen).
로그만 남기고 둔다. 여기서 FAILED 로 만들면 통과할 수 있었던 Run 을
죽인다.
R11. 승격이 발급하는 임대의 수명은 제출과 같다
둘 다 cfg.Lease.TTLSeconds 다. 승격은 Store.LeaseTTL 필드로 그 값을 받는다
(domain-entities.md §3.1). 값이 0 이면 승격을 안 하고 오류를 낸다 —
조용히 무한 임대를 발급하는 것보다 낫다.
R12. 승격 절차의 순서는 CreateRun 과 같다
steps.node_id -> runAcquires -> raiseAsks -> commit -> PushAsks
획득이 needs 를 보고 고르므로 앞의 효과가 먼저 반영돼 있어야 하고, 되묻기는
그 그림을 보고 올라간다. 알림은 커밋 뒤에만 — 트랜잭션 안에서 쏘면 롤백된
질문을 알리게 된다.
3. 깨우기
R13. 동기다
고루틴을 안 띄운다. 근거 셋:
게이트 CP2 의 「a 가 끝난 뒤 runctl status <b> -> RUNNING」이
비동기에서는 경쟁이다. 간헐적으로 빨간 게이트는 게이트가 아니다
ctx r.Context() 는 응답이 끝나면 취소된다. 고루틴이 그것을 쓰면
승격이 중간에 끊기고, Background 로 피하면 종료 순서에
기다릴 것이 하나 더 붙는다
비용 postResult 는 이미 SettleIfDone 을 동기로 부른다 —
판정 · 함대 조회 · 봉인 · 파일 쓰기가 그 안에 있다.
대기열이 비면 WakeQueued 는 질의 하나다
호출자가 끊어서 ctx 가 취소되면 훑기가 중간에 멈춘다. 그래도 된다 —
다음 사건이 같은 것을 다시 보고, WakeQueued 는 재진입 안전이다 (R9).
R14. 사건에 붙는다. 주기에 안 붙는다
붙는 것 자원이 실제로 풀린 자리 다섯 (business-logic-model.md §4.1)
안 붙는 것 reaper 주기 · 광고 하트비트 · 타이머
Reap 은 실제로 놓았을 때만 깨운다. 무조건 깨우면 RunReaper 가
RenewSeconds 마다 도므로 사실상 주기 폴링이다.
광고 하트비트(postNodes)도 마찬가지다 — 노드마다 주기로 오므로 무조건
깨우면 주기 폴링이 된다. 깨우는 것은 재기동 감지가 Run 을 실제로 정산한
경우뿐이다.
R15. postResult 는 진행 중에도 깨우고, 되돌림에는 안 깨운다
rolled == true 안 깨운다. 되돌림은 임대를 안 놓는다
(rollback.go 에 DELETE FROM leases 가 없다 — 셌다)
SettleIfDone 이 "" 깨운다. 빈 문자열은 「아직 진행 중」이고,
부분 반납은 바로 그 경우에 일어난다
SettleIfDone 이 상태 깨운다. Run 이 끝나 임대가 풀렸다
「부분 반납 뒤 깨우기」를 internal/store/release.go 에 못 넣는다 —
applyRelease 가 ReportStep 의 트랜잭션 안에서 돌기 때문이다. 그래서 지점은
여섯이고 호출 자리는 다섯이다 (business-logic-model.md §4.2).
R16. 깨우기 실패는 그 요청의 실패가 아니다
응답 코드도 본문도 안 바꾼다. 로그에 남기고 지나간다. 기동 시 실패도 기동을
안 막는다 — 승격을 못 하는 Mediator 도 제출과 조회는 해야 한다.
4. 안 건드리는 것 — NFR 대조
| # | 요구 | 이 유닛의 처리 |
|---|---|---|
| NFR-I1 | I1 · I2 · I4 · I5 를 안 건드린다. leases.node_id 기본키를 안 바꾼다 |
지킨다. 승격이 그 기본키에 기대어 경쟁을 막는다 (R9). 스키마 변경 0 |
| NFR-I2 | 상태 READY 를 안 만든다 |
지킨다. QUEUED 하나만 실제 값이 된다. ValidState 는 U-바닥이 이미 다섯으로 정했다 |
| NFR-I3 | drain 을 위한 중앙 라우트를 안 만든다 | 해당 없음. 이 유닛은 라우트를 하나도 안 만든다 |
| NFR-I4 | match.Match 를 안 고친다 |
지킨다. busy 를 부르는 쪽이 만들어 넘긴다. 승격도 제출과 같은 순수 함수를 부른다 |
| NFR-I5 | 단계 도중에 안 끊는다 | 해당 없음. 이 유닛은 도는 단계를 안 건드린다 |
| NFR-I6 | dry-run 응답을 안 바꾼다 | 지킨다. draining 합침이 dry == false 갈래 안에만 들어간다 (api.go:402) |
| NFR-I7 | 재제출은 200. 202 는 새로 QUEUED 가 되는 순간만 |
지킨다. R3 |
보안 확장 대조 — 이 유닛은 새 표면을 하나도 안 연다. 새 라우트 0 · 새
입력 파라미터 0 · 새 HTML 0 이라 SECURITY-04 · 05 · 07 · 08 · 12 가 전부
해당 없음이다. 걸리는 것은 하나다.
SECURITY-15 새 오류 문구에 내부 경로 · 스택 · SQL 을 안 싣는다.
호출자에게 나가는 것은 202 와 기존 갈래의 문구뿐이고,
승격 실패는 로그로만 나간다 (호출자가 없는 자리다)
5. 알려진 창 하나 — 사라졌다 돌아온 노드
임대가 풀리는 자리는 넷뿐이고 넷 다 깨우기가 덮는다. grep 으로 셌다
(business-logic-model.md §4.4). 임대는 SQL 이 스스로 지우지 않으므로
「자원이 풀렸는데 아무도 안 봤다」가 없다.
닫히지 않는 자리가 하나 있다.
1. Run b 가 409 를 받고 QUEUED 가 된다 후보 노드 X 가 Run a 에 물려 있다
2. Run a 가 끝난다 깨우기가 돈다. b 가 X 를 집는다 ok
그런데
2'. 노드 X 가 사라진다 광고가 만료된다. 후보가 0 이 된다
3'. 그 사이 깨우기가 돌아도 b 는 못 간다 후보가 없다
4'. 노드 X 가 돌아온다 광고가 다시 온다 — 깨우기 지점이 아니다
4' 에서 b 는 다음 사건까지 기다린다. 함대 어디선가 임대가 하나라도
풀리거나 Mediator 가 재기동하면 그때 간다. 활동이 있는 함대에서는 곧 닫히고,
완전히 조용한 함대에서는 열린 채로 있다.
여기서 안 닫는 이유 — 닫는 방법은 광고 처리(postNodes)에서 깨우는
것인데, 광고는 노드마다 주기로 오므로 무조건 깨우면 R14 를 깬다. 「이전에
만료돼 있던 노드가 돌아왔다」만 골라 깨우면 R14 를 안 깨지만, 깨우기
지점은 requirements.md FR-9 가 여섯으로 고정했다. 일곱째를 이 유닛이
스스로 더하지 않는다.
U-drain 이 같은 핸들러에 여섯째를 단다 (drain 해제 감지). 창을 닫기로
한다면 그 자리가 자연스럽고, 값도 이미 손에 있다 — UpsertAdvert 가 이전
정책을 돌려주듯 이전 만료 여부를 함께 돌려주면 된다. 이 유닛의 범위 밖이라
적어만 둔다.
6. 완료 조건
409 가 QUEUED 가 된다. 응답은 202. 422 는 그대로 FAILED
ErrNodeTaken 도 QUEUED 가 된다. 제출 경로에 409 가 안 남는다
같은 run_id 재제출은 200 + 기존 Run. 202 는 새로 QUEUED 가 되는 순간만
QUEUED Run 의 steps 가 제출 시점에 전부 PENDING 으로 있다. node_id 는 빈 문자열
runAcquires 와 raiseAsks 는 승격 시점으로 미뤄져 있다
Records.Open 은 제출 시점에 한다. 대기 중 취소가 봉인까지 닿는다
WakeQueued 가 created_at 오름차순으로 전부 훑는다. 앞이 못 가도 뒤를 안 막는다
승격은 Run 하나마다 독립 트랜잭션. 하나가 실패해도 나머지를 계속 본다
재진입 · 동시 호출 안전. 임대 기본키 충돌과 조건부 갱신이 경쟁을 막는다
깨우기 호출 자리가 다섯이다 — postResult · postCancel · Reap · 재기동 감지 · 기동
(여섯째 지점인 drain 해제는 U-drain 이 단다)
부분 반납은 postResult 의 깨우기가 덮는다. release.go 를 안 고친다
기동 시 WakeQueued 한 번. 재기동 후에도 QUEUED 가 참이다
reaper 주기에 안 얹었다. Reap 은 실제로 놓았을 때만 깨운다
상태 READY 를 안 만들었다 (NFR-I2)
dry-run 응답이 안 바뀌었다 (NFR-I6)
busy 에 DrainingNodes 가 합쳐졌다 (FR-11 중앙 쪽). match.go 는 안 만졌다
라우트 18개 회귀 0
화면 — S1 의 QUEUED 칩이 실제 행을 낸다. 어느 카드에도 안 얹혀 있다
6.1 테스트가 있어야 하는 자리
internal/store 는 ENODE_TEST_DATABASE_URL 이 없으면 t.Fatal 이고
internal/api 는 기존 헬퍼 newServer 를 그대로 써서 그 패키지의 규칙을
따른다. .ci-allowed-skips 가 비어 있고 CI 가 postgres 를 붙이므로 CI 에서
스킵은 0건이어야 한다 — 새 규칙을 만들지 않는다.
internal/store/queue_test.go (U-바닥이 만든 파일에 이어 붙인다)
WakeQueued 가 빈 대기열에서 아무것도 안 한다
WakeQueued 가 매칭되는 하나를 RUNNING 으로 옮긴다 — 임대 · steps.node_id · assigned 를 다 본다
WakeQueued 가 도착 순서를 지킨다 — 같은 노드를 다투는 둘 중 앞이 간다
WakeQueued 가 앞이 못 가도 뒤를 보낸다 — 다른 능력을 기다리는 둘
WakeQueued 가 두 번 불려도 같다 (재진입)
WakeQueued 가 draining 노드로 안 보낸다
WakeQueued 가 LeaseTTL 0 에서 오류를 낸다
promote 가 폭 상한 초과에서 Run 을 QUEUED 로 둔다
CreateQueuedRun 이 임대를 안 만들고 steps 를 전부 PENDING · node_id '' 로 넣는다
CreateQueuedRun 뒤 Cancel 이 QUEUED 를 FAILED 로 만들고 봉인한다
UnavailableNodes 가 busy 와 draining 의 합집합이다
internal/api/list_test.go 또는 신설 queue_test.go
409 상황의 제출이 202 와 state QUEUED 를 낸다. 본문에 assigned 도 reject 도 없다
422 상황의 제출은 그대로 422 · FAILED
QUEUED Run 재제출이 200 + 기존 Run 이다
dry-run 응답이 안 바뀐다 — draining 노드가 있어도
GET /v1/runs?state=QUEUED 가 그 행을 낸다
postResult 뒤에 대기 Run 이 RUNNING 이 된다
postCancel 뒤에 대기 Run 이 RUNNING 이 된다
cmd/mediator
기동 경로가 WakeQueued 를 한 번 부른다
커버리지 하한이 얇은 자리 둘 — internal/api 는 기준선이 82.8% 이고
internal/store 는 82.2% 다. 하한이 80% 라 새 코드가 안 덮이면 게이트가
빨개진다. 승격의 오류 갈래(0행 · 기본키 충돌 · 폭 초과 · LeaseTTL 0)를
전부 덮는 것이 여유를 만든다.
7. CP2 — 「기다린다」
runctl submit a.json -> 201 · RUNNING
runctl submit b.json -> 202 · QUEUED
runctl status <b> -> QUEUED
a 가 끝난 뒤 runctl status <b> -> RUNNING
runctl submit c.json (없는 능력) -> 422 · FAILED
go build ./... && go vet ./...
go test ./... 스킵 0건
go run ./scripts/glyphscan.go
golangci-lint run ./... 새 파일과 고친 파일에 0건
커버리지 게이트 (.coverage-contract.yml)
go test -v ./internal/boundary 2 checked, 2 skipped
라우트 18개 회귀 0
화면 S1 에 QUEUED 행이 보이고 어느 카드에도 안 얹혀 있다
빨간 것이 남으면 U-drain 에 안 들어간다.