문서와 심볼

aidlc-docs 를 그대로 읽는다 · 읽기 전용
/home/sunny/enode-fixup-workshop2-visualizer연결 중
construction/u-queue/functional-design/business-rules.md360 줄

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 에 안 들어간다.