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

feat: vowTools.allSettled #10077

Merged
merged 4 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion packages/vow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Here they are: {
```

You can use `heapVowE` exported from `@agoric/vow`, which converts a chain of
promises and vows to a promise for its final fulfilment, by unwrapping any
promises and vows to a promise for its final fulfillment, by unwrapping any
intermediate vows:

```js
Expand Down Expand Up @@ -77,6 +77,67 @@ const { watch, makeVowKit } = prepareVowTools(vowZone);
// Vows and resolvers you create can be saved in durable stores.
```

## VowTools
Copy link
Member

Choose a reason for hiding this comment

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

👏 !


VowTools are a set of utility functions for working with Vows in Agoric smart contracts and vats. These tools help manage asynchronous operations in a way that's resilient to vat upgrades, ensuring your smart contract can handle long-running processes reliably.

### Usage

VowTools are typically prepared in the start function of a smart contract or vat and passed in as a power to exos.


```javascript
import { prepareVowTools } from '@agoric/vow/vat.js';
import { makeDurableZone } from '@agoric/zone/durable.js';

export const start = async (zcf, privateArgs, baggage) => {
const zone = makeDurableZone(baggage);
const vowTools = prepareVowTools(zone.subZone('vows'));

// Use vowTools here...
}
```

### Available Tools

#### `when(vowOrPromise)`
Returns a Promise for the fulfillment of the very end of the `vowOrPromise` chain. It can retry disconnections due to upgrades of other vats, but cannot survive the upgrade of the calling vat.

#### `watch(promiseOrVow, [watcher], [context])`
Watch a Vow and optionally provide a `watcher` with `onFulfilled`/`onRejected` handlers and a `context` value for the handlers. When handlers are not provided the fulfillment or rejection will simply pass through.

It also registers pending Promises, so if the current vat is upgraded, the watcher is rejected because the Promise was lost when the heap was reset.

#### `all(arrayOfPassables, [watcher], [context])`
Vow-tolerant implementation of Promise.all that takes an iterable of vows and other Passables and returns a single Vow. It resolves with an array of values when all of the input's promises or vows are fulfilled and rejects with the first rejection reason when any of the input's promises or vows are rejected.

#### `allSettled(arrayOfPassables, [watcher], [context])`
Vow-tolerant implementation of Promise.allSettled that takes an iterable of vows and other Passables and returns a single Vow. It resolves when all of the input's promises or vows are settled with an array of settled outcome objects.

#### `asVow(fn)`
Takes a function that might return synchronously, throw an Error, or return a Promise or Vow and returns a Vow.

#### `asPromise(vow)`
Converts a Vow back into a Promise.

### Example

```javascript
const { when, watch, all, allSettled } = vowTools;

// Using watch to create a Vow
const myVow = watch(someAsyncOperation());

// Using when to resolve a Vow
const result = await when(myVow);

// Using all
const results = await when(all([vow, vowForVow, promise]));

// Using allSettled
const outcomes = await when(allSettled([vow, vowForVow, promise]));
```

## Internals

The current "version 0" vow internals expose a `shorten()` method, returning a
Expand Down
29 changes: 26 additions & 3 deletions packages/vow/src/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { makeWhen } from './when.js';

/**
* @import {Zone} from '@agoric/base-zone';
* @import {Passable} from '@endo/pass-style';
* @import {IsRetryableReason, AsPromiseFunction, EVow, Vow, ERef} from './types.js';
*/

Expand Down Expand Up @@ -52,11 +53,31 @@ export const prepareBasicVowTools = (zone, powers = {}) => {
};

/**
* Vow-tolerant implementation of Promise.all.
* Vow-tolerant implementation of Promise.all that takes an iterable of vows
* and other {@link Passable}s and returns a single {@link Vow}. It resolves
* with an array of values when all of the input's promises or vows are
* fulfilled and rejects when any of the input's promises or vows are
* rejected with the first rejection reason.
*
* @param {EVow<unknown>[]} maybeVows
* @param {unknown[]} maybeVows
*/
const allVows = maybeVows => watchUtils.all(maybeVows);
const all = maybeVows => watchUtils.all(maybeVows);

/**
* @param {unknown[]} maybeVows
* @deprecated use `vowTools.all`
*/
const allVows = all;

/**
* Vow-tolerant implementation of Promise.allSettled that takes an iterable
* of vows and other {@link Passable}s and returns a single {@link Vow}. It
* resolves when all of the input's promises or vows are settled with an
* array of settled outcome objects.
*
* @param {unknown[]} maybeVows
*/
const allSettled = maybeVows => watchUtils.allSettled(maybeVows);

/** @type {AsPromiseFunction} */
const asPromise = (specimenP, ...watcherArgs) =>
Expand All @@ -66,7 +87,9 @@ export const prepareBasicVowTools = (zone, powers = {}) => {
when,
watch,
makeVowKit,
all,
allVows,
allSettled,
asVow,
asPromise,
retriable,
Expand Down
1 change: 1 addition & 0 deletions packages/vow/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export {};
*/

/**
* Vows are objects that represent promises that can be stored durably.
* @template [T=any]
* @typedef {CopyTagged<'Vow', VowPayload<T>>} Vow
*/
Expand Down
140 changes: 100 additions & 40 deletions packages/vow/src/watch-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const { Fail, bare, details: X } = assert;
* @import {Zone} from '@agoric/base-zone';
* @import {Watch} from './watch.js';
* @import {When} from './when.js';
* @import {VowKit, AsPromiseFunction, IsRetryableReason, EVow} from './types.js';
* @import {VowKit, AsPromiseFunction, IsRetryableReason, Vow} from './types.js';
*/

const VowShape = M.tagged(
Expand Down Expand Up @@ -54,11 +54,16 @@ export const prepareWatchUtils = (
{
utils: M.interface('Utils', {
all: M.call(M.arrayOf(M.any())).returns(VowShape),
allSettled: M.call(M.arrayOf(M.any())).returns(VowShape),
asPromise: M.call(M.raw()).rest(M.raw()).returns(M.promise()),
}),
watcher: M.interface('Watcher', {
onFulfilled: M.call(M.any()).rest(M.any()).returns(M.any()),
onRejected: M.call(M.any()).rest(M.any()).returns(M.any()),
onFulfilled: M.call(M.raw()).rest(M.raw()).returns(M.raw()),
onRejected: M.call(M.raw()).rest(M.raw()).returns(M.raw()),
}),
helper: M.interface('Helper', {
createVow: M.call(M.arrayOf(M.any()), M.boolean()).returns(VowShape),
processResult: M.call(M.raw()).rest(M.raw()).returns(M.undefined()),
}),
retryRejectionPromiseWatcher: PromiseWatcherI,
},
Expand All @@ -68,6 +73,7 @@ export const prepareWatchUtils = (
* @property {number} remaining
* @property {MapStore<number, any>} resultsMap
* @property {VowKit['resolver']} resolver
* @property {boolean} [isAllSettled]
*/
/** @type {MapStore<bigint, VowState>} */
const idToVowState = detached.mapStore('idToVowState');
Expand All @@ -79,32 +85,83 @@ export const prepareWatchUtils = (
},
{
utils: {
/** @param {unknown[]} specimens */
all(specimens) {
return this.facets.helper.createVow(specimens, false);
},
/** @param {unknown[]} specimens */
allSettled(specimens) {
return /** @type {Vow<({status: 'fulfilled', value: any} | {status: 'rejected', reason: any})[]>} */ (
this.facets.helper.createVow(specimens, true)
);
},
/** @type {AsPromiseFunction} */
asPromise(specimenP, ...watcherArgs) {
// Watch the specimen in case it is an ephemeral promise.
const vow = watch(specimenP, ...watcherArgs);
const promise = when(vow);
// Watch the ephemeral result promise to ensure that if its settlement is
// lost due to upgrade of this incarnation, we will at least cause an
// unhandled rejection in the new incarnation.
zone.watchPromise(promise, this.facets.retryRejectionPromiseWatcher);

return promise;
},
},
watcher: {
/**
* @param {EVow<unknown>[]} vows
* @param {unknown} value
* @param {object} ctx
* @param {bigint} ctx.id
* @param {number} ctx.index
* @param {number} ctx.numResults
* @param {boolean} ctx.isAllSettled
*/
all(vows) {
onFulfilled(value, ctx) {
this.facets.helper.processResult(value, ctx, 'fulfilled');
},
/**
* @param {unknown} reason
* @param {object} ctx
* @param {bigint} ctx.id
* @param {number} ctx.index
* @param {number} ctx.numResults
* @param {boolean} ctx.isAllSettled
*/
onRejected(reason, ctx) {
this.facets.helper.processResult(reason, ctx, 'rejected');
},
},
helper: {
/**
* @param {unknown[]} specimens
* @param {boolean} isAllSettled
*/
createVow(specimens, isAllSettled) {
const { nextId: id, idToVowState } = this.state;
/** @type {VowKit<any[]>} */
const kit = makeVowKit();

// Preserve the order of the vow results.
for (let index = 0; index < vows.length; index += 1) {
watch(vows[index], this.facets.watcher, {
// Preserve the order of the results.
for (let index = 0; index < specimens.length; index += 1) {
watch(specimens[index], this.facets.watcher, {
id,
index,
numResults: vows.length,
numResults: specimens.length,
isAllSettled,
});
}

if (vows.length > 0) {
if (specimens.length > 0) {
// Save the state until rejection or all fulfilled.
this.state.nextId += 1n;
idToVowState.init(
id,
harden({
resolver: kit.resolver,
remaining: vows.length,
remaining: specimens.length,
resultsMap: detached.mapStore('resultsMap'),
isAllSettled,
}),
);
const idToNonStorableResults = provideLazyMap(
Expand All @@ -119,27 +176,36 @@ export const prepareWatchUtils = (
}
return kit.vow;
Copy link
Member Author

Choose a reason for hiding this comment

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

Is there a path for typing this, so we can keep the existing Vow<any[]> for allVows and something like Vow<({status: 'fulfilled', value: any} | {status: 'rejected', reason: Error})[]> for allVowsSettled?

h/t @Chris-Hibbert for the suggestion: https://github.com/Agoric/agoric-sdk/pull/9902/files#r1757395986

Copy link
Member

Choose a reason for hiding this comment

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

I've given this a shot above, but it is wholly untested. Hope it helps.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks, your suggestion worked! In hindsight, it was obvious 😅

},
/** @type {AsPromiseFunction} */
asPromise(specimenP, ...watcherArgs) {
// Watch the specimen in case it is an ephemeral promise.
const vow = watch(specimenP, ...watcherArgs);
const promise = when(vow);
// Watch the ephemeral result promise to ensure that if its settlement is
// lost due to upgrade of this incarnation, we will at least cause an
// unhandled rejection in the new incarnation.
zone.watchPromise(promise, this.facets.retryRejectionPromiseWatcher);

return promise;
},
},
watcher: {
onFulfilled(value, { id, index, numResults }) {
/**
* @param {unknown} result
* @param {object} ctx
* @param {bigint} ctx.id
* @param {number} ctx.index
* @param {number} ctx.numResults
* @param {boolean} ctx.isAllSettled
* @param {'fulfilled' | 'rejected'} status
*/
processResult(result, { id, index, numResults, isAllSettled }, status) {
const { idToVowState } = this.state;
if (!idToVowState.has(id)) {
// Resolution of the returned vow happened already.
return;
}
const { remaining, resultsMap, resolver } = idToVowState.get(id);
if (!isAllSettled && status === 'rejected') {
// For 'all', we reject immediately on the first rejection
idToVowState.delete(id);
resolver.reject(result);
return;
}

const possiblyWrappedResult = isAllSettled
? harden({
status,
[status === 'fulfilled' ? 'value' : 'reason']: result,
})
: result;

const idToNonStorableResults = provideLazyMap(
utilsToNonStorableResults,
this.facets.utils,
Expand All @@ -152,15 +218,16 @@ export const prepareWatchUtils = (
);

// Capture the fulfilled value.
if (zone.isStorable(value)) {
resultsMap.init(index, value);
if (zone.isStorable(possiblyWrappedResult)) {
resultsMap.init(index, possiblyWrappedResult);
} else {
nonStorableResults.set(index, value);
nonStorableResults.set(index, possiblyWrappedResult);
}
const vowState = harden({
remaining: remaining - 1,
resultsMap,
resolver,
isAllSettled,
});
if (vowState.remaining > 0) {
idToVowState.set(id, vowState);
Expand All @@ -177,26 +244,19 @@ export const prepareWatchUtils = (
results[i] = resultsMap.get(i);
} else {
numLost += 1;
results[i] = isAllSettled
? { status: 'rejected', reason: 'Unstorable result was lost' }
: undefined;
}
}
if (numLost > 0) {
if (numLost > 0 && !isAllSettled) {
resolver.reject(
assert.error(X`${numLost} unstorable results were lost`),
);
} else {
resolver.resolve(harden(results));
}
},
onRejected(value, { id, index: _index, numResults: _numResults }) {
const { idToVowState } = this.state;
if (!idToVowState.has(id)) {
// First rejection wins.
return;
}
const { resolver } = idToVowState.get(id);
idToVowState.delete(id);
resolver.reject(value);
},
},
retryRejectionPromiseWatcher: {
onFulfilled(_result) {},
Expand Down
15 changes: 15 additions & 0 deletions packages/vow/test/types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,18 @@ expectType<(p1: number, p2: string) => Vow<{ someValue: 'bar' }>>(
Promise.resolve({ someValue: 'bar' } as const),
),
);

expectType<
Vow<
(
| { status: 'fulfilled'; value: any }
| { status: 'rejected'; reason: any }
)[]
>
>(
vt.allSettled([
Promise.resolve(1),
Promise.reject(new Error('test')),
Promise.resolve('hello'),
]),
);
Loading
Loading