All BatchedQueues should drain when disposed (also adds query string for output: "debug=true") (#5098)

* ensure disposal is correct

* add tests for disposal of batching queue

* fixes for draining disposal

* Update packages/objectloader2/src/queues/batchingQueue.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix comment

* fix tests and build

* add query string inspection of debug parameter

* Update packages/objectloader2/src/queues/batchingQueue.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/objectloader2/src/core/objectLoader2Factory.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix test

* fix AI

* export getQueryParameter to avoid dup code.  Sandbox uses it too

* add tests for functions

* prettier fix

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Adam Hathcock
2025-07-21 12:01:27 +01:00
committed by GitHub
parent 8b73e63bc2
commit 477db6ef02
14 changed files with 412 additions and 113 deletions
@@ -65,11 +65,10 @@ export class ObjectLoader2 {
await Promise.all([
this.#gathered.disposeAsync(),
this.#downloader.disposeAsync(),
this.#cacheWriter.disposeAsync()
this.#cacheWriter.disposeAsync(),
this.#cacheReader.disposeAsync()
])
this.#deferments.dispose()
this.#cacheReader.dispose()
this.#cache.dispose()
}
async getRootObject(): Promise<Item | undefined> {
@@ -1,4 +1,4 @@
import { CustomLogger } from '../types/functions.js'
import { CustomLogger, getQueryParameter } from '../types/functions.js'
import { Base } from '../types/types.js'
import { ObjectLoader2 } from './objectLoader2.js'
import IndexedDatabase from './stages/indexedDatabase.js'
@@ -11,7 +11,7 @@ export interface ObjectLoader2FactoryOptions {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
keyRange?: { bound: Function; lowerBound: Function; upperBound: Function }
indexedDB?: IDBFactory
logger?: CustomLogger
logger2?: CustomLogger
}
export class ObjectLoader2Factory {
@@ -42,6 +42,7 @@ export class ObjectLoader2Factory {
headers?: Headers
options?: ObjectLoader2FactoryOptions
}): ObjectLoader2 {
const log = ObjectLoader2Factory.getLogger(params.options?.logger2)
let loader: ObjectLoader2
if (params.options?.useMemoryCache) {
loader = new ObjectLoader2({
@@ -56,7 +57,7 @@ export class ObjectLoader2Factory {
database: new MemoryDatabase({
items: new Map<string, Base>()
}),
logger: params.options.logger
logger: log
})
} else {
loader = new ObjectLoader2({
@@ -69,13 +70,24 @@ export class ObjectLoader2Factory {
headers: params.headers
}),
database: new IndexedDatabase({
logger: params.options?.logger,
logger: log,
indexedDB: params.options?.indexedDB,
keyRange: params.options?.keyRange
}),
logger: params.options?.logger
logger: log
})
}
return loader
}
static getLogger(providedLogger?: CustomLogger): CustomLogger | undefined {
if (getQueryParameter('debug', 'false') === 'true') {
return providedLogger || this.logger
}
return providedLogger
}
static logger: CustomLogger = (m?: string, ...optionalParams: unknown[]) => {
console.log(`[debug] ${m}`, ...optionalParams)
}
}
@@ -30,6 +30,6 @@ describe('CacheReader testing', () => {
const base = await objPromise
expect(base).toMatchSnapshot()
cacheReader.dispose()
await cacheReader.disposeAsync()
})
})
@@ -77,7 +77,7 @@ export class CacheReader {
this.#logger('readBatch: left, time', items.length, performance.now() - start)
}
dispose(): void {
this.#readQueue?.dispose()
disposeAsync(): Promise<void> {
return this.#readQueue?.disposeAsync() || Promise.resolve()
}
}
@@ -38,9 +38,8 @@ export class CacheWriter implements Queue<Item> {
}
async disposeAsync(): Promise<void> {
this.#writeQueue?.dispose()
this.#disposed = true
return Promise.resolve()
await this.#writeQueue?.disposeAsync()
}
get isDisposed(): boolean {
@@ -121,8 +121,7 @@ export default class IndexedDatabase implements Database {
async disposeAsync(): Promise<void> {
this.#cacheDB?.close()
this.#cacheDB = undefined
this.#writeQueue?.dispose()
await this.#writeQueue?.disposeAsync()
this.#writeQueue = undefined
return Promise.resolve()
}
}
+1
View File
@@ -1,2 +1,3 @@
export { ObjectLoader2 } from './core/objectLoader2.js'
export { ObjectLoader2Factory } from './core/objectLoader2Factory.js'
export { getQueryParameter } from './types/functions.js'
@@ -0,0 +1,74 @@
import { describe, test, expect, vi } from 'vitest'
import BatchingQueue from './batchingQueue.js'
describe('BatchingQueue disposal', () => {
test('should drain the queue on dispose', async () => {
const processFunction = vi.fn().mockResolvedValue(undefined)
const queue = new BatchingQueue<{ id: string }>({
batchSize: 5,
maxWaitTime: 1000,
processFunction
})
const items = Array.from({ length: 3 }, (_, i) => ({ id: `item-${i}` }))
items.forEach((item) => queue.add(item.id, item))
expect(queue.count()).toBe(3)
await queue.disposeAsync()
expect(processFunction).toHaveBeenCalledWith(items)
expect(queue.count()).toBe(0)
expect(queue.isDisposed()).toBe(true)
})
test('should wait for processing to finish before disposing', async () => {
let resolveProcess: (value: void | PromiseLike<void>) => void = () => {}
const processPromise = new Promise<void>((resolve) => {
resolveProcess = resolve
})
const processFunction = vi.fn().mockImplementation(() => processPromise)
const queue = new BatchingQueue<{ id: string }>({
batchSize: 2,
maxWaitTime: 100,
processFunction
})
const items1 = [{ id: 'item-1' }, { id: 'item-2' }]
items1.forEach((item) => queue.add(item.id, item))
// First batch is processing
expect(processFunction).toHaveBeenCalledWith(items1)
const items2 = [{ id: 'item-3' }]
items2.forEach((item) => queue.add(item.id, item))
const disposePromise = queue.disposeAsync()
// Queue should be disposed now, but processing is still ongoing
expect(queue.isDisposed()).toBe(true)
resolveProcess()
await disposePromise
expect(processFunction).toHaveBeenCalledTimes(2)
expect(processFunction).toHaveBeenCalledWith(items2)
expect(queue.count()).toBe(0)
expect(queue.isDisposed()).toBe(true)
})
test('adding items after dispose should do nothing', async () => {
const processFunction = vi.fn().mockResolvedValue(undefined)
const queue = new BatchingQueue<string>({
batchSize: 5,
maxWaitTime: 1000,
processFunction
})
await queue.disposeAsync()
queue.add('key1', 'item1')
expect(queue.count()).toBe(0)
expect(processFunction).not.toHaveBeenCalled()
})
})
@@ -1,26 +1,10 @@
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'
import { describe, test, expect, vi } from 'vitest'
import BatchingQueue from './batchingQueue.js'
describe('BatchingQueue', () => {
let queue: BatchingQueue<string>
beforeEach(() => {
queue = new BatchingQueue({
batchSize: 3,
maxWaitTime: 100,
processFunction: async (): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0))
}
})
})
afterEach(() => {
queue.dispose()
})
test('should add items and process them in batches', async () => {
const processSpy = vi.fn()
queue = new BatchingQueue({
const queue = new BatchingQueue({
batchSize: 2,
maxWaitTime: 100,
processFunction: async (batch: string[]): Promise<void> => {
@@ -29,18 +13,22 @@ describe('BatchingQueue', () => {
}
})
queue.add('key1', 'item1')
queue.add('key2', 'item2')
try {
queue.add('key1', 'item1')
queue.add('key2', 'item2')
await new Promise((resolve) => setTimeout(resolve, 200))
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).toHaveBeenCalledTimes(1)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
expect(processSpy).toHaveBeenCalledTimes(1)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
} finally {
await queue.disposeAsync()
}
})
test('should process items after timeout if batch size is not reached', async () => {
const processSpy = vi.fn()
queue = new BatchingQueue({
const queue = new BatchingQueue({
batchSize: 5,
maxWaitTime: 100,
processFunction: async (batch: string[]): Promise<void> => {
@@ -49,37 +37,22 @@ describe('BatchingQueue', () => {
}
})
queue.add('key1', 'item1')
queue.add('key2', 'item2')
try {
queue.add('key1', 'item1')
queue.add('key2', 'item2')
await new Promise((resolve) => setTimeout(resolve, 200))
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).toHaveBeenCalledTimes(1)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
})
test('should not process items if disposed', async () => {
const processSpy = vi.fn()
queue = new BatchingQueue({
batchSize: 2,
maxWaitTime: 10000,
processFunction: async (batch: string[]): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0))
processSpy(batch)
}
})
queue.add('key1', 'item1')
queue.dispose()
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).not.toHaveBeenCalled()
expect(processSpy).toHaveBeenCalledTimes(1)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
} finally {
await queue.disposeAsync()
}
})
test('should handle multiple batches correctly', async () => {
const processSpy = vi.fn()
queue = new BatchingQueue({
const queue = new BatchingQueue({
batchSize: 2,
maxWaitTime: 100,
processFunction: async (batch: string[]): Promise<void> => {
@@ -88,39 +61,65 @@ describe('BatchingQueue', () => {
}
})
queue.add('key1', 'item1')
queue.add('key2', 'item2')
queue.add('key3', 'item3')
queue.add('key4', 'item4')
try {
queue.add('key1', 'item1')
queue.add('key2', 'item2')
queue.add('key3', 'item3')
queue.add('key4', 'item4')
await new Promise((resolve) => setTimeout(resolve, 200))
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).toHaveBeenCalledTimes(2)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
expect(processSpy).toHaveBeenCalledWith(['item3', 'item4'])
expect(processSpy).toHaveBeenCalledTimes(2)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
expect(processSpy).toHaveBeenCalledWith(['item3', 'item4'])
} finally {
await queue.disposeAsync()
}
})
test('should retrieve items by key', () => {
queue.add('key1', 'item1')
queue.add('key2', 'item2')
test('should retrieve items by key', async () => {
const queue = new BatchingQueue<string>({
batchSize: 3,
maxWaitTime: 100,
processFunction: async (): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0))
}
})
try {
queue.add('key1', 'item1')
queue.add('key2', 'item2')
expect(queue.get('key1')).toBe('item1')
expect(queue.get('key2')).toBe('item2')
expect(queue.get('key3')).toBeUndefined()
expect(queue.get('key1')).toBe('item1')
expect(queue.get('key2')).toBe('item2')
expect(queue.get('key3')).toBeUndefined()
} finally {
await queue.disposeAsync()
}
})
test('should return correct count of items', () => {
expect(queue.count()).toBe(0)
test('should return correct count of items', async () => {
const queue = new BatchingQueue<string>({
batchSize: 3,
maxWaitTime: 100,
processFunction: async (): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0))
}
})
try {
expect(queue.count()).toBe(0)
queue.add('key1', 'item1')
queue.add('key2', 'item2')
queue.add('key1', 'item1')
queue.add('key2', 'item2')
expect(queue.count()).toBe(2)
expect(queue.count()).toBe(2)
} finally {
await queue.disposeAsync()
}
})
test('should not process items if already processing', async () => {
const processSpy = vi.fn()
queue = new BatchingQueue({
const queue = new BatchingQueue({
batchSize: 2,
maxWaitTime: 100,
processFunction: async (batch: string[]): Promise<void> => {
@@ -129,18 +128,22 @@ describe('BatchingQueue', () => {
}
})
queue.add('key1', 'item1')
queue.add('key2', 'item2')
queue.add('key3', 'item3')
try {
queue.add('key1', 'item1')
queue.add('key2', 'item2')
queue.add('key3', 'item3')
await new Promise((resolve) => setTimeout(resolve, 200))
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).toHaveBeenCalledTimes(1)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
expect(processSpy).toHaveBeenCalledTimes(1)
expect(processSpy).toHaveBeenCalledWith(['item1', 'item2'])
await new Promise((resolve) => setTimeout(resolve, 200))
await new Promise((resolve) => setTimeout(resolve, 200))
expect(processSpy).toHaveBeenCalledTimes(2)
expect(processSpy).toHaveBeenCalledWith(['item3'])
expect(processSpy).toHaveBeenCalledTimes(2)
expect(processSpy).toHaveBeenCalledWith(['item3'])
} finally {
await queue.disposeAsync()
}
})
})
@@ -1,6 +1,12 @@
import { CustomLogger } from '../types/functions.js'
import KeyedQueue from './keyedQueue.js'
/**
* Default wait time in milliseconds for processing ongoing tasks during disposal.
* This value was chosen to balance responsiveness and CPU usage in typical scenarios.
*/
const PROCESSING_WAIT_TIME_MS = 100
export default class BatchingQueue<T> {
#queue: KeyedQueue<string, T> = new KeyedQueue<string, T>()
#batchSize: number
@@ -43,24 +49,41 @@ export default class BatchingQueue<T> {
this.#logger = params.logger || ((): void => {})
}
dispose(): void {
async disposeAsync(): Promise<void> {
this.#disposed = true
if (this.#timeoutId) {
this.#getClearTimeoutFn()(this.#timeoutId)
this.#timeoutId = null
}
// Wait for any ongoing processing to finish
while (this.#isProcessing) {
await new Promise((resolve) =>
this.#getSetTimeoutFn()(resolve, PROCESSING_WAIT_TIME_MS)
)
}
// After any ongoing flush is completed, there might be items in the queue.
// We should flush them.
if (this.#queue.size > 0) {
await this.#flush()
}
}
add(key: string, item: T): void {
if (this.#disposed) return
this.#queue.enqueue(key, item)
this.#addCheck()
}
addAll(keys: string[], items: T[]): void {
if (this.#disposed) return
this.#queue.enqueueAll(keys, items)
this.#addCheck()
}
#addCheck(): void {
if (this.#disposed) return
if (this.#queue.size >= this.#batchSize) {
// Fire and forget, no need to await
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { isBase, isReference, isScalar, take, getQueryParameter } from './functions.js'
describe('isBase', () => {
it('should return true for valid Base objects', () => {
expect(isBase({ id: '123', speckle_type: 'Base' })).toBe(true)
})
it('should return false for objects without an id', () => {
expect(isBase({ speckle_type: 'Base' })).toBe(false)
})
it('should return false for objects with a non-string id', () => {
expect(isBase({ id: 123, speckle_type: 'Base' })).toBe(false)
})
it('should return false for null or undefined', () => {
expect(isBase(null)).toBe(false)
expect(isBase(undefined)).toBe(false)
})
it('should return false for non-objects', () => {
expect(isBase('a string')).toBe(false)
expect(isBase(123)).toBe(false)
})
})
describe('isReference', () => {
it('should return true for valid Reference objects', () => {
expect(isReference({ referencedId: '456' })).toBe(true)
})
it('should return false for objects without a referencedId', () => {
expect(isReference({ id: '456' })).toBe(false)
})
it('should return false for objects with a non-string referencedId', () => {
expect(isReference({ referencedId: 456 })).toBe(false)
})
it('should return false for null or undefined', () => {
expect(isReference(null)).toBe(false)
expect(isReference(undefined)).toBe(false)
})
it('should return false for non-objects', () => {
expect(isReference('a string')).toBe(false)
expect(isReference(123)).toBe(false)
})
})
describe('isScalar', () => {
it('should return true for scalar values', () => {
expect(isScalar('hello')).toBe(true)
expect(isScalar(123)).toBe(true)
expect(isScalar(true)).toBe(true)
expect(isScalar(BigInt(9007199254740991))).toBe(true)
expect(isScalar(Symbol('id'))).toBe(true)
expect(isScalar(undefined)).toBe(true)
expect(isScalar(null)).toBe(true)
})
it('should return false for non-scalar values', () => {
expect(isScalar({})).toBe(false)
expect(isScalar([])).toBe(false)
expect(isScalar(() => {})).toBe(false)
})
})
describe('take', () => {
it('should take the specified number of items from an iterator', () => {
const arr = [1, 2, 3, 4, 5]
const iterator = arr[Symbol.iterator]()
expect(take(iterator, 3)).toEqual([1, 2, 3])
})
it('should take all items if count is larger than the number of items', () => {
const arr = [1, 2]
const iterator = arr[Symbol.iterator]()
expect(take(iterator, 5)).toEqual([1, 2])
})
it('should take no items if count is 0', () => {
const arr = [1, 2, 3]
const iterator = arr[Symbol.iterator]()
expect(take(iterator, 0)).toEqual([])
})
it('should work with an empty iterator', () => {
const arr: number[] = []
const iterator = arr[Symbol.iterator]()
expect(take(iterator, 3)).toEqual([])
})
})
describe('getQueryParameter', () => {
const defaultValue = 'default'
describe('in a non-browser environment', () => {
it('should return the default value', () => {
expect(getQueryParameter('param', defaultValue)).toBe(defaultValue)
})
})
describe('in a browser environment', () => {
const mockWindow = {
document: {},
location: {
search: ''
}
}
beforeEach(() => {
vi.stubGlobal('window', mockWindow)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should return the parameter value from the URL', () => {
mockWindow.location.search = '?param=value'
expect(getQueryParameter('param', defaultValue)).toBe('value')
})
it('should return the default value if the parameter is not in the URL', () => {
mockWindow.location.search = '?otherparam=value'
expect(getQueryParameter('param', defaultValue)).toBe(defaultValue)
})
it('should return the default value if the URL has no query string', () => {
mockWindow.location.search = ''
expect(getQueryParameter('param', defaultValue)).toBe(defaultValue)
})
})
})
@@ -49,3 +49,22 @@ export function take<T>(it: Iterator<T>, count: number): T[] {
}
return result
}
export function getQueryParameter(paramName: string, defaultValue: string): string {
// Check if the code is running in a browser environment 🌐
const isBrowser =
typeof window !== 'undefined' && typeof window.document !== 'undefined'
if (!isBrowser) {
// If in Node.js or another server environment, return the default
return defaultValue
}
// In a browser, parse the query string
const params = new URLSearchParams(window.location.search)
// .get() returns the value, or null if it's not found.
// The nullish coalescing operator (??) provides the default value
// if the left-hand side is null or undefined.
return params.get(paramName) ?? defaultValue
}