문서와 심볼

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

U-대기열 — 흐름과 절차

이 문서는 절차다. 타입과 자리는 domain-entities.md, 규칙과 완료 조건은
business-rules.md 에 있다.


1. 이 유닛이 내는 것

   제출     매칭이 409 를 내면 거절 대신 QUEUED 로 만들고 202 를 낸다
            422 는 그대로 FAILED 다.  뜻이 「다시 내도 같다」이기 때문이다

   승격     자원이 풀린 자리에서 대기열을 도착 순서로 훑고,
            지금 매칭되는 것을 전부 RUNNING 으로 옮긴다

   깨우기   호출 자리 다섯.  전부 「무언가가 풀렸다」는 사건에 붙고,
            주기에는 안 붙는다

2. 제출 — 갈래가 하나 는다

2.1 지금과 뒤

   지금
      Validate 실패                 400
      매처 422                      CreateRejectedRun  ->  FAILED  ->  422
      매처 409                      CreateRejectedRun  ->  FAILED  ->  409
      폭 상한 초과                   CreateRejectedRun  ->  FAILED  ->  422
      dry                           200 DRY_RUN
      CreateRun ErrNodeTaken        409
      CreateRun 성공                 201

   뒤
      Validate 실패                 400                            그대로
      매처 422                      CreateRejectedRun  ->  FAILED  ->  422   그대로
      매처 409                      CreateQueuedRun    ->  QUEUED  ->  202   바뀐다
      폭 상한 초과                   CreateRejectedRun  ->  FAILED  ->  422   그대로
      dry                           200 DRY_RUN                    그대로
      CreateRun ErrNodeTaken        CreateQueuedRun    ->  QUEUED  ->  202   바뀐다
      CreateRun 성공                 201                            그대로

제출 경로에서 409 가 사라진다 (Q3 의 답 A). 두 자리가 같은 뜻이었기
때문이다 — internal/api/api.go:419 의 매처 거절은 「후보는 있는데 전부
점유됨」이고, :474 의 ErrNodeTaken 은 「배정 도중 다른 Run 이 가져갔다」다.
호출자는 둘을 구별할 수단이 없고 둘 다 「지금은 자리가 없다」이다.

ErrNodeTaken 갈래가 안전한 이유 — 그 오류는 임대 기본키 충돌이고
CreateRun 의 트랜잭션이 통째로 롤백된 뒤에 온다. 행이 하나도 안 남아
있으므로
손으로 풀 것 없이 그 자리에서 QUEUED 로 다시 만들면 된다.

2.2 busy 에 draining 을 합친다 — FR-11 의 중앙 쪽

   지금   if !dry { busy = BusyNodes(ctx) }
   뒤     if !dry { busy = UnavailableNodes(ctx) }        // BusyNodes ∪ DrainingNodes

dry-run 갈래는 안 건드린다 (NFR-I6). 지금 dry-run 은 busy 를 아예 안
읽는다 (internal/api/api.go:402 의 if !dry). 거기에 draining 을 합치면
응답이 바뀌고, 그것이 「dry-run 응답이 안 바뀌었다」는 완료 조건을 깬다.

match.go 는 안 만진다 (NFR-I4). 매처는 자기가 받은 것이 점유인지 정책인지
몰라도 된다 — 후보에서 빠진다는 결과가 같다.

2.3 CreateQueuedRun

   BEGIN
     INSERT INTO runs (run_id, state, principal, contract, work_id)
       VALUES ($1, 'QUEUED', $2, $3, $4)          assigned 를 안 넣는다 (NULL)
     insertSteps(tx, run_id, steps, nodeOf = {})   전부 PENDING · node_id ''
     Records.Open(run_id)                          커밋 전에 연다
   COMMIT

CreateRun 과 갈리는 자리가 셋이다.

   임대를 안 만든다           쥘 것이 없다.  QUEUED 는 아무것도 안 쥔다

   runAcquires 를 안 부른다   자원을 잡는 일이다.  대기 중에 하면 「기다리는데 쥐고 있다」가 된다

   raiseAsks 를 안 부른다     사람을 부르는 일이다.  아직 돌지도 않는 Run 때문에
                             인박스에 질문이 서면 답하는 사람이 무엇을 보고 답하는지 모른다

