@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,854 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graceful handoff, forget and the guarded view, as an independent review found them wanting (AaaS A1;
|
|
3
|
+
* docs/rfc/aaas-rfc.md §4.2 items 5 and 6, §4.6; threats S-6 and S-12).
|
|
4
|
+
*
|
|
5
|
+
* The contract being pinned is the one the router already states: a write is acknowledged only if the lease is held
|
|
6
|
+
* before and after it, and a graceful handoff loses nothing that was acknowledged. Each check below is a way the first
|
|
7
|
+
* implementation let an acknowledged write be lost, a flush be reported that did not land, a raw handle escape the
|
|
8
|
+
* guard, a forgotten tenant's image survive, or a throwing observer hold a lease.
|
|
9
|
+
*
|
|
10
|
+
* Offline and deterministic: a directory stands in for the object store, fake clocks move time by hand, and a slow
|
|
11
|
+
* upload is a hook that moves the clocks while the upload is "in flight". A few checks use real timers (the lease must be
|
|
12
|
+
* renewed while a handoff drains, a renewal chain, a drain deadline), each with room to spare on a busy machine. The founder's runtime state root is never opened.
|
|
13
|
+
*
|
|
14
|
+
* H handoff: writes after the final image are refused, the lease outlives the drain and the upload, a flush whose
|
|
15
|
+
* lease lapsed is reported as failed, a failed flush resumes service, an abandon stays abandoned
|
|
16
|
+
* D the drain is bounded, a stuck tenant holds nobody else, and a tenant that is closed meanwhile settles its handoff
|
|
17
|
+
* R one renewal chain, however a failed handoff and a slow lease store interleave
|
|
18
|
+
* T run() takes a synchronous callback
|
|
19
|
+
* V the guarded view hands out nothing raw
|
|
20
|
+
* F forget: an upload under way cannot leave the tenant's image behind, and forget wins against a handoff
|
|
21
|
+
* S copies of a dead epoch are deleted when the tenant is next opened
|
|
22
|
+
* P a destructive call takes only the tenant's own paths
|
|
23
|
+
* O an observer that throws cannot hold a lease
|
|
24
|
+
* L a replica keeps no copy of a tenant it handed off with everything flushed
|
|
25
|
+
*
|
|
26
|
+
* Run: cd ts && npx tsx scripts/tenant-state-handoff-tests.ts
|
|
27
|
+
*/
|
|
28
|
+
import assert from 'node:assert/strict';
|
|
29
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { tmpdir } from 'node:os';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
33
|
+
import { DirDurableStore } from '../src/tenant-state/durable-store.js';
|
|
34
|
+
import { discardLocalLedger, forgetTenant } from '../src/tenant-state/epoch-copies.js';
|
|
35
|
+
import { FencedError, TenantDrainingError } from '../src/tenant-state/errors.js';
|
|
36
|
+
import { deriveLedgerTenantId, leaseKeyFor, tenantRootFor } from '../src/tenant-state/ids.js';
|
|
37
|
+
import { SqliteLeaseStore } from '../src/tenant-state/lease-store.js';
|
|
38
|
+
import { TenantLease } from '../src/tenant-state/tenant-lease.js';
|
|
39
|
+
import { TenantRouter } from '../src/tenant-state/tenant-router.js';
|
|
40
|
+
import { ledgerDbPath } from '../src/state-ledger.js';
|
|
41
|
+
import { FaultyDurableStore, ManualClock } from './tenant-state-test-kit.js';
|
|
42
|
+
const ORG = 'acme';
|
|
43
|
+
const KEY = Buffer.alloc(32, 9);
|
|
44
|
+
const TENANT = deriveLedgerTenantId(KEY, 'https://idp.example/acme', 'handoff-user');
|
|
45
|
+
const LEASE_KEY = leaseKeyFor(ORG, TENANT);
|
|
46
|
+
const TENANT_2 = deriveLedgerTenantId(KEY, 'https://idp.example/acme', 'handoff-user-2');
|
|
47
|
+
const TTL = 10_000;
|
|
48
|
+
const MARGIN = 2_000;
|
|
49
|
+
let passed = 0;
|
|
50
|
+
const only = process.env.GOTRY_TEST_ONLY;
|
|
51
|
+
async function check(label, assertion) {
|
|
52
|
+
if (only && !label.startsWith(only))
|
|
53
|
+
return;
|
|
54
|
+
await assertion();
|
|
55
|
+
passed += 1;
|
|
56
|
+
console.log(` ok - ${label}`);
|
|
57
|
+
}
|
|
58
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-tenant-handoff-'));
|
|
59
|
+
const cleanups = [];
|
|
60
|
+
/** A call that has to come back: when it does not, the check fails with this message instead of the suite stalling. */
|
|
61
|
+
async function within(promise, ms, what) {
|
|
62
|
+
let timer;
|
|
63
|
+
try {
|
|
64
|
+
return await Promise.race([promise, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(`${what} did not settle within ${ms} ms`)), ms); })]);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const settle = (promise) => promise.then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
71
|
+
async function waitFor(condition, timeoutMs, what) {
|
|
72
|
+
const until = Date.now() + timeoutMs;
|
|
73
|
+
while (!condition()) {
|
|
74
|
+
if (Date.now() > until)
|
|
75
|
+
throw new Error(`timed out waiting for ${what}`);
|
|
76
|
+
await sleep(10);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function eventually(check, timeoutMs, what) {
|
|
80
|
+
const until = Date.now() + timeoutMs;
|
|
81
|
+
while (!await check()) {
|
|
82
|
+
if (Date.now() > until)
|
|
83
|
+
throw new Error(`timed out waiting for ${what}`);
|
|
84
|
+
await sleep(20);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const codeOf = (error) => error?.code;
|
|
88
|
+
function makeFleet(name, options = {}) {
|
|
89
|
+
const dir = join(root, name);
|
|
90
|
+
const storeClock = new ManualClock(1_000_000);
|
|
91
|
+
const clocks = [];
|
|
92
|
+
const leases = options.realTime
|
|
93
|
+
? new SqliteLeaseStore({ path: join(dir, 'leases.db') })
|
|
94
|
+
: new SqliteLeaseStore({ path: join(dir, 'leases.db'), nowMs: () => storeClock.nowMs() });
|
|
95
|
+
const durable = new FaultyDurableStore(new DirDurableStore(join(dir, 'durable')));
|
|
96
|
+
cleanups.push(() => leases.close());
|
|
97
|
+
return {
|
|
98
|
+
durable,
|
|
99
|
+
leases,
|
|
100
|
+
/** `node` names the state root; two replicas given the same one share it, as a misconfiguration would make them. */
|
|
101
|
+
replica(id, onEvent, node = id) {
|
|
102
|
+
const clock = new ManualClock(5_000);
|
|
103
|
+
clocks.push(clock);
|
|
104
|
+
const events = [];
|
|
105
|
+
const stateRoot = join(dir, 'nodes', node);
|
|
106
|
+
const router = new TenantRouter({
|
|
107
|
+
stateRoot,
|
|
108
|
+
replicaId: id,
|
|
109
|
+
leases: options.wrapLeases ? options.wrapLeases(leases) : leases,
|
|
110
|
+
durable,
|
|
111
|
+
ttlMs: options.realTime?.ttlMs ?? TTL,
|
|
112
|
+
marginMs: options.realTime?.marginMs ?? MARGIN,
|
|
113
|
+
...(options.realTime ? {} : { clock, autoRenew: false }),
|
|
114
|
+
snapshotEveryMs: 0,
|
|
115
|
+
...options.router,
|
|
116
|
+
onEvent: (event) => { events.push(event); onEvent?.(event); },
|
|
117
|
+
});
|
|
118
|
+
// A tenant with a call that never returns keeps close() waiting for its drain deadline: the cleanup must not wait for ever.
|
|
119
|
+
cleanups.push(() => within(router.close(), 5_000, 'router.close() in the cleanup').then(() => undefined, () => undefined));
|
|
120
|
+
return { router, clock, stateRoot, events };
|
|
121
|
+
},
|
|
122
|
+
/** Time passes everywhere: the store and every replica's own monotonic clock. */
|
|
123
|
+
advance(ms) {
|
|
124
|
+
storeClock.advance(ms);
|
|
125
|
+
for (const clock of clocks)
|
|
126
|
+
clock.advance(ms);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function insert(handle, idemKey) {
|
|
131
|
+
handle.run((ledger) => { assert.notEqual(ledger.insertEvent({ actor: 'test', kind: 'tenant-state.test', payload: { idemKey }, idemKey }), null); });
|
|
132
|
+
}
|
|
133
|
+
/** A write made straight through the ledger a consumer holds, outside any `run` or `runAsync` bracket. */
|
|
134
|
+
function insertThroughView(handle, idemKey) {
|
|
135
|
+
assert.notEqual(handle.ledger.insertEvent({ actor: 'test', kind: 'tenant-state.test', payload: { idemKey }, idemKey }), null);
|
|
136
|
+
}
|
|
137
|
+
function keysOf(handle) {
|
|
138
|
+
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));
|
|
139
|
+
}
|
|
140
|
+
const isDraining = (error) => error instanceof TenantDrainingError;
|
|
141
|
+
try {
|
|
142
|
+
// ---- H: handoff ------------------------------------------------------------------------------------
|
|
143
|
+
console.log('-- H handoff');
|
|
144
|
+
await check('H1 a write made through the ledger after the final image is taken is refused, so nothing acknowledged is lost', async () => {
|
|
145
|
+
const fleet = makeFleet('h1');
|
|
146
|
+
const a = fleet.replica('replica-a');
|
|
147
|
+
const b = fleet.replica('replica-b');
|
|
148
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
149
|
+
const acknowledged = ['before-1', 'before-2'];
|
|
150
|
+
for (const key of acknowledged)
|
|
151
|
+
insert(handle, key);
|
|
152
|
+
let refusal;
|
|
153
|
+
// The hook runs inside the final upload, after the image was taken: the moment a late writer used to slip in.
|
|
154
|
+
fleet.durable.beforeNextList = async () => {
|
|
155
|
+
try {
|
|
156
|
+
insertThroughView(handle, 'late-1');
|
|
157
|
+
acknowledged.push('late-1');
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
refusal = error;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
await handle.handoff();
|
|
164
|
+
const next = await b.router.openTenant(ORG, TENANT);
|
|
165
|
+
const restored = keysOf(next);
|
|
166
|
+
for (const key of acknowledged)
|
|
167
|
+
assert.ok(restored.includes(key), `${key} was acknowledged and has to be in the successor's ledger, which holds ${JSON.stringify(restored)}`);
|
|
168
|
+
assert.ok(isDraining(refusal), `the late write is refused with the draining error, got ${String(refusal)}`);
|
|
169
|
+
});
|
|
170
|
+
await check('H2 reading through the ledger is refused during the final flush too, with the same typed error, and works again if the flush fails', async () => {
|
|
171
|
+
const fleet = makeFleet('h2');
|
|
172
|
+
const a = fleet.replica('replica-a');
|
|
173
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
174
|
+
insert(handle, 'k-1');
|
|
175
|
+
let readDuringFlush;
|
|
176
|
+
fleet.durable.beforeNextList = async () => {
|
|
177
|
+
try {
|
|
178
|
+
handle.ledger.db.prepare('SELECT 1').get();
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
readDuringFlush = error;
|
|
182
|
+
}
|
|
183
|
+
fleet.durable.down = true;
|
|
184
|
+
};
|
|
185
|
+
await assert.rejects(handle.handoff(), /injected outage/);
|
|
186
|
+
assert.ok(isDraining(readDuringFlush), `the view is closed while the image is being flushed, got ${String(readDuringFlush)}`);
|
|
187
|
+
fleet.durable.down = false;
|
|
188
|
+
assert.equal(handle.isOpen(), true, 'a flush that failed for want of a store leaves the tenant serving');
|
|
189
|
+
insert(handle, 'k-2');
|
|
190
|
+
assert.deepEqual(keysOf(handle), ['k-1', 'k-2']);
|
|
191
|
+
insertThroughView(handle, 'k-3');
|
|
192
|
+
await handle.handoff();
|
|
193
|
+
});
|
|
194
|
+
await check('H3 a flush whose lease lapsed during the upload is reported as failed, not as flushed', async () => {
|
|
195
|
+
const fleet = makeFleet('h3');
|
|
196
|
+
const a = fleet.replica('replica-a');
|
|
197
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
198
|
+
insert(handle, 'k-1');
|
|
199
|
+
// The upload takes longer than the lease: by the time it lands, the replica no longer provably holds the tenant.
|
|
200
|
+
fleet.durable.beforeNextList = async () => { fleet.advance(TTL + 1); };
|
|
201
|
+
await assert.rejects(handle.handoff(), (error) => error instanceof FencedError);
|
|
202
|
+
assert.ok(!a.events.some(event => event.type === 'released' && event.flushed), 'nothing claims the tenant was flushed');
|
|
203
|
+
assert.ok(a.events.some(event => event.type === 'snapshot_failed'), 'and the failed snapshot is told apart from a published one');
|
|
204
|
+
});
|
|
205
|
+
await check('H3b the same, with a successor that took over while the upload was under way: it keeps what it pinned, and the old owner is told its flush did not count', async () => {
|
|
206
|
+
const fleet = makeFleet('h3b');
|
|
207
|
+
const a = fleet.replica('replica-a');
|
|
208
|
+
const b = fleet.replica('replica-b');
|
|
209
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
210
|
+
insert(handle, 'k-1');
|
|
211
|
+
await handle.snapshot();
|
|
212
|
+
insert(handle, 'k-2');
|
|
213
|
+
insert(handle, 'k-3');
|
|
214
|
+
let successor;
|
|
215
|
+
fleet.durable.beforeNextList = async () => {
|
|
216
|
+
fleet.advance(TTL + 1);
|
|
217
|
+
successor = await b.router.openTenant(ORG, TENANT);
|
|
218
|
+
};
|
|
219
|
+
await assert.rejects(handle.handoff(), (error) => error instanceof FencedError);
|
|
220
|
+
assert.ok(!a.events.some(event => event.type === 'released' && event.flushed), 'nothing claims the tenant was flushed');
|
|
221
|
+
assert.ok(successor, 'the successor took over');
|
|
222
|
+
assert.deepEqual(keysOf(successor), ['k-1'], 'the late image is above the successor\'s pinned base and is never read');
|
|
223
|
+
});
|
|
224
|
+
await check('H4 the lease is renewed while a handoff drains in-flight work, so work longer than the lease is still flushed', async () => {
|
|
225
|
+
const fleet = makeFleet('h4', { realTime: { ttlMs: 1_200, marginMs: 200 } });
|
|
226
|
+
const a = fleet.replica('replica-a');
|
|
227
|
+
const b = fleet.replica('replica-b');
|
|
228
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
229
|
+
insert(handle, 'k-1');
|
|
230
|
+
let finished = false;
|
|
231
|
+
const work = handle.runAsync(async (ledger) => {
|
|
232
|
+
await sleep(2_000);
|
|
233
|
+
assert.notEqual(ledger.insertEvent({ actor: 'test', kind: 'tenant-state.test', payload: {}, idemKey: 'k-2' }), null);
|
|
234
|
+
finished = true;
|
|
235
|
+
});
|
|
236
|
+
await sleep(100);
|
|
237
|
+
await handle.handoff();
|
|
238
|
+
await work;
|
|
239
|
+
assert.equal(finished, true);
|
|
240
|
+
assert.ok(!a.events.some(event => event.type === 'lost'), 'the lease was never lost while the drain waited');
|
|
241
|
+
const next = await b.router.openTenant(ORG, TENANT);
|
|
242
|
+
assert.deepEqual(keysOf(next), ['k-1', 'k-2'], 'the work that outlived the lease is in the final image');
|
|
243
|
+
});
|
|
244
|
+
await check('H5 a handoff that resumes service after a failed flush does not double the renewal timers', async () => {
|
|
245
|
+
const fleet = makeFleet('h5', { realTime: { ttlMs: 900, marginMs: 100 } });
|
|
246
|
+
const a = fleet.replica('replica-a');
|
|
247
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
248
|
+
insert(handle, 'k-1');
|
|
249
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
250
|
+
fleet.durable.down = true;
|
|
251
|
+
await assert.rejects(handle.handoff(), /injected outage/);
|
|
252
|
+
fleet.durable.down = false;
|
|
253
|
+
}
|
|
254
|
+
a.events.length = 0;
|
|
255
|
+
await sleep(1_300);
|
|
256
|
+
const renewals = a.events.filter(event => event.type === 'renewed').length;
|
|
257
|
+
assert.ok(renewals >= 2 && renewals <= 6, `one renewal chain runs (renewTick every 300 ms), saw ${renewals} renewals in 1.3 s`);
|
|
258
|
+
await handle.handoff();
|
|
259
|
+
});
|
|
260
|
+
await check('H6 a handoff that is overtaken by an abandon stays abandoned: it flushes nothing and does not bring the handle back', async () => {
|
|
261
|
+
const fleet = makeFleet('h6');
|
|
262
|
+
const a = fleet.replica('replica-a');
|
|
263
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
264
|
+
insert(handle, 'k-1');
|
|
265
|
+
let release;
|
|
266
|
+
const hold = new Promise((resolve) => { release = resolve; });
|
|
267
|
+
const work = handle.runAsync(async () => { await hold; });
|
|
268
|
+
const handingOff = handle.handoff();
|
|
269
|
+
await sleep(20);
|
|
270
|
+
await handle.abandon();
|
|
271
|
+
release();
|
|
272
|
+
await work.catch(() => undefined);
|
|
273
|
+
await assert.rejects(handingOff, isDraining);
|
|
274
|
+
assert.equal(handle.isOpen(), false, 'the abandoned handle is not serving again');
|
|
275
|
+
assert.equal(a.router.handleOf(ORG, TENANT), null);
|
|
276
|
+
const stored = await fleet.durable.list(`${LEASE_KEY}/`);
|
|
277
|
+
assert.deepEqual(stored.filter(key => /snap-/.test(key)), [], `nothing was flushed after the abandon, the store holds ${JSON.stringify(stored)}`);
|
|
278
|
+
});
|
|
279
|
+
// ---- V: the guarded view ------------------------------------------------------------------------------
|
|
280
|
+
console.log('-- V the guarded view');
|
|
281
|
+
await check('V1 no method of the guarded database returns the raw one, so a lease that is gone cannot be bypassed by chaining', async () => {
|
|
282
|
+
const fleet = makeFleet('v1');
|
|
283
|
+
const a = fleet.replica('replica-a');
|
|
284
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
285
|
+
const chained = handle.ledger.db.exec('CREATE TABLE IF NOT EXISTS probe (a INTEGER)');
|
|
286
|
+
const again = handle.ledger.db.defaultSafeIntegers(false);
|
|
287
|
+
// The replica's own clock passes the end of the lease: from here every call through the view has to fail.
|
|
288
|
+
a.clock.advance(TTL);
|
|
289
|
+
assert.throws(() => handle.ledger.db.prepare('SELECT 1'), (error) => error instanceof FencedError);
|
|
290
|
+
for (const returned of [chained, again]) {
|
|
291
|
+
assert.throws(() => returned.prepare('INSERT INTO probe(a) VALUES (1)').run(), (error) => error instanceof FencedError, 'what exec and its siblings return is still behind the gate');
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
await check('V2 closing the database through the view does not close the router\'s own connection', async () => {
|
|
295
|
+
const fleet = makeFleet('v2');
|
|
296
|
+
const a = fleet.replica('replica-a');
|
|
297
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
298
|
+
handle.ledger.db.close();
|
|
299
|
+
handle.ledger.close();
|
|
300
|
+
insert(handle, 'k-1');
|
|
301
|
+
assert.deepEqual(keysOf(handle), ['k-1'], 'the router\'s connection is still open and the write is there');
|
|
302
|
+
});
|
|
303
|
+
await check('V3 the raw database is not reachable through a property descriptor, and the view cannot be rewritten', async () => {
|
|
304
|
+
const fleet = makeFleet('v3');
|
|
305
|
+
const a = fleet.replica('replica-a');
|
|
306
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
307
|
+
const viaOne = Object.getOwnPropertyDescriptor(handle.ledger, 'db')?.value;
|
|
308
|
+
const viaAll = Object.getOwnPropertyDescriptors(handle.ledger).db?.value;
|
|
309
|
+
a.clock.advance(TTL);
|
|
310
|
+
for (const [name, db] of [['getOwnPropertyDescriptor', viaOne], ['getOwnPropertyDescriptors', viaAll]]) {
|
|
311
|
+
assert.throws(() => db.prepare('SELECT 1'), (error) => error instanceof FencedError, `${name} hands out the guarded database, which refuses once the lease is gone`);
|
|
312
|
+
}
|
|
313
|
+
assert.throws(() => { handle.ledger.db = null; }, TypeError, 'the view cannot be assigned to');
|
|
314
|
+
assert.throws(() => { delete handle.ledger.db; }, TypeError, 'and nothing can be deleted from it');
|
|
315
|
+
assert.throws(() => Object.setPrototypeOf(handle.ledger, null), TypeError, 'its prototype stays');
|
|
316
|
+
assert.throws(() => Object.freeze(handle.ledger), TypeError, 'and it cannot be frozen or sealed, which would reach the ledger the router holds');
|
|
317
|
+
assert.throws(() => Object.preventExtensions(handle.ledger), TypeError, 'or made non-extensible');
|
|
318
|
+
assert.equal(Object.isExtensible(handle.ledger), true, 'and the ledger the router holds is as extensible as before, whatever was tried');
|
|
319
|
+
});
|
|
320
|
+
// ---- F: forget ----------------------------------------------------------------------------------------
|
|
321
|
+
console.log('-- F forget');
|
|
322
|
+
await check('F1 a snapshot upload under way cannot outlive forget: the tenant leaves nothing in the store', async () => {
|
|
323
|
+
const fleet = makeFleet('f1');
|
|
324
|
+
const a = fleet.replica('replica-a');
|
|
325
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
326
|
+
insert(handle, 'k-1');
|
|
327
|
+
let release;
|
|
328
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
329
|
+
fleet.durable.beforeNextList = async () => { await gate; };
|
|
330
|
+
const uploading = handle.snapshot().catch(() => undefined);
|
|
331
|
+
await sleep(50);
|
|
332
|
+
const forgetting = handle.forget();
|
|
333
|
+
await sleep(50);
|
|
334
|
+
release();
|
|
335
|
+
await Promise.all([uploading, forgetting]);
|
|
336
|
+
assert.deepEqual(await fleet.durable.list(`${LEASE_KEY}/`), [], 'no image, manifest or base of the forgotten tenant is left');
|
|
337
|
+
assert.equal(existsSync(tenantRootFor(a.stateRoot, ORG, TENANT)), false, 'and no local file');
|
|
338
|
+
});
|
|
339
|
+
await check('F2 a snapshot requested after forget has begun is refused and uploads nothing', async () => {
|
|
340
|
+
const fleet = makeFleet('f2');
|
|
341
|
+
const a = fleet.replica('replica-a');
|
|
342
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
343
|
+
insert(handle, 'k-1');
|
|
344
|
+
const forgetting = handle.forget();
|
|
345
|
+
await assert.rejects(handle.snapshot(), isDraining);
|
|
346
|
+
await forgetting;
|
|
347
|
+
assert.deepEqual(await fleet.durable.list(`${LEASE_KEY}/`), []);
|
|
348
|
+
});
|
|
349
|
+
await check('F3 forget while the final flush is under way wins: the handoff does not release the lease, and nothing of the tenant stays in the store', async () => {
|
|
350
|
+
const fleet = makeFleet('f3');
|
|
351
|
+
const a = fleet.replica('replica-a');
|
|
352
|
+
const b = fleet.replica('replica-b');
|
|
353
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
354
|
+
insert(handle, 'k-1');
|
|
355
|
+
insert(handle, 'k-2');
|
|
356
|
+
let release;
|
|
357
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
358
|
+
fleet.durable.beforeNextList = async () => { await gate; };
|
|
359
|
+
const handingOff = settle(handle.handoff());
|
|
360
|
+
const forgetting = settle((async () => { await sleep(50); return handle.forget(); })());
|
|
361
|
+
await sleep(100);
|
|
362
|
+
release();
|
|
363
|
+
const [handoff, forgotten] = await Promise.all([within(handingOff, 3_000, 'the handoff'), within(forgetting, 3_000, 'forget')]);
|
|
364
|
+
assert.equal(forgotten.ok, true, `forget resolved, got ${forgotten.ok ? '' : String(forgotten.error)}`);
|
|
365
|
+
assert.equal(handoff.ok, false);
|
|
366
|
+
if (!handoff.ok)
|
|
367
|
+
assert.ok(isDraining(handoff.error), `and the handoff is rejected with the draining error, got ${String(handoff.error)}`);
|
|
368
|
+
assert.deepEqual(await fleet.durable.list(`${LEASE_KEY}/`), [], 'no image, manifest or base of the forgotten tenant is left');
|
|
369
|
+
assert.ok(!a.events.some(event => event.type === 'released' && event.flushed), 'nothing claims the tenant was flushed');
|
|
370
|
+
const next = await b.router.openTenant(ORG, TENANT);
|
|
371
|
+
assert.deepEqual(keysOf(next), [], 'the successor starts from an empty ledger');
|
|
372
|
+
});
|
|
373
|
+
await check('F4 forget keeps the lease renewed while it waits for a slow upload, so it is not fenced before it can delete', async () => {
|
|
374
|
+
const fleet = makeFleet('f4', { realTime: { ttlMs: 1_000, marginMs: 150 }, router: { renewEveryMs: 250 } });
|
|
375
|
+
const a = fleet.replica('replica-a');
|
|
376
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
377
|
+
insert(handle, 'k-1');
|
|
378
|
+
await handle.snapshot();
|
|
379
|
+
insert(handle, 'k-2');
|
|
380
|
+
fleet.durable.slowPut = { match: /\.db$/, ms: 2_000 };
|
|
381
|
+
const uploading = settle(handle.snapshot());
|
|
382
|
+
await sleep(50);
|
|
383
|
+
const forgotten = await settle(within(handle.forget(), 10_000, 'forget over a slow upload'));
|
|
384
|
+
await uploading;
|
|
385
|
+
assert.equal(forgotten.ok, true, `forget resolved, got ${forgotten.ok ? '' : String(forgotten.error)}`);
|
|
386
|
+
assert.deepEqual(await fleet.durable.list(`${LEASE_KEY}/`), [], 'and the image that was uploading is gone with the rest');
|
|
387
|
+
});
|
|
388
|
+
await check('F5 while forget waits for an upload, calls through the handle and the ledger are refused with the typed draining error', async () => {
|
|
389
|
+
const fleet = makeFleet('f5');
|
|
390
|
+
const a = fleet.replica('replica-a');
|
|
391
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
392
|
+
insert(handle, 'k-1');
|
|
393
|
+
let release;
|
|
394
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
395
|
+
fleet.durable.beforeNextList = async () => { await gate; };
|
|
396
|
+
const uploading = settle(handle.snapshot());
|
|
397
|
+
await sleep(30);
|
|
398
|
+
const forgetting = settle(handle.forget());
|
|
399
|
+
try {
|
|
400
|
+
await sleep(30);
|
|
401
|
+
assert.throws(() => insert(handle, 'late-1'), isDraining);
|
|
402
|
+
assert.throws(() => handle.run(() => 1), isDraining, 'a callback that never touches the ledger is refused too: the handle, not only the view, says no');
|
|
403
|
+
assert.throws(() => insertThroughView(handle, 'late-2'), isDraining);
|
|
404
|
+
assert.throws(() => handle.ledger.db.prepare('SELECT 1'), isDraining);
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
release();
|
|
408
|
+
}
|
|
409
|
+
await Promise.all([uploading, forgetting]);
|
|
410
|
+
});
|
|
411
|
+
await check('F6 while forget waits for an upload, an abandon does not take the lease from under it, and a second forget is refused', async () => {
|
|
412
|
+
const fleet = makeFleet('f6');
|
|
413
|
+
const a = fleet.replica('replica-a');
|
|
414
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
415
|
+
insert(handle, 'k-1');
|
|
416
|
+
let release;
|
|
417
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
418
|
+
fleet.durable.beforeNextList = async () => { await gate; };
|
|
419
|
+
const uploading = settle(handle.snapshot());
|
|
420
|
+
await sleep(30);
|
|
421
|
+
const forgetting = settle(handle.forget());
|
|
422
|
+
try {
|
|
423
|
+
await sleep(30);
|
|
424
|
+
await handle.abandon();
|
|
425
|
+
await within(assert.rejects(handle.forget(), isDraining), 1_000, 'a second forget');
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
release();
|
|
429
|
+
}
|
|
430
|
+
const [, forgotten] = await Promise.all([uploading, within(forgetting, 3_000, 'forget')]);
|
|
431
|
+
assert.equal(forgotten.ok, true, `forget finished what it began, got ${forgotten.ok ? '' : String(forgotten.error)}`);
|
|
432
|
+
assert.deepEqual(await fleet.durable.list(`${LEASE_KEY}/`), [], 'and the store holds nothing of the tenant');
|
|
433
|
+
});
|
|
434
|
+
await check('F7 forget deletes only what is at or below the floor it raised: a successor that took over while it was paused keeps everything it wrote', async () => {
|
|
435
|
+
let afterFloor = null;
|
|
436
|
+
const fleet = makeFleet('f7', {
|
|
437
|
+
wrapLeases: (inner) => ({
|
|
438
|
+
read: (key) => inner.read(key),
|
|
439
|
+
acquire: (key, owner, ttlMs) => inner.acquire(key, owner, ttlMs),
|
|
440
|
+
renew: (key, owner, epoch, ttlMs) => inner.renew(key, owner, epoch, ttlMs),
|
|
441
|
+
release: (key, owner, epoch) => inner.release(key, owner, epoch),
|
|
442
|
+
markForgotten: async (key, owner, epoch) => {
|
|
443
|
+
const record = await inner.markForgotten(key, owner, epoch);
|
|
444
|
+
const hook = afterFloor;
|
|
445
|
+
afterFloor = null;
|
|
446
|
+
await hook?.();
|
|
447
|
+
return record;
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
});
|
|
451
|
+
const a = fleet.replica('replica-a');
|
|
452
|
+
const b = fleet.replica('replica-b');
|
|
453
|
+
const c = fleet.replica('replica-c');
|
|
454
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
455
|
+
insert(handle, 'a-1');
|
|
456
|
+
await handle.snapshot();
|
|
457
|
+
let successor;
|
|
458
|
+
afterFloor = async () => {
|
|
459
|
+
// The forgetting owner is paused right here, past its lease. The successor takes the tenant over, writes and publishes above the floor.
|
|
460
|
+
fleet.advance(TTL + 1);
|
|
461
|
+
successor = await b.router.openTenant(ORG, TENANT);
|
|
462
|
+
insert(successor, 'b-1');
|
|
463
|
+
await successor.snapshot();
|
|
464
|
+
};
|
|
465
|
+
await handle.forget();
|
|
466
|
+
assert.ok(successor, 'the successor took the tenant over');
|
|
467
|
+
const kept = await fleet.durable.list(`${LEASE_KEY}/e00000002/`);
|
|
468
|
+
assert.ok(kept.some(key => key.endsWith('.db')), `the successor\'s image is still in the store, the store holds ${JSON.stringify(kept)}`);
|
|
469
|
+
assert.deepEqual(await fleet.durable.list(`${LEASE_KEY}/e00000001/`), [], 'and what the forgotten tenure left is gone');
|
|
470
|
+
await successor.abandon();
|
|
471
|
+
const next = await c.router.openTenant(ORG, TENANT);
|
|
472
|
+
assert.deepEqual(keysOf(next), ['b-1'], 'so the successor\'s crash loses nothing it had published');
|
|
473
|
+
});
|
|
474
|
+
await check('F8 closing the router waits for a forget that is under way, though the handle has left its table', async () => {
|
|
475
|
+
const fleet = makeFleet('f8');
|
|
476
|
+
const a = fleet.replica('replica-a');
|
|
477
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
478
|
+
insert(handle, 'k-1');
|
|
479
|
+
let release;
|
|
480
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
481
|
+
fleet.durable.beforeNextList = async () => { await gate; };
|
|
482
|
+
const uploading = settle(handle.snapshot());
|
|
483
|
+
await sleep(30);
|
|
484
|
+
const forgetting = settle(handle.forget());
|
|
485
|
+
await sleep(30);
|
|
486
|
+
let closed = false;
|
|
487
|
+
const closing = a.router.close().then((failed) => { closed = true; return failed; });
|
|
488
|
+
try {
|
|
489
|
+
await sleep(150);
|
|
490
|
+
assert.equal(closed, false, 'close() is still waiting for the forget');
|
|
491
|
+
}
|
|
492
|
+
finally {
|
|
493
|
+
release();
|
|
494
|
+
}
|
|
495
|
+
assert.deepEqual(await within(closing, 3_000, 'router.close()'), []);
|
|
496
|
+
assert.equal((await within(forgetting, 3_000, 'forget')).ok, true);
|
|
497
|
+
await uploading;
|
|
498
|
+
assert.deepEqual(await fleet.durable.list(`${LEASE_KEY}/`), [], 'the forget finished before close() returned');
|
|
499
|
+
assert.ok(a.events.some(event => event.type === 'released' && !event.flushed), 'and it handed the lease back');
|
|
500
|
+
});
|
|
501
|
+
// ---- S: dead epochs --------------------------------------------------------------------------------------
|
|
502
|
+
console.log('-- S dead epochs');
|
|
503
|
+
await check('S1 what a superseded owner uploaded after a forget is deleted when the tenant is next opened, and nothing else is', async () => {
|
|
504
|
+
const fleet = makeFleet('s1');
|
|
505
|
+
const a = fleet.replica('replica-a');
|
|
506
|
+
const b = fleet.replica('replica-b');
|
|
507
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
508
|
+
insert(handle, 'k-1');
|
|
509
|
+
await handle.snapshot();
|
|
510
|
+
await handle.forget();
|
|
511
|
+
const other = leaseKeyFor(ORG, TENANT_2);
|
|
512
|
+
// The late upload of an owner that did not know it was superseded lands in the dead epoch, beside a neighbour's own object.
|
|
513
|
+
await fleet.durable.putIfAbsent(`${LEASE_KEY}/e00000001/snap-000009.db`, Buffer.from('late'));
|
|
514
|
+
await fleet.durable.putIfAbsent(`${LEASE_KEY}/e00000001/snap-000009.json`, Buffer.from('{}'));
|
|
515
|
+
await fleet.durable.putIfAbsent(`${other}/e00000001/base.json`, Buffer.from('{}'));
|
|
516
|
+
assert.equal((await fleet.durable.list(`${LEASE_KEY}/`)).length, 2);
|
|
517
|
+
const next = await b.router.openTenant(ORG, TENANT);
|
|
518
|
+
assert.deepEqual(keysOf(next), []);
|
|
519
|
+
// The sweep runs beside the open, not in it, so a slow store cannot use up the lease before the first renewal.
|
|
520
|
+
await eventually(async () => (await fleet.durable.list(`${LEASE_KEY}/`)).filter(key => /\/e0*1\//.test(key)).length === 0, 3_000, 'the dead epoch to be swept');
|
|
521
|
+
assert.equal((await fleet.durable.list(`${other}/`)).length, 1, 'and another tenant\'s objects were not touched');
|
|
522
|
+
});
|
|
523
|
+
await check('S2 a sweep that fails does not fail the open: the tenant serves, and the next open sweeps again', async () => {
|
|
524
|
+
const fleet = makeFleet('s2');
|
|
525
|
+
const a = fleet.replica('replica-a');
|
|
526
|
+
const b = fleet.replica('replica-b');
|
|
527
|
+
const c = fleet.replica('replica-c');
|
|
528
|
+
const first = await a.router.openTenant(ORG, TENANT);
|
|
529
|
+
insert(first, 'k-1');
|
|
530
|
+
await first.snapshot();
|
|
531
|
+
await first.forget();
|
|
532
|
+
await fleet.durable.putIfAbsent(`${LEASE_KEY}/e00000001/snap-000009.db`, Buffer.from('late'));
|
|
533
|
+
fleet.durable.failNextDelete = true;
|
|
534
|
+
const second = await b.router.openTenant(ORG, TENANT);
|
|
535
|
+
insert(second, 'k-2');
|
|
536
|
+
assert.deepEqual(keysOf(second), ['k-2'], 'the tenant serves although the sweep beside its open failed');
|
|
537
|
+
await sleep(150);
|
|
538
|
+
assert.equal((await fleet.durable.list(`${LEASE_KEY}/e00000001/`)).length, 1, 'the failed sweep deleted nothing');
|
|
539
|
+
await second.handoff();
|
|
540
|
+
const third = await c.router.openTenant(ORG, TENANT);
|
|
541
|
+
await eventually(async () => (await fleet.durable.list(`${LEASE_KEY}/e00000001/`)).length === 0, 3_000, 'the next open to sweep the dead epoch');
|
|
542
|
+
assert.equal(third.isOpen(), true);
|
|
543
|
+
});
|
|
544
|
+
// ---- P: paths ---------------------------------------------------------------------------------------------
|
|
545
|
+
console.log('-- P paths');
|
|
546
|
+
await check('P1 forgetting never deletes a directory that is not the tenant\'s own, whatever root the caller supplies', async () => {
|
|
547
|
+
const dir = join(root, 'p1');
|
|
548
|
+
const storeClock = new ManualClock(1_000_000);
|
|
549
|
+
const leases = new SqliteLeaseStore({ path: join(dir, 'leases.db'), nowMs: () => storeClock.nowMs() });
|
|
550
|
+
cleanups.push(() => leases.close());
|
|
551
|
+
const durable = new DirDurableStore(join(dir, 'durable'));
|
|
552
|
+
const lease = await TenantLease.acquire({ store: leases, key: LEASE_KEY, replicaId: 'replica-p1', ttlMs: TTL, marginMs: MARGIN, clock: new ManualClock(5_000) });
|
|
553
|
+
const keep = (path) => { mkdirSync(path, { recursive: true }); writeFileSync(join(path, 'precious.txt'), 'keep'); };
|
|
554
|
+
const unrelated = join(dir, 'somebody-elses-files');
|
|
555
|
+
const sibling = tenantRootFor(join(dir, 'node'), ORG, TENANT_2);
|
|
556
|
+
keep(unrelated);
|
|
557
|
+
keep(sibling);
|
|
558
|
+
for (const tenantRoot of [unrelated, sibling, join(sibling, '..')]) {
|
|
559
|
+
await assert.rejects(forgetTenant({ durable, lease, organization: ORG, ledgerTenant: TENANT, tenantRoot }), RangeError, `a root of ${tenantRoot} is refused`);
|
|
560
|
+
}
|
|
561
|
+
for (const path of [unrelated, sibling])
|
|
562
|
+
assert.equal(readFileSync(join(path, 'precious.txt'), 'utf8'), 'keep', `${path} is untouched`);
|
|
563
|
+
const own = tenantRootFor(join(dir, 'node'), ORG, TENANT);
|
|
564
|
+
keep(own);
|
|
565
|
+
await forgetTenant({ durable, lease, organization: ORG, ledgerTenant: TENANT, tenantRoot: own });
|
|
566
|
+
assert.equal(existsSync(own), false, 'the tenant\'s own root is the one that goes');
|
|
567
|
+
});
|
|
568
|
+
await check('P3 two replicas that share one state root by misconfiguration: the successor\'s restore leaves the older writer holding a file that is no longer on the path, and the older writer is fenced', async () => {
|
|
569
|
+
const fleet = makeFleet('p3');
|
|
570
|
+
const a = fleet.replica('replica-a', undefined, 'shared-root');
|
|
571
|
+
const b = fleet.replica('replica-b', undefined, 'shared-root');
|
|
572
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
573
|
+
insert(handle, 'k-1');
|
|
574
|
+
await handle.snapshot();
|
|
575
|
+
const file = ledgerDbPath(tenantRootFor(a.stateRoot, ORG, TENANT));
|
|
576
|
+
// What a writer that is still running has open. Holding it also keeps its inode from being handed to the file that
|
|
577
|
+
// replaces it, which a file system may do the moment a file is deleted (Linux does; comparing inode numbers is no test).
|
|
578
|
+
const held = openSync(file, 'r');
|
|
579
|
+
try {
|
|
580
|
+
fleet.advance(TTL + 1);
|
|
581
|
+
const next = await b.router.openTenant(ORG, TENANT);
|
|
582
|
+
assert.equal(fstatSync(held).nlink, 0, 'the file the older writer has open is unlinked: the restore put another one on the path');
|
|
583
|
+
assert.notEqual(statSync(file).ino, fstatSync(held).ino, 'and the path leads to a different file');
|
|
584
|
+
assert.throws(() => insert(handle, 'zombie'), (error) => error instanceof FencedError);
|
|
585
|
+
assert.deepEqual(keysOf(next), ['k-1'], 'the successor\'s ledger is the durable copy');
|
|
586
|
+
}
|
|
587
|
+
finally {
|
|
588
|
+
closeSync(held);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
await check('P4 the directory that was checked is the one that goes: a ".." behind a symlink cannot lead the deletion somewhere else, and discarding the ledger takes the same guard', async () => {
|
|
592
|
+
const dir = join(root, 'p4');
|
|
593
|
+
const storeClock = new ManualClock(1_000_000);
|
|
594
|
+
const leases = new SqliteLeaseStore({ path: join(dir, 'leases.db'), nowMs: () => storeClock.nowMs() });
|
|
595
|
+
cleanups.push(() => leases.close());
|
|
596
|
+
const durable = new DirDurableStore(join(dir, 'durable'));
|
|
597
|
+
const lease = await TenantLease.acquire({ store: leases, key: LEASE_KEY, replicaId: 'replica-p4', ttlMs: TTL, marginMs: MARGIN, clock: new ManualClock(5_000) });
|
|
598
|
+
const tail = join('orgs', ORG, 'tenants', TENANT);
|
|
599
|
+
const deep = join(dir, 'elsewhere', 'deep');
|
|
600
|
+
mkdirSync(deep, { recursive: true });
|
|
601
|
+
symlinkSync(deep, join(dir, 'lnk'));
|
|
602
|
+
const checked = join(dir, tail);
|
|
603
|
+
const behindTheLink = join(dir, 'elsewhere', tail);
|
|
604
|
+
for (const path of [checked, behindTheLink]) {
|
|
605
|
+
mkdirSync(path, { recursive: true });
|
|
606
|
+
writeFileSync(join(path, 'precious.txt'), 'keep');
|
|
607
|
+
}
|
|
608
|
+
// Read as text this ends in the tenant\'s own path; the operating system reads "lnk/.." as the parent of what the link points to.
|
|
609
|
+
const tenantRoot = `${join(dir, 'lnk')}/../${tail}`;
|
|
610
|
+
assert.throws(() => discardLocalLedger({ durable, lease, organization: ORG, ledgerTenant: TENANT, tenantRoot: join(dir, 'somebody-elses-files') }), RangeError);
|
|
611
|
+
await forgetTenant({ durable, lease, organization: ORG, ledgerTenant: TENANT, tenantRoot });
|
|
612
|
+
assert.equal(readFileSync(join(behindTheLink, 'precious.txt'), 'utf8'), 'keep', 'the directory the string leads to behind the link is untouched');
|
|
613
|
+
assert.equal(existsSync(checked), false, 'the one that was checked is the one that went');
|
|
614
|
+
});
|
|
615
|
+
await check('P2 deleting by prefix takes whole segments only, so a name that is the start of another one cannot take its neighbour', async () => {
|
|
616
|
+
const store = new DirDurableStore(join(root, 'p2'));
|
|
617
|
+
await store.putIfAbsent('acme/t1/e00000001/base.json', Buffer.from('1'));
|
|
618
|
+
await store.putIfAbsent('acme2/t9/e00000001/base.json', Buffer.from('2'));
|
|
619
|
+
await assert.rejects(store.deletePrefix('acme'), RangeError);
|
|
620
|
+
assert.equal((await store.list('acme2/')).length, 1, 'the neighbour is untouched');
|
|
621
|
+
assert.equal(await store.deletePrefix('acme/'), 1);
|
|
622
|
+
assert.equal((await store.list('acme2/')).length, 1);
|
|
623
|
+
});
|
|
624
|
+
// ---- D: the drain ------------------------------------------------------------------------------------
|
|
625
|
+
console.log('-- D drain');
|
|
626
|
+
await check('D1 a drain that never finishes is bounded: the handoff is rejected with the typed timeout, the operator is told, and the tenant serves again', async () => {
|
|
627
|
+
const fleet = makeFleet('d1', { realTime: { ttlMs: 2_000, marginMs: 200 }, router: { drainTimeoutMs: 300, renewEveryMs: 200 } });
|
|
628
|
+
const a = fleet.replica('replica-a');
|
|
629
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
630
|
+
insert(handle, 'k-1');
|
|
631
|
+
handle.runAsync(() => new Promise(() => undefined)).catch(() => undefined);
|
|
632
|
+
const outcome = await settle(within(handle.handoff(), 3_000, 'a handoff over a call that never returns'));
|
|
633
|
+
assert.equal(outcome.ok, false);
|
|
634
|
+
if (!outcome.ok)
|
|
635
|
+
assert.equal(codeOf(outcome.error), 'GOTRY_TENANT_DRAIN_TIMEOUT', `rejected with the drain timeout, got ${String(outcome.error)}`);
|
|
636
|
+
assert.equal(handle.isOpen(), true, 'the tenant serves again');
|
|
637
|
+
insert(handle, 'k-2');
|
|
638
|
+
await handle.snapshot();
|
|
639
|
+
assert.deepEqual(keysOf(handle), ['k-1', 'k-2']);
|
|
640
|
+
assert.ok(a.events.some(event => event.type === 'drain_timeout'), 'the operator is told');
|
|
641
|
+
assert.ok(!a.events.some(event => event.type === 'released'), 'and nothing was released');
|
|
642
|
+
});
|
|
643
|
+
await check('D2 close() hands tenants off side by side, so one stuck tenant does not hold the others behind it', async () => {
|
|
644
|
+
const fleet = makeFleet('d2', { realTime: { ttlMs: 3_000, marginMs: 300 }, router: { drainTimeoutMs: 1_500, renewEveryMs: 300 } });
|
|
645
|
+
const a = fleet.replica('replica-a');
|
|
646
|
+
const first = await a.router.openTenant(ORG, TENANT);
|
|
647
|
+
const second = await a.router.openTenant(ORG, TENANT_2);
|
|
648
|
+
insert(first, 'k-1');
|
|
649
|
+
insert(second, 'k-1');
|
|
650
|
+
first.runAsync(() => new Promise(() => undefined)).catch(() => undefined);
|
|
651
|
+
const closing = a.router.close();
|
|
652
|
+
const secondKey = leaseKeyFor(ORG, TENANT_2);
|
|
653
|
+
const handedOff = () => a.events.some(event => event.type === 'released' && event.key === secondKey && event.flushed);
|
|
654
|
+
await waitFor(handedOff, 1_000, 'the second tenant to be handed off while the first is still draining');
|
|
655
|
+
assert.deepEqual(await within(closing, 8_000, 'router.close() with one tenant stuck'), [LEASE_KEY], 'the stuck tenant is reported, and only it');
|
|
656
|
+
});
|
|
657
|
+
for (const how of ['abandon', 'forget', 'lost']) {
|
|
658
|
+
await check(`D3 ${how}: a handoff that waits for a call that never returns is settled by it, not left pending`, async () => {
|
|
659
|
+
const fleet = makeFleet(`d3-${how}`, { router: { drainTimeoutMs: 10_000 } });
|
|
660
|
+
const a = fleet.replica('replica-a');
|
|
661
|
+
const b = fleet.replica('replica-b');
|
|
662
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
663
|
+
insert(handle, 'k-1');
|
|
664
|
+
handle.runAsync(() => new Promise(() => undefined)).catch(() => undefined);
|
|
665
|
+
const handingOff = settle(handle.handoff());
|
|
666
|
+
await sleep(30);
|
|
667
|
+
if (how === 'abandon')
|
|
668
|
+
await handle.abandon();
|
|
669
|
+
else if (how === 'forget')
|
|
670
|
+
await handle.forget();
|
|
671
|
+
else {
|
|
672
|
+
// The lease lapses, a successor takes over, and the next renewal is refused: the tenure is over.
|
|
673
|
+
fleet.advance(TTL + 1);
|
|
674
|
+
await b.router.openTenant(ORG, TENANT);
|
|
675
|
+
await handle.renew().catch(() => undefined);
|
|
676
|
+
}
|
|
677
|
+
const outcome = await within(handingOff, 1_000, `a handoff of a tenant that was ${how} meanwhile`);
|
|
678
|
+
assert.equal(outcome.ok, false);
|
|
679
|
+
if (!outcome.ok)
|
|
680
|
+
assert.ok(isDraining(outcome.error), `the handoff is rejected with the draining error, got ${String(outcome.error)}`);
|
|
681
|
+
assert.equal(handle.isOpen(), false);
|
|
682
|
+
assert.ok(!a.events.some(event => event.type === 'released' && event.flushed), 'and nothing claims a flush');
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
await check('D4 while a handoff drains, new calls through the handle are refused with the draining error, and the work that was running finishes and is flushed', async () => {
|
|
686
|
+
const fleet = makeFleet('d4');
|
|
687
|
+
const a = fleet.replica('replica-a');
|
|
688
|
+
const b = fleet.replica('replica-b');
|
|
689
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
690
|
+
insert(handle, 'k-1');
|
|
691
|
+
let finish;
|
|
692
|
+
const hold = new Promise((resolve) => { finish = resolve; });
|
|
693
|
+
const running = handle.runAsync(async (ledger) => {
|
|
694
|
+
await hold;
|
|
695
|
+
assert.notEqual(ledger.insertEvent({ actor: 'test', kind: 'tenant-state.test', payload: {}, idemKey: 'k-2' }), null);
|
|
696
|
+
});
|
|
697
|
+
const handingOff = settle(handle.handoff());
|
|
698
|
+
try {
|
|
699
|
+
await sleep(30);
|
|
700
|
+
assert.throws(() => handle.run(() => 1), isDraining, 'run() is refused, whatever the callback does');
|
|
701
|
+
await assert.rejects(handle.runAsync(async () => undefined), isDraining);
|
|
702
|
+
assert.equal(handle.isOpen(), false);
|
|
703
|
+
}
|
|
704
|
+
finally {
|
|
705
|
+
finish();
|
|
706
|
+
}
|
|
707
|
+
await running;
|
|
708
|
+
const outcome = await within(handingOff, 3_000, 'the handoff');
|
|
709
|
+
assert.equal(outcome.ok, true, `the handoff finished once the work did, got ${outcome.ok ? '' : String(outcome.error)}`);
|
|
710
|
+
const next = await b.router.openTenant(ORG, TENANT);
|
|
711
|
+
assert.deepEqual(keysOf(next), ['k-1', 'k-2'], 'and the work that was running is in the final image');
|
|
712
|
+
});
|
|
713
|
+
await check('D5 a call on a tenant whose abandon is under way is refused with the draining error, not let through to a closed database', async () => {
|
|
714
|
+
const fleet = makeFleet('d5');
|
|
715
|
+
const a = fleet.replica('replica-a');
|
|
716
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
717
|
+
insert(handle, 'k-1');
|
|
718
|
+
const abandoning = handle.abandon();
|
|
719
|
+
assert.throws(() => handle.run(() => 1), isDraining);
|
|
720
|
+
await within(assert.rejects(handle.forget(), isDraining), 1_000, 'a forget on a tenant whose abandon is under way');
|
|
721
|
+
await abandoning;
|
|
722
|
+
assert.equal(a.events.filter(event => event.type === 'released').length, 1, 'and the tenure ended once');
|
|
723
|
+
assert.throws(() => handle.run(() => 1), (error) => error instanceof FencedError, 'and once the lease is handed back it is the lease that says no');
|
|
724
|
+
});
|
|
725
|
+
await check('D6 the drain options are checked, and a close concurrency of one brings the sequential close back', async () => {
|
|
726
|
+
const fleet = makeFleet('d6');
|
|
727
|
+
const build = (extra) => new TenantRouter({ stateRoot: join(root, 'd6'), replicaId: 'replica-x', leases: fleet.leases, durable: fleet.durable, ...extra });
|
|
728
|
+
for (const bad of [0, -1, 1.5, Number.NaN, 2_147_483_648])
|
|
729
|
+
assert.throws(() => build({ drainTimeoutMs: bad }), RangeError, `drainTimeoutMs ${bad} is refused`);
|
|
730
|
+
for (const bad of [0, -1, 1.5, Number.NaN])
|
|
731
|
+
assert.throws(() => build({ closeConcurrency: bad }), RangeError, `closeConcurrency ${bad} is refused`);
|
|
732
|
+
const sequential = makeFleet('d6-one', { realTime: { ttlMs: 3_000, marginMs: 300 }, router: { drainTimeoutMs: 2_000, renewEveryMs: 300, closeConcurrency: 1 } });
|
|
733
|
+
const a = sequential.replica('replica-a');
|
|
734
|
+
const first = await a.router.openTenant(ORG, TENANT);
|
|
735
|
+
const second = await a.router.openTenant(ORG, TENANT_2);
|
|
736
|
+
insert(first, 'k-1');
|
|
737
|
+
insert(second, 'k-1');
|
|
738
|
+
first.runAsync(() => new Promise(() => undefined)).catch(() => undefined);
|
|
739
|
+
const closing = a.router.close();
|
|
740
|
+
await sleep(400);
|
|
741
|
+
const secondKey = leaseKeyFor(ORG, TENANT_2);
|
|
742
|
+
assert.ok(!a.events.some(event => event.type === 'released' && event.key === secondKey), 'with one lane the second tenant waits behind the stuck first one');
|
|
743
|
+
assert.deepEqual(await within(closing, 10_000, 'router.close()'), [LEASE_KEY]);
|
|
744
|
+
assert.ok(a.events.some(event => event.type === 'released' && event.key === secondKey && event.flushed), 'and is handed off once the first one has given up');
|
|
745
|
+
});
|
|
746
|
+
// ---- R: the renewal chain ------------------------------------------------------------------------------
|
|
747
|
+
console.log('-- R renewal');
|
|
748
|
+
await check('R1 a failed handoff does not start a second renewal chain while a renewal is still under way', async () => {
|
|
749
|
+
const gate = { hold: false, started: 0, waiting: [] };
|
|
750
|
+
const fleet = makeFleet('r1', {
|
|
751
|
+
realTime: { ttlMs: 5_000, marginMs: 500 },
|
|
752
|
+
router: { renewEveryMs: 600 },
|
|
753
|
+
wrapLeases: (inner) => ({
|
|
754
|
+
read: (key) => inner.read(key),
|
|
755
|
+
acquire: (key, owner, ttlMs) => inner.acquire(key, owner, ttlMs),
|
|
756
|
+
release: (key, owner, epoch) => inner.release(key, owner, epoch),
|
|
757
|
+
markForgotten: (key, owner, epoch) => inner.markForgotten(key, owner, epoch),
|
|
758
|
+
renew: async (key, owner, epoch, ttlMs) => {
|
|
759
|
+
gate.started += 1;
|
|
760
|
+
if (gate.hold)
|
|
761
|
+
await new Promise((resolve) => { gate.waiting.push(resolve); });
|
|
762
|
+
return inner.renew(key, owner, epoch, ttlMs);
|
|
763
|
+
},
|
|
764
|
+
}),
|
|
765
|
+
});
|
|
766
|
+
const a = fleet.replica('replica-a');
|
|
767
|
+
gate.hold = true;
|
|
768
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
769
|
+
insert(handle, 'k-1');
|
|
770
|
+
await waitFor(() => gate.started === 1, 4_000, 'the first renewal to start');
|
|
771
|
+
fleet.durable.down = true;
|
|
772
|
+
await assert.rejects(handle.handoff(), /injected outage/);
|
|
773
|
+
fleet.durable.down = false;
|
|
774
|
+
// A second chain would fire its timer in this time and queue a renewal behind the one that is still under way.
|
|
775
|
+
await sleep(900);
|
|
776
|
+
gate.hold = false;
|
|
777
|
+
for (const resume of gate.waiting.splice(0))
|
|
778
|
+
resume();
|
|
779
|
+
await sleep(120);
|
|
780
|
+
assert.equal(gate.started, 1, 'the next renewal waits for its own timer: no second chain queued one');
|
|
781
|
+
await handle.handoff();
|
|
782
|
+
});
|
|
783
|
+
// ---- T: run() takes a synchronous callback ---------------------------------------------------------------------
|
|
784
|
+
console.log('-- T run');
|
|
785
|
+
await check('T1 run() refuses a callback that returns a promise, and the promise it leaves behind cannot become an unhandled rejection', async () => {
|
|
786
|
+
const fleet = makeFleet('t1');
|
|
787
|
+
const a = fleet.replica('replica-a');
|
|
788
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
789
|
+
const unhandled = [];
|
|
790
|
+
const onUnhandled = (reason) => { unhandled.push(reason); };
|
|
791
|
+
process.on('unhandledRejection', onUnhandled);
|
|
792
|
+
try {
|
|
793
|
+
assert.throws(() => handle.run((async () => { await sleep(10); throw new Error('floating'); })), (error) => error instanceof TypeError && /runAsync/.test(error.message));
|
|
794
|
+
await sleep(80);
|
|
795
|
+
}
|
|
796
|
+
finally {
|
|
797
|
+
process.off('unhandledRejection', onUnhandled);
|
|
798
|
+
}
|
|
799
|
+
assert.deepEqual(unhandled, [], 'the rejection of a floating async callback is not left unhandled');
|
|
800
|
+
});
|
|
801
|
+
// ---- O: observers ------------------------------------------------------------------------------------
|
|
802
|
+
console.log('-- O observers');
|
|
803
|
+
await check('O1 an observer that throws cannot hold a lease: the tenant opens, and the lease is the replica\'s to renew and release', async () => {
|
|
804
|
+
const fleet = makeFleet('o1');
|
|
805
|
+
const a = fleet.replica('replica-a', () => { throw new Error('observer bug'); });
|
|
806
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
807
|
+
assert.equal(handle.isOpen(), true);
|
|
808
|
+
insert(handle, 'k-1');
|
|
809
|
+
await handle.handoff();
|
|
810
|
+
const b = fleet.replica('replica-b');
|
|
811
|
+
const next = await b.router.openTenant(ORG, TENANT);
|
|
812
|
+
assert.deepEqual(keysOf(next), ['k-1']);
|
|
813
|
+
});
|
|
814
|
+
// ---- L: local files ------------------------------------------------------------------------------------
|
|
815
|
+
console.log('-- L local files');
|
|
816
|
+
await check('L1 after a handoff that flushed everything the replica keeps no copy of the ledger; after one that did not, it keeps the only copy', async () => {
|
|
817
|
+
const fleet = makeFleet('l1');
|
|
818
|
+
const a = fleet.replica('replica-a');
|
|
819
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
820
|
+
insert(handle, 'k-1');
|
|
821
|
+
const file = ledgerDbPath(tenantRootFor(a.stateRoot, ORG, TENANT));
|
|
822
|
+
assert.equal(existsSync(file), true);
|
|
823
|
+
await handle.handoff();
|
|
824
|
+
for (const suffix of ['', '-wal', '-shm'])
|
|
825
|
+
assert.equal(existsSync(`${file}${suffix}`), false, `${suffix || 'the ledger'} is gone after a flushed handoff`);
|
|
826
|
+
const kept = await a.router.openTenant(ORG, TENANT);
|
|
827
|
+
insert(kept, 'k-2');
|
|
828
|
+
await kept.abandon();
|
|
829
|
+
assert.equal(existsSync(file), true, 'an abandoned tenant\'s file may hold writes that exist nowhere else, so it stays for an operator');
|
|
830
|
+
});
|
|
831
|
+
await check('L2 the local ledger of an abandoned tenure is replaced, not recovered, by the next open on the same replica: the durable copy is the authority', async () => {
|
|
832
|
+
const fleet = makeFleet('l2');
|
|
833
|
+
const a = fleet.replica('replica-a');
|
|
834
|
+
const handle = await a.router.openTenant(ORG, TENANT);
|
|
835
|
+
insert(handle, 'k-1');
|
|
836
|
+
await handle.snapshot();
|
|
837
|
+
insert(handle, 'k-2');
|
|
838
|
+
await handle.abandon();
|
|
839
|
+
const file = ledgerDbPath(tenantRootFor(a.stateRoot, ORG, TENANT));
|
|
840
|
+
assert.equal(existsSync(file), true, 'the abandoned file is there until the next open');
|
|
841
|
+
const again = await a.router.openTenant(ORG, TENANT);
|
|
842
|
+
assert.deepEqual(keysOf(again), ['k-1'], 'k-2 was written after the last snapshot: it is the declared tail of D-a and is not brought back from the old file');
|
|
843
|
+
});
|
|
844
|
+
console.log(`tenant-state-handoff: ${passed} checks passed`);
|
|
845
|
+
}
|
|
846
|
+
finally {
|
|
847
|
+
for (const cleanup of cleanups.reverse()) {
|
|
848
|
+
try {
|
|
849
|
+
await cleanup();
|
|
850
|
+
}
|
|
851
|
+
catch { /* best effort */ }
|
|
852
|
+
}
|
|
853
|
+
rmSync(root, { recursive: true, force: true });
|
|
854
|
+
}
|