@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,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trusted seam of the AaaS line (docs/rfc/aaas-rfc.md §4.4, threats S-1, S-3, S-13).
|
|
3
|
+
*
|
|
4
|
+
* Exactly one place turns "who is calling" into "whose ledger": `deriveSeamTenant`. It takes the caller an
|
|
5
|
+
* authenticator vouched for, and a per-organization key, and returns the ledger tenant as an HMAC digest
|
|
6
|
+
* (`deriveLedgerTenantId`). Nothing a request body, a header, a tool argument or a model output says is ever an
|
|
7
|
+
* input to it, and every API that needs a tenant takes the `SeamTenant` it returns, never a string.
|
|
8
|
+
*
|
|
9
|
+
* What the authenticator is, on the wire, is not decided here: the A3 boundary ADR chooses per-organization
|
|
10
|
+
* credentials or signed assertions. The authenticator is an in-process interface so that decision can land
|
|
11
|
+
* without touching the seam.
|
|
12
|
+
*/
|
|
13
|
+
import { TenantStateError } from '../tenant-state/errors.js';
|
|
14
|
+
import { assertOrganizationId, deriveLedgerTenantId } from '../tenant-state/ids.js';
|
|
15
|
+
const MAX_PRINCIPAL_FIELD_CHARS = 256;
|
|
16
|
+
/** A keyring over a fixed set of keys. Key storage and rotation are deployment concerns and are not designed here. */
|
|
17
|
+
export class StaticOrganizationKeyring {
|
|
18
|
+
keys = new Map();
|
|
19
|
+
constructor(keys) {
|
|
20
|
+
for (const [organization, key] of Object.entries(keys)) {
|
|
21
|
+
assertOrganizationId(organization);
|
|
22
|
+
if (!(key instanceof Uint8Array) || key.byteLength < 32)
|
|
23
|
+
throw new RangeError(`the key of organization ${organization} must be at least 32 bytes`);
|
|
24
|
+
this.keys.set(organization, Uint8Array.from(key));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
keyFor(organization) {
|
|
28
|
+
return this.keys.get(organization) ?? null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class SeamError extends TenantStateError {
|
|
32
|
+
}
|
|
33
|
+
/** The authenticated principal cannot be turned into a ledger tenant: an empty, oversized or malformed claim. */
|
|
34
|
+
export class InvalidPrincipalError extends SeamError {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super('GOTRY_SEAM_PRINCIPAL_INVALID', message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** The caller belongs to an organization this service holds no key for. */
|
|
40
|
+
export class UnknownOrganizationError extends SeamError {
|
|
41
|
+
constructor() {
|
|
42
|
+
super('GOTRY_SEAM_ORGANIZATION_UNKNOWN', 'the organization is not served here');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Something other than a `SeamTenant` reached an API that needs one. */
|
|
46
|
+
export class SeamTenantRequiredError extends SeamError {
|
|
47
|
+
constructor() {
|
|
48
|
+
super('GOTRY_SEAM_TENANT_REQUIRED', 'a tenant derived at the seam is required');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const issued = new WeakSet();
|
|
52
|
+
export function isSeamTenant(value) {
|
|
53
|
+
return typeof value === 'object' && value !== null && issued.has(value);
|
|
54
|
+
}
|
|
55
|
+
export function assertSeamTenant(value) {
|
|
56
|
+
if (!isSeamTenant(value))
|
|
57
|
+
throw new SeamTenantRequiredError();
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
/** `<organization>/<ledger tenant>`: the key for every in-process map and every metric. It contains no personal data. */
|
|
61
|
+
export function seamTenantKey(tenant) {
|
|
62
|
+
return `${assertSeamTenant(tenant).organization}/${tenant.ledgerTenant}`;
|
|
63
|
+
}
|
|
64
|
+
function principalField(name, value) {
|
|
65
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
66
|
+
throw new InvalidPrincipalError(`${name} must be a non-empty string`);
|
|
67
|
+
if (value.length > MAX_PRINCIPAL_FIELD_CHARS)
|
|
68
|
+
throw new InvalidPrincipalError(`${name} must be at most ${MAX_PRINCIPAL_FIELD_CHARS} characters`);
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The only derivation of a ledger tenant from a caller in the service.
|
|
73
|
+
*
|
|
74
|
+
* @throws InvalidTenantIdError for an organization id that is not path-safe, or an issuer or subject that is not well-formed
|
|
75
|
+
* @throws InvalidPrincipalError for a missing or oversized subject, issuer or scope
|
|
76
|
+
* @throws UnknownOrganizationError when the keyring holds no key for the organization
|
|
77
|
+
*/
|
|
78
|
+
export function deriveSeamTenant(caller, keyring) {
|
|
79
|
+
if (typeof caller !== 'object' || caller === null)
|
|
80
|
+
throw new InvalidPrincipalError('the caller must be an object');
|
|
81
|
+
const organization = assertOrganizationId(caller.organization);
|
|
82
|
+
const issuer = principalField('issuer', caller.issuer);
|
|
83
|
+
const subject = principalField('subject', caller.subject);
|
|
84
|
+
principalField('scope', caller.scope);
|
|
85
|
+
const key = keyring.keyFor(organization);
|
|
86
|
+
if (key === null)
|
|
87
|
+
throw new UnknownOrganizationError();
|
|
88
|
+
const ledgerTenant = deriveLedgerTenantId(key, issuer, subject);
|
|
89
|
+
const tenant = Object.freeze({ organization, ledgerTenant });
|
|
90
|
+
issued.add(tenant);
|
|
91
|
+
return tenant;
|
|
92
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable store of the AaaS state layer: where off-node copies of a tenant ledger live
|
|
3
|
+
* (docs/rfc/aaas-rfc.md §4.3, decision Q4).
|
|
4
|
+
*
|
|
5
|
+
* The contract is the smallest an object store gives: create-if-absent, read, list by prefix
|
|
6
|
+
* and delete by prefix. Create-if-absent is the only write, so an object is immutable once
|
|
7
|
+
* it exists and a stale writer can never replace what a newer owner relies on. Epoch-conditional
|
|
8
|
+
* appends (durability mode D-b) are a later refinement of the same primitive.
|
|
9
|
+
*
|
|
10
|
+
* Every call must settle. A binding gives each request a deadline and fails with `DurableStoreUnavailableError` when it
|
|
11
|
+
* passes: the router renews a tenant's lease while a flush or a forget waits on the store, so a call that never returns
|
|
12
|
+
* holds the tenant on its replica until the process dies.
|
|
13
|
+
*
|
|
14
|
+
* `DirDurableStore` is the reference implementation on a local directory. It stands in for the
|
|
15
|
+
* S3-compatible store of the real deployment, whose binding must pass the same conformance
|
|
16
|
+
* checks before it holds a real copy.
|
|
17
|
+
*/
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import { link, mkdir, open, readFile, readdir, rm, rmdir } from 'node:fs/promises';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { DurableStoreUnavailableError } from './errors.js';
|
|
22
|
+
const MAX_KEY_LENGTH = 512;
|
|
23
|
+
const SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
|
|
24
|
+
const PARTIAL_SEGMENT = /^(?:[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?$/;
|
|
25
|
+
/** Keys are `/`-separated segments of `[A-Za-z0-9._-]` that do not start with a dot, so no key can leave the store or touch its scratch area. */
|
|
26
|
+
export function assertDurableKey(key) {
|
|
27
|
+
if (typeof key !== 'string' || key.length === 0 || key.length > MAX_KEY_LENGTH || !key.split('/').every(segment => SEGMENT.test(segment))) {
|
|
28
|
+
throw new RangeError(`invalid durable key ${JSON.stringify(typeof key === 'string' ? key.slice(0, 80) : key)}`);
|
|
29
|
+
}
|
|
30
|
+
return key;
|
|
31
|
+
}
|
|
32
|
+
/** A prefix is complete segments followed by an optional partial one: `a/b/`, `a/b/snap-`, `a/b`. */
|
|
33
|
+
export function assertDurablePrefix(prefix) {
|
|
34
|
+
const segments = typeof prefix === 'string' ? prefix.split('/') : [];
|
|
35
|
+
const last = segments.pop();
|
|
36
|
+
if (typeof prefix !== 'string' || prefix.length === 0 || prefix.length > MAX_KEY_LENGTH ||
|
|
37
|
+
last === undefined || !PARTIAL_SEGMENT.test(last) || !segments.every(segment => SEGMENT.test(segment))) {
|
|
38
|
+
throw new RangeError(`invalid durable prefix ${JSON.stringify(typeof prefix === 'string' ? prefix.slice(0, 80) : prefix)}`);
|
|
39
|
+
}
|
|
40
|
+
return prefix;
|
|
41
|
+
}
|
|
42
|
+
/** A prefix to delete by: complete segments, so `acme/` can never take `acme2/`. */
|
|
43
|
+
export function assertDurableDeletePrefix(prefix) {
|
|
44
|
+
assertDurablePrefix(prefix);
|
|
45
|
+
if (!prefix.endsWith('/'))
|
|
46
|
+
throw new RangeError(`a durable delete takes whole segments and ends with "/", got ${JSON.stringify(prefix.slice(0, 80))}`);
|
|
47
|
+
return prefix;
|
|
48
|
+
}
|
|
49
|
+
function code(error) {
|
|
50
|
+
const value = error?.code;
|
|
51
|
+
return typeof value === 'string' ? value : undefined;
|
|
52
|
+
}
|
|
53
|
+
export class DirDurableStore {
|
|
54
|
+
root;
|
|
55
|
+
constructor(root) {
|
|
56
|
+
this.root = resolve(root);
|
|
57
|
+
}
|
|
58
|
+
async putIfAbsent(key, bytes) {
|
|
59
|
+
assertDurableKey(key);
|
|
60
|
+
const destination = this.pathOf(key);
|
|
61
|
+
const scratch = join(this.root, '.tmp');
|
|
62
|
+
const staged = join(scratch, randomBytes(8).toString('hex'));
|
|
63
|
+
try {
|
|
64
|
+
await mkdir(scratch, { recursive: true });
|
|
65
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
66
|
+
const handle = await open(staged, 'wx');
|
|
67
|
+
try {
|
|
68
|
+
await handle.writeFile(bytes);
|
|
69
|
+
await handle.sync();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await handle.close();
|
|
73
|
+
}
|
|
74
|
+
// A hard link either creates the key with all its bytes or fails: a reader never sees a half-written object.
|
|
75
|
+
await link(staged, destination);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (code(error) === 'EEXIST')
|
|
80
|
+
return false;
|
|
81
|
+
throw new DurableStoreUnavailableError(`durable put failed for ${key}`, { cause: error });
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await rm(staged, { force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async get(key) {
|
|
88
|
+
assertDurableKey(key);
|
|
89
|
+
try {
|
|
90
|
+
return await readFile(this.pathOf(key));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (code(error) === 'ENOENT' || code(error) === 'EISDIR' || code(error) === 'ENOTDIR')
|
|
94
|
+
return null;
|
|
95
|
+
throw new DurableStoreUnavailableError(`durable get failed for ${key}`, { cause: error });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async list(prefix) {
|
|
99
|
+
assertDurablePrefix(prefix);
|
|
100
|
+
const slash = prefix.lastIndexOf('/');
|
|
101
|
+
const directory = slash === -1 ? '' : prefix.slice(0, slash);
|
|
102
|
+
const keys = [];
|
|
103
|
+
try {
|
|
104
|
+
await this.walk(directory, keys);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new DurableStoreUnavailableError(`durable list failed for ${prefix}`, { cause: error });
|
|
108
|
+
}
|
|
109
|
+
return keys.filter(key => key.startsWith(prefix)).sort();
|
|
110
|
+
}
|
|
111
|
+
async deletePrefix(prefix) {
|
|
112
|
+
assertDurableDeletePrefix(prefix);
|
|
113
|
+
const keys = await this.list(prefix);
|
|
114
|
+
try {
|
|
115
|
+
for (const key of keys)
|
|
116
|
+
await rm(this.pathOf(key), { force: true });
|
|
117
|
+
for (const directory of new Set(keys.map(key => dirname(key))))
|
|
118
|
+
await this.pruneEmptyDirectories(directory);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
throw new DurableStoreUnavailableError(`durable delete failed for ${prefix}`, { cause: error });
|
|
122
|
+
}
|
|
123
|
+
return keys.length;
|
|
124
|
+
}
|
|
125
|
+
pathOf(key) {
|
|
126
|
+
return join(this.root, ...key.split('/'));
|
|
127
|
+
}
|
|
128
|
+
async walk(directory, into) {
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = await readdir(directory === '' ? this.root : this.pathOf(directory), { withFileTypes: true });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (code(error) === 'ENOENT' || code(error) === 'ENOTDIR')
|
|
135
|
+
return;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (directory === '' && entry.name === '.tmp')
|
|
140
|
+
continue;
|
|
141
|
+
const child = directory === '' ? entry.name : `${directory}/${entry.name}`;
|
|
142
|
+
if (entry.isDirectory())
|
|
143
|
+
await this.walk(child, into);
|
|
144
|
+
else
|
|
145
|
+
into.push(child);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async pruneEmptyDirectories(directory) {
|
|
149
|
+
for (let current = directory; current !== '.' && current !== ''; current = dirname(current)) {
|
|
150
|
+
try {
|
|
151
|
+
await rmdir(this.pathOf(current));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epoch-scoped durable copies of a tenant ledger (docs/rfc/aaas-rfc.md §4.2 item 3 and §4.3, mode D-a).
|
|
3
|
+
*
|
|
4
|
+
* Every owner copies only into the prefix of its own epoch:
|
|
5
|
+
*
|
|
6
|
+
* <org>/<tenant>/e<epoch>/base.json where this epoch's ledger started: one pinned snapshot, or empty
|
|
7
|
+
* <org>/<tenant>/e<epoch>/snap-<n>.db an immutable, verified image of the ledger
|
|
8
|
+
* <org>/<tenant>/e<epoch>/snap-<n>.json its manifest; written last, so a manifest means a complete image
|
|
9
|
+
*
|
|
10
|
+
* A new owner never trusts what it finds beside its own epoch. It restores the newest verified snapshot
|
|
11
|
+
* of the newest lineage, then pins that exact snapshot in its own `base.json` before it serves a write.
|
|
12
|
+
* Anything a superseded owner uploads later goes into a dead prefix. A newer epoch's base never points at
|
|
13
|
+
* it, so it can never become authoritative. Integrity therefore rests on the lease epoch and on these
|
|
14
|
+
* rules, not on clocks. What can be lost under D-a is the tail of acknowledged writes after the last
|
|
15
|
+
* snapshot, which is the declared RPO.
|
|
16
|
+
*
|
|
17
|
+
* Only the ledger file is copied. Files beside it (planner working files, tickets, artifacts) are not
|
|
18
|
+
* replicated yet (RFC gap 6, fact F-2).
|
|
19
|
+
*/
|
|
20
|
+
import Database from 'better-sqlite3';
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
25
|
+
import { ledgerDbPath, ledgerExists, openDb } from '../state-ledger.js';
|
|
26
|
+
import { CopyConflictError, RestoreVerificationError } from './errors.js';
|
|
27
|
+
import { leaseKeyFor, tenantRootFor } from './ids.js';
|
|
28
|
+
const MAX_SNAPSHOT_BYTES = 512 * 1024 * 1024;
|
|
29
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
30
|
+
const TENANT_TABLES = ['events', 'projection_docs', 'projection_items', 'workflow_runs', 'workflow_steps', 'pending_writes'];
|
|
31
|
+
// ---- layout ------------------------------------------------------------------
|
|
32
|
+
export function epochPrefixOf(leaseKey, epoch) {
|
|
33
|
+
return `${leaseKey}/e${String(epoch).padStart(8, '0')}/`;
|
|
34
|
+
}
|
|
35
|
+
function snapshotKeyOf(leaseKey, epoch, seq, extension) {
|
|
36
|
+
return `${epochPrefixOf(leaseKey, epoch)}snap-${String(seq).padStart(6, '0')}.${extension}`;
|
|
37
|
+
}
|
|
38
|
+
function baseKeyOf(leaseKey, epoch) {
|
|
39
|
+
return `${epochPrefixOf(leaseKey, epoch)}base.json`;
|
|
40
|
+
}
|
|
41
|
+
function epochOfKey(objectKey) {
|
|
42
|
+
const match = /\/e(\d{8,})\/[^/]+$/.exec(objectKey);
|
|
43
|
+
return match ? Number(match[1]) : null;
|
|
44
|
+
}
|
|
45
|
+
function snapshotSeqsOf(keys, extension) {
|
|
46
|
+
const pattern = new RegExp(`/snap-(\\d{6})\\.${extension}$`);
|
|
47
|
+
const seqs = new Set();
|
|
48
|
+
for (const key of keys) {
|
|
49
|
+
const match = pattern.exec(key);
|
|
50
|
+
if (match)
|
|
51
|
+
seqs.add(Number(match[1]));
|
|
52
|
+
}
|
|
53
|
+
return [...seqs].sort((a, b) => b - a);
|
|
54
|
+
}
|
|
55
|
+
// ---- manifests -----------------------------------------------------------------
|
|
56
|
+
function isCount(value) {
|
|
57
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
58
|
+
}
|
|
59
|
+
function parseJson(bytes) {
|
|
60
|
+
try {
|
|
61
|
+
const value = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
62
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function parseManifest(bytes, tenant, epoch, seq) {
|
|
69
|
+
const value = parseJson(bytes);
|
|
70
|
+
if (!value || value.v !== 1 || value.tenant !== tenant || value.epoch !== epoch || value.seq !== seq)
|
|
71
|
+
return null;
|
|
72
|
+
if (typeof value.sha256 !== 'string' || !HEX64.test(value.sha256))
|
|
73
|
+
return null;
|
|
74
|
+
if (![value.bytes, value.events, value.maxEventSeq, value.createdAtMs].every(isCount))
|
|
75
|
+
return null;
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function parseBase(bytes, epoch) {
|
|
79
|
+
const value = parseJson(bytes);
|
|
80
|
+
if (!value || value.v !== 1 || value.epoch !== epoch)
|
|
81
|
+
return null;
|
|
82
|
+
if (value.from === null)
|
|
83
|
+
return { v: 1, epoch, from: null };
|
|
84
|
+
const from = value.from;
|
|
85
|
+
if (!from || typeof from !== 'object' || !isCount(from.epoch) || !isCount(from.seq) || from.epoch < 1 || from.epoch >= epoch || from.seq < 1)
|
|
86
|
+
return null;
|
|
87
|
+
if (typeof from.sha256 !== 'string' || !HEX64.test(from.sha256))
|
|
88
|
+
return null;
|
|
89
|
+
return { v: 1, epoch, from: { epoch: from.epoch, seq: from.seq, sha256: from.sha256 } };
|
|
90
|
+
}
|
|
91
|
+
function sha256Of(bytes) {
|
|
92
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
93
|
+
}
|
|
94
|
+
function refLabel(epoch, seq) {
|
|
95
|
+
return `e${epoch}/snap-${seq}`;
|
|
96
|
+
}
|
|
97
|
+
// ---- verification --------------------------------------------------------------
|
|
98
|
+
/**
|
|
99
|
+
* A ledger image is trusted only if SQLite finds it intact, it holds rows of this tenant and no
|
|
100
|
+
* other, and replaying its events reproduces its projections (the drill D properties). The checks
|
|
101
|
+
* run on a scratch copy, so the image itself is never opened for writing.
|
|
102
|
+
*/
|
|
103
|
+
export function verifyLedgerImage(imagePath, ledgerTenant) {
|
|
104
|
+
const scratch = mkdtempSync(join(tmpdir(), 'gotry-verify-'));
|
|
105
|
+
try {
|
|
106
|
+
mkdirSync(dirname(ledgerDbPath(scratch)), { recursive: true });
|
|
107
|
+
copyFileSync(imagePath, ledgerDbPath(scratch));
|
|
108
|
+
const facts = inspectImage(ledgerDbPath(scratch), ledgerTenant);
|
|
109
|
+
assertFoldReproducesProjections(scratch, ledgerTenant);
|
|
110
|
+
return facts;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function inspectImage(copyPath, ledgerTenant) {
|
|
117
|
+
const db = new Database(copyPath, { fileMustExist: true });
|
|
118
|
+
try {
|
|
119
|
+
const integrity = db.pragma('integrity_check');
|
|
120
|
+
if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok')
|
|
121
|
+
throw new RestoreVerificationError('ledger image failed integrity_check');
|
|
122
|
+
for (const table of TENANT_TABLES) {
|
|
123
|
+
const foreign = db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE tenant_id <> ?`).get(ledgerTenant);
|
|
124
|
+
if (foreign.n !== 0)
|
|
125
|
+
throw new RestoreVerificationError(`ledger image holds rows of another tenant in ${table}`);
|
|
126
|
+
}
|
|
127
|
+
const row = db.prepare('SELECT COUNT(*) AS n, COALESCE(MAX(seq), 0) AS m FROM events').get();
|
|
128
|
+
return { events: row.n, maxEventSeq: row.m };
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (error instanceof RestoreVerificationError)
|
|
132
|
+
throw error;
|
|
133
|
+
throw new RestoreVerificationError(`ledger image is not a readable ledger: ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
db.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function assertFoldReproducesProjections(scratchRoot, ledgerTenant) {
|
|
140
|
+
let ledger;
|
|
141
|
+
try {
|
|
142
|
+
ledger = openDb(scratchRoot, ledgerTenant);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
throw new RestoreVerificationError(`ledger image could not be opened as a ledger: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const before = JSON.stringify({ wishes: ledger.readWishPool(), motivation: ledger.readMotivation() });
|
|
149
|
+
ledger.rebuildProjections();
|
|
150
|
+
const after = JSON.stringify({ wishes: ledger.readWishPool(), motivation: ledger.readMotivation() });
|
|
151
|
+
if (before !== after)
|
|
152
|
+
throw new RestoreVerificationError('ledger image: replaying its events does not reproduce its projections');
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
ledger.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function verifyCandidate(bytes, manifest, ledgerTenant) {
|
|
159
|
+
if (bytes.byteLength !== manifest.bytes)
|
|
160
|
+
throw new RestoreVerificationError('size differs from its manifest');
|
|
161
|
+
if (sha256Of(bytes) !== manifest.sha256)
|
|
162
|
+
throw new RestoreVerificationError('checksum differs from its manifest');
|
|
163
|
+
const scratch = mkdtempSync(join(tmpdir(), 'gotry-candidate-'));
|
|
164
|
+
try {
|
|
165
|
+
const imagePath = join(scratch, 'image.db');
|
|
166
|
+
writeFileSync(imagePath, bytes);
|
|
167
|
+
const facts = verifyLedgerImage(imagePath, ledgerTenant);
|
|
168
|
+
if (facts.events !== manifest.events || facts.maxEventSeq !== manifest.maxEventSeq) {
|
|
169
|
+
throw new RestoreVerificationError('event count or last sequence differs from its manifest');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ---- publishing ----------------------------------------------------------------
|
|
177
|
+
/** The lease key of the context and the tenant root that was checked: every file operation below uses that root, not the string the caller passed. */
|
|
178
|
+
function checkedContext(context) {
|
|
179
|
+
const key = leaseKeyFor(context.organization, context.ledgerTenant);
|
|
180
|
+
if (context.lease.key !== key)
|
|
181
|
+
throw new RangeError(`the lease ${context.lease.key} does not belong to tenant ${key}`);
|
|
182
|
+
return { key, root: ownTenantRoot(context) };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* `forgetTenant` deletes the tenant root recursively, and the root comes from the caller. It has to be the directory
|
|
186
|
+
* `tenantRootFor` lays out for exactly this tenant, `<state root>/orgs/<organization>/tenants/<tenant>`, so a mistaken or
|
|
187
|
+
* hostile context can name no other directory. The resolved path is returned: the string that was checked is the one that
|
|
188
|
+
* gets deleted, so a `..` behind a symlink cannot point the deletion somewhere else.
|
|
189
|
+
*/
|
|
190
|
+
function ownTenantRoot(context) {
|
|
191
|
+
const expectedTail = tenantRootFor('/', context.organization, context.ledgerTenant);
|
|
192
|
+
const actual = resolve(context.tenantRoot);
|
|
193
|
+
if (!isAbsolute(context.tenantRoot) || !actual.endsWith(expectedTail)) {
|
|
194
|
+
throw new RangeError(`the tenant root ${JSON.stringify(context.tenantRoot.slice(0, 200))} is not the root of tenant ${leaseKeyFor(context.organization, context.ledgerTenant)}`);
|
|
195
|
+
}
|
|
196
|
+
return actual;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Takes a consistent image of the live ledger, verifies it, and publishes it into this holder's
|
|
200
|
+
* epoch prefix. The lease is checked before the work and again right before the upload.
|
|
201
|
+
*/
|
|
202
|
+
export async function publishSnapshot(context, options = {}) {
|
|
203
|
+
const { key, root } = checkedContext(context);
|
|
204
|
+
const { durable, lease, ledgerTenant } = context;
|
|
205
|
+
lease.assertHeld();
|
|
206
|
+
const scratch = mkdtempSync(join(tmpdir(), 'gotry-snapshot-'));
|
|
207
|
+
try {
|
|
208
|
+
const imagePath = join(scratch, 'image.db');
|
|
209
|
+
const source = new Database(ledgerDbPath(root), { fileMustExist: true });
|
|
210
|
+
try {
|
|
211
|
+
source.pragma('busy_timeout = 8000');
|
|
212
|
+
await source.backup(imagePath);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
source.close();
|
|
216
|
+
}
|
|
217
|
+
// The image must not depend on a -wal or -shm file that would not travel with it.
|
|
218
|
+
const image = new Database(imagePath);
|
|
219
|
+
try {
|
|
220
|
+
image.pragma('journal_mode = DELETE');
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
image.close();
|
|
224
|
+
}
|
|
225
|
+
const facts = verifyLedgerImage(imagePath, ledgerTenant);
|
|
226
|
+
const bytes = readFileSync(imagePath);
|
|
227
|
+
if (bytes.byteLength > MAX_SNAPSHOT_BYTES)
|
|
228
|
+
throw new RangeError(`ledger image of ${bytes.byteLength} bytes exceeds the ${MAX_SNAPSHOT_BYTES} byte limit`);
|
|
229
|
+
const sha256 = sha256Of(bytes);
|
|
230
|
+
lease.assertHeld();
|
|
231
|
+
const existing = await durable.list(`${epochPrefixOf(key, lease.epoch)}snap-`);
|
|
232
|
+
const seq = (snapshotSeqsOf(existing, 'db')[0] ?? 0) + 1;
|
|
233
|
+
const manifest = {
|
|
234
|
+
v: 1,
|
|
235
|
+
tenant: ledgerTenant,
|
|
236
|
+
epoch: lease.epoch,
|
|
237
|
+
seq,
|
|
238
|
+
sha256,
|
|
239
|
+
bytes: bytes.byteLength,
|
|
240
|
+
events: facts.events,
|
|
241
|
+
maxEventSeq: facts.maxEventSeq,
|
|
242
|
+
createdAtMs: (options.nowMs ?? Date.now)(),
|
|
243
|
+
};
|
|
244
|
+
if (!await durable.putIfAbsent(snapshotKeyOf(key, lease.epoch, seq, 'db'), bytes)) {
|
|
245
|
+
throw new CopyConflictError(`${refLabel(lease.epoch, seq)} already exists in the prefix of epoch ${lease.epoch}`);
|
|
246
|
+
}
|
|
247
|
+
if (!await durable.putIfAbsent(snapshotKeyOf(key, lease.epoch, seq, 'json'), Buffer.from(JSON.stringify(manifest)))) {
|
|
248
|
+
throw new CopyConflictError(`the manifest of ${refLabel(lease.epoch, seq)} already exists`);
|
|
249
|
+
}
|
|
250
|
+
// The upload took time. An image that landed after the lease lapsed may sit above the base a successor already
|
|
251
|
+
// pinned, and is then never read: reporting it as published would tell a caller its writes are safe when they are not.
|
|
252
|
+
lease.assertHeld();
|
|
253
|
+
return manifest;
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Pins where this epoch's ledger started. Written once, before the owner serves a write or publishes a snapshot. */
|
|
260
|
+
async function writeBase(context, key, from) {
|
|
261
|
+
context.lease.assertHeld();
|
|
262
|
+
const record = { v: 1, epoch: context.lease.epoch, from };
|
|
263
|
+
if (!await context.durable.putIfAbsent(baseKeyOf(key, context.lease.epoch), Buffer.from(JSON.stringify(record)))) {
|
|
264
|
+
throw new CopyConflictError(`the base of epoch ${context.lease.epoch} already exists`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The newest verified snapshot of the newest lineage below `ownEpoch` and above the forgotten floor.
|
|
269
|
+
*
|
|
270
|
+
* The newest lineage is the highest epoch that left anything behind. Inside an epoch the newest snapshot
|
|
271
|
+
* that verifies wins; with none, the walk follows that epoch's `base.json` pin to the exact snapshot (and
|
|
272
|
+
* older ones of that epoch) it started from. A snapshot a superseded owner uploaded after a newer epoch
|
|
273
|
+
* pinned its base is above the pin and is never read.
|
|
274
|
+
*
|
|
275
|
+
* @throws RestoreVerificationError when copies exist but none of them verifies
|
|
276
|
+
*/
|
|
277
|
+
export async function resolveRestorePoint(durable, leaseKey, ledgerTenant, ownEpoch, forgottenAtEpoch, verify) {
|
|
278
|
+
const keysByEpoch = new Map();
|
|
279
|
+
for (const objectKey of await durable.list(`${leaseKey}/e`)) {
|
|
280
|
+
const epoch = epochOfKey(objectKey);
|
|
281
|
+
if (epoch === null || epoch <= forgottenAtEpoch || epoch >= ownEpoch)
|
|
282
|
+
continue;
|
|
283
|
+
keysByEpoch.set(epoch, [...keysByEpoch.get(epoch) ?? [], objectKey]);
|
|
284
|
+
}
|
|
285
|
+
const epochsWithEvidence = [...keysByEpoch.keys()].sort((a, b) => b - a);
|
|
286
|
+
const skipped = [];
|
|
287
|
+
const visited = new Set();
|
|
288
|
+
let corrupt = false;
|
|
289
|
+
let epoch = epochsWithEvidence[0] ?? null;
|
|
290
|
+
let ceiling = Number.POSITIVE_INFINITY;
|
|
291
|
+
while (epoch !== null && !visited.has(epoch)) {
|
|
292
|
+
visited.add(epoch);
|
|
293
|
+
const keys = keysByEpoch.get(epoch) ?? await durable.list(epochPrefixOf(leaseKey, epoch));
|
|
294
|
+
for (const seq of snapshotSeqsOf(keys, 'json').filter(candidate => candidate <= ceiling)) {
|
|
295
|
+
const label = refLabel(epoch, seq);
|
|
296
|
+
const manifestBytes = await durable.get(snapshotKeyOf(leaseKey, epoch, seq, 'json'));
|
|
297
|
+
const manifest = manifestBytes ? parseManifest(manifestBytes, ledgerTenant, epoch, seq) : null;
|
|
298
|
+
if (!manifest) {
|
|
299
|
+
skipped.push(`${label}: manifest is missing or malformed`);
|
|
300
|
+
corrupt = true;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const bytes = await durable.get(snapshotKeyOf(leaseKey, epoch, seq, 'db'));
|
|
304
|
+
if (!bytes) {
|
|
305
|
+
skipped.push(`${label}: image is missing`);
|
|
306
|
+
corrupt = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
verify(bytes, manifest);
|
|
311
|
+
return { point: { manifest, bytes }, skipped };
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (!(error instanceof RestoreVerificationError))
|
|
315
|
+
throw error;
|
|
316
|
+
skipped.push(`${label}: ${error.message}`);
|
|
317
|
+
corrupt = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const baseBytes = keys.includes(baseKeyOf(leaseKey, epoch)) ? await durable.get(baseKeyOf(leaseKey, epoch)) : null;
|
|
321
|
+
const base = baseBytes ? parseBase(baseBytes, epoch) : null;
|
|
322
|
+
if (base?.from === null)
|
|
323
|
+
break;
|
|
324
|
+
if (base?.from) {
|
|
325
|
+
if (base.from.epoch <= forgottenAtEpoch)
|
|
326
|
+
break;
|
|
327
|
+
epoch = base.from.epoch;
|
|
328
|
+
ceiling = base.from.seq;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
skipped.push(`e${epoch}: base is missing or malformed`);
|
|
332
|
+
epoch = epochsWithEvidence.find(candidate => candidate < epoch) ?? null;
|
|
333
|
+
ceiling = Number.POSITIVE_INFINITY;
|
|
334
|
+
}
|
|
335
|
+
if (corrupt)
|
|
336
|
+
throw new RestoreVerificationError('durable copies exist but none of them verifies', skipped);
|
|
337
|
+
return { point: null, skipped };
|
|
338
|
+
}
|
|
339
|
+
function evictCachedLedger(tenantRoot, ledgerTenant) {
|
|
340
|
+
if (!ledgerExists(tenantRoot))
|
|
341
|
+
return;
|
|
342
|
+
try {
|
|
343
|
+
openDb(tenantRoot, ledgerTenant).close();
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// A stale or damaged local file is about to be replaced; failing to open it changes nothing.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Makes the local ledger of a freshly acquired epoch equal to the newest verified durable copy, pins that
|
|
351
|
+
* copy in this epoch's `base.json`, and opens the ledger. Local files from an earlier tenure are always
|
|
352
|
+
* discarded: only the durable copy is authoritative.
|
|
353
|
+
*/
|
|
354
|
+
export async function restoreTenantLedger(context) {
|
|
355
|
+
const { key, root: tenantRoot } = checkedContext(context);
|
|
356
|
+
const { durable, lease, ledgerTenant } = context;
|
|
357
|
+
lease.assertHeld();
|
|
358
|
+
const resolution = await resolveRestorePoint(durable, key, ledgerTenant, lease.epoch, lease.forgottenAtEpoch, (bytes, manifest) => verifyCandidate(bytes, manifest, ledgerTenant));
|
|
359
|
+
lease.assertHeld();
|
|
360
|
+
evictCachedLedger(tenantRoot, ledgerTenant);
|
|
361
|
+
const dbPath = ledgerDbPath(tenantRoot);
|
|
362
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
363
|
+
for (const suffix of ['', '-wal', '-shm', '.restoring'])
|
|
364
|
+
rmSync(`${dbPath}${suffix}`, { force: true });
|
|
365
|
+
const point = resolution.point;
|
|
366
|
+
if (point) {
|
|
367
|
+
writeFileSync(`${dbPath}.restoring`, point.bytes);
|
|
368
|
+
renameSync(`${dbPath}.restoring`, dbPath);
|
|
369
|
+
}
|
|
370
|
+
const restoredFrom = point ? { epoch: point.manifest.epoch, seq: point.manifest.seq, sha256: point.manifest.sha256 } : null;
|
|
371
|
+
await writeBase(context, key, restoredFrom);
|
|
372
|
+
return {
|
|
373
|
+
ledger: openDb(tenantRoot, ledgerTenant),
|
|
374
|
+
restoredFrom,
|
|
375
|
+
skipped: resolution.skipped,
|
|
376
|
+
facts: point ? { events: point.manifest.events, maxEventSeq: point.manifest.maxEventSeq } : null,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
// ---- forgetting --------------------------------------------------------------------
|
|
380
|
+
/**
|
|
381
|
+
* Removes the local ledger files (the database and its -wal and -shm) of a tenure that flushed everything. Other files
|
|
382
|
+
* of the tenant's directory, such as a planner's working files, are not the ledger's and stay.
|
|
383
|
+
*/
|
|
384
|
+
export function discardLocalLedger(context) {
|
|
385
|
+
const file = ledgerDbPath(checkedContext(context).root);
|
|
386
|
+
for (const path of [file, `${file}-wal`, `${file}-shm`])
|
|
387
|
+
rmSync(path, { force: true });
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Forget means delete (red line 6): the floor goes into the lease record first, so no later restore can
|
|
391
|
+
* read an older copy even if a superseded owner uploads one afterwards; then every durable copy and the
|
|
392
|
+
* local files go; then the lease is handed back. The caller closes the tenant's ledger first.
|
|
393
|
+
*/
|
|
394
|
+
export async function forgetTenant(context) {
|
|
395
|
+
const { key, root } = checkedContext(context);
|
|
396
|
+
context.lease.assertHeld();
|
|
397
|
+
const record = await context.lease.markForgotten();
|
|
398
|
+
// Only the epochs at or below the floor that was just raised are the forgotten tenure's. An owner that was paused between
|
|
399
|
+
// raising the floor and deleting wakes up after a successor took over: what the successor wrote is above the floor, and
|
|
400
|
+
// it is not this call's to delete.
|
|
401
|
+
const deletedObjects = await sweepMaskedCopies(context.durable, key, record.forgottenAtEpoch);
|
|
402
|
+
rmSync(root, { recursive: true, force: true });
|
|
403
|
+
await context.lease.release();
|
|
404
|
+
return { deletedObjects, floor: record.forgottenAtEpoch };
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Deletes copies at or below the forgotten floor that a superseded owner uploaded after the forget. Every open of a
|
|
408
|
+
* tenant that was ever forgotten runs it; for one that is never opened again an operator runs it, once the previous
|
|
409
|
+
* owner's lease has certainly run out.
|
|
410
|
+
*/
|
|
411
|
+
export async function sweepMaskedCopies(durable, leaseKey, forgottenAtEpoch) {
|
|
412
|
+
let deleted = 0;
|
|
413
|
+
const dead = new Set();
|
|
414
|
+
for (const objectKey of await durable.list(`${leaseKey}/e`)) {
|
|
415
|
+
const epoch = epochOfKey(objectKey);
|
|
416
|
+
if (epoch !== null && epoch <= forgottenAtEpoch)
|
|
417
|
+
dead.add(epoch);
|
|
418
|
+
}
|
|
419
|
+
for (const epoch of dead)
|
|
420
|
+
deleted += await durable.deletePrefix(epochPrefixOf(leaseKey, epoch));
|
|
421
|
+
return deleted;
|
|
422
|
+
}
|