feat: queue skip support in model queue

This commit is contained in:
toki 2026-07-05 19:50:11 +09:00
parent b2754b871f
commit 35eb21959f
6 changed files with 340 additions and 122 deletions

View file

@ -0,0 +1,133 @@
<!-- task=m-model-group-long-context-admission/03+02_queue_skip plan=0 tag=QUEUE_SKIP -->
# Code Review Reference - QUEUE_SKIP
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is mandatory.** Complete implementation-owned sections, paste actual verification output, then stop with active files in place. Do not ask the user directly and do not run review finalization.
## 개요
date=2026-07-05
task=m-model-group-long-context-admission/03+02_queue_skip, plan=0, tag=QUEUE_SKIP
## Roadmap Targets
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/model-group-long-context-admission.md`
- Task ids:
- `queue-skip`: long queue head가 long slot을 기다릴 때 뒤의 normal 요청이 빈 일반 capacity로 dispatch
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [QUEUE_SKIP-1] Dispatchable queue item scan | [x] |
## 구현 체크리스트
- [x] queue pump가 head item만 보지 않고 앞에서부터 dispatch 가능한 item을 찾아 선택하도록 바꾼다.
- [x] long slot 부족으로 막힌 long item은 queue에 남기고, 뒤의 normal item은 일반 capacity가 있으면 dispatch한다. 검증: long head 대기 중 normal 요청이 head-of-line blocking 없이 완료된다. (`TestQueueSkipsLongHeadWhenNormalBehindCanDispatch` PASS)
- [x] 같은 class/같은 dispatch 가능 조건에서는 기존 FIFO와 priority/capacity tie-breaker를 유지한다. (front-scan이 최소 index의 dispatchable item을 먼저 선택; `TestQueueSkipPreservesFIFOAmongDispatchableNormalItems`, `TestModelQueueFIFOOrdering` PASS)
- [x] timeout, cancel, queue length accounting이 scan/pop 방식에서도 깨지지 않게 regression tests를 유지/추가한다. (기존 timeout/cancel/overflow/terminal-release 테스트 전부 PASS)
- [x] `go test -count=1 ./apps/edge/internal/service`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
- [x] 판정을 append한다.
- [x] active review/plan을 `code_review_local_G07_0.log`, `plan_local_G07_0.log`로 아카이브한다.
- [x] PASS이면 `complete.log` 작성 후 archive로 이동한다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 `USER_REVIEW.md`를 작성한다.
## 계획 대비 변경 사항
- 계획의 예상 구조는 `group.findDispatchableLocked` 같은 별도 helper 추출을 시사했으나, 기존 `findAvailableNodeLocked`가 이미 (long gate 포함) dispatch 가능 여부 평가를 담당하고 있어 별도 scan helper를 추가하지 않고 유일한 pump 지점인 `tryDispatchLocked` 하나만 front-scan 루프로 교체했다. 심볼 추가/이름 변경 없음. 최소 변경으로 head-of-line blocking을 제거하고 회귀 표면을 줄이기 위함이다.
- `model_queue.go`만 수정하면 충분해 계획의 후보였던 `service_test.go` 추가는 하지 않았다. `admit` 초기 dispatch 경로는 이미 `findAvailableNodeLocked`로 임의의 dispatchable candidate를 즉시 선택하므로 head-of-line blocking은 slot release 후 재펌프(`tryDispatchLocked`)에서만 발생했고, unit 레벨에서 결정적으로 재현 가능해 추가 integration 테스트가 불필요했다.
## 주요 설계 결정
- `tryDispatchLocked`를 "head-only, 1건 dispatch 후 return"에서 "front-scan, 진행이 있으면 재스캔"으로 변경했다. 각 outer pass에서 앞에서부터 첫 dispatchable item을 pop해 dispatch하고, item 제거(dispatch 또는 timeout)가 있으면 처음부터 다시 스캔한다. queue 길이가 유한하고 progress가 있을 때만 반복하므로 종료가 보장된다.
- long slot full로 막힌 long head는 `findAvailableNodeLocked(long=true)`에서 후보 없음으로 평가되어 스킵되고 queue에 남는다. 뒤의 normal item은 `long=false`로 평가되어 long slot state를 보지 않고 일반 capacity만으로 dispatch된다 (SDD D05/D06).
- 기존 동작(단일 slot release 시 1건 dispatch)은 그대로 유지된다. front-scan은 여러 slot이 동시에 열린 경우(예: `releaseNode`)에 dispatchable item을 모두 소진하도록 더 정확해졌으나, capacity 검사가 그대로 살아 있어 over-dispatch는 없다.
- FIFO/priority/rotation은 `findAvailableNodeLocked`와 최소 index 우선 선택으로 보존된다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- long head blocked 상태에서 normal이 dispatch되고 long이 queue에 남는지 확인한다.
- FIFO/priority/cancel/timeout 회귀가 없는지 확인한다.
- normal 후보 평가에서 long slot state를 사용하지 않는지 확인한다.
## 검증 결과
### QUEUE_SKIP-1 중간 검증
```bash
$ go test -count=1 -run 'TestQueueSkip' -v ./apps/edge/internal/service
=== RUN TestQueueSkipsLongHeadWhenNormalBehindCanDispatch
--- PASS: TestQueueSkipsLongHeadWhenNormalBehindCanDispatch (0.00s)
=== RUN TestQueueSkipPreservesFIFOAmongDispatchableNormalItems
--- PASS: TestQueueSkipPreservesFIFOAmongDispatchableNormalItems (0.00s)
PASS
ok iop/apps/edge/internal/service 0.004s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/service
ok iop/apps/edge/internal/service 0.461s
$ gofmt -l apps/edge/internal/service/model_queue.go apps/edge/internal/service/model_queue_test.go
(output 없음)
$ go build ./...
(output 없음)
```
기대 결과 PASS 충족. 신규 테스트 2개 + 기존 FIFO/timeout/cancel/overflow/terminal-release/long-slot 회귀 테스트 모두 통과.
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Fill every implementation-owned section before saving.**
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Pass
- spec conformance: Pass
- 발견된 문제: 없음
- 리뷰 근거:
- `tryDispatchLocked`가 queue 앞에서부터 첫 dispatchable item을 찾아 pop/dispatch하며, long slot full인 long head는 queue에 남기고 뒤 normal item은 일반 capacity 기준으로 dispatch한다.
- `findAvailableNodeLocked`의 long gate가 `long == true`일 때만 적용되어 SDD D05/D06과 S06을 충족한다.
- 신규 `TestQueueSkipsLongHeadWhenNormalBehindCanDispatch`와 `TestQueueSkipPreservesFIFOAmongDispatchableNormalItems`가 S06 및 FIFO 회귀를 직접 검증한다.
- 리뷰어 검증:
- `gofmt -l apps/edge/internal/service/model_queue.go apps/edge/internal/service/model_queue_test.go` -> 출력 없음
- `go test -count=1 -run 'TestQueueSkip|TestModelQueueFIFOOrdering|TestModelQueueLongDispatchReservesBothCounters|TestModelQueueNormalRequestDoesNotReserveLongSlot' -v ./apps/edge/internal/service` -> PASS
- `go test -count=1 ./apps/edge/internal/service` -> PASS
- `go build ./...` -> PASS
- `go test -count=1 ./apps/edge/...` -> PASS
- `git diff --check` -> PASS
- 다음 단계: PASS이므로 `complete.log` 작성 후 task directory를 archive로 이동한다. `m-model-group-long-context-admission` completion metadata를 보고하며 roadmap 수정은 런타임 책임으로 남긴다.

View file

@ -0,0 +1,45 @@
# Complete - m-model-group-long-context-admission/03+02_queue_skip
## 완료 일시
2026-07-05
## 요약
QUEUE_SKIP split task 1회 루프로 PASS. Long queue head가 long slot 부족으로 대기해도 뒤 normal 요청이 일반 capacity로 dispatch되도록 queue pump를 front-scan 방식으로 갱신했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | PASS | SDD S06 / `queue-skip` 구현과 검증 충족 |
## 구현/정리 내용
- `apps/edge/internal/service/model_queue.go`의 `tryDispatchLocked`가 queue head만 확인하지 않고 앞에서부터 dispatch 가능한 item을 찾아 dispatch하도록 변경했다.
- long slot full로 막힌 long item은 queue에 남기고, 뒤 normal item은 long slot 상태를 보지 않고 일반 capacity 기준으로 dispatch하게 했다.
- `apps/edge/internal/service/model_queue_test.go`에 long-head skip dispatch와 dispatchable normal FIFO 보존 테스트를 추가했다.
## 최종 검증
- `gofmt -l apps/edge/internal/service/model_queue.go apps/edge/internal/service/model_queue_test.go` - PASS; 출력 없음
- `go test -count=1 -run 'TestQueueSkip|TestModelQueueFIFOOrdering|TestModelQueueLongDispatchReservesBothCounters|TestModelQueueNormalRequestDoesNotReserveLongSlot' -v ./apps/edge/internal/service` - PASS; queue-skip, FIFO, long/normal slot 회귀 테스트 통과
- `go test -count=1 ./apps/edge/internal/service` - PASS; `ok iop/apps/edge/internal/service 0.459s`
- `go build ./...` - PASS; 출력 없음
- `go test -count=1 ./apps/edge/...` - PASS; Edge 하위 패키지 전체 통과
- `git diff --check` - PASS; 출력 없음
## Roadmap Completion
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/model-group-long-context-admission.md`
- Completed task ids:
- `queue-skip`: PASS; evidence=`agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/plan_local_G07_0.log`, `agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/code_review_local_G07_0.log`; verification=`go test -count=1 -run 'TestQueueSkip|TestModelQueueFIFOOrdering|TestModelQueueLongDispatchReservesBothCounters|TestModelQueueNormalRequestDoesNotReserveLongSlot' -v ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -1,90 +0,0 @@
<!-- task=m-model-group-long-context-admission/03+02_queue_skip plan=0 tag=QUEUE_SKIP -->
# Code Review Reference - QUEUE_SKIP
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is mandatory.** Complete implementation-owned sections, paste actual verification output, then stop with active files in place. Do not ask the user directly and do not run review finalization.
## 개요
date=2026-07-05
task=m-model-group-long-context-admission/03+02_queue_skip, plan=0, tag=QUEUE_SKIP
## Roadmap Targets
- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/model-group-long-context-admission.md`
- Task ids:
- `queue-skip`: long queue head가 long slot을 기다릴 때 뒤의 normal 요청이 빈 일반 capacity로 dispatch
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [QUEUE_SKIP-1] Dispatchable queue item scan | [ ] |
## 구현 체크리스트
- [ ] queue pump가 head item만 보지 않고 앞에서부터 dispatch 가능한 item을 찾아 선택하도록 바꾼다.
- [ ] long slot 부족으로 막힌 long item은 queue에 남기고, 뒤의 normal item은 일반 capacity가 있으면 dispatch한다. 검증: long head 대기 중 normal 요청이 head-of-line blocking 없이 완료된다.
- [ ] 같은 class/같은 dispatch 가능 조건에서는 기존 FIFO와 priority/capacity tie-breaker를 유지한다.
- [ ] timeout, cancel, queue length accounting이 scan/pop 방식에서도 깨지지 않게 regression tests를 유지/추가한다.
- [ ] `go test -count=1 ./apps/edge/internal/service`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
- [ ] 판정을 append한다.
- [ ] active review/plan을 `code_review_local_G07_0.log`, `plan_local_G07_0.log`로 아카이브한다.
- [ ] PASS이면 `complete.log` 작성 후 archive로 이동한다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 `USER_REVIEW.md`를 작성한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- long head blocked 상태에서 normal이 dispatch되고 long이 queue에 남는지 확인한다.
- FIFO/priority/cancel/timeout 회귀가 없는지 확인한다.
- normal 후보 평가에서 long slot state를 사용하지 않는지 확인한다.
## 검증 결과
### QUEUE_SKIP-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/service
(output)
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/service
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Fill every implementation-owned section before saving.**

