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(swingset): allow slow deletion of terminated vats #9227

Merged
merged 1 commit into from
Aug 13, 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
142 changes: 135 additions & 7 deletions packages/SwingSet/docs/run-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,14 @@ The kernel will invoke the following methods on the policy object (so all must e
* `policy.crankFailed()`
* `policy.emptyCrank()`

All methods should return `true` if the kernel should keep running, or `false` if it should stop.
All those methods should return `true` if the kernel should keep running, or `false` if it should stop.

The `computrons` argument may be `undefined` (e.g. if the crank was delivered to a non-`xs worker`-based vat, such as the comms vat). The policy should probably treat this as equivalent to some "typical" number of computrons.
The following methods are optional (for backwards compatibility with policy objects created for older kernels):

* `policy.allowCleanup()` : may return budget, see "Terminated-Vat Cleanup" below
* `policy.didCleanup({ cleanups })` (if missing, kernel pretends it returned `true` to keep running)

The `computrons` value may be `undefined` (e.g. if the crank was delivered to a non-`xs worker`-based vat, such as the comms vat). The policy should probably treat this as equivalent to some "typical" number of computrons.

`crankFailed` indicates that the vat suffered an error during crank delivery, such as a metering fault, memory allocation fault, or fatal syscall. We do not currently have a way to measure the computron usage of failed cranks (many of the error cases are signaled by the worker process exiting with a distinctive status code, which does not give it an opportunity to report back detailed metering data). The run policy should assume the worst.

Expand All @@ -57,6 +62,27 @@ More arguments may be added in the future, such as:

The run policy should be provided as the first argument to `controller.run()`. If omitted, the kernel defaults to `forever`, a policy that runs until the queue is empty.

## Terminated-Vat Cleanup

Some vats may grow very large (i.e. large c-lists with lots of imported/exported objects, or lots of vatstore entries). If/when these are terminated, the burst of cleanup work might overwhelm the kernel, especially when processing all the dropped imports (which trigger GC messages to other vats).

To protect the system against these bursts, the run policy can be configured to terminate vats slowly. Instead of doing all the cleanup work immediately, the policy allows the kernel to do a little bit of work each time `controller.run()` is called (e.g. once per block, for kernels hosted inside a blockchain).

There are two RunPolicy methods which control this. The first is `runPolicy.allowCleanup()`. This will be invoked many times during `controller.run()`, each time the kernel tries to decide what to do next (once per step). The return value will enable (or not) a fixed amount of cleanup work. The second is `runPolicy.didCleanup({ cleanups })`, which is called later, to inform the policy of how much cleanup work was actually done. The policy can count the cleanups and switch `allowCleanup()` to return `false` when it reaches a threshold. (We need the pre-check `allowCleanup` method because the simple act of looking for cleanup work is itself a cost that we might not be willing to pay).

If `allowCleanup()` exists, it must either return a falsy value or a `{ budget?: number }` object.

A falsy return value (eg `allowCleanup: () => false`) prohibits cleanup work. This can be useful in a "only clean up during idle blocks" approach (see below), but should not be the only policy used, otherwise vat cleanup would never happen.

A numeric `budget` limits how many cleanups are allowed to happen (if any are needed). One "cleanup" will delete one vatstore row, or one c-list entry (note that c-list deletion may trigger GC work), or one heap snapshot record, or one transcript span (including its populated transcript items). Using `{ budget: 5 }` seems to be a reasonable limit on each call, balancing overhead against doing sufficiently small units of work that we can limit the total work performed.

If `budget` is missing or `undefined`, the kernel will perform unlimited cleanup work. This also happens if `allowCleanup()` is missing entirely, which maintains the old behavior for host applications that haven't been updated to make new policy objects. Note that cleanup is higher priority than any delivery, and is second only to acceptance queue routing.

`didCleanup({ cleanups })` is called when the kernel actually performed some vat-termination cleanup, and the `cleanups` property is a number with the count of cleanups that took place. Each query to `allowCleanup()` might (or might not) be followed by a call to `didCleanup`, with a `cleanups` value that does not exceed the specified budget. Like other policy methods, `didCleanup` should return `true` if the kernel should keep running or `false` if it should stop.

To limit the work done per block (for blockchain-based applications) the host's RunPolicy objects must keep track of how many cleanups were reported, and change the behavior of `allowCleanup()` when it reaches a per-block threshold. See below for examples.


## Typical Run Policies

A basic policy might simply limit the block to 100 cranks with deliveries and two vat creations:
Expand All @@ -82,6 +108,7 @@ function make100CrankPolicy() {
return true;
},
});
return policy;
warner marked this conversation as resolved.
Show resolved Hide resolved
}
```

Expand All @@ -99,15 +126,15 @@ while(1) {

Note that a new instance of this kind of policy object should be provided in each call to `controller.run()`.

A more sophisticated one would count computrons. Suppose that experiments suggest that one million computrons take about 5 seconds to execute. The policy would look like:
A more sophisticated policy would count computrons, for example based on experimental observations that a 5-second budget is filled by about sixty-five million computrons. The policy would look like:


```js
function makeComputronCounterPolicy(limit) {
let total = 0;
let total = 0n;
const policy = harden({
vatCreated() {
total += 100000; // pretend vat creation takes 100k computrons
total += 100_000n; // pretend vat creation takes 100k computrons
return (total < limit);
},
crankComplete(details) {
Expand All @@ -116,17 +143,118 @@ function makeComputronCounterPolicy(limit) {
return (total < limit);
},
crankFailed() {
total += 1000000; // who knows, 1M is as good as anything
total += 1_000_000n; // who knows, 1M is as good as anything
return (total < limit);
},
emptyCrank() {
return true;
}
});
return policy;
}
```

See `src/runPolicies.js` for examples.
See [runPolicies.js](../src/lib/runPolicies.js) for examples.

To slowly terminate vats, limiting each block to 5 cleanups, the policy should start with a budget of 5, return the remaining `{ budget }` from `allowCleanup()`, and decrement it as `didCleanup` reports that budget being consumed:

```js
function makeSlowTerminationPolicy() {
let cranks = 0;
let vats = 0;
let cleanups = 5;
const policy = harden({
vatCreated() {
vats += 1;
return (vats < 2);
},
crankComplete(details) {
cranks += 1;
return (cranks < 100);
},
crankFailed() {
cranks += 1;
return (cranks < 100);
},
emptyCrank() {
return true;
},
allowCleanup() {
if (cleanups > 0) {
return { budget: cleanups };
} else {
return false;
}
},
didCleanup(spent) {
cleanups -= spent.cleanups;
},
});
return policy;
}
```

A more conservative approach might only allow cleanup in otherwise-empty blocks. To accompish this, use two separate policy objects, and two separate "runs". The first run only performs deliveries, and prohibits all cleanups:

```js
function makeDeliveryOnlyPolicy() {
let empty = true;
const didWork = () => { empty = false; return true; };
const policy = harden({
vatCreated: didWork,
crankComplete: didWork,
crankFailed: didWork,
emptyCrank: didWork,
allowCleanup: () => false,
});
const wasEmpty = () => empty;
return [ policy, wasEmpty ];
}
```

The second only performs cleanup, with a limited budget, stopping the run after any deliveries occur (such as GC actions):

```js
function makeCleanupOnlyPolicy() {
let cleanups = 5;
const stop: () => false;
const policy = harden({
vatCreated: stop,
crankComplete: stop,
crankFailed: stop,
emptyCrank: stop,
allowCleanup() {
if (cleanups > 0) {
return { budget: cleanups };
} else {
return false;
}
},
didCleanup(spent) {
cleanups -= spent.cleanups;
},
});
return policy;
}
```

On each block, the host should only perform the second (cleanup) run if the first policy reports that the block was empty:

```js
async function doBlock() {
const [ firstPolicy, wasEmpty ] = makeDeliveryOnlyPolicy();
await controller.run(firstPolicy);
if (wasEmpty()) {
const secondPolicy = makeCleanupOnlyPolicy();
await controller.run(secondPolicy);
}
}
```

Note that regardless of whatever computron/delivery budget is imposed by the first policy, the second policy will allow one additional delivery to be made (we do not yet have an `allowDelivery()` pre-check method that might inhibit this). The cleanup work, which may or may not happen, will sometimes trigger a GC delivery like `dispatch.dropExports`, but at most one such delivery will be made before the second policy returns `false` and stops `controller.run()`. If cleanup does not trigger such a delivery, or if no cleanup work needs to be done, then one normal run-queue delivery will be performed before the policy has a chance to say "stop". All other cleanup-triggered GC work will be deferred until the first run of the next block.

Also note that `budget` and `cleanups` are plain `Number`s, whereas `comptrons` is a `BigInt`.


## Non-Consensus Wallclock Limits

Expand Down
8 changes: 8 additions & 0 deletions packages/SwingSet/src/controller/upgradeSwingset.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ export const upgradeSwingset = kernelStorage => {
version = 1;
}

if (version < 2) {
// schema v2: add vats.terminated = []
assert(!kvStore.has('vats.terminated'));
kvStore.set('vats.terminated', JSON.stringify([]));
modified = true;
version = 2;
}

if (modified) {
kvStore.set('version', `${version}`);
}
Expand Down
Loading
Loading