mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(store): queue task refactor (#287)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { Request, RequestMeta } from './request';
|
||||
|
||||
import { tryCatch } from '@videojs/utils/function';
|
||||
import { isFunction, isUndefined } from '@videojs/utils/predicate';
|
||||
|
||||
import { StoreError } from './errors';
|
||||
@@ -37,18 +38,62 @@ export type DefaultTaskRecord = Record<TaskKey, Request<unknown, unknown>>;
|
||||
export type EnsureTaskRecord<T> = T extends TaskRecord ? T : never;
|
||||
|
||||
/**
|
||||
* Pending task info.
|
||||
* Base fields shared by all task states.
|
||||
*/
|
||||
export interface PendingTask<Key extends TaskKey = TaskKey, Input = unknown> {
|
||||
export interface TaskBase<Key extends TaskKey = TaskKey, Input = unknown> {
|
||||
id: symbol;
|
||||
name: string;
|
||||
key: Key;
|
||||
input: Input;
|
||||
startedAt: number;
|
||||
abort: AbortController;
|
||||
meta: RequestMeta | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending task - request in flight.
|
||||
*/
|
||||
export interface PendingTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
|
||||
status: 'pending';
|
||||
abort: AbortController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success task - completed successfully.
|
||||
*/
|
||||
export interface SuccessTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown> extends TaskBase<
|
||||
Key,
|
||||
Input
|
||||
> {
|
||||
status: 'success';
|
||||
settledAt: number;
|
||||
output: Output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error task - failed or cancelled.
|
||||
*/
|
||||
export interface ErrorTask<Key extends TaskKey = TaskKey, Input = unknown> extends TaskBase<Key, Input> {
|
||||
status: 'error';
|
||||
settledAt: number;
|
||||
error: unknown;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task with status discriminator.
|
||||
*/
|
||||
export type Task<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown>
|
||||
= | PendingTask<Key, Input>
|
||||
| SuccessTask<Key, Input, Output>
|
||||
| ErrorTask<Key, Input>;
|
||||
|
||||
/**
|
||||
* Settled task (success or error).
|
||||
*/
|
||||
export type SettledTask<Key extends TaskKey = TaskKey, Input = unknown, Output = unknown>
|
||||
= | SuccessTask<Key, Input, Output>
|
||||
| ErrorTask<Key, Input>;
|
||||
|
||||
/**
|
||||
* Context passed to task handler.
|
||||
*/
|
||||
@@ -90,13 +135,7 @@ export interface QueueConfig<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
/** Default scheduler when task has no schedule */
|
||||
scheduler?: TaskScheduler;
|
||||
onDispatch?: <K extends keyof Tasks>(task: PendingTask<TaskKey<K>, Tasks[K]['input']>) => void;
|
||||
onSettled?: <K extends keyof Tasks>(
|
||||
task: PendingTask<TaskKey<K>, Tasks[K]['input']>,
|
||||
result:
|
||||
| { status: 'success'; duration: number; output: Tasks[K]['output'] }
|
||||
| { status: 'cancelled'; error: unknown; duration: number }
|
||||
| { status: 'error'; error: unknown; duration: number },
|
||||
) => void;
|
||||
onSettled?: <K extends keyof Tasks>(task: SettledTask<TaskKey<K>, Tasks[K]['input'], Tasks[K]['output']>) => void;
|
||||
}
|
||||
|
||||
export interface QueuedTaskId<Key extends TaskKey = TaskKey> {
|
||||
@@ -112,16 +151,19 @@ export type QueuedRecord<Tasks extends TaskRecord> = {
|
||||
[K in keyof Tasks]?: QueuedTask<TaskKey<K>>;
|
||||
};
|
||||
|
||||
export type PendingRecord<Tasks extends TaskRecord> = {
|
||||
[K in keyof Tasks]?: PendingTask<TaskKey<K>, Tasks[K]['input']>;
|
||||
/**
|
||||
* Map of task key -> task (pending, success, or error).
|
||||
*/
|
||||
export type TasksRecord<Tasks extends TaskRecord> = {
|
||||
[K in keyof Tasks]?: Task<TaskKey<K>, Tasks[K]['input'], Tasks[K]['output']>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Listener callback for pending state changes.
|
||||
* Listener callback for task state changes.
|
||||
*
|
||||
* Called when tasks are dispatched or settled.
|
||||
* Called when tasks are dispatched, settled, or reset.
|
||||
*/
|
||||
export type QueueListener<Tasks extends TaskRecord> = (pending: PendingRecord<Tasks>) => void;
|
||||
export type QueueListener<Tasks extends TaskRecord> = (tasks: TasksRecord<Tasks>) => void;
|
||||
|
||||
// ----------------------------------------
|
||||
// Schedulers
|
||||
@@ -168,36 +210,27 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
readonly #subscribers = new Set<QueueListener<Tasks>>();
|
||||
|
||||
#queued: QueuedRecord<Tasks> = {};
|
||||
#pending: PendingRecord<Tasks> = {};
|
||||
#tasks: TasksRecord<Tasks> = {};
|
||||
#destroyed = false;
|
||||
|
||||
constructor(config: QueueConfig<Tasks> = {}) {
|
||||
this.#scheduler = config.scheduler ?? microtask;
|
||||
|
||||
// Wrap callbacks to catch errors and prevent breaking queue/scheduler
|
||||
const safeCallback = <Args extends [PendingTask, ...unknown[]]>(
|
||||
callback: ((...args: Args) => unknown) | undefined,
|
||||
) => {
|
||||
if (!callback) return undefined;
|
||||
return (...args: Args) => {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (e) {
|
||||
console.error('[vjs-queue]', e);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
this.#onDispatch = safeCallback(config.onDispatch);
|
||||
this.#onSettled = safeCallback(config.onSettled);
|
||||
const logError = (e: unknown) => console.error('[vjs-queue]', e);
|
||||
this.#onDispatch = tryCatch(config.onDispatch, logError);
|
||||
this.#onSettled = tryCatch(config.onSettled, logError);
|
||||
}
|
||||
|
||||
get queued(): Readonly<PublicQueuedRecord<Tasks>> {
|
||||
return Object.freeze({ ...this.#queued });
|
||||
}
|
||||
|
||||
get pending(): Readonly<PendingRecord<Tasks>> {
|
||||
return Object.freeze({ ...this.#pending });
|
||||
/**
|
||||
* Map of task key -> task (pending, success, or error).
|
||||
*/
|
||||
get tasks(): Readonly<TasksRecord<Tasks>> {
|
||||
return Object.freeze({ ...this.#tasks });
|
||||
}
|
||||
|
||||
get destroyed(): boolean {
|
||||
@@ -208,7 +241,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
* Check if a task with the given key is currently pending (executing).
|
||||
*/
|
||||
isPending(key: keyof Tasks): boolean {
|
||||
return key in this.#pending;
|
||||
return this.#tasks[key]?.status === 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,11 +252,53 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to pending state changes.
|
||||
* Check if a task with the given key is settled (success or error).
|
||||
*/
|
||||
isSettled(key: keyof Tasks): boolean {
|
||||
const task = this.#tasks[key];
|
||||
return task?.status === 'success' || task?.status === 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear settled task(s).
|
||||
*
|
||||
* Fires when tasks are dispatched or settled.
|
||||
* - If key provided: clears that specific settled task (no-op if pending or doesn't exist)
|
||||
* - If no key: clears all settled tasks (pending tasks are preserved)
|
||||
*
|
||||
* @param listener - Callback receiving the current pending map
|
||||
* @param key - Optional task key to reset. If omitted, resets all settled tasks.
|
||||
*/
|
||||
reset(key?: keyof Tasks): void {
|
||||
if (!isUndefined(key)) {
|
||||
const task = this.#tasks[key];
|
||||
if (!task || task.status === 'pending') return;
|
||||
|
||||
delete this.#tasks[key];
|
||||
this.#notifySubscribers();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset all settled tasks
|
||||
let cleared = false;
|
||||
for (const key of Reflect.ownKeys(this.#tasks)) {
|
||||
const task = this.#tasks[key];
|
||||
if (task && task.status !== 'pending') {
|
||||
delete this.#tasks[key];
|
||||
cleared = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleared) {
|
||||
this.#notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to task state changes.
|
||||
*
|
||||
* Fires when tasks are dispatched, settled, or reset.
|
||||
*
|
||||
* @param listener - Callback receiving the current tasks map
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
subscribe(listener: QueueListener<Tasks>): () => void {
|
||||
@@ -236,7 +311,7 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
#notifySubscribers(): void {
|
||||
if (this.#subscribers.size === 0) return;
|
||||
|
||||
const snapshot = this.pending;
|
||||
const snapshot = this.tasks;
|
||||
for (const listener of this.#subscribers) {
|
||||
try {
|
||||
listener(snapshot);
|
||||
@@ -262,7 +337,13 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
delete this.#queued[key];
|
||||
|
||||
// Abort any pending task with same key
|
||||
this.#pending[key]?.abort.abort(new StoreError('SUPERSEDED'));
|
||||
const existing = this.#tasks[key];
|
||||
if (existing?.status === 'pending') {
|
||||
existing.abort.abort(new StoreError('SUPERSEDED'));
|
||||
}
|
||||
|
||||
// Clear any settled task for this key (new request replaces it)
|
||||
delete this.#tasks[key];
|
||||
|
||||
return new Promise<Tasks[K]['output']>((resolve, reject) => {
|
||||
const task: QueuedTask = {
|
||||
@@ -307,24 +388,37 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
});
|
||||
}
|
||||
|
||||
dequeue<K extends keyof Tasks>(key: K): boolean {
|
||||
const queued = this.#queued[key];
|
||||
if (!queued) return false;
|
||||
/**
|
||||
* Cancel queued task(s) waiting to execute.
|
||||
*
|
||||
* - If key provided: cancels that specific queued task
|
||||
* - If no key: cancels all queued tasks
|
||||
*
|
||||
* @param key - Optional task key to cancel
|
||||
* @returns true if any task was cancelled
|
||||
*/
|
||||
cancel(key?: keyof Tasks): boolean {
|
||||
if (!isUndefined(key)) {
|
||||
const queued = this.#queued[key];
|
||||
if (!queued) return false;
|
||||
|
||||
queued.invalidate?.();
|
||||
queued.reject(new StoreError('REMOVED'));
|
||||
delete this.#queued[key];
|
||||
queued.invalidate?.();
|
||||
queued.reject(new StoreError('REMOVED'));
|
||||
delete this.#queued[key];
|
||||
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
// Cancel all queued
|
||||
const hadQueued = Object.keys(this.#queued).length > 0;
|
||||
for (const queued of Object.values(this.#queued)) {
|
||||
queued.invalidate?.();
|
||||
queued.reject(new StoreError('REMOVED'));
|
||||
}
|
||||
|
||||
this.#queued = {};
|
||||
|
||||
return hadQueued;
|
||||
}
|
||||
|
||||
async flush(key?: keyof Tasks): Promise<void> {
|
||||
@@ -338,18 +432,32 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
await Promise.allSettled(keys.map(k => this.#flushKey(k)));
|
||||
}
|
||||
|
||||
abort<K extends keyof Tasks>(key: K): void {
|
||||
// Reject queued
|
||||
const queued = this.#queued[key];
|
||||
queued?.invalidate?.();
|
||||
queued?.reject(new StoreError('ABORTED'));
|
||||
delete this.#queued[key];
|
||||
/**
|
||||
* Abort task(s) - both queued (waiting) and pending (executing).
|
||||
*
|
||||
* - If key provided: aborts that specific task
|
||||
* - If no key: aborts all tasks
|
||||
*
|
||||
* @param key - Optional task key to abort
|
||||
*/
|
||||
abort(key?: keyof Tasks): void {
|
||||
if (!isUndefined(key)) {
|
||||
// Reject queued
|
||||
const queued = this.#queued[key];
|
||||
queued?.invalidate?.();
|
||||
queued?.reject(new StoreError('ABORTED'));
|
||||
delete this.#queued[key];
|
||||
|
||||
// Abort pending
|
||||
this.#pending[key]?.abort.abort(new StoreError('ABORTED'));
|
||||
}
|
||||
// Abort pending task
|
||||
const task = this.#tasks[key];
|
||||
if (task?.status === 'pending') {
|
||||
task.abort.abort(new StoreError('ABORTED'));
|
||||
}
|
||||
|
||||
abortAll(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort all
|
||||
const error = new StoreError('ABORTED');
|
||||
|
||||
// Reject all queued
|
||||
@@ -360,9 +468,11 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
|
||||
this.#queued = {};
|
||||
|
||||
// Abort all pending
|
||||
for (const pending of Object.values(this.#pending)) {
|
||||
pending.abort.abort(error);
|
||||
// Abort all pending tasks
|
||||
for (const task of Object.values(this.#tasks)) {
|
||||
if (task?.status === 'pending') {
|
||||
task.abort.abort(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,8 +480,9 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
if (this.#destroyed) return;
|
||||
|
||||
this.#destroyed = true;
|
||||
this.abortAll();
|
||||
this.abort();
|
||||
this.#subscribers.clear();
|
||||
this.#tasks = {};
|
||||
}
|
||||
|
||||
async #flushKey(key: keyof Tasks): Promise<void> {
|
||||
@@ -393,7 +504,8 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
const abort = new AbortController();
|
||||
const startedAt = Date.now();
|
||||
|
||||
const pending: PendingTask = {
|
||||
const pendingTask: PendingTask = {
|
||||
status: 'pending',
|
||||
id,
|
||||
name,
|
||||
key,
|
||||
@@ -403,9 +515,9 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
meta,
|
||||
};
|
||||
|
||||
this.#pending[key as keyof Tasks] = pending;
|
||||
this.#tasks[key as keyof Tasks] = pendingTask;
|
||||
this.#notifySubscribers();
|
||||
this.#onDispatch?.(pending);
|
||||
this.#onDispatch?.(pendingTask);
|
||||
|
||||
try {
|
||||
if (abort.signal.aborted) {
|
||||
@@ -420,28 +532,38 @@ export class Queue<Tasks extends TaskRecord = DefaultTaskRecord> {
|
||||
|
||||
resolve(result);
|
||||
|
||||
this.#onSettled?.(pending, {
|
||||
const successTask: SuccessTask = {
|
||||
...pendingTask,
|
||||
status: 'success',
|
||||
duration: Date.now() - startedAt,
|
||||
settledAt: Date.now(),
|
||||
output: result,
|
||||
});
|
||||
};
|
||||
|
||||
// Only update if we're still the current task for this key
|
||||
if (this.#tasks[key as keyof Tasks] === pendingTask) {
|
||||
this.#tasks[key as keyof Tasks] = successTask;
|
||||
this.#notifySubscribers();
|
||||
}
|
||||
|
||||
this.#onSettled?.(successTask);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
|
||||
const cancelled = abort.signal.aborted;
|
||||
|
||||
this.#onSettled?.(pending, {
|
||||
status: cancelled ? 'cancelled' : 'error',
|
||||
duration: Date.now() - startedAt,
|
||||
const errorTask: ErrorTask = {
|
||||
...pendingTask,
|
||||
status: 'error',
|
||||
settledAt: Date.now(),
|
||||
error,
|
||||
});
|
||||
} finally {
|
||||
const currentPending = this.#pending[key as keyof Tasks];
|
||||
// Only remove if we're still the pending task for this key
|
||||
if (currentPending === pending) {
|
||||
delete this.#pending[key];
|
||||
cancelled: abort.signal.aborted,
|
||||
};
|
||||
|
||||
// Only update if we're still the current task for this key
|
||||
if (this.#tasks[key as keyof Tasks] === pendingTask) {
|
||||
this.#tasks[key as keyof Tasks] = errorTask;
|
||||
this.#notifySubscribers();
|
||||
}
|
||||
|
||||
this.#onSettled?.(errorTask);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PendingTask, TaskContext } from './queue';
|
||||
import type { PendingTask, Task, TaskContext } from './queue';
|
||||
import type { RequestMeta, RequestMetaInit, ResolvedRequestConfig } from './request';
|
||||
import type { AnySlice, InferSliceTarget, Slice, UnionSliceRequests, UnionSliceState, UnionSliceTasks } from './slice';
|
||||
import type { StateFactory } from './state';
|
||||
@@ -138,7 +138,7 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
|
||||
this.#attachAbort?.abort();
|
||||
this.#attachAbort = null;
|
||||
this.#target = null;
|
||||
this.#queue.abortAll();
|
||||
this.#queue.abort();
|
||||
this.#resetState();
|
||||
}
|
||||
|
||||
@@ -318,10 +318,11 @@ export class Store<Target, Slices extends AnySlice<Target>[] = AnySlice<Target>[
|
||||
handler,
|
||||
});
|
||||
} catch (error) {
|
||||
const pending = this.#queue.pending as Record<string | symbol, PendingTask | undefined>;
|
||||
const tasks = this.#queue.tasks as Record<string | symbol, Task | undefined>;
|
||||
const task = tasks[key];
|
||||
|
||||
this.#handleError({
|
||||
request: pending[key],
|
||||
request: task?.status === 'pending' ? task : undefined,
|
||||
error,
|
||||
});
|
||||
|
||||
|
||||
@@ -163,8 +163,8 @@ describe('queue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dequeue', () => {
|
||||
it('removes queued task', async () => {
|
||||
describe('cancel', () => {
|
||||
it('cancel(key) removes specific queued task', async () => {
|
||||
const queue = createQueue({
|
||||
scheduler: delay(100),
|
||||
});
|
||||
@@ -172,29 +172,33 @@ describe('queue', () => {
|
||||
const handler = vi.fn();
|
||||
const promise = queue.enqueue({ name: 'test', key: 'k', handler });
|
||||
|
||||
expect(queue.dequeue('k')).toBe(true);
|
||||
expect(queue.dequeue('k')).toBe(false);
|
||||
expect(queue.cancel('k')).toBe(true);
|
||||
expect(queue.cancel('k')).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
await expect(promise).rejects.toMatchObject({ code: 'REMOVED' });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('clears all queued tasks', async () => {
|
||||
it('cancel() clears all queued tasks', async () => {
|
||||
const queue = createQueue({ scheduler: delay(100) });
|
||||
|
||||
const p1 = queue.enqueue({ name: 'a', key: 'a', handler: vi.fn() });
|
||||
const p2 = queue.enqueue({ name: 'b', key: 'b', handler: vi.fn() });
|
||||
|
||||
queue.clear();
|
||||
expect(queue.cancel()).toBe(true);
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
await expect(p1).rejects.toMatchObject({ code: 'REMOVED' });
|
||||
await expect(p2).rejects.toMatchObject({ code: 'REMOVED' });
|
||||
expect(Reflect.ownKeys(queue.queued).length).toBe(0);
|
||||
});
|
||||
|
||||
it('cancel() returns false when no queued tasks', () => {
|
||||
const queue = createQueue();
|
||||
|
||||
expect(queue.cancel()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flush', () => {
|
||||
@@ -280,7 +284,7 @@ describe('queue', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('onSettled called with success', async () => {
|
||||
it('onSettled called with success task', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const onSettled = vi.fn();
|
||||
@@ -293,12 +297,11 @@ describe('queue', () => {
|
||||
});
|
||||
|
||||
expect(onSettled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'task' }),
|
||||
expect.objectContaining({ status: 'success' }),
|
||||
expect.objectContaining({ name: 'task', status: 'success', output: 'done' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('onSettled called with error status', async () => {
|
||||
it('onSettled called with error task', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const onSettled = vi.fn();
|
||||
@@ -313,10 +316,10 @@ describe('queue', () => {
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('oops');
|
||||
expect(onSettled).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: 'error' }));
|
||||
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({ status: 'error', cancelled: false }));
|
||||
});
|
||||
|
||||
it('onSettled called with cancelled status', async () => {
|
||||
it('onSettled called with cancelled task', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const onSettled = vi.fn();
|
||||
@@ -339,8 +342,7 @@ describe('queue', () => {
|
||||
await promise.catch(() => {});
|
||||
|
||||
expect(onSettled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'first' }),
|
||||
expect.objectContaining({ status: 'cancelled' }),
|
||||
expect.objectContaining({ name: 'first', status: 'error', cancelled: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -436,7 +438,7 @@ describe('queue', () => {
|
||||
|
||||
// Wait for task to start
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
expect(Reflect.ownKeys(queue.pending).length).toBe(1);
|
||||
expect(queue.tasks.k?.status).toBe('pending');
|
||||
|
||||
// Destroy queue
|
||||
queue.destroy();
|
||||
@@ -446,8 +448,8 @@ describe('queue', () => {
|
||||
expect(cleanupSpy).toHaveBeenCalledWith('aborted');
|
||||
expect(cleanupSpy).toHaveBeenCalledWith('cleanup');
|
||||
|
||||
// Pending object should be empty (self-cleaned)
|
||||
expect(Reflect.ownKeys(queue.pending).length).toBe(0);
|
||||
// After destroy, all tasks are cleared for memory cleanup
|
||||
expect(queue.tasks.k).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles scheduler error without double-cleanup when already flushed', async () => {
|
||||
@@ -575,7 +577,7 @@ describe('queue', () => {
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('notifies with pending map when task dispatches', async () => {
|
||||
it('notifies with tasks map on dispatch and settlement', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
@@ -602,18 +604,19 @@ describe('queue', () => {
|
||||
|
||||
// First call should have pending task
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const pendingObj = listener.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(Reflect.ownKeys(pendingObj).length).toBe(1);
|
||||
expect('test-key' in pendingObj).toBe(true);
|
||||
const pendingSnapshot = listener.mock.calls[0]![0] as Record<string, { status: string }>;
|
||||
expect(Reflect.ownKeys(pendingSnapshot).length).toBe(1);
|
||||
expect(pendingSnapshot['test-key']?.status).toBe('pending');
|
||||
|
||||
// Complete the handler
|
||||
resolveHandler!();
|
||||
await promise;
|
||||
|
||||
// Second call should have empty pending
|
||||
// Second call should have settled task (success)
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
const settledObj = listener.mock.calls[1]![0] as Record<string, unknown>;
|
||||
expect(Reflect.ownKeys(settledObj).length).toBe(0);
|
||||
const settledSnapshot = listener.mock.calls[1]![0] as Record<string, { status: string }>;
|
||||
expect(Reflect.ownKeys(settledSnapshot).length).toBe(1);
|
||||
expect(settledSnapshot['test-key']?.status).toBe('success');
|
||||
});
|
||||
|
||||
it('unsubscribe stops notifications', async () => {
|
||||
@@ -683,15 +686,15 @@ describe('queue', () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('provides strongly typed pending object', async () => {
|
||||
it('provides strongly typed tasks object', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
// Use default queue - type safety is validated at compile time
|
||||
const queue = createQueue();
|
||||
|
||||
queue.subscribe((pending) => {
|
||||
// Pending is a frozen object
|
||||
const task = pending.playback;
|
||||
queue.subscribe((tasks) => {
|
||||
// Tasks is a frozen object
|
||||
const task = tasks.playback;
|
||||
if (task) {
|
||||
expect(task.key).toBe('playback');
|
||||
expect(task.name).toBeDefined();
|
||||
@@ -705,5 +708,406 @@ describe('queue', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('task lifecycle', () => {
|
||||
it('task starts as pending and transitions to success', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
|
||||
const promise = queue.enqueue({
|
||||
name: 'task',
|
||||
key: 'k',
|
||||
handler: async () => {
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
return 'result';
|
||||
},
|
||||
});
|
||||
|
||||
// Task should be pending
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
const pendingTask = queue.tasks.k;
|
||||
expect(pendingTask?.status).toBe('pending');
|
||||
expect(pendingTask?.name).toBe('task');
|
||||
|
||||
// Wait for completion
|
||||
await promise;
|
||||
|
||||
// Task should be success
|
||||
const successTask = queue.tasks.k;
|
||||
expect(successTask?.status).toBe('success');
|
||||
if (successTask?.status === 'success') {
|
||||
expect(successTask.output).toBe('result');
|
||||
expect(successTask.settledAt).toBeGreaterThan(successTask.startedAt);
|
||||
}
|
||||
});
|
||||
|
||||
it('task starts as pending and transitions to error', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
const error = new Error('test error');
|
||||
|
||||
const promise = queue.enqueue({
|
||||
name: 'task',
|
||||
key: 'k',
|
||||
handler: async () => {
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
// Task should be pending
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
expect(queue.tasks.k?.status).toBe('pending');
|
||||
|
||||
// Wait for failure
|
||||
await expect(promise).rejects.toThrow('test error');
|
||||
|
||||
// Task should be error
|
||||
const errorTask = queue.tasks.k;
|
||||
expect(errorTask?.status).toBe('error');
|
||||
if (errorTask?.status === 'error') {
|
||||
expect(errorTask.error).toBe(error);
|
||||
expect(errorTask.cancelled).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('aborted task has cancelled flag set to true', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
|
||||
const promise = queue.enqueue({
|
||||
name: 'task',
|
||||
key: 'k',
|
||||
handler: async ({ signal }) => {
|
||||
await new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason));
|
||||
setTimeout(() => {}, 1000);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for task to start
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
expect(queue.tasks.k?.status).toBe('pending');
|
||||
|
||||
// Abort the task
|
||||
queue.abort('k');
|
||||
await promise.catch(() => {});
|
||||
|
||||
// Task should be error with cancelled=true
|
||||
const errorTask = queue.tasks.k;
|
||||
expect(errorTask?.status).toBe('error');
|
||||
if (errorTask?.status === 'error') {
|
||||
expect(errorTask.cancelled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('new request replaces settled task', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
|
||||
// First request
|
||||
await queue.enqueue({
|
||||
name: 'first',
|
||||
key: 'k',
|
||||
handler: async () => 'first-result',
|
||||
});
|
||||
|
||||
expect(queue.tasks.k?.status).toBe('success');
|
||||
if (queue.tasks.k?.status === 'success') {
|
||||
expect(queue.tasks.k.output).toBe('first-result');
|
||||
}
|
||||
|
||||
// Second request replaces settled task
|
||||
await queue.enqueue({
|
||||
name: 'second',
|
||||
key: 'k',
|
||||
handler: async () => 'second-result',
|
||||
});
|
||||
|
||||
expect(queue.tasks.k?.status).toBe('success');
|
||||
if (queue.tasks.k?.status === 'success') {
|
||||
expect(queue.tasks.k.output).toBe('second-result');
|
||||
expect(queue.tasks.k.name).toBe('second');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears settled task', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
|
||||
await queue.enqueue({
|
||||
name: 'task',
|
||||
key: 'k',
|
||||
handler: async () => 'result',
|
||||
});
|
||||
|
||||
expect(queue.tasks.k?.status).toBe('success');
|
||||
|
||||
queue.reset('k');
|
||||
|
||||
expect(queue.tasks.k).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is no-op when task is pending', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
|
||||
const promise = queue.enqueue({
|
||||
name: 'task',
|
||||
key: 'k',
|
||||
handler: async () => {
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
return 'result';
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for task to start
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
expect(queue.tasks.k?.status).toBe('pending');
|
||||
|
||||
// Reset should be no-op
|
||||
queue.reset('k');
|
||||
expect(queue.tasks.k?.status).toBe('pending');
|
||||
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('is no-op when task does not exist', () => {
|
||||
const queue = createQueue();
|
||||
|
||||
// Should not throw
|
||||
queue.reset('nonexistent');
|
||||
|
||||
expect(queue.tasks.nonexistent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('notifies subscribers when reset clears a task', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
const listener = vi.fn();
|
||||
|
||||
await queue.enqueue({
|
||||
name: 'task',
|
||||
key: 'k',
|
||||
handler: async () => 'result',
|
||||
});
|
||||
|
||||
queue.subscribe(listener);
|
||||
|
||||
queue.reset('k');
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const snapshot = listener.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(snapshot.k).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not notify subscribers when task does not exist', () => {
|
||||
const queue = createQueue();
|
||||
const listener = vi.fn();
|
||||
|
||||
queue.subscribe(listener);
|
||||
queue.reset('nonexistent');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets all settled tasks when no key provided', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
|
||||
// Create multiple settled tasks
|
||||
await queue.enqueue({ name: 'a', key: 'a', handler: async () => 'a-result' });
|
||||
await queue.enqueue({ name: 'b', key: 'b', handler: async () => 'b-result' });
|
||||
|
||||
expect(queue.tasks.a?.status).toBe('success');
|
||||
expect(queue.tasks.b?.status).toBe('success');
|
||||
|
||||
// Reset all
|
||||
queue.reset();
|
||||
|
||||
expect(queue.tasks.a).toBeUndefined();
|
||||
expect(queue.tasks.b).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves pending tasks when resetting all', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
|
||||
// Create a settled task
|
||||
await queue.enqueue({ name: 'settled', key: 'settled', handler: async () => 'done' });
|
||||
|
||||
// Create a pending task
|
||||
const pendingPromise = queue.enqueue({
|
||||
name: 'pending',
|
||||
key: 'pending',
|
||||
handler: async () => {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
return 'pending-done';
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
expect(queue.tasks.settled?.status).toBe('success');
|
||||
expect(queue.tasks.pending?.status).toBe('pending');
|
||||
|
||||
// Reset all - should only clear settled
|
||||
queue.reset();
|
||||
|
||||
expect(queue.tasks.settled).toBeUndefined();
|
||||
expect(queue.tasks.pending?.status).toBe('pending');
|
||||
|
||||
await pendingPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSettled', () => {
|
||||
it('returns true for success task', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
|
||||
|
||||
expect(queue.isSettled('k')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for error task', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
const promise = queue.enqueue({
|
||||
name: 'task',
|
||||
key: 'k',
|
||||
handler: async () => {
|
||||
throw new Error('fail');
|
||||
},
|
||||
});
|
||||
|
||||
await promise.catch(() => {});
|
||||
|
||||
expect(queue.isSettled('k')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for pending task', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
const promise = queue.enqueue({
|
||||
name: 'task',
|
||||
key: 'k',
|
||||
handler: async () => {
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
return 'result';
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
|
||||
expect(queue.isSettled('k')).toBe(false);
|
||||
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('returns false for non-existent task', () => {
|
||||
const queue = createQueue();
|
||||
|
||||
expect(queue.isSettled('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy cleanup', () => {
|
||||
it('clears all task references on destroy', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
|
||||
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
|
||||
expect(queue.tasks.k?.status).toBe('success');
|
||||
|
||||
queue.destroy();
|
||||
|
||||
expect(Reflect.ownKeys(queue.tasks).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tasks getter', () => {
|
||||
it('returns frozen snapshot', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
await queue.enqueue({ name: 'task', key: 'k', handler: async () => 'result' });
|
||||
|
||||
const tasks = queue.tasks;
|
||||
|
||||
expect(Object.isFrozen(tasks)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns independent snapshots', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
await queue.enqueue({ name: 'first', key: 'k', handler: async () => 'first' });
|
||||
|
||||
const snapshot1 = queue.tasks;
|
||||
|
||||
await queue.enqueue({ name: 'second', key: 'k', handler: async () => 'second' });
|
||||
|
||||
const snapshot2 = queue.tasks;
|
||||
|
||||
// Snapshots should be independent
|
||||
expect(snapshot1).not.toBe(snapshot2);
|
||||
if (snapshot1.k?.status === 'success' && snapshot2.k?.status === 'success') {
|
||||
expect(snapshot1.k.output).toBe('first');
|
||||
expect(snapshot2.k.output).toBe('second');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('symbol keys', () => {
|
||||
it('supports symbol keys', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
const key = Symbol('task');
|
||||
|
||||
await queue.enqueue({
|
||||
name: 'task',
|
||||
key,
|
||||
handler: async () => 'result',
|
||||
});
|
||||
|
||||
expect(queue.tasks[key]?.status).toBe('success');
|
||||
if (queue.tasks[key]?.status === 'success') {
|
||||
expect(queue.tasks[key].output).toBe('result');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('meta propagation', () => {
|
||||
it('meta defaults to null when not provided', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const queue = createQueue();
|
||||
|
||||
await queue.enqueue({
|
||||
name: 'task',
|
||||
key: 'k',
|
||||
handler: async () => 'result',
|
||||
});
|
||||
|
||||
expect(queue.tasks.k?.meta).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user