Quantum Studio: a run on the job service whose answer is lost is sent again, safely #600
6 changed files with 142 additions and 4 deletions
4
.github/workflows/wai-quantum-studio.yml
vendored
4
.github/workflows/wai-quantum-studio.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
- **这个浏览器**:借助编译为 WebAssembly 的量子作业服务引擎,在一个 worker 中运行;
|
||||
- **这台计算机**:借助以原生方式构建的同一个引擎,在[桌面应用](../wai-quantum-desktop/)中运行;
|
||||
- **作业服务**:通过 HTTP 运行。
|
||||
- **作业服务**:通过 HTTP 运行。对于保存密钥的服务,每次运行都带上自己的密钥;若没有收到应答,就用同一个密钥再发送一次,因此服务已经接收的作业不会被重复接收。
|
||||
|
||||
在桌面应用中,**你的硬件**是第四个地方:你自己的某个提供方(`wai-quantum providers add`);运行前先向它询问估算,不免费的运行之前会显示一个对话框;凭据由应用持有,从不由页面持有。在提供方队列中的作业不会随窗口关闭而消失:“取消”会在提供方取消它,**在后台等待**会把它留在那里并让 Studio 可以继续使用,完成后其结果会存入历史;**你的作业**列出已发送的作业。在浏览器标签页中,这个切换开关只会说明此类运行须经由桌面应用或命令行进行(`wai-quantum run --provider NAME`),因为浏览器标签页不得持有提供方的凭据。
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`); }
|
||||
|
|
|
|||
83
wai/quantum-studio/test/service.test.mjs
Normal file
83
wai/quantum-studio/test/service.test.mjs
Normal file
|
|
@ -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);
|
||||
Loading…
Reference in a new issue