Skip to content

Commit 7adae20

Browse files
authored
Merge pull request #393 from scratchfoundation/task-queue-manager
Add QueueManager & improve TaskQueue
2 parents 929896f + 62a4800 commit 7adae20

File tree

9 files changed

+363
-64
lines changed

9 files changed

+363
-64
lines changed

package-lock.json

Lines changed: 0 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/scratch-gui/webpack.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ const baseConfig = new ScratchWebpackConfigBuilder(
9191
from: 'chunks/fetch-worker.*.{js,js.map}',
9292
noErrorOnMissing: true
9393
},
94+
{
95+
context: '../../node_modules/scratch-storage/dist/web',
96+
from: 'chunks/vendors-*.{js,js.map}',
97+
noErrorOnMissing: true
98+
},
9499
{
95100
from: '../../node_modules/@mediapipe/face_detection',
96101
to: 'chunks/mediapipe/face_detection'

packages/scratch-render/src/BitmapSkin.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,10 @@ class BitmapSkin extends Skin {
6969
// memory.
7070
let textureData = bitmapData;
7171
if (bitmapData instanceof HTMLCanvasElement) {
72-
// Given a HTMLCanvasElement get the image data to pass to webgl and
73-
// Silhouette.
74-
const context = bitmapData.getContext('2d');
72+
// Given a HTMLCanvasElement, get the image data to pass to WebGL and the Silhouette. The 2D context was
73+
// likely already created, so `willReadFrequently` is likely ignored here, but it's good documentation and
74+
// may help if the code path changes someday.
75+
const context = bitmapData.getContext('2d', {willReadFrequently: true});
7576
textureData = context.getImageData(0, 0, bitmapData.width, bitmapData.height);
7677
}
7778

packages/scratch-vm/src/import/load-costume.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) {
145145
mergeCanvas.width = baseImageElement.width;
146146
mergeCanvas.height = baseImageElement.height;
147147

148-
const ctx = mergeCanvas.getContext('2d');
148+
const ctx = mergeCanvas.getContext('2d', {willReadFrequently: true});
149149
ctx.drawImage(baseImageElement, 0, 0);
150150
if (textImageElement) {
151151
ctx.drawImage(textImageElement, 0, 0);

packages/task-herder/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,5 @@
4848
},
4949
"overrides": {
5050
"vite": "npm:[email protected]"
51-
},
52-
"dependencies": {
53-
"p-limit": "7.2.0"
5451
}
5552
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { TaskQueue, type QueueOptions } from './TaskQueue'
2+
3+
/**
4+
* Manages multiple named task queues with shared or individual configurations. Each queue can be accessed by a
5+
* unique name or other identifier. For example, this can manage a separate queue for each of several different
6+
* servers, using the server's DNS name as the key.
7+
*/
8+
export class QueueManager<T = string> {
9+
private readonly queues: Map<T, TaskQueue>
10+
private readonly defaultOptions: QueueOptions
11+
12+
/**
13+
* @param defaultOptions The options that will be used to initialize each queue.
14+
* These can be overridden later on a per-queue basis.
15+
* @param iterable An optional iterable of key-value pairs to initialize the manager with existing queues.
16+
*/
17+
constructor(defaultOptions: QueueOptions, iterable?: Iterable<readonly [T, TaskQueue]> | null) {
18+
this.queues = new Map(iterable)
19+
this.defaultOptions = defaultOptions
20+
}
21+
22+
/**
23+
* Create a new task queue with the given identifier. If a queue with that identifier already exists, it will be
24+
* replaced. If you need to cancel tasks in that queue before replacing it, do so manually first.
25+
* @param id The identifier for the queue.
26+
* @param overrides Optional overrides for the default QueueOptions for this specific queue.
27+
* @returns The newly created TaskQueue.
28+
*/
29+
public create(id: T, overrides: Partial<QueueOptions> = {}): TaskQueue {
30+
const queue = new TaskQueue({ ...this.defaultOptions, ...overrides })
31+
this.queues.set(id, queue)
32+
return queue
33+
}
34+
35+
/**
36+
* Get the task queue for the given identifier.
37+
* @param id The identifier for the queue.
38+
* @returns The TaskQueue associated with the given identifier, or undefined if none exists.
39+
*/
40+
public get(id: T): TaskQueue | undefined {
41+
return this.queues.get(id)
42+
}
43+
44+
/**
45+
* Get the task queue for the given identifier, creating it if it does not already exist.
46+
* @param id The identifier for the queue.
47+
* @param overrides Optional overrides for the default QueueOptions for this specific queue. Only used if the queue
48+
* did not already exist.
49+
* @returns The TaskQueue associated with the given identifier.
50+
*/
51+
public getOrCreate(id: T, overrides: Partial<QueueOptions> = {}): TaskQueue {
52+
return this.get(id) ?? this.create(id, overrides)
53+
}
54+
55+
/**
56+
* @returns A copy of the default queue options. Used primarily for testing and inspection.
57+
*/
58+
public options(): Readonly<QueueOptions> {
59+
return {
60+
...this.defaultOptions,
61+
}
62+
}
63+
}

packages/task-herder/src/TaskQueue.ts

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import pLimit, { type LimitFunction } from 'p-limit'
21
import { CancelReason } from './CancelReason'
2+
import { PromiseWithResolvers } from './PromiseWithResolvers'
33
import { TaskRecord, type TaskOptions } from './TaskRecord'
44

55
export interface QueueOptions {
@@ -29,27 +29,44 @@ export class TaskQueue {
2929
private readonly burstLimit: number
3030
private readonly sustainRate: number
3131
private readonly queueCostLimit: number
32-
private readonly concurrencyLimiter: LimitFunction
33-
private readonly boundRunTasks = this.runTasks.bind(this)
32+
private readonly concurrencyLimit: number
3433
private tokenCount: number
3534

35+
private runningTasks = 0
3636
private pendingTaskRecords: TaskRecord<unknown>[] = []
37-
private timeout: number | null = null
3837
private lastRefillTime: number = Date.now()
38+
private onTaskAdded = PromiseWithResolvers<void>().resolve // start with a no-op of correct type
39+
private onTaskFinished = PromiseWithResolvers<void>().resolve // start with a no-op of correct type
3940

4041
constructor(options: QueueOptions) {
4142
this.burstLimit = options.burstLimit
4243
this.sustainRate = options.sustainRate
4344
this.tokenCount = options.startingTokens ?? options.burstLimit
4445
this.queueCostLimit = options.queueCostLimit ?? Infinity
45-
this.concurrencyLimiter = pLimit(options.concurrency ?? 1)
46+
this.concurrencyLimit = options.concurrency ?? 1
47+
void this.runTasks()
4648
}
4749

4850
/** @returns The number of tasks currently in the queue */
4951
get length(): number {
5052
return this.pendingTaskRecords.length
5153
}
5254

55+
/**
56+
* @returns The current configuration options of the queue. Used primarily for testing and inspection.
57+
* Note that the `startingTokens` value returned here reflects the current token count, which is only guaranteed to
58+
* match the originally configured starting tokens value if no time has passed and no tasks have been processed.
59+
*/
60+
get options(): Readonly<QueueOptions> {
61+
return {
62+
burstLimit: this.burstLimit,
63+
sustainRate: this.sustainRate,
64+
startingTokens: this.tokenCount,
65+
queueCostLimit: this.queueCostLimit,
66+
concurrency: this.concurrencyLimit,
67+
}
68+
}
69+
5370
/**
5471
* Adds a task to the queue. The task will first wait until enough tokens are available, then will wait its turn in
5572
* the concurrency queue.
@@ -77,10 +94,7 @@ export class TaskQueue {
7794
this.cancel(taskRecord.promise, new Error(CancelReason.Aborted))
7895
})
7996

80-
// If the queue was empty, we need to prime the pump
81-
if (this.pendingTaskRecords.length === 1) {
82-
void this.runTasks()
83-
}
97+
this.onTaskAdded()
8498

8599
return taskRecord.promise
86100
}
@@ -96,9 +110,6 @@ export class TaskQueue {
96110
if (taskIndex !== -1) {
97111
const [taskRecord] = this.pendingTaskRecords.splice(taskIndex, 1)
98112
taskRecord.cancel(reason ?? new Error(CancelReason.Cancel))
99-
if (taskIndex === 0 && this.pendingTaskRecords.length > 0) {
100-
void this.runTasks()
101-
}
102113
return true
103114
}
104115
return false
@@ -110,10 +121,6 @@ export class TaskQueue {
110121
* @returns The number of tasks that were cancelled.
111122
*/
112123
cancelAll(reason?: Error): number {
113-
if (this.timeout !== null) {
114-
clearTimeout(this.timeout)
115-
this.timeout = null
116-
}
117124
const oldTasks = this.pendingTaskRecords
118125
this.pendingTaskRecords = []
119126
reason = reason ?? new Error(CancelReason.Cancel)
@@ -164,17 +171,15 @@ export class TaskQueue {
164171
/**
165172
* Run tasks from the queue as tokens become available.
166173
*/
167-
private runTasks(): void {
168-
if (this.timeout !== null) {
169-
clearTimeout(this.timeout)
170-
this.timeout = null
171-
}
172-
174+
private async runTasks(): Promise<void> {
173175
for (;;) {
174176
const nextRecord = this.pendingTaskRecords.shift()
175177
if (!nextRecord) {
176178
// No more tasks to run
177-
return
179+
const { promise, resolve } = PromiseWithResolvers<void>()
180+
this.onTaskAdded = resolve
181+
await promise // wait until a task is added
182+
continue // then try again
178183
}
179184

180185
if (nextRecord.cost > this.burstLimit) {
@@ -185,16 +190,34 @@ export class TaskQueue {
185190

186191
// Refill before each task in case the time it took for the last task to run was enough to afford the next.
187192
if (this.refillAndSpend(nextRecord.cost)) {
188-
// Run the task within the concurrency limiter
189-
void this.concurrencyLimiter(nextRecord.run)
193+
if (this.runningTasks >= this.concurrencyLimit) {
194+
const { promise, resolve } = PromiseWithResolvers<void>()
195+
this.onTaskFinished = resolve
196+
await promise // wait until a task finishes
197+
// then we know there's room for at least one more task
198+
}
199+
void this.runTask(nextRecord)
190200
} else {
191201
// We can't currently afford this task. Put it back and wait until we can, then try again.
192202
this.pendingTaskRecords.unshift(nextRecord)
193203
const tokensNeeded = Math.max(nextRecord.cost - this.tokenCount, 0)
194204
const estimatedWait = Math.ceil((1000 * tokensNeeded) / this.sustainRate)
195-
this.timeout = setTimeout(this.boundRunTasks, estimatedWait)
196-
return
205+
await new Promise(resolve => setTimeout(resolve, estimatedWait))
197206
}
198207
}
199208
}
209+
210+
/**
211+
* Run a task record right now, managing the running tasks count.
212+
* @param taskRecord The task that should run.
213+
*/
214+
private async runTask(taskRecord: TaskRecord<unknown>): Promise<void> {
215+
this.runningTasks++
216+
try {
217+
await taskRecord.run()
218+
} finally {
219+
this.runningTasks--
220+
this.onTaskFinished()
221+
}
222+
}
200223
}

packages/task-herder/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { CancelReason } from './CancelReason'
22
export { type QueueOptions, TaskQueue } from './TaskQueue'
3+
export { QueueManager } from './QueueManager'

0 commit comments

Comments
 (0)