Skip to content

Commit

Permalink
test(vow): add retryable tests
Browse files Browse the repository at this point in the history
  • Loading branch information
mhofman committed Oct 1, 2024
1 parent 7b16c23 commit f38e095
Show file tree
Hide file tree
Showing 2 changed files with 306 additions and 0 deletions.
110 changes: 110 additions & 0 deletions packages/vow/test/retryable-restart.test.js
Original file line number Diff line number Diff line change
@@ -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',
);
});
196 changes: 196 additions & 0 deletions packages/vow/test/retryable.test.js
Original file line number Diff line number Diff line change
@@ -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/,
});
});

0 comments on commit f38e095

Please sign in to comment.