@coda-console/kernel 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/design.md CHANGED
@@ -345,7 +345,21 @@ kernel.pending({ address }); // { calls, runs, timers, letters }
345
345
 
346
346
  测试是确定性的模拟:几个实例跑在一个进程里,送信的口、时钟、`schedule` 都由测试控制;时钟只由测试推进,`tick()` 由测试随时调用。测试辅助代码放 `sim/`。
347
347
 
348
- 模拟分两层。`sim/world.mjs` 是世界:几个实例、受控的网络与时钟、提交计数、崩溃与提交失败的注入,以及下面的检查。它不认识任何宿主,经 `@coda-console/kernel/sim` 导出,宿主可以把自己真的处理函数接进来撞故障:每一代启动时,世界在迁移的那个事务里调用宿主的 `setup`;`setup` 在事务里,只能在事务外调用的函数放进 `node.recovered(fn)`:世界在这一代的 `recover()` 成功之后立即在事务外调用它,每一代一次,宿主在这里做第 10 节说的重扫。宿主经世界给的连接读写自己的表,这一代死了之后这条连接上的读写一律抛错;经 `node.schedule` 排的事也不再执行,排的事里撞上崩溃或提交失败,由世界接住。世界在宿主登记处理函数时包一层,在处理函数自己的事务里往 `sim_` 开头的表记一行,再照登记的那个对象的方法调用宿主的函数;按处理函数计的检查(S2、S3、S8、S9,取消函数与状态处理函数的次数)读这些表,其余的读内核的表。记账不取决于宿主给了哪些函数:一个地址在这一代登记过任何东西(`handle` 或 `results`),它听到的状态、结局、晚到的结局世界都记一行,宿主给没给对应的函数都一样;取消函数、回答函数会改变内核的回执,给了才包。宿主可以加自己的每步检查和静止点检查。`sim/host.mjs` 是内核自己测试用的宿主。
348
+ 模拟分两层。`sim/world.mjs` 是世界:几个实例、受控的网络与时钟、提交计数、崩溃与提交失败的注入,以及下面的检查。它不认识任何宿主,经 `@coda-console/kernel/sim` 导出,宿主可以把自己真的处理函数接进来撞故障:每一代启动时,世界在迁移的那个事务里调用宿主的 `setup`;`setup` 在事务里,只能在事务外调用的函数放进 `node.recovered(fn)`:世界在这一代的 `recover()` 成功之后立即在事务外调用它,每一代一次,宿主在这里做第 10 节说的重扫。宿主经世界给的连接读写自己的表,这一代死了之后这条连接上的读写一律抛错;经 `node.schedule` 排的事也不再执行,排的事里撞上崩溃或提交失败,由世界接住。世界在宿主登记处理函数时包一层,在处理函数自己的事务里往 `sim_` 开头的表记一行,再照登记的那个对象的方法调用宿主的函数;按处理函数计的检查(S2、S3、S8、S9,取消函数与状态处理函数的次数)读这些表,其余的读内核的表。记账不取决于宿主给了哪些函数:一个地址在这一代登记过任何东西(`handle` 或 `results`),它听到的状态、结局、晚到的结局世界都记一行,宿主给没给对应的函数都一样;取消函数、回答函数会改变内核的回执,给了才包。宿主可以加自己的每步检查和静止点检查。
349
+
350
+ 宿主在内核外面有自己一层(收信时核对收发双方、发信时选路、恢复时起自己的重送)时,在 `setup` 里用 `const post = node.ports({ receive, send, recover })` 把这一层接在世界和内核之间。每一代一次,随新的一代重置;哪一样不给,那一样就照世界自己的走法:
351
+
352
+ - `receive(envelope, from)`:世界把一封信交给这台时调它,代替直接调 `kernel.receive`,返回回执。`from` 是把这封信放进网络的那一台,由世界作保(P3),不取自信封;测试直接调 `world.receive(to, envelope)` 时取信封的 `from` 所属的那台。这台没起来时不调。
353
+ - `send(to, envelope)`:这一代的内核交出一封信时调它,返回回执的 Promise。S6 仍在内核交出的那一刻检查。宿主要把信放进网络,调 `node.ports()` 返回的 `post(to, envelope)`;不给 `send` 时内核交出的信就直接 `post`。`post` 绑在返回它的那一代上:这一代死了之后再调,哪怕新的一代已经起来,也抛崩溃,不入网,不占新一代的额度。
354
+ - `recover()`:世界恢复这一代时调它,代替直接调 `kernel.recover()`。它正常返回,这台才算恢复好,再调 `node.recovered` 登记的函数;它撞上崩溃或提交失败,同 `recover()` 撞上。
355
+
356
+ 世界对接进来的这一层查三条,违反了记进 `world.violations`:**P-in** 世界的一次转交里,`kernel.receive` 至多被调一次。按来源认,不看信的文本,也不看在宿主的第几层回调里:转交期间,宿主手里的内核(`setup` 拿到的 `kernel`、`node.kernel`)的 `receive` 每被调一次算一次,世界自己走默认路径的那一次也算;宿主把信重建一遍(换字段顺序、增删字段)再交,处理函数提交之后把同一封信再交,都算。内核自己内部的交付不经宿主手里的这个句柄,一律不算,比如 `answer()` 回答本机地址时内核经自己的 `receive` 交付;**P-out** 按信的文本、在一代之内累计:同一封信 `post` 的次数不多于这一代内核到此为止交出它的次数。内核重送同一封信是又交出一次;宿主没 `post` 的那次交出,额度一直留在这一代,之后可以多 `post` 一次。没交出过的信一 `post` 就报;**P-rec** `recover` 正常返回时,这一代的 `kernel.recover()` 恰好被调了一次。宿主写自己「恰好一次」的检查,可以用 `node.taken`(世界转交期间宿主手里的内核的 `receive` 被调的次数,按来源认,同 P-in)和 `node.posted`(这台经 `post` 放进网络的封数),两者都跨代累计。
357
+
358
+ 收尾时世界默认每一轮 `pump` 每台,这会替一个从不重送的宿主补上。宿主自己负责重送时,用 `world.quiesce(advance, { resend })`:`resend()` 每一轮调一次,让各台把留着的信再送一遍(比如把宿主的钟推过一个重送周期),世界不再 `pump`;推进时钟、`tick()` 仍由世界做。静不下来时抛出的错写明每台发件箱里还有几封信、还有几条调用没结束。
359
+
360
+ 世界的连接活过每一代,而真的进程一死,连接上的临时表、视图、触发器就没了。所以每一代启动、建内核之前,世界把这条连接上宿主建的临时对象全部删掉,第一代也删,宿主在 `open()` 里建的临时对象留不下来;SQLite 自己管的(名字以 `sqlite_` 开头,如 `AUTOINCREMENT` 带出的 `sqlite_sequence`)删不掉,不删;虚拟表的影子表(`PRAGMA temp.table_list` 里 `type` 为 `shadow` 的)随虚拟表一起删,不单独删。注册的 SQL 函数删不掉,宿主每一代重新注册。
361
+
362
+ `sim/host.mjs` 是内核自己测试用的宿主,走世界自己的路;`test/ports.test.mjs` 是一个经 `node.ports` 接进来、只靠自己的钟重送的宿主。
349
363
 
