| … | … | @@ -9,7 +9,26 @@ |
| 9 | 9 | const Corestore = require('corestore') |
| 10 | 10 | const b4a = require('b4a') |
| 11 | 11 | const crypto = require('hypercore-crypto') |
| 12 | const { openPool, taskId, resultKey } = require('./pool') | |
| 12 | const { openPool, taskId, resultKey, DEFAULT_LIMITS } = require('./pool') | |
| 13 | ||
| 14 | // a leased claim is expired once the worker's own clock passes at + leaseMs. | |
| 15 | // Reading the clock here (scheduling) is fine; apply() must never do this. | |
| 16 | function claimExpired (claim) { | |
| 17 | if (!claim || typeof claim.at !== 'number' || typeof claim.leaseMs !== 'number') return true | |
| 18 | return Date.now() > claim.at + claim.leaseMs | |
| 19 | } | |
| 20 | ||
| 21 | // resolve `promise` unless `ms` elapses first (the wallMs bound). The underlying | |
| 22 | // executor is not forcibly killed (a real WASM sandbox would be); we stop awaiting. | |
| 23 | function withTimeout (promise, ms) { | |
| 24 | return new Promise((resolve, reject) => { | |
| 25 | const timer = setTimeout(() => reject(new Error('wallMs timeout')), ms) | |
| 26 | promise.then( | |
| 27 | (v) => { clearTimeout(timer); resolve(v) }, | |
| 28 | (e) => { clearTimeout(timer); reject(e) } | |
| 29 | ) | |
| 30 | }) | |
| 31 | } | |
| 13 | 32 | |
| 14 | 33 | class ComputeNode { |
| 15 | 34 | constructor (store, bootstrap, { identity, executors } = {}) { |
| … | … | @@ -54,17 +73,20 @@ class ComputeNode { |
| 54 | 73 | return node ? node.value.output : null |
| 55 | 74 | } |
| 56 | 75 | |
| 57 | // submit returns { output, memoHit:true } on a cache hit, else { id, memoHit:false } | |
| 58 | async submit (codeHash, inputHash, input) { | |
| 76 | // submit returns { output, memoHit:true } on a cache hit, else { id, memoHit:false }. | |
| 77 | // opts.limits ({ fuel, wallMs, memPages }) bound the task (SPEC §4.3); opts.tier | |
| 78 | // is the trust requirement (SPEC §4.4; v0.1 = T0). | |
| 79 | async submit (codeHash, inputHash, input, { limits, tier = 'T0' } = {}) { | |
| 59 | 80 | const cached = await this.lookup(codeHash, inputHash) |
| 60 | 81 | if (cached !== null && cached !== undefined) return { output: cached, memoHit: true } |
| 61 | const id = taskId(codeHash, inputHash) | |
| 62 | await this.base.append({ type: 'task', id, codeHash, inputHash, input }) | |
| 82 | const lim = limits || DEFAULT_LIMITS | |
| 83 | const id = taskId(codeHash, inputHash, lim) | |
| 84 | await this.base.append({ type: 'task', id, codeHash, inputHash, input, limits: lim, tier }) | |
| 63 | 85 | return { id, memoHit: false } |
| 64 | 86 | } |
| 65 | 87 | |
| 66 | // worker loop: execute every task that has a registered executor and no result yet. | |
| 67 | // Returns the number of tasks executed. | |
| 88 | // worker loop: execute every task that has a registered executor and no result | |
| 89 | // yet, respecting other workers' leased claims. Returns the number executed. | |
| 68 | 90 | async runPendingTasks () { |
| 69 | 91 | let executed = 0 |
| 70 | 92 | const tasks = [] |
| … | … | @@ -76,8 +98,25 @@ class ComputeNode { |
| 76 | 98 | if (existing) continue |
| 77 | 99 | const exec = this.executors[task.codeHash] |
| 78 | 100 | if (!exec) continue |
| 79 | await this.base.append({ type: 'claim', id: task.id, worker: this.idHex }) | |
| 80 | const output = await exec(task.input) // T0 native execution | |
| 101 | ||
| 102 | // leased soft-lock (SPEC §6): skip a task another worker holds an UNEXPIRED | |
| 103 | // claim on. Best-effort dedup — simultaneous claims still race (wasted, not | |
| 104 | // corrupt: results are content-keyed + write-once). The clock is read HERE, | |
| 105 | // in the scheduler, never in apply() — the fold stays deterministic (SPEC §3). | |
| 106 | const claim = await this.base.view.get('claim/' + task.id) | |
| 107 | if (claim && claim.value.worker !== this.idHex && !claimExpired(claim.value)) continue | |
| 108 | ||
| 109 | const leaseMs = (task.limits && task.limits.wallMs) || DEFAULT_LIMITS.wallMs | |
| 110 | await this.base.append({ type: 'claim', id: task.id, worker: this.idHex, at: Date.now(), leaseMs }) | |
| 111 | ||
| 112 | let output | |
| 113 | try { | |
| 114 | // wallMs is the one bound enforceable without a sandbox (anti-DoS, SPEC | |
| 115 | // §4.3); fuel/memPages are enforced by the WASM sandbox (P2). | |
| 116 | output = await withTimeout(exec(task.input), leaseMs) // T0 native execution | |
| 117 | } catch { | |
| 118 | continue // timeout or executor error — leave unexecuted for retry / another worker | |
| 119 | } | |
| 81 | 120 | await this.base.append({ |
| 82 | 121 | type: 'result', |
| 83 | 122 | codeHash: task.codeHash, |
| … | … | @@ -3,8 +3,8 @@ |
| 3 | 3 | // One optimistic Autobase whose view is a Hyperbee. Mutable state = a |
| 4 | 4 | // deterministic fold (`apply`) over the append-only op log. View keys: |
| 5 | 5 | // member/<idPub> -> { role } |
| 6 | // task/<id> -> task op | |
| 7 | // claim/<id> -> { worker } (optimistic soft-lock) | |
| 6 | // task/<id> -> task op { codeHash, inputHash, input, limits, tier } | |
| 7 | // claim/<id> -> { worker, at, leaseMs } (leased soft-lock) | |
| 8 | 8 | // result/<codeHash>/<input> -> result op (the memoized chunk) |
| 9 | 9 | // |
| 10 | 10 | // apply() MUST stay deterministic (no Date.now/random/external reads) — SPEC §3. |
| … | … | @@ -18,9 +18,24 @@ function hash (str) { |
| 18 | 18 | return b4a.toString(crypto.hash(b4a.from(str)), 'hex') |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | // content-addressed identity of a unit of work (SPEC §4.1) | |
| 22 | function taskId (codeHash, inputHash) { | |
| 23 | return hash(codeHash + ':' + inputHash) | |
| 21 | // resource bounds (SPEC §4.3). fuel/memPages are enforced by the WASM sandbox | |
| 22 | // (P2); wallMs is enforceable today as a wall-clock timeout (see node.js). | |
| 23 | const DEFAULT_LIMITS = { fuel: 1000000000, wallMs: 30000, memPages: 16384 } | |
| 24 | ||
| 25 | // byte-stable encoding of limits (fixed field order) so the task id is identical | |
| 26 | // on every peer (content-addressing requires it). | |
| 27 | function encodeLimits (limits) { | |
| 28 | const l = limits || DEFAULT_LIMITS | |
| 29 | return l.fuel + '|' + l.wallMs + '|' + l.memPages | |
| 30 | } | |
| 31 | ||
| 32 | // content-addressed identity of a unit of work (SPEC §4.1: | |
| 33 | // id = hash(codeHash ‖ inputHash ‖ limits)). limits ARE part of the identity — a | |
| 34 | // different budget is a different task — but the RESULT is keyed by | |
| 35 | // (codeHash, inputHash) only: the same code+input yields the same output | |
| 36 | // regardless of budget. | |
| 37 | function taskId (codeHash, inputHash, limits) { | |
| 38 | return hash(codeHash + ':' + inputHash + ':' + encodeLimits(limits)) | |
| 24 | 39 | } |
| 25 | 40 | |
| 26 | 41 | function resultKey (codeHash, inputHash) { |
| … | … | @@ -53,8 +68,10 @@ function openPool (store, bootstrap) { |
| 53 | 68 | case 'task': // content-addressed work unit |
| 54 | 69 | await view.put('task/' + op.id, op) |
| 55 | 70 | break |
| 56 | case 'claim': // optimistic soft-lock to dedupe workers | |
| 57 | await view.put('claim/' + op.id, { worker: op.worker }) | |
| 71 | case 'claim': // leased soft-lock to dedupe workers (SPEC §6). | |
| 72 | // apply only STORES the lease fields the worker stamped — it never | |
| 73 | // reads the clock itself, so the fold stays deterministic (SPEC §3). | |
| 74 | await view.put('claim/' + op.id, { worker: op.worker, at: op.at, leaseMs: op.leaseMs }) | |
| 58 | 75 | break |
| 59 | 76 | case 'result': { // memoized output, keyed by content — WRITE-ONCE |
| 60 | 77 | // The fold is deterministic across peers, so "first result in |
| … | … | @@ -72,4 +89,4 @@ function openPool (store, bootstrap) { |
| 72 | 89 | }) |
| 73 | 90 | } |
| 74 | 91 | |
| 75 | module.exports = { openPool, taskId, resultKey, hash } | |
| 92 | module.exports = { openPool, taskId, resultKey, hash, DEFAULT_LIMITS } |
| … | … | @@ -0,0 +1,56 @@ |
| 1 | // test/claim-and-limits.js — two P0 audit fixes: | |
| 2 | // (1) tasks carry limits + tier, and limits are part of the task identity | |
| 3 | // (SPEC §4.1) while the result stays keyed by (codeHash, inputHash). | |
| 4 | // (2) the `claim` op is a LEASED soft-lock that is actually CONSULTED before | |
| 5 | // executing (it was previously written but never read — dead state). | |
| 6 | ||
| 7 | const test = require('brittle') | |
| 8 | const Corestore = require('corestore') | |
| 9 | const tmp = require('test-tmp') | |
| 10 | const { ComputeNode } = require('../lib/node') | |
| 11 | const { taskId } = require('../lib/pool') | |
| 12 | ||
| 13 | const codeHash = 'square-v1' | |
| 14 | const inputHash = 'abc123' | |
| 15 | const FOREIGN = 'ff'.repeat(32) // a worker id that is not ours | |
| 16 | const square = { [codeHash]: async (i) => i.n * i.n } | |
| 17 | ||
| 18 | test('limits are part of the task identity (SPEC §4.1), recorded on the task op', async (t) => { | |
| 19 | const a = taskId(codeHash, inputHash, { fuel: 1, wallMs: 1, memPages: 1 }) | |
| 20 | const b = taskId(codeHash, inputHash, { fuel: 2, wallMs: 1, memPages: 1 }) | |
| 21 | t.not(a, b, 'a different budget is a different task id') | |
| 22 | ||
| 23 | const node = await ComputeNode.create(await tmp(t), null, { executors: square }) | |
| 24 | await node.grantSelf('owner') | |
| 25 | const sub = await node.submit(codeHash, inputHash, { n: 5 }, { limits: { fuel: 7, wallMs: 7, memPages: 7 }, tier: 'T1' }) | |
| 26 | const taskNode = await node.base.view.get('task/' + sub.id) | |
| 27 | t.alike(taskNode.value.limits, { fuel: 7, wallMs: 7, memPages: 7 }, 'task records its limits') | |
| 28 | t.is(taskNode.value.tier, 'T1', 'task records its tier') | |
| 29 | await node.close() | |
| 30 | }) | |
| 31 | ||
| 32 | test('an UNEXPIRED foreign claim blocks execution (claim is consulted, not dead state)', async (t) => { | |
| 33 | const node = await ComputeNode.create(await tmp(t), null, { executors: square }) | |
| 34 | await node.grantSelf('owner') | |
| 35 | const sub = await node.submit(codeHash, inputHash, { n: 6 }) | |
| 36 | // another worker holds a fresh lease on this task | |
| 37 | await node.base.append({ type: 'claim', id: sub.id, worker: FOREIGN, at: Date.now(), leaseMs: 60000 }) | |
| 38 | ||
| 39 | const n = await node.runPendingTasks() | |
| 40 | t.is(n, 0, 'task skipped while another worker holds an unexpired claim') | |
| 41 | t.is(await node.lookup(codeHash, inputHash), null, 'no result produced') | |
| 42 | await node.close() | |
| 43 | }) | |
| 44 | ||
| 45 | test('an EXPIRED foreign claim does NOT block execution (lease recovery)', async (t) => { | |
| 46 | const node = await ComputeNode.create(await tmp(t), null, { executors: square }) | |
| 47 | await node.grantSelf('owner') | |
| 48 | const sub = await node.submit(codeHash, inputHash, { n: 6 }) | |
| 49 | // a stale lease: claimed far in the past, lease already elapsed | |
| 50 | await node.base.append({ type: 'claim', id: sub.id, worker: FOREIGN, at: Date.now() - 120000, leaseMs: 60000 }) | |
| 51 | ||
| 52 | const n = await node.runPendingTasks() | |
| 53 | t.is(n, 1, 'expired claim is ignored; task re-executed') | |
| 54 | t.is(await node.lookup(codeHash, inputHash), 36, 'result produced (6*6)') | |
| 55 | await node.close() | |
| 56 | }) |