From 2e7a49cd7af9809b0968bd04ef8f7975f6b6ea04 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 3 Nov 2026 06:27:10 -0500 Subject: [PATCH] Quantum Studio: a run on the job service whose answer is lost is sent again, safely The Studio's runs on the job service sent each job once and gave up on the first lost answer, of the submission or of any poll. A run now goes under an Idempotency-Key of its own (a random UUID) to a service whose description says it keeps keys, and is sent again under it, twice at most, where no answer came back, so a job the service took is answered rather than taken twice. A service that keeps no keys is sent no key, which its CORS would refuse, and no job twice. A poll whose answer is lost no longer ends the wait; five in a row do. test/service.test.mjs holds each of these against a scripted fetch, and the studio workflow runs it. The views page is unchanged: engine.js is not among its sources, and rebuilt in CI's image it is the same bytes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-quantum-studio.yml | 4 ++ wai/CHANGELOG.md | 8 +++ wai/quantum-studio/README.md | 2 +- wai/quantum-studio/README.zh-CN.md | 2 +- wai/quantum-studio/src/engine.js | 47 +++++++++++++- wai/quantum-studio/test/service.test.mjs | 83 ++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 wai/quantum-studio/test/service.test.mjs 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); -- 2.54.0 (Apple Git-157)