Skip to content

Commit

Permalink
feat(async-flow): endowments (#9566)
Browse files Browse the repository at this point in the history
closes: #XXXX
refs: #9449 #9521 #9304 #9281

## Description

Changed async-flow to support endowments. Changed `orchestrate` to use `asyncFlow` with endowments. Changed `sendAnywhere` example orchestration contract to be more compatible with this new `orchestrate`.

The CI errors are all in the `orchestration` package. After some earlier iteration where orchestration failures indicated async-flow bugs, which I fixed, the remaining errors seem plausibly to be integration bugs on the orchestration side revealed by using this improved `orchestrate` function. If so, that satisfies the purpose of this PR -- to enable integration testing to reveal such errors. However, this leaves open the question of how to bring this PR to fruition despite these CI errors.

In that iteration, the majority of errors were due to host-side promises, which we expected. To proceed with integration testing, I temporarily turned that case into a warning, by wrapping the host-side promise with a host-side vow. This stopgap measure is obviously fragile under upgrade. It would cause may upgrades to fail

However, I have not investigated these CI errors enough to be at all confident that none of them are due to bugs in async-flow. For any of those, they should be fixed in this PR.

### Security Considerations
nothing new
### Scaling Considerations
none, given that total endowments are low cardinality. All these endowments are prepare-time per-function. There should not be any cardinality limit on the activations making use of these endowments. But like all other async-flow scaling issues, that remains to be tested.
### Documentation Considerations
The endowment rules and taxonomy is interesting, and should be documented.
### Testing Considerations
We get CI errors only from the `orchestration` package. Some of these may be the integration bugs we wanted this exercise to reveal. However, others may be async-flow bugs, which should have been caught by async-flow unit tests.

The warning stopgap I mentioned above [appears in CI](https://github.com/Agoric/agoric-sdk/actions/runs/9637015639/job/26575694851?pr=9566#step:12:648) as, for example
```
Warning for now: vow expected, not promise Promise { <pending> } (Error#1)
Error#1: where warning happened
  at makeGuestForHostVow (.../async-flow/src/replay-membrane.js:329:9)
  at eval (.../async-flow/src/convert.js:119:10)
  at innerConvert (.../async-flow/src/convert.js:63:8)
  at convertRecur (.../async-flow/src/convert.js:30:8)
  at convert (.../async-flow/src/convert.js:76:1)
  at performCall (.../async-flow/src/replay-membrane.js:137:1)
  at guestCallsHost (.../async-flow/src/replay-membrane.js:195:9)
  at In "getChain" method of (Orchestrator orchestrator) [as getChain] (.../async-flow/src/replay-membrane.js:282:8)
  at eval (.../orchestration/src/examples/unbondExample.contract.js:60:23)
  at eval (.../async-flow/src/async-flow.js:222:1)
  at Object.restart (.../async-flow/src/async-flow.js:222:30)
  at makeAsyncFlowKit (.../async-flow/src/async-flow.js:430:6)
  at asyncFlow_hostFlow (.../async-flow/src/async-flow.js:448:13)
  at orcFn (.../orchestration/src/facade.js:124:15)
  at eval (.../pass-style/src/make-far.js:224:31)
```

The relevant lines are
```
  at In "getChain" method of (Orchestrator orchestrator) [as getChain] (.../async-flow/src/replay-membrane.js:282:8)
  at eval (.../orchestration/src/examples/unbondExample.contract.js:60:23)
```
where the first line indicates what method or method guard provided the inappropriate promise
```js
  getChain: M.callWhen(M.string()).returns(ChainInfoShape),
```

and the second line indicates where the guest code called it
```js
const omni = await orch.getChain('omniflixhub');
```

### Upgrade Considerations

The orchestration code in question cannot be truly upgrade safe until we see no more of these "vow expected, not promise" warnings. Even then, we should expect that async-flow as of this PR is ready for lots of testing, but not yet ready to run on the main chain with durable state expected to survive real upgrades.
  • Loading branch information
erights authored Jun 25, 2024
1 parent 80db38c commit 4390d8c
Show file tree
Hide file tree
Showing 25 changed files with 891 additions and 229 deletions.
1 change: 1 addition & 0 deletions packages/async-flow/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './src/async-flow.js';
export { makeStateRecord } from './src/endowments.js';
2 changes: 1 addition & 1 deletion packages/async-flow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"license": "Apache-2.0",
"dependencies": {
"@agoric/base-zone": "^0.1.0",
"@agoric/internal": "^0.3.2",
"@agoric/store": "^0.9.2",
"@agoric/vow": "^0.1.0",
"@endo/pass-style": "^1.4.0",
Expand All @@ -36,7 +37,6 @@
"@endo/promise-kit": "^1.1.2"
},
"devDependencies": {
"@agoric/internal": "^0.3.2",
"@agoric/swingset-liveslots": "^0.10.2",
"@agoric/zone": "^0.2.2",
"@endo/env-options": "^1.1.4",
Expand Down
30 changes: 17 additions & 13 deletions packages/async-flow/src/async-flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,14 @@ import { prepareVowTools, toPassableCap, VowShape } from '@agoric/vow';
import { makeReplayMembrane } from './replay-membrane.js';
import { prepareLogStore } from './log-store.js';
import { prepareBijection } from './bijection.js';
import { prepareEndowmentTools } from './endowments.js';
import { LogEntryShape, FlowStateShape } from './type-guards.js';

/**
* @import {WeakMapStore} from '@agoric/store'
* @import {PromiseKit} from '@endo/promise-kit'
* @import {Zone} from '@agoric/base-zone'
* @import {MapStore} from '@agoric/store';
* @import {LogStore} from '../src/log-store.js';
* @import {Bijection} from '../src/bijection.js';
* @import {FlowState, GuestAsyncFunc, HostAsyncFuncWrapper, PreparationOptions} from '../src/types.js';
* @import {ReplayMembrane} from '../src/replay-membrane.js';
* @import {FlowState, GuestAsyncFunc, HostAsyncFuncWrapper, PreparationOptions} from '../src/types.js'
* @import {ReplayMembrane} from '../src/replay-membrane.js'
*/

const { defineProperties } = Object;
Expand Down Expand Up @@ -53,7 +50,11 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
const {
vowTools = prepareVowTools(outerZone),
makeLogStore = prepareLogStore(outerZone),
makeBijection = prepareBijection(outerZone),
endowmentTools: { prepareEndowment, unwrap } = prepareEndowmentTools(
outerZone,
{ vowTools },
),
makeBijection = prepareBijection(outerZone, unwrap),
} = outerOptions;
const { watch, makeVowKit } = vowTools;

Expand Down Expand Up @@ -177,7 +178,7 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
eagerWakers.delete(flow);
}

const wakeWatch = vowish => {
const watchWake = vowish => {
// Extra paranoid because we're getting
// "promise watcher must be a virtual object"
// in the general vicinity.
Expand All @@ -188,13 +189,13 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
watch(vowish, wakeWatcher);
};
const panic = err => admin.panic(err);
const membrane = makeReplayMembrane(
const membrane = makeReplayMembrane({
log,
bijection,
vowTools,
wakeWatch,
watchWake,
panic,
);
});
initMembrane(flow, membrane);
const guestArgs = membrane.hostToGuest(activationArgs);

Expand Down Expand Up @@ -225,7 +226,9 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
// gating condition, the next line could grow the bijection
// of a failed flow, subverting other gating checks on bijection
// membership.
bijection.init(guestResultP, outcomeKit.vow);
const g = bijection.unwrapInit(guestResultP, outcomeKit.vow);
g === guestResultP ||
Fail`internal: promises should not be unwrapped ${g}`;
}
// log is driven at first by guestAyncFunc interaction through the
// membrane with the host activationArgs. At the end of its first
Expand Down Expand Up @@ -422,7 +425,7 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
const asyncFlowKit = internalMakeAsyncFlowKit(activationArgs);
const { flow } = asyncFlowKit;

const vow = toPassableCap(flow.getOutcome());
const vow = flow.getOutcome();
flowForOutcomeVowKey.init(toPassableCap(vow), flow);
flow.restart();
return asyncFlowKit;
Expand Down Expand Up @@ -484,6 +487,7 @@ export const prepareAsyncFlowTools = (outerZone, outerOptions = {}) => {
asyncFlow,
adminAsyncFlow,
allWokenP,
prepareEndowment,
});
};
harden(prepareAsyncFlowTools);
Expand Down
101 changes: 85 additions & 16 deletions packages/async-flow/src/bijection.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
import { b, Fail } from '@endo/errors';
import { M } from '@endo/patterns';
import { Far } from '@endo/pass-style';
import { Far, isPassable } from '@endo/pass-style';
import { toPassableCap } from '@agoric/vow';
import { makeEphemera } from './ephemera.js';

