Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support retriable Chelonia actions #1768

Merged
merged 10 commits into from
Dec 7, 2023
2 changes: 2 additions & 0 deletions frontend/main.js
Original file line number Diff line number Diff line change
@@ -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))
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
console.info('NODE_ENV:', process.env.NODE_ENV)

Vue.config.errorHandler = function (err, vm, info) {
2 changes: 1 addition & 1 deletion shared/domains/chelonia/db.js
Original file line number Diff line number Diff line change
@@ -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<Error | void> {
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<boolean> {
200 changes: 200 additions & 0 deletions shared/domains/chelonia/persistent-actions.js
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Date is a bad idea for anything besides maybe logging. The reason is that the date could change arbitrarily. The better alternative is to use a monotonic timer for this, such as performance.now(). I'm not sure exactly what this nextRetry field is used for, exactly, though.

The second issue is the, like for the this.timer assignment below, that 1e3 seems like a pretty low value for a retry

Copy link
Collaborator Author

@snowteamer snowteamer Nov 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nextRetry field is actually used for logging as per the issue requirement for 'chelonia.persistentActions/status'

1e3 seems like a pretty low value for a retry

options.retrySeconds is a number of seconds, but we need milliseconds here, hence the 1e3 multiplier

: ''
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
// 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1e3 seems like a pretty low value

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

}
status.pending = false
}

async handleSuccess (result: any): Promise<void> {
const { status } = this
status.lastError = ''
status.nextRetry = ''
status.pending = false
this.options.successInvocationSelector &&
await this.trySBP([this.options.successInvocationSelector, result])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we refactor this to use events instead of options.successInvocationSelector?

I think it makes sense to add at least two events using okTurtles.events:

  • CHEL_PERSISTENT_ACTIONS_SUCCESS - would be emitted above here
  • CHEL_PERSISTENT_ACTIONS_FAILURE - would be emitted next to line 82 above, next to the errorInvocation, along with the error object

And if you think of any others that we should emit, feel free to suggest

}

trySBP (invocation: SbpInvocation | void): any {
try {
return invocation ? sbp(...invocation) : undefined
snowteamer marked this conversation as resolved.
Show resolved Hide resolved
} catch (error) {
console.error(tag, error.message)
}
}
}

// SBP API

sbp('sbp/selectors/register', {
'chelonia.persistentActions/_init' (): void {
sbp('okTurtles.events/on', LOGIN, (function () {
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
this.actionsByID = Object.create(null)
this.databaseKey = `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, as this is a utility for Chelonia (and possibly in the future, an independent library), we cannot access 'state/vuex/getters' either

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

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<void> {
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<Error | void> {
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<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.
}
}
},

// 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)
}
}
})