d18ada4ee7
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled
163 lines
5.1 KiB
TypeScript
163 lines
5.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { Scheduler } from '../src/scheduler'
|
|
import type { Task } from '../src/types'
|
|
import { makeTask } from './_fixtures'
|
|
|
|
interface Harness {
|
|
scheduler: Scheduler
|
|
store: Map<string, Task>
|
|
/** taskIds dispatched, in order. */
|
|
dispatched: string[]
|
|
/** taskIds we issue completions for, simulating finish. */
|
|
complete: (id: string) => Promise<void>
|
|
}
|
|
|
|
function makeHarness(opts: {
|
|
maxConcurrency?: number
|
|
defaultMaxPerGroup?: number | null
|
|
failingDispatch?: Set<string>
|
|
}): Harness {
|
|
const store = new Map<string, Task>()
|
|
const dispatched: string[] = []
|
|
const scheduler = new Scheduler({
|
|
maxConcurrency: opts.maxConcurrency ?? 2,
|
|
defaultMaxPerGroup: opts.defaultMaxPerGroup ?? null,
|
|
getTask: (id) => store.get(id),
|
|
dispatch: (id) => {
|
|
if (opts.failingDispatch?.has(id)) return false
|
|
dispatched.push(id)
|
|
return true
|
|
},
|
|
demote: (_id) => {}
|
|
})
|
|
return {
|
|
scheduler,
|
|
store,
|
|
dispatched,
|
|
complete: async (id: string) => {
|
|
await scheduler.releaseSlot(id)
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('Scheduler', () => {
|
|
it('respects priority then FIFO', async () => {
|
|
const h = makeHarness({ maxConcurrency: 1 })
|
|
for (let i = 0; i < 4; i++) {
|
|
const t = makeTask({
|
|
id: `t${i}`,
|
|
priority: (i === 2 ? 0 : 10) as Task['priority']
|
|
})
|
|
h.store.set(t.id, t)
|
|
}
|
|
// t0 enqueues into an empty heap with the slot free → dispatches immediately.
|
|
// t1/t2/t3 wait until t0 finishes; among the queued, t2 (priority 0) wins.
|
|
await h.scheduler.enqueue('t0', 10)
|
|
await h.scheduler.enqueue('t1', 10)
|
|
await h.scheduler.enqueue('t2', 0)
|
|
await h.scheduler.enqueue('t3', 10)
|
|
|
|
// Drain — h.complete awaits releaseSlot which now awaits tryDispatch,
|
|
// so dispatched[i] is filled in by the time we read it.
|
|
await h.complete(h.dispatched[0]!)
|
|
await h.complete(h.dispatched[1]!)
|
|
await h.complete(h.dispatched[2]!)
|
|
await h.complete(h.dispatched[3]!)
|
|
|
|
expect(h.dispatched).toEqual(['t0', 't2', 't1', 't3'])
|
|
})
|
|
|
|
it('honors the global concurrency cap', async () => {
|
|
const h = makeHarness({ maxConcurrency: 2 })
|
|
for (let i = 0; i < 5; i++) {
|
|
const t = makeTask({ id: `t${i}` })
|
|
h.store.set(t.id, t)
|
|
}
|
|
for (let i = 0; i < 5; i++) await h.scheduler.enqueue(`t${i}`, 0)
|
|
expect(h.dispatched).toHaveLength(2)
|
|
expect(h.scheduler.stats().running).toBe(2)
|
|
expect(h.scheduler.stats().queued).toBe(3)
|
|
|
|
await h.complete(h.dispatched[0]!)
|
|
expect(h.dispatched).toHaveLength(3)
|
|
})
|
|
|
|
it('honors per-group caps (subscription throttle)', async () => {
|
|
const h = makeHarness({ maxConcurrency: 4 })
|
|
for (let i = 0; i < 4; i++) {
|
|
const t = makeTask({ id: `g${i}`, groupKey: 'sub:foo' })
|
|
h.store.set(t.id, t)
|
|
}
|
|
await h.scheduler.setMaxPerGroup('sub:foo', 1)
|
|
for (let i = 0; i < 4; i++) await h.scheduler.enqueue(`g${i}`, 0)
|
|
expect(h.dispatched).toHaveLength(1)
|
|
|
|
await h.complete(h.dispatched[0]!)
|
|
expect(h.dispatched).toHaveLength(2)
|
|
})
|
|
|
|
it('1000 concurrent enqueues never over- or under-dispatch', async () => {
|
|
const h = makeHarness({ maxConcurrency: 8 })
|
|
const N = 1000
|
|
for (let i = 0; i < N; i++) {
|
|
h.store.set(`x${i}`, makeTask({ id: `x${i}` }))
|
|
}
|
|
// Enqueue in parallel — the AsyncMutex must serialize slot accounting.
|
|
await Promise.all(
|
|
Array.from({ length: N }, (_, i) => h.scheduler.enqueue(`x${i}`, 0))
|
|
)
|
|
|
|
// Drain by completing 8 at a time until the heap is empty.
|
|
let drained = 0
|
|
while (drained < N) {
|
|
const inflight = [...h.dispatched.slice(drained, drained + 8)]
|
|
if (inflight.length === 0) break
|
|
for (const id of inflight) await h.complete(id)
|
|
drained += inflight.length
|
|
}
|
|
expect(h.dispatched).toHaveLength(N)
|
|
// No duplicates.
|
|
expect(new Set(h.dispatched).size).toBe(N)
|
|
})
|
|
|
|
it('setMaxConcurrency lower demotes lowest-priority running tasks', async () => {
|
|
const demoted: string[] = []
|
|
const store = new Map<string, Task>()
|
|
const dispatched: string[] = []
|
|
const scheduler = new Scheduler({
|
|
maxConcurrency: 3,
|
|
getTask: (id) => store.get(id),
|
|
dispatch: (id) => {
|
|
dispatched.push(id)
|
|
return true
|
|
},
|
|
demote: (id) => {
|
|
demoted.push(id)
|
|
}
|
|
})
|
|
store.set('hi', makeTask({ id: 'hi', priority: 0 }))
|
|
store.set('mid', makeTask({ id: 'mid', priority: 10 }))
|
|
store.set('lo', makeTask({ id: 'lo', priority: 20 }))
|
|
await scheduler.enqueue('hi', 0)
|
|
await scheduler.enqueue('mid', 10)
|
|
await scheduler.enqueue('lo', 20)
|
|
expect(scheduler.stats().running).toBe(3)
|
|
|
|
await scheduler.setMaxConcurrency(2)
|
|
expect(demoted).toEqual(['lo'])
|
|
})
|
|
|
|
it('failing dispatch releases the slot for re-use', async () => {
|
|
const h = makeHarness({
|
|
maxConcurrency: 1,
|
|
failingDispatch: new Set(['boom'])
|
|
})
|
|
h.store.set('boom', makeTask({ id: 'boom' }))
|
|
h.store.set('next', makeTask({ id: 'next' }))
|
|
await h.scheduler.enqueue('boom', 0)
|
|
await h.scheduler.enqueue('next', 0)
|
|
expect(h.dispatched).toContain('next')
|
|
})
|
|
})
|