/**
* @import {PromiseKit} from '@endo/promise-kit'
* @import {PassableCap} from '@endo/pass-style'
* @import {Zone} from '@agoric/base-zone'
* @import {Vow} from '@agoric/vow'
* @import {Ephemera} from './types.js';
*/

const BijectionI = M.interface('Bijection', {
reset: M.call().returns(),
init: M.call(M.any(), M.any()).returns(),
hasGuest: M.call(M.any()).returns(M.boolean()),
unwrapInit: M.call(M.raw(), M.any()).returns(M.raw()),
hasGuest: M.call(M.raw()).returns(M.boolean()),
hasHost: M.call(M.any()).returns(M.boolean()),
has: M.call(M.any(), M.any()).returns(M.boolean()),
guestToHost: M.call(M.any()).returns(M.any()),
hostToGuest: M.call(M.any()).returns(M.any()),
has: M.call(M.raw(), M.any()).returns(M.boolean()),
guestToHost: M.call(M.raw()).returns(M.any()),
hostToGuest: M.call(M.any()).returns(M.raw()),
});

/**
* @param {unknown} k
*/
const toKey = k =>
// @ts-expect-error k specificity
isPassable(k) ? toPassableCap(k) : k;

/**
* Makes a store like a WeakMapStore except that Promises and Vows can also be
* used as keys.
Expand All @@ -40,15 +48,15 @@ const makeVowishStore = name => {

return Far(name, {
init: (k, v) => {
const k2 = toPassableCap(k);
const k2 = toKey(k);
!map.has(k2) ||
// separate line so I can set a breakpoint
Fail`${b(name)} key already bound: ${k} -> ${map.get(k2)} vs ${v}`;
map.set(k2, v);
},
has: k => map.has(toPassableCap(k)),
has: k => map.has(toKey(k)),
get: k => {
const k2 = toPassableCap(k);
const k2 = toKey(k);
map.has(k2) ||
// separate line so I can set a breakpoint
Fail`${b(name)} key not found: ${k}`;
Expand All @@ -60,35 +68,91 @@ const makeVowishStore = name => {
/** @typedef {ReturnType<makeVowishStore>} VowishStore */

