Skip to content

Commit

Permalink
feat(store): add debounce/throttle option
Browse files Browse the repository at this point in the history
allow to use debounce/throttle when updating stores

if there are 15 events in a row to update a store,
it can be updated only once after all events completed in like 1.5s

related to podman-desktop#3376
Signed-off-by: Florent Benoit <[email protected]>
  • Loading branch information
benoitf committed Aug 16, 2023
1 parent bb745f1 commit 33a6125
Show file tree
Hide file tree
Showing 7 changed files with 248 additions and 28 deletions.
2 changes: 1 addition & 1 deletion packages/renderer/src/stores/containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const containersEventStore = new EventStore<ContainerInfo[]>(
listContainers,
ContainerIcon,
);
containersEventStore.setup();
containersEventStore.setupWithDebounce();

export const searchPattern = writable('');

Expand Down
132 changes: 129 additions & 3 deletions packages/renderer/src/stores/event-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
***********************************************************************/

import { beforeEach, expect, test, vi } from 'vitest';
import { EventStore } from './event-store';
import { EventStore, type EventStoreInfo } from './event-store';
import { get, writable, type Writable } from 'svelte/store';

// first, path window object
Expand Down Expand Up @@ -47,6 +47,17 @@ interface MyCustomTypeInfo {
name: string;
}

class TestEventStore<T> extends EventStore<T> {
public async performUpdate(
needUpdate: boolean,
eventStoreInfo: EventStoreInfo,
eventName: string,
args?: unknown[],
): Promise<void> {
return super.performUpdate(needUpdate, eventStoreInfo, eventName, args);
}
}

