From 54b97d6b4d89c88746f25833ea9860f0f2e06e66 Mon Sep 17 00:00:00 2001 From: Bill ZHANG <36790218+Lutra-Fs@users.noreply.github.com> Date: Sat, 30 Sep 2023 21:45:35 +1000 Subject: [PATCH] feat(autosave): implement autosave for models (#147) * feat(autosave): add initial implementation * fix(autoSave): fix multiple bugs Signed-off-by: Bill ZHANG <36790218+Lutra-Fs@users.noreply.github.com> * fix(App): fix bugs that cause worker init twice in dev mode Signed-off-by: Bill ZHANG <36790218+Lutra-Fs@users.noreply.github.com> --------- Signed-off-by: Bill ZHANG <36790218+Lutra-Fs@users.noreply.github.com> --- src/App.tsx | 11 ++- src/components/restorePopUp.tsx | 0 src/pages/index.tsx | 6 +- src/services/autoSave/autoSaveService.ts | 111 +++++++++++++++++++++++ src/services/model/modelService.ts | 6 +- src/workers/modelWorker.ts | 23 +++++ 6 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/components/restorePopUp.tsx create mode 100644 src/services/autoSave/autoSaveService.ts diff --git a/src/App.tsx b/src/App.tsx index 8c30db7..4fcb94f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,6 +64,12 @@ function App(): React.ReactElement { ); setSimWorker(worker); + return () => { + worker.terminate(); + } + }, []); + + useEffect(() => { const message: IncomingMessage = { func: 'init', args: [ @@ -71,8 +77,9 @@ function App(): React.ReactElement { '/model/bno_small_new_web/model.json', ], }; - worker.postMessage(message); - }, []); + if (simWorker === null) return; + simWorker.postMessage(message); + }, [simWorker]); let mainPageComponent; switch (page) { diff --git a/src/components/restorePopUp.tsx b/src/components/restorePopUp.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/index.tsx b/src/pages/index.tsx index c19d527..f4fb4d3 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -7,7 +7,7 @@ import { import { Canvas } from '@react-three/fiber'; import styled from 'styled-components'; import { useEffect, useMemo } from 'react'; -import { type OutgoingMessage } from '../workers/modelWorkerMessage'; +import { type IncomingMessage, type OutgoingMessage } from '../workers/modelWorkerMessage'; import { type ModelSave } from '../services/model/modelService'; const SimulatorContainer = styled.div` @@ -72,8 +72,8 @@ export default function Home(props: IndexProp): React.ReactElement { case 'init': console.log('worker initialised'); worker.postMessage({ - type: 'start' - }); + func: 'start' + } satisfies IncomingMessage); break; case 'output': for (const x of outputSubs) diff --git a/src/services/autoSave/autoSaveService.ts b/src/services/autoSave/autoSaveService.ts new file mode 100644 index 0000000..02acb88 --- /dev/null +++ b/src/services/autoSave/autoSaveService.ts @@ -0,0 +1,111 @@ +// a service that auto saves the current model with given interval +// to IndexedDB + +import { type ModelSave } from '../model/modelService'; + +export default class AutoSaveService { + saveInterval: number; + intervalObj: ReturnType | undefined; + maxAutoSaves: number; + getModelSerialized: () => ModelSave; + db!: IDBDatabase; + ready = false; + constructor( + getModelSerialized: () => ModelSave, + saveInterval = 10000, + maxAutoSaves = 5, + ) { + this.saveInterval = saveInterval; + this.maxAutoSaves = maxAutoSaves; + this.getModelSerialized = getModelSerialized; + this.intervalObj = undefined; + const dbRequest = indexedDB.open('modelAutoSave', 1); + dbRequest.onupgradeneeded = () => { + const db = dbRequest.result; + this.db = db; + const objectStore = this.db.createObjectStore('modelSave', { + autoIncrement: true, + }); + objectStore.transaction.oncomplete = () => { + console.log('Successfully created object store'); + }; + }; + dbRequest.onsuccess = () => { + console.log('Successfully opened IndexedDB'); + this.db = dbRequest.result; + this.ready = true; + }; + dbRequest.onerror = () => { + throw new Error('Failed to open IndexedDB'); + }; + } + + startAutoSave(): void { + if (this.intervalObj !== null && this.intervalObj !== undefined) { + throw new Error('Auto save already started'); + } + if (!this.ready) { + throw new Error('IndexedDB not ready'); + } + this.intervalObj = setInterval(() => { + const serialisationData = this.getModelSerialized(); + const modelSaveString = JSON.stringify(serialisationData); + console.log('saving model'); + const transaction = this.db.transaction(['modelSave'], 'readwrite'); + transaction.onerror = () => { + throw new Error('Failed to open transaction'); + }; + const objectStore = transaction.objectStore('modelSave'); + const request = objectStore.add(modelSaveString); + request.onsuccess = () => { + console.log('successfully saved model'); + }; + request.onerror = () => { + throw new Error('Failed to save model'); + }; + const request2 = objectStore.count(); + request2.onsuccess = () => { + const count = request2.result; + console.log('count', count); + if (count > this.maxAutoSaves) { + console.log('deleting old model'); + // use while loop to delete all but the last 5 models + const request3 = objectStore.getAllKeys(); + request3.onsuccess = () => { + const keys = request3.result; + console.log('keys', keys); + const keysToDelete = keys.slice(0, count - this.maxAutoSaves); + console.log('keysToDelete', keysToDelete); + keysToDelete.forEach((key) => { + const request4 = objectStore.delete(key); + request4.onsuccess = () => { + console.log('successfully deleted model'); + }; + request4.onerror = () => { + throw new Error('Failed to delete model'); + }; + }); + } + } + }; + request2.onerror = () => { + throw new Error('Failed to count models'); + }; + transaction.oncomplete = () => { + console.log('Successfully saved model and possibly deleted old models'); + } + }, this.saveInterval); + } + + pauseAutoSave(): void { + setTimeout(() => { + console.log('pausing auto save'); + clearInterval(this.intervalObj); + this.intervalObj = undefined; + }, 0); + } + + close(): void { + this.db.close(); + } +} diff --git a/src/services/model/modelService.ts b/src/services/model/modelService.ts index c1b1966..c74e7ce 100644 --- a/src/services/model/modelService.ts +++ b/src/services/model/modelService.ts @@ -77,8 +77,10 @@ export async function createModelService( export function modelSerialize( url: string, model: ModelService | null, -): ModelSave | null { - if (model == null) return null; +): ModelSave { + if (model == null) { + throw new Error('model is null, cannot serialise, check model is initialised or not'); + } // export a JSON as ModelSave return { diff --git a/src/workers/modelWorker.ts b/src/workers/modelWorker.ts index 6d530ee..56a2c5f 100644 --- a/src/workers/modelWorker.ts +++ b/src/workers/modelWorker.ts @@ -9,8 +9,10 @@ import { // modelDeserialize } from '../services/model/modelService'; import { type IncomingMessage } from './modelWorkerMessage'; +import AutoSaveService from '../services/autoSave/autoSaveService'; let modelService: ModelService | null = null; +let autoSaveService: AutoSaveService | null = null; interface UpdateForceArgs { loc: Vector2; @@ -36,6 +38,9 @@ export function onmessage( getServiceFromInitCond(this, dataPath, modelurl) .then((service) => { modelService = service; + autoSaveService = new AutoSaveService(() => { + return modelSerialize(modelurl, modelService); + }); this.postMessage({ type: 'init', success: true }); }) .catch((e) => { @@ -48,12 +53,30 @@ export function onmessage( throw new Error('modelService is null'); } modelService.startSimulation(); + if (autoSaveService != null) { + try { + autoSaveService.startAutoSave(); + } catch (e) { + // if error is not ready, retry in 1 second + const error = e as Error; + if (error.message === 'IndexedDB not ready') { + setTimeout(() => { + autoSaveService?.startAutoSave(); + }, 500); + } else { + throw e; + } + } + } break; case 'pause': if (modelService == null) { throw new Error('modelService is null'); } modelService.pauseSimulation(); + if (autoSaveService != null) { + autoSaveService.pauseAutoSave(); + } break; case 'updateForce': updateForce(data.args as UpdateForceArgs);