갈리지 않는 자리 셋. steps 를 제출 시점에 넣는 것, Records.Open 을
커밋 전에 하는 것, work_id 를 제출 시점에 박는 것이다. 이유는 각각
requirements.md §3.1 과 store.go 의 기존 주석에 있다 — 계약의 구조
검증(st.Kind() · contract.NeedsOf)이 제출에 동기로 붙어야 하고, 커밋 뒤에
열다 실패하면 Run 은 있는데 호출자는 오류를 받아 상태가 갈리며, work_id
는 Run 을 넘어 사는 유일한 식별자다.

insertSteps 를 CreateRun 과 나눠 쓴다. 두 벌이 되면 제출 경로마다 다른
정규화가 걸린다
— needs 를 안 적은 계약이 한쪽에서만 「직전 단계」로
채워지면 같은 계약이 경로에 따라 다른 순서로 돈다.


3. WakeQueued — 훑기

3.1 절차

   LeaseTTL 이 0 이면 오류를 낸다        조용히 무한 임대를 발급하지 않는다

   queued  <- ListQueued(ctx)          created_at 오름차순.  비면 여기서 끝난다
   adverts <- LiveAdverts(ctx)         훑기마다 한 번
   blocked <- UnavailableNodes(ctx)    훑기마다 한 번

   for q in queued:
       ctx 가 끝났으면 멈춘다
       held, err = promote(q, adverts, blocked)
       err 면   로그를 남기고 다음 q          실패 격리
       held 를 blocked 에 더한다               같은 훑기 안의 뒤 Run 이 같은 노드를 다시 안 고른다

FIFO 는 도착 순서이지 머리 하나가 아니다. 앞의 q 가 못 가도 continue
로 뒤를 본다. 머리에서 멈추면, 보드를 기다리는 Run 하나가 전혀 다른 능력을
기다리는 뒤의 Run 을 자원이 비어 있는데도 막는다. 그것은 공정성 정책이 아니라
결함이다 (requirements.md §3.2).

후보와 제외 목록은 훑기마다 한 번만 읽는다. q 마다 다시 읽으면 훑기
하나가 질의 2N 개가 된다. 대신 blocked 를 손으로 갱신한다 — 값이 같고
질의가 안 는다.

blocked 갱신이 없으면 어떻게 되나 — 기본키 충돌이 이중 배정을 여전히
막는다. 그러나 그것은 마지막 방어선이지 설계가 아니다. 갱신을 하면 헛도는
트랜잭션이 안 생기고, 같은 훑기 안에서 앞의 Run 이 실제로 앞선다.

3.2 promote — Run 하나의 트랜잭션

   assign, rej = match.Match(q.Contract.Requires, adverts, blocked)
   rej 면 아무것도 안 하고 돌아간다            아직 자리가 없다.  정상이라 로그를 안 남긴다

   nodes = assign 의 노드를 중복 없이           한 노드가 두 역할을 맡으면 임대는 하나다
   MaxLeasesPerRun 을 넘으면 로그만 남기고 둔다  아래 3.3

   BEGIN
     UPDATE runs SET state='RUNNING', assigned=$2
      WHERE run_id=$1 AND state='QUEUED'
     0행이면 롤백하고 돌아간다                  남이 먼저 옮겼거나 그 사이 취소됐다

     for n in nodes:
        INSERT INTO leases (node_id, run_id, not_after, nonce)
        기본키 충돌이면 롤백하고 돌아간다        다음 훑기에 다시 본다

     for a in assigned:
        UPDATE steps SET node_id=$3 WHERE run_id=$1 AND uses=$2

     runAcquires(tx, run_id)                   첫 단계가 획득일 수 있다
     raiseAsks(tx, run_id)                     첫 단계가 되묻기일 수 있다
   COMMIT
   PushAsks(raised)                            알림은 커밋 뒤에만
   nodes 를 돌려준다