test('should call fetch method using window event', async () => {
const myStoreInfo: Writable<MyCustomTypeInfo[]> = writable([]);
const checkForUpdateMock = vi.fn();
Expand All @@ -57,7 +68,7 @@ test('should call fetch method using window event', async () => {
// return true to trigger the update
checkForUpdateMock.mockResolvedValue(true);

const eventStore = new EventStore('my-test', myStoreInfo, checkForUpdateMock, [windowEventName], [], updater);
const eventStore = new TestEventStore('my-test', myStoreInfo, checkForUpdateMock, [windowEventName], [], updater);

// callbacks are empty
expect(callbacks.size).toBe(0);
Expand Down Expand Up @@ -106,7 +117,7 @@ test('should call fetch method using listener event', async () => {
// return true to trigger the update
checkForUpdateMock.mockResolvedValue(true);

const eventStore = new EventStore(
const eventStore = new TestEventStore(
'my-listener-test',
myStoreInfo,
checkForUpdateMock,
Expand Down Expand Up @@ -155,3 +166,118 @@ test('should call fetch method using listener event', async () => {
expect(eventStoreInfo.bufferEvents[0]).toHaveProperty('args', undefined);
expect(eventStoreInfo.bufferEvents[0]).toHaveProperty('length', 1);
});

test('Check debounce', async () => {
const myStoreInfo: Writable<MyCustomTypeInfo[]> = writable([]);
const checkForUpdateMock = vi.fn();

const windowEventName = 'my-custom-event';
const updater = vi.fn();

// return true to trigger the update
checkForUpdateMock.mockResolvedValue(true);

const eventStore = new TestEventStore('my-test', myStoreInfo, checkForUpdateMock, [windowEventName], [], updater);

// callbacks are empty
expect(callbacks.size).toBe(0);

// now call the setup with a debounce value of 200ms and no throttle
const eventStoreInfo = eventStore.setupWithDebounce(200, 0);

// spy performUpdate method
const performUpdateSpy = vi.spyOn(eventStore, 'performUpdate');

// check we have callbacks
expect(callbacks.size).toBe(1);

// now we call the listener
const callback = callbacks.get(windowEventName);
expect(callback).toBeDefined();

const myCustomTypeInfo: MyCustomTypeInfo = {
name: 'my-custom-type',
};
updater.mockResolvedValue([myCustomTypeInfo]);

// now, perform 20 calls every 50ms
for (let i = 0; i < 20; i++) {
await callback();
await new Promise(resolve => setTimeout(resolve, 50));
}

// wait debounce being called for 2 seconds
await new Promise(resolve => setTimeout(resolve, 2000));

// We did a lot of calls but with debounce, it should only be called once
expect(updater).toHaveBeenCalledOnce();

// check the store is updated
expect(get(myStoreInfo)).toStrictEqual([myCustomTypeInfo]);

// check the store is updated
expect(get(myStoreInfo)).toStrictEqual([myCustomTypeInfo]);

// check we have called performUpdate only once
expect(performUpdateSpy).toHaveBeenCalledOnce();

// check buffer events
expect(eventStoreInfo.bufferEvents.length).toBeGreaterThan(1);
});

// check that we're still calling the update method
// every throttle even if we have lot of calls and are postponing with debounce
test('Check debounce+delay', async () => {
const myStoreInfo: Writable<MyCustomTypeInfo[]> = writable([]);
const checkForUpdateMock = vi.fn();

const windowEventName = 'my-custom-event';
const updater = vi.fn();

// return true to trigger the update
checkForUpdateMock.mockResolvedValue(true);

const eventStore = new EventStore('my-test', myStoreInfo, checkForUpdateMock, [windowEventName], [], updater);

// callbacks are empty
expect(callbacks.size).toBe(0);

// now call the setup with a debounce value of 200ms and a throttle of 1s
const eventStoreInfo = eventStore.setupWithDebounce(200, 1000);

// check we have callbacks
expect(callbacks.size).toBe(1);

// now we call the listener
const callback = callbacks.get(windowEventName);
expect(callback).toBeDefined();

const myCustomTypeInfo: MyCustomTypeInfo = {
name: 'my-custom-type',
};
updater.mockResolvedValue([myCustomTypeInfo]);

// now, perform 40 calls every 50ms
for (let i = 0; i < 20; i++) {
await callback();
await new Promise(resolve => setTimeout(resolve, 50));
}

// wait debounce being called for 3 seconds
await new Promise(resolve => setTimeout(resolve, 3000));

// We did a lot of calls but with debounce and throttle it should be only like 2 calls
// get number of calls
const calls = updater.mock.calls.length;
expect(calls).toBeGreaterThan(1);
expect(calls).toBeLessThanOrEqual(4);

// check the store is updated
expect(get(myStoreInfo)).toStrictEqual([myCustomTypeInfo]);

// check the store is updated
expect(get(myStoreInfo)).toStrictEqual([myCustomTypeInfo]);

// check buffer events
expect(eventStoreInfo.bufferEvents.length).toBeGreaterThan(1);
});
133 changes: 112 additions & 21 deletions packages/renderer/src/stores/event-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,33 @@ import { addStore, updateStore } from './event-store-manager';
import humanizeDuration from 'humanize-duration';
import DesktopIcon from '../lib/images/DesktopIcon.svelte';

export interface EventStoreInfo {
// 1.5 SECOND for DEBOUNCE and 5s for THROTTLE
const SECOND = 1000;
const DEFAULT_DEBOUNCE_TIMEOUT = 1.5 * SECOND;
const DEFAULT_THROTTLE_TIMEOUT = 5 * SECOND;

interface EventStoreInfoEvent {
name: string;

iconComponent?: ComponentType;
// args of the event
args: unknown[];

// list last 100 events
bufferEvents: {
name: string;
date: number;
// if the event was skipped
skipped: boolean;
length?: number;

// args of the event
args: unknown[];
// update time in ms
humanDuration?: number;
}

export interface EventStoreInfo {
name: string;

date: number;
// if the event was skipped
skipped: boolean;
length?: number;
iconComponent?: ComponentType;

// update time in ms
humanDuration?: number;
}[];
// list last 100 events
bufferEvents: EventStoreInfoEvent[];

// number of elements in the store
size: number;
Expand All @@ -59,6 +66,14 @@ export interface EventStoreInfo {
// Helper to manage store updated from events

export class EventStore<T> {
// debounce delay in ms. If set to > 0 , timeout before updating store value
// if there are always new requests, it will never update the store
// as we postpone the update until there is no new request
private debounceTimeoutDelay = 0;

// debounce always delay in ms. If set to > 0 , update after this delay even if some requests are pending.
private debounceThrottleTimeoutDelay = 0;

constructor(
private name: string,
private store: Writable<T>,
Expand All @@ -73,6 +88,15 @@ export class EventStore<T> {
}
}

protected updateEvent(eventStoreInfo: EventStoreInfo, event: EventStoreInfoEvent) {
// update the info object
eventStoreInfo.bufferEvents.push(event);
if (eventStoreInfo.bufferEvents.length > 100) {
eventStoreInfo.bufferEvents.shift();
}
updateStore(eventStoreInfo);
}

protected async performUpdate(
needUpdate: boolean,
eventStoreInfo: EventStoreInfo,
Expand Down Expand Up @@ -100,22 +124,26 @@ export class EventStore<T> {
this.store.set(result);
}
} finally {
// update the info object
eventStoreInfo.bufferEvents.push({
this.updateEvent(eventStoreInfo, {
name: eventName,
args: args,
date: Date.now(),
skipped: !needUpdate,
length: numberOfResults,
humanDuration: updateDuration,
});
if (eventStoreInfo.bufferEvents.length > 100) {
eventStoreInfo.bufferEvents.shift();
}
updateStore(eventStoreInfo);
}
}

setupWithDebounce(
debounceTimeoutDelay = DEFAULT_DEBOUNCE_TIMEOUT,
debounceThrottleTimeoutDelay = DEFAULT_THROTTLE_TIMEOUT,
): EventStoreInfo {
this.debounceTimeoutDelay = debounceTimeoutDelay;
this.debounceThrottleTimeoutDelay = debounceThrottleTimeoutDelay;
return this.setup();
}

setup(): EventStoreInfo {
const bufferEvents = [];

Expand All @@ -136,10 +164,73 @@ export class EventStore<T> {
};
addStore(eventStoreInfo);

// for debounce
let timeout: NodeJS.Timeout | undefined;

// for throttling every 5s if not already done
let timeoutThrottle: NodeJS.Timeout | undefined;

const update = async (eventName: string, args?: unknown[]) => {
const needUpdate = await this.checkForUpdate(eventName, args);
await this.performUpdate(needUpdate, eventStoreInfo, eventName, args);

// method that do the update
const doUpdate = async () => {
await this.performUpdate(needUpdate, eventStoreInfo, eventName, args);
};

// no debounce, just do it
if (this.debounceTimeoutDelay <= 0) {
await doUpdate();
return;
}

// debounce timeout. If there is a pending action, cancel it and wait longer
if (timeout) {
clearTimeout(timeout);

this.updateEvent(eventStoreInfo, {
name: `debounce-${eventName}`,
args: args,
date: Date.now(),
skipped: true,
length: 0,
humanDuration: 0,
});

timeout = undefined;
}
timeout = setTimeout(() => {
// cancel the throttleTimeout if any
if (timeoutThrottle) {
clearTimeout(timeoutThrottle);
timeoutThrottle = undefined;
}

doUpdate()
.catch((error: unknown) => {
console.error(`Failed to update ${this.name}`, error);
})
.finally(() => {
timeout = undefined;
});
}, this.debounceTimeoutDelay);

// throttle timeout, ask after 5s to update anyway to have at least UI being refreshed every 5s if there is a lot of events
// because debounce will defer all the events until the end so it's not so nice from UI side.
if (!timeoutThrottle && this.debounceThrottleTimeoutDelay > 0) {
timeoutThrottle = setTimeout(() => {
doUpdate()
.catch((error: unknown) => {
console.error(`Failed to update ${this.name}`, error);
})
.finally(() => {
clearTimeout(timeoutThrottle);
timeoutThrottle = undefined;
});
}, this.debounceThrottleTimeoutDelay);
}
};

this.windowEvents.forEach(eventName => {
window.events?.receive(eventName, async (args?: unknown[]) => {
await update(eventName, args);
Expand Down
2 changes: 1 addition & 1 deletion packages/renderer/src/stores/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const imagesEventStore = new EventStore<ImageInfo[]>(
listImages,
ImageIcon,
);
imagesEventStore.setup();
imagesEventStore.setupWithDebounce();

export const searchPattern = writable('');

Expand Down
2 changes: 1 addition & 1 deletion packages/renderer/src/stores/pods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const eventStore = new EventStore<PodInfo[]>(
grabAllPods,
PodIcon,
);
eventStore.setup();
eventStore.setupWithDebounce();

export async function grabAllPods(): Promise<PodInfo[]> {
let result = await window.listPods();
Expand Down
3 changes: 3 additions & 0 deletions packages/renderer/src/stores/volumes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ test('volumes should be updated in case of a container is removed', async () =>
expect(containerRemovedCallback).toBeDefined();
await containerRemovedCallback();

// wait debounce
await new Promise(resolve => setTimeout(resolve, 2000));

// check if the volumes are updated
const volumes2 = get(volumeListInfos);
expect(volumes2.length).toBe(0);
Expand Down
2 changes: 1 addition & 1 deletion packages/renderer/src/stores/volumes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const volumesEventStore = new EventStore<VolumeListInfo[]>(
listVolumes,
VolumeIcon,
);
const volumesEventStoreInfo = volumesEventStore.setup();
const volumesEventStoreInfo = volumesEventStore.setupWithDebounce();

export const searchPattern = writable('');

Expand Down

0 comments on commit 33a6125

Please sign in to comment.