View file

@ -503,44 +503,61 @@ func findLastColon(s string) int {
return -1
}
// tryDispatchLocked attempts to dispatch the head of the queue to an available
// node. Must be called with m.mu held.
// tryDispatchLocked scans the queue from the front and dispatches every item
// that currently has an available candidate. Unlike a head-only pump, a blocked
// head does not stop dispatchable items behind it: when the head is a long
// request whose provider long slots are full, later normal requests that fit
// ordinary capacity are still dispatched, avoiding head-of-line blocking (SDD
// D05/D06). FIFO order is preserved among items with the same dispatchability
// because the scan always picks the earliest dispatchable item first. Expired
// items are timed out in place regardless of position. Must be called with
// m.mu held.
func (m *modelQueueManager) tryDispatchLocked(group *modelQueueGroup) {
for len(group.queue) > 0 {
head := group.queue[0]
for {
progressed := false
for i := 0; i < len(group.queue); i++ {
item := group.queue[i]
if time.Now().After(head.deadline) {
group.queue = group.queue[1:]
select {
case head.waitCh <- admitResult{err: fmt.Errorf("model group %q: %w", group.key, errQueueTimeout)}:
default:
if time.Now().After(item.deadline) {
group.queue = append(group.queue[:i], group.queue[i+1:]...)
select {
case item.waitCh <- admitResult{err: fmt.Errorf("model group %q: %w", group.key, errQueueTimeout)}:
default:
}
progressed = true
break
}
continue
}
candidate := m.findAvailableNodeLocked(group, head.candidates, head.long)
if candidate == nil {
candidate := m.findAvailableNodeLocked(group, item.candidates, item.long)
if candidate == nil {
// No slot for this item under current provider state; skip it so a
// later dispatchable item is not blocked behind it.
continue
}
group.queue = append(group.queue[:i], group.queue[i+1:]...)
slot := candidate.slotKey()
reserveLong := item.long && candidate.longContextCapacity > 0
group.inflight[slot]++
if reserveLong {
group.longInflight[slot]++
}
select {
case item.waitCh <- admitResult{candidate: candidate}:
group.lastSelectedSlot = slot
default:
// Caller already timed out or cancelled; release the reserved slot.
group.inflight[slot]--
if reserveLong {
group.longInflight[slot]--
}
}
progressed = true
break
}
group.queue = group.queue[1:]
slot := candidate.slotKey()
reserveLong := head.long && candidate.longContextCapacity > 0
group.inflight[slot]++
if reserveLong {
group.longInflight[slot]++
}
select {
case head.waitCh <- admitResult{candidate: candidate}:
group.lastSelectedSlot = slot
return
default:
// Caller already timed out or cancelled; release the reserved slot and try next.
group.inflight[slot]--
if reserveLong {
group.longInflight[slot]--
}
if !progressed {
break
}
}
}

View file

@ -1771,6 +1771,119 @@ func TestModelQueueLongDispatchReservesBothCounters(t *testing.T) {
}
}
// TestQueueSkipsLongHeadWhenNormalBehindCanDispatch verifies the queue-skip
// contract (S06 / queue-skip): when the head of the queue is a long request
// whose provider long slots are full, a normal request queued behind it is
// still dispatched on ordinary capacity, and the blocked long head stays queued.
func TestQueueSkipsLongHeadWhenNormalBehindCanDispatch(t *testing.T) {
entry := &edgenode.NodeEntry{NodeID: "node-skip"}
// Provider has normal capacity 2 and a single long slot.
cands := []candidateNode{{entry: entry, capacity: 2, providerID: "prov-skip", longContextCapacity: 1}}
m := newModelQueueManager(nil)
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-skip", groupPolicy{})
// One long request already in flight: the single long slot is full while one
// of the two normal slots remains free.
g.inflight["node-skip:prov-skip"] = 1
g.longInflight["node-skip:prov-skip"] = 1
longItem := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
long: true,
}
normalItem := &queueItem{
candidates: cands,
waitCh: make(chan admitResult, 1),
deadline: time.Now().Add(2 * time.Second),
}
// long head first, normal request queued behind it.
g.queue = []*queueItem{longItem, normalItem}
m.tryDispatchLocked(g)
m.mu.Unlock()
// The normal request behind the blocked long head must be dispatched.
select {
case res := <-normalItem.waitCh:
if res.err != nil {
t.Fatalf("normal item expected dispatch, got error: %v", res.err)
}
if res.candidate == nil || res.candidate.providerID != "prov-skip" {
t.Fatalf("normal item: unexpected candidate %v", res.candidate)
}
default:
t.Fatal("normal request behind long head was not dispatched (head-of-line blocking)")
}
// The long head must remain queued because its long slot is still full.
select {
case <-longItem.waitCh:
t.Fatal("long head should not dispatch while its long slot is full")
default:
}
m.mu.Lock()
if len(g.queue) != 1 || g.queue[0] != longItem {
n := len(g.queue)
m.mu.Unlock()
t.Fatalf("expected only the long head to remain queued, got %d item(s)", n)
}
m.mu.Unlock()
}
// TestQueueSkipPreservesFIFOAmongDispatchableNormalItems verifies that the
// front-scan dispatch keeps FIFO order among equally-dispatchable items: with a
// single normal slot, the earlier queued item is dispatched first and the later
// one only after another slot frees.
func TestQueueSkipPreservesFIFOAmongDispatchableNormalItems(t *testing.T) {
entry := &edgenode.NodeEntry{NodeID: "node-fifo2"}
cands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-fifo2"}}
m := newModelQueueManager(nil)
m.mu.Lock()
g := m.getOrCreateGroupLocked("g-fifo2", groupPolicy{})
// Fill the single normal slot so both queued items must wait.
g.inflight["node-fifo2:prov-fifo2"] = 1
first := &queueItem{candidates: cands, waitCh: make(chan admitResult, 1), deadline: time.Now().Add(2 * time.Second)}
second := &queueItem{candidates: cands, waitCh: make(chan admitResult, 1), deadline: time.Now().Add(2 * time.Second)}
g.queue = []*queueItem{first, second}
m.mu.Unlock()
// Release one slot: only the earlier (first) item may dispatch.
m.mu.Lock()
m.releaseSlotLocked("g-fifo2", "node-fifo2", "prov-fifo2", false)
m.mu.Unlock()
select {
case res := <-first.waitCh:
if res.err != nil {
t.Fatalf("first item dispatch error: %v", res.err)
}
default:
t.Fatal("first (earliest) item was not dispatched first")
}
select {
case <-second.waitCh:
t.Fatal("second item should still wait (only one slot freed)")
default:
}
// Release again → the second item dispatches.
m.mu.Lock()
m.releaseSlotLocked("g-fifo2", "node-fifo2", "prov-fifo2", false)
m.mu.Unlock()
select {
case res := <-second.waitCh:
if res.err != nil {
t.Fatalf("second item dispatch error: %v", res.err)
}
default:
t.Fatal("second item not dispatched after second slot release")
}
}
// TestModelQueueNormalRequestDoesNotReserveLongSlot verifies a normal request
// occupies only the normal in-flight counter, never the long counter, even on a
// provider that declares long capacity.