Skip to content

Commit

Permalink
feat: web worker based timers (#2821)
Browse files Browse the repository at this point in the history
adds a new Timer class that uses a SharedWorker to run `setInterval` calls on a background thread to avoid throttling due to a tab becoming inactive.
  • Loading branch information
gavinbarron authored Nov 2, 2023
1 parent e7c3050 commit 8eedbb5
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 12 deletions.
Binary file modified .yarn/install-state.gz
Binary file not shown.
27 changes: 16 additions & 11 deletions packages/mgt-chat/src/statefulClient/GraphNotificationClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
} from '@microsoft/microsoft-graph-types';
import { GraphConfig } from './GraphConfig';
import { SubscriptionsCache } from './Caching/SubscriptionCache';
import { Timer } from '../utils/Timer';

export const appSettings = {
defaultSubscriptionLifetimeInMinutes: 10,
Expand Down Expand Up @@ -50,12 +51,13 @@ const isMembershipNotification = (o: Notification<Entity>): o is Notification<Aa

export class GraphNotificationClient {
private connection?: HubConnection = undefined;
private renewalInterval = -1;
private cleanupInterval = -1;
private renewalInterval?: string;
private cleanupInterval?: string;
private renewalCount = 0;
private chatId = '';
private sessionId = '';
private readonly subscriptionCache: SubscriptionsCache = new SubscriptionsCache();
private readonly timer = new Timer();
private get graph() {
return this._graph;
}
Expand Down Expand Up @@ -85,9 +87,10 @@ export class GraphNotificationClient {
* i.e
*/
public async tearDown() {
log('clearing intervals');
window.clearInterval(this.cleanupInterval);
window.clearInterval(this.renewalInterval);
log('cleaning up graph notification resources');
if (this.cleanupInterval) this.timer.clearInterval(this.cleanupInterval);
if (this.renewalInterval) this.timer.clearInterval(this.renewalInterval);
this.timer.close();
await this.unsubscribeFromChatNotifications(this.chatId, this.sessionId);
}

Expand Down Expand Up @@ -177,7 +180,7 @@ export class GraphNotificationClient {
await this.subscriptionCache.cacheSubscription(this.chatId, this.sessionId, subscriptionRecord);

// only start timer once. -1 for renewalInterval is semaphore it has stopped.
if (this.renewalInterval === -1) this.startRenewalTimer();
if (this.renewalInterval === undefined) this.startRenewalTimer();
};

private async subscribeToResource(resourcePath: string, changeTypes: ChangeTypes[]) {
Expand Down Expand Up @@ -215,14 +218,15 @@ export class GraphNotificationClient {
}

private readonly startRenewalTimer = () => {
if (this.renewalInterval !== -1) clearInterval(this.renewalInterval);
this.renewalInterval = window.setInterval(this.syncTimerWrapper, appSettings.renewalTimerInterval * 1000);
if (this.renewalInterval !== undefined) this.timer.clearInterval(this.renewalInterval);
this.renewalInterval = this.timer.setInterval(this.syncTimerWrapper, appSettings.renewalTimerInterval * 1000);
log(`Start renewal timer . Id: ${this.renewalInterval}`);
};

private readonly syncTimerWrapper = () => void this.renewalTimer();

private readonly renewalTimer = async () => {
log(`running subscription renewal timer for chatId: ${this.chatId} sessionId: ${this.sessionId}`);
const subscriptions =
(await this.subscriptionCache.loadSubscriptions(this.chatId, this.sessionId))?.subscriptions || [];
if (subscriptions.length === 0) {
Expand All @@ -242,7 +246,7 @@ export class GraphNotificationClient {
log(`Renewing Graph subscription. RenewalCount: ${this.renewalCount}`);
// stop interval to prevent new invokes until refresh is ready.
clearInterval(this.renewalInterval);
this.renewalInterval = -1;
this.renewalInterval = undefined;
void this.renewChatSubscriptions();
// There is one subscription that need expiration, all subscriptions will be renewed
break;
Expand All @@ -251,7 +255,7 @@ export class GraphNotificationClient {
};

public renewChatSubscriptions = async () => {
clearInterval(this.renewalInterval);
if (this.renewalInterval) this.timer.clearInterval(this.renewalInterval);

const expirationTime = new Date(
new Date().getTime() + appSettings.defaultSubscriptionLifetimeInMinutes * 60 * 1000
Expand Down Expand Up @@ -321,14 +325,15 @@ export class GraphNotificationClient {
}

private startCleanupTimer() {
this.cleanupInterval = window.setInterval(this.cleanupTimerSync, appSettings.removalTimerInterval * 1000);
this.cleanupInterval = this.timer.setInterval(this.cleanupTimerSync, appSettings.removalTimerInterval * 1000);
}

private readonly cleanupTimerSync = () => {
void this.cleanupTimer();
};

private readonly cleanupTimer = async () => {
log(`running cleanup timer`);
const offset = Math.min(
appSettings.removalThreshold * 1000,
appSettings.defaultSubscriptionLifetimeInMinutes * 60 * 1000
Expand Down
62 changes: 62 additions & 0 deletions packages/mgt-chat/src/utils/Timer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { v4 as uuid } from 'uuid';
import { TimerWork } from './timerWorker';

export interface Work {
id: string;
callback: () => void;
}

export class Timer {
private readonly worker: SharedWorker;
private readonly work = new Map<string, () => void>();
constructor() {
this.worker = new SharedWorker(/* webpackChunkName: "timer-worker" */ new URL('./timerWorker.js', import.meta.url));
this.worker.port.onmessage = this.onMessage;
}

public setInterval(callback: () => void, delay: number): string {
const intervalWork: TimerWork = {
type: 'setInterval',
id: uuid(),
delay
};

this.work.set(intervalWork.id, callback);

this.worker.port.postMessage(intervalWork);

return intervalWork.id;
}

public clearInterval(id: string): void {
if (this.work.has(id)) {
const intervalWork: TimerWork = {
type: 'clearInterval',
id
};
this.worker.port.postMessage(intervalWork);
this.work.delete(id);
}
}

private readonly onMessage = (event: MessageEvent<TimerWork>): void => {
const intervalWork = event.data;
if (!intervalWork) {
return;
}

switch (intervalWork.type) {
case 'runCallback': {
if (this.work.has(intervalWork.id)) {
const work = this.work.get(intervalWork.id);
work?.();
}
break;
}
}
};

public close() {
this.worker.port.close();
}
}
32 changes: 32 additions & 0 deletions packages/mgt-chat/src/utils/timerWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export interface TimerWork {
id: string;
type: 'clearInterval' | 'setInterval' | 'runCallback';
delay?: number;
}

const ctx: SharedWorkerGlobalScope = self as unknown as SharedWorkerGlobalScope;

const intervals = new Map<string, ReturnType<typeof setInterval>>();

ctx.onconnect = (e: MessageEvent<unknown>) => {
const port = e.ports[0];
const handleMessage = (event: MessageEvent<TimerWork>) => {
const data: TimerWork = event.data;
const delay = data.delay;
const jobId = data.id;
switch (data.type) {
case 'setInterval': {
const interval = setInterval(() => {
const message: TimerWork = { ...event.data, ...{ type: 'runCallback' } };
port.postMessage(message);
}, delay);
intervals.set(jobId, interval);
break;
}
case 'clearInterval':
clearTimeout(intervals.get(jobId));
intervals.delete(jobId);
}
};
port.onmessage = handleMessage;
};
2 changes: 1 addition & 1 deletion packages/mgt-chat/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"strict": true,
"sourceMap": true,
"jsx": "react",
"lib": ["dom", "es5", "DOM.Iterable"]
"lib": ["dom", "es5", "DOM.Iterable", "WebWorker"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"],
Expand Down

0 comments on commit 8eedbb5

Please sign in to comment.