350
364
  故障模型:
351
365
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coda-console/kernel",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Coda 的调用内核:嵌在宿主进程里的库,只管幂等调用与责任转移",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/sim/world.mjs CHANGED
@@ -7,7 +7,8 @@ import { createKernel } from '../kernel.mjs';
7
7
  //
8
8
  // The world knows no host. Each instance runs whatever handlers its `setup` registers; the world wraps them to record,
9
9
  // in its own `sim_` tables and in the handler's own transaction, what committed. Its checks read those tables and the
10
- // kernel's, so they hold for any host; a host adds its own with onStep() and onQuiet().
10
+ // kernel's, so they hold for any host; a host adds its own with onStep() and onQuiet(). A host with a layer of its own
11
+ // around the kernel puts it between the world and the kernel with node.ports(), and the world checks that layer too.
11
12
 
12
13
  /** The instance died right after a commit: the commit stands, everything in memory is gone. */
13
14
  export class Crash extends Error {
@@ -94,14 +95,53 @@ function connection(node, generation, record) {
94
95
  });
95
96
  }
96
97
 
98
+ /**
99
+ * A generation's host puts a letter its kernel handed over into the network, for `to`, an address of some instance of
100
+ * the world. P-out counts by the letter's text, over the whole generation: a letter may be posted no more often than
101
+ * this generation's kernel has handed it over so far. A handing over the host did not post stays to its credit, so a
102
+ * letter handed over twice and posted once may be posted again later. Once that generation has died it throws Crash,
103
+ * whatever generation has come since.
104
+ * @param {SimNode} node
105
+ * @param {object} generation
106
+ * @param {string} to
107
+ * @param {object} envelope
108
+ * @returns {Promise<object>} the receipt; rejected when the letter or its receipt is lost
109
+ */
110
+ function post(node, generation, to, envelope) {
111
+ if (generation.dead) throw new Crash(`${node.name} is down`);
112
+ const { handed } = generation;
113
+ const text = JSON.stringify(envelope);
114
+ const owed = handed.get(text) ?? 0;
115
+ if (owed === 0) node.world.violations.push(`P-out: ${node.name} posted a letter more often than its kernel handed it over: ${text}`);
116
+ else if (owed === 1) handed.delete(text);
117
+ else handed.set(text, owed - 1);
118
+ node.posted += 1;
119
+ return new Promise((resolve, reject) => node.world.net.push({ request: true, from: node, to, envelope: text, resolve, reject }));
120
+ }
121
+
97
122
  /**
98
123
  * What a host gives an instance: called on every generation inside its first transaction, with migrate(), so what it
99
124
  * writes commits with it. It creates the host's own tables and registers its handlers with `kernel.handle` and
100
125
  * `kernel.results`. Being inside a transaction, it cannot call what runs only outside one (recover(), tick(), pump(),
101
- * inquire(), answer(), when()); it hands that to `node.recovered(fn)`.
126
+ * inquire(), answer(), when()); it hands that to `node.recovered(fn)`. A host with a layer of its own around the kernel
127
+ * puts it between the world and the kernel with `node.ports()`.
102
128
  * @typedef {(context: { kernel: object, node: SimNode, world: World }) => void} Setup
103
129
  */
