@danceiny/gotry 0.2.0-rc.31 → 0.2.0-rc.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +63 -53
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/client/client.js +66 -21
- package/dist/capabilities/doctor.js +7 -8
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +148 -47
- package/dist/capabilities/session/gui-flight.js +949 -0
- package/dist/capabilities/session/gui-hotel.js +470 -0
- package/dist/capabilities/session-search.js +102 -14
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bootstrap-tests.js +23 -6
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +19 -4
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +5 -0
- package/dist/scripts/gotry-web-client-tests.js +20 -1
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +1 -1
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +211 -6
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/gotry-web-api.js +64 -2
- package/dist/src/index.js +70 -18
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +5 -1
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +185 -67
- package/ts/capabilities/session/gui-flight.ts +1022 -0
- package/ts/capabilities/session/gui-hotel.ts +505 -0
- package/ts/capabilities/session-search.ts +130 -18
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/gotry-web-api.ts +51 -2
- package/ts/src/index.ts +67 -19
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures of the tenant-state suites (AaaS A1): fake clocks for the offline, deterministic runs.
|
|
3
|
+
*/
|
|
4
|
+
import { DurableStoreUnavailableError } from '../src/tenant-state/errors.js';
|
|
5
|
+
export class ManualClock {
|
|
6
|
+
now;
|
|
7
|
+
constructor(start) {
|
|
8
|
+
this.now = start;
|
|
9
|
+
}
|
|
10
|
+
nowMs() {
|
|
11
|
+
return this.now;
|
|
12
|
+
}
|
|
13
|
+
advance(ms) {
|
|
14
|
+
this.now += ms;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** A clock that jumps forward by itself on one read, to trip a gate that sits between two reads. */
|
|
18
|
+
export class TrippingClock extends ManualClock {
|
|
19
|
+
readsUntilJump = -1;
|
|
20
|
+
jumpMs = 0;
|
|
21
|
+
/** After `reads` more reads, the next read first moves the clock forward by `jumpMs`. */
|
|
22
|
+
arm(reads, jumpMs) {
|
|
23
|
+
this.readsUntilJump = reads;
|
|
24
|
+
this.jumpMs = jumpMs;
|
|
25
|
+
}
|
|
26
|
+
nowMs() {
|
|
27
|
+
if (this.readsUntilJump === 0) {
|
|
28
|
+
this.readsUntilJump = -1;
|
|
29
|
+
this.advance(this.jumpMs);
|
|
30
|
+
}
|
|
31
|
+
else if (this.readsUntilJump > 0) {
|
|
32
|
+
this.readsUntilJump -= 1;
|
|
33
|
+
}
|
|
34
|
+
return super.nowMs();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** A durable store with an outage switch and one-shot failures, to cut the network at chosen moments. */
|
|
38
|
+
export class FaultyDurableStore {
|
|
39
|
+
down = false;
|
|
40
|
+
failNextPut = null;
|
|
41
|
+
conflictNextPut = null;
|
|
42
|
+
/** Runs once, just before the next listing is answered: a way to land another replica's upload inside a restore. */
|
|
43
|
+
beforeNextList = null;
|
|
44
|
+
/** While set, every put whose key matches waits this long first: an object store that is slow to take an image. */
|
|
45
|
+
slowPut = null;
|
|
46
|
+
/** Fails the next delete once, to see what a failed sweep does to an open. */
|
|
47
|
+
failNextDelete = false;
|
|
48
|
+
inner;
|
|
49
|
+
constructor(inner) {
|
|
50
|
+
this.inner = inner;
|
|
51
|
+
}
|
|
52
|
+
gate() {
|
|
53
|
+
if (this.down)
|
|
54
|
+
throw new DurableStoreUnavailableError('injected outage');
|
|
55
|
+
}
|
|
56
|
+
async putIfAbsent(key, bytes) {
|
|
57
|
+
this.gate();
|
|
58
|
+
const slow = this.slowPut;
|
|
59
|
+
if (slow?.match.test(key))
|
|
60
|
+
await new Promise((resolve) => { setTimeout(resolve, slow.ms); });
|
|
61
|
+
if (this.failNextPut?.test(key)) {
|
|
62
|
+
this.failNextPut = null;
|
|
63
|
+
throw new DurableStoreUnavailableError(`injected failure for ${key}`);
|
|
64
|
+
}
|
|
65
|
+
if (this.conflictNextPut?.test(key)) {
|
|
66
|
+
this.conflictNextPut = null;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return this.inner.putIfAbsent(key, bytes);
|
|
70
|
+
}
|
|
71
|
+
async get(key) {
|
|
72
|
+
this.gate();
|
|
73
|
+
return this.inner.get(key);
|
|
74
|
+
}
|
|
75
|
+
async list(prefix) {
|
|
76
|
+
this.gate();
|
|
77
|
+
const hook = this.beforeNextList;
|
|
78
|
+
if (hook) {
|
|
79
|
+
this.beforeNextList = null;
|
|
80
|
+
await hook();
|
|
81
|
+
}
|
|
82
|
+
return this.inner.list(prefix);
|
|
83
|
+
}
|
|
84
|
+
async deletePrefix(prefix) {
|
|
85
|
+
this.gate();
|
|
86
|
+
if (this.failNextDelete) {
|
|
87
|
+
this.failNextDelete = false;
|
|
88
|
+
throw new DurableStoreUnavailableError(`injected delete failure for ${prefix}`);
|
|
89
|
+
}
|
|
90
|
+
return this.inner.deletePrefix(prefix);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant ownership core (AaaS A1.1, docs/rfc/aaas-rfc.md §4.2 items 1, 2, 4 and 6; threats S-1/S-3, S-6).
|
|
3
|
+
*
|
|
4
|
+
* Offline and deterministic: SQLite lease files in mkdtemp directories, fake clocks for expiry and
|
|
5
|
+
* self-fencing, and a few real child processes racing one lease file. No network, no model, no shared
|
|
6
|
+
* state write; the founder's runtime state root is never opened.
|
|
7
|
+
*
|
|
8
|
+
* A tenant and organization ids: derivation, path-safe validators, nothing touches the filesystem first.
|
|
9
|
+
* B the reference lease store passes the LeaseStore conformance checks; its triggers refuse an epoch
|
|
10
|
+
* that goes backwards or skips, and a deleted lease row.
|
|
11
|
+
* C the conformance checks can fail: three deliberately broken stores each turn named checks red.
|
|
12
|
+
* D the holder session: the fence deadline, renewal, outage, takeover, a frozen clock, graceful
|
|
13
|
+
* release, a round trip that eats the lease, and call ordering.
|
|
14
|
+
* E real processes: six race one acquisition and exactly one wins; the same after an expiry.
|
|
15
|
+
*
|
|
16
|
+
* Run: cd ts && npx tsx scripts/tenant-state-tests.ts
|
|
17
|
+
*/
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { createHmac } from 'node:crypto';
|
|
21
|
+
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import Database from 'better-sqlite3';
|
|
27
|
+
import { FencedError, InvalidTenantIdError, LeaseHeldError, LeaseLostError, LeaseStoreUnavailableError, TenantStateError, } from '../src/tenant-state/errors.js';
|
|
28
|
+
import { assertLedgerTenantId, assertOrganizationId, 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 { runLeaseStoreConformance } from './tenant-state-lease-conformance.js';
|
|
32
|
+
import { ManualClock } from './tenant-state-test-kit.js';
|
|
33
|
+
const TS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const WORKER = join(TS_DIR, 'scripts', 'tenant-state-lease-worker.ts');
|
|
35
|
+
let passed = 0;
|
|
36
|
+
async function check(label, assertion) {
|
|
37
|
+
await assertion();
|
|
38
|
+
passed += 1;
|
|
39
|
+
console.log(` ok - ${label}`);
|
|
40
|
+
}
|
|
41
|
+
/** A lease store whose network can be cut, and whose acquire can be made slow. */
|
|
42
|
+
class FaultyLeaseStore {
|
|
43
|
+
down = false;
|
|
44
|
+
afterAcquire = null;
|
|
45
|
+
inner;
|
|
46
|
+
constructor(inner) {
|
|
47
|
+
this.inner = inner;
|
|
48
|
+
}
|
|
49
|
+
gate() {
|
|
50
|
+
if (this.down)
|
|
51
|
+
throw new LeaseStoreUnavailableError('injected outage');
|
|
52
|
+
}
|
|
53
|
+
async read(key) {
|
|
54
|
+
this.gate();
|
|
55
|
+
return this.inner.read(key);
|
|
56
|
+
}
|
|
57
|
+
async acquire(key, owner, ttlMs) {
|
|
58
|
+
this.gate();
|
|
59
|
+
const grant = await this.inner.acquire(key, owner, ttlMs);
|
|
60
|
+
this.afterAcquire?.();
|
|
61
|
+
return grant;
|
|
62
|
+
}
|
|
63
|
+
async renew(key, owner, epoch, ttlMs) {
|
|
64
|
+
this.gate();
|
|
65
|
+
return this.inner.renew(key, owner, epoch, ttlMs);
|
|
66
|
+
}
|
|
67
|
+
async release(key, owner, epoch) {
|
|
68
|
+
this.gate();
|
|
69
|
+
return this.inner.release(key, owner, epoch);
|
|
70
|
+
}
|
|
71
|
+
async markForgotten(key, owner, epoch) {
|
|
72
|
+
this.gate();
|
|
73
|
+
return this.inner.markForgotten(key, owner, epoch);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Delegates everything; each broken store below overrides exactly one behavior. */
|
|
77
|
+
class DelegatingLeaseStore {
|
|
78
|
+
inner;
|
|
79
|
+
constructor(inner) {
|
|
80
|
+
this.inner = inner;
|
|
81
|
+
}
|
|
82
|
+
read(key) { return this.inner.read(key); }
|
|
83
|
+
acquire(key, owner, ttlMs) { return this.inner.acquire(key, owner, ttlMs); }
|
|
84
|
+
renew(key, owner, epoch, ttlMs) { return this.inner.renew(key, owner, epoch, ttlMs); }
|
|
85
|
+
release(key, owner, epoch) { return this.inner.release(key, owner, epoch); }
|
|
86
|
+
markForgotten(key, owner, epoch) { return this.inner.markForgotten(key, owner, epoch); }
|
|
87
|
+
}
|
|
88
|
+
/** No compare-and-set: acquiring evicts whoever holds the lease. */
|
|
89
|
+
class EvictingLeaseStore extends DelegatingLeaseStore {
|
|
90
|
+
async acquire(key, owner, ttlMs) {
|
|
91
|
+
const current = await this.inner.read(key);
|
|
92
|
+
if (current)
|
|
93
|
+
await this.inner.release(key, current.owner, current.epoch);
|
|
94
|
+
return this.inner.acquire(key, owner, ttlMs);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** The epoch is no fencing token: every grant reports epoch 1. */
|
|
98
|
+
class FlatEpochLeaseStore extends DelegatingLeaseStore {
|
|
99
|
+
async acquire(key, owner, ttlMs) {
|
|
100
|
+
const grant = await this.inner.acquire(key, owner, ttlMs);
|
|
101
|
+
return { ...grant, record: { ...grant.record, epoch: 1 } };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Renewal does not check who asks: a stale holder revives the current lease. */
|
|
105
|
+
class TokenBlindRenewLeaseStore extends DelegatingLeaseStore {
|
|
106
|
+
async renew(key, owner, epoch, ttlMs) {
|
|
107
|
+
const current = await this.inner.read(key);
|
|
108
|
+
if (!current)
|
|
109
|
+
throw new LeaseLostError(`${key}: unknown`);
|
|
110
|
+
return this.inner.renew(key, current.owner, current.epoch, ttlMs);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Every renewal and release takes a little real time, and the store counts how many are in flight at once. */
|
|
114
|
+
class CountingLeaseStore extends DelegatingLeaseStore {
|
|
115
|
+
inFlight = 0;
|
|
116
|
+
maxInFlight = 0;
|
|
117
|
+
async tracked(run) {
|
|
118
|
+
this.inFlight += 1;
|
|
119
|
+
this.maxInFlight = Math.max(this.maxInFlight, this.inFlight);
|
|
120
|
+
try {
|
|
121
|
+
await sleep(5);
|
|
122
|
+
return await run();
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
this.inFlight -= 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
renew(key, owner, epoch, ttlMs) {
|
|
129
|
+
return this.tracked(() => this.inner.renew(key, owner, epoch, ttlMs));
|
|
130
|
+
}
|
|
131
|
+
release(key, owner, epoch) {
|
|
132
|
+
return this.tracked(() => this.inner.release(key, owner, epoch));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function fencedReason(run) {
|
|
136
|
+
try {
|
|
137
|
+
run();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (error instanceof FencedError)
|
|
141
|
+
return error.reason;
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-tenant-state-'));
|
|
147
|
+
const children = new Set();
|
|
148
|
+
try {
|
|
149
|
+
// ---- A: ids ----------------------------------------------------------------
|
|
150
|
+
console.log('-- A tenant and organization ids');
|
|
151
|
+
const ORG_KEY = Buffer.alloc(32, 7);
|
|
152
|
+
const ISSUER = 'https://idp.example/acme';
|
|
153
|
+
const TENANT_A = deriveLedgerTenantId(ORG_KEY, ISSUER, 'user-1');
|
|
154
|
+
const TENANT_B = deriveLedgerTenantId(ORG_KEY, ISSUER, 'user-2');
|
|
155
|
+
await check('A1 derivation is HMAC-SHA256 over issuer, NUL and subject, truncated to 128 bits (known answer)', () => {
|
|
156
|
+
assert.equal(TENANT_A, 't-3129611eac9be1d5994a2b216b06ed27');
|
|
157
|
+
const independent = createHmac('sha256', ORG_KEY).update(`${ISSUER}\u0000user-1`).digest('hex').slice(0, 32);
|
|
158
|
+
assert.equal(TENANT_A, `t-${independent}`);
|
|
159
|
+
assert.equal(deriveLedgerTenantId(ORG_KEY, ISSUER, 'user-1'), TENANT_A, 'deterministic');
|
|
160
|
+
});
|
|
161
|
+
await check('A2 the id changes with the key, the issuer and the subject; the separator keeps field boundaries', () => {
|
|
162
|
+
assert.notEqual(TENANT_A, TENANT_B);
|
|
163
|
+
assert.notEqual(TENANT_A, deriveLedgerTenantId(Buffer.alloc(32, 8), ISSUER, 'user-1'));
|
|
164
|
+
assert.notEqual(TENANT_A, deriveLedgerTenantId(ORG_KEY, 'https://idp.example/other', 'user-1'));
|
|
165
|
+
assert.notEqual(deriveLedgerTenantId(ORG_KEY, 'ab', 'c'), deriveLedgerTenantId(ORG_KEY, 'a', 'bc'));
|
|
166
|
+
});
|
|
167
|
+
await check('A3 non-ASCII and astral subjects derive; lone surrogates, NUL, empty, oversized and short keys are refused', () => {
|
|
168
|
+
assert.match(deriveLedgerTenantId(ORG_KEY, ISSUER, '用户-é-😀'), /^t-[0-9a-f]{32}$/);
|
|
169
|
+
const refused = (run) => assert.throws(run, (error) => error instanceof InvalidTenantIdError && error.code === 'GOTRY_TENANT_ID_INVALID');
|
|
170
|
+
refused(() => deriveLedgerTenantId(ORG_KEY, ISSUER, '\ud800'));
|
|
171
|
+
refused(() => deriveLedgerTenantId(ORG_KEY, ISSUER, 'a\ud801b'));
|
|
172
|
+
refused(() => deriveLedgerTenantId(ORG_KEY, 'iss\u0000x', 'user'));
|
|
173
|
+
refused(() => deriveLedgerTenantId(ORG_KEY, ISSUER, 'user\u0000x'));
|
|
174
|
+
refused(() => deriveLedgerTenantId(ORG_KEY, '', 'user'));
|
|
175
|
+
refused(() => deriveLedgerTenantId(ORG_KEY, ISSUER, ''));
|
|
176
|
+
refused(() => deriveLedgerTenantId(ORG_KEY, ISSUER, 'x'.repeat(513)));
|
|
177
|
+
refused(() => deriveLedgerTenantId(Buffer.alloc(31, 7), ISSUER, 'user'));
|
|
178
|
+
refused(() => deriveLedgerTenantId('not-bytes', ISSUER, 'user'));
|
|
179
|
+
assert.match(deriveLedgerTenantId(ORG_KEY, ISSUER, 'x'.repeat(512)), /^t-[0-9a-f]{32}$/, '512 bytes is the inclusive limit');
|
|
180
|
+
});
|
|
181
|
+
await check('A4 validators accept the fixed formats and refuse path-shaped, cased, padded and non-string ids', () => {
|
|
182
|
+
for (const ok of ['acme', 'a', 'acme-eu-1', '0day'])
|
|
183
|
+
assert.equal(assertOrganizationId(ok), ok);
|
|
184
|
+
for (const bad of ['', 'Acme', '-acme', 'a_b', 'a/b', '..', '../x', 'a'.repeat(64), 'acme\n', ' acme', 'ac me', 123, null, undefined]) {
|
|
185
|
+
assert.throws(() => assertOrganizationId(bad), InvalidTenantIdError, `organization ${JSON.stringify(bad)}`);
|
|
186
|
+
}
|
|
187
|
+
assert.equal(assertLedgerTenantId(TENANT_A), TENANT_A);
|
|
188
|
+
const hex = TENANT_A.slice(2);
|
|
189
|
+
for (const bad of ['local', 't-', `t-${hex.slice(1)}`, `t-${hex}0`, `t-${hex.toUpperCase()}`, `T-${hex}`, `${TENANT_A}\n`, `../${TENANT_A}`, `${TENANT_A}/..`, `t-${'g'.repeat(32)}`, 42, null]) {
|
|
190
|
+
assert.throws(() => assertLedgerTenantId(bad), InvalidTenantIdError, `tenant ${JSON.stringify(bad)}`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
await check('A5 tenantRootFor and leaseKeyFor build from validated ids only, and refuse bad ids before any filesystem call', () => {
|
|
194
|
+
const stateRoot = join(root, 'ids-state');
|
|
195
|
+
assert.equal(tenantRootFor(stateRoot, 'acme', TENANT_A), join(stateRoot, 'orgs', 'acme', 'tenants', TENANT_A));
|
|
196
|
+
assert.equal(leaseKeyFor('acme', TENANT_A), `acme/${TENANT_A}`);
|
|
197
|
+
for (const [org, tenant] of [['..', TENANT_A], ['acme', '../../etc'], ['acme/x', TENANT_A], ['acme', `${TENANT_A}/../${TENANT_B}`], ['', TENANT_A]]) {
|
|
198
|
+
assert.throws(() => tenantRootFor(stateRoot, org, tenant), InvalidTenantIdError);
|
|
199
|
+
assert.throws(() => leaseKeyFor(org, tenant), InvalidTenantIdError);
|
|
200
|
+
}
|
|
201
|
+
assert.deepEqual(readdirSync(root).filter(name => name === 'ids-state'), [], 'no directory was created for any id, valid or not');
|
|
202
|
+
});
|
|
203
|
+
// ---- B: reference lease store ---------------------------------------------------
|
|
204
|
+
console.log('-- B reference lease store');
|
|
205
|
+
const conformanceClock = new ManualClock(1_000_000);
|
|
206
|
+
const conformanceDb = join(root, 'conformance', 'leases.db');
|
|
207
|
+
const referenceStore = new SqliteLeaseStore({ path: conformanceDb, nowMs: () => conformanceClock.nowMs() });
|
|
208
|
+
await check('B1 the reference store passes every LeaseStore conformance check', async () => {
|
|
209
|
+
const failures = await runLeaseStoreConformance({ store: referenceStore, advance: (ms) => conformanceClock.advance(ms), now: () => conformanceClock.nowMs() }, 'reference');
|
|
210
|
+
assert.deepEqual(failures, []);
|
|
211
|
+
});
|
|
212
|
+
await check('B2 the lease expires exactly when the store clock reaches its end, not before', async () => {
|
|
213
|
+
const key = 'boundary/b2';
|
|
214
|
+
const grant = await referenceStore.acquire(key, 'a', 1_000);
|
|
215
|
+
conformanceClock.advance(999);
|
|
216
|
+
await assert.rejects(() => referenceStore.acquire(key, 'b', 1_000), LeaseHeldError, 'one millisecond before the end');
|
|
217
|
+
conformanceClock.advance(1);
|
|
218
|
+
assert.equal(conformanceClock.nowMs(), grant.record.leaseUntilMs);
|
|
219
|
+
const next = await referenceStore.acquire(key, 'b', 1_000);
|
|
220
|
+
assert.equal(next.record.epoch, 2, 'at the end instant the lease is over');
|
|
221
|
+
});
|
|
222
|
+
await check('B3 storage triggers refuse an epoch that goes back or skips, a lower forgotten floor, and a deleted row', async () => {
|
|
223
|
+
const key = 'triggers/b3';
|
|
224
|
+
await referenceStore.acquire(key, 'a', 1_000);
|
|
225
|
+
await referenceStore.markForgotten(key, 'a', 1);
|
|
226
|
+
const raw = new Database(conformanceDb);
|
|
227
|
+
try {
|
|
228
|
+
raw.pragma('busy_timeout = 5000');
|
|
229
|
+
assert.throws(() => raw.prepare('UPDATE leases SET epoch = epoch - 1 WHERE key = ?').run(key), /moves forward by exactly one/);
|
|
230
|
+
assert.throws(() => raw.prepare('UPDATE leases SET epoch = epoch + 2 WHERE key = ?').run(key), /moves forward by exactly one/);
|
|
231
|
+
assert.throws(() => raw.prepare('UPDATE leases SET forgotten_at_epoch = 0 WHERE key = ?').run(key), /never moves back/);
|
|
232
|
+
assert.throws(() => raw.prepare('DELETE FROM leases WHERE key = ?').run(key), /never deleted/);
|
|
233
|
+
assert.equal(raw.prepare('SELECT epoch, forgotten_at_epoch AS floor FROM leases WHERE key = ?').get(key).epoch, 1);
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
raw.close();
|
|
237
|
+
}
|
|
238
|
+
const renewed = await referenceStore.renew(key, 'a', 1, 1_000);
|
|
239
|
+
assert.equal(renewed.record.forgottenAtEpoch, 1, 'the refused statements left the row usable and intact');
|
|
240
|
+
});
|
|
241
|
+
await check('B4 the audit trail lists every granted operation in order with strictly increasing acquire epochs', async () => {
|
|
242
|
+
const trail = referenceStore.history('reference/check-6');
|
|
243
|
+
assert.deepEqual(trail.map(entry => `${entry.op}:${entry.epoch}`), ['acquire:1', 'acquire:2', 'renew:2']);
|
|
244
|
+
const all = referenceStore.history();
|
|
245
|
+
const acquires = new Map();
|
|
246
|
+
for (const entry of all.filter(item => item.op === 'acquire')) {
|
|
247
|
+
assert.equal(entry.epoch, (acquires.get(entry.key) ?? 0) + 1, `${entry.key} acquires move one epoch at a time`);
|
|
248
|
+
acquires.set(entry.key, entry.epoch);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
referenceStore.close();
|
|
252
|
+
// ---- C: the checks can fail ------------------------------------------------------
|
|
253
|
+
console.log('-- C the conformance checks can fail');
|
|
254
|
+
async function brokenRun(name, wrap) {
|
|
255
|
+
const clock = new ManualClock(2_000_000);
|
|
256
|
+
const inner = new SqliteLeaseStore({ path: join(root, 'broken', `${name}.db`), nowMs: () => clock.nowMs() });
|
|
257
|
+
try {
|
|
258
|
+
const failures = await runLeaseStoreConformance({ store: wrap(inner), advance: (ms) => clock.advance(ms), now: () => clock.nowMs() }, name);
|
|
259
|
+
return failures.map(failure => failure.check);
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
inner.close();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
await check('C1 a store without compare-and-set fails the exclusivity checks', async () => {
|
|
266
|
+
const failed = await brokenRun('evicting', inner => new EvictingLeaseStore(inner));
|
|
267
|
+
assert.ok(failed.includes('acquire is refused while another unexpired lease exists'), failed.join(' | '));
|
|
268
|
+
assert.ok(failed.includes('acquire is refused for the current holder too'), failed.join(' | '));
|
|
269
|
+
});
|
|
270
|
+
await check('C2 a store whose epoch is not a fencing token fails the epoch checks', async () => {
|
|
271
|
+
const failed = await brokenRun('flat-epoch', inner => new FlatEpochLeaseStore(inner));
|
|
272
|
+
assert.ok(failed.includes('epochs strictly increase and never repeat'), failed.join(' | '));
|
|
273
|
+
assert.ok(failed.includes('an expired lease is taken at epoch + 1 and the old holder is refused everywhere'), failed.join(' | '));
|
|
274
|
+
});
|
|
275
|
+
await check('C3 a store that renews for anyone fails the stale-holder checks', async () => {
|
|
276
|
+
const failed = await brokenRun('token-blind', inner => new TokenBlindRenewLeaseStore(inner));
|
|
277
|
+
assert.ok(failed.includes('renew by another owner or for another epoch is refused'), failed.join(' | '));
|
|
278
|
+
assert.ok(failed.includes('an expired lease is taken at epoch + 1 and the old holder is refused everywhere'), failed.join(' | '));
|
|
279
|
+
});
|
|
280
|
+
// ---- D: holder session -----------------------------------------------------------
|
|
281
|
+
console.log('-- D holder session');
|
|
282
|
+
const TTL = 10_000;
|
|
283
|
+
const MARGIN = 2_000;
|
|
284
|
+
const KEY = leaseKeyFor('acme', TENANT_A);
|
|
285
|
+
function makeWorld(name) {
|
|
286
|
+
const storeClock = new ManualClock(1_000_000);
|
|
287
|
+
const store = new SqliteLeaseStore({ path: join(root, 'holder', `${name}.db`), nowMs: () => storeClock.nowMs() });
|
|
288
|
+
const clocks = [];
|
|
289
|
+
return {
|
|
290
|
+
store,
|
|
291
|
+
faulty: new FaultyLeaseStore(store),
|
|
292
|
+
storeClock,
|
|
293
|
+
replica() {
|
|
294
|
+
const clock = new ManualClock(5_000);
|
|
295
|
+
clocks.push(clock);
|
|
296
|
+
return clock;
|
|
297
|
+
},
|
|
298
|
+
advance(ms) {
|
|
299
|
+
storeClock.advance(ms);
|
|
300
|
+
for (const clock of clocks)
|
|
301
|
+
clock.advance(ms);
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const hold = (world, replicaId, clock, store = world.store) => TenantLease.acquire({ store, key: KEY, replicaId, ttlMs: TTL, marginMs: MARGIN, clock });
|
|
306
|
+
await check('D1 the holder stops acknowledging at sent-at + ttl - margin: held one tick before, fenced on the tick', async () => {
|
|
307
|
+
const world = makeWorld('d1');
|
|
308
|
+
const lease = await hold(world, 'r1', world.replica());
|
|
309
|
+
assert.equal(lease.epoch, 1);
|
|
310
|
+
assert.equal(lease.remainingMs(), TTL - MARGIN);
|
|
311
|
+
lease.assertHeld();
|
|
312
|
+
world.advance(TTL - MARGIN - 1);
|
|
313
|
+
lease.assertHeld();
|
|
314
|
+
assert.equal(lease.isHeld(), true);
|
|
315
|
+
world.advance(1);
|
|
316
|
+
assert.equal(fencedReason(() => lease.assertHeld()), 'lease_expired_locally');
|
|
317
|
+
assert.equal(lease.isHeld(), false);
|
|
318
|
+
const record = await world.store.read(KEY);
|
|
319
|
+
assert.ok(record && record.leaseUntilMs > world.storeClock.nowMs(), 'the store still lists the lease as valid: the holder fenced itself early');
|
|
320
|
+
world.store.close();
|
|
321
|
+
});
|
|
322
|
+
await check('D2 a renewal keeps the epoch and restarts the window from the moment it was sent', async () => {
|
|
323
|
+
const world = makeWorld('d2');
|
|
324
|
+
const lease = await hold(world, 'r1', world.replica());
|
|
325
|
+
world.advance(3_000);
|
|
326
|
+
await lease.renew();
|
|
327
|
+
assert.equal(lease.epoch, 1);
|
|
328
|
+
assert.equal(lease.remainingMs(), TTL - MARGIN);
|
|
329
|
+
const record = await world.store.read(KEY);
|
|
330
|
+
assert.equal(record?.leaseUntilMs, world.storeClock.nowMs() + TTL);
|
|
331
|
+
world.store.close();
|
|
332
|
+
});
|
|
333
|
+
await check('D3 a store outage fails the holder closed at its deadline; a renewal that gets through resumes it', async () => {
|
|
334
|
+
const world = makeWorld('d3');
|
|
335
|
+
const lease = await hold(world, 'r1', world.replica(), world.faulty);
|
|
336
|
+
world.advance(5_000);
|
|
337
|
+
world.faulty.down = true;
|
|
338
|
+
await assert.rejects(() => lease.renew(), LeaseStoreUnavailableError);
|
|
339
|
+
assert.equal(lease.isHeld(), true, 'an unreachable store does not end the lease before its deadline');
|
|
340
|
+
world.advance(3_500);
|
|
341
|
+
assert.equal(fencedReason(() => lease.assertHeld()), 'lease_expired_locally', 'owners stop at lease end while the store is down');
|
|
342
|
+
world.faulty.down = false;
|
|
343
|
+
await lease.renew();
|
|
344
|
+
assert.equal(lease.isHeld(), true, 'the epoch never changed, so nobody else held the tenant: resuming is safe');
|
|
345
|
+
assert.equal(lease.epoch, 1);
|
|
346
|
+
world.store.close();
|
|
347
|
+
});
|
|
348
|
+
await check('D4 a renewal that arrives after the store lease ended is refused and the holder is finished for good', async () => {
|
|
349
|
+
const world = makeWorld('d4');
|
|
350
|
+
const lease = await hold(world, 'r1', world.replica(), world.faulty);
|
|
351
|
+
world.faulty.down = true;
|
|
352
|
+
world.advance(10_500);
|
|
353
|
+
world.faulty.down = false;
|
|
354
|
+
await assert.rejects(() => lease.renew(), LeaseLostError);
|
|
355
|
+
assert.equal(fencedReason(() => lease.assertHeld()), 'lease_lost');
|
|
356
|
+
await assert.rejects(() => lease.renew(), (error) => error instanceof FencedError && error.reason === 'lease_lost');
|
|
357
|
+
world.store.close();
|
|
358
|
+
});
|
|
359
|
+
await check('D5 takeover after expiry: the new holder gets epoch + 1, the old one is refused and is fenced', async () => {
|
|
360
|
+
const world = makeWorld('d5');
|
|
361
|
+
const a = await hold(world, 'r1', world.replica());
|
|
362
|
+
world.advance(TTL + 1);
|
|
363
|
+
const b = await hold(world, 'r2', world.replica());
|
|
364
|
+
assert.equal(b.epoch, 2);
|
|
365
|
+
assert.equal(fencedReason(() => a.assertHeld()), 'lease_expired_locally');
|
|
366
|
+
await assert.rejects(() => a.renew(), LeaseLostError);
|
|
367
|
+
assert.equal(fencedReason(() => a.assertHeld()), 'lease_lost');
|
|
368
|
+
b.assertHeld();
|
|
369
|
+
await assert.rejects(() => hold(world, 'r3', world.replica()), (error) => error instanceof LeaseHeldError && error.record.owner === 'r2' && error.record.epoch === 2);
|
|
370
|
+
world.store.close();
|
|
371
|
+
});
|
|
372
|
+
await check('D6 limit of the local fence: a frozen clock cannot see a takeover, but the store refuses the old holder', async () => {
|
|
373
|
+
const world = makeWorld('d6');
|
|
374
|
+
const frozen = world.replica();
|
|
375
|
+
const a = await hold(world, 'r1', frozen);
|
|
376
|
+
world.storeClock.advance(TTL + 1);
|
|
377
|
+
const b = await hold(world, 'r2', world.replica());
|
|
378
|
+
assert.equal(b.epoch, 2);
|
|
379
|
+
assert.equal(a.isHeld(), true, 'a process paused between its check and its acknowledgement still passes the local gate');
|
|
380
|
+
await assert.rejects(() => a.renew(), LeaseLostError, 'the lease store, not the local clock, ends it');
|
|
381
|
+
assert.equal(a.isHeld(), false);
|
|
382
|
+
world.store.close();
|
|
383
|
+
});
|
|
384
|
+
await check('D7 graceful release: the holder stops first, the next owner starts at once at epoch + 1, release is idempotent', async () => {
|
|
385
|
+
const world = makeWorld('d7');
|
|
386
|
+
const a = await hold(world, 'r1', world.replica());
|
|
387
|
+
await a.release();
|
|
388
|
+
assert.equal(fencedReason(() => a.assertHeld()), 'lease_released');
|
|
389
|
+
const b = await hold(world, 'r2', world.replica());
|
|
390
|
+
assert.equal(b.epoch, 2);
|
|
391
|
+
await a.release();
|
|
392
|
+
await assert.rejects(() => a.renew(), (error) => error instanceof FencedError && error.reason === 'lease_released');
|
|
393
|
+
b.assertHeld();
|
|
394
|
+
world.store.close();
|
|
395
|
+
});
|
|
396
|
+
await check('D8 release while the store is down still stops the holder; the next owner waits out the lease', async () => {
|
|
397
|
+
const world = makeWorld('d8');
|
|
398
|
+
const a = await hold(world, 'r1', world.replica(), world.faulty);
|
|
399
|
+
world.faulty.down = true;
|
|
400
|
+
await assert.rejects(() => a.release(), LeaseStoreUnavailableError);
|
|
401
|
+
assert.equal(fencedReason(() => a.assertHeld()), 'lease_released');
|
|
402
|
+
world.faulty.down = false;
|
|
403
|
+
await assert.rejects(() => hold(world, 'r2', world.replica()), LeaseHeldError);
|
|
404
|
+
world.advance(TTL + 1);
|
|
405
|
+
assert.equal((await hold(world, 'r2', world.replica())).epoch, 2);
|
|
406
|
+
world.store.close();
|
|
407
|
+
});
|
|
408
|
+
await check('D9 a round trip that eats the usable lease is handed back, not held as an already fenced epoch', async () => {
|
|
409
|
+
const world = makeWorld('d9');
|
|
410
|
+
const slow = world.replica();
|
|
411
|
+
world.faulty.afterAcquire = () => slow.advance(TTL);
|
|
412
|
+
await assert.rejects(() => hold(world, 'r1', slow, world.faulty), LeaseLostError);
|
|
413
|
+
world.faulty.afterAcquire = null;
|
|
414
|
+
const record = await world.store.read(KEY);
|
|
415
|
+
assert.equal(record?.epoch, 1);
|
|
416
|
+
assert.ok(record && record.leaseUntilMs <= world.storeClock.nowMs(), 'the consumed grant was released at once');
|
|
417
|
+
assert.equal((await hold(world, 'r2', world.replica())).epoch, 2, 'the next owner needs no wait');
|
|
418
|
+
world.store.close();
|
|
419
|
+
});
|
|
420
|
+
await check('D10 renew and release of one holder run one at a time in call order', async () => {
|
|
421
|
+
const world = makeWorld('d10');
|
|
422
|
+
const lease = await hold(world, 'r1', world.replica());
|
|
423
|
+
const results = await Promise.allSettled([lease.renew(), lease.renew(), lease.release(), lease.renew()]);
|
|
424
|
+
assert.deepEqual(results.map(result => result.status), ['fulfilled', 'fulfilled', 'fulfilled', 'rejected']);
|
|
425
|
+
assert.equal(fencedReason(() => lease.assertHeld()), 'lease_released');
|
|
426
|
+
assert.deepEqual(world.store.history(KEY).map(entry => entry.op), ['acquire', 'renew', 'renew', 'release']);
|
|
427
|
+
world.store.close();
|
|
428
|
+
});
|
|
429
|
+
await check('D10b a holder has at most one renewal or release outstanding at the store, however many calls overlap', async () => {
|
|
430
|
+
const world = makeWorld('d10b');
|
|
431
|
+
const counting = new CountingLeaseStore(world.store);
|
|
432
|
+
const lease = await hold(world, 'r1', world.replica(), counting);
|
|
433
|
+
const results = await Promise.allSettled([lease.renew(), lease.renew(), lease.renew(), lease.release()]);
|
|
434
|
+
assert.deepEqual(results.map(result => result.status), ['fulfilled', 'fulfilled', 'fulfilled', 'fulfilled']);
|
|
435
|
+
assert.equal(counting.maxInFlight, 1, 'the second call waited for the first');
|
|
436
|
+
assert.deepEqual(world.store.history(KEY).map(entry => entry.op), ['acquire', 'renew', 'renew', 'renew', 'release']);
|
|
437
|
+
world.store.close();
|
|
438
|
+
});
|
|
439
|
+
await check('D11 forget needs a lease that is provably held and records the floor at the holder epoch', async () => {
|
|
440
|
+
const world = makeWorld('d11');
|
|
441
|
+
const lease = await hold(world, 'r1', world.replica());
|
|
442
|
+
const marked = await lease.markForgotten();
|
|
443
|
+
assert.equal(marked.forgottenAtEpoch, 1);
|
|
444
|
+
world.advance(TTL - MARGIN);
|
|
445
|
+
await assert.rejects(() => lease.markForgotten(), FencedError, 'a fenced holder cannot forget');
|
|
446
|
+
world.advance(MARGIN + 1);
|
|
447
|
+
const next = await hold(world, 'r2', world.replica());
|
|
448
|
+
assert.equal(next.epoch, 2);
|
|
449
|
+
assert.equal((await world.store.read(KEY))?.forgottenAtEpoch, 1, 'the floor outlives the holder that set it');
|
|
450
|
+
world.store.close();
|
|
451
|
+
});
|
|
452
|
+
await check('D12 acquire refuses a margin that leaves no usable window, a ttl outside the store bounds, and a blank replica id', async () => {
|
|
453
|
+
const world = makeWorld('d12');
|
|
454
|
+
const clock = world.replica();
|
|
455
|
+
const base = { store: world.store, key: KEY, replicaId: 'r1', ttlMs: TTL, marginMs: MARGIN, clock };
|
|
456
|
+
await assert.rejects(() => TenantLease.acquire({ ...base, marginMs: TTL }), RangeError);
|
|
457
|
+
await assert.rejects(() => TenantLease.acquire({ ...base, marginMs: -1 }), RangeError);
|
|
458
|
+
await assert.rejects(() => TenantLease.acquire({ ...base, ttlMs: 50, marginMs: 10 }), RangeError);
|
|
459
|
+
await assert.rejects(() => TenantLease.acquire({ ...base, replicaId: '' }), RangeError);
|
|
460
|
+
assert.equal(await world.store.read(KEY), null, 'no refused request reached the store');
|
|
461
|
+
world.store.close();
|
|
462
|
+
});
|
|
463
|
+
await check('D13 every typed failure is a TenantStateError with a stable code', () => {
|
|
464
|
+
const codes = [
|
|
465
|
+
new LeaseHeldError({ key: 'k', owner: 'o', epoch: 1, leaseUntilMs: 0, forgottenAtEpoch: 0 }),
|
|
466
|
+
new LeaseLostError('x'),
|
|
467
|
+
new FencedError('lease_lost', 'k', 1),
|
|
468
|
+
new LeaseStoreUnavailableError('x'),
|
|
469
|
+
new InvalidTenantIdError('x'),
|
|
470
|
+
].map(error => {
|
|
471
|
+
assert.ok(error instanceof TenantStateError);
|
|
472
|
+
return error.code;
|
|
473
|
+
});
|
|
474
|
+
assert.deepEqual(codes, [
|
|
475
|
+
'GOTRY_TENANT_LEASE_HELD',
|
|
476
|
+
'GOTRY_TENANT_LEASE_LOST',
|
|
477
|
+
'GOTRY_TENANT_FENCED',
|
|
478
|
+
'GOTRY_TENANT_LEASE_STORE_UNAVAILABLE',
|
|
479
|
+
'GOTRY_TENANT_ID_INVALID',
|
|
480
|
+
]);
|
|
481
|
+
});
|
|
482
|
+
// ---- E: real processes -------------------------------------------------------------
|
|
483
|
+
console.log('-- E real processes racing one lease file');
|
|
484
|
+
async function raceAcquire(dbPath, key, replicaIds, ttlMs, round) {
|
|
485
|
+
const goFile = join(root, `go-${round}`);
|
|
486
|
+
const runs = replicaIds.map((replicaId) => {
|
|
487
|
+
const child = spawn(process.execPath, ['--import', 'tsx', WORKER, dbPath, goFile, key, replicaId, String(ttlMs)], {
|
|
488
|
+
cwd: TS_DIR,
|
|
489
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
490
|
+
});
|
|
491
|
+
children.add(child);
|
|
492
|
+
let stdout = '';
|
|
493
|
+
let stderr = '';
|
|
494
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
495
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
496
|
+
const ready = new Promise((resolve) => {
|
|
497
|
+
const poll = setInterval(() => { if (stdout.includes('ready\n')) {
|
|
498
|
+
clearInterval(poll);
|
|
499
|
+
resolve();
|
|
500
|
+
} }, 5);
|
|
501
|
+
child.once('close', () => { clearInterval(poll); resolve(); });
|
|
502
|
+
});
|
|
503
|
+
const closed = new Promise((resolve) => {
|
|
504
|
+
child.once('close', (code) => { children.delete(child); resolve({ code, stdout, stderr }); });
|
|
505
|
+
});
|
|
506
|
+
return { ready, closed };
|
|
507
|
+
});
|
|
508
|
+
const budget = setTimeout(() => { for (const child of children)
|
|
509
|
+
child.kill('SIGKILL'); }, 60_000);
|
|
510
|
+
try {
|
|
511
|
+
await Promise.all(runs.map(run => run.ready));
|
|
512
|
+
writeFileSync(goFile, 'go');
|
|
513
|
+
const results = await Promise.all(runs.map(run => run.closed));
|
|
514
|
+
return results.map((result, index) => {
|
|
515
|
+
assert.equal(result.code, 0, `${replicaIds[index]} exited ${String(result.code)}: ${result.stderr}`);
|
|
516
|
+
const lines = result.stdout.trim().split('\n');
|
|
517
|
+
return JSON.parse(lines[lines.length - 1]);
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
finally {
|
|
521
|
+
clearTimeout(budget);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const raceDb = join(root, 'race', 'leases.db');
|
|
525
|
+
const observer = new SqliteLeaseStore({ path: raceDb });
|
|
526
|
+
await check('E1 six processes acquire one fresh lease at the same moment: exactly one is granted, epoch 1; the rest are told who holds it', async () => {
|
|
527
|
+
const key = leaseKeyFor('acme', TENANT_A);
|
|
528
|
+
const outcomes = await raceAcquire(raceDb, key, ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'], 60_000, 'e1');
|
|
529
|
+
const granted = outcomes.filter(outcome => outcome.outcome === 'granted');
|
|
530
|
+
assert.equal(granted.length, 1, JSON.stringify(outcomes));
|
|
531
|
+
assert.equal(granted[0].epoch, 1);
|
|
532
|
+
for (const loser of outcomes.filter(outcome => outcome.outcome !== 'granted')) {
|
|
533
|
+
assert.equal(loser.outcome, 'held', JSON.stringify(loser));
|
|
534
|
+
assert.equal(loser.owner, granted[0].replicaId);
|
|
535
|
+
assert.equal(loser.epoch, 1);
|
|
536
|
+
}
|
|
537
|
+
assert.deepEqual(observer.history(key).map(entry => `${entry.op}:${entry.owner}:${entry.epoch}`), [`acquire:${granted[0].replicaId}:1`]);
|
|
538
|
+
});
|
|
539
|
+
await check('E2 after the lease expires in real time, six fresh processes race again: exactly one is granted, epoch 2', async () => {
|
|
540
|
+
const key = leaseKeyFor('acme', TENANT_B);
|
|
541
|
+
const first = await raceAcquire(raceDb, key, ['q1', 'q2', 'q3'], 1_500, 'e2a');
|
|
542
|
+
assert.equal(first.filter(outcome => outcome.outcome === 'granted').length, 1);
|
|
543
|
+
for (let waited = 0;; waited += 20) {
|
|
544
|
+
const record = await observer.read(key);
|
|
545
|
+
if (record && record.leaseUntilMs <= Date.now())
|
|
546
|
+
break;
|
|
547
|
+
assert.ok(waited < 5_000, 'the lease never expired');
|
|
548
|
+
await sleep(20);
|
|
549
|
+
}
|
|
550
|
+
const second = await raceAcquire(raceDb, key, ['s1', 's2', 's3', 's4', 's5', 's6'], 60_000, 'e2b');
|
|
551
|
+
const granted = second.filter(outcome => outcome.outcome === 'granted');
|
|
552
|
+
assert.equal(granted.length, 1, JSON.stringify(second));
|
|
553
|
+
assert.equal(granted[0].epoch, 2);
|
|
554
|
+
assert.deepEqual(observer.history(key).map(entry => entry.epoch), [1, 2]);
|
|
555
|
+
assert.ok(second.filter(outcome => outcome.outcome === 'held').every(outcome => outcome.epoch === 2 && outcome.owner === granted[0].replicaId));
|
|
556
|
+
});
|
|
557
|
+
observer.close();
|
|
558
|
+
console.log(`tenant-state-tests: ${passed} checks passed`);
|
|
559
|
+
}
|
|
560
|
+
finally {
|
|
561
|
+
for (const child of children)
|
|
562
|
+
child.kill('SIGKILL');
|
|
563
|
+
rmSync(root, { recursive: true, force: true });
|
|
564
|
+
}
|