From 35eb21959f76be6c31985d3c5594d23c03137e8b Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 5 Jul 2026 19:50:11 +0900 Subject: [PATCH] feat: queue skip support in model queue --- .../code_review_local_G07_0.log | 133 ++++++++++++++++++ .../03+02_queue_skip/complete.log | 45 ++++++ .../03+02_queue_skip/plan_local_G07_0.log} | 0 .../03+02_queue_skip/CODE_REVIEW-local-G07.md | 90 ------------ apps/edge/internal/service/model_queue.go | 81 ++++++----- .../edge/internal/service/model_queue_test.go | 113 +++++++++++++++ 6 files changed, 340 insertions(+), 122 deletions(-) create mode 100644 agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/code_review_local_G07_0.log create mode 100644 agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/complete.log rename agent-task/{m-model-group-long-context-admission/03+02_queue_skip/PLAN-local-G07.md => archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/plan_local_G07_0.log} (100%) delete mode 100644 agent-task/m-model-group-long-context-admission/03+02_queue_skip/CODE_REVIEW-local-G07.md diff --git a/agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/code_review_local_G07_0.log b/agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/code_review_local_G07_0.log new file mode 100644 index 0000000..904eb48 --- /dev/null +++ b/agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/code_review_local_G07_0.log @@ -0,0 +1,133 @@ + + +# 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 수정은 런타임 책임으로 남긴다. diff --git a/agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/complete.log b/agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/complete.log new file mode 100644 index 0000000..b3ec9a2 --- /dev/null +++ b/agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-model-group-long-context-admission/03+02_queue_skip/PLAN-local-G07.md b/agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/plan_local_G07_0.log similarity index 100% rename from agent-task/m-model-group-long-context-admission/03+02_queue_skip/PLAN-local-G07.md rename to agent-task/archive/2026/07/m-model-group-long-context-admission/03+02_queue_skip/plan_local_G07_0.log diff --git a/agent-task/m-model-group-long-context-admission/03+02_queue_skip/CODE_REVIEW-local-G07.md b/agent-task/m-model-group-long-context-admission/03+02_queue_skip/CODE_REVIEW-local-G07.md deleted file mode 100644 index ac34ab7..0000000 --- a/agent-task/m-model-group-long-context-admission/03+02_queue_skip/CODE_REVIEW-local-G07.md +++ /dev/null @@ -1,90 +0,0 @@ - - -# 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.** diff --git a/apps/edge/internal/service/model_queue.go b/apps/edge/internal/service/model_queue.go index ae2a366..45cf071 100644 --- a/apps/edge/internal/service/model_queue.go +++ b/apps/edge/internal/service/model_queue.go @@ -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 } } } diff --git a/apps/edge/internal/service/model_queue_test.go b/apps/edge/internal/service/model_queue_test.go index afbe653..ebe4079 100644 --- a/apps/edge/internal/service/model_queue_test.go +++ b/apps/edge/internal/service/model_queue_test.go @@ -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.