104
130
 
131
+ /**
132
+ * Where a host stands between the world and its kernel, for one generation; each one left out is the world's own way.
133
+ * - receive: the world hands the host a letter for this instance; `from` is the instance that put it into the network,
134
+ * which the world vouches for (P3). Returns the receipt.
135
+ * - send: this generation's kernel hands over a letter; the host puts it into the network with the `post` that
136
+ * `node.ports()` returned, or not.
137
+ * - recover: the world recovers this generation; the host calls `kernel.recover()`.
138
+ * @typedef {{
139
+ * receive?: (envelope: object, from: SimNode) => object,
140
+ * send?: (to: string, envelope: object) => Promise<object>,
141
+ * recover?: () => void,
142
+ * }} Ports
143
+ */
144
+
105
145
  /** One instance: a database that outlives crashes, and the kernel generation running on it. */
106
146
  export class SimNode {
107
147
  up = false;
@@ -129,6 +169,16 @@ export class SimNode {
129
169
  host = undefined;
130
170
  /** @type {(() => void) | undefined} what this generation's host does once its recover() has succeeded */
131
171
  afterRecover = undefined;
172
+ /**
173
+ * calls of receive() on the host's handle while the world was handing this instance a letter, over all generations:
174
+ * the world's own call on its way, and every one of the host's, whatever the letter and at whatever depth. The kernel
175
+ * delivering to itself, as answer() does to a local address, does not go through the handle and does not count.
176
+ */
177
+ taken = 0;
178
+ /** letters put into the network with a generation's post, over all generations */
179
+ posted = 0;
180
+ /** the world is handing this instance a letter */
181
+ delivering = false;
132
182
 
133
183
  /**
134
184
  * @param {World} world
@@ -152,10 +202,25 @@ export class SimNode {
152
202
  * @param {{ recover?: boolean }} [options]
153
203
  */
154
204
  boot({ recover = true } = {}) {
155
- this.generation = { dead: false };
205
+ // A process that dies takes its connection's temporary tables, views and triggers with it; this connection outlives
206
+ // every generation, so the world drops them itself. The first generation too: nothing a host's open() made in temp
207
+ // is left for it. Triggers go first, as dropping a table drops its own. What SQLite keeps itself, such as the
208
+ // sqlite_sequence of an AUTOINCREMENT table, cannot be dropped and is no host's. Nor are a virtual table's shadow
209
+ // tables, which go with it: SQLite itself tells them apart, asking the table's module, in table_list. SQL functions
210
+ // cannot be taken back: a host registers its own again on every generation.
211
+ for (const { type, name } of this.q(`SELECT type, name FROM sqlite_temp_master AS m
212
+ WHERE type IN ('trigger', 'view', 'table') AND name NOT GLOB 'sqlite_*'
213
+ AND NOT EXISTS (SELECT 1 FROM pragma_table_list AS l WHERE l.schema = 'temp' AND l.name = m.name AND l.type = 'shadow')
214
+ ORDER BY CASE type WHEN 'trigger' THEN 0 WHEN 'view' THEN 1 ELSE 2 END`).all())
215
+ this.raw.exec(`DROP ${type.toUpperCase()} temp."${name.replaceAll('"', '""')}"`);
216
+ // handed: per letter text, how often this generation's kernel handed it over and its host has not posted it yet.
217
+ const generation = { dead: false, /** @type {Ports} */ ports: {}, handed: new Map(), recovers: 0 };
218
+ // Bound to this generation, so that a host of a generation that died cannot post through the next one.
219
+ generation.post = (to, envelope) => post(this, generation, to, envelope);
220
+ this.generation = generation;
156
221
  this.db = connection(this, this.generation, false);
157
222
  this.woke = null;
158
- this.kernel = createKernel({
223
+ const kernel = createKernel({
159
224
  db: connection(this, this.generation, true),
160
225
  now: () => this.world.time,
161
226
  send: (to, envelope) => this.world.send(this, to, envelope),
@@ -168,13 +233,33 @@ export class SimNode {
168
233
  // What the host asks for, once it commits: a run asked to cancel, and a timer gone from its table without coming
169
234
  // due, must be traceable to one of these (C7, 7).
170
235
  for (const name of ['cancel', 'abandon', 'stop', 'unschedule']) {
171
- const fn = this.kernel[name].bind(this.kernel);
172
- this.kernel[name] = (ref, ...rest) => {
236
+ const fn = kernel[name].bind(kernel);
237
+ kernel[name] = (ref, ...rest) => {
173
238
  this.intents.add(`${name} ${key(ref.from, ref.id)}`);
174
239
  return fn(ref, ...rest);
175
240
  };
176
241
  }
177
- this.wrap(this.kernel);
242
+ // Counted for P-rec.
243
+ const recovering = kernel.recover.bind(kernel);
244
+ kernel.recover = () => {
245
+ generation.recovers += 1;
246
+ return recovering();
247
+ };
248
+ this.wrap(kernel);
249
+ // The handle the host and the world hold, counted for P-in by where a call of receive() comes from, not by the letter
250
+ // it carries: one through the handle during a delivery is taken. Every other function is the kernel's own, bound to
251
+ // it, so the kernel calling its own receive() never comes back through the handle.
252
+ const receive = envelope => {
253
+ if (this.delivering) this.taken += 1;
254
+ return kernel.receive(envelope);
255
+ };
256
+ this.kernel = new Proxy(kernel, {
257
+ get(target, name) {
258
+ if (name === 'receive') return receive;
259
+ const value = Reflect.get(target, name);
260
+ return typeof value === 'function' ? value.bind(target) : value;
261
+ },
262
+ });
178
263
  this.afterRecover = undefined;
179
264
  this.up = true;
180
265
  this.ready = false;
@@ -235,8 +320,14 @@ export class SimNode {
235
320
  }
236
321
 
237
322
  recover() {
323
+ const { generation } = this;
238
324
  try {
239
- this.kernel.recover();
325
+ if (generation.ports.recover) {
326
+ this.world.count('ports: a recovery through the host');
327
+ generation.ports.recover();
328
+ if (generation.recovers !== 1)
329
+ this.world.violations.push(`P-rec: the host of ${this.name} recovered, its kernel's recover() called ${generation.recovers} times, not once`);
330
+ } else this.kernel.recover();
240
331
  this.ready = true;
241
332
  this.afterRecover?.();
242
333
  } catch (error) {
@@ -262,6 +353,17 @@ export class SimNode {
262
353
  this.afterRecover = fn;
263
354
  }
264
355
 
356
+ /**
357
+ * Puts the host between the world and this generation's kernel (see Ports). Called from the setup, once per
358
+ * generation; the next generation starts without them.
359
+ * @param {Ports} ports
360
+ * @returns {(to: string, envelope: object) => Promise<object>} this generation's post (see post())
361
+ */
362
+ ports(ports) {
363
+ this.generation.ports = ports;
364
+ return this.generation.post;
365
+ }
366
+
265
367
  /**
266
368
  * The host's way to do something later: `fn` runs when the world runs its tasks, unless this generation has died by
267
369
  * then. A crash or a failed commit in it is the world's business, as in act().
@@ -438,7 +540,10 @@ export class World {
438
540
  node.timers = timers;
439
541
  }
440
542
 
441
- /** The `send` every instance gets. It checks S6 at the moment of handing over; an answer is no letter of the outbox. */
543
+ /**
544
+ * The `send` every instance gets. It checks S6 at the moment of handing over; an answer is no letter of the outbox.
545
+ * The letter then goes to the host's send, or straight into the network.
546
+ */
442
547
  send(node, to, envelope) {
443
548
  const call = envelope.kind === 'call' ? envelope : envelope.call;
444
549
  const text = JSON.stringify(envelope);
@@ -448,7 +553,11 @@ export class World {
448
553
  .get(call.from, call.id, to, envelope.digest) !== undefined
449
554
  : node.q('SELECT envelope FROM kernel_outbox WHERE call_from = ? AND call_id = ?').all(call.from, call.id).some(row => row.envelope === text));
450
555
  if (!owed) this.violations.push(`S6: ${node.name} sent a letter that is no committed outbox row nor an inquiry about an open call: ${text}`);
451
- return new Promise((resolve, reject) => this.net.push({ request: true, to, envelope: text, resolve, reject }));
556
+ const { handed, ports } = node.generation;
557
+ handed.set(text, (handed.get(text) ?? 0) + 1);
558
+ if (!ports.send) return node.generation.post(to, envelope);
559
+ this.count('ports: a letter out through the host');
560
+ return ports.send(to, envelope);
452
561
  }
453
562
 
454
563
  /**
@@ -472,7 +581,7 @@ export class World {
472
581
  if (this.chance(faults.duplicate)) this.net.push({ ...event, resolve() {}, reject() {} });
473
582
  const envelope = JSON.parse(event.envelope);
474
583
  this.notice('letter', envelope);
475
- let receipt = this.receive(event.to, envelope);
584
+ let receipt = this.receive(event.to, envelope, event.from);
476
585
  if (envelope.kind === 'answer') this.checkAnswer(envelope, receipt);
477
586
  this.notice('receipt', envelope, receipt);
478
587
  if (this.chance(faults.dropReceipt)) receipt = undefined;
@@ -545,14 +654,30 @@ export class World {
545
654
  this.violations.push(`C8: an answer ${key(answer.from, answer.id)} got applied: ${receipt.applied}, but its number was ${taken ? '' : 'not '}taken`);
546
655
  }
547
656
 
548
- receive(to, envelope) {
657
+ /**
658
+ * Hands a letter to the instance that owns `to`, through its host's receive when it gave one. The receipt comes back
659
+ * as it would over a wire; undefined when that instance is down or crashed meanwhile.
660
+ * @param {string} to
661
+ * @param {object} envelope
662
+ * @param {SimNode} [from] the instance that put the letter into the network; called directly, the owner of its `from`
663
+ */
664
+ receive(to, envelope, from = this.owners.get(envelope.from)) {
549
665
  const node = this.owners.get(to);
550
666
  if (!node?.up) return undefined;
667
+ const { ports } = node.generation;
668
+ const taken = node.taken;
669
+ node.delivering = true;
551
670
  try {
552
- return JSON.parse(JSON.stringify(node.kernel.receive(envelope)));
671
+ if (!ports.receive) return JSON.parse(JSON.stringify(node.kernel.receive(envelope)));
672
+ this.count('ports: a letter in through the host');
673
+ return JSON.parse(JSON.stringify(ports.receive(envelope, from)));
553
674
  } catch (error) {
554
675
  if (!lost(error)) throw error;
555
676
  return undefined;
677
+ } finally {
678
+ node.delivering = false;
679
+ if (node.taken - taken > 1)
680
+ this.violations.push(`P-in: the host of ${node.name} handed one ${envelope.kind} letter to its kernel ${node.taken - taken} times`);
556
681
  }
557
682
  }
558
683
 
@@ -617,19 +742,21 @@ export class World {
617
742
  * checks their statuses there; then it rests again, calling `advance` every round, until a round finds nothing to
618
743
  * advance. Otherwise it looks at none of the kernel's tables; what they hold at rest is for checkQuiet() to judge.
619
744
  * @param {() => boolean} [advance] the hosts move on what they hold by a step; true while there was something to move
745
+ * @param {{ resend?: () => void }} [options] resend: how the hosts have every instance send what it keeps again, once a
746
+ * round; without it the world pumps every instance itself, which would make up for a host that never does
620
747
  */
621
- async quiesce(advance = () => false) {
748
+ async quiesce(advance = () => false, { resend } = {}) {
622
749
  this.calm = true;
623
750
  // Letters already in flight may still come back refused by faults chosen earlier; the pumps that count come after.
624
751
  await this.drain();
625
- await this.rest(() => false);
752
+ await this.rest(() => false, resend);
626
753
  this.checkStatus();
627
754
  this.checkHalted();
628
- await this.rest(advance);
755
+ await this.rest(advance, resend);
629
756
  }
630
757
 
631
- /** @param {() => boolean} advance */
632
- async rest(advance) {
758
+ /** @param {() => boolean} advance @param {(() => void) | undefined} resend */
759
+ async rest(advance, resend) {
633
760
  for (let round = 0; round < 50; round++) {
634
761
  const crashed = this.crashed;
635
762
  for (const node of this.nodes) {
@@ -637,7 +764,8 @@ export class World {
637
764
  else if (!node.ready) node.recover();
638
765
  }
639
766
  const advanced = advance();
640
- for (const node of this.nodes) this.pump(node);
767
+ if (resend) resend();
768
+ else for (const node of this.nodes) this.pump(node);
641
769
  // The clock passes every timer, and every instance ticks.
642
770
  this.time = Math.max(this.time, ...this.nodes.filter(node => node.migrated()).map(node => node.q('SELECT max(due) AS due FROM kernel_timers').get().due ?? 0));
643
771
  for (const node of this.nodes) this.tick(node);
@@ -646,7 +774,8 @@ export class World {
646
774
  if (this.crashed === crashed && !advanced && this.net.length === 0 && this.tasks.length === 0
647
775
  && this.nodes.every(node => node.ready && node.count('SELECT count(*) AS n FROM kernel_timers') === 0)) return;
648
776
  }
649
- throw new Error('the world did not come to rest');
777
+ throw new Error(`the world did not come to rest: ${this.nodes.map(node => `${node.name} has ${node.count('SELECT count(*) AS n FROM kernel_outbox')} `
778
+ + `letters in its outbox and ${node.count(`SELECT count(*) AS n FROM kernel_calls WHERE state IN ${UNENDED}`)} calls not ended`).join(', ')}`);
650
779
  }
651
780
 
652
781
  /** At rest, before the held runs finish: a call that has not ended knows the status its run has now (C6). */