From f38e095e4813f485acf71f7cc3b3d0fb7c7978ca Mon Sep 17 00:00:00 2001 From: Mathieu Hofman Date: Tue, 1 Oct 2024 07:34:46 +0000 Subject: [PATCH] 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 000000000000..39ca98ac3a6a --- /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 000000000000..5c9e98089b2a --- /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/, + }); +});