U-대기열 Functional Design — 계약 질문 넷
이 유닛은 기존의 뜨거운 경로 둘을 고친다 — 모든 제출이 지나는 submit 과
모든 단계 보고가 지나는 postResult 다. 아래 넷은 그 둘의 모양을 가르는데
어느 문서도 값을 안 준 자리다. 나머지는 2절에 근거를 적고 닫았다.
계획 파일을 안 만들었다. 규칙 Step 4 는
construction/plans/{unit}-functional-design-plan.md 를 요구하지만 사용자
지시가 「계획 파일은 Units Generation 과 Code Generation 에서만 만든다」이다.
이 단계의 계획은 3절 하나로 족하다 — U-바닥에서와 같은 처리다.
딛는 것은 U-바닥이 이미 냈다. store.StateQueued · store.ListQueued ·
store.DrainingNodes 가 internal/store/queue.go 에 있고, ValidState 가
QUEUED 를 이미 받는다 (internal/store/list.go:38). 이 유닛이 하는 것은
그 어휘를 가진 행을 실제로 만들고 다시 움직이는 것이다.
1. 질문 넷
권장을 A 로 뒀다. [Answer]: 를 채워 주면 그 값으로 산출물 셋을 낸다.
Q1. 승격이 어느 층에 사는가
requirements.md §4.1 이 store.WakeQueued(ctx) error 로 시그니처를
고정했다. 고정되지 않은 것은 그 안이 얼마나 하느냐다 — 매칭 조합이 store
에 사는가, api 에 남는가.
A. store.WakeQueued 가 승격을 통째로 진다. (권장)
store.WakeQueued(ctx)
ListQueued created_at 오름차순
LiveAdverts + 못 쓰는 노드 후보와 제외 목록을 한 번 읽는다
Run 하나마다 독립 트랜잭션
match.Match -> 폭 상한 -> 임대 발급 -> runs.state = RUNNING
assigned 채우기 -> steps.node_id 채우기
runAcquires -> raiseAsks
실패하면 로그를 남기고 다음 Run 으로
internal/api/queue.go 깨우기 호출 · 로그 · 202 응답 조립만 진다
label 을 internal/api/api.go:849 에서 internal/store 로 옮긴다 — 승격도
제출도 []match.Assignment 를 []store.Assigned 로 옮겨야 하고, 두 벌이
되면 Record 의 노드 이름이 경로마다 갈린다.
B. 조합을 internal/api 가 계속 쥔다. Store 에 승격 함수를 필드로 주입한다
(AnswerPath 와 같은 방식) 하고 WakeQueued 는 목록을 돌며 그것을 부른다.
A 인 이유 셋:
Reap 이 깨우기 지점이다 그 자리는 store 안이고 api 를 모른다. A 면 s.WakeQueued(ctx)
한 줄이다. B 면 store 가 자기 필드로 나갔다 오는 왕복이 되고,
필드를 안 배선한 테스트에서 조용히 아무것도 안 한다
이름과 소유가 갈리지 않는다 B 의 WakeQueued 는 이름만 store 의 것이다
매처는 그대로 순수하다 패키지 주석의 「매칭 로직은 여기 없다」가 뜻하는 것은
ADR-014 결정 3 의 순수 함수다. A 는 match.Match 를 부르기만
하고 안 고친다. store 는 이미 internal/match 를 임포트한다
(Run.Reject 가 그 타입이다)
A 의 값은 조합이 두 자리에 생긴다는 것이다. 겹치는 입력만 store 메서드
하나로 빼고 나머지는 안 합친다 — 근거는 2절 ⑤ 다.
Q2. 깨우기가 동기인가 비동기인가
깨우기 지점이 뜨거운 경로에 붙는다. postResult 는 모든 단계 보고가 지난다.
A. 동기다. 그 자리에서 WakeQueued 를 부르고, 오류는 로그만 남긴다.
응답 코드도 본문도 안 바뀐다. (권장)
B. 고루틴으로 띄운다.
A 인 이유:
게이트가 그것을 요구한다 CP2 의 「a 가 끝난 뒤 runctl status <b> -> RUNNING」은
B 에서 경쟁이다. 간헐적으로 빨간 게이트는 게이트가 아니다
이미 무거운 것이 동기다 postResult 는 SettleIfDone 을 동기로 부른다 — 판정 · 함대
조회 · 봉인 · 파일 쓰기가 그 안에 있다. WakeQueued 는 그보다 가볍고,
대기열이 비면 질의 하나다
ctx 가 문제다 B 는 r.Context() 를 쓸 수 없다 — 응답이 끝나면 취소되므로
승격이 중간에 끊긴다. context.Background() 로 피하면
main 의 종료 순서에 기다릴 것이 하나 더 붙는다
Q3. 어느 409 가 QUEUED 가 되는가
제출 경로에서 409 를 내는 자리가 둘이다.
internal/api/api.go:419 match.Match 의 Reject{Code: CodeAllBusy}
후보는 있는데 전부 점유됨
internal/api/api.go:474 CreateRun 의 ErrNodeTaken
"another run took the node during allocation"
배정 도중 다른 Run 이 노드를 가져갔다 (I5 롤백)
A. 둘 다 QUEUED 다. 제출 경로에서 409 가 사라진다. (권장)
B. 매처의 409 만 QUEUED 다. 경쟁 패배는 그대로 409 를 낸다.
A 인 이유 — 완료 조건이 「409 가 QUEUED 가 된다」이고 B 면 그 문장이
절반만 참이다. 호출자는 두 409 를 구별할 수단이 없다: 둘 다 「지금은 자리가
없다」이고 둘 다 다시 내면 되는 것이다. 대기열을 넣은 이유가 그 재시도를
호출자에게서 걷어오는 것인데, B 는 경쟁에서 진 제출만 호출자에게 남긴다.
ErrNodeTaken 은 트랜잭션이 통째로 롤백되므로 손으로 풀 것이 없고, 그 자리에서
QUEUED 로 다시 만드는 것이 안전하다.
A 에서도 폭 상한 거절은 그대로 FAILED 다 — 그것은 CodeNoCandidate
(422) 이고 「다시 내도 같다」이기 때문이다.
Q4. Reap 의 어느 자리에서 깨우는가
완료 조건에 두 문장이 함께 있다 — 「깨우기가 다섯 지점에 있다 (Reap 포함)」과
「reaper 주기에 안 얹었다」다. Reap 은 RunReaper 가 RenewSeconds
마다 부른다 (cmd/mediator/main.go:87).
A. Reap 이 실제로 무언가를 놓았을 때만 깨운다. (권장)
되묻기 만료로 정산된 Run 이 있거나 만료 임대 회수가 행을 건드렸으면 Reap
끝에서 한 번. 아무것도 안 놓은 스캔은 대기열을 안 본다.
B. Reap 끝에서 무조건 한 번.
A 인 이유 — B 는 회수할 것이 없는 스캔마다 ListQueued 를 돌리므로
사실상 주기 폴링이고, 완료 조건의 둘째 문장과 어긋난다. A 는 「자원이
풀렸다」는 사건에 붙는다 — 나머지 지점 넷과 같은 성질이다.
2. 묻지 않고 닫은 것 열
질문 후보였다가 문서와 코드 대조로 닫힌 것이다.
① 202 의 본문은 runView 다. State 가 QUEUED · Steps 전부
PENDING 이고 node 가 빈 값 · Assigned 없음 · Reject 없음 · Warnings
는 201 과 같게 싣는다. requirements.md §3.1 이 GET /v1/runs/{id} 의
모양을 그렇게 고정했고, 제출 응답과 조회 응답이 다르면 읽는 쪽이 둘을 안다.
② 임대 TTL 은 Store 필드로 받는다. §4.1 이 WakeQueued(ctx) error 로
인자 없는 모양을 고정했으므로 승격이 쓸 not_after 를 인자로 못 받는다.
MaxLeasesPerRun 이 이미 같은 방식으로 배선돼 있다 (cmd/mediator/main.go
의 openRecords) — 새 방식을 만들지 않고 LeaseTTL time.Duration 을 그 옆에
둔다.
③ Records.Open 은 제출 시점이다. §3.1 이 못 박았다. Cancel 이
sealRecord 를 부르므로 (internal/store/reap.go:207) 대기 중 취소가 봉인까지
닿으려면 그때 이미 열려 있어야 한다.
④ 대기 중 취소는 코드를 안 고쳐도 된다. Cancel 이
state NOT IN ('SUCCEEDED','FAILED') 로 거르므로 QUEUED 가 그대로 걸리고,
steps 를 PENDING 에서 FAILED 로 옮기고, 없는 임대를 지우고, 봉인한다.
읽고 확인했다.
⑤ submit 과 WakeQueued 를 한 함수로 안 합친다 — 입력만 합친다.
겹치는 것은 「후보 목록과 못 쓰는 노드를 읽는 것」이고 그것만 store 메서드
하나로 뺀다 (BusyNodes 와 DrainingNodes 의 합집합. FR-11 의 중앙 쪽이다).
나머지는 다르다 — submit 은 새 행을 만들고 HTTP 갈래 여섯을 가르고 dry-run
을 겸하며, WakeQueued 는 있는 행을 옮긴다. 합치면 인자 플래그가 셋 붙는다.
⑥ dry-run 은 안 바뀐다 (NFR-I6). 지금 dry-run 은 busy 를 아예 안 읽는다
(internal/api/api.go:402 의 if !dry). draining 을 거기 합치면 응답이
바뀌므로 안 합친다. 합치는 것은 dry == false 갈래 하나다.
⑦ 승격 시 폭 상한을 다시 검사하되, 넘으면 그 Run 을 실패시키지 않는다.
t=0 의 422 가 영구인 이유는 「다시 내도 같다」인데, 승격 시점의 초과는 그때
고른 노드 조합의 문제라 다음 깨우기에 달라질 수 있다 — 한 노드가 두 역할을
맡으면 셈이 줄기 때문이다 (api.go 의 seen 이 그 중복을 접는다). 로그에
남기고 QUEUED 로 둔다.
⑧ QUEUED Run 의 steps.node_id 는 빈 문자열이다. §3.1 이 정했고,
ClaimStep 이 WHERE s.node_id = $1 로 자기 몫만 보므로 빈 값인 단계는 아무
노드도 못 집는다. 승격이 그 열을 채우는 것이 곧 배분이다 —
internal/store/claim.go 의 「당기기가 이미 배분이다」가 그대로 성립한다.
⑨ work_id 는 제출 시점에 박는다. CreateRun 이 하는 그대로다
(internal/store/store.go:323). Run 을 넘어 사는 유일한 식별자라 대기 중에도
있어야 현황판이 같은 Work 의 Run 둘을 잇는다.
⑩ GET /v1/runs 도 runctl 도 안 고친다. 목록 쪽은 U-관측API 가
state 필터를 이미 냈고 QUEUED 는 그 어휘의 값 하나다. runctl 쪽은 읽고
확인했다 — internal/runctl/client.go:70 이 StatusCode/100 == 2 로 받아
202 가 통과하고, Terminal 이 SUCCEEDED 와 FAILED 뿐이라 (client.go:208)
--wait 도 QUEUED 에서 계속 폴링한다.
3. 답을 받으면 낼 것
[ ] Q1 ~ Q4 의 답을 받는다
[ ] 답에 모호한 것이 있으면 후속 질문을 낸다. 없으면 그대로 간다
[ ] domain-entities.md QUEUED Run 의 행 모양 · 새 Store 필드 · 옮기는 함수
[ ] business-logic-model.md 제출 갈래 · 승격 절차 · 깨우기 자리와 그 근거
[ ] business-rules.md 규칙 · 완료 조건 · 테스트 목록 · CP2
frontend-components.md 를 안 낸다. 이 유닛에 화면이 없다 — S1 의
QUEUED 칩과 행은 U-관측API 가 이미 그렸고 지금 빈 목록을 낸다. 이 유닛이
하는 것은 그 목록에 행이 생기게 하는 것이다.
4. 코드를 읽고 찾은 정정 하나
「부분 반납 뒤 깨우기」는 독립된 호출 자리가 아니다.
unit-of-work.md 의 U-대기열 파일 행렬이 internal/store/release.go 를
「부분 반납 뒤 깨우기」로 적었다. 그 자리에서는 깨울 수 없다.
ReportStep tx, err := s.pool.Begin(ctx) claim.go:773
afterStep applyStepEffects claim.go:843
applyStepEffects applyRelease claim.go:863
applyRelease DELETE FROM leases (tx 안) release.go:73
ReportStep tx.Commit(ctx) claim.go:823
applyRelease 는 ReportStep 의 트랜잭션 안에서 돈다. 거기서 깨우면
아직 커밋 안 된 임대 해제를 보고 승격하려 들고, 승격의 트랜잭션이 같은 행을
기다린다. 자리는 커밋 뒤 — 즉 postResult 다.
그래서 postResult 의 깨우기 한 번이 「완료」와 「부분 반납」 둘을 덮는다.
딸린 조건이 하나 붙는다: SettleIfDone 이 빈 문자열을 내도(아직 진행 중이라는
뜻이다) 깨워야 한다. 안 그러면 부분 반납으로 풀린 노드를 아무도 안 본다.
파급 — internal/store/release.go 를 안 고친다. 이 유닛이 만지는 파일이
여섯에서 다섯으로 준다. 깨우기 지점은 여전히 여섯이고 (requirements.md
FR-9), 호출 자리가 다섯인 것뿐이다.
postResult 완료 · 부분 반납 api.go
postCancel 취소 api.go
Reap 회수 · 되묻기 만료 store/reap.go
postNodes 노드 재기동 감지 api.go
postNodes drain 해제 감지 api.go U-drain 이 단다
run() 기동 시 한 번 cmd/mediator/main.go