@danceiny/gotry 0.2.0-rc.31 → 0.2.0-rc.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +63 -53
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/client/client.js +66 -21
- package/dist/capabilities/doctor.js +7 -8
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +148 -47
- package/dist/capabilities/session/gui-flight.js +949 -0
- package/dist/capabilities/session/gui-hotel.js +470 -0
- package/dist/capabilities/session-search.js +102 -14
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bootstrap-tests.js +23 -6
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +19 -4
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +5 -0
- package/dist/scripts/gotry-web-client-tests.js +20 -1
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +1 -1
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +211 -6
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/gotry-web-api.js +64 -2
- package/dist/src/index.js +70 -18
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +5 -1
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +185 -67
- package/ts/capabilities/session/gui-flight.ts +1022 -0
- package/ts/capabilities/session/gui-hotel.ts +505 -0
- package/ts/capabilities/session-search.ts +130 -18
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/gotry-web-api.ts +51 -2
- package/ts/src/index.ts +67 -19
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fault-injection drill of the per-tenant state layer (AaaS A1.4, docs/rfc/aaas-rfc.md §4.2, §4.6 and §7; #275 checklist).
|
|
3
|
+
*
|
|
4
|
+
* Real OS processes are the replicas. Four of them contend for two tenants through one SQLite lease file and one
|
|
5
|
+
* durable directory, each on its own state root, writing numbered events and snapshotting every 120 ms. The drill
|
|
6
|
+
* kills owners with SIGKILL, freezes an owner with SIGSTOP past its lease and resumes it, and hands tenants off
|
|
7
|
+
* with SIGTERM. Afterwards a verifier takes each tenant over and checks the ledger against what the replicas logged.
|
|
8
|
+
*
|
|
9
|
+
* What it proves (all offline, on isolated roots, no network, no model):
|
|
10
|
+
* I1 the lease epochs of a tenant are consecutive: no epoch was granted twice or skipped;
|
|
11
|
+
* I2 each epoch has at most one writer;
|
|
12
|
+
* I3 no write is acknowledged after the next owner took the lease (the local gate works on a real clock);
|
|
13
|
+
* I4 the restored ledger is intact, holds only this tenant, and is exactly the lineage the durable copies describe:
|
|
14
|
+
* every pinned snapshot holds exactly the writes acknowledged before it began, and nothing else is there;
|
|
15
|
+
* I5 a graceful handoff loses nothing, also from a consumer that keeps writing through the ledger it holds while the
|
|
16
|
+
* replica hands off (and is refused once the final image is being taken), over an object store that is slow to
|
|
17
|
+
* take an image; a crash loses only the writes after the last snapshot;
|
|
18
|
+
* I6 the two tenants never see each other's writes.
|
|
19
|
+
*
|
|
20
|
+
* A simulated trigger is not the real trigger: this is engineering evidence for A1 and counts for none of A3 or A4.
|
|
21
|
+
*
|
|
22
|
+
* Run: cd ts && npx tsx scripts/tenant-state-drill-tests.ts
|
|
23
|
+
*/
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { DirDurableStore } from '../src/tenant-state/durable-store.js';
|
|
32
|
+
import { LeaseHeldError } from '../src/tenant-state/errors.js';
|
|
33
|
+
import { deriveLedgerTenantId, leaseKeyFor } from '../src/tenant-state/ids.js';
|
|
34
|
+
import { SqliteLeaseStore } from '../src/tenant-state/lease-store.js';
|
|
35
|
+
import { TenantRouter } from '../src/tenant-state/tenant-router.js';
|
|
36
|
+
const TS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
37
|
+
const WORKER = join(TS_DIR, 'scripts', 'tenant-state-drill-worker.ts');
|
|
38
|
+
const ORG = 'acme';
|
|
39
|
+
const ORG_KEY = Buffer.alloc(32, 3);
|
|
40
|
+
const TENANT_A = deriveLedgerTenantId(ORG_KEY, 'https://idp.example/acme', 'user-1');
|
|
41
|
+
const TENANT_B = deriveLedgerTenantId(ORG_KEY, 'https://idp.example/acme', 'user-2');
|
|
42
|
+
const KEY_A = leaseKeyFor(ORG, TENANT_A);
|
|
43
|
+
const KEY_B = leaseKeyFor(ORG, TENANT_B);
|
|
44
|
+
const TENANTS = [{ label: 'a', tenant: TENANT_A, key: KEY_A }, { label: 'b', tenant: TENANT_B, key: KEY_B }];
|
|
45
|
+
const DEADLINE_MS = 170_000;
|
|
46
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-tenant-drill-'));
|
|
47
|
+
const leaseDb = join(root, 'leases.db');
|
|
48
|
+
const durableDir = join(root, 'durable');
|
|
49
|
+
const logDir = join(root, 'logs');
|
|
50
|
+
mkdirSync(logDir, { recursive: true });
|
|
51
|
+
mkdirSync(join(root, 'tmp'), { recursive: true });
|
|
52
|
+
const leases = new SqliteLeaseStore({ path: leaseDb });
|
|
53
|
+
const durable = new DirDurableStore(durableDir);
|
|
54
|
+
let passed = 0;
|
|
55
|
+
function ok(condition, message) {
|
|
56
|
+
assert.ok(condition, message);
|
|
57
|
+
passed += 1;
|
|
58
|
+
}
|
|
59
|
+
function eq(actual, expected, message) {
|
|
60
|
+
assert.deepEqual(actual, expected, message);
|
|
61
|
+
passed += 1;
|
|
62
|
+
}
|
|
63
|
+
const replicas = new Map();
|
|
64
|
+
let nextReplica = 1;
|
|
65
|
+
function spawnReplica() {
|
|
66
|
+
const id = `w${nextReplica++}`;
|
|
67
|
+
const child = spawn(process.execPath, ['--import', 'tsx', WORKER, join(root, 'nodes', id), leaseDb, durableDir, logDir, id, ORG, TENANT_A, TENANT_B], {
|
|
68
|
+
cwd: TS_DIR,
|
|
69
|
+
// A replica killed mid-snapshot cannot clean up its scratch directory, so it gets one inside the drill root.
|
|
70
|
+
env: { ...process.env, TMPDIR: join(root, 'tmp'), TENANT_DRILL_SLOW_PUT_MS: '40' },
|
|
71
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
72
|
+
});
|
|
73
|
+
const replica = {
|
|
74
|
+
id,
|
|
75
|
+
child,
|
|
76
|
+
stderr: '',
|
|
77
|
+
exited: new Promise((resolve) => { child.once('close', (code, signal) => { resolve({ code, signal }); }); }),
|
|
78
|
+
};
|
|
79
|
+
child.stderr?.on('data', (chunk) => { replica.stderr += chunk.toString(); });
|
|
80
|
+
replicas.set(id, replica);
|
|
81
|
+
return replica;
|
|
82
|
+
}
|
|
83
|
+
const deadline = setTimeout(() => {
|
|
84
|
+
for (const replica of replicas.values())
|
|
85
|
+
replica.child.kill('SIGKILL');
|
|
86
|
+
console.error(`DRILL DEADLINE: ${DEADLINE_MS} ms exceeded, replicas killed`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}, DEADLINE_MS);
|
|
89
|
+
async function waitFor(label, probe, accept, timeoutMs = 25_000) {
|
|
90
|
+
const until = Date.now() + timeoutMs;
|
|
91
|
+
for (;;) {
|
|
92
|
+
const value = await probe();
|
|
93
|
+
if (accept(value))
|
|
94
|
+
return value;
|
|
95
|
+
if (Date.now() > until)
|
|
96
|
+
throw new Error(`timed out waiting for ${label}`);
|
|
97
|
+
await sleep(25);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function readLogs() {
|
|
101
|
+
const entries = [];
|
|
102
|
+
for (const file of readdirSync(logDir)) {
|
|
103
|
+
for (const line of readFileSync(join(logDir, file), 'utf8').split('\n')) {
|
|
104
|
+
if (!line)
|
|
105
|
+
continue;
|
|
106
|
+
try {
|
|
107
|
+
entries.push(JSON.parse(line));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// A replica killed mid-write leaves one torn last line.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return entries.sort((a, b) => a.wall - b.wall);
|
|
115
|
+
}
|
|
116
|
+
const acksIn = (label, epoch) => readLogs().filter(e => e.t === 'ack' && e.tenant === label && e.epoch === epoch).length;
|
|
117
|
+
const epochOf = async (key) => (await leases.read(key))?.epoch ?? 0;
|
|
118
|
+
async function crash(replica) {
|
|
119
|
+
replica.child.kill('SIGKILL');
|
|
120
|
+
await replica.exited;
|
|
121
|
+
replicas.delete(replica.id);
|
|
122
|
+
spawnReplica();
|
|
123
|
+
}
|
|
124
|
+
async function takeover(label, key, previous) {
|
|
125
|
+
const epoch = await waitFor(`a new owner of tenant ${label}`, () => epochOf(key), value => value > previous);
|
|
126
|
+
await waitFor(`acknowledged writes by the owner of epoch ${epoch} of tenant ${label}`, () => acksIn(label, epoch), count => count >= 8);
|
|
127
|
+
return epoch;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
// ---- scenario ------------------------------------------------------------------------------------
|
|
131
|
+
console.log('-- scenario: four replicas, two tenants, kills, a pause and a graceful handoff');
|
|
132
|
+
for (let index = 0; index < 4; index += 1)
|
|
133
|
+
spawnReplica();
|
|
134
|
+
for (const { label, key } of TENANTS) {
|
|
135
|
+
await waitFor(`tenant ${label} to be served`, async () => ({ epoch: await epochOf(key), acks: acksIn(label, 1) }), value => value.epoch >= 1 && value.acks >= 15);
|
|
136
|
+
}
|
|
137
|
+
await sleep(1_200);
|
|
138
|
+
const killedEpochs = [];
|
|
139
|
+
let epochA = await epochOf(KEY_A);
|
|
140
|
+
for (let round = 1; round <= 2; round += 1) {
|
|
141
|
+
const record = await leases.read(KEY_A);
|
|
142
|
+
const owner = replicas.get(record?.owner ?? '');
|
|
143
|
+
assert.ok(owner, `the owner of tenant a (${record?.owner}) is one of ours`);
|
|
144
|
+
killedEpochs.push({ label: 'a', epoch: record?.epoch ?? 0 });
|
|
145
|
+
await crash(owner);
|
|
146
|
+
epochA = await takeover('a', KEY_A, epochA);
|
|
147
|
+
}
|
|
148
|
+
console.log(` ok - two SIGKILLs of the owner of tenant a: epoch now ${epochA}`);
|
|
149
|
+
const recordB = await leases.read(KEY_B);
|
|
150
|
+
const frozen = replicas.get(recordB?.owner ?? '');
|
|
151
|
+
assert.ok(frozen, 'the owner of tenant b is one of ours');
|
|
152
|
+
const frozenEpoch = recordB?.epoch ?? 0;
|
|
153
|
+
frozen.child.kill('SIGSTOP');
|
|
154
|
+
const takenAt = Date.now();
|
|
155
|
+
const epochB = await takeover('b', KEY_B, frozenEpoch);
|
|
156
|
+
await sleep(700);
|
|
157
|
+
const resumedAt = Date.now();
|
|
158
|
+
frozen.child.kill('SIGCONT');
|
|
159
|
+
await sleep(1_800);
|
|
160
|
+
const afterResume = readLogs().filter(e => e.replica === frozen.id && e.wall >= resumedAt && (e.t === 'refused' || (e.t === 'event' && e.event === 'lost')));
|
|
161
|
+
assert.ok(afterResume.length > 0, 'a frozen owner that wakes up past its lease is refused or told it lost the tenant, and the logs show it');
|
|
162
|
+
console.log(` ok - owner of tenant b frozen past its lease (epoch ${frozenEpoch} -> ${epochB}), resumed after ${resumedAt - takenAt} ms: ${afterResume.length} refusals or losses logged after the resume`);
|
|
163
|
+
const gracefulOwner = replicas.get((await leases.read(KEY_A))?.owner ?? '');
|
|
164
|
+
assert.ok(gracefulOwner, 'the owner of tenant a is one of ours');
|
|
165
|
+
const gracefulEpoch = await epochOf(KEY_A);
|
|
166
|
+
gracefulOwner.child.kill('SIGTERM');
|
|
167
|
+
await gracefulOwner.exited;
|
|
168
|
+
replicas.delete(gracefulOwner.id);
|
|
169
|
+
spawnReplica();
|
|
170
|
+
epochA = await takeover('a', KEY_A, gracefulEpoch);
|
|
171
|
+
console.log(` ok - graceful SIGTERM handoff of tenant a: epoch ${gracefulEpoch} -> ${epochA}`);
|
|
172
|
+
await sleep(1_500);
|
|
173
|
+
await waitFor('every replica to have started', () => readLogs().filter(e => e.t === 'start').length, count => count === nextReplica - 1, 30_000);
|
|
174
|
+
for (const replica of [...replicas.values()])
|
|
175
|
+
replica.child.kill('SIGTERM');
|
|
176
|
+
for (const replica of [...replicas.values()]) {
|
|
177
|
+
const settled = await Promise.race([replica.exited, sleep(20_000).then(() => 'timeout')]);
|
|
178
|
+
if (settled === 'timeout')
|
|
179
|
+
replica.child.kill('SIGKILL');
|
|
180
|
+
const ended = await replica.exited;
|
|
181
|
+
assert.ok(settled !== 'timeout' && ended.code === 0, `replica ${replica.id} ended with ${String(ended.code)} ${String(ended.signal)}: ${replica.stderr.slice(0, 400)}`);
|
|
182
|
+
}
|
|
183
|
+
clearTimeout(deadline);
|
|
184
|
+
// ---- verification --------------------------------------------------------------------------------------
|
|
185
|
+
console.log('-- verification: a fresh replica takes each tenant over and the ledger is compared with the logs');
|
|
186
|
+
const verifier = new TenantRouter({
|
|
187
|
+
stateRoot: join(root, 'verifier'), replicaId: 'verifier', leases, durable, ttlMs: 1_500, marginMs: 400, autoRenew: true,
|
|
188
|
+
});
|
|
189
|
+
const logs = readLogs();
|
|
190
|
+
eq(logs.filter(e => e.t === 'start').length, nextReplica - 1, 'every replica logged its start');
|
|
191
|
+
const gracefulEpochs = new Map();
|
|
192
|
+
for (const entry of logs) {
|
|
193
|
+
if (entry.t === 'event' && entry.event === 'released' && entry.flushed === true && entry.key !== undefined && entry.epoch !== undefined) {
|
|
194
|
+
gracefulEpochs.set(entry.key, (gracefulEpochs.get(entry.key) ?? new Set()).add(entry.epoch));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
let totalAcks = 0;
|
|
198
|
+
let totalLost = 0;
|
|
199
|
+
let checkedLinks = 0;
|
|
200
|
+
for (const { label, tenant, key } of TENANTS) {
|
|
201
|
+
let handle = null;
|
|
202
|
+
for (let waited = 0; handle === null; waited += 150) {
|
|
203
|
+
try {
|
|
204
|
+
handle = await verifier.openTenant(ORG, tenant);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (!(error instanceof LeaseHeldError) || waited > 15_000)
|
|
208
|
+
throw error;
|
|
209
|
+
await sleep(150);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// I1: consecutive epochs, one grant each.
|
|
213
|
+
const grants = leases.history(key).filter(entry => entry.op === 'acquire');
|
|
214
|
+
eq(grants.map(entry => entry.epoch), grants.map((_, index) => index + 1), `I1 tenant ${label}: lease epochs are consecutive`);
|
|
215
|
+
ok(grants.length >= (label === 'a' ? 5 : 3), `tenant ${label} changed hands at least ${label === 'a' ? 5 : 3} times (${grants.length} grants)`);
|
|
216
|
+
const grantedAt = new Map(grants.map(entry => [entry.epoch, entry.atMs]));
|
|
217
|
+
// I2 and I3: one writer per epoch, and no acknowledgement after the next owner took the lease.
|
|
218
|
+
const acks = logs.filter(e => e.t === 'ack' && e.tenant === label);
|
|
219
|
+
const writers = new Map();
|
|
220
|
+
for (const ack of acks)
|
|
221
|
+
writers.set(ack.epoch, (writers.get(ack.epoch) ?? new Set()).add(ack.replica));
|
|
222
|
+
for (const [epoch, who] of writers)
|
|
223
|
+
ok(who.size === 1, `I2 tenant ${label} epoch ${epoch}: written by ${[...who].join(', ')}`);
|
|
224
|
+
for (const ack of acks) {
|
|
225
|
+
const next = grantedAt.get(ack.epoch + 1);
|
|
226
|
+
if (next !== undefined)
|
|
227
|
+
ok(ack.at < next, `I3 tenant ${label}: ack of epoch ${ack.epoch} #${ack.n} at ${ack.at} is before the next grant at ${next}`);
|
|
228
|
+
}
|
|
229
|
+
// I4: the lineage described by the durable copies, and the ledger equal to it.
|
|
230
|
+
const restored = handle.restoredFrom;
|
|
231
|
+
assert.ok(restored, `tenant ${label} has a durable lineage to restore`);
|
|
232
|
+
const chain = [];
|
|
233
|
+
for (let cursor = restored; cursor !== null;) {
|
|
234
|
+
chain.unshift({ epoch: cursor.epoch, seq: cursor.seq });
|
|
235
|
+
const baseBytes = await durable.get(`${key}/e${String(cursor.epoch).padStart(8, '0')}/base.json`);
|
|
236
|
+
assert.ok(baseBytes, `epoch ${cursor.epoch} pinned its base`);
|
|
237
|
+
const base = JSON.parse(Buffer.from(baseBytes).toString('utf8'));
|
|
238
|
+
cursor = base.from;
|
|
239
|
+
}
|
|
240
|
+
const startsByEpoch = new Map();
|
|
241
|
+
for (const entry of logs) {
|
|
242
|
+
if (entry.t === 'snap-start' && entry.tenant === label)
|
|
243
|
+
startsByEpoch.set(entry.epoch, [...startsByEpoch.get(entry.epoch) ?? [], entry.covered]);
|
|
244
|
+
}
|
|
245
|
+
const expected = new Set();
|
|
246
|
+
const writesInChain = new Map();
|
|
247
|
+
let previousEvents = 0;
|
|
248
|
+
for (const link of chain) {
|
|
249
|
+
const manifestBytes = await durable.get(`${key}/e${String(link.epoch).padStart(8, '0')}/snap-${String(link.seq).padStart(6, '0')}.json`);
|
|
250
|
+
assert.ok(manifestBytes, `the manifest of e${link.epoch}/snap-${link.seq} exists`);
|
|
251
|
+
const manifest = JSON.parse(Buffer.from(manifestBytes).toString('utf8'));
|
|
252
|
+
const written = manifest.events - previousEvents;
|
|
253
|
+
previousEvents = manifest.events;
|
|
254
|
+
writesInChain.set(link.epoch, written);
|
|
255
|
+
const covered = startsByEpoch.get(link.epoch)?.[link.seq - 1];
|
|
256
|
+
if (covered !== undefined) {
|
|
257
|
+
eq(written, covered, `I4 tenant ${label}: e${link.epoch}/snap-${link.seq} holds exactly the ${covered} writes acknowledged before it began`);
|
|
258
|
+
checkedLinks += 1;
|
|
259
|
+
}
|
|
260
|
+
for (let n = 1; n <= written; n += 1)
|
|
261
|
+
expected.add(`${label}:${link.epoch}:${n}`);
|
|
262
|
+
}
|
|
263
|
+
const present = handle.run(ledger => ledger.db.prepare("SELECT idem_key FROM events WHERE kind = 'tenant-state.drill'").all().map(row => row.idem_key));
|
|
264
|
+
eq(new Set(present), expected, `I4/I6 tenant ${label}: the ledger holds exactly the lineage, no write of another epoch, replica or tenant`);
|
|
265
|
+
eq(present.length, expected.size, `I4 tenant ${label}: no event twice`);
|
|
266
|
+
// I5: graceful epochs lose nothing; crashed epochs lose only the tail after their last pinned snapshot.
|
|
267
|
+
for (const epoch of gracefulEpochs.get(key) ?? []) {
|
|
268
|
+
eq(writesInChain.get(epoch) ?? 0, acksIn(label, epoch), `I5 tenant ${label}: graceful epoch ${epoch} lost no acknowledged write`);
|
|
269
|
+
}
|
|
270
|
+
let lost = 0;
|
|
271
|
+
for (const [epoch, who] of writers) {
|
|
272
|
+
void who;
|
|
273
|
+
const tail = acksIn(label, epoch) - (writesInChain.get(epoch) ?? 0);
|
|
274
|
+
ok(tail >= 0, `I5 tenant ${label} epoch ${epoch}: the lineage holds no more than was acknowledged`);
|
|
275
|
+
ok(tail <= 400, `I5 tenant ${label} epoch ${epoch}: the lost tail (${tail}) stays within a few snapshot intervals`);
|
|
276
|
+
lost += tail;
|
|
277
|
+
}
|
|
278
|
+
totalAcks += acks.length;
|
|
279
|
+
totalLost += lost;
|
|
280
|
+
console.log(` ok - tenant ${label}: ${grants.length} epochs, ${acks.length} acknowledged writes, lineage of ${chain.length} snapshots with ${expected.size} events, ${lost} acknowledged writes in crashed tails`);
|
|
281
|
+
}
|
|
282
|
+
await verifier.close();
|
|
283
|
+
ok(checkedLinks > 0, `I4 the per-link check ran on ${checkedLinks} snapshots of the lineages, it is not skipped for want of a log`);
|
|
284
|
+
const viewAcks = logs.filter(e => e.t === 'ack' && e.via === 'view').length;
|
|
285
|
+
const viewRefusals = logs.filter(e => e.t === 'refused' && e.via === 'view');
|
|
286
|
+
ok(viewAcks > 0, `I5 a consumer that kept the ledger wrote ${viewAcks} acknowledged events through it while its replica handed off, and none of them is missing from the final images`);
|
|
287
|
+
ok(viewRefusals.length > 0 && viewRefusals.every(e => (e.code ?? '').startsWith('GOTRY_TENANT_')), `I5 every refusal of that consumer is a typed tenant error (${viewRefusals.length} refusals), and not an untyped failure`);
|
|
288
|
+
ok(viewRefusals.some(e => e.code === 'GOTRY_TENANT_DRAINING'), 'I5 at least one of them is the draining error: once the final image was being taken, the view said so (a replica starved past its lease may be fenced instead)');
|
|
289
|
+
const killed = killedEpochs.length;
|
|
290
|
+
console.log(`tenant-state-drill-tests: ${passed} assertions passed (${killed} SIGKILLs, 1 SIGSTOP past the lease, 1 graceful handoff, ${totalAcks} acknowledged writes, ${totalLost} lost in crashed tails)`);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
clearTimeout(deadline);
|
|
294
|
+
for (const replica of replicas.values())
|
|
295
|
+
replica.child.kill('SIGKILL');
|
|
296
|
+
leases.close();
|
|
297
|
+
rmSync(root, { recursive: true, force: true });
|
|
298
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child process of tenant-state-drill-tests.ts: one replica serving two tenants through a TenantRouter.
|
|
3
|
+
*
|
|
4
|
+
* argv: <state root> <lease db> <durable dir> <log dir> <replica id> <organization> <tenant a> <tenant b>
|
|
5
|
+
*
|
|
6
|
+
* The replica keeps trying to own both tenants. For each tenant it holds, it writes one numbered event at a
|
|
7
|
+
* time and publishes a snapshot every 120 ms. It logs, before and after every step, what it is about to do and
|
|
8
|
+
* what was acknowledged, in a file that survives its own death, so the drill can compare the final ledger with
|
|
9
|
+
* what the replica believed. On SIGTERM it hands every tenant off gracefully and exits; while it does, a consumer that
|
|
10
|
+
* keeps the ledger it was handed (a booking runtime does) goes on writing through it, inside a `runAsync` bracket the
|
|
11
|
+
* drain has to wait for, and then tries once more after the bracket, when the view has to refuse.
|
|
12
|
+
*
|
|
13
|
+
* TENANT_DRILL_SLOW_PUT_MS makes the object store slow to take an image, as a real one can be.
|
|
14
|
+
*/
|
|
15
|
+
import { closeSync, openSync, writeSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
18
|
+
import { DirDurableStore } from '../src/tenant-state/durable-store.js';
|
|
19
|
+
import { LeaseHeldError } from '../src/tenant-state/errors.js';
|
|
20
|
+
import { SqliteLeaseStore } from '../src/tenant-state/lease-store.js';
|
|
21
|
+
import { TenantRouter } from '../src/tenant-state/tenant-router.js';
|
|
22
|
+
const [stateRoot, leaseDb, durableDir, logDir, replicaId, organization, tenantA, tenantB] = process.argv.slice(2);
|
|
23
|
+
const SNAPSHOT_EVERY_MS = 120;
|
|
24
|
+
const RETRY_AFTER_HELD_MS = 150;
|
|
25
|
+
const DRAIN_WRITER_MS = 150;
|
|
26
|
+
const SLOW_PUT_MS = Number(process.env.TENANT_DRILL_SLOW_PUT_MS ?? 0);
|
|
27
|
+
/** The reference store with a delay before every image it takes, so an upload is a window and not an instant. */
|
|
28
|
+
class SlowImages {
|
|
29
|
+
inner;
|
|
30
|
+
ms;
|
|
31
|
+
constructor(inner, ms) {
|
|
32
|
+
this.inner = inner;
|
|
33
|
+
this.ms = ms;
|
|
34
|
+
}
|
|
35
|
+
async putIfAbsent(key, bytes) {
|
|
36
|
+
if (key.endsWith('.db'))
|
|
37
|
+
await sleep(this.ms);
|
|
38
|
+
return this.inner.putIfAbsent(key, bytes);
|
|
39
|
+
}
|
|
40
|
+
get(key) { return this.inner.get(key); }
|
|
41
|
+
list(prefix) { return this.inner.list(prefix); }
|
|
42
|
+
deletePrefix(prefix) { return this.inner.deletePrefix(prefix); }
|
|
43
|
+
}
|
|
44
|
+
const log = openSync(join(logDir, `${replicaId}.jsonl`), 'a');
|
|
45
|
+
function record(entry) {
|
|
46
|
+
writeSync(log, `${JSON.stringify({ ...entry, replica: replicaId, wall: Date.now() })}\n`);
|
|
47
|
+
}
|
|
48
|
+
const router = new TenantRouter({
|
|
49
|
+
stateRoot,
|
|
50
|
+
replicaId,
|
|
51
|
+
leases: new SqliteLeaseStore({ path: leaseDb }),
|
|
52
|
+
durable: SLOW_PUT_MS > 0 ? new SlowImages(new DirDurableStore(durableDir), SLOW_PUT_MS) : new DirDurableStore(durableDir),
|
|
53
|
+
ttlMs: 1_500,
|
|
54
|
+
marginMs: 400,
|
|
55
|
+
autoRenew: true,
|
|
56
|
+
renewEveryMs: 300,
|
|
57
|
+
onEvent: (event) => { record({ t: 'event', event: event.type, key: event.key, epoch: event.epoch, flushed: 'flushed' in event ? event.flushed : undefined }); },
|
|
58
|
+
});
|
|
59
|
+
const seats = [
|
|
60
|
+
{ label: 'a', tenant: tenantA, handle: null, epoch: 0, next: 1, nextTryAt: 0, lastSnapshotAt: 0 },
|
|
61
|
+
{ label: 'b', tenant: tenantB, handle: null, epoch: 0, next: 1, nextTryAt: 0, lastSnapshotAt: 0 },
|
|
62
|
+
];
|
|
63
|
+
let stopping = false;
|
|
64
|
+
process.on('SIGTERM', () => { stopping = true; });
|
|
65
|
+
record({ t: 'start', pid: process.pid });
|
|
66
|
+
while (!stopping) {
|
|
67
|
+
for (const seat of seats) {
|
|
68
|
+
if (!seat.handle?.isOpen()) {
|
|
69
|
+
seat.handle = null;
|
|
70
|
+
if (Date.now() < seat.nextTryAt)
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
const handle = await router.openTenant(organization, seat.tenant);
|
|
74
|
+
seat.handle = handle;
|
|
75
|
+
seat.epoch = handle.epoch;
|
|
76
|
+
seat.next = 1;
|
|
77
|
+
seat.lastSnapshotAt = Date.now();
|
|
78
|
+
record({ t: 'open', tenant: seat.label, epoch: handle.epoch, from: handle.restoredFrom });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
seat.nextTryAt = Date.now() + RETRY_AFTER_HELD_MS;
|
|
82
|
+
if (!(error instanceof LeaseHeldError))
|
|
83
|
+
record({ t: 'open-failed', tenant: seat.label, code: error.code ?? String(error) });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const handle = seat.handle;
|
|
88
|
+
const n = seat.next;
|
|
89
|
+
try {
|
|
90
|
+
let at = 0;
|
|
91
|
+
handle.run((ledger) => {
|
|
92
|
+
at = Date.now();
|
|
93
|
+
ledger.insertEvent({ actor: 'drill', kind: 'tenant-state.drill', payload: { n }, idemKey: `${seat.label}:${seat.epoch}:${n}` });
|
|
94
|
+
});
|
|
95
|
+
record({ t: 'ack', tenant: seat.label, epoch: seat.epoch, n, at });
|
|
96
|
+
seat.next += 1;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
record({ t: 'refused', tenant: seat.label, epoch: seat.epoch, n, code: error.code ?? String(error) });
|
|
100
|
+
await sleep(20);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (Date.now() - seat.lastSnapshotAt >= SNAPSHOT_EVERY_MS) {
|
|
104
|
+
// Nothing else writes in this process, so the image holds exactly the writes acknowledged so far.
|
|
105
|
+
const covered = seat.next - 1;
|
|
106
|
+
record({ t: 'snap-start', tenant: seat.label, epoch: seat.epoch, covered });
|
|
107
|
+
try {
|
|
108
|
+
const manifest = await handle.snapshot();
|
|
109
|
+
record({ t: 'snap', tenant: seat.label, epoch: seat.epoch, seq: manifest.seq, covered });
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
record({ t: 'snap-failed', tenant: seat.label, epoch: seat.epoch, code: error.code ?? String(error) });
|
|
113
|
+
}
|
|
114
|
+
seat.lastSnapshotAt = Date.now();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
await sleep(4);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The long-lived consumer. Inside a `runAsync` bracket it writes through the ledger for a while, which the drain has to
|
|
121
|
+
* wait for and the final image has to hold; once the bracket is over it tries the view again, which has to be refused
|
|
122
|
+
* as soon as the image is being taken. Every acknowledged write is logged like the loop's own, so the verification
|
|
123
|
+
* counts them the same way.
|
|
124
|
+
*/
|
|
125
|
+
async function drainingWriter(seat) {
|
|
126
|
+
const handle = seat.handle;
|
|
127
|
+
const note = (entry) => record({ ...entry, tenant: seat.label, epoch: seat.epoch, via: 'view' });
|
|
128
|
+
const until = Date.now() + DRAIN_WRITER_MS;
|
|
129
|
+
try {
|
|
130
|
+
await handle.runAsync(async (ledger) => {
|
|
131
|
+
while (Date.now() < until) {
|
|
132
|
+
const n = seat.next;
|
|
133
|
+
ledger.insertEvent({ actor: 'drill', kind: 'tenant-state.drill', payload: { n }, idemKey: `${seat.label}:${seat.epoch}:${n}` });
|
|
134
|
+
note({ t: 'ack', n, at: Date.now() });
|
|
135
|
+
seat.next += 1;
|
|
136
|
+
await sleep(3);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
note({ t: 'refused', n: seat.next, code: error.code ?? String(error) });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
145
|
+
const n = seat.next;
|
|
146
|
+
try {
|
|
147
|
+
handle.ledger.insertEvent({ actor: 'drill', kind: 'tenant-state.drill', payload: { n }, idemKey: `${seat.label}:${seat.epoch}:${n}` });
|
|
148
|
+
note({ t: 'ack', n, at: Date.now() });
|
|
149
|
+
seat.next += 1;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
note({ t: 'refused', n, code: error.code ?? String(error) });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
await sleep(2);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
record({ t: 'draining' });
|
|
159
|
+
const writers = seats.filter(seat => seat.handle?.isOpen()).map(seat => drainingWriter(seat));
|
|
160
|
+
const failed = await router.close();
|
|
161
|
+
await Promise.all(writers);
|
|
162
|
+
record({ t: 'closed', failed });
|
|
163
|
+
closeSync(log);
|
|
164
|
+
process.exit(0);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance checks for any DurableStore binding (AaaS A1, docs/rfc/aaas-rfc.md §4.3).
|
|
3
|
+
*
|
|
4
|
+
* The reference directory store runs them offline. The S3-compatible binding of decision Q4 must pass
|
|
5
|
+
* the same checks before it holds a real copy. A failed check is returned, not thrown, so a suite can
|
|
6
|
+
* also prove the checks themselves can fail against deliberately broken stores.
|
|
7
|
+
*/
|
|
8
|
+
class Violation extends Error {
|
|
9
|
+
}
|
|
10
|
+
function expect(condition, detail) {
|
|
11
|
+
if (!condition)
|
|
12
|
+
throw new Violation(detail);
|
|
13
|
+
}
|
|
14
|
+
function text(bytes) {
|
|
15
|
+
return bytes === null ? null : Buffer.from(bytes).toString('utf8');
|
|
16
|
+
}
|
|
17
|
+
async function expectRangeError(run, detail) {
|
|
18
|
+
try {
|
|
19
|
+
await run();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof RangeError)
|
|
23
|
+
return;
|
|
24
|
+
throw new Violation(`${detail}: threw ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`);
|
|
25
|
+
}
|
|
26
|
+
throw new Violation(`${detail}: did not throw`);
|
|
27
|
+
}
|
|
28
|
+
export async function runDurableStoreConformance(store, namespace) {
|
|
29
|
+
const failures = [];
|
|
30
|
+
let sequence = 0;
|
|
31
|
+
async function check(name, body) {
|
|
32
|
+
sequence += 1;
|
|
33
|
+
try {
|
|
34
|
+
await body(`${namespace}/check-${sequence}`);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
failures.push({ check: name, detail: error instanceof Error ? error.message : String(error) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await check('put creates an absent key and never overwrites an existing one', async (prefix) => {
|
|
41
|
+
expect(await store.putIfAbsent(`${prefix}/a`, Buffer.from('first')) === true, 'the first put creates');
|
|
42
|
+
expect(await store.putIfAbsent(`${prefix}/a`, Buffer.from('second')) === false, 'the second put reports the key exists');
|
|
43
|
+
expect(text(await store.get(`${prefix}/a`)) === 'first', 'the first bytes survive');
|
|
44
|
+
});
|
|
45
|
+
await check('an unknown key reads as null', async (prefix) => {
|
|
46
|
+
expect(await store.get(`${prefix}/missing`) === null, 'get of an unknown key');
|
|
47
|
+
});
|
|
48
|
+
await check('bytes round-trip exactly, including a large object', async (prefix) => {
|
|
49
|
+
const payload = Buffer.alloc(1_048_576 + 3);
|
|
50
|
+
for (let index = 0; index < payload.length; index += 1)
|
|
51
|
+
payload[index] = (index * 31 + 7) & 0xff;
|
|
52
|
+
expect(await store.putIfAbsent(`${prefix}/big.bin`, payload) === true, 'put');
|
|
53
|
+
const back = await store.get(`${prefix}/big.bin`);
|
|
54
|
+
expect(back !== null && Buffer.compare(Buffer.from(back), payload) === 0, 'the same 1 MiB + 3 bytes come back');
|
|
55
|
+
});
|
|
56
|
+
await check('list returns sorted keys under a prefix, including a partial last segment', async (prefix) => {
|
|
57
|
+
for (const name of ['snap-2.db', 'snap-1.db', 'snap-1.json', 'base.json'])
|
|
58
|
+
await store.putIfAbsent(`${prefix}/e1/${name}`, Buffer.from(name));
|
|
59
|
+
await store.putIfAbsent(`${prefix}/e2/snap-1.db`, Buffer.from('x'));
|
|
60
|
+
const all = await store.list(`${prefix}/`);
|
|
61
|
+
expect(all.length === 5, `all five under the prefix, got ${all.length}`);
|
|
62
|
+
expect(JSON.stringify(all) === JSON.stringify([...all].sort()), 'sorted');
|
|
63
|
+
const partial = await store.list(`${prefix}/e1/snap-`);
|
|
64
|
+
expect(JSON.stringify(partial) === JSON.stringify([`${prefix}/e1/snap-1.db`, `${prefix}/e1/snap-1.json`, `${prefix}/e1/snap-2.db`]), `partial prefix: ${JSON.stringify(partial)}`);
|
|
65
|
+
expect((await store.list(`${prefix}/nothing/`)).length === 0, 'an empty prefix lists nothing');
|
|
66
|
+
});
|
|
67
|
+
await check('delete removes exactly the keys under the prefix and counts them', async (prefix) => {
|
|
68
|
+
await store.putIfAbsent(`${prefix}/keep/a`, Buffer.from('a'));
|
|
69
|
+
for (const name of ['x', 'y', 'z'])
|
|
70
|
+
await store.putIfAbsent(`${prefix}/gone/${name}`, Buffer.from(name));
|
|
71
|
+
expect(await store.deletePrefix(`${prefix}/gone/`) === 3, 'three deleted');
|
|
72
|
+
expect((await store.list(`${prefix}/gone/`)).length === 0, 'nothing left under the prefix');
|
|
73
|
+
expect(text(await store.get(`${prefix}/keep/a`)) === 'a', 'a sibling is untouched');
|
|
74
|
+
expect(await store.deletePrefix(`${prefix}/gone/`) === 0, 'deleting again deletes nothing');
|
|
75
|
+
expect(await store.putIfAbsent(`${prefix}/gone/x`, Buffer.from('again')) === true, 'a deleted key can be created again');
|
|
76
|
+
});
|
|
77
|
+
await check('sixteen concurrent puts of one key create it exactly once with intact bytes', async (prefix) => {
|
|
78
|
+
const contenders = Array.from({ length: 16 }, (_, index) => Buffer.from(`contender-${index}-${'x'.repeat(4096)}`));
|
|
79
|
+
const results = await Promise.all(contenders.map(bytes => store.putIfAbsent(`${prefix}/race`, bytes)));
|
|
80
|
+
expect(results.filter(Boolean).length === 1, `${results.filter(Boolean).length} puts reported creating the key`);
|
|
81
|
+
const stored = await store.get(`${prefix}/race`);
|
|
82
|
+
expect(stored !== null && contenders.some(bytes => Buffer.compare(bytes, Buffer.from(stored)) === 0), 'the stored bytes are exactly one contender');
|
|
83
|
+
});
|
|
84
|
+
await check('keys that could leave the store or touch its scratch area are rejected', async (prefix) => {
|
|
85
|
+
for (const bad of ['', '/', 'a//b', '../x', `${prefix}/../x`, '.tmp/x', `${prefix}/.hidden`, 'a b', 'a/\u0000b']) {
|
|
86
|
+
await expectRangeError(() => store.putIfAbsent(bad, Buffer.from('x')), `put ${JSON.stringify(bad)}`);
|
|
87
|
+
await expectRangeError(() => store.get(bad), `get ${JSON.stringify(bad)}`);
|
|
88
|
+
}
|
|
89
|
+
await expectRangeError(() => store.list('../'), 'list ../');
|
|
90
|
+
await expectRangeError(() => store.deletePrefix(''), 'delete of the empty prefix');
|
|
91
|
+
});
|
|
92
|
+
await check('delete takes whole segments only: a prefix that stops inside a name would also take its neighbours', async (prefix) => {
|
|
93
|
+
await store.putIfAbsent(`${prefix}/org/t1/base.json`, Buffer.from('1'));
|
|
94
|
+
await store.putIfAbsent(`${prefix}/org2/t9/base.json`, Buffer.from('2'));
|
|
95
|
+
await expectRangeError(() => store.deletePrefix(`${prefix}/org`), 'delete of a prefix that ends inside a segment');
|
|
96
|
+
expect((await store.list(`${prefix}/org2/`)).length === 1, 'the neighbour that shares the spelling is untouched');
|
|
97
|
+
expect(await store.deletePrefix(`${prefix}/org/`) === 1, 'the whole segment is deleted');
|
|
98
|
+
expect((await store.list(`${prefix}/org2/`)).length === 1, 'and the neighbour is still there');
|
|
99
|
+
});
|
|
100
|
+
return failures;
|
|
101
|
+
}
|