From 1ac054ffcbf665b885ec55944a0652023139387f Mon Sep 17 00:00:00 2001 From: Mathieu Hofman Date: Tue, 23 Jul 2024 01:45:32 +0000 Subject: [PATCH 1/6] feat(vow): abandoned errors are retriable --- packages/internal/src/upgrade-api.js | 26 +++++++++++++++++++++- packages/internal/test/upgrade-api.test.js | 15 +++++++++++++ packages/vow/vat.js | 12 ++++++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/internal/src/upgrade-api.js b/packages/internal/src/upgrade-api.js index c2cadd7be16..6c35963d780 100644 --- a/packages/internal/src/upgrade-api.js +++ b/packages/internal/src/upgrade-api.js @@ -45,5 +45,29 @@ harden(makeUpgradeDisconnection); * @returns {reason is UpgradeDisconnection} */ export const isUpgradeDisconnection = reason => - isFrozen(reason) && matches(reason, UpgradeDisconnectionShape); + reason != null && // eslint-disable-line eqeqeq + isFrozen(reason) && + matches(reason, UpgradeDisconnectionShape); harden(isUpgradeDisconnection); + +/** + * Returns whether a reason is a 'vat terminated' error generated when an object + * is abandoned by a vat during an upgrade. + * + * Normally we do not want to rely on the `message` of an error object, but this + * is a pragmatic solution to the current state of vat upgrade errors. In the + * future we'd prefer having an error with a cause referencing a disconnection + * object like for promise rejections. See + * https://github.com/Agoric/agoric-sdk/issues/9582 + * + * @param {any} reason + * @returns {reason is Error} + */ +export const isAbandonedError = reason => + reason != null && // eslint-disable-line eqeqeq + isFrozen(reason) && + matches(reason, M.error()) && + // We're not using a constant here since this special value is already + // sprinkled throughout the SDK + reason.message === 'vat terminated'; +harden(isAbandonedError); diff --git a/packages/internal/test/upgrade-api.test.js b/packages/internal/test/upgrade-api.test.js index 5ffc38f9b29..f8eee5d2d92 100644 --- a/packages/internal/test/upgrade-api.test.js +++ b/packages/internal/test/upgrade-api.test.js @@ -1,8 +1,11 @@ // @ts-check import test from 'ava'; +import { makeMarshal } from '@endo/marshal'; + import { makeUpgradeDisconnection, isUpgradeDisconnection, + isAbandonedError, } from '../src/upgrade-api.js'; test('isUpgradeDisconnection must recognize disconnection objects', t => { @@ -18,3 +21,15 @@ test('isUpgradeDisconnection must recognize original-format disconnection object }); t.true(isUpgradeDisconnection(disconnection)); }); + +test('isAbandonedError recognizes marshalled vat terminated errors', t => { + const { fromCapData, toCapData } = makeMarshal(undefined, undefined, { + serializeBodyFormat: 'smallcaps', + errorIdNum: 70_000, + marshalSaveError: () => {}, + }); + const error = harden(Error('vat terminated')); + const remoteError = fromCapData(toCapData(error)); + + t.true(isAbandonedError(remoteError)); +}); diff --git a/packages/vow/vat.js b/packages/vow/vat.js index a486730b9c4..ad7d177d409 100644 --- a/packages/vow/vat.js +++ b/packages/vow/vat.js @@ -5,7 +5,10 @@ /* global globalThis */ // @ts-check -import { isUpgradeDisconnection } from '@agoric/internal/src/upgrade-api.js'; +import { + isUpgradeDisconnection, + isAbandonedError, +} from '@agoric/internal/src/upgrade-api.js'; import { makeHeapZone } from '@agoric/base-zone/heap.js'; import { prepareBasicVowTools } from './src/tools.js'; @@ -15,11 +18,16 @@ import makeE from './src/E.js'; const isRetryableReason = (reason, priorRetryValue) => { if ( isUpgradeDisconnection(reason) && - (!priorRetryValue || + (!isUpgradeDisconnection(priorRetryValue) || reason.incarnationNumber > priorRetryValue.incarnationNumber) ) { return reason; } + // For abandoned errors there is no way to differentiate errors from + // consecutive upgrades + if (isAbandonedError(reason) && !isAbandonedError(priorRetryValue)) { + return reason; + } return undefined; }; From f82a96bb844dcf73a345392a1504e7a888bf06c9 Mon Sep 17 00:00:00 2001 From: Mathieu Hofman Date: Tue, 1 Oct 2024 07:35:56 +0000 Subject: [PATCH 2/6] type(liveslots-tools): missing types for strict test env helpers --- .../swingset-liveslots/tools/prepare-strict-test-env.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/swingset-liveslots/tools/prepare-strict-test-env.js b/packages/swingset-liveslots/tools/prepare-strict-test-env.js index c785f376d34..c93490f5f60 100644 --- a/packages/swingset-liveslots/tools/prepare-strict-test-env.js +++ b/packages/swingset-liveslots/tools/prepare-strict-test-env.js @@ -21,6 +21,8 @@ export { flushIncarnation }; export { eventLoopIteration as nextCrank }; /** + * @import { PromiseKit } from '@endo/promise-kit' + * @import { Baggage } from '@agoric/swingset-liveslots' * @import { ReincarnateOptions } from './setup-vat-data.js' */ @@ -37,6 +39,7 @@ export const annihilate = (options = {}) => { return incarnation; }; +/** @returns {Baggage} */ export const getBaggage = () => { return incarnation.fakeVomKit.cm.provideBaggage(); }; @@ -51,7 +54,7 @@ export const nextLife = (fromIncarnation = incarnation) => { }; /** - * @template {(baggage: import('@agoric/swingset-liveslots').Baggage) => Promise | any} B + * @template {(baggage: Baggage) => Promise | any} B * @param {B} build * @param {(tools: Awaited>) => Promise | void} [run] * @param {object} [options] @@ -72,7 +75,7 @@ export const startLife = async ( oldIncarnationNumber, ); const { fakeVomKit } = nextLife(fromIncarnation); - /** @type {Map>} */ + /** @type {Map>} */ const previouslyWatchedPromises = new Map(); let buildTools; try { From 1b1772bf279bf6084a0aa5a479d4aa2e56511879 Mon Sep 17 00:00:00 2001 From: Mathieu Hofman Date: Tue, 1 Oct 2024 17:39:24 +0000 Subject: [PATCH 3/6] chore(vow): retriable -> retryable --- packages/vow/README.md | 4 ++-- packages/vow/src/tools.js | 18 ++++------------ packages/vow/src/types.ts | 36 +++++++++++++++++++++++++------ packages/vow/src/vow-utils.js | 2 +- packages/vow/test/types.test-d.ts | 6 +++--- 5 files changed, 39 insertions(+), 27 deletions(-) diff --git a/packages/vow/README.md b/packages/vow/README.md index 08d32ef3d13..bd8478a8b1b 100644 --- a/packages/vow/README.md +++ b/packages/vow/README.md @@ -150,8 +150,8 @@ Here is an (oversimplified) algorithm that `watch` and `when` use to obtain a final result: ```js -// Directly await the non-retriable original specimen. -// This is non-retriable because we don't know how our caller obtained +// Directly await the non-retryable original specimen. +// This is non-retryable because we don't know how our caller obtained // it in the first place, since it is an application-specific detail // that may not be side-effect free. let result = await specimenP; diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index 1764483e56b..d57cc2378ed 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -36,19 +36,8 @@ export const prepareBasicVowTools = (zone, powers = {}) => { const asVow = makeAsVow(makeVowKit); // FIXME in https://github.com/Agoric/agoric-sdk/pull/9785 - /** - * @alpha Not yet implemented - * - * Create a function that retries the given function if the underlying - * functions rejects due to upgrade disconnection. - * - * @template {(...args: any[]) => Promise} F - * @param {Zone} fnZone - the zone for the named function - * @param {string} name - * @param {F} fn - * @returns {F extends (...args: infer Args) => Promise ? (...args: Args) => Vow : never} - */ - const retriable = + /** @type {VowTools['retryable']} */ + const retryable = (fnZone, name, fn) => // @ts-expect-error cast (...args) => { @@ -95,7 +84,8 @@ export const prepareBasicVowTools = (zone, powers = {}) => { allSettled, asVow, asPromise, - retriable, + retryable, + retriable: retryable, // For temporary backwards compat with alpha implementation }); }; harden(prepareBasicVowTools); diff --git a/packages/vow/src/types.ts b/packages/vow/src/types.ts index dee2d14531f..96f0ffb4636 100644 --- a/packages/vow/src/types.ts +++ b/packages/vow/src/types.ts @@ -100,6 +100,27 @@ export type AsPromiseFunction< watcherArgs?: C | undefined, ) => Promise; +export interface RetryableTool { + /** + * Create a function that retries the given function if the underlying + * async function rejects due to an upgrade disconnection. The return value + * of the created function is a vow that settles to the final retry result. + * + * The retried function should be idempotent. + * + * @param fnZone the zone for the named function + * @param name base name to use in the zone + * @param fn the retried function + */ + Promise>( + fnZone: Zone, + name: string, + fn: F, + ): F extends (...args: infer Args) => Promise + ? (...args: Args) => Vow + : never; +} + export type VowTools = { /** * Vow-tolerant implementation of Promise.all that takes an iterable of vows @@ -142,13 +163,14 @@ export type VowTools = { fn: (...args: any[]) => Vow> | Awaited | PromiseVow, ) => Vow>; makeVowKit: () => VowKit; - retriable: Promise>( - fnZone: Zone, - name: string, - fn: F, - ) => F extends (...args: infer Args) => Promise - ? (...args: Args) => Vow - : never; + /** + * @alpha Not yet implemented + */ + retryable: RetryableTool; + /** + * @deprecated use `retryable` + */ + retriable: RetryableTool; watch: ( specimenP: EVow, watcher?: Watcher | undefined, diff --git a/packages/vow/src/vow-utils.js b/packages/vow/src/vow-utils.js index e18465ce367..d0a40de1694 100644 --- a/packages/vow/src/vow-utils.js +++ b/packages/vow/src/vow-utils.js @@ -29,7 +29,7 @@ harden(isVow); /** * A vow is a passable tagged as 'Vow'. Its payload is a record with * API-versioned remotables. payload.vowV0 is the API for the `watch` and - * `when` operators to use for retriable shortening of the vow chain. + * `when` operators to use for retryable shortening of the vow chain. * * If the specimen is a Vow, return its payload, otherwise undefined. * diff --git a/packages/vow/test/types.test-d.ts b/packages/vow/test/types.test-d.ts index 0993fe14e0b..d609bdac4b9 100644 --- a/packages/vow/test/types.test-d.ts +++ b/packages/vow/test/types.test-d.ts @@ -8,11 +8,11 @@ const vt: VowTools = null as any; const zone: Zone = null as any; // @ts-expect-error function param must return promise -vt.retriable(zone, 'foo', () => null); -vt.retriable(zone, 'foo', () => Promise.resolve(null)); +vt.retryable(zone, 'foo', () => null); +vt.retryable(zone, 'foo', () => Promise.resolve(null)); expectType<(p1: number, p2: string) => Vow<{ someValue: 'bar' }>>( - vt.retriable(zone, 'foo', (p1: number, p2: string) => + vt.retryable(zone, 'foo', (p1: number, p2: string) => Promise.resolve({ someValue: 'bar' } as const), ), ); From cf36da495c1e159bf9b2e23e0823350a9eb93b1c Mon Sep 17 00:00:00 2001 From: Mathieu Hofman Date: Tue, 1 Oct 2024 21:15:22 +0000 Subject: [PATCH 4/6] chore(orchestration): retriable -> retryable --- packages/boot/test/orchestration/contract-upgrade.test.ts | 4 ++-- packages/orchestration/src/exos/chain-hub.js | 6 +++--- packages/orchestration/src/utils/zoe-tools.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/boot/test/orchestration/contract-upgrade.test.ts b/packages/boot/test/orchestration/contract-upgrade.test.ts index c8dcc03f3c9..7f453ed990d 100644 --- a/packages/boot/test/orchestration/contract-upgrade.test.ts +++ b/packages/boot/test/orchestration/contract-upgrade.test.ts @@ -24,8 +24,8 @@ test.after.always(t => t.context.shutdown?.()); * Because the send-anywhere flow requires a lookup(), it waits forever. This * gives us a point at which we can upgrade the vat with a working agoricNames * and see that the flow continues from that point. (The lookup call is not made - * directly in a flow, but instead from a host API which uses the retriable - * helper. As such it tests both the idempotent retry mechanism of retriable on + * directly in a flow, but instead from a host API which uses the retryable + * helper. As such it tests both the idempotent retry mechanism of retryable on * upgrades, and the ability to resume an async-flow for which a host vow * settles after an upgrade.) */ diff --git a/packages/orchestration/src/exos/chain-hub.js b/packages/orchestration/src/exos/chain-hub.js index e9f52972e4b..97e25d5d1fc 100644 --- a/packages/orchestration/src/exos/chain-hub.js +++ b/packages/orchestration/src/exos/chain-hub.js @@ -205,7 +205,7 @@ export const makeChainHub = (zone, agoricNames, vowTools) => { valueShape: M.string(), }); - const lookupChainInfo = vowTools.retriable( + const lookupChainInfo = vowTools.retryable( zone, 'lookupChainInfo', /** @param {string} chainName */ @@ -227,7 +227,7 @@ export const makeChainHub = (zone, agoricNames, vowTools) => { }, ); - const lookupConnectionInfo = vowTools.retriable( + const lookupConnectionInfo = vowTools.retryable( zone, 'lookupConnectionInfo', /** @@ -258,7 +258,7 @@ export const makeChainHub = (zone, agoricNames, vowTools) => { ); /* eslint-disable no-use-before-define -- chainHub defined below */ - const lookupChainsAndConnection = vowTools.retriable( + const lookupChainsAndConnection = vowTools.retryable( zone, 'lookupChainsAndConnection', /** diff --git a/packages/orchestration/src/utils/zoe-tools.js b/packages/orchestration/src/utils/zoe-tools.js index e81a1136e3c..a5cbbe389f8 100644 --- a/packages/orchestration/src/utils/zoe-tools.js +++ b/packages/orchestration/src/utils/zoe-tools.js @@ -80,7 +80,7 @@ export const makeZoeTools = (zcf, { when, allVows, allSettled, asVow }) => { // const { zcfSeat: tempSeat, userSeat: userSeatP } = // zcf.makeEmptySeatKit(); // const uSeat = await userSeatP; - // // TODO how do I store in the place for this retriable? + // // TODO how do I store in the place for this retryable? // atomicTransfer(zcf, srcSeat, tempSeat, amounts); // tempSeat.exit(); // return uSeat; From 53039135f760666f88ac0659f5e65c2c1b74a1d5 Mon Sep 17 00:00:00 2001 From: Mathieu Hofman Date: Tue, 23 Jul 2024 07:36:11 +0000 Subject: [PATCH 5/6] feat(vow): retryable tools --- .../snapshots/send-anywhere.test.ts.md | 6 + .../snapshots/send-anywhere.test.ts.snap | Bin 1239 -> 1340 bytes .../snapshots/staking-combinations.test.ts.md | 6 + .../staking-combinations.test.ts.snap | Bin 2471 -> 2573 bytes .../snapshots/unbond.contract.test.ts.md | 6 + .../snapshots/unbond.contract.test.ts.snap | Bin 2055 -> 2146 bytes packages/vow/src/retryable.js | 224 ++++++++++++++++++ packages/vow/src/tools.js | 13 +- packages/vow/src/types.ts | 3 - .../contracts/snapshots/valueVow.test.js.md | 3 + .../contracts/snapshots/valueVow.test.js.snap | Bin 451 -> 511 bytes 11 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 packages/vow/src/retryable.js diff --git a/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.md b/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.md index 6d091f7bb4f..38ac9729bdd 100644 --- a/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.md @@ -31,6 +31,9 @@ Generated by [AVA](https://avajs.dev). chainInfos: {}, connectionInfos: {}, denom: {}, + lookupChainInfo_kindHandle: 'Alleged: kind', + lookupChainsAndConnection_kindHandle: 'Alleged: kind', + lookupConnectionInfo_kindHandle: 'Alleged: kind', }, contract: { 'ChainHub Admin_kindHandle': 'Alleged: kind', @@ -74,8 +77,11 @@ Generated by [AVA](https://avajs.dev). }, }, vows: { + AdminRetryableFlow_kindHandle: 'Alleged: kind', + AdminRetryableFlow_singleton: 'Alleged: AdminRetryableFlow', PromiseWatcher_kindHandle: 'Alleged: kind', VowInternalsKit_kindHandle: 'Alleged: kind', WatchUtils_kindHandle: 'Alleged: kind', + retryableFlowForOutcomeVow: {}, }, } diff --git a/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.snap b/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.snap index 0149838132a32474b67377e6ba9f0795e280ebfb..897c3c4e64b0a400c6639ac4ed092169d96d0e52 100644 GIT binary patch literal 1340 zcmV-C1;hG5RzV^Vn|0F|LX`w~cbLq9<$|yr~vs zCF{PH#UG0Z00000000A>SWRpjMHK$V_S$R5UfWIDrfCWmKN3O$AtY3RxTGmoBT^cY zv=s+b#=8@H$$G|`9oLi#>H!HP5J(7#L#fmXRUA0Lg&QY&p;FZYXF&W^zyTo+ToHJk z-R#a%E1R6GeBb-#&HI`6>}y*cPuSc3&;>{v(s|b`!z>6i|?Go^HNi&<#&B{NO zfZs~MvnKG43EVb;N*P!!1Kl!kxvWVvb&1l|GVsZN^k2)s@d|LU0(@Bk{;FuE2U8U_ zqgBI^C8zK5r7*8qi)mgn_g&ugm=ZiB9FDA$OigF*ggf@q6+Ej4i|ru~4A1hBq=Uhp z>N=rtG_8=j#zG{JBJ1t7D!Lm=NS4)&BO8h;mgN=}A;)HR`(bBr~ zQkuJjJ28UcOf(a|E_gz04+qPkZq~a#6tnl2xU(ENkyYvb9i_xA_8?LNG4u)zgFETv z%x{HeKIN@QGW9yxVBBe~wX>ez4DC_}HzKngN|59~X6`+zY9OX2|M;@-`@(OXi8EVE zwk?KSwN43dZzDIM`RJ;!sdo>_ea!x&fnE^ZHnpjf`H?!j>x#&Ho5gk37m8&&-XdhK z8e2hvR(*l?SpYp5gG{$$Zwlc}T9n3+nE|!8nc4wsAVtlyV?mv*GbJyL*Q@qVM|bJXLltd*v*g6QdQbnB>&+_faVagLBU+*Yo$5q_<-6UXbyN zX_Lg9tSH8kl=KqGho9>8dW41ROF`RwlBh8iElowusc3mBTG98h+>I*mSykJ;s#8S{ z=pu!iRp3??sMmnCnkF`zI`c{mxUSRYrk;5ywGEOWbwAd2Q&;pEcUDw@=Jipsa$X?vdzEV?yMK;7iO87I4=B9;^dy z9r(Bo+^K7N^^~5z8q9}Z@`5Ec^HfP%r}B9yGENXR70VOdtS8sTHmUQfk%=d|LF9g~ z$L7rIg6)hFlNgH);DHA4N&~pj0PZ%l;5AagE9k-V##1@$tjxhnT2BdHf~fbv$W=W@ y($S{aU^H`A&uivWcDp2#d9=g4*A~OXwswA4Y0)jkWXo4ns(%4Pr7(1T5dZ*4PmdG; literal 1239 zcmV;|1StDKRzVS50plMHqg@_S)-@UE57UlO})*;s6H(CnUrrO>q-7X+o2< zaG@ISPV8;gGuG}HQ%>y#QK<(cE(rBPM1BA#4i!g`5C_DmAOVL`Aue1vA%PI`I&1IF z5-S^@EI%JJ^M1@b{&cV93TxlLbHJn{UDj^VcAK`D<8#{cJ3{(*4s4rB`c~D+qJZIt z=tOS=sbfEkjnll*4uCbJ|V#{bpUu_IA#bf3wvxH|e?*u}VkPQNa zaFP1AxYcyUAb{>iAl>Rvhp+Zq0rD`02rfNp$pA4k6T}dn)nUH$sB{Dm;5Q<0?j14k zhf%ED&H(RcfUh!ukp(VgfxB7YVHT+6fXy7x&jBCjfCo7xs+o9HbyD~<2mF--Ue1xSn*Uh?e${|y^1#hJaF7Rn$}6(6Dl7h-$1s`LEdYlF;I{(stPU_8 zxUB=9=!!*Mwa9&;17GUEKRWPQQK8QzDyOVuePP+|I(#|IU)rLXSLD9K+b)xWhlI0Z zb)2dA%wBMYyL1FkyMv`>p9jKSKJaL-ccS`@IV>g}62~|L zX_*Vw-Ib2(pFpaQhN8CYdGr>^hTp2y>KF?LW=@%}aa5a%7N(;5RJ1r1EvZvK^QZ*; zUQ(ugWva=U#M1#~AYTS9mw|g_#cU?A<_~4y7nL@fcwvyBnx3poswwX?Zm-GzEu^Q{ zF0AZ$l>2+k8{Q?%J5855E$b@t`>s4~JthniSKh){uX4jvD-AN~pp%_QC)w2saHRr# zP*I|6B%+*AA5b*K6V4>$vq*kQjN4++Wn2c&>ddzl;Cpoo<}rzW95Es<1&`rH12}I0 zHw@s=0Dd%pvZ?r06MpLSoDcsX1P`Os7jImh$mbJdag3fpuIQ-T*Gsy#4p zWXJaCU`K2-n!2m!75S9sPn^kI+G6gzOJQOgM}Nv2bWbv`;Y;et{{XXANqo5v002-M BR*L`t diff --git a/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.md b/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.md index afcd6ed2fd1..8d65dc66fcb 100644 --- a/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.md @@ -96,6 +96,9 @@ Generated by [AVA](https://avajs.dev). chainName: 'agoric', }, }, + lookupChainInfo_kindHandle: 'Alleged: kind', + lookupChainsAndConnection_kindHandle: 'Alleged: kind', + lookupConnectionInfo_kindHandle: 'Alleged: kind', }, contract: { 'ChainHub Admin_kindHandle': 'Alleged: kind', @@ -208,8 +211,11 @@ Generated by [AVA](https://avajs.dev). }, }, vows: { + AdminRetryableFlow_kindHandle: 'Alleged: kind', + AdminRetryableFlow_singleton: 'Alleged: AdminRetryableFlow', PromiseWatcher_kindHandle: 'Alleged: kind', VowInternalsKit_kindHandle: 'Alleged: kind', WatchUtils_kindHandle: 'Alleged: kind', + retryableFlowForOutcomeVow: {}, }, } diff --git a/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.snap b/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.snap index 406237967cd19ffb5aa5ec256a4d339d32f20cb7..45b56186c154fcc15ba34f43428c868dcaab9d2b 100644 GIT binary patch literal 2573 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 diff --git a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md index 2be729c04fe..59ebbc6aebb 100644 --- a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md @@ -114,6 +114,9 @@ Generated by [AVA](https://avajs.dev). }, }, denom: {}, + lookupChainInfo_kindHandle: 'Alleged: kind', + lookupChainsAndConnection_kindHandle: 'Alleged: kind', + lookupConnectionInfo_kindHandle: 'Alleged: kind', }, contract: { orchestration: { @@ -156,8 +159,11 @@ Generated by [AVA](https://avajs.dev). }, }, vows: { + AdminRetryableFlow_kindHandle: 'Alleged: kind', + AdminRetryableFlow_singleton: 'Alleged: AdminRetryableFlow', PromiseWatcher_kindHandle: 'Alleged: kind', VowInternalsKit_kindHandle: 'Alleged: kind', WatchUtils_kindHandle: 'Alleged: kind', + retryableFlowForOutcomeVow: {}, }, } diff --git a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.snap b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.snap index 4edb9b3096e692215483c3b51ee90fcca221d911..c768cf53a1814b08b71acc0ef5ada297bd16eea6 100644 GIT binary patch literal 2146 zcmV-o2%YyqRzVe)p6==EZm)@n3M(_Sm&tlbn4M%YYxc(M zPF6t}PFJ1jsZ8~$PSxohN01OfHj)hl4N5{DOngv69t>-gpotJc4S7{ji7VoQFGkRS z55g|m)wffpW@o!6Pufpi^?(0&{&W8GU(TtQHX4ShZrM-1Ou5btN^MRouG@^X?M;*0 zPrj^agp)5ENcj;)_oqaZ$TEP_0G`aMf(lM?Kz(b{s3ukoMqB zerVb@C0l1m+gSo<$3^7@TIy$rNf#(PRP?wfGt^rW)6YMJNyQo#VcLU~nJq__o)TnE z(mKU9%-&)=Ajndx$yje|jsQ#m1hpQg%hs}Cnp>Ur(tvl;x!x`4+lx$F8X7Sv)4@KI zYZhaWB+wNk73J#G?K8D&q(Zw}jMQbK(us>vUexutcY3HQz#J1J63-`>OuJ>;^>d<~ z^+i=RJB-&?Ok=!y=i>atX;US}Z7BCqcqW@JiDplE=ex5oRiO6W=pr}CBoIs$#+iqQFHqgU^Gu$Tb8Dnsw~nD=%9_*DW(B!RO@ z3I0)!0WTzh@5r!w_MFAXJRMvwEB`4u@^pwaO-omu_sP`geb~u`dM^||D+oe1w{(pP zbcP_!_UH~DS?}u~o^vqYO^IE^0Mn!DL;fJr47CMJ0*c}wGiiaW!x|>j7MXU@BFx_C z_EZTdJ}DXo(-}M;8t#8jO6%7NiJ(92N%VUQot^L#hy`ipByJPVE zrWOx@C(o6J`Z9Ll2PxN$^7D8z`tRB2Gil&t8h9)XJe`(ycGMFuucUz=q=Db3fq%>J zxCdU!00%R`!x>;RBf(QLJai=kJd*+5$^h?W0Dl&!XC<|qM{O|+Je&n~vcR{qz|XS4 zd$L-=qjob3_;bKQ4p_?pPvn3XbCO!glU?4-0YA?HH)ZI--6Gu`0KGO<8`j!E5K6e1 zu0H+{>t_BQ)R%Tbt~4z)ayBPhI%{4uw%;LI*5zl!F z^b_wjVT2U#r@&IP3BmN^_ubj{TK+@Zo^{(kKR0*6q5fii zV(U}l!XH1jaA*6k+{u2q0!@<%?n#o0@(W>r7-gEd-J*;;cUivo^S}prxcab|yAM-* zH}inM;07gRP@)TvKxql&uN8p13c#g;ly;`&3>CRr0KQQG-YfwBDgYm!0aj)Ja|XCN zBdO;-3GUhq@Wu@AHyJwP8L3_b?kNJRMd0Zo@KzD{V-X0HfI}roz33Tfr39QU0Z+)# zvS*~%O2Cgxz>N}6DFbKAK&uQqR|Z}yOX?L*BJ@2x=#)yyE6OxV)wClGEhNCBTy+!UG)8LkKYFLQjmoINDYH(he5L|?vm&L+DNjayy#oA1h9y0)>lNUJ49j?6;VO`*O0Ywo zhFU)I^sa6PPk~Ou(8j<8JKaurETGLTO<1mbtHMRum(`8_U06g3H>$v96?nA@+^Pci z*MO&Lz%Ogkq(0)AlzfeLfY>&*_!`q6@6bo+&bDc3lb$YXyK#69nAIW0E^zt0O1Rn5 z)m2I?i?HV8Uq@tpk2S@SHEyWP)FNB-6uYc*vY-3(qNKjZ7cV!G8l*vuFI5MhLF*0D YY?3CelMPO-y7L+NKRGIjvt1ql0L6C|fB*mh literal 2055 zcmV+i2>ACwRzVP*435c4K_kUNu z8|y3tqaTY100000000BsSzBxzR~i1!UdOxMo4xD3*iEcY^g6r1ABx$0= zZWK{uot-&ePd$6aojDuFL5&o(PkE?{-~lAAQB@Ve8{z>~^rb~jUMK=_kpLk9st}?o z2oP!zh3?E=&Yp3cUH1w0X=ncLznt@5&zWzo*DPD#bg$iD!W0%`uAq)ET~4>$4O_U^ zZWsm?^y1XXFyd(ah(@#}0H*-F0N@sYKLB_SK#2fH2ry1!ITAZldy46TknZ1XHVcvh ziGyS?PC|JS28rg#fk>ty!#kwpp`GccXgJ6lGD4_9>1{~9X9S!)t3jbIjVv>rt%L_)s}CifUu`L;v1wg!sZJ_(aS34vu) zx&i`VHGNLm_4C4Z*sx}l&kHKpFsn!t@^`J54|zZPnp}U3)|qpGZZgO9P}z|lD%V9V zQPXNV%!fQAL)I;Od&zdrG=*+A*b@P7jWj(gPt&c=n?l#LZALdw(=Bfcyoigb8?@N2 z1jBC7SeG&2o?Mr2Q7$v5q(voa4&}z9jN1*5_LNNXiqkS*xBbodvP8@1Hs^k5_5e2} zK-BPMrqFtkCgiP=JD0G&1x;+VsN6`c*@qPbqA6A=tz^EJkPUBRHw$!sa5QmVh_EVsdKDh3pSkk zG$E;k)EK=0dz?0yM>;BzG_$6!*tX>dR$Zbbx2V3!M4RY`SAWGg>(~v`Wf!Q>H->jp zGN{9!~-*s*nRJEWDiro=F0) zs?ZMy%zG~hL`H$-QQ-Qh0{=+BfS->7zgA%%4NL`o;hIeKf-gqvwqxqv`%g>n<6gNi z{b=2h8C>C*29w0SEK$dNaR(n>Yx5tR@i4zl$(^Z#@kO;E6KP3Ims`T5K!`tzO2}N3 z*KBSqaAU=x++A<==@dvviQ&$2I?wlYqc)KB?NX`;XfJyh{#`Qp-sMLYJ9eID++far z%A{G-&oQ@YiTxIYfPg?_DR=o*Y{w5uh3b{C!B(V^$mKL}H4VI!2Hr?3u{#pTh_}+f zpVL4g1I%R<_-FvWngKqO0iMkOFRJj23Xi>(0d8f0+Zmvg1&(KdC$kFMc!2GhEbwd= z_+A$HX%_fb7AWNuwuu1Sd=5CC1Kb?&`5f>{4)~?YRu0sUcXPmha=`qU0=;*)Oz%$s zfAlfB?WmK43NB&lPd~<6XG)7BuMcRe;9Z5q*w9Crk2IZVt16rQ#xBSQ<|Bx^~wCO3y^tq#VxBcSy z2ey5z-}c#=nR!q2mxi}KBX9iN;Ujmq|Fyf>uXdn39pdbrCM1-X&Mk}^_I87D;oXCT z9>@cq$fIx4O7u@!Bs8A~jw_&)3R=zsXB1FY0fjH-fwlKRU&t$EC$Cm0?PebMQ66|V z56l(-RsfzW0N*YEHwy}TAyDAnDFFW{03V-Fpv8cuCntc~1n~R>@WupidjcpIfln5J z&lVN-Qb5ylMc{f7ctwR)0-D|~0)Hz4)e>-_1Y9ox-zot=E&*?s6!yu$XqGPnm9he> z_QMihOHC4Tu<5}AgQiT`YX|In(^KLXvUiXs~>J+y%kQ6oF`Mg#jFQ5>$5)ho lB(rFZS(m3epU|o`TCdYOtI~DBoT~Q;^1u3F9A}vv001{s;uZh^ diff --git a/packages/vow/src/retryable.js b/packages/vow/src/retryable.js new file mode 100644 index 00000000000..98e5416bcb7 --- /dev/null +++ b/packages/vow/src/retryable.js @@ -0,0 +1,224 @@ +import { Fail } from '@endo/errors'; +import { M } from '@endo/patterns'; +import { PromiseWatcherI } from '@agoric/base-zone'; +import { makeAsVow, toPassableCap, VowShape } from './vow-utils.js'; + +/** + * @import {MapStore, WeakMapStore} from '@agoric/store' + * @import {Zone} from '@agoric/base-zone' + * @import {Vow, VowKit, IsRetryableReason, VowTools} from './types.js' + * @import {Passable, PassableCap} from '@endo/pass-style' + */ + +/** + * @typedef {object} PreparationOptions + * @property {() => VowKit} makeVowKit + * @property {IsRetryableReason} isRetryableReason + */ + +/** + * @typedef {(...args: Passable[]) => Promise} RetryableFunc + */ + +const { defineProperties } = Object; + +const RetryableFlowIKit = harden({ + flow: M.interface('Flow', { + restart: M.call().returns(), + getOutcome: M.call().returns(VowShape), + }), + resultWatcher: PromiseWatcherI, +}); + +const AdminRetryableFlowI = M.interface('RetryableFlowAdmin', { + getFlowForOutcomeVow: M.call(VowShape).returns(M.opt(M.remotable('flow'))), +}); + +/** + * @param {Zone} outerZone + * @param {PreparationOptions} outerOptions + */ +export const prepareRetryableTools = (outerZone, outerOptions) => { + const { makeVowKit, isRetryableReason } = outerOptions; + + const asVow = makeAsVow(makeVowKit); + + /** + * So we can give out wrapper functions easily and recover flow objects + * for their activations later. + */ + const flowForOutcomeVowKey = + /** @type {MapStore} */ ( + outerZone.mapStore('retryableFlowForOutcomeVow', { + keyShape: M.remotable('toPassableCap'), + valueShape: M.remotable('flow'), // isDone === false + }) + ); + + /** + * @param {Zone} zone + * @param {string} tag + * @param {RetryableFunc} retryableFunc + */ + const prepareRetryableFlowKit = (zone, tag, retryableFunc) => { + typeof retryableFunc === 'function' || + Fail`retryableFunc must be a callable function ${retryableFunc}`; + + const internalMakeRetryableFlowKit = zone.exoClassKit( + tag, + RetryableFlowIKit, + activationArgs => { + harden(activationArgs); + + return { + activationArgs, // restarting the retryable function uses the original args + outcomeKit: makeVowKit(), // outcome of activation as vow + lastRetryReason: undefined, + runs: 0n, + isDone: false, // persistently done + }; + }, + { + flow: { + /** + * Calls the retryable function, either for the initial run or when + * the result of the previous run fails with a retryable reason. + */ + restart() { + const { state, facets } = this; + const { activationArgs, isDone } = state; + const { flow, resultWatcher } = facets; + + !isDone || + // separate line so I can set a breakpoint + Fail`Cannot restart a done retryable flow ${flow}`; + + const runId = state.runs + 1n; + state.runs = runId; + + let resultP; + try { + resultP = Promise.resolve(retryableFunc(...activationArgs)); + } catch (err) { + resultP = Promise.reject(err); + } + + outerZone.watchPromise(harden(resultP), resultWatcher, runId); + }, + getOutcome() { + const { state } = this; + const { outcomeKit } = state; + return outcomeKit.vow; + }, + }, + resultWatcher: { + onFulfilled(value, runId) { + const { state } = this; + const { runs, outcomeKit } = state; + if (runId !== runs) return; + !state.isDone || + Fail`Cannot resolve a done retryable flow ${this.facets.flow}`; + outcomeKit.resolver.resolve(value); + flowForOutcomeVowKey.delete(toPassableCap(outcomeKit.vow)); + state.isDone = true; + }, + onRejected(reason, runId) { + const { state } = this; + const { runs, outcomeKit } = state; + if (runId !== runs) return; + !state.isDone || + Fail`Cannot reject a done retryable flow ${this.facets.flow}`; + const retryReason = isRetryableReason( + reason, + state.lastRetryReason, + ); + if (retryReason) { + state.lastRetryReason = retryReason; + this.facets.flow.restart(); + } else { + outcomeKit.resolver.reject(reason); + flowForOutcomeVowKey.delete(toPassableCap(outcomeKit.vow)); + state.isDone = true; + } + }, + }, + }, + ); + const makeRetryableFlowKit = activationArgs => { + const retryableKit = internalMakeRetryableFlowKit(activationArgs); + const { flow } = retryableKit; + + const vow = flow.getOutcome(); + flowForOutcomeVowKey.init(toPassableCap(vow), flow); + flow.restart(); + return retryableKit; + }; + return harden(makeRetryableFlowKit); + }; + + /** + * @type {VowTools['retryable']} + */ + const retryable = (zone, tag, retryableFunc) => { + const makeRetryableKit = prepareRetryableFlowKit(zone, tag, retryableFunc); + const wrapperFuncName = `${tag}_retryable`; + + const wrapperFunc = { + /** @param {any[]} args */ + [wrapperFuncName](...args) { + // Make sure any error results in a rejected vow + return asVow(() => { + zone.isStorable(harden(args)) || + Fail`retryable arguments must be storable ${args}`; + const { flow } = makeRetryableKit(args); + return flow.getOutcome(); + }); + }, + }[wrapperFuncName]; + defineProperties(wrapperFunc, { + length: { value: retryableFunc.length }, + }); + // @ts-expect-error inferred generic func + return harden(wrapperFunc); + }; + + const adminRetryableFlow = outerZone.exo( + 'AdminRetryableFlow', + AdminRetryableFlowI, + { + /** + * @param {Vow} outcomeVow + */ + getFlowForOutcomeVow(outcomeVow) { + return flowForOutcomeVowKey.get(toPassableCap(outcomeVow)); + }, + }, + ); + + return harden({ + prepareRetryableFlowKit, + adminRetryableFlow, + retryable, + }); +}; +harden(prepareRetryableTools); + +/** + * @typedef {ReturnType} RetryableTools + */ + +/** + * @typedef {RetryableTools['adminRetryableFlow']} AdminRetryableFlow + */ + +/** + * @typedef {ReturnType} MakeRetryableFlowKit + */ + +/** + * @typedef {ReturnType} RetryableFlowKit + */ + +/** + * @typedef {RetryableFlowKit['flow']} RetryableFlow + */ diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index d57cc2378ed..35511de09c9 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -3,6 +3,7 @@ import { makeAsVow } from './vow-utils.js'; import { prepareVowKit } from './vow.js'; import { prepareWatchUtils } from './watch-utils.js'; import { prepareWatch } from './watch.js'; +import { prepareRetryableTools } from './retryable.js'; import { makeWhen } from './when.js'; /** @@ -35,14 +36,10 @@ export const prepareBasicVowTools = (zone, powers = {}) => { const watchUtils = makeWatchUtils(); const asVow = makeAsVow(makeVowKit); - // FIXME in https://github.com/Agoric/agoric-sdk/pull/9785 - /** @type {VowTools['retryable']} */ - const retryable = - (fnZone, name, fn) => - // @ts-expect-error cast - (...args) => { - return watch(fn(...args)); - }; + const { retryable } = prepareRetryableTools(zone, { + makeVowKit, + isRetryableReason, + }); /** * Vow-tolerant implementation of Promise.all that takes an iterable of vows diff --git a/packages/vow/src/types.ts b/packages/vow/src/types.ts index 96f0ffb4636..7c7e8418b20 100644 --- a/packages/vow/src/types.ts +++ b/packages/vow/src/types.ts @@ -163,9 +163,6 @@ export type VowTools = { fn: (...args: any[]) => Vow> | Awaited | PromiseVow, ) => Vow>; makeVowKit: () => VowKit; - /** - * @alpha Not yet implemented - */ retryable: RetryableTool; /** * @deprecated use `retryable` diff --git a/packages/zoe/test/unitTests/contracts/snapshots/valueVow.test.js.md b/packages/zoe/test/unitTests/contracts/snapshots/valueVow.test.js.md index 0d19048e384..feb090d21fe 100644 --- a/packages/zoe/test/unitTests/contracts/snapshots/valueVow.test.js.md +++ b/packages/zoe/test/unitTests/contracts/snapshots/valueVow.test.js.md @@ -9,11 +9,14 @@ Generated by [AVA](https://avajs.dev). > contract baggage after start { + AdminRetryableFlow_kindHandle: 'Alleged: kind', + AdminRetryableFlow_singleton: 'Alleged: AdminRetryableFlow', PromiseWatcher_kindHandle: 'Alleged: kind', VowInternalsKit_kindHandle: 'Alleged: kind', WatchUtils_kindHandle: 'Alleged: kind', publicFacet_kindHandle: 'Alleged: kind', publicFacet_singleton: 'Alleged: publicFacet', + retryableFlowForOutcomeVow: {}, vowResolver: { resolver: Object @Alleged: VowInternalsKit resolver {}, vow: Object @Vow { diff --git a/packages/zoe/test/unitTests/contracts/snapshots/valueVow.test.js.snap b/packages/zoe/test/unitTests/contracts/snapshots/valueVow.test.js.snap index 756b545154ffce69e085fbb02d6bd01a36320434..2631812d4c3acae432f2f432ac8de7fbd508a7ec 100644 GIT binary patch literal 511 zcmVI2#-Y;Dp*_81mpa8s zD3e4coZ3vz>_j_zdKt&k$@k*BKo`ZK9^H8b;0=It0KWh{Ctys#1pz+@*z|xs4_Kql zfI9o(M~)m(_38UQXccrDbPsFft&;$)4Cu*LJWaC$cIHHeiib+)9}d$j-jP|Xc%fR| zQi>CfUk1fp1F`WBgiW(VvD4W?^td9l)eYXm*G5lM%kQL%CTy0C^Jt{=-ORDcq_TUd zTekjw>HF5D$}U^_V0IL$G#bi?m$$9gw#K(!mDXgSKGDPjs@6??tV3<~k6fguJSt|~ zCpxQX@6L68z*eg{8&L1&-gr0nrbpt72YmH_Z_O1Gtrhc1ub(xqo~exbM4RI8jhGkF z8t%6S^sMT)@i&77>N=RWEr4Co2vv|rI$tEB(l@O6c4zWLX&IM@PXhSdM08qx{h?gd zC@XWFk6yI$cL((Jzxm4&-}hO`kk#=?S&9WPZK$9K@Lfc!TS)n zZR}LH!XJwW00000000A(lg)0^Koo_~9g`mkCD=+@sR$wS2CP`HK!OEI)ddw&Q?(ly z+c%Es;E6m_o9voZp8~1Pg16|ptauLgtSeP9I2cDPGOLm9J>NN_qq&|0N{44=v1FTA z#c?3xSjL=~RL;y)TeDb3k+kwe{BqF2>QI+DZvcz{TmrZSAP9Ixzy$#VYJ1dv7kuK- z60NVU>wbJrTj}oXJWxsaR))O2ZCz`UG*)bNT1|&*X>ASEC;Cc2 zwe>L9#d|hd<(#cralRAqgMgpS7Q{viqNLr`Xx(g$>Jz;Xe!M0TrSH7v+nLF^(lRO$R|5FfM6@>E^_y~2qqK5ejGu4h tZ+rCgzxhjv>$ Date: Tue, 1 Oct 2024 07:34:46 +0000 Subject: [PATCH 6/6] test(vow): add retryable tests --- packages/vow/test/retryable-restart.test.js | 110 +++++++++++ packages/vow/test/retryable.test.js | 196 ++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 packages/vow/test/retryable-restart.test.js create mode 100644 packages/vow/test/retryable.test.js diff --git a/packages/vow/test/retryable-restart.test.js b/packages/vow/test/retryable-restart.test.js new file mode 100644 index 00000000000..39ca98ac3a6 --- /dev/null +++ b/packages/vow/test/retryable-restart.test.js @@ -0,0 +1,110 @@ +import { + annihilate, + getBaggage, + nextCrank, + startLife, + test, +} from '@agoric/swingset-vat/tools/prepare-strict-test-env-ava.js'; + +import { Fail } from '@endo/errors'; +import { makeDurableZone } from '@agoric/zone/durable.js'; + +import { prepareVowTools } from '../vat.js'; + +test.serial('retries on disconnection', async t => { + annihilate(); + + t.plan(1); + + await startLife( + async baggage => { + const zone = makeDurableZone(baggage, 'durableRoot'); + const { retryable, watch } = prepareVowTools(zone); + const retry = retryable(zone, 'retry', async () => { + // Never resolves, simulates external call + await new Promise(() => {}); + }); + + const watcher = zone.exo('DurableVowTestWatcher', undefined, { + onFulfilled(value) { + t.fail( + `First incarnation watcher onFulfilled triggered with value ${value}`, + ); + }, + onRejected(reason) { + t.fail( + `First incarnation watcher onRejected triggered with reason ${reason}`, + ); + }, + }); + + return { zone, watch, retry, watcher }; + }, + async ({ zone, watch, retry, watcher }) => { + const result = retry(); + zone.makeOnce('result', () => result); + watch(result, watcher); + await nextCrank(); + }, + ); + + await startLife( + baggage => { + const zone = makeDurableZone(baggage, 'durableRoot'); + const { retryable, when } = prepareVowTools(zone); + + // Reconnect retryable definition + retryable(zone, 'retry', async () => { + // Simulate call that settles + await nextCrank(); + return 42; + }); + + zone.exo('DurableVowTestWatcher', undefined, { + onFulfilled(value) { + t.is(value, 42, 'vow resolved with value 42'); + }, + onRejected(reason) { + t.fail( + `Second incarnation watcher onRejected triggered with reason ${reason}`, + ); + }, + }); + + return { zone, when }; + }, + async ({ zone, when }) => { + const result = zone.makeOnce('result', () => Fail`result should exist`); + + await when(result); + }, + ); +}); + +test.serial('errors on non durably storable arguments', async t => { + annihilate(); + + const baggage = getBaggage(); + const zone = makeDurableZone(baggage, 'durableRoot'); + const { retryable, when } = prepareVowTools(zone); + + const passthrough = retryable(zone, 'passthrough', async arg => arg); + + const nonStorableArg = { + promise: new Promise(() => {}), + }; + + t.false(zone.isStorable(nonStorableArg), 'arg is actually non storable'); + + let resultV; + t.notThrows(() => { + resultV = passthrough(nonStorableArg); + }, 'retryable does not synchronously error'); + + const resultP = when(resultV); + await t.throwsAsync( + resultP, + { message: /^retryable arguments must be storable/ }, + 'expected rejection', + ); +}); diff --git a/packages/vow/test/retryable.test.js b/packages/vow/test/retryable.test.js new file mode 100644 index 00000000000..5c9e98089b2 --- /dev/null +++ b/packages/vow/test/retryable.test.js @@ -0,0 +1,196 @@ +// @ts-check +import test from 'ava'; + +import { Fail } from '@endo/errors'; +import { Far } from '@endo/pass-style'; +import { makeHeapZone } from '@agoric/base-zone/heap.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; + +import { prepareVowKit } from '../src/vow.js'; +import { isVow } from '../src/vow-utils.js'; +import { prepareRetryableTools } from '../src/retryable.js'; +import { makeWhen } from '../src/when.js'; + +/** + * @import {IsRetryableReason} from '../src/types.js' + */ + +/** + * @param {object} [options] + * @param {IsRetryableReason} [options.isRetryableReason] + */ +const makeTestTools = ({ isRetryableReason = () => false } = {}) => { + const zone = makeHeapZone(); + const makeVowKit = prepareVowKit(zone); + const when = makeWhen(isRetryableReason); + + const { retryable, adminRetryableFlow } = prepareRetryableTools(zone, { + makeVowKit, + isRetryableReason, + }); + + return { zone, when, makeVowKit, retryable, adminRetryableFlow }; +}; + +test('successful flow', async t => { + const { zone, when, retryable } = makeTestTools(); + + const succeed = retryable(zone, 'succeed', async () => 42); + + const resultV = succeed(); + const result = await when(resultV); + t.is(result, 42, 'expected result'); +}); + +test('rejected flow', async t => { + const { zone, when, retryable } = makeTestTools(); + + const reject = retryable(zone, 'reject', async () => Fail`some error`); + + const resultV = reject(); + const resultP = when(resultV); + await t.throwsAsync(resultP, { message: 'some error' }, 'expected rejection'); +}); + +test('throwing flow', async t => { + const { zone, when, retryable } = makeTestTools(); + + const error = retryable(zone, 'error', () => Fail`some error`); + + const resultV = error(); + const resultP = when(resultV); + await t.throwsAsync(resultP, { message: 'some error' }, 'expected rejection'); +}); + +test('passable arguments', async t => { + const { zone, when, makeVowKit, retryable } = makeTestTools(); + + const argValue = { + remotable: Far('test'), + promise: Promise.resolve(), + vowKit: makeVowKit(), + }; + + const passthrough = retryable(zone, 'passthrough', async arg => arg); + + const resultV = passthrough(argValue); + const result = await when(resultV); + t.deepEqual(result, argValue, 'expected result'); +}); + +test('non-passable arguments', async t => { + const { zone, when, retryable } = makeTestTools(); + + const passthrough = retryable(zone, 'passthrough', async arg => arg); + + const nonPassableArg = harden({ + foo() { + return 'bar'; + }, + }); + + t.false(zone.isStorable(nonPassableArg), 'arg is actually non passable'); + + let resultV; + t.notThrows(() => { + resultV = passthrough(nonPassableArg); + }, 'retryable does not synchronously error'); + + const resultP = when(resultV); + await t.throwsAsync( + resultP, + { message: /^retryable arguments must be storable/ }, + 'expected rejection', + ); +}); + +test('outcome vow', async t => { + const { zone, when, retryable, adminRetryableFlow } = makeTestTools(); + + const succeed = retryable(zone, 'succeed', async () => 42); + + const resultV = succeed(); + + t.true(isVow(resultV), 'retryable result is vow'); + + const flow = adminRetryableFlow.getFlowForOutcomeVow(resultV); + t.truthy(flow, 'flow from outcome vow'); + + t.is(flow.getOutcome(), resultV, 'outcome vow match'); + + const result = await when(resultV); + t.is(result, 42, 'expected result'); + + t.throws( + () => adminRetryableFlow.getFlowForOutcomeVow(resultV), + undefined, + 'outcome vow not found', + ); +}); + +test('retry', async t => { + const { zone, when, retryable } = makeTestTools({ + isRetryableReason: (reason, priorReason) => + reason !== priorReason && reason.startsWith('retry') && reason, + }); + + const expectedCalls = 3; + + let getResultCalled = 0; + const resultProvider = Far('ResultProvider', { + getResult() { + if (getResultCalled < expectedCalls) { + getResultCalled += 1; + } + // eslint-disable-next-line prefer-promise-reject-errors + return Promise.reject(`retry-${getResultCalled}`); + }, + }); + + const resultFromProvider = retryable( + zone, + 'resultFromProvider', + async provider => provider.getResult(), + ); + + const resultV = resultFromProvider(resultProvider); + + const result = await when(resultV).catch(r => r); + t.is( + result, + `retry-${expectedCalls}`, + 'expected getResult called multiple times', + ); +}); + +test('restart', async t => { + const { zone, when, retryable, adminRetryableFlow } = makeTestTools(); + + let runNum = 0; + const restarted = retryable(zone, 'testRestartedRetryable', async () => { + // Non idempotent function to simplify the test + runNum += 1; + const currentRun = runNum; + await eventLoopIteration(); + if (currentRun < 3) { + // Trigger our own invocation restart + // eslint-disable-next-line no-use-before-define + flow.restart(); + } + if (currentRun === 2) { + throw Error('reject'); + } + return currentRun; + }); + + const resultV = restarted(); + const flow = adminRetryableFlow.getFlowForOutcomeVow(resultV); + t.truthy(flow, 'flow from outcome vow'); + + const result = await when(resultV); + t.is(result, 3, 'flow result from restart'); + + t.throws(() => flow.restart(), { + message: /^Cannot restart a done retryable flow/, + }); +});