U-바닥 — 흐름과 질의
타입은 domain-entities.md 에 있다. 이 문서는 그 타입을 채우는 절차다.
아래 SQL 은 전부 실제 PostgreSQL 17 에서 돌려 확인했다. 스키마를 그대로
올리고 nodes.drain 을 더한 뒤 픽스처를 넣어 결과를 봤다. 확인한 것은 절마다
적었다.
1. 이 유닛이 내는 것
internal/store/list.go NEW ListNodes · ListRuns
internal/store/queue.go NEW StateQueued · DrainingNodes · ListQueued
internal/store/store.go o UpsertAdvert 가 이전 정책을 돌려준다
internal/store/observe.go o Steps 가 chosen 을 싣는다
internal/store/schema.sql o nodes.drain 한 열
internal/contract/advert.go o Policy 타입 · Advert.Policy
internal/match/match.go o Reject 에 json 태그 셋
internal/boundary/imports_test.go NEW 경계 검사
2. UpsertAdvert — 이전 정책을 한 문장으로 돌려준다
2.1 왜 이전 값인가
U-drain 의 해제 감지가 이 값 하나로 닫힌다. 광고 처리는 「지금 정책이
무엇인가」가 아니라 「방금 풀렸는가」를 알아야 WakeQueued 를 부를 수 있다
(decisions.md 1절 깨우기 행의 여섯째).
이전 지금 판정
─────────── ─────────── ────────────────────────────────
"" "" 아무 일 없음
"" graceful 걸렸다. 후보에서 빠진다
graceful graceful 걸린 채로 유지
graceful "" 풀렸다. WakeQueued 를 부른다
at-boundary "" 풀렸다. WakeQueued 를 부른다
2.2 한 문장으로 얻는다
UPDATE 는 새 값을 남기므로 RETURNING 만으로는 이전 값을 못 본다. CTE 가
같은 스냅샷을 본다는 규칙을 쓴다 — WITH 의 하위 문장은 본 문장의 효과를
못 보고 전부 같은 스냅샷을 쓴다.
WITH prev AS (SELECT drain FROM nodes WHERE node_id = $1)
INSERT INTO nodes (node_id, label, principal, capabilities, expires_at,
seen_at, instance, drain)
VALUES ($1,$2,$3,$4, now() + $5::interval, now(), $6, $7)
ON CONFLICT (node_id) DO UPDATE SET
label = EXCLUDED.label, principal = EXCLUDED.principal,
capabilities = EXCLUDED.capabilities,
expires_at = EXCLUDED.expires_at, seen_at = now(),
instance = EXCLUDED.instance, drain = EXCLUDED.drain
RETURNING coalesce((SELECT drain FROM prev), '')
확인했다 — 같은 노드에 세 번 연달아 넣어 '' -> graceful ->
at-boundary 순서로 이전 값이 나왔고, 마지막에 열의 값은 '' 였다.
트랜잭션 둘로 쪼개지 않는 이유 — SELECT ... FOR UPDATE 다음에 UPSERT 를
두면 왕복이 둘이 되고 행 잠금이 는다. 광고는 노드마다 갱신 주기마다 오는
가장 잦은 쓰기라 그 비용이 함대 크기에 곱해진다.
func (s *Store) UpsertAdvert(ctx context.Context, a contract.Advert,
principal string, ttl time.Duration) (contract.Policy, error)
기존 호출자는 internal/api/api.go 의 postNodes 하나다. 반환이 둘이 되므로
그 줄이 바뀌는데, 이 유닛은 이전 정책을 버리는 형태로 고친다 — 값을 읽어
쓰는 것은 U-drain 의 일이다.
if _, err := s.st.UpsertAdvert(r.Context(), a, principal(r), ttl); err != nil {
unit-boundaries.md §2 의 파일 행렬에 api.go 가 U-바닥 열에 없다. 이 한
줄만큼 어긋난다 — 시그니처를 바꾸면 부르는 자리가 안 컴파일되기 때문이고,
등록 줄도 핸들러 로직도 아닌 반환값 자리 하나다. 행렬의 뜻(「등록 줄만
만진다」)은 그대로다.
3. ListNodes
3.1 질의
SELECT n.node_id, n.label, coalesce(n.instance,''), n.capabilities,
n.seen_at, n.expires_at, n.drain,
l.run_id, l.not_after
FROM nodes n
LEFT JOIN leases l ON l.node_id = n.node_id
WHERE n.expires_at > now()
ORDER BY n.node_id
leases.node_id 가 PRIMARY KEY 라 조인이 1:1 을 못 넘는다 — 행이 불어나지
않는다. LEFT 인 것은 임대 없는 노드도 함대에 있기 때문이다.
확인했다 — 만료된 노드가 목록에서 빠지고, 임대 있는 노드만 run_id 와
not_after 가 채워졌으며, drain 이 실려 나왔다.
nonce 를 안 고른다. principal 도 안 고른다. 고르지 않는 것이 안 내는
방법이다 — 구조체에서 빼는 것만으로는 나중에 누가 필드를 더할 때 다시
새어 나온다.
3.2 observed_at 을 먼저 읽는다 — 순서가 곧 정확성이다
맞는 순서 SELECT now() -> T0
행 질의 -> expires_at > T1 (T1 >= T0)
결과의 모든 행이 expires_at > T1 >= T0 이므로
expires_at - observed_at 이 반드시 양수다
틀린 순서 행 질의 -> expires_at > T1
SELECT now() -> T2 (T2 >= T1)
expires_at 가 (T1, T2] 안인 행이 통과하는데 관측 시각으로는
이미 만료다. 화면이 음수 카운트다운을 그린다
한 문장으로 합칠 수 없다 — 행이 0개면 now() 도 안 나온다. 그래서 두
문장이고, 순서가 규칙이다.
같은 규칙을 ListRuns 에도 건다. 저쪽은 만료가 없어 음수가 안 나지만 두
조회가 다른 규칙을 쓰면 언젠가 한쪽만 바뀐다.
4. ListRuns
4.1 질의
SELECT r.run_id, r.state, r.verdict, coalesce(r.work_id,''),
r.created_at, r.ended_at, r.assigned, r.reject,
coalesce(
(SELECT jsonb_agg(jsonb_build_object(
'node', coalesce(s.node_id,''),
'step', s.name,
'attempt', s.attempt,
'started_at', s.started_at)
ORDER BY s.seq)
FROM steps s
WHERE s.run_id = r.run_id AND s.state = 'CLAIMED'), '[]'::jsonb)
FROM runs r
WHERE ($1 = '' OR r.state = $1)
AND ($2 = '' OR r.work_id = $2)
AND ($3::timestamptz IS NULL OR r.created_at > $3)
ORDER BY r.created_at DESC, r.run_id DESC
LIMIT $4
확인했다 — 폭 2 인 Run 에서 current 가 CLAIMED 단계 둘을 seq 순서로
냈고 각각 다른 노드였다. CLAIMED 가 없는 Run 은 [] 를 냈다.
4.2 current[] 를 조인이 아니라 상관 부질의로 뽑는 이유
steps 를 JOIN 하면 CLAIMED 단계 수만큼 Run 행이 불어나고, 그러면
LIMIT 이 Run 이 아니라 단계를 센다 — 100 을 요청했는데 Run 이 60 개
나온다. 부질의는 행 수를 안 바꾼다.
N+1 이 아니다 — 부질의가 LIMIT 만큼만 돌고(최대 1000), steps 의
PRIMARY KEY (run_id, seq) 가 run_id 로 시작해 인덱스로 닿는다.
4.3 정렬의 동점을 가른다
created_at DESC 만으로는 같은 밀리초에 들어온 Run 둘의 순서가 실행마다
달라질 수 있다. run_id DESC 를 뒤에 붙인다 — match.Match 가 동점을
node_id 로 가르는 것과 같은 이유다 (ADR-014 결정 3, 같은 입력이면 같은
출력).
4.4 jsonb 로 나온 시각을 Go 가 읽는다
PostgreSQL 의 to_json 이 timestamptz 를 항상 ISO 8601 로 낸다 —
2026-09-04T14:41:17.775932+00:00. Go 의 json.Unmarshal 이 그대로
time.Time 으로 읽는 것을 확인했다 (RFC3339 의 수치 오프셋 형태다).
DateStyle 설정에 안 흔들린다.
5. DrainingNodes
SELECT node_id FROM nodes WHERE drain <> '' AND expires_at > now()
반환은 map[string]bool — match.Match 의 busy 와 같은 모양이라
submit 이 두 map 을 합쳐 그대로 넘긴다.
expires_at 을 함께 거르는 이유 — 후보 목록이 LiveAdverts 에서 오므로
만료된 노드를 busy 에 넣어도 아무것도 안 바뀐다. 같은 기준을 쓰는 것이
두 목록이 갈리지 않게 한다.
확인했다 — draining 이지만 만료된 노드가 빠지고 살아 있는 것만 나왔다.
6. ListQueued
SELECT run_id, coalesce(contract_versions -> -1, contract), created_at
FROM runs WHERE state = 'QUEUED' ORDER BY created_at, run_id
ORDER BY created_at 이 FIFO 다 (decisions.md 1절 — 우선순위는 이월,
FIFO 만). run_id 는 동점을 가른다.
계약을 liveContract 규칙으로 읽는다 — QUEUED Run 은 아직 안 돌았으므로
contract_versions 가 비어 contract 가 나온다. 그래도 같은 조각을 쓰는
것은 규칙이 저장소에 한 벌만 있게 하기 위해서다. 확인했다 — 빈
contract_versions 에서 -> -1 이 NULL 을 내고 coalesce 가 제출 전문을
골랐다.
이 유닛은 WakeQueued 를 안 만든다. 같은 파일에 U-대기열이 더한다.
7. Steps 에 chosen 을 싣는다
SELECT seq, name, state, uses, coalesce(node_id,''), needs, attempt,
started_at, ended_at, chosen
FROM steps WHERE run_id = $1 ORDER BY seq
열이 이미 있으므로 SELECT 목록과 Scan 인자에 하나씩 더하는 일이다.
GET /v1/runs/{id} 의 응답에 chosen 이 실려 나가고, 그것이 FR-3 이다.
8. 경계 검사기 — internal/boundary/imports_test.go
8.1 무엇을 세는가
internal/panel -> internal/store 금지. 제어판은 HTTP 로 Mediator 를 본다
internal/panel -> internal/api 금지
internal/api/ui -> internal/store 금지. 화면은 정적 파일이다
internal/enode -> internal/panel 금지. 데몬은 제어판을 모른다
표는 Go 슬라이스 리터럴 하나로 둔다. 패키지 이름이 바뀌면 표도 같이 바꾼다.
8.2 절차
1 go.mod 에서 module 줄을 읽어 모듈 경로를 얻는다
적어 두지 않는다 — 같은 사실이 두 곳에 살면 한쪽이 낡는다.
ci.yml 의 스킵 감시가 go list -m 에게 묻는 것과 같은 이유다
2 규칙마다 from 디렉터리가 있는지 본다
없으면 t.Logf 로 "없음 — 검사 안 함" 을 적고 다음 규칙으로 간다.
통과가 아니다. 없는 것을 통과로 세면 검사기가 항진명제가 된다
3 있으면 그 디렉터리와 그 아래 전부를 걷는다
internal/panel 규칙이 internal/panel/ui 도 덮는다
4 .go 파일마다 go/parser 를 parser.ImportsOnly 로 돌려 임포트만 읽는다
_test.go 도 읽는다 — 테스트가 store 를 임포트하면 그 패키지는
DB 없이 못 돌고, decisions.md 2절이 두 패키지를 "DB 없이 도는
테스트로 채운다" 로 정한 것이 깨진다
5 임포트 경로에서 모듈 경로를 벗겨 to 와 맞춰 본다
to 자신과 그 아래를 전부 금지로 센다
6 끝에 요약을 적는다 — 검사한 규칙 수 · 건너뛴 규칙 수
8.3 t.Skip 을 쓰지 않는다
.ci-allowed-skips 의 규칙 "CI 로 그 조건을 갖출 수 없는 스킵" 만 목록에 오른다
여기의 조건 "internal/panel 이 아직 없다" 는 U-제어판이 갖춘다
결과 목록에 못 오르고, 목록에 없는 t.Skip 은
ci.yml 의 「스킵 감시 (전 패키지)」가 위반으로 센다
그래서 통과시키고 t.Logf 로 적는다. 그런데 go test ./... 는 통과한
패키지의 t.Logf 도 fmt.Println 도 안 보인다 — 실측했다. 그래서 CP0
게이트 명령에 한 줄을 더한다.
go test -v ./internal/boundary
8.4 이 패키지가 게이트를 안 깬다 — 실측
go build ./... exit 0. 테스트 파일만 있는 패키지를 건너뛴다
go vet ./... exit 0
go test ./... ok. 결과를 내므로 스킵 감시의 want 계산에 맞는다
커버리지 문장이 0 이라 프로파일에 그 패키지가 아예 안 나오고,
ci.yml 의 하한 검사는 프로파일에 나온 패키지만 센다
9. 흐름 하나로
광고가 온다
postNodes -> UpsertAdvert(a) a.Policy.Drain 이 nodes.drain 으로
<- 이전 정책 U-drain 이 이 값으로 해제를 감지한다
제출이 온다
submit -> LiveAdverts + BusyNodes DrainingNodes 는 U-대기열이 합친다
-> match.Match 이 유닛은 Reject 의 태그만 만진다
현황판이 5초마다
GET /v1/nodes -> SELECT now() 먼저. T0
-> ListNodes 만료 안 된 광고 + 임대 + drain
GET /v1/runs -> SELECT now()
-> ListRuns 필터 넷 + current[] + reject
임대가 풀린다
WakeQueued -> ListQueued FIFO. 이 유닛은 목록만 낸다
승격은 U-대기열
ListNodes · ListRuns 를 부르는 라우트는 이 유닛에 없다. U-관측API 가
internal/api/list.go 에서 부른다.