@danceiny/gotry 0.2.0-rc.32 → 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/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/extension-bridge.js +140 -46
- package/dist/capabilities/session/gui-flight.js +281 -69
- package/dist/capabilities/session/gui-hotel.js +74 -17
- package/dist/capabilities/session-search.js +6 -5
- 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/bridge-ledger-tests.js +20 -18
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +42 -8
- 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/index.js +31 -1
- 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 +4 -1
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/extension-bridge.ts +176 -65
- package/ts/capabilities/session/gui-flight.ts +318 -88
- package/ts/capabilities/session/gui-hotel.ts +72 -18
- package/ts/capabilities/session-search.ts +17 -5
- 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/index.ts +28 -2
- 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,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance checks for any LeaseStore binding (AaaS A1, docs/rfc/aaas-rfc.md §4.2).
|
|
3
|
+
*
|
|
4
|
+
* The reference SQLite store runs them offline against a fake clock. The hotel-be
|
|
5
|
+
* MySQL binding of decision Q3 must pass the same checks before it holds a real
|
|
6
|
+
* lease, with `advance` backed by a real sleep. A failed check is returned, not
|
|
7
|
+
* thrown, so a suite can also prove the checks themselves can fail by running them
|
|
8
|
+
* against deliberately broken stores.
|
|
9
|
+
*/
|
|
10
|
+
import { LeaseHeldError, LeaseLostError } from '../src/tenant-state/errors.js';
|
|
11
|
+
const TTL = 1_000;
|
|
12
|
+
class Violation extends Error {
|
|
13
|
+
}
|
|
14
|
+
function expect(condition, detail) {
|
|
15
|
+
if (!condition)
|
|
16
|
+
throw new Violation(detail);
|
|
17
|
+
}
|
|
18
|
+
async function expectRejects(run, type, detail) {
|
|
19
|
+
try {
|
|
20
|
+
await run();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof type)
|
|
24
|
+
return error;
|
|
25
|
+
throw new Violation(`${detail}: threw ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`);
|
|
26
|
+
}
|
|
27
|
+
throw new Violation(`${detail}: did not throw`);
|
|
28
|
+
}
|
|
29
|
+
export async function runLeaseStoreConformance(subject, namespace) {
|
|
30
|
+
const { store } = subject;
|
|
31
|
+
const failures = [];
|
|
32
|
+
let sequence = 0;
|
|
33
|
+
async function check(name, body) {
|
|
34
|
+
sequence += 1;
|
|
35
|
+
try {
|
|
36
|
+
await body(`${namespace}/check-${sequence}`);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
failures.push({ check: name, detail: error instanceof Error ? error.message : String(error) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await check('first acquire grants epoch 1', async (key) => {
|
|
43
|
+
expect(await store.read(key) === null, 'an unknown key reads as null');
|
|
44
|
+
const grant = await store.acquire(key, 'a', TTL);
|
|
45
|
+
expect(grant.record.epoch === 1, `epoch ${grant.record.epoch}`);
|
|
46
|
+
expect(grant.record.owner === 'a' && grant.record.key === key, 'owner and key echo the request');
|
|
47
|
+
expect(grant.ttlMs === TTL, `granted ttl ${grant.ttlMs}`);
|
|
48
|
+
expect(grant.record.leaseUntilMs > subject.now(), 'the lease ends in the future');
|
|
49
|
+
expect(grant.record.forgottenAtEpoch === 0, 'nothing is forgotten yet');
|
|
50
|
+
expect(JSON.stringify(await store.read(key)) === JSON.stringify(grant.record), 'read returns the granted record');
|
|
51
|
+
});
|
|
52
|
+
await check('acquire is refused while another unexpired lease exists', async (key) => {
|
|
53
|
+
await store.acquire(key, 'a', TTL);
|
|
54
|
+
const held = await expectRejects(() => store.acquire(key, 'b', TTL), LeaseHeldError, 'a second owner must be refused');
|
|
55
|
+
expect(held.record.owner === 'a' && held.record.epoch === 1, 'the refusal names the current holder');
|
|
56
|
+
});
|
|
57
|
+
await check('acquire is refused for the current holder too', async (key) => {
|
|
58
|
+
await store.acquire(key, 'a', TTL);
|
|
59
|
+
await expectRejects(() => store.acquire(key, 'a', TTL), LeaseHeldError, 'a holder must renew, not re-acquire');
|
|
60
|
+
});
|
|
61
|
+
await check('renew keeps the epoch and extends the lease', async (key) => {
|
|
62
|
+
const first = await store.acquire(key, 'a', TTL);
|
|
63
|
+
await subject.advance(TTL / 2);
|
|
64
|
+
const renewed = await store.renew(key, 'a', 1, TTL);
|
|
65
|
+
expect(renewed.record.epoch === 1, `epoch ${renewed.record.epoch}`);
|
|
66
|
+
expect(renewed.record.leaseUntilMs > first.record.leaseUntilMs, 'the lease end moved forward');
|
|
67
|
+
});
|
|
68
|
+
await check('renew by another owner or for another epoch is refused', async (key) => {
|
|
69
|
+
await store.acquire(key, 'a', TTL);
|
|
70
|
+
await expectRejects(() => store.renew(key, 'b', 1, TTL), LeaseLostError, 'foreign owner');
|
|
71
|
+
await expectRejects(() => store.renew(key, 'a', 2, TTL), LeaseLostError, 'foreign epoch');
|
|
72
|
+
await expectRejects(() => store.renew(`${key}-unknown`, 'a', 1, TTL), LeaseLostError, 'unknown key');
|
|
73
|
+
await store.renew(key, 'a', 1, TTL);
|
|
74
|
+
});
|
|
75
|
+
await check('an expired lease is taken at epoch + 1 and the old holder is refused everywhere', async (key) => {
|
|
76
|
+
await store.acquire(key, 'a', TTL);
|
|
77
|
+
await subject.advance(TTL + 1);
|
|
78
|
+
const taken = await store.acquire(key, 'b', TTL);
|
|
79
|
+
expect(taken.record.epoch === 2 && taken.record.owner === 'b', `epoch ${taken.record.epoch} owner ${taken.record.owner}`);
|
|
80
|
+
await expectRejects(() => store.renew(key, 'a', 1, TTL), LeaseLostError, 'stale renew');
|
|
81
|
+
await expectRejects(() => store.release(key, 'a', 1), LeaseLostError, 'stale release');
|
|
82
|
+
await expectRejects(() => store.markForgotten(key, 'a', 1), LeaseLostError, 'stale forget');
|
|
83
|
+
await store.renew(key, 'b', 2, TTL);
|
|
84
|
+
});
|
|
85
|
+
await check('renewing after expiry is refused even when nobody took over', async (key) => {
|
|
86
|
+
await store.acquire(key, 'a', TTL);
|
|
87
|
+
await subject.advance(TTL + 1);
|
|
88
|
+
await expectRejects(() => store.renew(key, 'a', 1, TTL), LeaseLostError, 'an expired lease cannot be revived');
|
|
89
|
+
const again = await store.acquire(key, 'a', TTL);
|
|
90
|
+
expect(again.record.epoch === 2, `re-acquire after expiry starts epoch ${again.record.epoch}`);
|
|
91
|
+
});
|
|
92
|
+
await check('release expires the lease at once and keeps the epoch', async (key) => {
|
|
93
|
+
await store.acquire(key, 'a', TTL);
|
|
94
|
+
const released = await store.release(key, 'a', 1);
|
|
95
|
+
expect(released.epoch === 1, `epoch ${released.epoch}`);
|
|
96
|
+
expect(released.leaseUntilMs <= subject.now(), 'the lease is already over');
|
|
97
|
+
const next = await store.acquire(key, 'b', TTL);
|
|
98
|
+
expect(next.record.epoch === 2, `the next owner starts epoch ${next.record.epoch} with no waiting`);
|
|
99
|
+
});
|
|
100
|
+
await check('a stale release changes nothing', async (key) => {
|
|
101
|
+
await store.acquire(key, 'a', TTL);
|
|
102
|
+
await subject.advance(TTL + 1);
|
|
103
|
+
await store.acquire(key, 'b', TTL);
|
|
104
|
+
await expectRejects(() => store.release(key, 'a', 1), LeaseLostError, 'stale release');
|
|
105
|
+
const held = await expectRejects(() => store.acquire(key, 'c', TTL), LeaseHeldError, 'b still holds');
|
|
106
|
+
expect(held.record.owner === 'b' && held.record.epoch === 2, 'the stale release did not touch the live lease');
|
|
107
|
+
});
|
|
108
|
+
await check('epochs strictly increase and never repeat', async (key) => {
|
|
109
|
+
const epochs = [];
|
|
110
|
+
for (let round = 0; round < 6; round += 1) {
|
|
111
|
+
const owner = round % 2 === 0 ? 'a' : 'b';
|
|
112
|
+
const grant = await store.acquire(key, owner, TTL);
|
|
113
|
+
epochs.push(grant.record.epoch);
|
|
114
|
+
if (round % 3 === 2)
|
|
115
|
+
await store.release(key, owner, grant.record.epoch);
|
|
116
|
+
else
|
|
117
|
+
await subject.advance(TTL + 1);
|
|
118
|
+
}
|
|
119
|
+
expect(epochs.join(',') === '1,2,3,4,5,6', `epochs ${epochs.join(',')}`);
|
|
120
|
+
});
|
|
121
|
+
await check('forget records a floor that survives the next acquisition', async (key) => {
|
|
122
|
+
await store.acquire(key, 'a', TTL);
|
|
123
|
+
await expectRejects(() => store.markForgotten(key, 'b', 1), LeaseLostError, 'only the holder may forget');
|
|
124
|
+
const marked = await store.markForgotten(key, 'a', 1);
|
|
125
|
+
expect(marked.forgottenAtEpoch === 1, `floor ${marked.forgottenAtEpoch}`);
|
|
126
|
+
await store.release(key, 'a', 1);
|
|
127
|
+
const next = await store.acquire(key, 'b', TTL);
|
|
128
|
+
expect(next.record.epoch === 2 && next.record.forgottenAtEpoch === 1, `epoch ${next.record.epoch} floor ${next.record.forgottenAtEpoch}`);
|
|
129
|
+
});
|
|
130
|
+
await check('forget needs a live lease', async (key) => {
|
|
131
|
+
await store.acquire(key, 'a', TTL);
|
|
132
|
+
await subject.advance(TTL + 1);
|
|
133
|
+
await expectRejects(() => store.markForgotten(key, 'a', 1), LeaseLostError, 'an expired holder cannot forget');
|
|
134
|
+
});
|
|
135
|
+
await check('ttl outside the bounds is rejected', async (key) => {
|
|
136
|
+
await expectRejects(() => store.acquire(key, 'a', 1), RangeError, 'ttl below the minimum');
|
|
137
|
+
await expectRejects(() => store.acquire(key, 'a', 24 * 3_600_000), RangeError, 'ttl above the maximum');
|
|
138
|
+
await expectRejects(() => store.acquire(key, 'a', 1_000.5), RangeError, 'fractional ttl');
|
|
139
|
+
expect(await store.read(key) === null, 'a rejected request leaves no record');
|
|
140
|
+
});
|
|
141
|
+
return failures;
|
|
142
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child process of tenant-state-tests.ts: races one lease acquisition against its
|
|
3
|
+
* sibling processes on a shared SQLite lease file.
|
|
4
|
+
*
|
|
5
|
+
* argv: <lease db path> <go file> <lease key> <replica id> <ttl ms>
|
|
6
|
+
* Prints "ready", waits for the go file, then prints one JSON line with the outcome.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
10
|
+
import { LeaseHeldError } from '../src/tenant-state/errors.js';
|
|
11
|
+
import { SqliteLeaseStore } from '../src/tenant-state/lease-store.js';
|
|
12
|
+
const [dbPath, goFile, key, replicaId, ttl] = process.argv.slice(2);
|
|
13
|
+
const store = new SqliteLeaseStore({ path: dbPath });
|
|
14
|
+
process.stdout.write('ready\n');
|
|
15
|
+
while (!existsSync(goFile))
|
|
16
|
+
await sleep(2);
|
|
17
|
+
let outcome;
|
|
18
|
+
try {
|
|
19
|
+
const grant = await store.acquire(key, replicaId, Number(ttl));
|
|
20
|
+
outcome = { replicaId, outcome: 'granted', epoch: grant.record.epoch };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
outcome = error instanceof LeaseHeldError
|
|
24
|
+
? { replicaId, outcome: 'held', epoch: error.record.epoch, owner: error.record.owner }
|
|
25
|
+
: { replicaId, outcome: 'error', message: String(error) };
|
|
26
|
+
}
|
|
27
|
+
process.stdout.write(`${JSON.stringify(outcome)}\n`);
|
|
28
|
+
store.close();
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant router and tenant-bound handle (AaaS A1.3, docs/rfc/aaas-rfc.md §4.2 items 5 and 6, §4.4; threats S-1, S-2, S-6).
|
|
3
|
+
*
|
|
4
|
+
* Offline and deterministic: a directory stands in for the object store, SQLite lease files for the lease
|
|
5
|
+
* store, fake clocks for expiry, so every replica is a router over its own state root in one process. Two
|
|
6
|
+
* checks use real timers (background renewal, periodic snapshots). No network, no model, and the
|
|
7
|
+
* founder's runtime state root is never opened.
|
|
8
|
+
*
|
|
9
|
+
* T opening: one handle per tenant, a tenant another replica owns is refused with its owner named,
|
|
10
|
+
* bad ids are refused before any store or file is touched, tenants are isolated from each other.
|
|
11
|
+
* G the guard: once the lease is not provably held, every call on the handle's ledger fails, down to
|
|
12
|
+
* a statement prepared earlier; a write is acknowledged only if the lease holds before and after.
|
|
13
|
+
* H handoff: graceful (zero loss, the next owner starts at once), crash (the declared RPO), a failed
|
|
14
|
+
* flush that keeps serving, a drain that lets in-flight work finish, abandon, forget, router close.
|
|
15
|
+
* L lifecycle: loss, background renewal, periodic snapshots, events without personal data.
|
|
16
|
+
*
|
|
17
|
+
* Run: cd ts && npx tsx scripts/tenant-state-router-tests.ts
|
|
18
|
+
*/
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
24
|
+
import { DirDurableStore } from '../src/tenant-state/durable-store.js';
|
|
25
|
+
import { DurableStoreUnavailableError, FencedError, InvalidTenantIdError, LeaseHeldError, LeaseLostError, RestoreVerificationError, RouterClosedError, TenantDrainingError, } from '../src/tenant-state/errors.js';
|
|
26
|
+
import { deriveLedgerTenantId, leaseKeyFor, tenantRootFor } from '../src/tenant-state/ids.js';
|
|
27
|
+
import { SqliteLeaseStore } from '../src/tenant-state/lease-store.js';
|
|
28
|
+
import { TenantRouter } from '../src/tenant-state/tenant-router.js';
|
|
29
|
+
import { ledgerDbPath } from '../src/state-ledger.js';
|
|
30
|
+
import { FaultyDurableStore, ManualClock } from './tenant-state-test-kit.js';
|
|
31
|
+
const ORG = 'acme';
|
|
32
|
+
const ORG_KEY = Buffer.alloc(32, 5);
|
|
33
|
+
const ISSUER = 'https://idp.example/acme';
|
|
34
|
+
const TENANT_A = deriveLedgerTenantId(ORG_KEY, ISSUER, 'user-1');
|
|
35
|
+
const TENANT_B = deriveLedgerTenantId(ORG_KEY, ISSUER, 'user-2');
|
|
36
|
+
const KEY_A = leaseKeyFor(ORG, TENANT_A);
|
|
37
|
+
const TTL = 10_000;
|
|
38
|
+
const MARGIN = 2_000;
|
|
39
|
+
let passed = 0;
|
|
40
|
+
async function check(label, assertion) {
|
|
41
|
+
await assertion();
|
|
42
|
+
passed += 1;
|
|
43
|
+
console.log(` ok - ${label}`);
|
|
44
|
+
}
|
|
45
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-tenant-router-'));
|
|
46
|
+
const cleanups = [];
|
|
47
|
+
function makeFleet(name) {
|
|
48
|
+
const dir = join(root, name);
|
|
49
|
+
const storeClock = new ManualClock(1_000_000);
|
|
50
|
+
const leases = new SqliteLeaseStore({ path: join(dir, 'leases.db'), nowMs: () => storeClock.nowMs() });
|
|
51
|
+
const store = new DirDurableStore(join(dir, 'durable'));
|
|
52
|
+
const durable = new FaultyDurableStore(store);
|
|
53
|
+
const clocks = [];
|
|
54
|
+
cleanups.push(() => leases.close());
|
|
55
|
+
return {
|
|
56
|
+
dir,
|
|
57
|
+
leases,
|
|
58
|
+
storeClock,
|
|
59
|
+
store,
|
|
60
|
+
durable,
|
|
61
|
+
replica(id, options = {}) {
|
|
62
|
+
const clock = new ManualClock(5_000);
|
|
63
|
+
clocks.push(clock);
|
|
64
|
+
const events = [];
|
|
65
|
+
const stateRoot = join(dir, 'nodes', id);
|
|
66
|
+
const router = new TenantRouter({
|
|
67
|
+
stateRoot,
|
|
68
|
+
replicaId: id,
|
|
69
|
+
leases,
|
|
70
|
+
durable,
|
|
71
|
+
ttlMs: TTL,
|
|
72
|
+
marginMs: MARGIN,
|
|
73
|
+
clock,
|
|
74
|
+
autoRenew: false,
|
|
75
|
+
snapshotEveryMs: options.snapshotEveryMs ?? 0,
|
|
76
|
+
onEvent: (event) => { events.push(event); },
|
|
77
|
+
});
|
|
78
|
+
cleanups.push(() => router.close().then(() => undefined));
|
|
79
|
+
return { id, router, clock, stateRoot, events };
|
|
80
|
+
},
|
|
81
|
+
advance(ms, frozen = []) {
|
|
82
|
+
storeClock.advance(ms);
|
|
83
|
+
for (const clock of clocks)
|
|
84
|
+
if (!frozen.includes(clock))
|
|
85
|
+
clock.advance(ms);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function write(handle, prefix, count) {
|
|
90
|
+
const keys = [];
|
|
91
|
+
handle.run((ledger) => {
|
|
92
|
+
for (let index = 1; index <= count; index += 1) {
|
|
93
|
+
const idemKey = `${prefix}-${index}`;
|
|
94
|
+
assert.notEqual(ledger.insertEvent({ actor: 'test', kind: 'tenant-state.test', payload: { index }, idemKey }), null);
|
|
95
|
+
keys.push(idemKey);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
return keys;
|
|
99
|
+
}
|
|
100
|
+
function keysOf(handle) {
|
|
101
|
+
return handle.run(ledger => ledger.db.prepare("SELECT idem_key FROM events WHERE kind = 'tenant-state.test' ORDER BY seq").all().map(row => row.idem_key));
|
|
102
|
+
}
|
|
103
|
+
function fencedWith(reason) {
|
|
104
|
+
return (error) => error instanceof FencedError && error.reason === reason;
|
|
105
|
+
}
|
|
106
|
+
const prefixOf = (key) => `${key}/`;
|
|
107
|
+
try {
|
|
108
|
+
// ---- T: opening ---------------------------------------------------------------------
|
|
109
|
+
console.log('-- T opening');
|
|
110
|
+
await check('T1 a first open acquires epoch 1 and restores empty; further and concurrent opens share one handle', async () => {
|
|
111
|
+
const fleet = makeFleet('t1');
|
|
112
|
+
const a = fleet.replica('replica-a');
|
|
113
|
+
const [first, second, third] = await Promise.all([a.router.openTenant(ORG, TENANT_A), a.router.openTenant(ORG, TENANT_A), a.router.openTenant(ORG, TENANT_A)]);
|
|
114
|
+
assert.equal(first, second);
|
|
115
|
+
assert.equal(second, third);
|
|
116
|
+
assert.equal(first.epoch, 1);
|
|
117
|
+
assert.equal(first.restoredFrom, null);
|
|
118
|
+
assert.deepEqual([first.organization, first.ledgerTenant], [ORG, TENANT_A]);
|
|
119
|
+
assert.equal(first.ledger.tenant, TENANT_A);
|
|
120
|
+
assert.equal(await a.router.openTenant(ORG, TENANT_A), first);
|
|
121
|
+
assert.equal(a.router.handleOf(ORG, TENANT_A), first);
|
|
122
|
+
assert.equal(a.router.openCount(), 1);
|
|
123
|
+
assert.deepEqual(a.events.map(event => event.type), ['acquired', 'restored'], 'one acquisition served all three callers');
|
|
124
|
+
assert.deepEqual(write(first, 'k', 3), ['k-1', 'k-2', 'k-3']);
|
|
125
|
+
assert.deepEqual(keysOf(first), ['k-1', 'k-2', 'k-3']);
|
|
126
|
+
first.ledger.close();
|
|
127
|
+
assert.deepEqual(keysOf(first), ['k-1', 'k-2', 'k-3'], 'a consumer cannot close the ledger the router owns');
|
|
128
|
+
});
|
|
129
|
+
await check('T2 a tenant another replica owns is refused with the owner named, and the refusing replica touches no file', async () => {
|
|
130
|
+
const fleet = makeFleet('t2');
|
|
131
|
+
const a = fleet.replica('replica-a');
|
|
132
|
+
const b = fleet.replica('replica-b');
|
|
133
|
+
await a.router.openTenant(ORG, TENANT_A);
|
|
134
|
+
await assert.rejects(() => b.router.openTenant(ORG, TENANT_A), (error) => error instanceof LeaseHeldError && error.record.owner === 'replica-a' && error.record.epoch === 1);
|
|
135
|
+
assert.equal(b.router.openCount(), 0);
|
|
136
|
+
assert.equal(existsSync(b.stateRoot), false, 'no directory was created for a tenant this replica does not own');
|
|
137
|
+
assert.deepEqual(b.events, []);
|
|
138
|
+
});
|
|
139
|
+
await check('T3 ids outside the fixed format are refused before any store or file is touched', async () => {
|
|
140
|
+
const fleet = makeFleet('t3');
|
|
141
|
+
const a = fleet.replica('replica-a');
|
|
142
|
+
for (const [organization, tenant] of [['..', TENANT_A], [ORG, '../../etc'], ['Acme', TENANT_A], [ORG, `${TENANT_A}/../${TENANT_B}`], ['', TENANT_A], [ORG, 'local']]) {
|
|
143
|
+
await assert.rejects(() => a.router.openTenant(organization, tenant), InvalidTenantIdError);
|
|
144
|
+
}
|
|
145
|
+
assert.equal(existsSync(a.stateRoot), false);
|
|
146
|
+
assert.deepEqual(fleet.leases.history(), [], 'the lease store never saw a refused id');
|
|
147
|
+
assert.deepEqual(await fleet.store.list('a'), []);
|
|
148
|
+
assert.throws(() => a.router.handleOf('..', TENANT_A), InvalidTenantIdError);
|
|
149
|
+
});
|
|
150
|
+
await check('T4 two tenants on one replica are isolated: own root, own epoch, own rows, own tenant column', async () => {
|
|
151
|
+
const fleet = makeFleet('t4');
|
|
152
|
+
const a = fleet.replica('replica-a');
|
|
153
|
+
const one = await a.router.openTenant(ORG, TENANT_A);
|
|
154
|
+
const two = await a.router.openTenant(ORG, TENANT_B);
|
|
155
|
+
assert.notEqual(one, two);
|
|
156
|
+
assert.deepEqual([one.epoch, two.epoch], [1, 1]);
|
|
157
|
+
write(one, 'one', 4);
|
|
158
|
+
write(two, 'two', 2);
|
|
159
|
+
assert.deepEqual(keysOf(one), ['one-1', 'one-2', 'one-3', 'one-4']);
|
|
160
|
+
assert.deepEqual(keysOf(two), ['two-1', 'two-2']);
|
|
161
|
+
assert.equal(one.ledger.tenant, TENANT_A);
|
|
162
|
+
assert.equal(two.ledger.tenant, TENANT_B);
|
|
163
|
+
assert.notEqual(tenantRootFor(a.stateRoot, ORG, TENANT_A), tenantRootFor(a.stateRoot, ORG, TENANT_B));
|
|
164
|
+
assert.equal(a.router.openCount(), 2);
|
|
165
|
+
});
|
|
166
|
+
// ---- G: the guard ----------------------------------------------------------------------
|
|
167
|
+
console.log('-- G the guard');
|
|
168
|
+
await check('G1 once the lease is not provably held every call on the ledger fails, down to a statement or transaction made earlier, and nothing is written', async () => {
|
|
169
|
+
const fleet = makeFleet('g1');
|
|
170
|
+
const a = fleet.replica('replica-a');
|
|
171
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
172
|
+
write(handle, 'k', 2);
|
|
173
|
+
const prepared = handle.ledger.db.prepare('SELECT COUNT(*) AS n FROM events');
|
|
174
|
+
const insert = handle.ledger.db.prepare("INSERT INTO events (tenant_id, ts, actor, kind, subject_id, payload) VALUES (?, 'now', 'test', 'tenant-state.test', '', '{}')");
|
|
175
|
+
const transaction = handle.ledger.db.transaction(() => 'ran');
|
|
176
|
+
const before = prepared.get().n;
|
|
177
|
+
assert.equal(transaction(), 'ran', 'a transaction built while the lease held runs while it holds');
|
|
178
|
+
assert.equal(transaction.database, undefined, 'a guarded transaction does not hand out the raw database');
|
|
179
|
+
const rows = handle.ledger.db.prepare('SELECT idem_key FROM events').iterate();
|
|
180
|
+
const piped = handle.ledger.db.prepare('SELECT COUNT(*) AS n FROM events').pluck();
|
|
181
|
+
assert.equal(piped.get(), before, 'chaining works while the lease holds');
|
|
182
|
+
fleet.advance(TTL - MARGIN);
|
|
183
|
+
const fenced = fencedWith('lease_expired_locally');
|
|
184
|
+
assert.throws(() => handle.run(() => 1), fenced);
|
|
185
|
+
assert.throws(() => handle.ledger.insertEvent({ actor: 'test', kind: 'tenant-state.test', payload: {}, idemKey: 'late' }), fenced);
|
|
186
|
+
assert.throws(() => handle.ledger.readEvents(), fenced);
|
|
187
|
+
assert.throws(() => handle.ledger.db.prepare('SELECT 1').get(), fenced);
|
|
188
|
+
assert.throws(() => handle.ledger.db.exec('SELECT 1'), fenced);
|
|
189
|
+
assert.throws(() => handle.ledger.db.transaction(() => 1)(), fenced);
|
|
190
|
+
assert.throws(() => handle.ledger.db.transaction(() => 1).immediate(), fenced);
|
|
191
|
+
assert.throws(() => prepared.get(), fenced, 'a statement prepared while the lease held is guarded at use');
|
|
192
|
+
assert.throws(() => insert.run(TENANT_A), fenced);
|
|
193
|
+
assert.throws(() => transaction(), fenced, 'a transaction built earlier is guarded at use');
|
|
194
|
+
assert.throws(() => transaction.immediate(), fenced);
|
|
195
|
+
assert.throws(() => rows.next(), fenced, 'an iterator started earlier proves the lease on every step');
|
|
196
|
+
assert.throws(() => piped.get(), fenced, 'a chained statement stays guarded');
|
|
197
|
+
assert.throws(() => prepared.database.prepare('SELECT 1'), fenced, 'the database reached through a statement is guarded too');
|
|
198
|
+
rows.return?.();
|
|
199
|
+
assert.equal(handle.ledger.tenant, TENANT_A, 'plain properties stay readable');
|
|
200
|
+
await handle.renew();
|
|
201
|
+
assert.equal(prepared.get().n, before, 'nothing was written while the handle was fenced');
|
|
202
|
+
assert.equal(handle.run(() => 'serving again'), 'serving again', 'a renewal at an unchanged epoch resumes the tenant');
|
|
203
|
+
});
|
|
204
|
+
await check('G2 run acknowledges only if the lease holds after the work too: work that outlives the lease is refused and never reaches the next owner', async () => {
|
|
205
|
+
const fleet = makeFleet('g2');
|
|
206
|
+
const a = fleet.replica('replica-a');
|
|
207
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
208
|
+
write(handle, 'kept', 3);
|
|
209
|
+
await handle.snapshot();
|
|
210
|
+
assert.throws(() => handle.run((ledger) => {
|
|
211
|
+
ledger.insertEvent({ actor: 'test', kind: 'tenant-state.test', payload: {}, idemKey: 'unacknowledged' });
|
|
212
|
+
a.clock.advance(TTL);
|
|
213
|
+
}), fencedWith('lease_expired_locally'));
|
|
214
|
+
fleet.advance(TTL + 1, [a.clock]);
|
|
215
|
+
const b = fleet.replica('replica-b');
|
|
216
|
+
const next = await b.router.openTenant(ORG, TENANT_A);
|
|
217
|
+
assert.deepEqual(keysOf(next), ['kept-1', 'kept-2', 'kept-3'], 'the unacknowledged write is not in the lineage the next owner restores');
|
|
218
|
+
});
|
|
219
|
+
await check('G3 runAsync has the same bracket across awaits', async () => {
|
|
220
|
+
const fleet = makeFleet('g3');
|
|
221
|
+
const a = fleet.replica('replica-a');
|
|
222
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
223
|
+
assert.equal(await handle.runAsync(async (ledger) => {
|
|
224
|
+
await sleep(1);
|
|
225
|
+
ledger.insertEvent({ actor: 'test', kind: 'tenant-state.test', payload: {}, idemKey: 'ok' });
|
|
226
|
+
return 'acknowledged';
|
|
227
|
+
}), 'acknowledged');
|
|
228
|
+
await assert.rejects(() => handle.runAsync(async () => {
|
|
229
|
+
await sleep(1);
|
|
230
|
+
a.clock.advance(TTL);
|
|
231
|
+
}), fencedWith('lease_expired_locally'));
|
|
232
|
+
});
|
|
233
|
+
await check('G4 a manual renewal restarts the window from the moment it was sent', async () => {
|
|
234
|
+
const fleet = makeFleet('g4');
|
|
235
|
+
const a = fleet.replica('replica-a');
|
|
236
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
237
|
+
fleet.advance(5_000);
|
|
238
|
+
await handle.renew();
|
|
239
|
+
fleet.advance(5_000);
|
|
240
|
+
assert.equal(handle.run(() => 'still held'), 'still held', 'the first window would have ended at 8000; the renewal moved it to 13000');
|
|
241
|
+
assert.deepEqual(a.events.map(event => event.type), ['acquired', 'restored', 'renewed']);
|
|
242
|
+
});
|
|
243
|
+
// ---- H: handoff --------------------------------------------------------------------------------
|
|
244
|
+
console.log('-- H handoff');
|
|
245
|
+
await check('H1 graceful handoff loses nothing: the final snapshot is flushed, the lease released, and the next owner starts at once with every write', async () => {
|
|
246
|
+
const fleet = makeFleet('h1');
|
|
247
|
+
const a = fleet.replica('replica-a');
|
|
248
|
+
const b = fleet.replica('replica-b');
|
|
249
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
250
|
+
const written = write(handle, 'k', 10);
|
|
251
|
+
await handle.handoff();
|
|
252
|
+
assert.deepEqual(a.events.map(event => event.type), ['acquired', 'restored', 'snapshot', 'released']);
|
|
253
|
+
assert.equal(handle.isOpen(), false);
|
|
254
|
+
assert.equal(a.router.openCount(), 0);
|
|
255
|
+
assert.throws(() => handle.run(() => 1), fencedWith('lease_released'));
|
|
256
|
+
const next = await b.router.openTenant(ORG, TENANT_A);
|
|
257
|
+
assert.equal(next.epoch, 2, 'no waiting for the lease to expire');
|
|
258
|
+
assert.deepEqual(keysOf(next), written);
|
|
259
|
+
});
|
|
260
|
+
await check('H2 a crash loses exactly the writes after the last snapshot, and the lineage stays intact for the owner after that', async () => {
|
|
261
|
+
const fleet = makeFleet('h2');
|
|
262
|
+
const frozen = fleet.replica('replica-a');
|
|
263
|
+
const handle = await frozen.router.openTenant(ORG, TENANT_A);
|
|
264
|
+
const kept = write(handle, 'k', 5);
|
|
265
|
+
await handle.snapshot();
|
|
266
|
+
write(handle, 'lost', 3);
|
|
267
|
+
fleet.advance(TTL + 1, [frozen.clock]);
|
|
268
|
+
const b = fleet.replica('replica-b');
|
|
269
|
+
const second = await b.router.openTenant(ORG, TENANT_A);
|
|
270
|
+
assert.equal(second.epoch, 2);
|
|
271
|
+
assert.deepEqual(keysOf(second), kept, 'the three writes after the snapshot are the declared RPO');
|
|
272
|
+
const more = write(second, 'm', 2);
|
|
273
|
+
await second.handoff();
|
|
274
|
+
const c = fleet.replica('replica-c');
|
|
275
|
+
assert.deepEqual(keysOf(await c.router.openTenant(ORG, TENANT_A)), [...kept, ...more]);
|
|
276
|
+
});
|
|
277
|
+
await check('H3 a handoff whose flush fails keeps serving and keeps the lease; once the store is back it completes with every write', async () => {
|
|
278
|
+
const fleet = makeFleet('h3');
|
|
279
|
+
const a = fleet.replica('replica-a');
|
|
280
|
+
const b = fleet.replica('replica-b');
|
|
281
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
282
|
+
write(handle, 'k', 3);
|
|
283
|
+
fleet.durable.down = true;
|
|
284
|
+
await assert.rejects(() => handle.handoff(), DurableStoreUnavailableError);
|
|
285
|
+
assert.equal(handle.isOpen(), true, 'the tenant keeps serving: releasing now would hand the next owner an older copy');
|
|
286
|
+
await assert.rejects(() => b.router.openTenant(ORG, TENANT_A), LeaseHeldError);
|
|
287
|
+
const more = write(handle, 'm', 2);
|
|
288
|
+
fleet.durable.down = false;
|
|
289
|
+
await handle.handoff();
|
|
290
|
+
assert.deepEqual(keysOf(await b.router.openTenant(ORG, TENANT_A)), ['k-1', 'k-2', 'k-3', ...more]);
|
|
291
|
+
});
|
|
292
|
+
await check('H4 abandon releases at once without a flush: the writes after the last snapshot are given up on purpose', async () => {
|
|
293
|
+
const fleet = makeFleet('h4');
|
|
294
|
+
const a = fleet.replica('replica-a');
|
|
295
|
+
const b = fleet.replica('replica-b');
|
|
296
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
297
|
+
const kept = write(handle, 'k', 2);
|
|
298
|
+
await handle.snapshot();
|
|
299
|
+
write(handle, 'gone', 2);
|
|
300
|
+
fleet.durable.down = true;
|
|
301
|
+
await handle.abandon();
|
|
302
|
+
fleet.durable.down = false;
|
|
303
|
+
assert.deepEqual(a.events.at(-1), { type: 'released', key: KEY_A, epoch: 1, flushed: false });
|
|
304
|
+
assert.deepEqual(keysOf(await b.router.openTenant(ORG, TENANT_A)), kept);
|
|
305
|
+
});
|
|
306
|
+
await check('H5 a drain lets in-flight work finish and be acknowledged, refuses new work, then hands off with that work included', async () => {
|
|
307
|
+
const fleet = makeFleet('h5');
|
|
308
|
+
const a = fleet.replica('replica-a');
|
|
309
|
+
const b = fleet.replica('replica-b');
|
|
310
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
311
|
+
let finish = () => undefined;
|
|
312
|
+
const gate = new Promise((resolve) => { finish = resolve; });
|
|
313
|
+
const inFlight = handle.runAsync(async (ledger) => {
|
|
314
|
+
await gate;
|
|
315
|
+
ledger.insertEvent({ actor: 'test', kind: 'tenant-state.test', payload: {}, idemKey: 'in-flight' });
|
|
316
|
+
return 'done';
|
|
317
|
+
});
|
|
318
|
+
const handoff = handle.handoff();
|
|
319
|
+
await sleep(5);
|
|
320
|
+
assert.equal(handle.isOpen(), false);
|
|
321
|
+
assert.throws(() => handle.run(() => 1), TenantDrainingError);
|
|
322
|
+
await assert.rejects(() => handle.runAsync(async () => 1), TenantDrainingError);
|
|
323
|
+
await assert.rejects(() => handle.handoff(), TenantDrainingError, 'a second handoff does not start while one drains');
|
|
324
|
+
await assert.rejects(() => a.router.openTenant(ORG, TENANT_A), TenantDrainingError, 'opening a tenant that is being handed off says so, instead of reporting it held by this replica');
|
|
325
|
+
finish();
|
|
326
|
+
assert.equal(await inFlight, 'done');
|
|
327
|
+
await handoff;
|
|
328
|
+
assert.deepEqual(keysOf(await b.router.openTenant(ORG, TENANT_A)), ['in-flight']);
|
|
329
|
+
});
|
|
330
|
+
await check('H6 forget through the handle deletes every copy and the local files, closes the handle, and the next open starts empty at the next epoch', async () => {
|
|
331
|
+
const fleet = makeFleet('h6');
|
|
332
|
+
const a = fleet.replica('replica-a');
|
|
333
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
334
|
+
write(handle, 'k', 4);
|
|
335
|
+
await handle.snapshot();
|
|
336
|
+
assert.ok((await fleet.store.list(prefixOf(KEY_A))).length > 0);
|
|
337
|
+
const outcome = await handle.forget();
|
|
338
|
+
assert.equal(outcome.floor, 1);
|
|
339
|
+
assert.deepEqual(await fleet.store.list(prefixOf(KEY_A)), []);
|
|
340
|
+
assert.equal(existsSync(ledgerDbPath(tenantRootFor(a.stateRoot, ORG, TENANT_A))), false);
|
|
341
|
+
assert.equal(handle.isOpen(), false);
|
|
342
|
+
assert.equal(a.router.openCount(), 0);
|
|
343
|
+
const reopened = await a.router.openTenant(ORG, TENANT_A);
|
|
344
|
+
assert.equal(reopened.epoch, 2);
|
|
345
|
+
assert.equal(reopened.restoredFrom, null);
|
|
346
|
+
assert.deepEqual(keysOf(reopened), []);
|
|
347
|
+
});
|
|
348
|
+
await check('H7 closing the router hands every tenant off with a flush and refuses new opens', async () => {
|
|
349
|
+
const fleet = makeFleet('h7');
|
|
350
|
+
const a = fleet.replica('replica-a');
|
|
351
|
+
const b = fleet.replica('replica-b');
|
|
352
|
+
const one = await a.router.openTenant(ORG, TENANT_A);
|
|
353
|
+
const two = await a.router.openTenant(ORG, TENANT_B);
|
|
354
|
+
const keysOne = write(one, 'one', 3);
|
|
355
|
+
const keysTwo = write(two, 'two', 2);
|
|
356
|
+
assert.deepEqual(await a.router.close(), []);
|
|
357
|
+
assert.equal(a.router.openCount(), 0);
|
|
358
|
+
await assert.rejects(() => a.router.openTenant(ORG, TENANT_A), RouterClosedError);
|
|
359
|
+
assert.deepEqual(keysOf(await b.router.openTenant(ORG, TENANT_A)), keysOne);
|
|
360
|
+
assert.deepEqual(keysOf(await b.router.openTenant(ORG, TENANT_B)), keysTwo);
|
|
361
|
+
});
|
|
362
|
+
// ---- L: lifecycle ---------------------------------------------------------------------------------
|
|
363
|
+
console.log('-- L lifecycle');
|
|
364
|
+
await check('L1 losing the lease ends the handle for good: it leaves the registry, refuses work, and the tenant cannot be reopened while someone else holds it', async () => {
|
|
365
|
+
const fleet = makeFleet('l1');
|
|
366
|
+
const frozen = fleet.replica('replica-a');
|
|
367
|
+
const b = fleet.replica('replica-b');
|
|
368
|
+
const handle = await frozen.router.openTenant(ORG, TENANT_A);
|
|
369
|
+
write(handle, 'k', 2);
|
|
370
|
+
fleet.advance(TTL + 1, [frozen.clock]);
|
|
371
|
+
await b.router.openTenant(ORG, TENANT_A);
|
|
372
|
+
assert.equal(handle.isOpen(), true, 'a frozen clock cannot see the takeover');
|
|
373
|
+
await assert.rejects(() => handle.renew(), LeaseLostError);
|
|
374
|
+
assert.equal(handle.isOpen(), false);
|
|
375
|
+
assert.equal(frozen.router.handleOf(ORG, TENANT_A), null);
|
|
376
|
+
assert.throws(() => handle.run(() => 1), fencedWith('lease_lost'));
|
|
377
|
+
assert.deepEqual(frozen.events.at(-1), { type: 'lost', key: KEY_A, epoch: 1 });
|
|
378
|
+
await assert.rejects(() => frozen.router.openTenant(ORG, TENANT_A), (error) => error instanceof LeaseHeldError && error.record.owner === 'replica-b');
|
|
379
|
+
});
|
|
380
|
+
await check('L2 a restore that fails releases the lease again and serves nothing', async () => {
|
|
381
|
+
const fleet = makeFleet('l2');
|
|
382
|
+
const a = fleet.replica('replica-a');
|
|
383
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
384
|
+
write(handle, 'k', 2);
|
|
385
|
+
await handle.handoff();
|
|
386
|
+
const image = join(fleet.dir, 'durable', ...`${KEY_A}/e00000001/snap-000001.db`.split('/'));
|
|
387
|
+
const bytes = readFileSync(image);
|
|
388
|
+
bytes[0] ^= 0xff;
|
|
389
|
+
writeFileSync(image, bytes);
|
|
390
|
+
const b = fleet.replica('replica-b');
|
|
391
|
+
await assert.rejects(() => b.router.openTenant(ORG, TENANT_A), RestoreVerificationError);
|
|
392
|
+
assert.equal(b.router.openCount(), 0);
|
|
393
|
+
assert.deepEqual(b.events.map(event => event.type), ['acquired']);
|
|
394
|
+
const record = await fleet.leases.read(KEY_A);
|
|
395
|
+
assert.ok(record && record.epoch === 2 && record.leaseUntilMs <= fleet.storeClock.nowMs(), 'the failed attempt gave the lease back');
|
|
396
|
+
});
|
|
397
|
+
await check('L3 background renewal keeps a tenant across more than two lease terms on real timers, and abandon stops it and frees the tenant', async () => {
|
|
398
|
+
const dir = join(root, 'l3');
|
|
399
|
+
const leases = new SqliteLeaseStore({ path: join(dir, 'leases.db') });
|
|
400
|
+
cleanups.push(() => leases.close());
|
|
401
|
+
const durable = new DirDurableStore(join(dir, 'durable'));
|
|
402
|
+
const events = [];
|
|
403
|
+
const router = new TenantRouter({
|
|
404
|
+
stateRoot: join(dir, 'a'), replicaId: 'replica-a', leases, durable,
|
|
405
|
+
ttlMs: 1_000, marginMs: 250, renewEveryMs: 150, autoRenew: true, onEvent: (event) => { events.push(event); },
|
|
406
|
+
});
|
|
407
|
+
const handle = await router.openTenant(ORG, TENANT_A);
|
|
408
|
+
await sleep(2_300);
|
|
409
|
+
assert.equal(handle.isOpen(), true);
|
|
410
|
+
assert.equal(handle.run(() => 'served after two terms'), 'served after two terms');
|
|
411
|
+
assert.equal((await leases.read(KEY_A))?.epoch, 1, 'renewals never changed the epoch');
|
|
412
|
+
assert.ok(events.filter(event => event.type === 'renewed').length >= 8, `renewals: ${events.filter(event => event.type === 'renewed').length}`);
|
|
413
|
+
await handle.abandon();
|
|
414
|
+
const next = new TenantRouter({ stateRoot: join(dir, 'b'), replicaId: 'replica-b', leases, durable, ttlMs: 1_000, marginMs: 250, autoRenew: false });
|
|
415
|
+
assert.equal((await next.openTenant(ORG, TENANT_A)).epoch, 2, 'abandon released the lease at once');
|
|
416
|
+
await next.close();
|
|
417
|
+
});
|
|
418
|
+
await check('L4 periodic snapshots publish while serving, one at a time, with contiguous sequence numbers', async () => {
|
|
419
|
+
const fleet = makeFleet('l4');
|
|
420
|
+
const a = fleet.replica('replica-a', { snapshotEveryMs: 40 });
|
|
421
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
422
|
+
write(handle, 'k', 6);
|
|
423
|
+
await sleep(700);
|
|
424
|
+
await handle.handoff();
|
|
425
|
+
const manifests = (await fleet.store.list(`${KEY_A}/e00000001/snap-`)).filter(key => key.endsWith('.json'));
|
|
426
|
+
assert.ok(manifests.length >= 3, `${manifests.length} snapshots in 700 ms plus the final one`);
|
|
427
|
+
assert.deepEqual(manifests.map(key => Number(/snap-(\d{6})\.json$/.exec(key)?.[1])), manifests.map((_, index) => index + 1));
|
|
428
|
+
assert.equal(a.events.filter(event => event.type === 'snapshot').length, manifests.length);
|
|
429
|
+
});
|
|
430
|
+
await check('L5 events carry only the organization and the hmac tenant id, an epoch and codes: no personal data', async () => {
|
|
431
|
+
const fleet = makeFleet('l5');
|
|
432
|
+
const a = fleet.replica('replica-a');
|
|
433
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
434
|
+
write(handle, 'secret-looking-key', 1);
|
|
435
|
+
await handle.snapshot();
|
|
436
|
+
fleet.durable.down = true;
|
|
437
|
+
await handle.snapshot().catch(() => undefined);
|
|
438
|
+
fleet.durable.down = false;
|
|
439
|
+
await handle.handoff();
|
|
440
|
+
assert.ok(a.events.length >= 5);
|
|
441
|
+
for (const event of a.events) {
|
|
442
|
+
assert.equal(event.key, KEY_A);
|
|
443
|
+
assert.doesNotMatch(JSON.stringify(event), /secret-looking-key|user-1|idp\.example/);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
await check('L6 snapshots requested together run one after the other and take consecutive sequence numbers', async () => {
|
|
447
|
+
const fleet = makeFleet('l6');
|
|
448
|
+
const a = fleet.replica('replica-a');
|
|
449
|
+
const handle = await a.router.openTenant(ORG, TENANT_A);
|
|
450
|
+
write(handle, 'k', 3);
|
|
451
|
+
const manifests = await Promise.all([handle.snapshot(), handle.snapshot(), handle.snapshot()]);
|
|
452
|
+
assert.deepEqual(manifests.map(manifest => manifest.seq), [1, 2, 3]);
|
|
453
|
+
});
|
|
454
|
+
console.log(`tenant-state-router-tests: ${passed} checks passed`);
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
for (const cleanup of [...cleanups].reverse()) {
|
|
458
|
+
try {
|
|
459
|
+
await cleanup();
|
|
460
|
+
}
|
|
461
|
+
catch { /* best effort */ }
|
|
462
|
+
}
|
|
463
|
+
rmSync(root, { recursive: true, force: true });
|
|
464
|
+
}
|