Skip to content

Commit 1cacf23

Browse files
committed
feat(swingset): add "run policy" object to controller.run()
closes #3460
1 parent 9506ddb commit 1cacf23

File tree

6 files changed

+380
-20
lines changed

6 files changed

+380
-20
lines changed

packages/SwingSet/docs/run-policy.md

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# The Run Policy
2+
3+
The SwingSet kernel maintains a queue of pending operations to execute: mostly deliveries to vats and promise resolution notifications. This queue may be sizable, and each operation may provoke more work to be added to the list.
4+
5+
SwingSet mananges its own prioritization and scheduling for this workload, but it does not get to decide how much work should be done at a time. The host applications breaks this work up into "blocks". We colloquially describe this as the "block size": in the SwingSet context this refers to the number of deliveries ("cranks") performed before declaring the block to be finished, rather than measuring the number of bytes in some host application consensus data structure.
6+
7+
The host is responsible for committing the SwingSet state to durable storage (i.e. `swingstore.commit()`) at the end of a block. Outbound messages are embargoed until after this commit point, to prevent "hangover inconsistency" from revealing state that might still get unwound. Large block sizes are generally more efficient, however 1: they increase latency, because outbound reply messages cannot be delivered until the end of the block, and 2: they increase the likelihood of losing progress because of a crash or other interrupt during the block. Depending upon the application, it may be appropriate to limit blocks to 5-10 seconds of computation.
8+
9+
To impose this limit, the host application has two choices. The first is to call `controller.step()` many times, until the limit is reached. Each invocation performs one delivery to one vat, and returns `true` if there is more work left on the run-queue, or `false` if the queue is empty. Applications could use this method if they want to impose a wallclock limit on the block (keep calling `step()` until 5 seconds have passed or it returns `false`).
10+
11+
But the more sophisticated approach is to call `controller.run(policy)`, with a "Run Policy" object that knows when the block should end. The policy object will get detailed information about each delivery, including the metering results (how many low-level JS engine operations were performed), and can use this to guide the block size.
12+
13+
## Cranks
14+
15+
The kernel maintains two queues. The highest priority queue contains "GC Actions", which are message to vats that indicate an object has been garbage collected and can now be freed. These are provoked by reference-counting operations that occur as a side-effect of GC syscalls, as well as vat termination events. Each GC Action counts as a "crank", and is reported to the policy object. The policy may end a block while there is still GC Action work to do, in which case the kernel will pick it back up again when the next block begins.
16+
17+
If the GC Action queue is entirely empty, the kernel will look for regular work to do. This consists of the following event types:
18+
19+
* message deliveries to vats (provoked by `syscall.send`, delivered as `dispatch.deliver`)
20+
* promise resolution notifications (provoked by `syscall.resolve`, delivered as `dispatch.notify`)
21+
* vat creation
22+
23+
Each message delivery and resolution notification causes (at most) one vat to execute one "crank" (it might not execute any crank, e.g. when a message is delivered to an unresolved promise, it just gets added to the promise's queue). This crank gives the vat some amount of time to process the delivery, during which it may invoke any number of syscalls. The crank might cause the vat to be terminated, either because of an error, or because it took too much CPU and exceeded its Meter's allowance. Each crank yields a "delivery results object", which indicates the success
24+
25+
When run in a suitable vat worker (`managerType: 'xs-worker'`), the delivery results include the number of "computrons" consumed, as counted by the JS engine. Computrons are vaguely correlated to CPU cycles (despite being much larger) and thus some correspondence to wallclock time. A Run Policy which wishes to limit wallclock time in a consensus-base manner should pay attention to the cumulative computron count, and end the block after some experimentally-determined limit.
26+
27+
Vat creation also gives a single vat (the brand new one) time to run the top-level forms of its source bundle, as well as the invocation of its `buildRootObject()` method. This typically takes considerably longer than the subsequent messages, and is not currently metered.
28+
29+
## Run Policy
30+
31+
The kernel will invoke the following methods on the policy object (so all must exist, even if they're empty):
32+
33+
* `policy.vatCreated()`
34+
* `policy.crankComplete(computrons)`
35+
* `policy.crankFailed()`
36+
37+
All methods should return `true` if the kernel should keep running, or `false` if it should stop.
38+
39+
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.
40+
41+
`crankFailed` indicates 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.
42+
43+
More arguments may be added in the future, such as:
44+
* `vatCreated:` the size of the source bundle
45+
* `crankComplete`: the number of syscalls that were made
46+
* `crankComplete`: the aggregate size of the delivery/notification arguments
47+
* (the first message delivered to each ZCF contract vat contains a very large contract source bundle, and takes considerable time to execute, and this would let the policy treat these cranks accordingly)
48+
* `crankFailed`: the number of computrons consumed before the failure
49+
* `crankFailed`: the nature of the failure (we might be able to distinguish between 1: per-crank metering limit exceeded, 2: allocation limit exceed, 3: fatal syscall, 4: Meter exhausted)
50+
51+
The run policy should be provided as the first argument to `controller.run()`.
52+
53+
## Typical Run Policies
54+
55+
A basic policy might simply limit the block to 100 cranks and two vat creations:
56+
57+
```js
58+
function make100CrankPolicy() {
59+
let cranks = 0;
60+
let vats = 0;
61+
const policy = harden({
62+
vatCreated() {
63+
vats += 1;
64+
return (vats < 2);
65+
},
66+
crankComplete(computrons) {
67+
cranks += 1;
68+
return (cranks < 100);
69+
},
70+
crankFailed() {
71+
cranks += 1;
72+
return (cranks < 100);
73+
},
74+
});
75+
}
76+
```
77+
78+
and would be supplied like:
79+
80+
```js
81+
while(1) {
82+
processInboundIO();
83+
const policy = make100CrankPolicy();
84+
await controller.run(policy);
85+
commit();
86+
processOutboundIO();
87+
}
88+
```
89+
90+
Note that a new policy object should be provided for each call to `run()`.
91+
92+
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:
93+
94+
95+
```js
96+
function makeComputronCounterPolicy(limit) {
97+
let total = 0;
98+
const policy = harden({
99+
vatCreated() {
100+
total += 100000; // pretend vat creation takes 100k computrons
101+
return (total < limit);
102+
},
103+
crankComplete(computrons) {
104+
total += computrons;
105+
return (total < limit);
106+
},
107+
crankFailed() {
108+
total += 1000000; // who knows, 1M is as good as anything
109+
return (total < limit);
110+
},
111+
});
112+
}
113+
```
114+
115+
## Non-Consensus Wallclock Limits
116+
117+
If the SwingSet kernel is not being operated in consensus mode, then it is safe to use wallclock time as a block limit:
118+
119+
```js
120+
function makeWallclockPolicy(seconds) {
121+
let timeout = Date.now() + 1000*seconds;
122+
const policy = harden({
123+
vatCreated: () => Date.now() < timeout,
124+
crankComplete: () => Date.now() < timeout,
125+
crankFailed: () => Date.now() < timeout,
126+
});
127+
}
128+
```
129+
130+
The kernel does not know (ahead of time) how long the last crank will take, so this will ensure the N-1 initial cranks take less than `seconds` time.
131+
132+
The kernel knows nothing of time, because the kernel is specifically deterministic. If you want to use wallclock time (which is inherently non-deterministic across the separate nodes of a consensus machine), the run policy is responsible for bringing its own source of nondeterminism.

packages/SwingSet/src/controller.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,8 @@ export async function makeSwingsetController(
306306
kernel.kdebugEnable(flag);
307307
},
308308

309-
async run() {
310-
return kernel.run();
309+
async run(policy) {
310+
return kernel.run(policy);
311311
},
312312

313313
async step() {

0 commit comments

Comments
 (0)