• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-monorepo / 20774689234

07 Jan 2026 07:48AM UTC coverage: 81.711% (+0.4%) from 81.283%
20774689234

push

github

apowers313
test: add seed to arf layout

13684 of 18082 branches covered (75.68%)

Branch coverage included in aggregate %.

42283 of 50412 relevant lines covered (83.87%)

151314.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.19
/graphty-element/src/managers/OperationQueueManager.ts
1
import PQueue from "p-queue";
15✔
2
import toposort from "toposort";
15!
3

4
import { OBSOLESCENCE_RULES } from "../constants/obsolescence-rules";
15✔
5
import { GraphtyLogger, type Logger } from "../logging/GraphtyLogger.js";
15✔
6
import type { OperationMetadata, OperationProgress } from "../types/operations";
7
import type { EventManager } from "./EventManager";
8
import type { Manager } from "./interfaces";
9

10
// Constants for operation queue management
11
const PROGRESS_CANCELLATION_THRESHOLD = 90; // Progress threshold for respecting in-progress operations
15✔
12
const CLEANUP_DELAY_MS = 1000; // Delay before cleaning up operation progress tracking
15✔
13
const OPERATION_POLL_INTERVAL_MS = 10; // Polling interval for waitForCompletion
15✔
14

15
export interface ProgressContext {
16
    setProgress(percent: number): void;
17
    setMessage(message: string): void;
18
    setPhase(phase: string): void;
19
}
20

21
export interface OperationContext {
22
    signal: AbortSignal;
23
    progress: ProgressContext;
24
    id: string;
25
}
26

27
export interface Operation {
28
    id: string;
29
    category: OperationCategory;
30
    execute: (context: OperationContext) => Promise<void> | void;
31
    abortController?: AbortController;
32
    metadata?: OperationMetadata;
33
    resolve?: (value: void | PromiseLike<void>) => void;
34
    reject?: (reason?: unknown) => void;
35
}
36

37
export type OperationCategory =
38
    | "style-init" // Initialize style template
39
    | "style-apply" // Apply styles to existing elements
40
    | "data-add" // Add nodes/edges
41
    | "data-remove" // Remove nodes/edges
42
    | "data-update" // Update node/edge properties
43
    | "layout-set" // Set layout engine
44
    | "layout-update" // Update layout positions
45
    | "algorithm-run" // Run graph algorithms
46
    | "camera-update" // Update camera position/mode
47
    | "render-update"; // Update rendering settings
48

49
/**
50
 * Manages a queue of graph operations with dependency resolution and batching
51
 * Ensures operations execute in the correct order based on their dependencies
52
 */