AND state='QUEUED' 가 동시 승격을 막는다. 두 훑기가 같은 Run 을 동시에
보면 뒤의 것이 행 잠금에서 기다리다가, 앞이 커밋한 뒤 조건을 다시 재고
0행을 받는다. 새 잠금을 손으로 짤 것이 없다.

임대 기본키 충돌이 노드 경쟁을 막는다. CreateRun 이 I5 를 얻는
방식과 같은 기계다 — 경쟁하는 쪽이 INSERT 에서 충돌하고 롤백이 이미 잡은
것을 전부 되돌린다. 손으로 해제할 것이 없다. 그래서 이 함수는 재진입 ·
동시 호출 안전
이다.

순서가 CreateRun 과 같다. steps.node_id 를 먼저 채우고, 획득을 하고,
되묻기를 맨 뒤에 올린다. 획득이 needs 를 보고 고르므로 앞의 효과가 먼저
반영돼 있어야 하고, 되묻기는 그 그림을 보고 올라간다 (claim.go 의
afterStep 주석과 같은 근거).

3.3 승격 시 폭 상한 초과는 영구가 아니다

t=0 의 폭 초과가 422 로 영구인 이유는 **「다시 내도 같다」**이다. 승격
시점의 초과는 그렇지 않다 — 셈이 그때 고른 노드 조합에 달렸고, 한 노드가 두
역할을 맡으면 중복이 접혀 셈이 줄기 때문이다 (api.go 의 seen). 다음
훑기에 다른 조합이 나오면 통과한다.

그래서 로그만 남기고 QUEUED 로 둔다. 여기서 FAILED 로 만들면 통과할
수 있었던 Run 을 죽인다.


4. 깨우기 — 호출 자리 다섯

4.1 지점 여섯과 자리 다섯

   지점                     자리                               언제

   완료 · 부분 반납          postResult                        ReportStep 이 커밋한 뒤 (아래 4.2)
   취소                     postCancel                        Cancel 이 성공한 뒤
   회수 · 되묻기 만료         Reap                              실제로 놓은 것이 있을 때만
   노드 재기동 감지           postNodes                         FailRestarted 가 Run 을 냈을 때만
   drain 해제 감지           postNodes                         U-drain 이 단다.  이 유닛이 아니다
   기동                     cmd/mediator/main.go run()        migrate 뒤 한 번

지점이 여섯이고 자리가 다섯인 이유 — 「완료」와 「부분 반납」이 같은
자리다. 4.2 절이 그 근거다.

internal/api 쪽 셋은 같은 조각을 쓴다.

// wake 는 자원이 풀린 자리에서 대기열을 훑는다.
//
// 동기다 (Q2 의 답 A).  이 자리에서 고루틴을 띄우면 r.Context() 를 못 쓴다 —
// 응답이 끝나면 취소되므로 승격이 중간에 끊긴다.  Background 로 피하면
// 종료 순서에 기다릴 것이 하나 더 붙는다.
//
// 오류를 응답에 안 싣는다.  깨우기 실패는 이 요청의 실패가 아니고,
// 다음 사건이 같은 것을 다시 본다 — WakeQueued 는 재진입 안전이다.
func (s *Server) wake(ctx context.Context, why string) {
    if err := s.st.WakeQueued(ctx); err != nil {
        s.log.Error("cannot promote queued runs", "why", why, "err", err)
    }
}

4.2 「부분 반납」을 release.go 에 못 넣는다

unit-of-work.md 의 파일 행렬이 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 다. 그 한 번이 「완료」와 「부분 반납」
둘을 덮는다. 딸린 조건이 하나 붙는다:

   SettleIfDone 이 빈 문자열을 내도 깨운다      빈 문자열은 「아직 진행 중」이다.
                                            부분 반납은 바로 그 경우에 일어난다
   rolled 이면 안 깨운다                       되돌림은 임대를 안 놓는다.
                                            rollback.go 에 DELETE FROM leases 가 없다

internal/store/release.go 를 안 고친다. 이 유닛이 만지는 파일이 여섯에서
다섯으로 준다.

4.3 Reap 은 실제로 놓았을 때만 깨운다 — Q4 의 답 A

