U-대기열 — 타입과 자리
이 유닛은 스키마를 한 줄도 안 바꾼다. 새 열 0 · 새 테이블 0 · 새 인덱스
0 · 새 라우트 0 이다. runs.state 에 QUEUED 가 실제로 들어가고, 그 행이
다시 RUNNING 으로 움직이는 것이 전부다.
질문 넷의 답은 A · A · A · A 다 (functional-design-questions.md).
읽는 순서 — 이 문서가 무엇이 어떤 모양으로 있는가이고, 절차는
business-logic-model.md, 규칙과 완료 조건은 business-rules.md 에 있다.
1. 스키마 — 안 바꾼다
1.1 QUEUED 는 마이그레이션이 아니다
runs.state 에 CHECK 가 없다 (schema.sql). 어휘가 늘 때 마이그레이션을
강요하지 않기로 한 결정이 여기서 값을 낸다. U-바닥이 store.StateQueued
상수와 ValidState 의 다섯째 값을 이미 놓았다 (internal/store/list.go:38).
1.2 인덱스를 안 더한다
ListQueued 가 WHERE state = $1 ORDER BY created_at, run_id 로 훑는다.
인덱스를 더하지 않는 근거는 U-바닥의 domain-entities.md §1.4 와 같다 —
runs 는 v1 규모에서 작고, 대기열은 그중에서도 부분집합이며, 훑기는 자원이
풀린 사건에만 돈다(주기가 아니다). 느려지는 것이 보이면 그때 재는 것이
지금 짐작해서 거는 것보다 낫다.
2. QUEUED Run 의 행 모양
제출 시점에 이 모양으로 완성된다. 승격이 채우는 것은 아래 표의 굵은
칸 넷뿐이다.
| 테이블 | 열 | QUEUED 일 때 |
승격 뒤 |
|---|---|---|---|
runs |
state |
QUEUED |
RUNNING |
contract |
제출 전문 | 그대로 | |
contract_versions |
[] |
그대로 | |
principal |
제출자 | 그대로 | |
work_id |
계약의 work 에서 유도 |
그대로 | |
assigned |
NULL |
[{as, nodes:[{node,label}]}] |
|
reject |
NULL |
NULL |
|
verdict · ended_at |
NULL |
NULL |
|
created_at |
제출 시각. FIFO 의 기준이다 | 그대로 | |
steps |
state |
전부 PENDING |
그대로 |
node_id |
빈 문자열 | 역할의 노드 | |
kind · needs |
제출 시점에 확정 | 그대로 | |
attempt |
0 |
그대로 | |
started_at · ended_at · result |
NULL |
그대로 | |
chosen |
false |
그대로 | |
leases |
행이 없다 | 역할이 쥔 노드마다 하나 | |
| Record | 디렉터리 | 열려 있다 | 그대로 |
세 가지가 이 표에서 곧바로 따라 나온다.
① 아무 노드도 못 집는다. ClaimStep 이 WHERE s.node_id = $1 로 자기
몫만 보므로 빈 문자열인 단계는 어느 노드의 질의에도 안 걸린다.
승격이 그 열을 채우는 것이 곧 배분이다 — internal/store/claim.go 의
「당기기가 이미 배분이다」가 그대로 성립하고, 배분 정책은 여전히 0 개다.
② Reap 이 안 쓸어간다. 회수 UPDATE 가
run_id IN (SELECT run_id FROM leases WHERE not_after <= now()) 로 걸러지는데
QUEUED 는 임대를 안 쥐므로 그 부분질의에 안 들어온다. 뒤이은 두 문장도
state='FAILED' 이거나 종료 상태만 본다. 대기 상한이 없다는 결정이 코드에서
이미 참이다.
③ 대기 중 취소가 봉인까지 닿는다. Record 가 제출 시점에 열려 있고
Cancel 이 state NOT IN ('SUCCEEDED','FAILED') 로 거르므로 QUEUED 가
그대로 걸린다. steps 를 PENDING 에서 FAILED 로 옮기고, 없는 임대를
지우고, internal/store/reap.go:207 에서 봉인한다. 코드를 안 고친다.
3. 새로 생기는 자리
3.1 Store 필드 하나 — LeaseTTL
requirements.md §4.1 이 store.WakeQueued(ctx) error 로 인자 없는
시그니처를 고정했다. 승격이 발급할 임대의 not_after 를 인자로 못 받으므로
배선으로 받는다.
type Store struct {
// ...
// MaxLeasesPerRun 은 한 Run 이 동시에 쥘 수 있는 노드 수다 (ADR-024 §4.2).
MaxLeasesPerRun int
// LeaseTTL 은 승격이 발급하는 임대의 수명이다.
//
// 인자가 아니라 필드인 이유 — WakeQueued 는 인자를 안 받는다.
// 부르는 자리가 여섯이고 그중 둘(Reap · 기동)은 설정을 손에 안 쥐고 있다.
// 0 이면 승격을 안 한다 — 조용히 무한 임대를 발급하는 것보다 낫다.
LeaseTTL time.Duration
}
MaxLeasesPerRun 이 이미 같은 방식으로 배선돼 있다 (cmd/mediator/main.go
의 openRecords). 새 방식을 만들지 않고 그 옆에 둔다.
값은 cfg.Lease.TTLSeconds 다 — submit 이 notAfter 를 계산하는 데 쓰는
그 값이고, 두 경로가 같은 수명을 발급해야 한다.
3.2 옮기는 함수 하나 — label
Q1 의 답 A 가 승격을 store 에 둔다. 승격도 제출도 []match.Assignment 를
[]store.Assigned 로 옮겨야 한다.
지금 internal/api/api.go:849 func label(as, adverts) []store.Assigned
뒤 internal/store/list.go func Label(as, adverts) []Assigned
두 벌로 두면 Record 의 노드 이름이 경로마다 갈린다. label 이 붙이는
것은 사람이 읽는 이름이고, 그것이 없으면 봉인된 Record 에서 「서로 다른
기계였다」를 사람이 못 읽는다 (ADR-015 §2). 제출로 들어온 Run 과 승격으로
들어온 Run 이 그 점에서 다르면 안 된다.
internal/api 는 internal/store 를 이미 임포트하므로 부르는 쪽은
store.Label(...) 한 줄이 된다. 경계 검사기의 금지 넷에 이 방향은 없다.
3.3 새 함수 다섯
// internal/store/queue.go — U-바닥이 만든 파일에 이어 붙인다
func (s *Store) WakeQueued(ctx context.Context) error
func (s *Store) UnavailableNodes(ctx context.Context) (map[string]bool, error)
func (s *Store) promote(ctx context.Context, q QueuedRun,
adverts []contract.Advert, blocked map[string]bool) ([]string, error) // 비공개
// internal/store/store.go
func (s *Store) CreateQueuedRun(ctx context.Context, r Run, steps []contract.Step) error
func (s *Store) insertSteps(ctx context.Context, tx pgx.Tx, runID string,
steps []contract.Step, nodeOf map[string]string) error // 비공개
// internal/api/queue.go — 신설
func (s *Server) wake(ctx context.Context, why string)
promote 가 []string 을 내는 것은 그 Run 이 쥔 노드다. 훑는 쪽이 같은
훑기 안에서 그 노드를 blocked 에 더해야 뒤의 Run 이 같은 노드를 다시
고르지 않는다. 기본키 충돌이 이중 배정을 막아 주긴 하지만, 그것은 마지막
방어선이지 설계가 아니다 — 헛도는 트랜잭션이 줄고 FIFO 가 뜻대로 돈다.
insertSteps 는 CreateRun 과 CreateQueuedRun 이 같이 쓴다. 그 안에
st.Kind() 검증과 contract.NeedsOf 정규화가 있고, 두 벌이 되면 제출
경로마다 다른 정규화가 걸린다.
UnavailableNodes 는 BusyNodes 와 DrainingNodes 의 합집합이다 (FR-11 의
중앙 쪽). submit 과 WakeQueued 가 같은 것을 봐야 한다 — 제출은 draining
노드를 후보에서 빼는데 승격은 안 뺀다면, 대기하던 Run 이 회수 중인 노드로
간다.
4. 와이어 — 202
{
"run_id": "…",
"state": "QUEUED",
"steps": [ { "seq": 1, "name": "…", "state": "PENDING", "node": "", … } ],
"warnings": [ "…" ]
}
runView 그대로다. assigned · reject · verdict 는 omitempty 라 빠진다.
제출 응답과 조회 응답이 같다. requirements.md §3.1 이
GET /v1/runs/{id} 의 모양을 이렇게 고정했고, 둘이 다르면 읽는 쪽이 형태를
둘 알아야 한다. warnings 는 201 과 같게 싣는다 — 경고는 계약 저자가 읽고
고칠 자리이고 (ADR-061 §2), 대기에 들어갔다고 안 보여 줄 이유가 없다.
5. 안 만드는 것
READY 상태 NFR-I2. ADR-064 §1.1 이 기각했다. QUEUED 하나만 는다
대기열 테이블 runs.state 가 대기열이다. 따로 두면 두 벌이 되고,
크래시 복구 검증 대상이 하나 는다
우선순위 열 · 정책 틀 constraints.md §2. 조건은 도착 순서 하나다
새 라우트 제출도 조회도 이미 있다. QUEUED 는 그 표면의 값 하나다
새 타입 LeaseGrant · Run · QueuedRun 을 그대로 쓴다
match.go 의 변경 NFR-I4. busy 는 부르는 쪽이 만들어 넘긴다 (ADR-014 결정 3)
runctl 의 변경 client.go:70 이 StatusCode/100 == 2 로 받아 202 가 통과하고,
Terminal 이 SUCCEEDED 와 FAILED 뿐이라(client.go:208)
--wait 도 QUEUED 에서 계속 폴링한다. 읽고 확인했다
internal/store/release.go applyRelease 는 ReportStep 의 트랜잭션 안이라 깨우기를
거기 못 둔다. 자리는 커밋 뒤인 postResult 다
(functional-design-questions.md 4절)
GET /v1/runs 의 변경 U-관측API 가 state 필터를 이미 냈다. 이 유닛은 그 값을
가진 행이 실제로 생기게 할 뿐이다