53
export class OperationQueueManager implements Manager {
15✔
54
    private queue: PQueue;
55
    private pendingOperations = new Map<string, Operation>();
23✔
56
    private operationCounter = 0;
23✔
57
    private batchingEnabled = true;
23✔
58
    private currentBatch = new Set<string>();
23✔
59
    private logger: Logger = GraphtyLogger.getLogger(["graphty", "operation"]);
23✔
60

61
    // Progress tracking
62
    private operationProgress = new Map<string, OperationProgress>();
23✔
63

64
    // Active operation controllers for cancellation
65
    private activeControllers = new Map<string, AbortController>();
23✔
66

67
    // Track running vs queued operations
68
    private runningOperations = new Map<string, Operation>();
23✔
69
    private queuedOperations = new Map<string, Operation>();
23✔
70

71
    // Track completed operation categories for cross-batch dependency resolution
72
    private completedCategories = new Set<OperationCategory>();
23✔
73

74
    // Batch mode support
75
    private batchMode = false;
23✔
76
    private batchOperations = new Set<string>();
23✔
77
    private batchPromises = new Map<string, Promise<void>>();
23✔
78

79
    // Trigger system
80
    private triggers = new Map<
23✔
81
        OperationCategory,
82
        ((metadata?: OperationMetadata) => {
83
            category: OperationCategory;
84
            execute: (context: OperationContext) => Promise<void> | void;
85
            description?: string;
86
        } | null)[]
87
    >();
23✔
88

89
    // Callback for when operations are queued (for testing)
90
    onOperationQueued?: (category: OperationCategory, description?: string) => void;
91

92
    // Check if layout engine exists (will be set by Graph)
93
    hasLayoutEngine?: () => boolean;
94

95
    // Dependency graph based on actual manager dependencies
96
    private static readonly CATEGORY_DEPENDENCIES: [OperationCategory, OperationCategory][] = [
23✔
97
        // Style must be initialized before applying
98
        ["style-apply", "style-init"],
23✔
99

100
        // Data operations depend on style being ready
101
        ["data-add", "style-init"],
23✔
102
        ["data-update", "style-init"],
23✔
103

104
        // Layout-set does NOT depend on data - it can create an empty layout engine
105
        // Data will be added to the layout engine when data-add runs later
106
        // ["layout-set", "data-add"], // REMOVED for stateless design
107

108
        // Layout-update DOES depend on data existing
109
        ["layout-update", "data-add"],
23✔
110
        // Layout-update depends on layout being set first
111
        ["layout-update", "layout-set"],
23✔
112

113
        // Algorithms depend on data
114
        ["algorithm-run", "data-add"],
23✔
115

116
        // Style application depends on algorithms (for calculated styles)
117
        ["style-apply", "algorithm-run"],
23✔
118

119
        // Camera updates may depend on layout for zoom-to-fit
120
        ["camera-update", "layout-set"],
23✔
121

122
        // Render updates come last
123
        ["render-update", "style-apply"],
23✔
124
        ["render-update", "data-add"],
23✔
125
        ["render-update", "layout-update"],
23✔
126
    ];
23✔
127

128
    // Post-execution triggers: operations that automatically trigger after other operations
129
    private static readonly POST_EXECUTION_TRIGGERS: Partial<Record<OperationCategory, OperationCategory[]>> = {
23✔
130
        "data-add": ["layout-update"],
23✔
131
        "data-remove": ["layout-update"],
23✔
132
        "data-update": ["layout-update"],
23✔
133
        "algorithm-run": ["style-apply"],
23✔
134
    };
23✔
135

136
    /**
137
     * Creates a new operation queue manager
138
     * @param eventManager - Event manager for emitting operation events
139
     * @param options - Queue configuration options
140
     * @param options.concurrency - Maximum concurrent operations (default: 1)
141
     * @param options.autoStart - Whether to auto-start the queue (default: true)
142
     * @param options.intervalCap - Maximum operations per interval
143
     * @param options.interval - Time interval in milliseconds
144
     */
145
    constructor(
23✔
146
        private eventManager: EventManager,
1,065✔
147
        options: {
1,065✔
148
            concurrency?: number;
149
            autoStart?: boolean;
150
            intervalCap?: number;
151
            interval?: number;
152
        } = {},
1,065✔
153
    ) {
1,065✔
154
        const queueOptions: {
1,065✔
155
            concurrency?: number;
156
            autoStart?: boolean;
157
            intervalCap?: number;
158
            interval?: number;
159
        } = {
1,065✔
160
            concurrency: options.concurrency ?? 1, // Sequential by default
1,065!
161
            autoStart: options.autoStart ?? true,
1,065!
162
        };
1,065✔
163

164
        if (options.intervalCap !== undefined) {
1,065!
165
            queueOptions.intervalCap = options.intervalCap;
×
166
        }
×
167

168
        if (options.interval !== undefined) {
1,065!
169
            queueOptions.interval = options.interval;
×
170
        }
×
171

172
        this.queue = new PQueue(queueOptions);
1,065✔
173

174
        // Listen for queue events
175
        this.queue.on("active", () => {
1,065✔
176
            this.eventManager.emitGraphEvent("operation-queue-active", {
40,837✔
177
                size: this.queue.size,
40,837✔
178
                pending: this.queue.pending,
40,837✔
179
            });
40,837✔
180
        });
1,065✔
181

182
        this.queue.on("idle", () => {
1,065✔
183
            this.eventManager.emitGraphEvent("operation-queue-idle", {});
2,092✔
184
        });
1,065✔
185
    }
1,065✔
186

187
    /**
188
     * Initialize the operation queue manager
189
     */
190
    async init(): Promise<void> {
23✔
191
        // No initialization needed
192
    }
939✔
193

194
    /**
195
     * Dispose the operation queue and cancel all active operations
196
     */
197
    dispose(): void {
23✔
198
        // Cancel all active operations
199
        this.activeControllers.forEach((controller) => {
778✔
200
            controller.abort();
38,740✔
201
        });
778✔
202

203
        this.queue.clear();
778✔
204
        this.pendingOperations.clear();
778✔
205
        this.operationProgress.clear();
778✔
206
        this.currentBatch.clear();
778✔
207
        this.activeControllers.clear();
778✔
208
        this.runningOperations.clear();
778✔
209
        this.queuedOperations.clear();
778✔
210
    }
778✔
211

212
    /**
213
     * Queue an operation for execution
214
     * Returns the operation ID
215
     * @param category - Category of the operation
216
     * @param execute - Function to execute for this operation
217
     * @param options - Optional metadata for the operation
218
     * @returns The unique operation ID
219
     */
220
    queueOperation(
23✔
221
        category: OperationCategory,
43,138✔
222
        execute: (context: OperationContext) => Promise<void> | void,
43,138✔
223
        options?: Partial<OperationMetadata>,
43,138✔
224
    ): string {
43,138✔
225
        const id = `op-${this.operationCounter++}`;
43,138✔
226
        const controller = new AbortController();
43,138✔
227

228
        // Initialize progress tracking with all fields
229
        this.operationProgress.set(id, {
43,138✔
230
            percent: 0,
43,138✔
231
            message: undefined,
43,138✔
232
            phase: undefined,
43,138✔
233
            startTime: Date.now(),
43,138✔
234
            lastUpdate: Date.now(),
43,138✔
235
        });
43,138✔
236

237
        const operation: Operation = {
43,138✔
238
            id,
43,138✔
239
            category,
43,138✔
240
            execute: async (ctx) => {
43,138✔
241
                const result = execute(ctx);
15,753✔
242
                if (result instanceof Promise) {
15,753✔
243
                    await result;
2,826✔
244
                }
2,798✔
245
            },
15,753✔
246
            abortController: controller,
43,138✔
247
            metadata: {
43,138✔
248
                ...options,
43,138✔
249
                timestamp: Date.now(),
43,138✔
250
            },
43,138✔
251
        };
43,138✔
252

253
        this.pendingOperations.set(id, operation);
43,138✔
254
        this.activeControllers.set(id, controller);
43,138✔
255

256
        this.logger.debug("Operation queued", {
43,138✔
257
            id,
43,138✔
258
            category,
43,138✔
259
            description: options?.description,
43,138✔
260
        });
43,138✔
261

262
        // Handle batch mode differently
263
        if (this.batchMode) {
43,138!
264
            // In batch mode: queue but don't execute yet
265
            this.batchOperations.add(id);
177✔
266
        } else {
43,138✔
267
            // Normal mode: add to current batch for immediate execution
268
            this.currentBatch.add(id);
42,961✔
269

270
            // Apply obsolescence rules before scheduling
271
            this.applyObsolescenceRules(operation);
42,961✔
272

273
            // Schedule batch execution
274
            if (this.batchingEnabled) {
42,961✔
275
                // When batching is enabled, schedule batch execution on next microtask
276
                if (this.currentBatch.size === 1) {
42,961✔
277
                    queueMicrotask(() => {
42,917✔
278
                        this.executeBatch();
42,917✔
279
                    });
42,917✔
280
                }
42,917✔
281
            } else {
42,961!
282
                // When batching is disabled, execute immediately
283
                queueMicrotask(() => {
×
284
                    this.executeBatch();
×
285
                });
×
286
            }
×
287
        }
42,961✔
288

289
        return id;
43,138✔
290
    }
43,138✔
291

292
    /**
293
     * Apply obsolescence rules for a new operation
294
     * @param newOperation - The new operation to check for obsolescence rules
295
     */
296
    private applyObsolescenceRules(newOperation: Operation): void {
23✔
297
        const { metadata } = newOperation;
42,961✔
298
        const defaultRule = OBSOLESCENCE_RULES[newOperation.category];
42,961✔
299
        // Only apply obsolescence if explicitly requested via metadata or default rules
300
        if (
42,961✔
301
            !metadata?.obsoletes &&
42,961✔
302
            !metadata?.shouldObsolete &&
42,949✔
303
            !metadata?.respectProgress &&
42,944✔
304
            !metadata?.skipRunning &&
42,944✔
305
            !defaultRule?.obsoletes
42,944✔
306
        ) {
42,961✔
307
            return;
154✔
308
        }
154✔
309

310
        // Get obsolescence rules from metadata or defaults
311
        const customObsoletes = metadata?.obsoletes ?? [];
42,961✔
312
        const defaultObsoletes = defaultRule?.obsoletes ?? [];
42,961!
313

314
        const categoriesToObsolete = [...new Set([...customObsoletes, ...defaultObsoletes])];
42,961✔
315
        const shouldObsolete = metadata?.shouldObsolete;
42,961✔
316
        const skipRunning = metadata?.skipRunning ?? defaultRule?.skipRunning ?? false;
42,961✔
317
        const respectProgress = metadata?.respectProgress ?? defaultRule?.respectProgress ?? true;
42,961!
318

319
        // Check all operations for obsolescence
320
        const allOperations = [
42,961✔
321
            ...this.pendingOperations.values(),
42,961✔
322
            ...this.queuedOperations.values(),
42,961✔
323
            ...(skipRunning ? [] : this.runningOperations.values()),
42,961!
324
        ];
42,961✔
325

326
        for (const operation of allOperations) {
42,961✔
327
            // Skip if it's the same operation
328
            if (operation.id === newOperation.id) {
14,317,318✔
329
                continue;
42,807✔
330
            }
42,807✔
331

332
            let shouldCancel = false;
14,274,511✔
333

334
            // Check category-based obsolescence
335
            if (categoriesToObsolete.includes(operation.category)) {
14,316,978✔
336
                shouldCancel = true;
25,271✔
337
            }
25,271✔
338

339
            // Check custom shouldObsolete function
340
            if (!shouldCancel && shouldObsolete) {
14,317,318!
341
                shouldCancel = shouldObsolete({
3✔
342
                    category: operation.category,
3✔
343
                    id: operation.id,
3✔
344
                    metadata: operation.metadata,
3✔
345
                });
3✔
346
            }
3✔
347

348
            if (shouldCancel) {
14,316,756✔
349
                // Check progress if respectProgress is enabled
350
                if (respectProgress && this.runningOperations.has(operation.id)) {
25,273!
351
                    const progress = this.operationProgress.get(operation.id);
5✔
352
                    if (progress && progress.percent >= PROGRESS_CANCELLATION_THRESHOLD) {
5!
353
                        // Don't cancel near-complete operations
354
                        continue;
×
355
                    }
×
356
                }
5✔
357

358
                // Cancel the operation
359
                const controller = this.activeControllers.get(operation.id);
25,273✔
360
                if (controller && !controller.signal.aborted) {
25,273✔
361
                    controller.abort();
25,235✔
362

363
                    // Emit obsolescence event
364
                    this.eventManager.emitGraphEvent("operation-obsoleted", {
25,235✔
365
                        id: operation.id,
25,235✔
366
                        category: operation.category,
25,235✔
367
                        reason: `Obsoleted by ${newOperation.category} operation`,
25,235✔
368
                        obsoletedBy: newOperation.id,
25,235✔
369
                    });
25,235✔
370

371
                    // Remove from pending/queued
372
                    this.pendingOperations.delete(operation.id);
25,235✔
373
                    this.queuedOperations.delete(operation.id);
25,235✔
374
                    this.currentBatch.delete(operation.id);
25,235✔
375
                }
25,235✔
376
            }
25,273✔
377
        }
14,317,318✔
378
    }
42,961✔
379

380
    /**
381
     * Execute all operations in the current batch
382
     */
383
    private executeBatch(): void {
23✔
384
        // Get all operations in current batch
385
        const batchIds = Array.from(this.currentBatch);
85,833✔
386
        this.currentBatch.clear();
85,833✔
387

388
        const operations = batchIds
85,833✔
389
            .map((id) => this.pendingOperations.get(id))
85,833✔
390
            .filter((op): op is Operation => op !== undefined);
85,833✔
391

392
        if (operations.length > 0) {
85,833✔
393
            this.logger.debug("Executing operation batch", {
42,940✔
394
                operationCount: operations.length,
42,940✔
395
                categories: [...new Set(operations.map((op) => op.category))],
42,940✔
396
            });
42,940✔
397
        }
42,940✔
398

399
        // Remove from pending and add to queued
400
        batchIds.forEach((id) => {
85,833✔
401
            const op = this.pendingOperations.get(id);
43,124✔
402
            if (op) {
43,124✔
403
                this.queuedOperations.set(id, op);
43,124✔
404
            }
43,124✔
405

406
            this.pendingOperations.delete(id);
43,124✔
407
        });
85,833✔
408

409
        if (operations.length === 0) {
85,833✔
410
            return;
42,893✔
411
        }
42,893✔
412

413
        // Sort operations by dependency order
414
        const sortedOperations = this.sortOperations(operations);
42,940✔
415

416
        // Add to queue with p-queue's signal support
417
        for (const operation of sortedOperations) {
43,414✔
418
            // queue.add always returns a promise
419
            void this.queue
43,124✔
420
                .add(
43,124✔
421
                    async ({ signal }) => {
43,124✔
422
                        try {
15,753✔
423
                            const context: OperationContext = {
15,753✔
424
                                signal: signal ?? operation.abortController?.signal ?? new AbortController().signal,
15,753!
425
                                progress: this.createProgressContext(operation.id, operation.category),
15,753✔
426
                                id: operation.id,
15,753✔
427
                            };
15,753✔
428
                            await this.executeOperation(operation, context);
15,753✔
429
                        } finally {
15,751✔
430
                            // Cleanup progress and controller after delay
431
                            setTimeout(() => {
15,751✔
432
                                this.operationProgress.delete(operation.id);
13,214✔
433
                                this.activeControllers.delete(operation.id);
13,214✔
434
                            }, CLEANUP_DELAY_MS);
15,751✔
435
                        }
15,751✔
436
                    },
15,753✔
437
                    {
43,124✔
438
                        signal: operation.abortController?.signal,
43,124✔
439
                    },
43,124✔
440
                )
43,124✔
441
                .catch((error: unknown) => {
43,124✔
442
                    if (error && (error as Error).name === "AbortError") {
25,207✔
443
                        this.eventManager.emitGraphEvent("operation-obsoleted", {
25,207✔
444
                            id: operation.id,
25,207✔
445
                            category: operation.category,
25,207✔
446
                            reason: "Obsoleted by newer operation",
25,207✔
447
                        });
25,207✔
448
                    }
25,207✔
449
                });
43,124✔
450
        }
43,124✔
451

452
        // Emit batch complete event after all operations
453
        this.eventManager.emitGraphEvent("operation-batch-complete", {
42,940✔
454
            operationCount: sortedOperations.length,
42,940✔
455
            operations: sortedOperations.map((op) => ({
42,940✔
456
                id: op.id,
43,124✔
457
                category: op.category,
43,124✔
458
                description: op.metadata?.description,
43,124✔
459
            })),
42,940✔
460
        });
42,940✔
461
    }
85,833✔
462

463
    /**
464
     * Sort operations based on category dependencies
465
     * @param operations - Array of operations to sort
466
     * @returns Sorted array of operations in dependency order
467
     */
468
    private sortOperations(operations: Operation[]): Operation[] {
23✔
469
        // Group by category
470
        const operationsByCategory = new Map<OperationCategory, Operation[]>();
42,940✔
471
        operations.forEach((op) => {
42,940✔
472
            const ops = operationsByCategory.get(op.category) ?? [];
43,124✔
473
            ops.push(op);
43,124✔
474
            operationsByCategory.set(op.category, ops);
43,124✔
475
        });
42,940✔
476

477
        // Get unique categories
478
        const categories = Array.from(operationsByCategory.keys());
42,940✔
479

480
        // Build dependency edges for toposort
481
        const edges: [OperationCategory, OperationCategory][] = [];
42,940✔
482
        OperationQueueManager.CATEGORY_DEPENDENCIES.forEach(([dependent, dependency]) => {
42,940✔
483
            // Only add edge if:
484
            // 1. The dependent operation is in this batch
485
            // 2. The dependency hasn't already been completed in a previous batch
486
            // 3. The dependency is either in this batch OR needs to be waited for
487
            if (categories.includes(dependent) && !this.completedCategories.has(dependency)) {
472,340✔
488
                if (categories.includes(dependency)) {
592!
489
                    // Both are in this batch - normal dependency
490
                    edges.push([dependency, dependent]); // toposort expects [from, to]
24✔
491
                }
24✔
492
                // If dependency is not in this batch and not completed, the dependent operation
493
                // may fail or produce incorrect results. In a truly stateless system, we should
494
                // either wait for the dependency or auto-queue it. For now, we allow it to proceed
495
                // and rely on the manager's internal checks (e.g., DataManager checking for styles).
496
            }
592✔
497
        });
42,940✔
498

499
        // Sort categories
500
        let sortedCategories: OperationCategory[];
42,940✔
501
        try {
42,940✔
502
            sortedCategories = toposort.array(categories, edges);
42,940✔
503
        } catch (error) {
42,940!
504
            // Circular dependency detected - emit error event
505
            this.eventManager.emitGraphError(
×
506
                null,
×
507
                error instanceof Error ? error : new Error("Circular dependency detected"),
×
508
                "other",
×
509
                { categories, edges },
×
510
            );
×
511
            sortedCategories = categories; // Fallback to original order
×
512
        }
×
513

514
        // Flatten operations in sorted category order
515
        const sortedOperations: Operation[] = [];
42,940✔
516
        sortedCategories.forEach((category) => {
42,940✔
517
            const categoryOps = operationsByCategory.get(category) ?? [];
42,985!
518
            sortedOperations.push(...categoryOps);
42,985✔
519
        });
42,940✔
520

521
        return sortedOperations;
42,940✔
522
    }
42,940✔
523

524
    /**
525
     * Execute a single operation
526
     * @param operation - The operation to execute
527
     * @param context - Execution context with abort signal and progress tracking
528
     */
529
    private async executeOperation(operation: Operation, context: OperationContext): Promise<void> {
23✔
530
        // Move from queued to running
531
        this.queuedOperations.delete(operation.id);
15,753✔
532
        this.runningOperations.set(operation.id, operation);
15,753✔
533

534
        this.logger.debug("Operation started", {
15,753✔
535
            id: operation.id,
15,753✔
536
            category: operation.category,
15,753✔
537
            description: operation.metadata?.description,
15,753✔
538
        });
15,753✔
539

540
        this.eventManager.emitGraphEvent("operation-start", {
15,753✔
541
            id: operation.id,
15,753✔
542
            category: operation.category,
15,753✔
543
            description: operation.metadata?.description,
15,753✔
544
        });
15,753✔
545

546
        const startTime = performance.now();
15,753✔
547

548
        try {
15,753✔
549
            await operation.execute(context);
15,753✔
550

551
            // Mark as complete
552
            context.progress.setProgress(100);
15,721✔
553

554
            // Mark category as completed for cross-batch dependency resolution
555
            this.completedCategories.add(operation.category);
15,721✔
556

557
            const duration = performance.now() - startTime;
15,721✔
558

559
            this.logger.debug("Operation completed", {
15,721✔
560
                id: operation.id,
15,721✔
561
                category: operation.category,
15,721✔
562
                duration: duration.toFixed(2),
15,721✔
563
            });
15,721✔
564

565
            this.eventManager.emitGraphEvent("operation-complete", {
15,721✔
566
                id: operation.id,
15,721✔
567
                category: operation.category,
15,721✔
568
                duration,
15,721✔
569
            });
15,721✔
570

571
            // Resolve the operation's promise
572
            if (operation.resolve) {
15,727✔
573
                operation.resolve();
15,639✔
574
            }
15,639✔
575

576
            // Trigger post-execution operations if not skipped
577
            if (!operation.metadata?.skipTriggers) {
15,753✔
578
                this.triggerPostExecutionOperations(operation);
15,187✔
579
            }
15,187✔
580
        } catch (error) {
15,753!
581
            if (error && (error as Error).name === "AbortError") {
30!
582
                // Reject the operation's promise
583
                if (operation.reject) {
3!
584
                    operation.reject(error);
×
585
                }
×
586

587
                // Remove from running on abort
588
                this.runningOperations.delete(operation.id);
3✔
589
                throw error; // Let p-queue handle abort errors
3✔
590
            }
3✔
591

592
            // Reject the operation's promise
593
            if (operation.reject) {
27✔
594
                operation.reject(error);
24✔
595
            }
24✔
596

597
            this.handleOperationError(operation, error);
27✔
598
        } finally {
15,753✔
599
            // Always remove from running operations
600
            this.runningOperations.delete(operation.id);
15,751✔
601
        }
15,751✔
602
    }
15,753✔
603

604
    /**
605
     * Create progress context for an operation
606
     * @param id - Unique operation ID
607
     * @param category - Operation category
608
     * @returns Progress context for updating operation progress
609
     */
610
    private createProgressContext(id: string, category: OperationCategory): ProgressContext {
23✔
611
        return {
15,753✔
612
            setProgress: (percent: number) => {
15,753✔
613
                const progress = this.operationProgress.get(id);
15,780✔
614
                if (progress) {
15,780✔
615
                    progress.percent = percent;
15,754✔
616
                    progress.lastUpdate = Date.now();
15,754✔
617
                    this.emitProgressUpdate(id, category, progress);
15,754✔
618
                }
15,754✔
619
            },
15,780✔
620
            setMessage: (message: string) => {
15,753✔
621
                const progress = this.operationProgress.get(id);
75✔
622
                if (progress) {
75✔
623
                    progress.message = message;
75✔
624
                    progress.lastUpdate = Date.now();
75✔
625
                    this.emitProgressUpdate(id, category, progress);
75✔
626
                }
75✔
627
            },
75✔
628
            setPhase: (phase: string) => {
15,753✔
629
                const progress = this.operationProgress.get(id);
3✔
630
                if (progress) {
3✔
631
                    progress.phase = phase;
3✔
632
                    progress.lastUpdate = Date.now();
3✔
633
                    this.emitProgressUpdate(id, category, progress);
3✔
634
                }
3✔
635
            },
3✔
636
        };
15,753✔
637
    }
15,753✔
638

639
    /**
640
     * Emit progress update event
641
     * @param id - Operation ID
642
     * @param category - Operation category
643
     * @param progress - Current progress state
644
     */
645
    private emitProgressUpdate(id: string, category: OperationCategory, progress: OperationProgress): void {
23✔
646
        this.eventManager.emitGraphEvent("operation-progress", {
15,832✔
647
            id,
15,832✔
648
            category,
15,832✔
649
            progress: progress.percent,
15,832✔
650
            message: progress.message,
15,832✔
651
            phase: progress.phase,
15,832✔
652
            duration: Date.now() - progress.startTime,
15,832✔
653
        });
15,832✔
654
    }
15,832✔
655

656
    /**
657
     * Handle operation errors
658
     * @param operation - The operation that failed
659
     * @param error - The error that occurred
660
     */
661
    private handleOperationError(operation: Operation, error: unknown): void {
23✔
662
        this.logger.error("Operation failed", error instanceof Error ? error : new Error(String(error)), {
27!
663
            id: operation.id,
27✔
664
            category: operation.category,
27✔
665
            description: operation.metadata?.description,
27✔
666
        });
27✔
667

668
        this.eventManager.emitGraphError(null, error instanceof Error ? error : new Error(String(error)), "other", {
27!
669
            operationId: operation.id,
27✔
670
            category: operation.category,
27✔
671
            description: operation.metadata?.description,
27✔
672
        });
27✔
673
    }
27✔
674

675
    /**
676
     * Wait for all queued operations to complete
677
     */
678
    async waitForCompletion(): Promise<void> {
23✔
679
        // First, ensure any pending batch is queued
680
        if (this.currentBatch.size > 0) {
432!
681
            this.executeBatch();
32✔
682
        }
32✔
683

684
        // Then wait for queue to be idle
685
        await this.queue.onIdle();
432✔
686
    }
432✔
687

688
    /**
689
     * Get queue statistics
690
     * @returns Current queue state including pending operations, size, and pause status
691
     */
692
    getStats(): {
23✔
693
        pending: number;
694
        size: number;
695
        isPaused: boolean;
696
    } {
4✔
697
        return {
4✔
698
            pending: this.queue.pending,
4✔
699
            size: this.queue.size,
4✔
700
            isPaused: this.queue.isPaused,
4✔
701
        };
4✔
702
    }
4✔
703

704
    /**
705
     * Pause/resume queue execution
706
     */
707
    pause(): void {
23✔
708
        this.queue.pause();
2✔
709
    }
2✔
710

711
    /**
712
     * Resume queue execution after being paused
713
     */
714
    resume(): void {
23✔
715
        this.queue.start();
2✔
716
    }
2✔
717

718
    /**
719
     * Clear all pending operations
720
     */
721
    clear(): void {
23✔
722
        // Cancel all active operations
723
        this.activeControllers.forEach((controller, id) => {
6✔
724
            if (!controller.signal.aborted) {
11✔
725
                controller.abort();
7✔
726
                this.eventManager.emitGraphEvent("operation-cancelled", {
7✔
727
                    id,
7✔
728
                    reason: "Queue cleared",
7✔
729
                });
7✔
730
            }
7✔
731
        });
6✔
732

733
        this.queue.clear();
6✔
734
        this.pendingOperations.clear();
6✔
735
        this.currentBatch.clear();
6✔
736
        this.runningOperations.clear();
6✔
737
        this.queuedOperations.clear();
6✔
738
    }
6✔
739

740
    /**
741
     * Disable batching (execute operations immediately)
742
     */
743
    disableBatching(): void {
23✔
744
        this.batchingEnabled = false;
×
745
    }
×
746

747
    /**
748
     * Enable batching to group operations before execution
749
     */
750
    enableBatching(): void {
23✔
751
        this.batchingEnabled = true;
×
752
        // TODO: Operations queued while batching was disabled will be executed
753
        // when waitForCompletion() is called
754
    }
×
755

756
    /**
757
     * Enter batch mode - operations will be queued but not executed
758
     */
759
    enterBatchMode(): void {
23✔
760
        this.batchMode = true;
36✔
761
        this.batchOperations.clear();
36✔
762
    }
36✔
763

764
    /**
765
     * Exit batch mode - execute all batched operations in dependency order
766
     */
767
    async exitBatchMode(): Promise<void> {
23✔
768
        if (!this.batchMode) {
36!
769
            return;
1✔
770
        }
1✔
771

772
        this.batchMode = false;
35✔
773

774
        // Move all batched operations to currentBatch
775
        this.batchOperations.forEach((id) => {
35✔
776
            this.currentBatch.add(id);
177✔
777
        });
35✔
778

779
        // Collect all batch promises
780
        const promises = Array.from(this.batchPromises.values());
35✔
781

782
        // Clear batch tracking
783
        this.batchOperations.clear();
35✔
784
        this.batchPromises.clear();
35✔
785

786
        // Execute the batch
787
        this.executeBatch();
35✔
788

789
        // Wait for all operations to complete
790
        // Use allSettled to handle both resolved and rejected promises
791
        // Individual operation errors are already handled via handleOperationError
792
        await Promise.allSettled(promises);
35✔
793
    }
36✔
794

795
    /**
796
     * Check if currently in batch mode
797
     * @returns True if in batch mode, false otherwise
798
     */
799
    isInBatchMode(): boolean {
23✔
800
        return this.batchMode;
10✔
801
    }
10✔
802

803
    /**
804
     * Queue an operation and get a promise for its completion
805
     * Used for batch mode operations
806
     * @param category - Category of the operation
807
     * @param execute - Function to execute for this operation
808
     * @param options - Optional metadata for the operation
809
     * @returns Promise that resolves when the operation completes
810
     */
811
    queueOperationAsync(
23✔
812
        category: OperationCategory,
43,026✔
813
        execute: (context: OperationContext) => Promise<void> | void,
43,026✔
814
        options?: Partial<OperationMetadata>,
43,026✔
815
    ): Promise<void> {
43,026✔
816
        const id = this.queueOperation(category, execute, options);
43,026✔
817

818
        // Create promise that resolves when this specific operation completes
819
        const promise = new Promise<void>((resolve, reject) => {
43,026✔
820
            // Store resolvers with the operation
821
            const operation = this.pendingOperations.get(id);
43,026✔
822
            if (operation) {
43,026✔
823
                operation.resolve = resolve;
43,026✔
824
                operation.reject = reject;
43,026✔
825
            }
43,026✔
826
        });
43,026✔
827

828
        if (this.batchMode) {
43,026!
829
            // In batch mode, track the promise for later
830
            this.batchPromises.set(id, promise);
177✔
831
            // Return immediately resolved promise to avoid deadlock
832
            // The actual operation will execute when exitBatchMode is called
833
            return Promise.resolve();
177✔
834
        }
177✔
835

836
        // Not in batch mode, execute normally
837
        this.executeBatch();
42,849✔
838
        return promise;
42,849✔
839
    }
43,026✔
840

841
    /**
842
     * Wait for a specific operation to complete
843
     * @param id - Operation ID to wait for
844
     */
845
    private async _waitForOperation(id: string): Promise<void> {
23✔
846
        while (this.pendingOperations.has(id) || this.queuedOperations.has(id) || this.runningOperations.has(id)) {
×
847
            await new Promise((resolve) => setTimeout(resolve, OPERATION_POLL_INTERVAL_MS));
×
848
        }
×
849
    }
×
850

851
    /**
852
     * Get the AbortController for a specific operation
853
     * @param operationId - ID of the operation
854
     * @returns The AbortController or undefined if not found
855
     */
856
    getOperationController(operationId: string): AbortController | undefined {
23✔
857
        return this.activeControllers.get(operationId);
×
858
    }
×
859

860
    /**
861
     * Cancel a specific operation
862
     * @param operationId - ID of the operation to cancel
863
     * @returns True if the operation was cancelled, false otherwise
864
     */
865
    cancelOperation(operationId: string): boolean {
23✔
866
        const controller = this.activeControllers.get(operationId);
×
867
        if (controller && !controller.signal.aborted) {
×
868
            controller.abort();
×
869

870
            // Emit cancellation event
871
            this.eventManager.emitGraphEvent("operation-cancelled", {
×
872
                id: operationId,
×
873
                reason: "Manual cancellation",
×
874
            });
×
875

876
            return true;
×
877
        }
×
878

879
        return false;
×
880
    }
×
881

882
    /**
883
     * Mark a category as completed (for satisfying cross-batch dependencies)
884
     * This is useful when a category's requirements are met through other means
885
     * (e.g., style-init is satisfied by constructor initialization)
886
     * @param category - The operation category to mark as completed
887
     */
888
    markCategoryCompleted(category: OperationCategory): void {
23✔
889
        this.completedCategories.add(category);
919✔
890
    }
919✔
891

892
    /**
893
     * Clear completed status for a category
894
     * This is useful when a category needs to be re-executed
895
     * (e.g., setStyleTemplate is called explicitly, overriding initial styles)
896
     * @param category - The operation category to clear
897
     */
898
    clearCategoryCompleted(category: OperationCategory): void {
23✔
899
        this.completedCategories.delete(category);
658✔
900
    }
658✔
901

902
    /**
903
     * Cancel all operations of a specific category
904
     * @param category - The operation category to cancel
905
     * @returns Number of operations cancelled
906
     */
907
    cancelByCategory(category: OperationCategory): number {
23✔
908
        let cancelledCount = 0;
×
909

910
        // Cancel pending operations
911
        this.pendingOperations.forEach((operation) => {
×
912
            if (operation.category === category) {
×
913
                if (this.cancelOperation(operation.id)) {
×
914
                    cancelledCount++;
×
915
                }
×
916
            }
×
917
        });
×
918

919
        return cancelledCount;
×
920
    }
×
921

922
    /**
923
     * Get current progress for an operation
924
     * @param operationId - ID of the operation
925
     * @returns Progress information or undefined if not found
926
     */
927
    getOperationProgress(operationId: string): OperationProgress | undefined {
23✔
928
        return this.operationProgress.get(operationId);
×
929
    }
×
930

931
    /**
932
     * Get all active operation IDs
933
     * @returns Array of active operation IDs
934
     */
935
    getActiveOperations(): string[] {
23✔
936
        return Array.from(this.activeControllers.keys());
×
937
    }
×
938

939
    /**
940
     * Register a custom trigger for a specific operation category
941
     * @param category - Operation category to trigger on
942
     * @param trigger - Function that returns trigger configuration or null
943
     */
944
    registerTrigger(
23✔
945
        category: OperationCategory,
996✔
946
        trigger: (metadata?: OperationMetadata) => {
996✔
947
            category: OperationCategory;
948
            execute: (context: OperationContext) => Promise<void> | void;
949
            description?: string;
950
        } | null,
951
    ): void {
996✔
952
        if (!this.triggers.has(category)) {
996✔
953
            this.triggers.set(category, []);
995✔
954
        }
995✔
955

956
        const triggerArray = this.triggers.get(category);
996✔
957
        if (triggerArray) {
996✔
958
            triggerArray.push(trigger);
996✔
959
        }
996✔
960
    }
996✔
961

962
    /**
963
     * Trigger post-execution operations based on the completed operation
964
     * @param operation - The completed operation that may trigger other operations
965
     */
966
    private triggerPostExecutionOperations(operation: Operation): void {
23✔
967
        // Check for default triggers
968
        const defaultTriggers = OperationQueueManager.POST_EXECUTION_TRIGGERS[operation.category];
15,187✔
969

970
        // Check for custom triggers
971
        const customTriggers = this.triggers.get(operation.category) ?? [];
15,187✔
972

973
        // Process default triggers
974
        if (defaultTriggers) {
15,187✔
975
            for (const triggerCategory of defaultTriggers) {
12,915✔
976
                // Check prerequisites
977
                if (triggerCategory === "layout-update" && this.hasLayoutEngine && !this.hasLayoutEngine()) {
12,915!
978
                    continue; // Skip if no layout engine
2✔
979
                }
2✔
980

981
                // Queue the triggered operation
982
                void this.queueTriggeredOperation(triggerCategory, operation.metadata);
12,913✔
983
            }
12,913✔
984
        }
12,915✔
985

986
        // Process custom triggers
987
        for (const trigger of customTriggers) {
15,187✔
988
            const result = trigger(operation.metadata);
12,775✔
989
            if (result) {
12,775✔
990
                // Queue the custom triggered operation
991
                void this.queueTriggeredOperation(
12,773✔
992
                    result.category,
12,773✔
993
                    operation.metadata,
12,773✔
994
                    result.execute,
12,773✔
995
                    result.description,
12,773✔
996
                );
12,773✔
997
            }
12,773✔
998
        }
12,775✔
999
    }
15,187✔
1000

1001
    /**
1002
     * Queue a triggered operation
1003
     * @param category - Category of the triggered operation
1004
     * @param _sourceMetadata - Metadata from the source operation (reserved for future use)
1005
     * @param execute - Optional execution function
1006
     * @param description - Optional description of the operation
1007
     */
1008
    private async queueTriggeredOperation(
23✔
1009
        category: OperationCategory,
25,686✔
1010
        _sourceMetadata?: OperationMetadata,
25,686✔
1011
        execute?: (context: OperationContext) => Promise<void> | void,
25,686✔
1012
        description?: string,
25,686✔
1013
    ): Promise<void> {
25,686✔
1014
        // Notify test callback if set
1015
        if (this.onOperationQueued) {
25,686!
1016
            this.onOperationQueued(category, description);
4✔
1017
        }
4✔
1018

1019
        // Default execute function for layout-update
1020
        if (!execute && category === "layout-update") {
25,686✔
1021
            execute = (context: OperationContext) => {
12,845✔
1022
                // This will be implemented by the Graph/LayoutManager
1023
                context.progress.setMessage("Updating layout positions");
48✔
1024
            };
48✔
1025
        }
12,845✔
1026

1027
        if (!execute) {
25,686!
1028
            return; // No execute function provided
68✔
1029
        }
68✔
1030

1031
        // Queue the operation
1032
        await this.queueOperationAsync(category, execute, {
25,618✔
1033
            description: description ?? `Triggered ${category}`,
25,638✔
1034
            source: "trigger",
25,686✔
1035
            skipTriggers: true, // Prevent trigger loops
25,686✔
1036
        });
25,686✔
1037
    }
25,686✔
1038
}
23✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc