diff --git a/src/apis/save.ts b/src/apis/save.ts index 3a3ab8f3636..554861736b3 100644 --- a/src/apis/save.ts +++ b/src/apis/save.ts @@ -12,16 +12,19 @@ import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import { unref } from 'vue' -interface SaveData { +export interface SaveData { version: number autosaveContent: string documentState: string +} + +export interface SaveOptions { force: boolean manualSave: boolean } interface SaveResponse { - data: Document + data: { document: Document } } /** @@ -32,7 +35,7 @@ interface SaveResponse { */ export function save( connection: ShallowRef | Connection, - data: SaveData, + data: SaveData & SaveOptions, ): Promise { const con = unref(connection) const pub = con.shareToken ? '/public' : '' @@ -61,7 +64,7 @@ export function save( */ export function saveViaSendBeacon( connection: Connection, - data: Omit, + data: SaveData, ): boolean { const con = unref(connection) const pub = con.shareToken ? '/public' : '' diff --git a/src/components/CollaborativeEditor.vue b/src/components/CollaborativeEditor.vue index b4d9fcfbe21..d5bf71e913f 100644 --- a/src/components/CollaborativeEditor.vue +++ b/src/components/CollaborativeEditor.vue @@ -318,11 +318,12 @@ export default defineComponent({ ? () => createMarkdownSerializer(editor.schema).serialize(editor.state.doc) : () => serializePlainText(editor.state.doc) - const { saveService } = provideSaveService( + const { document, saveService } = provideSaveService( connection, syncService, serialize, ydoc, + setDirty, ) const syncProvider = shallowRef(null) @@ -343,6 +344,7 @@ export default defineComponent({ clearIndexedDb, connection, dirty, + document, editor, editorReady, el, @@ -371,7 +373,6 @@ export default defineComponent({ return { IDLE_TIMEOUT, - document: null, fileNode: null, idle: false, @@ -589,8 +590,9 @@ export default defineComponent({ bus.on('error', this.onError) bus.on('stateChange', this.onStateChange) bus.on('idle', this.onIdle) - bus.on('save', this.onSave) bus.on('permissionChange', this.onPermissionChange) + this.saveService.bus.on('error', this.onError) + this.saveService.bus.on('save', this.onSave) }, unlistenSyncServiceEvents() { @@ -601,8 +603,9 @@ export default defineComponent({ bus.off('error', this.onError) bus.off('stateChange', this.onStateChange) bus.off('idle', this.onIdle) - bus.off('save', this.onSave) bus.off('permissionChange', this.onPermissionChange) + this.saveService.bus.off('error', this.onError) + this.saveService.bus.off('save', this.onSave) }, reconnect() { @@ -614,8 +617,7 @@ export default defineComponent({ this.idle = false }, - onOpened({ document, session, content, documentState, readOnly }) { - this.document = document + onOpened({ session, content, documentState, readOnly }) { this.readOnly = readOnly this.editMode = !readOnly && !this.openReadOnlyEnabled this.hasConnectionIssue = false @@ -666,9 +668,7 @@ export default defineComponent({ this.updateUser(session) }, - onChange({ document }) { - this.document = document - + onChange() { this.syncError = null this.setEditable(this.editMode) }, @@ -690,7 +690,7 @@ export default defineComponent({ }) }, - onSync({ document }) { + onSync() { this.hasConnectionIssue = this.syncService.backend.fetcher === 0 || !this.syncProvider?.wsconnected @@ -702,9 +702,6 @@ export default defineComponent({ this.$nextTick(() => { this.$emit('syncService:sync') }) - if (document) { - this.document = document - } }, onError({ type, data }) { @@ -758,17 +755,6 @@ export default defineComponent({ } this.$emit('ready') } - if (Object.hasOwn(state, 'dirty')) { - if (state.dirty) { - // ignore initial loading and other automated changes before first user change - if (this.editor.can().undo() || this.editor.can().redo()) { - this.setDirty(state.dirty) - this.saveService.autosave() - } - } else { - this.setDirty(state.dirty) - } - } }, onIdle() { diff --git a/src/composables/useSaveService.ts b/src/composables/useSaveService.ts index dccfc1c0853..7d1b3bfa9d0 100644 --- a/src/composables/useSaveService.ts +++ b/src/composables/useSaveService.ts @@ -5,10 +5,11 @@ import type { InjectionKey, ShallowRef } from 'vue' import type { Doc } from 'yjs' -import type { SyncService } from '../services/SyncService.ts' +import type { SaveData } from '../apis/save.ts' +import type { Document, SyncService } from '../services/SyncService.ts' import type { Connection } from './useConnection.ts' -import { inject, provide } from 'vue' +import { computed, inject, onUnmounted, provide, ref, watch } from 'vue' import { getDocumentState } from '../helpers/yjs.ts' import { SaveService } from '../services/SaveService.ts' @@ -20,21 +21,87 @@ const saveServiceKey = Symbol('text:save') as InjectionKey * @param syncService mostly used for the event bus and events * @param serialize to extract the document markdown content * @param ydoc to extract the document state from + * @param setDirty set the dirty state for the editor */ export function provideSaveService( connection: ShallowRef, syncService: SyncService, serialize: () => string, ydoc: Doc, + setDirty: (val: boolean) => Promise, ) { + const document = ref() + + /** + * Get the data for the save request + */ + function getSaveData(): SaveData { + return { + version: syncService.version, + autosaveContent: serialize(), + documentState: getDocumentState(ydoc), + } + } + const saveService = new SaveService({ connection, - syncService, - serialize, - getDocumentState: () => getDocumentState(ydoc), + document, + getSaveData, + }) + + syncService.bus.on('changesPushed', saveService.autosave) + syncService.bus.on('close', saveService.clear) + onUnmounted(() => { + syncService.bus.off('changesPushed', saveService.autosave) + syncService.bus.off('close', saveService.clear) + }) + + /** + * Update the document ref based on the event provided + * + * @param event that triggered the update + * @param event.document latest state of the document + */ + function updateDocument(event: { document: Document }) { + // Limit lastSavedVersionTime to now. No saving from the future. + const lastSavedVersionTime = Math.min( + event.document.lastSavedVersionTime, + Math.ceil(Date.now() / 1000), + ) + document.value = { + ...event.document, + lastSavedVersionTime, + } + } + syncService.bus.on('opened', updateDocument) + syncService.bus.on('change', updateDocument) + saveService.bus.on('save', updateDocument) + onUnmounted(() => { + syncService.bus.off('opened', updateDocument) + syncService.bus.off('change', updateDocument) + saveService.bus.off('save', updateDocument) }) + + const versionWithChanges = ref(0) + /** + * Update the tracked version based on the one in the event + * + * @param event that triggered the update + * @param event.version with changes pushed to the server + */ + function updateVersionWithChanges(event: { version: number }) { + versionWithChanges.value = Math.max(event.version, versionWithChanges.value) + } + syncService.bus.on('changesPushed', updateVersionWithChanges) + onUnmounted(() => { + syncService.bus.off('changesPushed', updateVersionWithChanges) + }) + + const dirty = computed(() => (document.value?.lastSavedVersion ?? 0) < versionWithChanges.value) + watch(dirty, setDirty) + provide(saveServiceKey, saveService) - return { saveService } + return { document, saveService } } /** diff --git a/src/composables/useSyncService.ts b/src/composables/useSyncService.ts index 3f216af3179..3a612f08c46 100644 --- a/src/composables/useSyncService.ts +++ b/src/composables/useSyncService.ts @@ -26,6 +26,7 @@ export function provideSyncService( openConnection, }) provide(syncServiceKey, syncService) + return { syncService } } diff --git a/src/services/PollingBackend.ts b/src/services/PollingBackend.ts index 2e8c4181f42..ff6fe94e826 100644 --- a/src/services/PollingBackend.ts +++ b/src/services/PollingBackend.ts @@ -140,23 +140,23 @@ class PollingBackend { } _handleResponse({ data }: { data: PollData }) { - const { document, sessions } = data + const { document, readOnly, sessions, steps } = data this.#fetchRetryCounter = 0 - if (data.readOnly !== undefined && data.readOnly !== this.#readOnly) { - this.#readOnly = data.readOnly + if (readOnly !== undefined && readOnly !== this.#readOnly) { + this.#readOnly = readOnly this.#syncService.bus.emit('permissionChange', { readOnly: this.#readOnly, }) - if (data.readOnly) { + if (readOnly) { this.maximumReadOnlyTimer() } } this.#syncService.bus.emit('change', { document, sessions }) - this.#syncService.receiveSteps(data) + this.#syncService.receiveSteps({ sessions, steps }) - if (data.steps.length === 0) { + if (steps.length === 0) { if (!this.#initialLoadingFinished) { this.#initialLoadingFinished = true } diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index f0088ded779..7202e555e61 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -3,77 +3,93 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { ShallowRef } from 'vue' +import type { Ref, ShallowRef } from 'vue' +import type { SaveData } from '../apis/save.ts' import type { Connection } from '../composables/useConnection.ts' -import type { SyncService } from './SyncService.ts' +import type { Document } from './SyncService.ts' import { showError } from '@nextcloud/dialogs' import debounce from 'debounce' +import mitt from 'mitt' import { save, saveViaSendBeacon } from '../apis/save.ts' import { logger } from '../helpers/logger.js' import { ERROR_TYPE } from './SyncService.ts' -/** - * Interval to save the serialized document and the document state - * - * time in ms - */ -const AUTOSAVE_INTERVAL = 30000 +// Time constants in seconds: +// Only autosave after 1 second typing breaks +const AUTOSAVE_DEBOUNCE = 1 +// Server only accepts auutosaves every 10 seconds +const SERVER_AUTOSAVE_INTERVAL = 10 +// Randomize save times to prevent all clients saving at the same time. +const MAX_RANDOM_AUTOSAVE_DELAY = 3 +// First retry on error - interval will double with every retry. +const RETRY_TIMEOUT = SERVER_AUTOSAVE_INTERVAL + +type ErrorType = (typeof ERROR_TYPE)[keyof typeof ERROR_TYPE] + +export declare type EventTypes = { + /* error */ + error: { type: ErrorType, data?: object } + + /* Emitted after successful save */ + save: { document: Document } +} class SaveService { + bus = mitt() connection: ShallowRef - syncService - serialize - getDocumentState + document: Ref + autosaveErrorCount = 0 + getSaveData autosave + clear constructor({ connection, - syncService, - serialize, - getDocumentState, + document, + getSaveData, }: { connection: ShallowRef - syncService: SyncService - serialize: () => string - getDocumentState: () => string + document: Ref + getSaveData: () => SaveData }) { this.connection = connection - this.syncService = syncService - this.serialize = serialize - this.getDocumentState = getDocumentState - this.autosave = debounce(this._autosave.bind(this), AUTOSAVE_INTERVAL) - this.syncService.bus.on('close', () => { - this.autosave.clear() - }) - } - - get version() { - return this.syncService.version - } - - get emit() { - return this.syncService.bus.emit + this.document = document + this.getSaveData = getSaveData + this.autosave = debounce(this._autosave.bind(this), AUTOSAVE_DEBOUNCE * 1000) + this.clear = this.autosave.clear.bind(this.autosave) } + /** + * Save the current state + * + * @param options for saving + * @param options.force force save for handling conflicts + * @param options.manualSave user initiated the saving - not autosave + * @return true on success, false if autosave was throttled by the server + */ async save({ force = false, manualSave = true } = {}) { logger.debug('[SaveService] saving', { force, manualSave }) if (!this.connection.value) { logger.warn('Could not save due to missing connection') return } + const data = this.getSaveData() try { const response = await save(this.connection.value, { - version: this.version, - autosaveContent: this.serialize(), - documentState: this.getDocumentState(), + ...data, force, manualSave, }) - this.emit('stateChange', { dirty: false }) + // update the document - even if the save was throttled + this.bus.emit('save', response.data) + if (response.data.document.lastSavedVersion < data.version) { + logger.debug('[SaveService] Server throttled save request.', { response }) + return false + } logger.debug('[SaveService] saved', { response }) - this.emit('save', response.data) this.autosave.clear() + return true } catch (e) { logger.error('Failed to save document.', { error: e }) const response = ( @@ -84,7 +100,7 @@ class SaveService { return } if (response?.status === 412) { - this.emit('error', { + this.bus.emit('error', { type: ERROR_TYPE.LOAD_ERROR, data: response, }) @@ -100,11 +116,7 @@ class SaveService { if (!this.connection.value) { return } - const success = saveViaSendBeacon(this.connection.value, { - version: this.version, - autosaveContent: this.serialize(), - documentState: this.getDocumentState(), - }) + const success = saveViaSendBeacon(this.connection.value, this.getSaveData()) if (success) { logger.debug('[SaveService] saved using sendBeacon') } @@ -115,11 +127,32 @@ class SaveService { } _autosave() { - return this.save({ manualSave: false }).catch((error) => { - logger.error('Failed to autosave document.', { error }) - // retry in 30 seconds - this.autosave() - }) + const lastSave = this.document.value?.lastSavedVersionTime ?? 0 + const now = Date.now() / 1000 + // Server won't accept autosaves yet + if (now < lastSave + SERVER_AUTOSAVE_INTERVAL) { + logger.debug('Not autosaving as last save is recent', { lastSave, now }) + const nextSave = lastSave + SERVER_AUTOSAVE_INTERVAL + Math.random() * MAX_RANDOM_AUTOSAVE_DELAY + setTimeout(this.autosave, (nextSave - now) * 1000) + return + } + logger.debug('Autosaving') + this.save({ manualSave: false }) + .then((saved) => { + this.autosaveErrorCount = 0 + // server did not save due to throttling + if (saved === false) { + // Make sure to not hammer the server if clocks are out of sync. + setTimeout(this.autosave, SERVER_AUTOSAVE_INTERVAL * 1000) + } + }) + .catch((error) => { + logger.error('Failed to autosave document.', { error }) + // double the delay on every failed attempt + const delay = Math.pow(2, this.autosaveErrorCount) * RETRY_TIMEOUT + setTimeout(this.autosave, delay * 1000) + this.autosaveErrorCount++ + }) } } diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 5a6f7c06e51..47ee29d1599 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -110,10 +110,13 @@ export declare type EventTypes = { opened: OpenData /* received new steps */ - sync: { document?: object, steps: Step[] } + sync: { steps: Step[] } /* state changed (dirty) */ - stateChange: { initialLoading?: boolean, dirty?: boolean } + stateChange: { initialLoading?: boolean } + + /* local yjs changes have been pushed to the server */ + changesPushed: { version: number } /* error */ error: { type: ErrorType, data?: object } @@ -121,9 +124,6 @@ export declare type EventTypes = { /* Events for session and document meta data */ change: { sessions: Session[], document: Document } - /* Emitted after successful save */ - save: object - /* Emitted once a document becomes idle */ idle: void @@ -232,9 +232,7 @@ class SyncService { this.#sending = true clearInterval(this.#sendIntervalId) this.#sendIntervalId = undefined - if (this.#outbox.hasUpdate) { - this.bus.emit('stateChange', { dirty: true }) - } + const hadUpdate = this.#outbox.hasUpdate if (!this.hasActiveConnection()) { return } @@ -257,6 +255,10 @@ class SyncService { this.#sending = false if (steps?.length > 0) { this.receiveSteps({ steps }) + if (hadUpdate) { + // this.version has been increased in receiveSteps + this.bus.emit('changesPushed', { version: this.version }) + } } }) .catch((err) => { @@ -294,17 +296,14 @@ class SyncService { receiveSteps({ steps, - document, sessions = [], }: { steps: Step[] - document?: object sessions?: Session[] }) { const versionAfter = Math.max(this.version, ...steps.map((s) => s.version)) this.bus.emit('sync', { steps: [...awarenessSteps(sessions), ...steps], - document, }) if (this.version < versionAfter) { // Steps up to version where emitted but it looks like they were not processed.