From c45ba09ff753cb9310476e9db4ceee515f6c305d Mon Sep 17 00:00:00 2001 From: snowteamer <64228468+snowteamer@users.noreply.github.com> Date: Mon, 16 Oct 2023 09:30:36 +0200 Subject: [PATCH 01/10] Fix return value in 'chelonia/db/set' --- shared/domains/chelonia/db.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/domains/chelonia/db.js b/shared/domains/chelonia/db.js index 46ca54b67..2f5b941f6 100644 --- a/shared/domains/chelonia/db.js +++ b/shared/domains/chelonia/db.js @@ -77,7 +77,7 @@ const dbPrimitiveSelectors = process.env.LIGHTWEIGHT_CLIENT === 'true' // eslint-disable-next-line require-await 'chelonia/db/set': async function (key: string, value: Buffer | string): Promise { checkKey(key) - sbp('okTurtles.data/set', key, value) + return sbp('okTurtles.data/set', key, value) }, // eslint-disable-next-line require-await 'chelonia/db/delete': async function (key: string): Promise { From 421c340d4a53d7ec1d525d757864e933ec815fe4 Mon Sep 17 00:00:00 2001 From: snowteamer <64228468+snowteamer@users.noreply.github.com> Date: Mon, 16 Oct 2023 11:00:25 +0200 Subject: [PATCH 02/10] Add chelonia/persistent-actions.js --- frontend/main.js | 2 + shared/domains/chelonia/persistent-actions.js | 200 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 shared/domains/chelonia/persistent-actions.js diff --git a/frontend/main.js b/frontend/main.js index d9b7e69c9..58750c84c 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -17,6 +17,7 @@ import { LOGIN, LOGOUT, SWITCH_GROUP } from './utils/events.js' import './controller/namespace.js' import './controller/actions/index.js' import './controller/backend.js' +import '~/shared/domains/chelonia/persistent-actions.js' import manifests from './model/contracts/manifests.json' import router from './controller/router.js' import { PUBSUB_INSTANCE } from './controller/instance-keys.js' @@ -43,6 +44,7 @@ const { Vue, L } = Common console.info('GI_VERSION:', process.env.GI_VERSION) console.info('CONTRACTS_VERSION:', process.env.CONTRACTS_VERSION) +console.info('LIGHTWEIGHT_CLIENT:', JSON.stringify(process.env.LIGHTWEIGHT_CLIENT)) console.info('NODE_ENV:', process.env.NODE_ENV) Vue.config.errorHandler = function (err, vm, info) { diff --git a/shared/domains/chelonia/persistent-actions.js b/shared/domains/chelonia/persistent-actions.js new file mode 100644 index 000000000..9194daa5e --- /dev/null +++ b/shared/domains/chelonia/persistent-actions.js @@ -0,0 +1,200 @@ +'use strict' + +import sbp from '@sbp/sbp' +import { LOGIN } from '~/frontend/utils/events.js' + +type SbpInvocation = any[] + +type PersistentActionOptions = { + errorInvocation?: SbpInvocation, + // Maximum number of tries, default: Infinity. + maxAttempts: number, + // How many seconds to wait between retries. + retrySeconds: number, + skipCondition?: SbpInvocation, + // SBP selector to call on success with the received value. + successInvocationSelector?: string, + totalFailureInvocation?: SbpInvocation +} + +type PersistentActionStatus = { + cancelled: boolean, + failedAttemptsSoFar: number, + lastError: string, + nextRetry: string, + pending: boolean +} + +const defaultOptions: PersistentActionOptions = { + maxAttempts: Number.POSITIVE_INFINITY, + retrySeconds: 30 +} +const tag = '[chelonia.persistentActions]' + +class PersistentAction { + invocation: SbpInvocation + options: PersistentActionOptions + status: PersistentActionStatus + timer: Object + + constructor (invocation: SbpInvocation, options: PersistentActionOptions = {}) { + this.invocation = invocation + this.options = { ...defaultOptions, ...options } + this.status = { + cancelled: false, + failedAttemptsSoFar: 0, + lastError: '', + nextRetry: '', + pending: false + } + } + + // Do not call if the action is pending or cancelled! + async attempt (): Promise { + if (await this.trySBP(this.options.skipCondition)) { + this.cancel() + return + } + try { + this.status.pending = true + const result = await sbp(...this.invocation) + this.handleSuccess(result) + } catch (error) { + this.handleError(error) + } + } + + cancel (): void { + this.timer && clearTimeout(this.timer) + this.status.cancelled = true + this.status.nextRetry = '' + } + + async handleError (error: Error): Promise { + const { options, status } = this + // Update relevant status fields before calling any optional selector. + status.failedAttemptsSoFar++ + status.lastError = error.message + const anyAttemptLeft = options.maxAttempts > status.failedAttemptsSoFar + status.nextRetry = anyAttemptLeft && !status.cancelled + ? new Date(Date.now() + options.retrySeconds * 1e3).toISOString() + : '' + // Perform any optional SBP invocation. + await this.trySBP(options.errorInvocation) + !anyAttemptLeft && await this.trySBP(options.totalFailureInvocation) + // Schedule a retry if appropriate. + if (status.nextRetry) { + // Note: there should be no older active timeout to clear. + this.timer = setTimeout(() => this.attempt(), this.options.retrySeconds * 1e3) + } + status.pending = false + } + + async handleSuccess (result: any): Promise { + const { status } = this + status.lastError = '' + status.nextRetry = '' + status.pending = false + this.options.successInvocationSelector && + await this.trySBP([this.options.successInvocationSelector, result]) + } + + trySBP (invocation: SbpInvocation | void): any { + try { + return invocation ? sbp(...invocation) : undefined + } catch (error) { + console.error(tag, error.message) + } + } +} + +// SBP API + +sbp('sbp/selectors/register', { + 'chelonia.persistentActions/_init' (): void { + sbp('okTurtles.events/on', LOGIN, (function () { + this.actionsByID = Object.create(null) + this.databaseKey = `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}` + this.nextID = 0 + // Necessary for now as _init cannot be async. + this.ready = false + sbp('chelonia.persistentActions/_load') + .then(() => sbp('chelonia.persistentActions/retryAll')) + }.bind(this))) + }, + + // Called on login to load the correct set of actions for the current user. + async 'chelonia.persistentActions/_load' (): Promise { + const { actionsByID = {}, nextID = 0 } = (await sbp('chelonia/db/get', this.databaseKey)) ?? {} + for (const id in actionsByID) { + this.actionsByID[id] = new PersistentAction(actionsByID[id].invocation, actionsByID[id].options) + } + this.nextID = nextID + this.ready = true + }, + + // Updates the database version of the pending action list. + 'chelonia.persistentActions/_save' (): Promise { + return sbp( + 'chelonia/db/set', + this.databaseKey, + { actionsByID: JSON.stringify(this.actionsByID), nextID: this.nextID } + ) + }, + + // === Public Selectors === // + + 'chelonia.persistentActions/enqueue' (...args): number[] { + if (!this.ready) throw new Error(`${tag} Not ready yet.`) + const ids: number[] = [] + for (const arg of args) { + const id = this.nextID++ + this.actionsByID[id] = Array.isArray(arg) + ? new PersistentAction(arg) + : new PersistentAction(arg.invocation, arg) + ids.push(id) + } + // Likely no need to await this call. + sbp('chelonia.persistentActions/_save') + for (const id of ids) this.actionsByID[id].attempt() + return ids + }, + + // Cancels a specific action by its ID. + // The action won't be retried again, but an async action cannot be aborted if its promise is stil pending. + 'chelonia.persistentActions/cancel' (id: number): void { + if (id in this.actionsByID) { + this.actionsByID[id].cancel() + delete this.actionsByID[id] + // Likely no need to await this call. + sbp('chelonia.persistentActions/_save') + } + }, + + // Forces retrying an existing persisted action given its ID. + // Note: 'failedAttemptsSoFar' will still be increased upon failure. + async 'chelonia.persistentActions/forceRetry' (id: number): Promise { + if (id in this.actionsByID) { + const action = this.actionsByID[id] + // Bail out if the action is already pending or cancelled. + if (action.status.pending || action.status.cancelled) return + try { + await action.attempt() + // If the action succeded, delete it and update the DB. + delete this.actionsByID[id] + sbp('chelonia.persistentActions/_save') + } catch { + // Do nothing. + } + } + }, + + // Retry all existing persisted actions. + // TODO: add some delay between actions so as not to spam the server, + // or have a way to issue them all at once in a single network call. + 'chelonia.persistentActions/retryAll' (): void { + for (const id in this.actionsByID) { + sbp('chelonia.persistentActions/forceRetry', id) + } + } +}) From 21131ee8931a68117027cb99a9d5cf14c8a0c385 Mon Sep 17 00:00:00 2001 From: snowteamer <64228468+snowteamer@users.noreply.github.com> Date: Mon, 23 Oct 2023 16:43:42 +0200 Subject: [PATCH 03/10] Apply review --- frontend/main.js | 8 +- shared/domains/chelonia/persistent-actions.js | 82 ++++++++++--------- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/frontend/main.js b/frontend/main.js index 58750c84c..c1e3ae824 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -44,7 +44,7 @@ const { Vue, L } = Common console.info('GI_VERSION:', process.env.GI_VERSION) console.info('CONTRACTS_VERSION:', process.env.CONTRACTS_VERSION) -console.info('LIGHTWEIGHT_CLIENT:', JSON.stringify(process.env.LIGHTWEIGHT_CLIENT)) +console.info('LIGHTWEIGHT_CLIENT:', process.env.LIGHTWEIGHT_CLIENT) console.info('NODE_ENV:', process.env.NODE_ENV) Vue.config.errorHandler = function (err, vm, info) { @@ -242,13 +242,17 @@ async function startApp () { } sbp('okTurtles.events/off', CONTRACT_IS_SYNCING, initialSyncFn) sbp('okTurtles.events/on', CONTRACT_IS_SYNCING, syncFn.bind(this)) - sbp('okTurtles.events/on', LOGIN, () => { + sbp('okTurtles.events/on', LOGIN, async () => { this.ephemeral.finishedLogin = 'yes' if (this.$store.state.currentGroupId) { this.initOrResetPeriodicNotifications() this.checkAndEmitOneTimeNotifications() } + const databaseKey = `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}` + sbp('chelonia.persistentActions/configure', { databaseKey }) + await sbp('chelonia.persistentActions/load') + await sbp('chelonia.persistentActions/retryAll') }) sbp('okTurtles.events/on', LOGOUT, () => { this.ephemeral.finishedLogin = 'no' diff --git a/shared/domains/chelonia/persistent-actions.js b/shared/domains/chelonia/persistent-actions.js index 9194daa5e..d021bc8d9 100644 --- a/shared/domains/chelonia/persistent-actions.js +++ b/shared/domains/chelonia/persistent-actions.js @@ -1,7 +1,6 @@ 'use strict' import sbp from '@sbp/sbp' -import { LOGIN } from '~/frontend/utils/events.js' type SbpInvocation = any[] @@ -35,7 +34,7 @@ class PersistentAction { invocation: SbpInvocation options: PersistentActionOptions status: PersistentActionStatus - timer: Object + timer: TimeoutID | void constructor (invocation: SbpInvocation, options: PersistentActionOptions = {}) { this.invocation = invocation @@ -112,38 +111,27 @@ class PersistentAction { sbp('sbp/selectors/register', { 'chelonia.persistentActions/_init' (): void { - sbp('okTurtles.events/on', LOGIN, (function () { - this.actionsByID = Object.create(null) - this.databaseKey = `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}` - this.nextID = 0 - // Necessary for now as _init cannot be async. - this.ready = false - sbp('chelonia.persistentActions/_load') - .then(() => sbp('chelonia.persistentActions/retryAll')) - }.bind(this))) + this.actionsByID = Object.create(null) + this.nextID = 0 + // Necessary for now as _init cannot be async. Becomes true when 'configure' has been called. + this.ready = false }, - // Called on login to load the correct set of actions for the current user. - async 'chelonia.persistentActions/_load' (): Promise { - const { actionsByID = {}, nextID = 0 } = (await sbp('chelonia/db/get', this.databaseKey)) ?? {} - for (const id in actionsByID) { - this.actionsByID[id] = new PersistentAction(actionsByID[id].invocation, actionsByID[id].options) + // Cancels a specific action by its ID. + // The action won't be retried again, but an async action cannot be aborted if its promise is stil pending. + 'chelonia.persistentActions/cancel' (id: number): void { + if (id in this.actionsByID) { + this.actionsByID[id].cancel() + delete this.actionsByID[id] + // Likely no need to await this call. + sbp('chelonia.persistentActions/save') } - this.nextID = nextID - this.ready = true }, - // Updates the database version of the pending action list. - 'chelonia.persistentActions/_save' (): Promise { - return sbp( - 'chelonia/db/set', - this.databaseKey, - { actionsByID: JSON.stringify(this.actionsByID), nextID: this.nextID } - ) + 'chelonia.persistentActions/configure' (options: { databaseKey: string }): void { + this.databaseKey = options.databaseKey }, - // === Public Selectors === // - 'chelonia.persistentActions/enqueue' (...args): number[] { if (!this.ready) throw new Error(`${tag} Not ready yet.`) const ids: number[] = [] @@ -155,22 +143,11 @@ sbp('sbp/selectors/register', { ids.push(id) } // Likely no need to await this call. - sbp('chelonia.persistentActions/_save') + sbp('chelonia.persistentActions/save') for (const id of ids) this.actionsByID[id].attempt() return ids }, - // Cancels a specific action by its ID. - // The action won't be retried again, but an async action cannot be aborted if its promise is stil pending. - 'chelonia.persistentActions/cancel' (id: number): void { - if (id in this.actionsByID) { - this.actionsByID[id].cancel() - delete this.actionsByID[id] - // Likely no need to await this call. - sbp('chelonia.persistentActions/_save') - } - }, - // Forces retrying an existing persisted action given its ID. // Note: 'failedAttemptsSoFar' will still be increased upon failure. async 'chelonia.persistentActions/forceRetry' (id: number): Promise { @@ -182,13 +159,23 @@ sbp('sbp/selectors/register', { await action.attempt() // If the action succeded, delete it and update the DB. delete this.actionsByID[id] - sbp('chelonia.persistentActions/_save') + sbp('chelonia.persistentActions/save') } catch { // Do nothing. } } }, + // Called on login to load the correct set of actions for the current user. + async 'chelonia.persistentActions/load' (): Promise { + const { actionsByID = {}, nextID = 0 } = (await sbp('chelonia/db/get', this.databaseKey)) ?? {} + for (const id in actionsByID) { + this.actionsByID[id] = new PersistentAction(actionsByID[id].invocation, actionsByID[id].options) + } + this.nextID = nextID + this.ready = true + }, + // Retry all existing persisted actions. // TODO: add some delay between actions so as not to spam the server, // or have a way to issue them all at once in a single network call. @@ -196,5 +183,20 @@ sbp('sbp/selectors/register', { for (const id in this.actionsByID) { sbp('chelonia.persistentActions/forceRetry', id) } + }, + + // Updates the database version of the pending action list. + 'chelonia.persistentActions/save' (): Promise { + return sbp( + 'chelonia/db/set', + this.databaseKey, + { actionsByID: JSON.stringify(this.actionsByID), nextID: this.nextID } + ) + }, + + 'chelonia.persistentActions/status' (id: number) { + if (id in this.actionsByID) { + return JSON.stringify(this.actionsByID[id]) + } } }) From 637e5a6989d627376ce83b4dfe24b388d598f4d5 Mon Sep 17 00:00:00 2001 From: snowteamer <64228468+snowteamer@users.noreply.github.com> Date: Thu, 26 Oct 2023 08:47:10 +0200 Subject: [PATCH 04/10] Make '/load' call '/retryAll' --- frontend/main.js | 1 - shared/domains/chelonia/persistent-actions.js | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/main.js b/frontend/main.js index c1e3ae824..499882aa5 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -252,7 +252,6 @@ async function startApp () { const databaseKey = `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}` sbp('chelonia.persistentActions/configure', { databaseKey }) await sbp('chelonia.persistentActions/load') - await sbp('chelonia.persistentActions/retryAll') }) sbp('okTurtles.events/on', LOGOUT, () => { this.ephemeral.finishedLogin = 'no' diff --git a/shared/domains/chelonia/persistent-actions.js b/shared/domains/chelonia/persistent-actions.js index d021bc8d9..6171a0c65 100644 --- a/shared/domains/chelonia/persistent-actions.js +++ b/shared/domains/chelonia/persistent-actions.js @@ -174,6 +174,7 @@ sbp('sbp/selectors/register', { } this.nextID = nextID this.ready = true + sbp('chelonia.persistentActions/retryAll') }, // Retry all existing persisted actions. From d357522c6d65991a340453ad870ad692a16a48d5 Mon Sep 17 00:00:00 2001 From: snowteamer <64228468+snowteamer@users.noreply.github.com> Date: Thu, 26 Oct 2023 09:20:29 +0200 Subject: [PATCH 05/10] Make trySBP async --- shared/domains/chelonia/persistent-actions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/domains/chelonia/persistent-actions.js b/shared/domains/chelonia/persistent-actions.js index 6171a0c65..15ea057e8 100644 --- a/shared/domains/chelonia/persistent-actions.js +++ b/shared/domains/chelonia/persistent-actions.js @@ -98,9 +98,9 @@ class PersistentAction { await this.trySBP([this.options.successInvocationSelector, result]) } - trySBP (invocation: SbpInvocation | void): any { + async trySBP (invocation: SbpInvocation | void): Promise { try { - return invocation ? sbp(...invocation) : undefined + return invocation ? await sbp(...invocation) : undefined } catch (error) { console.error(tag, error.message) } From 6192b3a69c1095035f111ca58b80579264f7910d Mon Sep 17 00:00:00 2001 From: snowteamer <64228468+snowteamer@users.noreply.github.com> Date: Sun, 29 Oct 2023 11:18:25 +0100 Subject: [PATCH 06/10] Emit events on action success and failure --- shared/domains/chelonia/events.js | 3 + shared/domains/chelonia/persistent-actions.js | 126 ++++++++++-------- 2 files changed, 74 insertions(+), 55 deletions(-) diff --git a/shared/domains/chelonia/events.js b/shared/domains/chelonia/events.js index 0141d6290..75b4cdac5 100644 --- a/shared/domains/chelonia/events.js +++ b/shared/domains/chelonia/events.js @@ -5,3 +5,6 @@ export const CONTRACTS_MODIFIED = 'contracts-modified' export const EVENT_HANDLED = 'event-handled' export const CONTRACT_REGISTERED = 'contract-registered' export const CONTRACT_UNREGISTERED = 'contract-unregistered' +export const PERSISTENT_ACTION_FAILURE = 'persistent-action-failure' +export const PERSISTENT_ACTION_SUCCESS = 'persistent-action-success' +export const PERSISTENT_ACTION_TOTAL_FAILURE = 'persistent-action-total_failure' diff --git a/shared/domains/chelonia/persistent-actions.js b/shared/domains/chelonia/persistent-actions.js index 15ea057e8..caf239ff2 100644 --- a/shared/domains/chelonia/persistent-actions.js +++ b/shared/domains/chelonia/persistent-actions.js @@ -1,8 +1,11 @@ 'use strict' import sbp from '@sbp/sbp' +import '@sbp/okturtles.events' +import { PERSISTENT_ACTION_FAILURE, PERSISTENT_ACTION_SUCCESS, PERSISTENT_ACTION_TOTAL_FAILURE } from './events.js' type SbpInvocation = any[] +type UUIDV4 = string type PersistentActionOptions = { errorInvocation?: SbpInvocation, @@ -11,91 +14,98 @@ type PersistentActionOptions = { // How many seconds to wait between retries. retrySeconds: number, skipCondition?: SbpInvocation, - // SBP selector to call on success with the received value. - successInvocationSelector?: string, totalFailureInvocation?: SbpInvocation } -type PersistentActionStatus = { - cancelled: boolean, +type PersistentActionStatus = {| + attempting: boolean, failedAttemptsSoFar: number, lastError: string, nextRetry: string, - pending: boolean -} + resolved: boolean +|} -const defaultOptions: PersistentActionOptions = { +const defaultOptions = { maxAttempts: Number.POSITIVE_INFINITY, retrySeconds: 30 } const tag = '[chelonia.persistentActions]' class PersistentAction { + id: UUIDV4 invocation: SbpInvocation options: PersistentActionOptions status: PersistentActionStatus timer: TimeoutID | void constructor (invocation: SbpInvocation, options: PersistentActionOptions = {}) { + // $FlowFixMe: Cannot resolve name `crypto`. + this.id = crypto.randomUUID() this.invocation = invocation this.options = { ...defaultOptions, ...options } this.status = { - cancelled: false, + attempting: false, failedAttemptsSoFar: 0, lastError: '', nextRetry: '', - pending: false + resolved: false } } - // Do not call if the action is pending or cancelled! async attempt (): Promise { + // Bail out if the action is already attempting or resolved. + if (this.status.attempting || this.status.resolved) return if (await this.trySBP(this.options.skipCondition)) { this.cancel() return } try { - this.status.pending = true + this.status.attempting = true const result = await sbp(...this.invocation) + this.status.attempting = false this.handleSuccess(result) } catch (error) { + this.status.attempting = false this.handleError(error) } } cancel (): void { this.timer && clearTimeout(this.timer) - this.status.cancelled = true this.status.nextRetry = '' + this.status.resolved = true } async handleError (error: Error): Promise { - const { options, status } = this - // Update relevant status fields before calling any optional selector. + const { id, options, status } = this + // Update relevant status fields before calling any optional code. status.failedAttemptsSoFar++ status.lastError = error.message const anyAttemptLeft = options.maxAttempts > status.failedAttemptsSoFar - status.nextRetry = anyAttemptLeft && !status.cancelled + if (!anyAttemptLeft) status.resolved = true + status.nextRetry = anyAttemptLeft && !status.resolved ? new Date(Date.now() + options.retrySeconds * 1e3).toISOString() : '' // Perform any optional SBP invocation. + sbp('okTurtles.events/emit', PERSISTENT_ACTION_FAILURE, { error, id }) await this.trySBP(options.errorInvocation) - !anyAttemptLeft && await this.trySBP(options.totalFailureInvocation) + if (!anyAttemptLeft) { + sbp('okTurtles.events/emit', PERSISTENT_ACTION_TOTAL_FAILURE, { error, id }) + await this.trySBP(options.totalFailureInvocation) + } // Schedule a retry if appropriate. if (status.nextRetry) { // Note: there should be no older active timeout to clear. this.timer = setTimeout(() => this.attempt(), this.options.retrySeconds * 1e3) } - status.pending = false } - async handleSuccess (result: any): Promise { - const { status } = this + handleSuccess (result: any): void { + const { id, status } = this status.lastError = '' status.nextRetry = '' - status.pending = false - this.options.successInvocationSelector && - await this.trySBP([this.options.successInvocationSelector, result]) + status.resolved = true + sbp('okTurtles.events/emit', PERSISTENT_ACTION_SUCCESS, { id, result }) } async trySBP (invocation: SbpInvocation | void): Promise { @@ -112,14 +122,19 @@ class PersistentAction { sbp('sbp/selectors/register', { 'chelonia.persistentActions/_init' (): void { this.actionsByID = Object.create(null) - this.nextID = 0 // Necessary for now as _init cannot be async. Becomes true when 'configure' has been called. this.ready = false + sbp('okTurtles.events/on', PERSISTENT_ACTION_SUCCESS, ({ id }) => { + sbp('chelonia.persistentActions/cancel', id) + }) + sbp('okTurtles.events/on', PERSISTENT_ACTION_TOTAL_FAILURE, ({ id }) => { + sbp('chelonia.persistentActions/cancel', id) + }) }, // Cancels a specific action by its ID. - // The action won't be retried again, but an async action cannot be aborted if its promise is stil pending. - 'chelonia.persistentActions/cancel' (id: number): void { + // The action won't be retried again, but an async action cannot be aborted if its promise is stil attempting. + 'chelonia.persistentActions/cancel' (id: UUIDV4): void { if (id in this.actionsByID) { this.actionsByID[id].cancel() delete this.actionsByID[id] @@ -128,19 +143,28 @@ sbp('sbp/selectors/register', { } }, - 'chelonia.persistentActions/configure' (options: { databaseKey: string }): void { - this.databaseKey = options.databaseKey + // TODO: validation + 'chelonia.persistentActions/configure' ({ databaseKey, options = {} }: { databaseKey: string; options: Object }): void { + if (!databaseKey) throw new TypeError(`${tag} 'databaseKey' is required`) + this.databaseKey = databaseKey + for (const key in options) { + if (key in defaultOptions) { + defaultOptions[key] = options[key] + } else { + throw new TypeError(`${tag} Unknown option: ${key}`) + } + } }, - 'chelonia.persistentActions/enqueue' (...args): number[] { + 'chelonia.persistentActions/enqueue' (...args): UUIDV4[] { if (!this.ready) throw new Error(`${tag} Not ready yet.`) - const ids: number[] = [] + const ids: UUIDV4[] = [] for (const arg of args) { - const id = this.nextID++ - this.actionsByID[id] = Array.isArray(arg) + const action = Array.isArray(arg) ? new PersistentAction(arg) : new PersistentAction(arg.invocation, arg) - ids.push(id) + this.actionsByID[action.id] = action + ids.push(action.id) } // Likely no need to await this call. sbp('chelonia.persistentActions/save') @@ -150,29 +174,21 @@ sbp('sbp/selectors/register', { // Forces retrying an existing persisted action given its ID. // Note: 'failedAttemptsSoFar' will still be increased upon failure. - async 'chelonia.persistentActions/forceRetry' (id: number): Promise { + 'chelonia.persistentActions/forceRetry' (id: UUIDV4): void { if (id in this.actionsByID) { - const action = this.actionsByID[id] - // Bail out if the action is already pending or cancelled. - if (action.status.pending || action.status.cancelled) return - try { - await action.attempt() - // If the action succeded, delete it and update the DB. - delete this.actionsByID[id] - sbp('chelonia.persistentActions/save') - } catch { - // Do nothing. - } + this.actionsByID[id].attempt() } }, - // Called on login to load the correct set of actions for the current user. + // Loads and tries every stored persistent action under the configured database key. async 'chelonia.persistentActions/load' (): Promise { - const { actionsByID = {}, nextID = 0 } = (await sbp('chelonia/db/get', this.databaseKey)) ?? {} - for (const id in actionsByID) { - this.actionsByID[id] = new PersistentAction(actionsByID[id].invocation, actionsByID[id].options) + const storedActions = JSON.parse((await sbp('chelonia/db/get', this.databaseKey)) ?? '[]') + for (const { id, invocation, options } of storedActions) { + this.actionsByID[id] = new PersistentAction(invocation, options) + // Use the stored ID instead of the autogenerated one. + // TODO: find a cleaner alternative. + this.actionsByID[id].id = id } - this.nextID = nextID this.ready = true sbp('chelonia.persistentActions/retryAll') }, @@ -186,18 +202,18 @@ sbp('sbp/selectors/register', { } }, - // Updates the database version of the pending action list. + // Updates the database version of the attempting action list. 'chelonia.persistentActions/save' (): Promise { return sbp( 'chelonia/db/set', this.databaseKey, - { actionsByID: JSON.stringify(this.actionsByID), nextID: this.nextID } + JSON.stringify(Object.values(this.actionsByID)) ) }, - 'chelonia.persistentActions/status' (id: number) { - if (id in this.actionsByID) { - return JSON.stringify(this.actionsByID[id]) - } + 'chelonia.persistentActions/status' () { + return Object.values(this.actionsByID) + // $FlowFixMe: `PersistentAction` is incompatible with mixed + .map((action: PersistentAction) => ({ id: action.id, invocation: action.invocation, ...action.status })) } }) From 4ca17b062ac302ae5241da9c58e95b667977b542 Mon Sep 17 00:00:00 2001 From: snowteamer <64228468+snowteamer@users.noreply.github.com> Date: Mon, 6 Nov 2023 20:14:56 +0100 Subject: [PATCH 07/10] Use .checkDatabaseKey rather than .ready --- shared/domains/chelonia/persistent-actions.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/shared/domains/chelonia/persistent-actions.js b/shared/domains/chelonia/persistent-actions.js index caf239ff2..204f6e886 100644 --- a/shared/domains/chelonia/persistent-actions.js +++ b/shared/domains/chelonia/persistent-actions.js @@ -122,8 +122,9 @@ class PersistentAction { sbp('sbp/selectors/register', { 'chelonia.persistentActions/_init' (): void { this.actionsByID = Object.create(null) - // Necessary for now as _init cannot be async. Becomes true when 'configure' has been called. - this.ready = false + this.checkDatabaseKey = function () { + if (!this.databaseKey) throw new TypeError(`${tag} No database key configured`) + }.bind(this) sbp('okTurtles.events/on', PERSISTENT_ACTION_SUCCESS, ({ id }) => { sbp('chelonia.persistentActions/cancel', id) }) @@ -145,7 +146,6 @@ sbp('sbp/selectors/register', { // TODO: validation 'chelonia.persistentActions/configure' ({ databaseKey, options = {} }: { databaseKey: string; options: Object }): void { - if (!databaseKey) throw new TypeError(`${tag} 'databaseKey' is required`) this.databaseKey = databaseKey for (const key in options) { if (key in defaultOptions) { @@ -157,7 +157,6 @@ sbp('sbp/selectors/register', { }, 'chelonia.persistentActions/enqueue' (...args): UUIDV4[] { - if (!this.ready) throw new Error(`${tag} Not ready yet.`) const ids: UUIDV4[] = [] for (const arg of args) { const action = Array.isArray(arg) @@ -182,6 +181,7 @@ sbp('sbp/selectors/register', { // Loads and tries every stored persistent action under the configured database key. async 'chelonia.persistentActions/load' (): Promise { + this.checkDatabaseKey() const storedActions = JSON.parse((await sbp('chelonia/db/get', this.databaseKey)) ?? '[]') for (const { id, invocation, options } of storedActions) { this.actionsByID[id] = new PersistentAction(invocation, options) @@ -189,7 +189,6 @@ sbp('sbp/selectors/register', { // TODO: find a cleaner alternative. this.actionsByID[id].id = id } - this.ready = true sbp('chelonia.persistentActions/retryAll') }, @@ -204,6 +203,7 @@ sbp('sbp/selectors/register', { // Updates the database version of the attempting action list. 'chelonia.persistentActions/save' (): Promise { + this.checkDatabaseKey() return sbp( 'chelonia/db/set', this.databaseKey, From 9d14577318c9350922e962990c7a4fad3ff474cf Mon Sep 17 00:00:00 2001 From: snowteamer <64228468+snowteamer@users.noreply.github.com> Date: Thu, 9 Nov 2023 18:17:50 +0100 Subject: [PATCH 08/10] Pause and unload persistent actions on logout --- frontend/main.js | 2 ++ shared/domains/chelonia/persistent-actions.js | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/main.js b/frontend/main.js index 499882aa5..0a8a3ffa5 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -256,7 +256,9 @@ async function startApp () { sbp('okTurtles.events/on', LOGOUT, () => { this.ephemeral.finishedLogin = 'no' router.currentRoute.path !== '/' && router.push({ path: '/' }).catch(console.error) + // Stop timers related to periodic notifications or persistent actions. sbp('gi.periodicNotifications/clearStatesAndStopTimers') + sbp('chelonia.persistentActions/unload') }) sbp('okTurtles.events/on', SWITCH_GROUP, () => { this.initOrResetPeriodicNotifications() diff --git a/shared/domains/chelonia/persistent-actions.js b/shared/domains/chelonia/persistent-actions.js index 204f6e886..9028e9e25 100644 --- a/shared/domains/chelonia/persistent-actions.js +++ b/shared/domains/chelonia/persistent-actions.js @@ -122,9 +122,9 @@ class PersistentAction { sbp('sbp/selectors/register', { 'chelonia.persistentActions/_init' (): void { this.actionsByID = Object.create(null) - this.checkDatabaseKey = function () { + this.checkDatabaseKey = () => { if (!this.databaseKey) throw new TypeError(`${tag} No database key configured`) - }.bind(this) + } sbp('okTurtles.events/on', PERSISTENT_ACTION_SUCCESS, ({ id }) => { sbp('chelonia.persistentActions/cancel', id) }) @@ -215,5 +215,15 @@ sbp('sbp/selectors/register', { return Object.values(this.actionsByID) // $FlowFixMe: `PersistentAction` is incompatible with mixed .map((action: PersistentAction) => ({ id: action.id, invocation: action.invocation, ...action.status })) + }, + + // Pauses every currently loaded action, and removes them from memory. + // Note: persistent storage is not affected, so that these actions can be later loaded again and retried. + 'chelonia.persistentActions/unload' (): void { + for (const id in this.actionsByID) { + // Clear the action's timeout, but don't cancel it so that it can later resumed. + this.actionsByID[id].timer && clearTimeout(this.actionsByID[id].timer) + delete this.actionsByID[id] + } } }) From 4222f8fb66e9d53bf14f0d7130adb4c20e132e76 Mon Sep 17 00:00:00 2001 From: snowteamer <64228468+snowteamer@users.noreply.github.com> Date: Sat, 18 Nov 2023 23:20:48 +0100 Subject: [PATCH 09/10] Add persistent-actions.test.js --- shared/domains/chelonia/persistent-actions.js | 1 + .../chelonia/persistent-actions.test.js | 167 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 shared/domains/chelonia/persistent-actions.test.js diff --git a/shared/domains/chelonia/persistent-actions.js b/shared/domains/chelonia/persistent-actions.js index 9028e9e25..57e96513c 100644 --- a/shared/domains/chelonia/persistent-actions.js +++ b/shared/domains/chelonia/persistent-actions.js @@ -87,6 +87,7 @@ class PersistentAction { ? new Date(Date.now() + options.retrySeconds * 1e3).toISOString() : '' // Perform any optional SBP invocation. + // The event has to be fired first for the action to be immediately removed from the list. sbp('okTurtles.events/emit', PERSISTENT_ACTION_FAILURE, { error, id }) await this.trySBP(options.errorInvocation) if (!anyAttemptLeft) { diff --git a/shared/domains/chelonia/persistent-actions.test.js b/shared/domains/chelonia/persistent-actions.test.js new file mode 100644 index 000000000..24329e79b --- /dev/null +++ b/shared/domains/chelonia/persistent-actions.test.js @@ -0,0 +1,167 @@ +/* eslint-env mocha */ + +// Can run directly with: +// ./node_modules/.bin/mocha -w --require Gruntfile.js --require @babel/register shared/domains/chelonia/persistent-actions.test.js + +// FIXME: `Error: unsafe must be called before registering selector` when Mocha reloads the file. + +import assert from 'node:assert' +import crypto from 'node:crypto' +import sbp from '@sbp/sbp' +import sinon from 'sinon' + +import '~/shared/domains/chelonia/db.js' + +import './persistent-actions.js' +import { PERSISTENT_ACTION_FAILURE, PERSISTENT_ACTION_TOTAL_FAILURE, PERSISTENT_ACTION_SUCCESS } from './events.js' + +// Provides the 'crypto' global in the Nodejs environment. +globalThis.crypto = crypto +// Necessary to avoid 'JSON.stringify' errors since Node timeouts are circular objects, whereas browser timeouts are just integers. +setTimeout(() => {}).constructor.prototype.toJSON = () => undefined + +sbp('sbp/selectors/register', { + call (fn, ...args) { + fn(...args) + }, + returnImmediately (arg) { + return arg + }, + throwImmediately (arg) { + throw arg + }, + log (msg) { + console.log(msg) + }, + rejectsAfterFiveSeconds (...args) { + return new Promise((resolve, reject) => { + setTimeout(reject, 5e3) + }) + }, + resolveAfterFiveSeconds (...args) { + return new Promise((resolve, reject) => { + setTimeout(resolve, 5e3) + }) + } +}) + +const createRandomError = () => new Error(`Bad number: ${String(Math.random())}`) +const getActionStatus = (id) => sbp('chelonia.persistentActions/status').find(obj => obj.id === id) +const isActionRemoved = (id) => !sbp('chelonia.persistentActions/status').find(obj => obj.id === id) + +const spies = { + returnImmediately: sinon.spy(sbp('sbp/selectors/fn', 'returnImmediately')) +} +// Custom `configure` options for tests. +const testOptions = { + maxAttempts: 3, + retrySeconds: 0.5 +} + +describe('Test persistent actions', function () { + it('should configure', function () { + sbp('chelonia.persistentActions/configure', { + databaseKey: 'test-key', + options: testOptions + }) + }) + + it('should enqueue', function () { + const invocation = ['returnImmediately', Math.random()] + const ids = sbp('chelonia.persistentActions/enqueue', invocation) + assert.strictEqual(ids.length, 1) + // Check the action was correctly queued. + const statuses = sbp('chelonia.persistentActions/status') + assert.strictEqual(statuses.length, 1) + const [status] = statuses + assert.strictEqual(status.id, ids[0]) + assert.deepEqual(status.invocation, invocation) + assert.strictEqual(status.attempting, false) + assert.strictEqual(status.failedAttemptsSoFar, 0) + assert.strictEqual(status.lastError, '') + assert.strictEqual(status.nextRetry, '') + assert.strictEqual(status.resolved, false) + // Check the action's invocation was NOT called yet. + assert.strictEqual(spies.returnImmediately.called, false) + }) + + it('should emit a success event and remove the action', function () { + const randomNumber = Math.random() + const invocation = ['returnImmediately', randomNumber] + const [id] = sbp('chelonia.persistentActions/enqueue', invocation) + return new Promise((resolve, reject) => { + sbp('okTurtles.events/once', PERSISTENT_ACTION_SUCCESS, (details) => { + try { + assert.strictEqual(details.id, id) + assert.strictEqual(details.result, randomNumber) + // Check the action was correctly removed. + assert(isActionRemoved(id)) + resolve() + } catch (err) { + reject(err) + } + }) + }) + }) + + it('should emit a failure event and schedule a retry', function () { + const ourError = createRandomError() + const invocation = ['throwImmediately', ourError] + const [id] = sbp('chelonia.persistentActions/enqueue', invocation) + return new Promise((resolve, reject) => { + sbp('okTurtles.events/once', PERSISTENT_ACTION_FAILURE, (details) => { + try { + assert.strictEqual(details.id, id) + assert.strictEqual(details.error, ourError) + // Check the action status. + const status = getActionStatus(id) + assert.strictEqual(status.failedAttemptsSoFar, 1) + assert.strictEqual(status.lastError, ourError.message) + assert.strictEqual(status.resolved, false) + // Check a retry has been scheduled. + assert(new Date(status.nextRetry) - Date.now() <= testOptions.retrySeconds * 1e3) + resolve() + } catch (err) { + reject(err) + } + }) + }) + }) + + it('should emit a total failure event and remove the action', function () { + const counter = sinon.spy() + const ourError = createRandomError() + const invocation = ['throwImmediately', ourError] + const [id] = sbp('chelonia.persistentActions/enqueue', { + invocation, + errorInvocation: ['call', counter] + }) + return new Promise((resolve, reject) => { + sbp('okTurtles.events/on', PERSISTENT_ACTION_TOTAL_FAILURE, (details) => { + if (details.id !== id) return + try { + assert.strictEqual(counter.callCount, testOptions.maxAttempts) + assert.strictEqual(details.error, ourError) + assert(isActionRemoved(id)) + resolve() + } catch (err) { + reject(err) + } + }) + }) + }) + + it('should call the given total failure invocation', function () { + return new Promise((resolve) => { + sbp('chelonia.persistentActions/enqueue', { + invocation: ['throwImmediately', createRandomError()], + totalFailureInvocation: ['call', resolve] + }) + }) + }) +}) + +/* +const get = () => sbp('chelonia/db/get', `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}`) +const set = (value) => sbp('chelonia/db/set', `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}`, value) +*/ From 3b57a3cd9fb7795ba59c9084341e244c9ecb8993 Mon Sep 17 00:00:00 2001 From: snowteamer <64228468+snowteamer@users.noreply.github.com> Date: Mon, 27 Nov 2023 23:30:00 +0100 Subject: [PATCH 10/10] Improve persistent action tests --- shared/domains/chelonia/persistent-actions.js | 24 ++- .../chelonia/persistent-actions.test.js | 177 ++++++++++++------ 2 files changed, 133 insertions(+), 68 deletions(-) diff --git a/shared/domains/chelonia/persistent-actions.js b/shared/domains/chelonia/persistent-actions.js index 57e96513c..946f38787 100644 --- a/shared/domains/chelonia/persistent-actions.js +++ b/shared/domains/chelonia/persistent-actions.js @@ -25,6 +25,12 @@ type PersistentActionStatus = {| resolved: boolean |} +const coerceToError = (arg: any): Error => { + if (arg && arg instanceof Error) return arg + console.warn(tag, 'Please use Error objects when throwing or rejecting') + return new Error((typeof arg === 'string' ? arg : JSON.stringify(arg)) ?? 'undefined') +} + const defaultOptions = { maxAttempts: Number.POSITIVE_INFINITY, retrySeconds: 30 @@ -54,11 +60,11 @@ class PersistentAction { async attempt (): Promise { // Bail out if the action is already attempting or resolved. + // TODO: should we also check whether the skipCondition call is pending? if (this.status.attempting || this.status.resolved) return - if (await this.trySBP(this.options.skipCondition)) { - this.cancel() - return - } + if (await this.trySBP(this.options.skipCondition)) this.cancel() + // We need to check this again because cancel() could have been called while awaiting the trySBP call. + if (this.status.resolved) return try { this.status.attempting = true const result = await sbp(...this.invocation) @@ -66,7 +72,7 @@ class PersistentAction { this.handleSuccess(result) } catch (error) { this.status.attempting = false - this.handleError(error) + await this.handleError(coerceToError(error)) } } @@ -113,7 +119,7 @@ class PersistentAction { try { return invocation ? await sbp(...invocation) : undefined } catch (error) { - console.error(tag, error.message) + console.error(tag, coerceToError(error).message) } } } @@ -172,8 +178,10 @@ sbp('sbp/selectors/register', { return ids }, - // Forces retrying an existing persisted action given its ID. - // Note: 'failedAttemptsSoFar' will still be increased upon failure. + // Forces retrying a given persisted action immediately, rather than waiting for the scheduled retry. + // - 'status.failedAttemptsSoFar' will still be increased upon failure. + // - Does nothing if a retry is already running. + // - Does nothing if the action has already been resolved, rejected or cancelled. 'chelonia.persistentActions/forceRetry' (id: UUIDV4): void { if (id in this.actionsByID) { this.actionsByID[id].attempt() diff --git a/shared/domains/chelonia/persistent-actions.test.js b/shared/domains/chelonia/persistent-actions.test.js index 24329e79b..da37f5d65 100644 --- a/shared/domains/chelonia/persistent-actions.test.js +++ b/shared/domains/chelonia/persistent-actions.test.js @@ -1,7 +1,7 @@ /* eslint-env mocha */ // Can run directly with: -// ./node_modules/.bin/mocha -w --require Gruntfile.js --require @babel/register shared/domains/chelonia/persistent-actions.test.js +// ./node_modules/.bin/mocha --require Gruntfile.js --require @babel/register shared/domains/chelonia/persistent-actions.test.js // FIXME: `Error: unsafe must be called before registering selector` when Mocha reloads the file. @@ -22,26 +22,26 @@ setTimeout(() => {}).constructor.prototype.toJSON = () => undefined sbp('sbp/selectors/register', { call (fn, ...args) { - fn(...args) - }, - returnImmediately (arg) { - return arg - }, - throwImmediately (arg) { - throw arg + return fn(...args) }, log (msg) { console.log(msg) }, - rejectsAfterFiveSeconds (...args) { + rejectAfter100ms (arg) { return new Promise((resolve, reject) => { - setTimeout(reject, 5e3) + setTimeout(() => reject(arg), 100) }) }, - resolveAfterFiveSeconds (...args) { + resolveAfter100ms (arg) { return new Promise((resolve, reject) => { - setTimeout(resolve, 5e3) + setTimeout(() => resolve(arg), 100) }) + }, + returnImmediately (arg) { + return arg + }, + throwImmediately (arg) { + throw arg } }) @@ -53,6 +53,7 @@ const spies = { returnImmediately: sinon.spy(sbp('sbp/selectors/fn', 'returnImmediately')) } // Custom `configure` options for tests. +// Mocha has a default 2000ms test timeout, therefore we'll use short delays. const testOptions = { maxAttempts: 3, retrySeconds: 0.5 @@ -66,47 +67,76 @@ describe('Test persistent actions', function () { }) }) - it('should enqueue', function () { - const invocation = ['returnImmediately', Math.random()] - const ids = sbp('chelonia.persistentActions/enqueue', invocation) - assert.strictEqual(ids.length, 1) - // Check the action was correctly queued. - const statuses = sbp('chelonia.persistentActions/status') - assert.strictEqual(statuses.length, 1) - const [status] = statuses - assert.strictEqual(status.id, ids[0]) - assert.deepEqual(status.invocation, invocation) - assert.strictEqual(status.attempting, false) - assert.strictEqual(status.failedAttemptsSoFar, 0) - assert.strictEqual(status.lastError, '') - assert.strictEqual(status.nextRetry, '') - assert.strictEqual(status.resolved, false) - // Check the action's invocation was NOT called yet. + it('should enqueue without immediately attempting', function () { + // Prepare actions to enqueue. Random numbers are used to make invocations different. + const args = [ + // Basic syntax. + ['returnImmediately', Math.random()], + // Minimal option syntax. + { + invocation: ['returnImmediately', Math.random()] + }, + // Full option syntax. + { + errorInvocation: ['log', 'Action n°3 failed'], + invocation: ['returnImmediately', Math.random()], + maxAttempts: 4, + retrySeconds: 5, + skipCondition: ['test'], + totalFailureInvocation: ['log', 'Action n°3 totally failed'] + } + ] + const ids = sbp('chelonia.persistentActions/enqueue', ...args) + assert(Array.isArray(ids)) + assert(ids.length === args.length) + // Check the actions have been correctly queued. + ids.forEach((id, index) => { + const arg = args[index] + const status = getActionStatus(id) + assert.strictEqual(status.id, id) + assert.deepEqual(status.invocation, arg.invocation ?? arg) + assert.strictEqual(status.attempting, false) + assert.strictEqual(status.failedAttemptsSoFar, 0) + assert.strictEqual(status.lastError, '') + assert.strictEqual(status.nextRetry, '') + assert.strictEqual(status.resolved, false) + }) + // Check the actions have NOT been tried yet. assert.strictEqual(spies.returnImmediately.called, false) }) it('should emit a success event and remove the action', function () { - const randomNumber = Math.random() - const invocation = ['returnImmediately', randomNumber] - const [id] = sbp('chelonia.persistentActions/enqueue', invocation) - return new Promise((resolve, reject) => { - sbp('okTurtles.events/once', PERSISTENT_ACTION_SUCCESS, (details) => { + // Prepare actions using both sync and async invocations. + // TODO: maybe the async case is enough, which would make the code simpler. + const randomNumbers = [Math.random(), Math.random()] + const invocations = [ + ['resolveAfter100ms', randomNumbers[0]], + ['returnImmediately', randomNumbers[1]] + ] + const ids = sbp('chelonia.persistentActions/enqueue', ...invocations) + return Promise.all(ids.map((id, index) => new Promise((resolve, reject) => { + // Registers a success handler for each received id. + sbp('okTurtles.events/on', PERSISTENT_ACTION_SUCCESS, function handler (details) { + if (details.id !== id) return try { - assert.strictEqual(details.id, id) - assert.strictEqual(details.result, randomNumber) - // Check the action was correctly removed. + // Check the action has actually been called and its result is correct. + assert.strictEqual(details.result, randomNumbers[index]) + // Check the action has been correctly removed. assert(isActionRemoved(id)) - resolve() + // Wait a little to make sure the action isn't going to be retried. + setTimeout(resolve, (testOptions.retrySeconds + 1) * 1e3) } catch (err) { reject(err) + } finally { + sbp('okTurtles.events/off', PERSISTENT_ACTION_SUCCESS, handler) } }) - }) + }))) }) it('should emit a failure event and schedule a retry', function () { const ourError = createRandomError() - const invocation = ['throwImmediately', ourError] + const invocation = ['rejectAfter100ms', ourError] const [id] = sbp('chelonia.persistentActions/enqueue', invocation) return new Promise((resolve, reject) => { sbp('okTurtles.events/once', PERSISTENT_ACTION_FAILURE, (details) => { @@ -128,40 +158,67 @@ describe('Test persistent actions', function () { }) }) - it('should emit a total failure event and remove the action', function () { - const counter = sinon.spy() + it('should emit N failure events, then a total failure event and remove the action (sync)', function () { const ourError = createRandomError() const invocation = ['throwImmediately', ourError] - const [id] = sbp('chelonia.persistentActions/enqueue', { - invocation, - errorInvocation: ['call', counter] - }) + return e2eFailureTest(invocation, ourError) + }) + + it('should emit N failure events, then a total failure event and remove the action (async)', function () { + const ourError = createRandomError() + const invocation = ['rejectAfter100ms', ourError] + return e2eFailureTest(invocation, ourError) + }) + + it('should handle non-Error failures gracefully', function () { + const ourError = 'not a real error' + const invocation = ['rejectAfter100ms', ourError] + return e2eFailureTest(invocation, ourError) + }) + + function e2eFailureTest (invocation, ourError) { + const errorInvocationSpy = sinon.spy() + const errorInvocation = ['call', errorInvocationSpy] + + const [id] = sbp('chelonia.persistentActions/enqueue', { invocation, errorInvocation }) + return new Promise((resolve, reject) => { + let failureEventCounter = 0 + sbp('okTurtles.events/on', PERSISTENT_ACTION_FAILURE, (details) => { + if (details.id !== id) return + failureEventCounter++ + try { + assert(failureEventCounter <= testOptions.maxAttempts, 1) + // Check the event handler was called before the corresponding SBP invocation. + assert.strictEqual(failureEventCounter, errorInvocationSpy.callCount + 1, 2) + assert.strictEqual(details.error.message, ourError?.message ?? ourError, 3) + } catch (err) { + reject(err) + } + }) sbp('okTurtles.events/on', PERSISTENT_ACTION_TOTAL_FAILURE, (details) => { if (details.id !== id) return try { - assert.strictEqual(counter.callCount, testOptions.maxAttempts) - assert.strictEqual(details.error, ourError) - assert(isActionRemoved(id)) + assert.strictEqual(failureEventCounter, testOptions.maxAttempts, 3) + assert.strictEqual(errorInvocationSpy.callCount, testOptions.maxAttempts, 4) + assert.strictEqual(details.error.message, ourError?.message ?? ourError, 5) + assert(isActionRemoved(id), 6) resolve() } catch (err) { reject(err) } }) }) - }) + } - it('should call the given total failure invocation', function () { - return new Promise((resolve) => { - sbp('chelonia.persistentActions/enqueue', { - invocation: ['throwImmediately', createRandomError()], - totalFailureInvocation: ['call', resolve] - }) + it('should cancel and remove the given action', function () { + return new Promise((resolve, reject) => { + // This action will reject the promise and fail the test if it ever gets tried. + const [id] = sbp('chelonia.persistentActions/enqueue', ['call', reject]) + sbp('chelonia.persistentActions/cancel', id) + assert(isActionRemoved(id)) + // Wait half a second to be sure the action isn't going to be tried despite being removed. + setTimeout(resolve, 500) }) }) }) - -/* -const get = () => sbp('chelonia/db/get', `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}`) -const set = (value) => sbp('chelonia/db/set', `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}`, value) -*/