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
|
# 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
|
# 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;
|
# 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
|
# - 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
|
# 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
|
# nothing; and the views page agent hosts draw is built from these sources
|
||||||
|
|
@ -131,6 +134,7 @@ jobs:
|
||||||
npm ci --no-audit --no-fund
|
npm ci --no-audit --no-fund
|
||||||
npx playwright install --with-deps chromium
|
npx playwright install --with-deps chromium
|
||||||
node test/hardware.test.mjs
|
node test/hardware.test.mjs
|
||||||
|
node test/service.test.mjs
|
||||||
node test/telemetry.test.mjs
|
node test/telemetry.test.mjs
|
||||||
node test/update.test.mjs
|
node test/update.test.mjs
|
||||||
node build-views.mjs --check
|
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
|
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.
|
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
|
### 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
|
- **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 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/);
|
- **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.
|
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 中运行;
|
- **这个浏览器**:借助编译为 WebAssembly 的量子作业服务引擎,在一个 worker 中运行;
|
||||||
- **这台计算机**:借助以原生方式构建的同一个引擎,在[桌面应用](../wai-quantum-desktop/)中运行;
|
- **这台计算机**:借助以原生方式构建的同一个引擎,在[桌面应用](../wai-quantum-desktop/)中运行;
|
||||||
- **作业服务**:通过 HTTP 运行。
|
- **作业服务**:通过 HTTP 运行。对于保存密钥的服务,每次运行都带上自己的密钥;若没有收到应答,就用同一个密钥再发送一次,因此服务已经接收的作业不会被重复接收。
|
||||||
|
|
||||||
在桌面应用中,**你的硬件**是第四个地方:你自己的某个提供方(`wai-quantum providers add`);运行前先向它询问估算,不免费的运行之前会显示一个对话框;凭据由应用持有,从不由页面持有。在提供方队列中的作业不会随窗口关闭而消失:“取消”会在提供方取消它,**在后台等待**会把它留在那里并让 Studio 可以继续使用,完成后其结果会存入历史;**你的作业**列出已发送的作业。在浏览器标签页中,这个切换开关只会说明此类运行须经由桌面应用或命令行进行(`wai-quantum run --provider NAME`),因为浏览器标签页不得持有提供方的凭据。
|
在桌面应用中,**你的硬件**是第四个地方:你自己的某个提供方(`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 {
|
export class ServiceEngine {
|
||||||
constructor(base) {
|
constructor(base) {
|
||||||
this.base = base.replace(/\/+$/, '');
|
this.base = base.replace(/\/+$/, '');
|
||||||
|
|
@ -152,14 +159,50 @@ export class ServiceEngine {
|
||||||
if (!res.ok) throw new Error(body.error || `the service answered ${res.status}`);
|
if (!res.ok) throw new Error(body.error || `the service answered ${res.status}`);
|
||||||
return body;
|
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) {
|
async run(request, onProgress) {
|
||||||
this.stopped = false;
|
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;
|
this.current = sub.id;
|
||||||
let wait = 300;
|
let wait = 300;
|
||||||
|
let missed = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (this.stopped) throw new Error('cancelled');
|
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);
|
onProgress?.(job);
|
||||||
if (job.status === 'done') { job.request ??= request; this.current = null; return 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}`); }
|
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