RunReaper 가 cfg.Lease.RenewSeconds 마다 Reap 을 부른다
(cmd/mediator/main.go:87). 무조건 깨우면 사실상 주기 폴링이고, 완료
조건의 「reaper 주기에 안 얹었다」와 어긋난다.

Reap 이 자원을 놓는 자리가 셋이고, 셋 중 하나라도 행을 건드렸으면 끝에서
한 번 깨운다.

   ExpireAsks 뒤의 SettleIfDone 이 Run 을 정산했다        reap.go 머리
   만료 임대 회수가 Run 을 FAILED 로 만들었다  (n > 0)     reap.go:57
   종료 Run 의 임대를 지웠다                              reap.go:81

셋째를 세는 것이 중요하다 — Mediator 가 죽어 있는 동안 끝난 Run 의 임대가
거기서 풀리는데, 그 경로에는 postResult 도 postCancel 도 없다.

4.4 임대가 풀리는 자리는 넷뿐이고 넷 다 덮인다

   store/reap.go:325   SettleIfDone      postResult · postNodes 재기동 · Reap 되묻기 만료
   store/reap.go:201   Cancel            postCancel
   store/reap.go:81    Reap 의 정리        Reap
   store/release.go:73 applyRelease      postResult  (4.2)

grep 으로 셌다. 임대는 SQL 이 스스로 지우지 않는다 — not_after 는 열일
뿐이고 그 값을 보고 지우는 것은 Reap 이다. 그래서 「자원이 풀렸는데
아무도 안 봤다」가 없다.
이 유닛이 여는 하나의 창은 business-rules.md
5절에 적었다.

4.5 기동 시 한 번

// run() 안, migrate 뒤 · reaper 를 띄우기 전
if err := st.WakeQueued(ctx); err != nil {
    log.Error("cannot promote queued runs at startup", "err", err)
}

QUEUED 는 아무것도 안 쥐므로 재기동 후에도 참이다 — 회수할 것도 복구할
것도 없고, 그냥 다시 보면 된다. 실패해도 기동을 안 막는다. 승격을 못
하는 Mediator 도 제출과 조회는 해야 하고, 다음 사건이 같은 것을 다시 본다.


5. 크래시 복구

   무엇이 남나          runs 행 하나와 steps 행들.  임대는 없다

   무엇을 복구하나       아무것도 안 한다.  QUEUED 는 쥔 것이 없어 되돌릴 상태가 없다

   누가 다시 보나        기동 시 WakeQueued 한 번 (4.5).  그 뒤로는 사건이 볼 때마다

   Record 는            제출 시점에 열려 있고 봉인 안 된 채 남는다.
                       Mediator 가 죽어 있는 동안 끝난 Run 을 sealExpired 가 잡는데,
                       QUEUED 는 종료 상태가 아니라 거기 안 걸린다 — 맞다.  아직 안 끝났다

상태가 하나 늘었는데 복구 대상은 안 늘었다. QUEUED 를 기각했던 옛 사유가
「상태가 늘면 크래시 복구 검증 대상이 늘어난다」였는데, 이 상태는 자원을 안
쥐므로 그 비용이 없다.


6. 흐름 하나로

   제출  ──  매칭  ──┬─ 배정됨 ────────  CreateRun          201 RUNNING
                    │
                    ├─ 422 ──────────  CreateRejectedRun  422 FAILED
                    │
                    ├─ 409 ──────────┐
                    │                ├  CreateQueuedRun   202 QUEUED
                    └─ ErrNodeTaken ─┘        │
                                             │
                                       (기다린다.  상한 없음)
                                             │
   자원이 풀린다 ── postResult          ─────┤
                  postCancel           ─────┤
                  Reap                 ─────┤     WakeQueued
                  postNodes 재기동      ─────┤       ListQueued  (created_at 오름차순)
                  기동                  ─────┘       q 마다 promote
                                                          │
                                                    UPDATE ... AND state='QUEUED'
                                                    INSERT leases
                                                    UPDATE steps.node_id
                                                    runAcquires · raiseAsks
                                                          │
                                                       RUNNING