U-바닥 — 타입과 열
이 유닛은 라우트를 하나도 안 만든다. 끝난 시점에 밖에서 보이는 변화가
없고 기존 라우트 15개가 그대로 돈다는 것이 이 유닛의 성질이다
(unit-of-work.md). 그래서 이 문서가 내는 것은 화면도 응답도 아니라 다른
넷이 딛는 타입과 열이다.
질문 셋의 답은 A · A · A 다 (functional-design-questions.md).
그 값이 아래 1.2 · 3 · 4 에 들어 있다.
1. 스키마
1.1 nodes 에 정책 열 하나
-- drain 은 노드가 스스로 건 회수 정책이다 (ADR-063).
--
-- 정본은 노드의 파일이고 이 열은 광고가 나른 복사본이다 (decisions.md 1절).
-- 그래서 값이 없다는 것과 "" 를 가르지 않는다 — 광고는 델타가 아니라 매번
-- 전부이므로(ADR-012) 안 실린 것이 곧 안 걸려 있는 것이다.
--
-- capabilities 와 달리 jsonb 가 아니다. 저쪽이 jsonb 인 이유는 속성 어휘가
-- 창발하기 때문인데, 이 값은 셋으로 닫힌 열거다.
ALTER TABLE nodes ADD COLUMN IF NOT EXISTS drain text NOT NULL DEFAULT '';
값은 셋뿐이다.
"" 안 걸려 있다. 기본값
graceful 새 임대를 안 받는다. 도는 것은 그대로 둔다
at-boundary 도는 단계를 끝까지 두고 결과 보고 뒤 임대를 놓는다
1.2 runs.state 어휘에 QUEUED — 스키마 변경이 아니다
runs.state 에 CHECK 가 없다. steps.state 쪽 주석이 그 이유를 이미
적었다 — 「어휘가 늘 때 마이그레이션을 강요하지 않기 위해서다」. 그래서
QUEUED 는 ALTER 한 줄도 필요 없다. 스키마 파일은 안 건드린다.
1.3 steps.chosen 은 이미 있다
schema.sql 의 마지막 ALTER TABLE steps ADD COLUMN IF NOT EXISTS chosen boolean NOT NULL DEFAULT false 가 그것이다. FR-3 은 그 열을 뷰에 싣는
일이지 열을 만드는 일이 아니다.
1.4 인덱스를 안 더한다
DrainingNodes 와 ListQueued 가 새 조건으로 훑는다. 둘 다 안 더한다.
nodes PRIMARY KEY 하나짜리 표이고 함대가 이틀 안에 두 자리를 안 넘는다
runs.state runs_work_idx (work_id, created_at) 가 이미 있고 QUEUED 는
전체의 소수다. ListQueued 가 도는 것은 깨우기 여섯 지점뿐이다
수가 커지면 그때 더한다 — 페이지네이션을 안 넣은 것과 같은 조건이다
(decisions.md 2절).
2. 와이어 타입 — internal/contract
2.1 Policy
// Policy 는 노드가 스스로 건 회수 정책이다 (ADR-063).
//
// 정본은 노드의 파일이고 이것은 광고에 실려 오는 복사본이다. 중앙은
// 이 값으로 후보에서 빼고, 같은 값을 광고 응답으로 되돌려 준다 —
// 데몬이 파일을 직접 읽었는데도 응답의 값을 쓰는 이유는 Worker 가
// "Mediator 가 확인한 값" 으로 움직여야 하기 때문이다. 그러지 않으면
// 노드는 임대를 놓았는데 중앙은 아직 후보로 아는 창이 생긴다.
type Policy struct {
Drain string `json:"drain,omitempty"` // "" | "graceful" | "at-boundary"
}
panel_token 이 없다. 정책 파일에는 있지만(unit-boundaries.md §4)
그것은 기계 하나의 로컬 비밀이라 광고에 실으면 Mediator 의 DB 로 흘러간다.
파일 타입은 U-drain 의 internal/enode/policy.go 가 따로 갖는다 — 와이어
타입과 파일 타입이 다른 것이 여기서는 흠이 아니라 경계다.
2.2 Advert 에 필드 하나 (Q1 의 답 A)
type Advert struct {
NodeID string `json:"node_id"`
Label string `json:"label"`
Instance string `json:"instance,omitempty"`
Capabilities []Capability `json:"capabilities"`
// Policy 는 이 노드가 스스로 건 정책이다 (ADR-063).
//
// 광고에 실려 온다 — 중앙이 받는 라우트를 새로 만들지 않는 것이
// ADR-042 방향의 핵심이다. 중앙 라우트로 받으면 토큰만 있으면 남의
// 노드를 함대에서 뺄 수 있고, 소유의 증거가 "그 기계의 파일을 쓸 수
// 있다" 로 닫히지 않는다.
//
// 비어 있으면 안 걸려 있는 것이다. 광고는 델타가 아니라 매번 전부이므로
// (ADR-012) 안 싣는 것이 곧 푸는 것이 된다 — capability 를 빼는 것이
// "지금은 못 한다" 가 되는 것과 같은 기계다.
Policy Policy `json:"policy,omitempty"`
}
이 자리가 internal/api/api.go 를 안 건드리는 이유 — postNodes 가 본문
전체를 json.NewDecoder(r.Body).Decode(&a) 로 Advert 하나에 읽는다. 필드가
늘면 파싱이 저절로 받는다.
3. 조회 타입 — internal/store/list.go (신설)
Q2 의 답 A 다. store 가 페이지를 통째로 낸다.
3.1 노드
// LeaseView 는 관측된 임대다. store.LeaseRow 가 아니다.
//
// LeaseRow 에는 Nonce 가 있고 그것은 ADR-010 의 허가 아티팩트라 노드에게만
// 내려간다. 재사용하면 토큰을 가진 누구나 현황판에서 남의 nonce 를 읽는다.
// ADR-065 의 응답 예시가 둘뿐인 것이 그 뜻이다 — principal 을 안 내는 것과
// 같은 결정이다.
type LeaseView struct {
RunID string `json:"run_id"`
NotAfter time.Time `json:"not_after"`
}
// NodeView 는 GET /v1/nodes 의 노드 항목 하나다 (ADR-065 + draining).
type NodeView struct {
NodeID string `json:"node_id"`
Label string `json:"label"`
Instance string `json:"instance,omitempty"`
Capabilities []contract.Capability `json:"capabilities"`
SeenAt time.Time `json:"seen_at"`
ExpiresAt time.Time `json:"expires_at"`
// Lease 는 응답 시점에 저장돼 있던 사실이다 (ADR-065).
// 없다고 다음 제출에서 잡힌다는 뜻은 아니다.
Lease *LeaseView `json:"lease,omitempty"`
// Draining 은 납작하다. 광고 응답의 policy 와 이름이 다른 것은
// 저쪽이 왕복이고 이쪽이 관측이기 때문이다 (requirements.md §4.2).
Draining string `json:"draining,omitempty"`
}
type NodePage struct {
ObservedAt time.Time `json:"observed_at"`
Nodes []NodeView `json:"nodes"`
}
principal 이 없다. free · available · 순위 · 필터도 없다 (ADR-065 §2).
3.2 Run
// CurrentStep 은 지금 도는 단계 하나다 (requirements.md §8.4 ①-a).
//
// 배열인 이유 — 폭이 1 을 넘는 Run 에서 단수면 S1 카드가 남의 노드 단계를
// 그린다. CP3 의 계약이 "단계 둘 이상" 을 요구하므로 실제로 걸린다.
type CurrentStep struct {
Node string `json:"node"`
Step string `json:"step"` // steps.name = 계약의 steps[].id
Attempt int `json:"attempt"`
StartedAt *time.Time `json:"started_at,omitempty"`
}
// RunView 는 GET /v1/runs 의 한 행이다 (decisions.md 2절 + §8.4 ①).
//
// 본문이 없다 — StepView 와 같은 이유다. 목록에 판정의 근거까지 실으면
// "이걸로 충분한데 왜 Record 를 기다리나" 가 되고 그 순간 I4 가 형해화된다.
type RunView struct {
RunID string `json:"run_id"`
State string `json:"state"`
Verdict *Verdict `json:"verdict,omitempty"`
WorkID string `json:"work_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
Assigned []Assigned `json:"assigned,omitempty"`
Current []CurrentStep `json:"current,omitempty"`
Reject *match.Reject `json:"reject,omitempty"`
}
type RunPage struct {
ObservedAt time.Time `json:"observed_at"`
Runs []RunView `json:"runs"`
}
// RunFilter 는 GET /v1/runs 의 질의 인자다. 제로값이 "안 자른다" 다.
type RunFilter struct {
State string // "" 면 전체
Work string // 정확 일치
Since time.Time // 제로면 안 자른다. created_at 을 자른다 (Q3 의 답 A)
Limit int // 0 이면 100. 1000 을 넘으면 1000 으로 조인다
}
principal 이 없다 — ADR-065 와 같은 이유다 (decisions.md 2절).
Current 에 omitempty 가 붙었다. unit-of-work.md 가 「없으면 안 낸다」로
적었다 — CLAIMED 단계가 없는 Run(대기 · 종료)에 빈 배열을 매번 싣지 않는다.
3.3 대기열 — internal/store/queue.go (신설)
// StateQueued 는 배정을 못 받아 기다리는 Run 이다 (ADR-064).
//
// store.go 의 상수 블록에 안 둔다 — 그 블록의 주석이 "상태 이름은
// INVARIANTS §1.1 그대로다" 인데 이것은 ADR-064 가 더한 것이라 그 문장을
// 거짓으로 만든다. 같은 패키지이므로 읽는 쪽은 store.StateQueued 하나다.
//
// runs.state 에 CHECK 가 없어 마이그레이션이 필요 없다.
const StateQueued = "QUEUED"
// QueuedRun 은 승격 후보 하나다. U-대기열의 WakeQueued 가 이것을 훑는다.
//
// Contract 를 통째로 싣는 이유 — 승격은 match.Match(c.Requires, ...) 를
// 다시 부르는 일이고, 폭 상한 검사와 Work 키도 계약에서 나온다.
// Requires 만 실으면 부르는 쪽이 나머지를 다시 읽어야 한다.
type QueuedRun struct {
RunID string
Contract contract.Contract
CreatedAt time.Time
}
Principal 이 없다. 승격은 이미 있는 행의 UPDATE 라 INSERT 가 요구하던
값이 필요 없다.
4. 기존 타입에 붙는 것
4.1 StepView.Chosen — internal/store/observe.go
// Chosen 은 이 단계가 갈림길에 골라진 적이 있는가다 (ADR-060 §3).
//
// 비어 있어도 내보낸다 — Needs 와 같은 이유다. SKIPPED 하나로는
// "안 골랐다" 와 "골랐는데 못 닿았다" 가 구별되지 않고, 그 둘을 가르는
// 유일한 글자가 이것이다. 안 내보내면 읽는 쪽이 없음과 false 를 같게
// 봐야 하고, 그것은 추측이다.
Chosen bool `json:"chosen"`
omitempty 를 안 붙인다. 같은 구조체의 Attempt 가 붙인 것과 갈리는데,
attempt 는 0 이 「아직 안 돌았다」로 자명하고 chosen 은 없음과 false 가
같은 글자가 되기 때문이다.
4.2 match.Reject 의 태그 — internal/match/match.go
type Reject struct {
Code int `json:"code"`
As string `json:"as,omitempty"`
Reason string `json:"reason"`
}
Match 의 시그니처도 로직도 안 건드린다 — NFR-I4 가 지켜진다.
unit-boundaries.md §2 의 「아무 유닛도 안 만진다」가 이 세 줄만큼 바뀌고,
그 정정은 unit-of-work-dependency.md §3 이 이미 적었다.
As 에 omitempty 가 붙은 것은 폭 상한 거절이 As 를 안 채우기 때문이다
(api.go 의 MaxPerRun 분기가 Reject{Code, Reason} 만 만든다).
5. 열과 필드와 키
DB 열 Go 필드 json 키
────────────────────────────── ─────────────────────────── ────────────────
nodes.node_id NodeView.NodeID node_id
nodes.label NodeView.Label label
nodes.instance NodeView.Instance instance
nodes.capabilities NodeView.Capabilities capabilities
nodes.seen_at NodeView.SeenAt seen_at
nodes.expires_at NodeView.ExpiresAt expires_at
nodes.drain NodeView.Draining draining
leases.run_id LeaseView.RunID lease.run_id
leases.not_after LeaseView.NotAfter lease.not_after
leases.nonce (안 낸다) (없다)
nodes.principal (안 낸다) (없다)
runs.run_id RunView.RunID run_id
runs.state RunView.State state
runs.verdict RunView.Verdict verdict
runs.work_id RunView.WorkID work_id
runs.created_at RunView.CreatedAt created_at
runs.ended_at RunView.EndedAt ended_at
runs.assigned RunView.Assigned assigned
runs.reject RunView.Reject reject.{code,as,reason}
steps (CLAIMED 조인) RunView.Current current[]
runs.contract (안 낸다) (없다)
runs.principal (안 낸다) (없다)
steps.chosen StepView.Chosen chosen
now() NodePage/RunPage.ObservedAt observed_at
6. 안 만드는 것
라우트 하나도. 등록 줄은 U-관측API 의 몫이다
WakeQueued U-대기열. queue.go 파일은 이 유닛이 만들고 함수는 저쪽이 더한다
정책 파일 파서 U-drain 의 internal/enode/policy.go
페이지네이션 limit 뿐 (decisions.md 2절)
runs.state 의 CHECK 없던 것을 새로 만들지 않는다. 만들면 다음 어휘가
마이그레이션을 요구하게 된다
내부 관측 테이블 없다. 관측 기준은 DB 의 지금 행 하나다 (ADR-065 §3)