diff --git a/.github/workflows/wai-quantum-studio.yml b/.github/workflows/wai-quantum-studio.yml index b8a41dcd1..647697be9 100644 --- a/.github/workflows/wai-quantum-studio.yml +++ b/.github/workflows/wai-quantum-studio.yml @@ -13,6 +13,9 @@ name: wai-quantum studio and desktop # a run that is not free, never before a free one, and sends nothing on a "no"; a job # in the provider's queue is asked after by its id, Cancel cancels it there, "Wait in # the background" leaves it, and a job left waiting gives its result when it finishes; +# - test/service.test.mjs, without a browser: a job on the job service whose answer is +# lost is sent again under its key to a service that keeps keys, and once, with no +# key, to one that does not; a poll whose answer is lost does not end the wait; # - test/telemetry.test.mjs, without a browser: the correctness record is off by # default, and while off records nothing, writes nothing and asks the engine # nothing; and the views page agent hosts draw is built from these sources @@ -131,6 +134,7 @@ jobs: npm ci --no-audit --no-fund npx playwright install --with-deps chromium node test/hardware.test.mjs + node test/service.test.mjs node test/telemetry.test.mjs node test/update.test.mjs node build-views.mjs --check diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index c60267659..3c6a72886 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -302,6 +302,14 @@ and no `depends_on :macos`. It now has both, so the tap carries it as the releas invoice of version 5, since version 4 is now defined, and is refused `unknown_version` as before; every other vector keeps its outcome, and the new vectors are added. +### Quantum Studio: a run on the job service whose answer is lost is sent again, safely + +- **Quantum Studio:** a run on the job service goes under an `Idempotency-Key` of its own, a random UUID, to a + service whose description says it keeps keys. Where no answer comes back, the run is sent again under that key, + twice at most, so a job the service took is answered rather than taken twice. A service that keeps no keys is sent + no key, which it would refuse across origins, and no job twice. A poll whose answer is lost no longer ends the + wait; five in a row do. + ### The receipt ledger prices media jobs per operation, and bills the work done - **Tariff version 3** ([extensions/receipt-ledger.md](extensions/receipt-ledger.md) §4.6): each job price diff --git a/wai/quantum-studio/README.md b/wai/quantum-studio/README.md index 919d29d39..20593d219 100644 --- a/wai/quantum-studio/README.md +++ b/wai/quantum-studio/README.md @@ -4,7 +4,7 @@ An editor for OpenQASM programs, with three places to run them: - **this browser**, through the quantum job service's engine compiled to WebAssembly, in a worker; - **this computer**, through the same engine built natively, in the [desktop app](../wai-quantum-desktop/); -- **the job service**, over HTTP. +- **the job service**, over HTTP. A run goes under a key of its own to a service that keeps keys, and where no answer comes back it is sent again under that key, so a job the service took is not taken twice. In the desktop app, **your hardware** is a fourth: one of your own providers (`wai-quantum providers add`), its estimate asked for first and a dialog shown before a run that is not free, the credential held by the app and never by the page. A job in the provider's queue outlives the window: Cancel cancels it there, **Wait in the background** leaves it and frees the Studio, and its result goes to the history when it finishes; **Your jobs** lists what was sent. In a browser tab the switch only says that such runs go through the desktop app or the command line (`wai-quantum run --provider NAME`), since a browser tab must not hold a provider's credential. diff --git a/wai/quantum-studio/README.zh-CN.md b/wai/quantum-studio/README.zh-CN.md index b9cf6569f..2e075a8f2 100644 --- a/wai/quantum-studio/README.zh-CN.md +++ b/wai/quantum-studio/README.zh-CN.md @@ -8,7 +8,7 @@ - **这个浏览器**:借助编译为 WebAssembly 的量子作业服务引擎,在一个 worker 中运行; - **这台计算机**:借助以原生方式构建的同一个引擎,在[桌面应用](../wai-quantum-desktop/)中运行; -- **作业服务**:通过 HTTP 运行。 +- **作业服务**:通过 HTTP 运行。对于保存密钥的服务,每次运行都带上自己的密钥;若没有收到应答,就用同一个密钥再发送一次,因此服务已经接收的作业不会被重复接收。 在桌面应用中,**你的硬件**是第四个地方:你自己的某个提供方(`wai-quantum providers add`);运行前先向它询问估算,不免费的运行之前会显示一个对话框;凭据由应用持有,从不由页面持有。在提供方队列中的作业不会随窗口关闭而消失:“取消”会在提供方取消它,**在后台等待**会把它留在那里并让 Studio 可以继续使用,完成后其结果会存入历史;**你的作业**列出已发送的作业。在浏览器标签页中,这个切换开关只会说明此类运行须经由桌面应用或命令行进行(`wai-quantum run --provider NAME`),因为浏览器标签页不得持有提供方的凭据。 diff --git a/wai/quantum-studio/src/engine.js b/wai/quantum-studio/src/engine.js index ca1e18acd..a8f9052f3 100644 --- a/wai/quantum-studio/src/engine.js +++ b/wai/quantum-studio/src/engine.js @@ -140,6 +140,13 @@ export class TabEngine { } } +// A key for one submission: a random UUID. +function freshKey() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + const b = globalThis.crypto.getRandomValues(new Uint8Array(16)); + return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); +} + export class ServiceEngine { constructor(base) { this.base = base.replace(/\/+$/, ''); @@ -152,14 +159,50 @@ export class ServiceEngine { if (!res.ok) throw new Error(body.error || `the service answered ${res.status}`); return body; } + // Whether the service keeps the key a job is sent under (its description's + // `idempotency`), asked once. Only to one that does is a key sent, since one + // that does not refuses the header across origins, and a job sent again. + keepsKeys() { + this.keeps ??= this.describe().then((d) => !!d?.idempotency?.on?.includes('POST /v1/jobs'), () => false); + return this.keeps; + } + // The job, sent under a key of its own and sent again under it where no + // answer came back (twice at most), so a job the service took is answered + // rather than taken twice. + async submit(request) { + const key = (await this.keepsKeys()) ? freshKey() : null; + const headers = { 'content-type': 'application/json', ...(key ? { 'idempotency-key': key } : {}) }; + for (let sent = 1; ; sent += 1) { + let res; + try { + res = await fetch(`${this.base}/v1/jobs`, { method: 'POST', headers, body: JSON.stringify(request) }); + } catch (e) { + if (!key || sent >= 3 || this.stopped) throw e; + await new Promise((r) => setTimeout(r, 1000 * sent)); + continue; + } + return this.json(res); + } + } async run(request, onProgress) { this.stopped = false; - const sub = await this.json(await fetch(`${this.base}/v1/jobs`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request) })); + const sub = await this.submit(request); this.current = sub.id; let wait = 300; + let missed = 0; for (;;) { if (this.stopped) throw new Error('cancelled'); - const job = await this.json(await fetch(`${this.base}/v1/jobs/${encodeURIComponent(sub.id)}`)); + // A poll whose answer is lost does not end the wait; five in a row do. + let res; + try { + res = await fetch(`${this.base}/v1/jobs/${encodeURIComponent(sub.id)}`); + missed = 0; + } catch (e) { + if ((missed += 1) >= 5) throw e; + await new Promise((r) => setTimeout(r, wait)); + continue; + } + const job = await this.json(res); onProgress?.(job); if (job.status === 'done') { job.request ??= request; this.current = null; return job; } if (job.status !== 'queued' && job.status !== 'running') { this.current = null; throw new Error(job.error || `the job ${job.status}`); } diff --git a/wai/quantum-studio/test/service.test.mjs b/wai/quantum-studio/test/service.test.mjs new file mode 100644 index 000000000..e00e34282 --- /dev/null +++ b/wai/quantum-studio/test/service.test.mjs @@ -0,0 +1,83 @@ +// The Studio's runs on the job service, without a browser or a service: a +// job whose answer is lost is sent again under its key to a service that +// keeps keys, and sent once, with no key, to one that does not; a poll whose +// answer is lost does not end the wait. +// +// node test/service.test.mjs +import { ServiceEngine } from '../src/engine.js'; + +let fails = 0; +const check = (ok, what) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails += 1; }; + +// A service from a script: each answer is a status and a body, or 'drop' +// for a connection that ends before its answer. Every request is kept. +function service(script) { + const seen = []; + globalThis.fetch = async (url, init = {}) => { + const path = new URL(url).pathname; + seen.push({ method: init.method || 'GET', path, headers: init.headers || {} }); + const queue = script[`${init.method || 'GET'} ${path}`] ?? []; + const next = queue.length > 1 ? queue.shift() : queue[0]; + if (next === undefined) return new Response(JSON.stringify({ error: `no answer for ${path}` }), { status: 500 }); + if (next === 'drop') throw new TypeError('Failed to fetch'); + return new Response(JSON.stringify(next[1]), { status: next[0], headers: { 'content-type': 'application/json' } }); + }; + return seen; +} + +const base = 'https://jobs.example/quantum-jobs'; +const done = { id: 'j1', status: 'done', result: { counts: { '00': 5, '11': 5 } } }; +const keeps = { idempotency: { header: 'Idempotency-Key', on: ['POST /v1/jobs', 'POST /v1/run'] } }; + +{ + const seen = service({ + 'GET /quantum-jobs/v1': [[200, keeps]], + 'POST /quantum-jobs/v1/jobs': ['drop', [202, { id: 'j1', status: 'queued' }]], + 'GET /quantum-jobs/v1/jobs/j1': [[200, done]], + }); + const job = await new ServiceEngine(base).run({ qasm: 'OPENQASM 3.0; qubit q;', shots: 10 }); + const posts = seen.filter((r) => r.method === 'POST'); + const keys = posts.map((r) => r.headers['idempotency-key']); + check(job.status === 'done' && job.id === 'j1', 'the job whose answer was lost ends done'); + check(posts.length === 2 && keys[0] && keys[0] === keys[1], `sent again under the same key (${keys.join(', ')})`); + check(/^[0-9a-f-]{32,36}$/.test(keys[0]), 'the key is a random UUID'); +} + +{ + const seen = service({ + 'GET /quantum-jobs/v1': [[200, { service: 'wai-quantum-service' }]], + 'POST /quantum-jobs/v1/jobs': ['drop'], + }); + let err = null; + try { await new ServiceEngine(base).run({ qasm: 'OPENQASM 3.0; qubit q;' }); } catch (e) { err = e; } + const posts = seen.filter((r) => r.method === 'POST'); + check(err && posts.length === 1, 'a service that keeps no keys is sent the job once'); + check(!('idempotency-key' in posts[0].headers), 'and no key, which its CORS would refuse'); +} + +{ + const seen = service({ + 'GET /quantum-jobs/v1': [[200, keeps]], + 'POST /quantum-jobs/v1/jobs': [[202, { id: 'j1', status: 'queued' }]], + 'GET /quantum-jobs/v1/jobs/j1': ['drop', [200, { id: 'j1', status: 'running' }], [200, done]], + }); + const e = new ServiceEngine(base); + const job = await e.run({ qasm: 'OPENQASM 3.0; qubit q;' }); + check(job.status === 'done', 'a poll whose answer is lost does not end the wait'); + await e.run({ qasm: 'OPENQASM 3.0; qubit q;' }); + check(seen.filter((r) => r.path === '/quantum-jobs/v1').length === 1, 'the service is asked once whether it keeps keys'); +} + +{ + service({ + 'GET /quantum-jobs/v1': [[200, keeps]], + 'POST /quantum-jobs/v1/jobs': [[202, { id: 'j1', status: 'queued' }]], + 'GET /quantum-jobs/v1/jobs/j1': ['drop'], + }); + let err = null; + try { await new ServiceEngine(base).run({ qasm: 'OPENQASM 3.0; qubit q;' }); } catch (e) { err = e; } + check(err !== null, 'five lost polls in a row end the wait'); +} + +console.log(fails ? `${fails} failed` : 'service: every check passed'); +process.exit(fails ? 1 : 0);