fix: Omnichannel queue starting multiple times due to race condition (#34062)
Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com>pull/34109/head^2
parent
18cea50a5b
commit
072a749470
@ -0,0 +1,5 @@ |
||||
--- |
||||
"@rocket.chat/meteor": patch |
||||
--- |
||||
|
||||
Fixes condition causing Omnichannel queue to start more than once. |
||||
@ -0,0 +1,68 @@ |
||||
// This class is used to manage calls to a service's .start and .stop functions
|
||||
// Specifically for cases where the start function has different conditions that may cause the service to actually start or not,
|
||||
// or when the start process can take a while to complete
|
||||
// Using this class, you ensure that calls to .start and .stop will be chained, so you avoid race conditions
|
||||
// At the same time, it prevents those functions from running more times than necessary if there are several calls to them (for example when loading setting values)
|
||||
export class ServiceStarter { |
||||
private lock = Promise.resolve(); |
||||
|
||||
private currentCall?: 'start' | 'stop'; |
||||
|
||||
private nextCall?: 'start' | 'stop'; |
||||
|
||||
private starterFn: () => Promise<void>; |
||||
|
||||
private stopperFn?: () => Promise<void>; |
||||
|
||||
constructor(starterFn: () => Promise<void>, stopperFn?: () => Promise<void>) { |
||||
this.starterFn = starterFn; |
||||
this.stopperFn = stopperFn; |
||||
} |
||||
|
||||
private async checkStatus(): Promise<void> { |
||||
if (this.nextCall === 'start') { |
||||
return this.doCall('start'); |
||||
} |
||||
|
||||
if (this.nextCall === 'stop') { |
||||
return this.doCall('stop'); |
||||
} |
||||
} |
||||
|
||||
private async doCall(call: 'start' | 'stop'): Promise<void> { |
||||
this.nextCall = undefined; |
||||
this.currentCall = call; |
||||
try { |
||||
if (call === 'start') { |
||||
await this.starterFn(); |
||||
} else if (this.stopperFn) { |
||||
await this.stopperFn(); |
||||
} |
||||
} finally { |
||||
this.currentCall = undefined; |
||||
await this.checkStatus(); |
||||
} |
||||
} |
||||
|
||||
private async call(call: 'start' | 'stop'): Promise<void> { |
||||
// If something is already chained to run after the current call, it's okay to replace it with the new call
|
||||
this.nextCall = call; |
||||
if (this.currentCall) { |
||||
return this.lock; |
||||
} |
||||
this.lock = this.checkStatus(); |
||||
return this.lock; |
||||
} |
||||
|
||||
async start(): Promise<void> { |
||||
return this.call('start'); |
||||
} |
||||
|
||||
async stop(): Promise<void> { |
||||
return this.call('stop'); |
||||
} |
||||
|
||||
async wait(): Promise<void> { |
||||
return this.lock; |
||||
} |
||||
} |
||||
@ -0,0 +1,91 @@ |
||||
import { ServiceStarter } from '../src/lib/ServiceStarter'; |
||||
|
||||
const wait = (time: number) => { |
||||
return new Promise((resolve) => { |
||||
setTimeout(() => resolve(undefined), time); |
||||
}); |
||||
}; |
||||
|
||||
describe('ServiceStarter', () => { |
||||
it('should call the starterFn and stopperFn when calling .start and .stop', async () => { |
||||
const start = jest.fn(); |
||||
const stop = jest.fn(); |
||||
|
||||
const instance = new ServiceStarter(start, stop); |
||||
|
||||
expect(start).not.toHaveBeenCalled(); |
||||
expect(stop).not.toHaveBeenCalled(); |
||||
|
||||
await instance.start(); |
||||
|
||||
expect(start).toHaveBeenCalled(); |
||||
expect(stop).not.toHaveBeenCalled(); |
||||
|
||||
start.mockReset(); |
||||
|
||||
await instance.stop(); |
||||
|
||||
expect(start).not.toHaveBeenCalled(); |
||||
expect(stop).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should only call .start for the second time after the initial call has finished running', async () => { |
||||
let running = false; |
||||
const start = jest.fn(async () => { |
||||
expect(running).toBe(false); |
||||
|
||||
running = true; |
||||
await wait(100); |
||||
running = false; |
||||
}); |
||||
const stop = jest.fn(); |
||||
|
||||
const instance = new ServiceStarter(start, stop); |
||||
|
||||
void instance.start(); |
||||
void instance.start(); |
||||
|
||||
await instance.wait(); |
||||
|
||||
expect(start).toHaveBeenCalledTimes(2); |
||||
expect(stop).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should chain up to two calls to .start', async () => { |
||||
const start = jest.fn(async () => { |
||||
await wait(100); |
||||
}); |
||||
const stop = jest.fn(); |
||||
|
||||
const instance = new ServiceStarter(start, stop); |
||||
|
||||
void instance.start(); |
||||
void instance.start(); |
||||
void instance.start(); |
||||
void instance.start(); |
||||
|
||||
await instance.wait(); |
||||
|
||||
expect(start).toHaveBeenCalledTimes(2); |
||||
expect(stop).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should skip the chained calls to .start if .stop is called', async () => { |
||||
const start = jest.fn(async () => { |
||||
await wait(100); |
||||
}); |
||||
const stop = jest.fn(); |
||||
|
||||
const instance = new ServiceStarter(start, stop); |
||||
|
||||
void instance.start(); |
||||
void instance.start(); |
||||
void instance.start(); |
||||
void instance.stop(); |
||||
|
||||
await instance.wait(); |
||||
|
||||
expect(start).toHaveBeenCalledTimes(1); |
||||
expect(stop).toHaveBeenCalledTimes(1); |
||||
}); |
||||
}); |
||||
Loading…
Reference in new issue