@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,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epoch-scoped durable copies (AaaS A1.2, docs/rfc/aaas-rfc.md §4.2 item 3 and §4.3 mode D-a; threats S-6 and S-12).
|
|
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. Replicas are separate state roots in one process. No network, no model,
|
|
6
|
+
* and the founder's runtime state root is never opened.
|
|
7
|
+
*
|
|
8
|
+
* K the directory store passes the DurableStore conformance checks, and the checks can fail.
|
|
9
|
+
* S publishing: a verified, self-contained image in the holder's epoch prefix; fenced holders publish nothing.
|
|
10
|
+
* V image verification: foreign-tenant rows, a non-database and a broken fold are refused.
|
|
11
|
+
* R restoring: the newest lineage wins, a base pins the exact snapshot, corrupt copies are passed over.
|
|
12
|
+
* X takeover: a paused owner's late upload never reaches a later owner's ledger; local files never survive.
|
|
13
|
+
* F forget: every copy goes, the floor masks late uploads, and a sweep removes them.
|
|
14
|
+
* I faults: a store outage and a crash between an image and its manifest.
|
|
15
|
+
*
|
|
16
|
+
* Run: cd ts && npx tsx scripts/tenant-state-copies-tests.ts
|
|
17
|
+
*/
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import Database from 'better-sqlite3';
|
|
24
|
+
import { ledgerDbPath, openDb } from '../src/state-ledger.js';
|
|
25
|
+
import { DirDurableStore } from '../src/tenant-state/durable-store.js';
|
|
26
|
+
import { forgetTenant, publishSnapshot, resolveRestorePoint, restoreTenantLedger, sweepMaskedCopies, verifyLedgerImage, } from '../src/tenant-state/epoch-copies.js';
|
|
27
|
+
import { CopyConflictError, DurableStoreUnavailableError, FencedError, LeaseLostError, RestoreVerificationError, } from '../src/tenant-state/errors.js';
|
|
28
|
+
import { deriveLedgerTenantId, leaseKeyFor, tenantRootFor } from '../src/tenant-state/ids.js';
|
|
29
|
+
import { SqliteLeaseStore } from '../src/tenant-state/lease-store.js';
|
|
30
|
+
import { TenantLease } from '../src/tenant-state/tenant-lease.js';
|
|
31
|
+
import { runDurableStoreConformance } from './tenant-state-durable-conformance.js';
|
|
32
|
+
import { FaultyDurableStore, ManualClock, TrippingClock } from './tenant-state-test-kit.js';
|
|
33
|
+
const ORG = 'acme';
|
|
34
|
+
const ORG_KEY = Buffer.alloc(32, 9);
|
|
35
|
+
const ISSUER = 'https://idp.example/acme';
|
|
36
|
+
const TENANT = deriveLedgerTenantId(ORG_KEY, ISSUER, 'user-1');
|
|
37
|
+
const OTHER_TENANT = deriveLedgerTenantId(ORG_KEY, ISSUER, 'user-2');
|
|
38
|
+
const KEY = leaseKeyFor(ORG, TENANT);
|
|
39
|
+
const TTL = 10_000;
|
|
40
|
+
const MARGIN = 2_000;
|
|
41
|
+
let passed = 0;
|
|
42
|
+
async function check(label, assertion) {
|
|
43
|
+
await assertion();
|
|
44
|
+
passed += 1;
|
|
45
|
+
console.log(` ok - ${label}`);
|
|
46
|
+
}
|
|
47
|
+
/** Breaks one rule of the contract: a put overwrites what exists. */
|
|
48
|
+
class OverwritingDurableStore {
|
|
49
|
+
inner;
|
|
50
|
+
written = new Map();
|
|
51
|
+
constructor(inner) {
|
|
52
|
+
this.inner = inner;
|
|
53
|
+
}
|
|
54
|
+
async putIfAbsent(key, bytes) {
|
|
55
|
+
this.written.set(key, bytes);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
async get(key) {
|
|
59
|
+
return this.written.get(key) ?? this.inner.get(key);
|
|
60
|
+
}
|
|
61
|
+
async list(prefix) {
|
|
62
|
+
return [...this.written.keys()].filter(key => key.startsWith(prefix)).sort();
|
|
63
|
+
}
|
|
64
|
+
async deletePrefix(prefix) {
|
|
65
|
+
return this.inner.deletePrefix(prefix);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Breaks another rule: the partial last segment of a prefix is ignored, so a listing returns the whole directory. */
|
|
69
|
+
/** A store whose delete treats the prefix as raw text, so `acme` would take `acme2` too. */
|
|
70
|
+
class RawPrefixDeleteDurableStore {
|
|
71
|
+
inner;
|
|
72
|
+
directory;
|
|
73
|
+
constructor(inner, directory) {
|
|
74
|
+
this.inner = inner;
|
|
75
|
+
this.directory = directory;
|
|
76
|
+
}
|
|
77
|
+
putIfAbsent(key, bytes) { return this.inner.putIfAbsent(key, bytes); }
|
|
78
|
+
get(key) { return this.inner.get(key); }
|
|
79
|
+
list(prefix) { return this.inner.list(prefix); }
|
|
80
|
+
async deletePrefix(prefix) {
|
|
81
|
+
const keys = await this.inner.list(prefix);
|
|
82
|
+
for (const key of keys)
|
|
83
|
+
rmSync(join(this.directory, ...key.split('/')), { force: true });
|
|
84
|
+
return keys.length;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
class DirectoryOnlyListDurableStore {
|
|
88
|
+
inner;
|
|
89
|
+
constructor(inner) {
|
|
90
|
+
this.inner = inner;
|
|
91
|
+
}
|
|
92
|
+
putIfAbsent(key, bytes) { return this.inner.putIfAbsent(key, bytes); }
|
|
93
|
+
get(key) { return this.inner.get(key); }
|
|
94
|
+
deletePrefix(prefix) { return this.inner.deletePrefix(prefix); }
|
|
95
|
+
list(prefix) { return this.inner.list(prefix.slice(0, prefix.lastIndexOf('/') + 1)); }
|
|
96
|
+
}
|
|
97
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-tenant-copies-'));
|
|
98
|
+
const worlds = [];
|
|
99
|
+
const openLedgers = [];
|
|
100
|
+
function makeWorld(name) {
|
|
101
|
+
const dir = join(root, name);
|
|
102
|
+
const storeClock = new ManualClock(1_000_000);
|
|
103
|
+
const leases = new SqliteLeaseStore({ path: join(dir, 'leases.db'), nowMs: () => storeClock.nowMs() });
|
|
104
|
+
const durableDir = join(dir, 'durable');
|
|
105
|
+
const store = new DirDurableStore(durableDir);
|
|
106
|
+
const clocks = [];
|
|
107
|
+
const world = {
|
|
108
|
+
dir,
|
|
109
|
+
leases,
|
|
110
|
+
storeClock,
|
|
111
|
+
durableDir,
|
|
112
|
+
store,
|
|
113
|
+
durable: new FaultyDurableStore(store),
|
|
114
|
+
replicaClock() {
|
|
115
|
+
const clock = new ManualClock(5_000);
|
|
116
|
+
clocks.push(clock);
|
|
117
|
+
return clock;
|
|
118
|
+
},
|
|
119
|
+
advance(ms, frozen = []) {
|
|
120
|
+
storeClock.advance(ms);
|
|
121
|
+
for (const clock of clocks)
|
|
122
|
+
if (!frozen.includes(clock))
|
|
123
|
+
clock.advance(ms);
|
|
124
|
+
},
|
|
125
|
+
pathOf(key) {
|
|
126
|
+
return join(durableDir, ...key.split('/'));
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
worlds.push(world);
|
|
130
|
+
return world;
|
|
131
|
+
}
|
|
132
|
+
async function acquire(world, id, clock, node = id) {
|
|
133
|
+
const lease = await TenantLease.acquire({ store: world.leases, key: KEY, replicaId: id, ttlMs: TTL, marginMs: MARGIN, clock });
|
|
134
|
+
const tenantRoot = tenantRootFor(join(world.dir, 'nodes', node), ORG, TENANT);
|
|
135
|
+
return { lease, tenantRoot, context: { durable: world.durable, lease, organization: ORG, ledgerTenant: TENANT, tenantRoot } };
|
|
136
|
+
}
|
|
137
|
+
/** A replica taking over the tenant: acquire the lease at the next epoch, then restore the ledger. */
|
|
138
|
+
async function boot(world, id, options = {}) {
|
|
139
|
+
const clock = options.clock ?? world.replicaClock();
|
|
140
|
+
const { lease, context, tenantRoot } = await acquire(world, id, clock, options.node);
|
|
141
|
+
const restored = await restoreTenantLedger(context);
|
|
142
|
+
openLedgers.push(restored.ledger);
|
|
143
|
+
return { id, clock, lease, context, tenantRoot, restored, ledger: restored.ledger };
|
|
144
|
+
}
|
|
145
|
+
function appendEvents(ledger, prefix, count) {
|
|
146
|
+
const keys = [];
|
|
147
|
+
for (let index = 1; index <= count; index += 1) {
|
|
148
|
+
const idemKey = `${prefix}-${index}`;
|
|
149
|
+
assert.notEqual(ledger.insertEvent({ actor: 'test', kind: 'tenant-state.test', payload: { index }, idemKey }), null);
|
|
150
|
+
keys.push(idemKey);
|
|
151
|
+
}
|
|
152
|
+
return keys;
|
|
153
|
+
}
|
|
154
|
+
function addWishes(ledger, count) {
|
|
155
|
+
for (let index = 1; index <= count; index += 1)
|
|
156
|
+
ledger.appendWish({ name: `wish-${index}`, reason: 'test', conditions: { days: index } });
|
|
157
|
+
}
|
|
158
|
+
function idemKeys(ledger) {
|
|
159
|
+
return ledger.db.prepare("SELECT idem_key FROM events WHERE kind = 'tenant-state.test' ORDER BY seq").all().map(row => row.idem_key);
|
|
160
|
+
}
|
|
161
|
+
function eventRows(ledger) {
|
|
162
|
+
return ledger.db.prepare('SELECT seq, tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id FROM events ORDER BY seq').all();
|
|
163
|
+
}
|
|
164
|
+
function integrityOf(dbPath) {
|
|
165
|
+
const db = new Database(dbPath, { fileMustExist: true });
|
|
166
|
+
try {
|
|
167
|
+
return String(db.pragma('integrity_check', { simple: true }));
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
db.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function sha256(bytes) {
|
|
174
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
175
|
+
}
|
|
176
|
+
const prefixOf = (epoch) => `${KEY}/e${String(epoch).padStart(8, '0')}`;
|
|
177
|
+
const snapKey = (epoch, seq, extension) => `${prefixOf(epoch)}/snap-${String(seq).padStart(6, '0')}.${extension}`;
|
|
178
|
+
const baseKey = (epoch) => `${prefixOf(epoch)}/base.json`;
|
|
179
|
+
function flipByte(path, offset) {
|
|
180
|
+
const bytes = readFileSync(path);
|
|
181
|
+
bytes[offset] ^= 0xff;
|
|
182
|
+
writeFileSync(path, bytes);
|
|
183
|
+
}
|
|
184
|
+
function assertFenced(promise, reason) {
|
|
185
|
+
return assert.rejects(() => promise, (error) => error instanceof FencedError && error.reason === reason);
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
// ---- K: the durable store contract ----------------------------------------------------
|
|
189
|
+
console.log('-- K durable store');
|
|
190
|
+
await check('K1 the directory store passes every DurableStore conformance check', async () => {
|
|
191
|
+
assert.deepEqual(await runDurableStoreConformance(new DirDurableStore(join(root, 'conformance')), 'reference'), []);
|
|
192
|
+
});
|
|
193
|
+
await check('K2 the conformance checks can fail: an overwriting store and a directory-only listing store each turn named checks red', async () => {
|
|
194
|
+
const overwriting = await runDurableStoreConformance(new OverwritingDurableStore(new DirDurableStore(join(root, 'broken-overwrite'))), 'overwriting');
|
|
195
|
+
const names = overwriting.map(failure => failure.check);
|
|
196
|
+
assert.ok(names.includes('put creates an absent key and never overwrites an existing one'), names.join(' | '));
|
|
197
|
+
assert.ok(names.includes('sixteen concurrent puts of one key create it exactly once with intact bytes'), names.join(' | '));
|
|
198
|
+
const directoryOnly = await runDurableStoreConformance(new DirectoryOnlyListDurableStore(new DirDurableStore(join(root, 'broken-listing'))), 'listing');
|
|
199
|
+
assert.ok(directoryOnly.map(failure => failure.check).includes('list returns sorted keys under a prefix, including a partial last segment'), JSON.stringify(directoryOnly));
|
|
200
|
+
const rawDelete = await runDurableStoreConformance(new RawPrefixDeleteDurableStore(new DirDurableStore(join(root, 'broken-delete')), join(root, 'broken-delete')), 'rawdelete');
|
|
201
|
+
assert.ok(rawDelete.map(failure => failure.check).includes('delete takes whole segments only: a prefix that stops inside a name would also take its neighbours'), JSON.stringify(rawDelete));
|
|
202
|
+
});
|
|
203
|
+
// ---- S: publishing ------------------------------------------------------------------------
|
|
204
|
+
console.log('-- S publishing');
|
|
205
|
+
await check('S1 a snapshot lands in the holder epoch prefix with a manifest that matches the stored image; a second one takes the next sequence', async () => {
|
|
206
|
+
const world = makeWorld('s1');
|
|
207
|
+
const r1 = await boot(world, 'r1');
|
|
208
|
+
assert.equal(r1.lease.epoch, 1);
|
|
209
|
+
assert.equal(r1.restored.restoredFrom, null);
|
|
210
|
+
appendEvents(r1.ledger, 'k', 5);
|
|
211
|
+
addWishes(r1.ledger, 2);
|
|
212
|
+
const first = await publishSnapshot(r1.context, { nowMs: () => 42 });
|
|
213
|
+
assert.deepEqual({ epoch: first.epoch, seq: first.seq, tenant: first.tenant, createdAtMs: first.createdAtMs }, { epoch: 1, seq: 1, tenant: TENANT, createdAtMs: 42 });
|
|
214
|
+
assert.equal(first.events, r1.ledger.db.prepare('SELECT COUNT(*) AS n FROM events').get().n);
|
|
215
|
+
const stored = await world.store.get(snapKey(1, 1, 'db'));
|
|
216
|
+
assert.ok(stored);
|
|
217
|
+
assert.equal(sha256(stored), first.sha256);
|
|
218
|
+
assert.equal(stored.byteLength, first.bytes);
|
|
219
|
+
assert.equal((await publishSnapshot(r1.context)).seq, 2);
|
|
220
|
+
assert.deepEqual(await world.store.list(`${KEY}/`), [baseKey(1), snapKey(1, 1, 'db'), snapKey(1, 1, 'json'), snapKey(1, 2, 'db'), snapKey(1, 2, 'json')]);
|
|
221
|
+
assert.deepEqual(JSON.parse(Buffer.from(await world.store.get(baseKey(1))).toString('utf8')), { v: 1, epoch: 1, from: null });
|
|
222
|
+
});
|
|
223
|
+
await check('S2 the stored image is self-contained: rollback-journal header, no -wal or -shm needed', async () => {
|
|
224
|
+
const world = makeWorld('s2');
|
|
225
|
+
const r1 = await boot(world, 'r1');
|
|
226
|
+
appendEvents(r1.ledger, 'k', 3);
|
|
227
|
+
await publishSnapshot(r1.context);
|
|
228
|
+
const stored = Buffer.from(await world.store.get(snapKey(1, 1, 'db')));
|
|
229
|
+
assert.equal(stored.subarray(0, 15).toString('latin1'), 'SQLite format 3');
|
|
230
|
+
assert.deepEqual([stored[18], stored[19]], [1, 1], 'file-format versions 1 and 1 mean no write-ahead log');
|
|
231
|
+
});
|
|
232
|
+
await check('S3 a fenced holder publishes nothing: refused at the first gate, and again at the gate right before the upload', async () => {
|
|
233
|
+
const world = makeWorld('s3');
|
|
234
|
+
const r1 = await boot(world, 'r1');
|
|
235
|
+
appendEvents(r1.ledger, 'k', 2);
|
|
236
|
+
const before = await world.store.list(`${KEY}/`);
|
|
237
|
+
world.advance(TTL - MARGIN);
|
|
238
|
+
await assertFenced(publishSnapshot(r1.context), 'lease_expired_locally');
|
|
239
|
+
assert.deepEqual(await world.store.list(`${KEY}/`), before);
|
|
240
|
+
const second = makeWorld('s3b');
|
|
241
|
+
const tripping = new TrippingClock(5_000);
|
|
242
|
+
const r2 = await boot(second, 'r1', { clock: tripping });
|
|
243
|
+
appendEvents(r2.ledger, 'm', 2);
|
|
244
|
+
const listed = await second.store.list(`${KEY}/`);
|
|
245
|
+
tripping.arm(1, TTL);
|
|
246
|
+
await assertFenced(publishSnapshot(r2.context), 'lease_expired_locally');
|
|
247
|
+
assert.deepEqual(await second.store.list(`${KEY}/`), listed, 'the image was built but never uploaded: the gate right before the upload tripped');
|
|
248
|
+
});
|
|
249
|
+
await check('S4 publishing with a lease of another tenant is refused before anything is read', async () => {
|
|
250
|
+
const world = makeWorld('s4');
|
|
251
|
+
const clock = world.replicaClock();
|
|
252
|
+
const otherKey = leaseKeyFor(ORG, OTHER_TENANT);
|
|
253
|
+
const lease = await TenantLease.acquire({ store: world.leases, key: otherKey, replicaId: 'r1', ttlMs: TTL, marginMs: MARGIN, clock });
|
|
254
|
+
const context = { durable: world.durable, lease, organization: ORG, ledgerTenant: TENANT, tenantRoot: tenantRootFor(join(world.dir, 'nodes', 'r1'), ORG, TENANT) };
|
|
255
|
+
await assert.rejects(() => publishSnapshot(context), RangeError);
|
|
256
|
+
await assert.rejects(() => restoreTenantLedger(context), RangeError);
|
|
257
|
+
await assert.rejects(() => forgetTenant(context), RangeError);
|
|
258
|
+
assert.deepEqual(await world.store.list(`${ORG}/`), []);
|
|
259
|
+
});
|
|
260
|
+
await check('S5 publishing refuses a ledger that holds rows of another tenant, and uploads nothing', async () => {
|
|
261
|
+
const world = makeWorld('s5');
|
|
262
|
+
const r1 = await boot(world, 'r1');
|
|
263
|
+
appendEvents(r1.ledger, 'k', 2);
|
|
264
|
+
r1.ledger.db.prepare("INSERT INTO events (tenant_id, ts, actor, kind, subject_id, payload) VALUES (?, '2026-01-01T00:00:00.000Z', 'test', 'tenant-state.test', '', '{}')").run(OTHER_TENANT);
|
|
265
|
+
const before = await world.store.list(`${KEY}/`);
|
|
266
|
+
await assert.rejects(() => publishSnapshot(r1.context), (error) => error instanceof RestoreVerificationError && /another tenant/.test(error.message));
|
|
267
|
+
assert.deepEqual(await world.store.list(`${KEY}/`), before);
|
|
268
|
+
});
|
|
269
|
+
// ---- V: image verification ---------------------------------------------------------------------
|
|
270
|
+
console.log('-- V image verification');
|
|
271
|
+
function ledgerImage(name, tenant, seed) {
|
|
272
|
+
const dir = join(root, 'images', name);
|
|
273
|
+
const ledger = openDb(dir, tenant);
|
|
274
|
+
seed(ledger);
|
|
275
|
+
ledger.close();
|
|
276
|
+
const image = join(root, 'images', `${name}.db`);
|
|
277
|
+
mkdirSync(dirname(image), { recursive: true });
|
|
278
|
+
copyFileSync(ledgerDbPath(dir), image);
|
|
279
|
+
return image;
|
|
280
|
+
}
|
|
281
|
+
await check('V1 a healthy image verifies and reports its event count and last sequence', () => {
|
|
282
|
+
const image = ledgerImage('v1', TENANT, (ledger) => { appendEvents(ledger, 'k', 4); addWishes(ledger, 2); });
|
|
283
|
+
const facts = verifyLedgerImage(image, TENANT);
|
|
284
|
+
assert.ok(facts.events >= 6 && facts.maxEventSeq >= facts.events);
|
|
285
|
+
});
|
|
286
|
+
await check('V2 an image holding another tenant is refused, even though it is a perfectly healthy ledger', () => {
|
|
287
|
+
const image = ledgerImage('v2', OTHER_TENANT, (ledger) => { appendEvents(ledger, 'o', 3); });
|
|
288
|
+
assert.throws(() => verifyLedgerImage(image, TENANT), (error) => error instanceof RestoreVerificationError && /another tenant/.test(error.message));
|
|
289
|
+
assert.ok(verifyLedgerImage(image, OTHER_TENANT).events >= 3, 'the same file verifies for its own tenant');
|
|
290
|
+
});
|
|
291
|
+
await check('V3 a file that is not a database is refused', () => {
|
|
292
|
+
const image = ledgerImage('v3', TENANT, (ledger) => { appendEvents(ledger, 'k', 2); });
|
|
293
|
+
flipByte(image, 0);
|
|
294
|
+
assert.throws(() => verifyLedgerImage(image, TENANT), RestoreVerificationError);
|
|
295
|
+
});
|
|
296
|
+
await check('V4 an image whose projections disagree with its events is refused although SQLite finds it intact', () => {
|
|
297
|
+
const image = ledgerImage('v4', TENANT, (ledger) => { addWishes(ledger, 3); });
|
|
298
|
+
const raw = new Database(image);
|
|
299
|
+
try {
|
|
300
|
+
raw.prepare('DELETE FROM projection_items').run();
|
|
301
|
+
raw.prepare('DELETE FROM projection_docs').run();
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
raw.close();
|
|
305
|
+
}
|
|
306
|
+
assert.equal(integrityOf(image), 'ok');
|
|
307
|
+
assert.throws(() => verifyLedgerImage(image, TENANT), (error) => error instanceof RestoreVerificationError && /projections/.test(error.message));
|
|
308
|
+
});
|
|
309
|
+
// ---- R: restoring --------------------------------------------------------------------------------
|
|
310
|
+
console.log('-- R restoring');
|
|
311
|
+
await check('R1 a new owner restores exactly what the old owner published: same event rows, same projections, integrity ok', async () => {
|
|
312
|
+
const world = makeWorld('r1');
|
|
313
|
+
const r1 = await boot(world, 'r1');
|
|
314
|
+
appendEvents(r1.ledger, 'k', 12);
|
|
315
|
+
addWishes(r1.ledger, 3);
|
|
316
|
+
const manifest = await publishSnapshot(r1.context);
|
|
317
|
+
const rows = eventRows(r1.ledger);
|
|
318
|
+
const wishes = r1.ledger.readWishPool();
|
|
319
|
+
assert.ok(wishes.length === 3);
|
|
320
|
+
await r1.lease.release();
|
|
321
|
+
r1.ledger.close();
|
|
322
|
+
const r2 = await boot(world, 'r2');
|
|
323
|
+
assert.equal(r2.lease.epoch, 2);
|
|
324
|
+
assert.deepEqual(r2.restored.restoredFrom, { epoch: 1, seq: 1, sha256: manifest.sha256 });
|
|
325
|
+
assert.deepEqual(r2.restored.facts, { events: manifest.events, maxEventSeq: manifest.maxEventSeq });
|
|
326
|
+
assert.deepEqual(eventRows(r2.ledger), rows, 'the restored event sequence equals the source at the snapshot point');
|
|
327
|
+
assert.deepEqual(r2.ledger.readWishPool(), wishes);
|
|
328
|
+
assert.equal(integrityOf(ledgerDbPath(r2.tenantRoot)), 'ok');
|
|
329
|
+
assert.equal(r2.ledger.tenant, TENANT);
|
|
330
|
+
assert.deepEqual(JSON.parse(Buffer.from(await world.store.get(baseKey(2))).toString('utf8')), { v: 1, epoch: 2, from: { epoch: 1, seq: 1, sha256: manifest.sha256 } });
|
|
331
|
+
});
|
|
332
|
+
await check('R2 lineage: the newest epoch with evidence wins, and an epoch that only pinned a base hands that pin on', async () => {
|
|
333
|
+
const world = makeWorld('r2');
|
|
334
|
+
const r1 = await boot(world, 'r1');
|
|
335
|
+
appendEvents(r1.ledger, 'a', 3);
|
|
336
|
+
await publishSnapshot(r1.context);
|
|
337
|
+
appendEvents(r1.ledger, 'b', 3);
|
|
338
|
+
const e1s2 = await publishSnapshot(r1.context);
|
|
339
|
+
await r1.lease.release();
|
|
340
|
+
const r2 = await boot(world, 'r2');
|
|
341
|
+
assert.equal(r2.restored.restoredFrom?.seq, 2);
|
|
342
|
+
assert.equal(r2.restored.restoredFrom?.epoch, 1);
|
|
343
|
+
appendEvents(r2.ledger, 'c', 4);
|
|
344
|
+
const e2s1 = await publishSnapshot(r2.context);
|
|
345
|
+
await r2.lease.release();
|
|
346
|
+
const r3 = await boot(world, 'r3');
|
|
347
|
+
assert.deepEqual(r3.restored.restoredFrom, { epoch: 2, seq: 1, sha256: e2s1.sha256 });
|
|
348
|
+
assert.deepEqual(idemKeys(r3.ledger), ['a-1', 'a-2', 'a-3', 'b-1', 'b-2', 'b-3', 'c-1', 'c-2', 'c-3', 'c-4']);
|
|
349
|
+
await r3.lease.release();
|
|
350
|
+
const r4 = await boot(world, 'r4');
|
|
351
|
+
assert.deepEqual(r4.restored.restoredFrom, { epoch: 2, seq: 1, sha256: e2s1.sha256 }, 'epoch 3 published nothing: epoch 4 follows the base that epoch 3 pinned');
|
|
352
|
+
assert.notEqual(e1s2.sha256, e2s1.sha256);
|
|
353
|
+
});
|
|
354
|
+
await check('R3 a snapshot a superseded owner uploads after a newer epoch pinned its base is never restored', async () => {
|
|
355
|
+
const world = makeWorld('r3');
|
|
356
|
+
const frozen = world.replicaClock();
|
|
357
|
+
const r1 = await boot(world, 'r1', { clock: frozen });
|
|
358
|
+
appendEvents(r1.ledger, 'k', 3);
|
|
359
|
+
const pinned = await publishSnapshot(r1.context);
|
|
360
|
+
appendEvents(r1.ledger, 'late', 2);
|
|
361
|
+
world.advance(TTL + 1, [frozen]);
|
|
362
|
+
const r2 = await boot(world, 'r2');
|
|
363
|
+
assert.deepEqual(r2.restored.restoredFrom, { epoch: 1, seq: 1, sha256: pinned.sha256 });
|
|
364
|
+
r1.lease.assertHeld();
|
|
365
|
+
const late = await publishSnapshot(r1.context);
|
|
366
|
+
assert.equal(late.epoch, 1);
|
|
367
|
+
assert.equal(late.seq, 2, 'the paused owner passed its local gate and uploaded into its own, now dead, prefix');
|
|
368
|
+
world.advance(TTL + 1, [frozen]);
|
|
369
|
+
const r3 = await boot(world, 'r3');
|
|
370
|
+
assert.deepEqual(r3.restored.restoredFrom, { epoch: 1, seq: 1, sha256: pinned.sha256 }, 'epoch 2 pinned seq 1, so seq 2 is above the pin');
|
|
371
|
+
assert.deepEqual(idemKeys(r3.ledger), ['k-1', 'k-2', 'k-3']);
|
|
372
|
+
});
|
|
373
|
+
await check('R4 a corrupt newest snapshot is passed over for the previous one, and the skip is reported', async () => {
|
|
374
|
+
const world = makeWorld('r4');
|
|
375
|
+
const r1 = await boot(world, 'r1');
|
|
376
|
+
appendEvents(r1.ledger, 'a', 4);
|
|
377
|
+
await publishSnapshot(r1.context);
|
|
378
|
+
appendEvents(r1.ledger, 'b', 4);
|
|
379
|
+
await publishSnapshot(r1.context);
|
|
380
|
+
await r1.lease.release();
|
|
381
|
+
const image = world.pathOf(snapKey(1, 2, 'db'));
|
|
382
|
+
flipByte(image, Math.floor(readFileSync(image).length / 2));
|
|
383
|
+
const r2 = await boot(world, 'r2');
|
|
384
|
+
assert.equal(r2.restored.restoredFrom?.seq, 1);
|
|
385
|
+
assert.equal(r2.restored.skipped.length, 1);
|
|
386
|
+
assert.match(r2.restored.skipped[0], /e1\/snap-2: checksum differs from its manifest/);
|
|
387
|
+
assert.deepEqual(idemKeys(r2.ledger), ['a-1', 'a-2', 'a-3', 'a-4']);
|
|
388
|
+
});
|
|
389
|
+
await check('R5 when every copy is corrupt the restore fails loudly, writes no base and serves nothing', async () => {
|
|
390
|
+
const world = makeWorld('r5');
|
|
391
|
+
const r1 = await boot(world, 'r1');
|
|
392
|
+
appendEvents(r1.ledger, 'a', 3);
|
|
393
|
+
await publishSnapshot(r1.context);
|
|
394
|
+
await r1.lease.release();
|
|
395
|
+
flipByte(world.pathOf(snapKey(1, 1, 'db')), 0);
|
|
396
|
+
const { lease, context } = await acquire(world, 'r2', world.replicaClock());
|
|
397
|
+
await assert.rejects(() => restoreTenantLedger(context), (error) => error instanceof RestoreVerificationError && error.skipped.length === 1);
|
|
398
|
+
assert.deepEqual(await world.store.list(prefixOf(2)), [], 'no base was pinned for the failed epoch');
|
|
399
|
+
await lease.release();
|
|
400
|
+
});
|
|
401
|
+
await check('R6 an image of another tenant under this tenant\'s key is refused although its checksum and manifest are valid', async () => {
|
|
402
|
+
const world = makeWorld('r6');
|
|
403
|
+
const image = ledgerImage('r6-foreign', OTHER_TENANT, (ledger) => { appendEvents(ledger, 'o', 3); });
|
|
404
|
+
const bytes = readFileSync(image);
|
|
405
|
+
const facts = new Database(image, { readonly: true });
|
|
406
|
+
const counts = facts.prepare('SELECT COUNT(*) AS n, MAX(seq) AS m FROM events').get();
|
|
407
|
+
facts.close();
|
|
408
|
+
assert.equal(await world.store.putIfAbsent(snapKey(1, 1, 'db'), bytes), true);
|
|
409
|
+
const manifest = { v: 1, tenant: TENANT, epoch: 1, seq: 1, sha256: sha256(bytes), bytes: bytes.byteLength, events: counts.n, maxEventSeq: counts.m, createdAtMs: 0 };
|
|
410
|
+
assert.equal(await world.store.putIfAbsent(snapKey(1, 1, 'json'), Buffer.from(JSON.stringify(manifest))), true);
|
|
411
|
+
const first = await acquire(world, 'r1', world.replicaClock());
|
|
412
|
+
assert.equal(first.lease.epoch, 1);
|
|
413
|
+
await first.lease.release();
|
|
414
|
+
const second = await acquire(world, 'r2', world.replicaClock());
|
|
415
|
+
assert.equal(second.lease.epoch, 2, 'the planted copy sits in epoch 1, below this holder');
|
|
416
|
+
await assert.rejects(() => restoreTenantLedger(second.context), (error) => error instanceof RestoreVerificationError && /another tenant/.test(error.skipped.join(' ')));
|
|
417
|
+
await second.lease.release();
|
|
418
|
+
});
|
|
419
|
+
await check('R7 a copy conflict is loud: a rogue base in this epoch, or a snapshot slot that is already taken', async () => {
|
|
420
|
+
const world = makeWorld('r7');
|
|
421
|
+
const { lease, context } = await acquire(world, 'r1', world.replicaClock());
|
|
422
|
+
assert.equal(await world.store.putIfAbsent(baseKey(1), Buffer.from('{"rogue":true}')), true);
|
|
423
|
+
await assert.rejects(() => restoreTenantLedger(context), CopyConflictError);
|
|
424
|
+
await lease.release();
|
|
425
|
+
const r2 = await boot(world, 'r2');
|
|
426
|
+
appendEvents(r2.ledger, 'k', 2);
|
|
427
|
+
world.durable.conflictNextPut = /snap-000001\.db$/;
|
|
428
|
+
await assert.rejects(() => publishSnapshot(r2.context), CopyConflictError);
|
|
429
|
+
});
|
|
430
|
+
await check('R8 epochs past eight digits are still found, so a flapping loop that burns epochs cannot hide the copies', async () => {
|
|
431
|
+
const world = makeWorld('r8');
|
|
432
|
+
const r1 = await boot(world, 'r1');
|
|
433
|
+
appendEvents(r1.ledger, 'k', 3);
|
|
434
|
+
const manifest = await publishSnapshot(r1.context);
|
|
435
|
+
const image = await world.store.get(snapKey(1, 1, 'db'));
|
|
436
|
+
const far = 100_000_001;
|
|
437
|
+
const farKey = (name) => `${KEY}/e${far}/${name}`;
|
|
438
|
+
assert.equal(await world.store.putIfAbsent(farKey('snap-000001.db'), image), true);
|
|
439
|
+
assert.equal(await world.store.putIfAbsent(farKey('snap-000001.json'), Buffer.from(JSON.stringify({ ...manifest, epoch: far }))), true);
|
|
440
|
+
const resolution = await resolveRestorePoint(world.store, KEY, TENANT, far + 1, 0, () => undefined);
|
|
441
|
+
assert.equal(resolution.point?.manifest.epoch, far);
|
|
442
|
+
assert.equal(resolution.point?.manifest.seq, 1);
|
|
443
|
+
});
|
|
444
|
+
// ---- X: takeover ---------------------------------------------------------------------------------------
|
|
445
|
+
console.log('-- X takeover');
|
|
446
|
+
await check('X1 a paused owner uploads after the takeover; the next owner still restores exactly the lineage of the one that took over', async () => {
|
|
447
|
+
const world = makeWorld('x1');
|
|
448
|
+
const frozen = world.replicaClock();
|
|
449
|
+
const r1 = await boot(world, 'r1', { clock: frozen });
|
|
450
|
+
const snapshotted = appendEvents(r1.ledger, 'k', 20);
|
|
451
|
+
await publishSnapshot(r1.context);
|
|
452
|
+
appendEvents(r1.ledger, 'u', 5);
|
|
453
|
+
world.advance(TTL + 1, [frozen]);
|
|
454
|
+
const r2 = await boot(world, 'r2');
|
|
455
|
+
assert.deepEqual(idemKeys(r2.ledger), snapshotted, 'the five unsnapshotted writes of the dead owner are the declared RPO');
|
|
456
|
+
const taken = appendEvents(r2.ledger, 'v', 10);
|
|
457
|
+
await publishSnapshot(r2.context);
|
|
458
|
+
const late = await publishSnapshot(r1.context);
|
|
459
|
+
assert.equal(late.events >= 25, true, 'the paused owner, blind to the takeover, uploaded all 25 of its events into its dead prefix');
|
|
460
|
+
await assert.rejects(() => r1.lease.renew(), LeaseLostError, 'the lease store ends it the moment it asks');
|
|
461
|
+
world.advance(TTL + 1, [frozen]);
|
|
462
|
+
const r3 = await boot(world, 'r3');
|
|
463
|
+
assert.equal(r3.lease.epoch, 3);
|
|
464
|
+
assert.deepEqual(idemKeys(r3.ledger), [...snapshotted, ...taken]);
|
|
465
|
+
assert.equal(idemKeys(r3.ledger).some(key => key.startsWith('u-')), false);
|
|
466
|
+
assert.equal(integrityOf(ledgerDbPath(r3.tenantRoot)), 'ok');
|
|
467
|
+
});
|
|
468
|
+
await check('X2 a replica that takes the tenant again on the same node discards its own stale local file', async () => {
|
|
469
|
+
const world = makeWorld('x2');
|
|
470
|
+
const r1 = await boot(world, 'r1', { node: 'node-a' });
|
|
471
|
+
const kept = appendEvents(r1.ledger, 'k', 6);
|
|
472
|
+
await publishSnapshot(r1.context);
|
|
473
|
+
appendEvents(r1.ledger, 'local-only', 4);
|
|
474
|
+
assert.equal(idemKeys(r1.ledger).length, 10);
|
|
475
|
+
world.advance(TTL + 1);
|
|
476
|
+
const again = await boot(world, 'r1-again', { node: 'node-a' });
|
|
477
|
+
assert.equal(again.lease.epoch, 2);
|
|
478
|
+
assert.deepEqual(idemKeys(again.ledger), kept, 'only the durable copy is authoritative; the four local-only events are gone');
|
|
479
|
+
assert.equal(integrityOf(ledgerDbPath(again.tenantRoot)), 'ok');
|
|
480
|
+
});
|
|
481
|
+
await check('X3 known limit of mode D-a: a frozen-clock owner can land a snapshot between its successor taking the lease and pinning its base, and that snapshot is restored', async () => {
|
|
482
|
+
const world = makeWorld('x3');
|
|
483
|
+
const frozen = world.replicaClock();
|
|
484
|
+
const r1 = await boot(world, 'r1', { clock: frozen });
|
|
485
|
+
appendEvents(r1.ledger, 'k', 3);
|
|
486
|
+
await publishSnapshot(r1.context);
|
|
487
|
+
appendEvents(r1.ledger, 'after-takeover', 2);
|
|
488
|
+
world.advance(TTL + 1, [frozen]);
|
|
489
|
+
// The paused owner wakes with its clock where it left it and uploads while its successor is still restoring.
|
|
490
|
+
world.durable.beforeNextList = async () => { await publishSnapshot(r1.context); };
|
|
491
|
+
const r2 = await boot(world, 'r2');
|
|
492
|
+
assert.equal(r2.lease.epoch, 2);
|
|
493
|
+
assert.deepEqual(idemKeys(r2.ledger), ['k-1', 'k-2', 'k-3', 'after-takeover-1', 'after-takeover-2'], 'KNOWN LIMIT: durability mode D-a cannot refuse an upload that lands before the base is pinned. FLIP: when the window is closed (the epoch-conditional append of mode D-b, or a seal on the previous epoch), expect only k-1..k-3 here and remove the limit from RFC section 4.3');
|
|
494
|
+
assert.equal(r2.restored.restoredFrom?.seq, 2);
|
|
495
|
+
});
|
|
496
|
+
// ---- F: forget ----------------------------------------------------------------------------------------------
|
|
497
|
+
console.log('-- F forget');
|
|
498
|
+
await check('F1 forget deletes every durable copy and the local files, raises the floor, and hands the lease back', async () => {
|
|
499
|
+
const world = makeWorld('f1');
|
|
500
|
+
const r1 = await boot(world, 'r1');
|
|
501
|
+
appendEvents(r1.ledger, 'k', 5);
|
|
502
|
+
await publishSnapshot(r1.context);
|
|
503
|
+
await publishSnapshot(r1.context);
|
|
504
|
+
assert.equal((await world.store.list(`${KEY}/`)).length, 5);
|
|
505
|
+
r1.ledger.close();
|
|
506
|
+
const outcome = await forgetTenant(r1.context);
|
|
507
|
+
assert.deepEqual(outcome, { deletedObjects: 5, floor: 1 });
|
|
508
|
+
assert.deepEqual(await world.store.list(`${KEY}/`), []);
|
|
509
|
+
assert.equal(await world.store.get(snapKey(1, 1, 'db')), null);
|
|
510
|
+
assert.throws(() => readFileSync(ledgerDbPath(r1.tenantRoot)), /ENOENT/);
|
|
511
|
+
const record = await world.leases.read(KEY);
|
|
512
|
+
assert.equal(record?.forgottenAtEpoch, 1);
|
|
513
|
+
assert.ok(record && record.leaseUntilMs <= world.storeClock.nowMs(), 'the lease was handed back');
|
|
514
|
+
assert.equal(r1.lease.isHeld(), false);
|
|
515
|
+
const r2 = await boot(world, 'r2');
|
|
516
|
+
assert.equal(r2.lease.epoch, 2);
|
|
517
|
+
assert.equal(r2.restored.restoredFrom, null);
|
|
518
|
+
assert.deepEqual(idemKeys(r2.ledger), [], 'the tenant starts from nothing');
|
|
519
|
+
});
|
|
520
|
+
await check('F2 a copy that a superseded owner uploads after the forget is masked by the floor and removed by the sweep', async () => {
|
|
521
|
+
const world = makeWorld('f2');
|
|
522
|
+
const frozen = world.replicaClock();
|
|
523
|
+
const r1 = await boot(world, 'r1', { clock: frozen });
|
|
524
|
+
appendEvents(r1.ledger, 'k', 5);
|
|
525
|
+
const old = await publishSnapshot(r1.context);
|
|
526
|
+
const oldImage = Buffer.from(await world.store.get(snapKey(1, 1, 'db')));
|
|
527
|
+
const oldManifest = Buffer.from(await world.store.get(snapKey(1, 1, 'json')));
|
|
528
|
+
r1.ledger.close();
|
|
529
|
+
world.advance(TTL + 1, [frozen]);
|
|
530
|
+
const r2 = await boot(world, 'r2');
|
|
531
|
+
r2.ledger.close();
|
|
532
|
+
await forgetTenant(r2.context);
|
|
533
|
+
assert.equal(old.epoch, 1);
|
|
534
|
+
// The paused owner of epoch 1 wakes after the forget and re-creates its snapshot.
|
|
535
|
+
assert.equal(await world.store.putIfAbsent(snapKey(1, 1, 'db'), oldImage), true);
|
|
536
|
+
assert.equal(await world.store.putIfAbsent(snapKey(1, 1, 'json'), oldManifest), true);
|
|
537
|
+
assert.equal((await world.store.list(`${KEY}/`)).length, 2);
|
|
538
|
+
const r3 = await boot(world, 'r3');
|
|
539
|
+
assert.equal(r3.lease.epoch, 3);
|
|
540
|
+
assert.equal(r3.lease.forgottenAtEpoch, 2);
|
|
541
|
+
assert.equal(r3.restored.restoredFrom, null, 'the floor masks every copy at or below epoch 2');
|
|
542
|
+
assert.deepEqual(idemKeys(r3.ledger), []);
|
|
543
|
+
assert.equal(await sweepMaskedCopies(world.store, KEY, r3.lease.forgottenAtEpoch), 2);
|
|
544
|
+
assert.deepEqual(await world.store.list(`${KEY}/e00000001/`), []);
|
|
545
|
+
});
|
|
546
|
+
await check('F3 a fenced holder cannot forget, and a refused forget deletes nothing', async () => {
|
|
547
|
+
const world = makeWorld('f3');
|
|
548
|
+
const r1 = await boot(world, 'r1');
|
|
549
|
+
appendEvents(r1.ledger, 'k', 2);
|
|
550
|
+
await publishSnapshot(r1.context);
|
|
551
|
+
const before = await world.store.list(`${KEY}/`);
|
|
552
|
+
world.advance(TTL - MARGIN);
|
|
553
|
+
await assertFenced(forgetTenant(r1.context), 'lease_expired_locally');
|
|
554
|
+
assert.deepEqual(await world.store.list(`${KEY}/`), before);
|
|
555
|
+
assert.equal((await world.leases.read(KEY))?.forgottenAtEpoch, 0);
|
|
556
|
+
});
|
|
557
|
+
// ---- I: faults ---------------------------------------------------------------------------------------------------
|
|
558
|
+
console.log('-- I faults');
|
|
559
|
+
await check('I1 a durable store outage fails publishing and restoring with a typed error, loses no local write, and publishing resumes with the next sequence', async () => {
|
|
560
|
+
const world = makeWorld('i1');
|
|
561
|
+
const r1 = await boot(world, 'r1');
|
|
562
|
+
appendEvents(r1.ledger, 'k', 3);
|
|
563
|
+
await publishSnapshot(r1.context);
|
|
564
|
+
world.durable.down = true;
|
|
565
|
+
appendEvents(r1.ledger, 'm', 3);
|
|
566
|
+
await assert.rejects(() => publishSnapshot(r1.context), DurableStoreUnavailableError);
|
|
567
|
+
assert.equal(idemKeys(r1.ledger).length, 6, 'writes are acknowledged locally; replication lag simply grows');
|
|
568
|
+
world.durable.down = false;
|
|
569
|
+
assert.equal((await publishSnapshot(r1.context)).seq, 2);
|
|
570
|
+
await r1.lease.release();
|
|
571
|
+
world.durable.down = true;
|
|
572
|
+
const { lease, context } = await acquire(world, 'r2', world.replicaClock());
|
|
573
|
+
await assert.rejects(() => restoreTenantLedger(context), DurableStoreUnavailableError);
|
|
574
|
+
world.durable.down = false;
|
|
575
|
+
assert.deepEqual((await world.store.list(prefixOf(2))), [], 'the failed restore pinned no base');
|
|
576
|
+
await lease.release();
|
|
577
|
+
});
|
|
578
|
+
await check('I2 a crash between an image and its manifest leaves an orphan that restores ignore and the next publish skips past', async () => {
|
|
579
|
+
const world = makeWorld('i2');
|
|
580
|
+
const r1 = await boot(world, 'r1');
|
|
581
|
+
appendEvents(r1.ledger, 'a', 3);
|
|
582
|
+
await publishSnapshot(r1.context);
|
|
583
|
+
appendEvents(r1.ledger, 'b', 3);
|
|
584
|
+
world.durable.failNextPut = /snap-000002\.json$/;
|
|
585
|
+
await assert.rejects(() => publishSnapshot(r1.context), DurableStoreUnavailableError);
|
|
586
|
+
assert.ok(await world.store.get(snapKey(1, 2, 'db')), 'the image was uploaded before the failure');
|
|
587
|
+
assert.equal(await world.store.get(snapKey(1, 2, 'json')), null, 'and its manifest was not');
|
|
588
|
+
assert.equal((await publishSnapshot(r1.context)).seq, 3, 'the orphan slot is not reused');
|
|
589
|
+
await r1.lease.release();
|
|
590
|
+
const r2 = await boot(world, 'r2');
|
|
591
|
+
assert.equal(r2.restored.restoredFrom?.seq, 3);
|
|
592
|
+
assert.deepEqual(r2.restored.skipped, [], 'an orphan image is not a corrupt snapshot');
|
|
593
|
+
assert.equal(idemKeys(r2.ledger).length, 6);
|
|
594
|
+
});
|
|
595
|
+
console.log(`tenant-state-copies-tests: ${passed} checks passed`);
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
for (const ledger of openLedgers) {
|
|
599
|
+
try {
|
|
600
|
+
ledger.close();
|
|
601
|
+
}
|
|
602
|
+
catch { /* already closed */ }
|
|
603
|
+
}
|
|
604
|
+
for (const world of worlds)
|
|
605
|
+
world.leases.close();
|
|
606
|
+
rmSync(root, { recursive: true, force: true });
|
|
607
|
+
}
|