/**
* As suggested by the name, this *mostly* represents a mathematical bijection,
* i.e., a one-to-one mapping. But rather than a general bijection map store
* (which would be interesting), this one is specialized to support the
* async-flow replay-membrane, where the two sides are "guest" and "host".
*
* If `unwrap` is omitted, it defaults to an identity function on its
* `guestWrapper` argument, in which case this does represent exactly a
* mathematical bijection between host and guest.
*
* If `unwrap` is provided, it supports the unwrapping of guest wrappers, into
* so-call unwrapped guests, like state records or functions,
* that are not themselves `Passable`. This was motivated to support endowments,
* which are often similar non-passables on the host-side.
* However, it can support the unwrapping of any guest remotable wrapper.
* When `unwrap` returns something `!==` its `guestWrapper` argument,
* then we preserve the bijection (one-to-one mapping) between the host
* and the unwrapped guest. To support the internal bookkeeping of the
* replay-membrane, we also map the guestWrapper to that same host, but
* not vice versa. Since the guest wrapper should not be visible outside
* the replay-membrane, this extra bookkeeping should be invisible.
*
* This bijection only grows monotonically until reset, which clears the entire
* mapping. Until reset, each pair, once entered, cannot be altered or deleted.
* The mapping itself is completely ephemeral, but the bijection object itself
* is durable. The mapping is also effectively reset by reincarnation, i.e,
* on upgrade.
* See also https://github.com/Agoric/agoric-sdk/issues/9365
*
* To eventually address https://github.com/Agoric/agoric-sdk/issues/9301
* the bijection itself persists to support passing guest-created remotables
* and promises through the membrane.
* The resulting host wrappers must not only survive upgrade, then must
* reestablish their mapping to the correct corresponding guest objects that
* they are taken to wrap. We plan to do this via `equate` repopulating
* the bijection by the time the host wrapper needs to know what
* corresponding guest it is now taken to wrap.
*
* @param {Zone} zone
* @param {(hostWrapper: PassableCap | Vow, guestWrapper: PassableCap) => unknown} [unwrap]
* defaults to identity function on `guestWrapper` arg
*/
export const prepareBijection = zone => {
export const prepareBijection = (
zone,
unwrap = (_hostWrapper, guestWrapper) => guestWrapper,
) => {
/** @type {Ephemera<Bijection, VowishStore>} */
const g2h = makeEphemera(() => makeVowishStore('guestToHost'));
/** @type {Ephemera<Bijection, VowishStore>} */
const h2g = makeEphemera(() => makeVowishStore('hostToGuest'));

// Guest arguments and results are now unguarded, i.e., guarded by `M.raw()`,
// so that they can be non-passables. Therefore, we need to harden these
// here.
return zone.exoClass('Bijection', BijectionI, () => ({}), {
reset() {
const { self } = this;

g2h.resetFor(self);
h2g.resetFor(self);
},
init(g, h) {
unwrapInit(g, h) {
harden(g);
const { self } = this;
const guestToHost = g2h.for(self);
const hostToGuest = h2g.for(self);

const gUnwrapped = unwrap(h, g);
!hostToGuest.has(h) ||
Fail`hostToGuest key already bound: ${h} -> ${hostToGuest.get(h)} vs ${g}`;
guestToHost.init(g, h);
hostToGuest.init(h, g);
self.has(g, h) ||
Fail`hostToGuest key already bound: ${h} -> ${hostToGuest.get(h)} vs ${gUnwrapped}`;
guestToHost.init(gUnwrapped, h);
hostToGuest.init(h, gUnwrapped);
self.has(gUnwrapped, h) ||
// separate line so I can set a breakpoint
Fail`internal: ${g} <-> ${h}`;
if (g !== gUnwrapped) {
// When they are different, also map g to h without mapping h to g
!guestToHost.has(g) ||
// separate line so I can set a breakpoint
Fail`hidden guest wrapper already bound ${g}`;
guestToHost.init(g, h);
}
return gUnwrapped;
},
hasGuest(g) {
harden(g);
const { self } = this;
const guestToHost = g2h.for(self);

Expand All @@ -101,6 +165,7 @@ export const prepareBijection = zone => {
return hostToGuest.has(h);
},
has(g, h) {
harden(g);
const { self } = this;
const guestToHost = g2h.for(self);
const hostToGuest = h2g.for(self);
Expand All @@ -118,6 +183,7 @@ export const prepareBijection = zone => {
}
},
guestToHost(g) {
harden(g);
const { self } = this;
const guestToHost = g2h.for(self);

Expand All @@ -127,6 +193,9 @@ export const prepareBijection = zone => {
const { self } = this;
const hostToGuest = h2g.for(self);

// Even though result is unguarded, i.e., guarded by `M.raw()`, don't
// need to harden here because was already harden when added to
// collection.
return hostToGuest.get(h);
},
});
Expand Down
6 changes: 2 additions & 4 deletions packages/async-flow/src/convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,14 @@ export const makeConvertKit = (
return bijection.hostToGuest(hRem);
}
const gRem = makeGuestForHostRemotable(hRem);
bijection.init(gRem, hRem);
return gRem;
return bijection.unwrapInit(gRem, hRem);
},
hVow => {
if (bijection.hasHost(hVow)) {
return bijection.hostToGuest(hVow);
}
const gP = makeGuestForHostVow(hVow);
bijection.init(gP, hVow);
return gP;
return bijection.unwrapInit(gP, hVow);
},
hErr => {
const gErr = harden(
Expand Down
Loading

0 comments on commit 4390d8c

Please sign in to comment.