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: Add a helper for publishing iteration results to chain storage #5432

Closed
wants to merge 18 commits into from
Closed
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
3 changes: 3 additions & 0 deletions packages/vats/decentral-core-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"board": {
"sourceSpec": "@agoric/vats/src/vat-board.js"
},
"chainStorage": {
"sourceSpec": "./src/vat-chainStorage.js"
},
"distributeFees": {
"sourceSpec": "@agoric/vats/src/vat-distributeFees.js"
},
Expand Down
1 change: 1 addition & 0 deletions packages/vats/src/bridge-ids.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
export const BANK = 'bank';
export const CORE = 'core';
export const DIBC = 'dibc';
export const STORAGE = 'storage';
export const PROVISION = 'provision';
export const WALLET = 'wallet';
30 changes: 30 additions & 0 deletions packages/vats/src/core/chain-behaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,36 @@ export const makeBridgeManager = async ({
};
harden(makeBridgeManager);

/**
* @param {BootstrapPowers & {
* consume: { loadVat: ERef<VatLoader<ChainStorageVat>> }
* }} powers
*/
export const makeChainStorage = async ({
consume: { bridgeManager: bridgeManagerP, loadVat },
produce: { chainStorage: chainStorageP },
}) => {
const bridgeManager = await bridgeManagerP;
if (!bridgeManager) {
console.warn('Cannot support chainStorage without an actual chain.');
chainStorageP.resolve(undefined);
return;
}

// TODO: Formalize root key.
// Must not be any of {activityhash,beansOwing,egress,mailbox},
// and must be reserved in sites that use those keys (both Go and JS).
const ROOT_KEY = 'published';

const vat = E(loadVat)('chainStorage');
const rootNodeP = E(vat).makeBridgedChainStorageRoot(
bridgeManager,
BRIDGE_ID.STORAGE,
ROOT_KEY,
);
chainStorageP.resolve(rootNodeP);
};

/**
* no free lunch on chain
*
Expand Down
9 changes: 9 additions & 0 deletions packages/vats/src/core/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ const SHARED_CHAIN_BOOTSTRAP_MANIFEST = harden({
},
home: { produce: { chainTimerService: 'timer' } },
},
makeChainStorage: {
consume: {
bridgeManager: true,
loadVat: true,
},
produce: {
chainStorage: true,
},
},
makeClientBanks: {
consume: {
bankManager: 'bank',
Expand Down
2 changes: 2 additions & 0 deletions packages/vats/src/core/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@
* bldIssuerKit: RemoteIssuerKit,
* board: Board,
* bridgeManager: OptionalBridgeManager,
* chainStorage: unknown,
* chainTimerService: TimerService,
* client: ClientManager,
* clientCreator: ClientCreator,
Expand Down Expand Up @@ -235,6 +236,7 @@
* @typedef {{ mint: ERef<Mint>, issuer: ERef<Issuer>, brand: Brand }} RemoteIssuerKit
* @typedef {ReturnType<Awaited<BankVat>['makeBankManager']>} BankManager
* @typedef {ERef<ReturnType<import('../vat-bank.js').buildRootObject>>} BankVat
* @typedef {ERef<ReturnType<import('../vat-chainStorage.js').buildRootObject>>} ChainStorageVat
* @typedef {ERef<ReturnType<import('../vat-provisioning.js').buildRootObject>>} ProvisioningVat
* @typedef {ERef<ReturnType<import('../vat-mints.js').buildRootObject>>} MintsVat
* @typedef {ERef<ReturnType<import('../vat-priceAuthority.js').buildRootObject>>} PriceAuthorityVat
Expand Down
57 changes: 57 additions & 0 deletions packages/vats/src/lib-chainPublish.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// @ts-check

import { observeIteration } from '@agoric/notifier';
import { E } from '@endo/far';

/**
* Publish results of an async iterable into a chain storage node as an array
* serialized using JSON.stringify or an optionally provided function (e.g.,
* leveraging a serialize function from makeMarshal).
* Array items are possibly-duplicated [index, value] pairs, where index is a
* string (ascending numeric or terminal "finish" or "fail").
*
* @param {ERef<AsyncIterable>} source
* @param {{ setValue: (val: any) => void }} chainStorageNode
* @param {{ timerService: ERef<TimerService>, serialize?: (obj: any) => string }} powers
*/
export async function publishToChainNode(
Copy link
Member Author

Choose a reason for hiding this comment

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

This was originally envisioned as a function that accepts a chainStorage node and returns a subscription kit, but upon digging in it seemed more useful to me for it to consume from an existing iterable and return nothing. Still another option would be to make it effectively a tee, but I think it makes more sense to have the caller compose e.g. getKey() and the base iterable.

Copy link
Member

Choose a reason for hiding this comment

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

I don't think this is the right abstraction. It crosses layers and combines concerns in a muddy way. What are your trying to improve wrt the sketch in #5400?

Copy link
Member Author

Choose a reason for hiding this comment

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

What layers do you think are being crossed and what concerns are being combined? Transmitting arbitrary data into a chainStorage node seems very respectful of layers and independent concerns. As for #5400, I looked it over carefully but didn't see anything to address the specific requirements of publishing to chain storage... am I just missing something?

Copy link
Member

Choose a reason for hiding this comment

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

What layers do you think are being crossed and what concerns are being combined?

I guess what jumps out at me looking at the helper is it is trying to combine the iterator observation with the block batching. I think that the block batching belongs in the cosmic-swingset layer, and the iteration client is handled by #5400.

Transmitting arbitrary data into a chainStorage node seems very respectful of layers and independent concerns.

You're right that it needs to be done at a lower layer than #5400, but it should be even lower than JS entirely. I'm trying hard to "put the code where the data is". Since it is a cosmic-swingset concern (touching block height, event log, and merkle tree), it belongs outside of vats. I appreciate you're trying to respect peoples' time, but I would really like it if you would reach out if something about the layering doesn't make sense or seems unnecessarily weird (e.g. the chainTimer hack).

As for #5400, I looked it over carefully but didn't see anything to address the specific requirements of publishing to chain storage... am I just missing something?

Here is an adapter that explains how #5400 accomplishes these platform concerns, presuming that the batching is solved at the cosmic-swingset layer:

// adapt chainStorageNode to makeMarshalSubscriber
const publisher = Far('chainPublisherNode', {
  publish: jsonable => E(chainStorageNode).setValue(JSON.stringify(jsonable)),
  getPublisherDetails: () => { chainStorageKey: E(chainStorageNode).getKey() },
});

// Then something like:
makeMarshalSubscriber(observeEach(subscription), publisher);

Is that clearer now? I'll be working on splitting x/pubstore Golang module from x/swingset. I want to implement cosmos-specific publishing concerns there, such as batching, history, and event logging.

With some polish and moving the code around until we have minimal interfaces, I think this could be quite good.

source,
chainStorageNode,
{ timerService, serialize = JSON.stringify },
) {
let nextIndex = 0n;
let isNewBlock = true;
let oldResults = [];
let results = [];
const makeAcceptor = forceIndex => {
return value => {
if (isNewBlock) {
isNewBlock = false;
oldResults = results;
results = [];
E(timerService)
.delay(1n)
.then(() => {
isNewBlock = true;
});
}
// To avoid loss when detecting the new block *after* already consuming
// results produced within it, we associate each result with an index and
// include results of the previous batch.
// Downstream consumers are expected to deduplicate by index.
// We represent the index as a string to maintain compatibility with
// JSON.stringify and to avoid overly inflating the data size (e.g. with
// "@qclass" objects from makeMarshal serialize functions).
const index = forceIndex || String(nextIndex);
nextIndex += 1n;
results.push([index, value]);
const combined = harden(oldResults.slice().concat(results));
E(chainStorageNode).setValue(serialize(combined));
};
};
await observeIteration(source, {
updateState: makeAcceptor(),
finish: makeAcceptor('finish'),
fail: makeAcceptor('fail'),
});
}
70 changes: 70 additions & 0 deletions packages/vats/src/lib-chainStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// @ts-check

import { Far } from '@endo/far';

const { details: X } = assert;

// TODO: Formalize segment constraints.
// Must be nonempty and disallow (unescaped) `.`, and for simplicity
// (and future possibility of e.g. escaping) we currently limit to
// ASCII alphanumeric plus underscore.
const pathSegmentPattern = /^[a-zA-Z0-9_-]{1,100}$/;

/**
* Create a root storage node for a given backing function and root key.
*
* @param {(message: any) => any} toStorage a function for sending a storageMessage object to the storage implementation (cf. golang/cosmos/x/swingset/storage.go)
* @param {string} rootKey
*/
export function makeChainStorageRoot(toStorage, rootKey) {
assert.typeof(rootKey, 'string');

function makeChainStorageNode(key) {
const node = {
getKey() {
Copy link
Member

Choose a reason for hiding this comment

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

Good! The composer in #5400 can pass this method through directly.

return key;
},
getChildNode(name) {
assert.typeof(name, 'string');
assert(
pathSegmentPattern.test(name),
X`Path segment must be a short ASCII identifier: ${name}`,
);
return makeChainStorageNode(`${key}.${name}`);
},
setValue(value) {
assert.typeof(value, 'string');
toStorage({ key, method: 'set', value });
},
async delete() {
assert(key !== rootKey);
// A 'set' with no value deletes a key if it has no children, but
Copy link
Member

Choose a reason for hiding this comment

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

This issue has been resolved in Golang. Please remove the race and make this a direct wrapper.

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think we want to allow deleting a non-leaf node—IIUC, @warner and I agree that silent failure (by means of replacement with "set data to empty") seems like bad behavior that could leave orphans. But at any rate, this is a comment for #5385 rather than here.

Copy link
Member

@michaelfig michaelfig May 28, 2022

Choose a reason for hiding this comment

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

As I mentioned in #5385 (comment) the problem is solved and merged. I think you and @warner may be misunderstanding the intended semantics (implemented by #5394):

  • nodes with empty data are not stored in the tree, and unreferenced path linkage is cleared when emptying a node
  • path linkage of nodes with nonempty data is always present, and when an empty node is filled any missing path linkage is built

The linkage information is just used to enumerate children. To demonstrate the separation between linkage and data, that linkage information is kept in swingset/path Cosmos IAVL key prefix, unlike the swingset/data key prefix.

Please remove this racy workaround. It is unnecessary, and the only reason I'm requesting changes.

// otherwise sets data to the empty string and leaves all nodes intact.
// We want to reject silently incomplete deletes (at least for now).
// This check is unfortunately racy (e.g., a vat could wake up
// and set data for a child before _this_ vat receives an
// already-enqueued response claiming no children), but we can tolerate
// that because transforming a deletion into a set-to-empty is
// effectively indistinguishable from a valid reordering where a fully
// successful 'delete' is followed by a child-key 'set' (for which
// absent parent keys are automatically created with empty-string data).
const childCount = await toStorage({ key, method: 'size' });
if (childCount > 0) {
assert.fail(X`Refusing to delete node with children: ${key}`);
}
toStorage({ key, method: 'set' });
},
// Possible extensions:
// * getValue()
// * getChildNames() and/or getChildNodes()
// * getName()
// * recursive delete
// * batch operations
// * local buffering (with end-of-block commit)
};
return Far('chainStorageNode', node);
}

const rootNode = makeChainStorageNode(rootKey);
return rootNode;
}
15 changes: 15 additions & 0 deletions packages/vats/src/vat-chainStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { E, Far } from '@endo/far';
import { makeChainStorageRoot } from './lib-chainStorage.js';

export function buildRootObject(_vatPowers) {
function makeBridgedChainStorageRoot(bridgeManager, bridgeId, rootKey) {
// XXX: Should we validate uniqueness of rootKey, or is that an external concern?
const toStorage = message => E(bridgeManager).toBridge(bridgeId, message);
const rootNode = makeChainStorageRoot(toStorage, rootKey);
return rootNode;
}

return Far('root', {
makeBridgedChainStorageRoot,
});
}
Loading