@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,743 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tenant router of the AaaS state layer (docs/rfc/aaas-rfc.md §4.2 items 5 and 6, §4.4).
|
|
3
|
+
*
|
|
4
|
+
* One replica asks the router for a tenant by `(organization, ledger tenant)`. The router acquires the
|
|
5
|
+
* lease, restores the ledger from the newest verified durable copy, and returns a handle. The handle is
|
|
6
|
+
* the only way to the tenant's ledger, and it is tenant-bound: no method takes a tenant id.
|
|
7
|
+
*
|
|
8
|
+
* A write is acknowledged only if the lease is provably held before the work and after it. `run` and
|
|
9
|
+
* `runAsync` bracket the work, and the ledger the handle exposes is a guarded view, so a long-lived
|
|
10
|
+
* consumer that keeps the ledger (a booking runtime, say) is stopped the same way: every call, including
|
|
11
|
+
* every prepared statement, first proves the lease. A write that was applied locally but not acknowledged
|
|
12
|
+
* is harmless, because a later owner restores from the durable copy and local files never survive.
|
|
13
|
+
*
|
|
14
|
+
* Reads go to the owner. A request for a tenant another replica owns fails with `LeaseHeldError`, whose
|
|
15
|
+
* record names the owner; forwarding to it belongs to the deployment layer (A3).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type Database from 'better-sqlite3'
|
|
19
|
+
import type { StateLedger } from '../state-ledger.ts'
|
|
20
|
+
import type { DurableStore } from './durable-store.ts'
|
|
21
|
+
import {
|
|
22
|
+
discardLocalLedger,
|
|
23
|
+
forgetTenant,
|
|
24
|
+
publishSnapshot,
|
|
25
|
+
restoreTenantLedger,
|
|
26
|
+
sweepMaskedCopies,
|
|
27
|
+
type EpochCopyContext,
|
|
28
|
+
type RestoreResult,
|
|
29
|
+
type SnapshotManifest,
|
|
30
|
+
type SnapshotRef,
|
|
31
|
+
} from './epoch-copies.ts'
|
|
32
|
+
import { DrainTimeoutError, FencedError, LeaseLostError, ReplicaAtCapacityError, RouterClosedError, TenantDrainingError } from './errors.ts'
|
|
33
|
+
import { leaseKeyFor, tenantRootFor } from './ids.ts'
|
|
34
|
+
import type { LeaseStore } from './lease-store.ts'
|
|
35
|
+
import { systemMonotonicClock, TenantLease, type MonotonicClock } from './tenant-lease.ts'
|
|
36
|
+
|
|
37
|
+
// ---- guarded views ----------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
type Callable = (...args: unknown[]) => unknown
|
|
40
|
+
type Gate = () => void
|
|
41
|
+
|
|
42
|
+
/** An iterator that proves the lease on every step. Closing it is cleanup and always allowed. */
|
|
43
|
+
function guardedIterator(iterator: Iterator<unknown>, assertHeld: Gate): IterableIterator<unknown> {
|
|
44
|
+
const guarded: IterableIterator<unknown> = {
|
|
45
|
+
next: () => {
|
|
46
|
+
assertHeld()
|
|
47
|
+
return iterator.next()
|
|
48
|
+
},
|
|
49
|
+
return: (value?: unknown) => iterator.return?.(value) ?? { done: true, value },
|
|
50
|
+
[Symbol.iterator]: () => guarded,
|
|
51
|
+
}
|
|
52
|
+
return guarded
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A prepared statement behind a gate. This is a facade, not a proxy: better-sqlite3 statements carry read-only
|
|
57
|
+
* properties, among them a raw `database` handle, that a proxy would have to return unchanged and so would leak.
|
|
58
|
+
*/
|
|
59
|
+
function guardedStatement(statement: Database.Statement, assertHeld: Gate): object {
|
|
60
|
+
const target = statement as unknown as Record<string, Callable>
|
|
61
|
+
const facade: Record<string, unknown> = {}
|
|
62
|
+
for (const name of ['run', 'get', 'all', 'columns']) {
|
|
63
|
+
facade[name] = (...args: unknown[]) => {
|
|
64
|
+
assertHeld()
|
|
65
|
+
return Reflect.apply(target[name], statement, args)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
facade.iterate = (...args: unknown[]) => {
|
|
69
|
+
assertHeld()
|
|
70
|
+
return guardedIterator(Reflect.apply(target.iterate, statement, args) as Iterator<unknown>, assertHeld)
|
|
71
|
+
}
|
|
72
|
+
for (const name of ['bind', 'pluck', 'expand', 'raw', 'safeIntegers']) {
|
|
73
|
+
facade[name] = (...args: unknown[]) => {
|
|
74
|
+
assertHeld()
|
|
75
|
+
Reflect.apply(target[name], statement, args)
|
|
76
|
+
return facade
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const name of ['reader', 'readonly', 'source', 'busy']) {
|
|
80
|
+
Object.defineProperty(facade, name, { get: () => (statement as unknown as Record<string, unknown>)[name], enumerable: true })
|
|
81
|
+
}
|
|
82
|
+
Object.defineProperty(facade, 'database', { get: () => guardedDatabase(statement.database, assertHeld), enumerable: true })
|
|
83
|
+
return facade
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A transaction function behind a gate, with its `.deferred`, `.immediate` and `.exclusive` variants. Its raw `database` handle is not passed on. */
|
|
87
|
+
function guardedTransaction(transaction: Callable, assertHeld: Gate): Callable {
|
|
88
|
+
const gate = (fn: Callable): Callable => function (this: unknown, ...args: unknown[]) {
|
|
89
|
+
assertHeld()
|
|
90
|
+
return Reflect.apply(fn, this, args)
|
|
91
|
+
}
|
|
92
|
+
const guarded = gate(transaction)
|
|
93
|
+
for (const mode of ['default', 'deferred', 'immediate', 'exclusive']) {
|
|
94
|
+
const variant = (transaction as unknown as Record<string, unknown>)[mode]
|
|
95
|
+
if (typeof variant === 'function') Object.defineProperty(guarded, mode, { value: gate(variant as Callable), enumerable: true })
|
|
96
|
+
}
|
|
97
|
+
return guarded
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The database of a ledger: every method proves the lease first, and statements and transactions come back guarded. */
|
|
101
|
+
function guardedDatabase(db: Database.Database, assertHeld: Gate): Database.Database {
|
|
102
|
+
const guarded: Database.Database = new Proxy(db, {
|
|
103
|
+
get(target, property) {
|
|
104
|
+
const value: unknown = Reflect.get(target, property, target)
|
|
105
|
+
if (typeof value !== 'function') return value
|
|
106
|
+
// The router owns the connection its own writes, its snapshots and its handoff depend on; a consumer cannot close it.
|
|
107
|
+
if (property === 'close') return () => guarded
|
|
108
|
+
return (...args: unknown[]) => {
|
|
109
|
+
assertHeld()
|
|
110
|
+
const result = Reflect.apply(value as Callable, target, args)
|
|
111
|
+
// `exec`, `defaultSafeIntegers`, `function`, `loadExtension` and the like return the database itself; what a
|
|
112
|
+
// caller chains onto it has to stay behind the gate.
|
|
113
|
+
if (result === target) return guarded
|
|
114
|
+
if (property === 'prepare') return guardedStatement(result as Database.Statement, assertHeld)
|
|
115
|
+
if (property === 'transaction') return guardedTransaction(result as Callable, assertHeld)
|
|
116
|
+
return result
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
return guarded
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The ledger as a handle exposes it. The router owns its lifecycle, so `close` does nothing here, and the view is
|
|
125
|
+
* read-only: nothing a consumer does to it changes the ledger the router holds.
|
|
126
|
+
*/
|
|
127
|
+
function guardedLedger(ledger: StateLedger, assertHeld: Gate): StateLedger {
|
|
128
|
+
return new Proxy(ledger, {
|
|
129
|
+
get(target, property) {
|
|
130
|
+
const value: unknown = Reflect.get(target, property, target)
|
|
131
|
+
if (property === 'db') return guardedDatabase(value as Database.Database, assertHeld)
|
|
132
|
+
if (typeof value !== 'function') return value
|
|
133
|
+
if (property === 'close') return () => undefined
|
|
134
|
+
return (...args: unknown[]) => {
|
|
135
|
+
assertHeld()
|
|
136
|
+
return Reflect.apply(value as Callable, target, args)
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
// `Object.getOwnPropertyDescriptor(ledger, 'db').value` is a second way to the database that `get` does not cover.
|
|
140
|
+
getOwnPropertyDescriptor(target, property) {
|
|
141
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(target, property)
|
|
142
|
+
if (property === 'db' && descriptor && 'value' in descriptor) return { ...descriptor, value: guardedDatabase(descriptor.value as Database.Database, assertHeld) }
|
|
143
|
+
return descriptor
|
|
144
|
+
},
|
|
145
|
+
// An assignment to a property that exists ends in defineProperty, so refusing that and delete is what makes the view read-only;
|
|
146
|
+
// the prototype and the extensibility are the rest of what a consumer could change on the ledger the router holds.
|
|
147
|
+
defineProperty: () => false,
|
|
148
|
+
deleteProperty: () => false,
|
|
149
|
+
setPrototypeOf: () => false,
|
|
150
|
+
preventExtensions: () => false,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- events and options ---------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/** What a router reports about its tenants. Keys are `<organization>/<hmac tenant id>`: no personal data. */
|
|
157
|
+
export type RouterEvent =
|
|
158
|
+
| { readonly type: 'acquired'; readonly key: string; readonly epoch: number }
|
|
159
|
+
| { readonly type: 'restored'; readonly key: string; readonly epoch: number; readonly from: SnapshotRef | null; readonly skipped: number }
|
|
160
|
+
| { readonly type: 'renewed'; readonly key: string; readonly epoch: number }
|
|
161
|
+
| { readonly type: 'renew_failed'; readonly key: string; readonly epoch: number; readonly code: string }
|
|
162
|
+
| { readonly type: 'snapshot'; readonly key: string; readonly epoch: number; readonly seq: number }
|
|
163
|
+
| { readonly type: 'snapshot_failed'; readonly key: string; readonly epoch: number; readonly code: string }
|
|
164
|
+
| { readonly type: 'lost'; readonly key: string; readonly epoch: number }
|
|
165
|
+
| { readonly type: 'drain_timeout'; readonly key: string; readonly epoch: number; readonly inFlight: number }
|
|
166
|
+
| { readonly type: 'released'; readonly key: string; readonly epoch: number; readonly flushed: boolean }
|
|
167
|
+
|
|
168
|
+
export interface TenantRouterOptions {
|
|
169
|
+
readonly stateRoot: string
|
|
170
|
+
/** Unique per process incarnation. It is what the lease record shows as the owner, so it should also say where the replica can be reached. */
|
|
171
|
+
readonly replicaId: string
|
|
172
|
+
readonly leases: LeaseStore
|
|
173
|
+
readonly durable: DurableStore
|
|
174
|
+
readonly ttlMs?: number
|
|
175
|
+
readonly marginMs?: number
|
|
176
|
+
readonly clock?: MonotonicClock
|
|
177
|
+
/** Renew in the background every `renewEveryMs`. Suites that drive time by hand turn it off. */
|
|
178
|
+
readonly autoRenew?: boolean
|
|
179
|
+
readonly renewEveryMs?: number
|
|
180
|
+
/** Publish a snapshot this often while serving. Zero (the default) leaves snapshots to the caller and to handoff. */
|
|
181
|
+
readonly snapshotEveryMs?: number
|
|
182
|
+
/**
|
|
183
|
+
* The most tenants this replica holds at once, counting those still opening and those draining. A tenant over it is
|
|
184
|
+
* refused with `ReplicaAtCapacityError` before its lease or any file is touched. Each open tenant holds about three file
|
|
185
|
+
* descriptors (docs/ops/aaas-capacity-baseline.md), so a deployment sets this from its descriptor limit; unset, as
|
|
186
|
+
* before, there is no cap and the limit is whatever the process runs out of first.
|
|
187
|
+
*/
|
|
188
|
+
readonly maxOpenTenants?: number
|
|
189
|
+
/**
|
|
190
|
+
* The longest a handoff waits for calls that were already running (`runAsync`) to finish. The lease is renewed through the
|
|
191
|
+
* drain, so a call that never returns would otherwise hold the tenant on this replica for ever and keep `close()` waiting. When
|
|
192
|
+
* it passes, `handoff()` rejects with `DrainTimeoutError`, the tenant serves again and a `drain_timeout` event says so. Set it
|
|
193
|
+
* below the termination grace period of the deployment. Default 120 s.
|
|
194
|
+
*/
|
|
195
|
+
readonly drainTimeoutMs?: number
|
|
196
|
+
/** Tenants `close()` hands off at the same time, so that one stuck tenant does not hold the ones behind it. Default 8. */
|
|
197
|
+
readonly closeConcurrency?: number
|
|
198
|
+
readonly onEvent?: (event: RouterEvent) => void
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface TenantHandle {
|
|
202
|
+
readonly organization: string
|
|
203
|
+
readonly ledgerTenant: string
|
|
204
|
+
readonly epoch: number
|
|
205
|
+
/** The snapshot this epoch started from, or `null` for an empty ledger. */
|
|
206
|
+
readonly restoredFrom: SnapshotRef | null
|
|
207
|
+
/** A guarded view: every call proves the lease first. */
|
|
208
|
+
readonly ledger: StateLedger
|
|
209
|
+
isOpen(): boolean
|
|
210
|
+
/** Runs `work` on the ledger and returns its result only if the lease is held before and after. */
|
|
211
|
+
run<T>(work: (ledger: StateLedger) => T): T
|
|
212
|
+
runAsync<T>(work: (ledger: StateLedger) => Promise<T>): Promise<T>
|
|
213
|
+
renew(): Promise<void>
|
|
214
|
+
snapshot(): Promise<SnapshotManifest>
|
|
215
|
+
/** Graceful handoff: drain, publish a final snapshot, release the lease. If the flush fails the handle keeps serving. */
|
|
216
|
+
handoff(): Promise<void>
|
|
217
|
+
/** Stops without a flush and releases the lease at once. Writes after the last snapshot are given up. */
|
|
218
|
+
abandon(): Promise<void>
|
|
219
|
+
/** Forget: every durable copy and the local files go, then the lease is handed back. */
|
|
220
|
+
forget(): Promise<{ readonly deletedObjects: number; readonly floor: number }>
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface HandleDeps {
|
|
224
|
+
readonly key: string
|
|
225
|
+
readonly organization: string
|
|
226
|
+
readonly ledgerTenant: string
|
|
227
|
+
readonly lease: TenantLease
|
|
228
|
+
readonly context: EpochCopyContext
|
|
229
|
+
readonly restored: RestoreResult
|
|
230
|
+
readonly autoRenew: boolean
|
|
231
|
+
readonly renewEveryMs: number
|
|
232
|
+
readonly snapshotEveryMs: number
|
|
233
|
+
readonly drainTimeoutMs: number
|
|
234
|
+
emit(event: RouterEvent): void
|
|
235
|
+
detach(handle: TenantHandle): void
|
|
236
|
+
/** Work the router waits for when it closes, though the handle has left its table: a forget that is under way. */
|
|
237
|
+
retain(work: Promise<unknown>): void
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A refused renewal, or a lease that was released or lost, ends the tenure for good. A merely expired local deadline does not: a renewal can resume it. */
|
|
241
|
+
function endsTenure(error: unknown): boolean {
|
|
242
|
+
return error instanceof LeaseLostError || (error instanceof FencedError && error.reason !== 'lease_expired_locally')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
type TenantState = 'serving' | 'draining' | 'flushing' | 'forgetting' | 'closed'
|
|
246
|
+
|
|
247
|
+
/** A promise in disguise: anything with a `then` function. */
|
|
248
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
249
|
+
return (typeof value === 'object' || typeof value === 'function') && value !== null && typeof (value as { then?: unknown }).then === 'function'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
class OpenTenant implements TenantHandle {
|
|
253
|
+
readonly organization: string
|
|
254
|
+
readonly ledgerTenant: string
|
|
255
|
+
readonly epoch: number
|
|
256
|
+
readonly restoredFrom: SnapshotRef | null
|
|
257
|
+
readonly ledger: StateLedger
|
|
258
|
+
private readonly raw: StateLedger
|
|
259
|
+
private readonly deps: HandleDeps
|
|
260
|
+
// `draining`: new work is refused and what already runs may finish. `flushing` begins when the drain is over and the
|
|
261
|
+
// final image is about to be taken: from then on a write through the ledger could not be in the image, so the view is
|
|
262
|
+
// closed to every call until the tenant is closed or serving again. `forgetting`: the tenant is being deleted; nothing is
|
|
263
|
+
// served, but the lease is renewed until the deletion has handed it back.
|
|
264
|
+
private state: TenantState = 'serving'
|
|
265
|
+
private inFlight = 0
|
|
266
|
+
private readonly idleWaiters = new Set<() => void>()
|
|
267
|
+
private renewTimer: ReturnType<typeof setTimeout> | null = null
|
|
268
|
+
private renewTicking = false
|
|
269
|
+
private snapshotTimer: ReturnType<typeof setTimeout> | null = null
|
|
270
|
+
private snapshotTail: Promise<unknown> = Promise.resolve()
|
|
271
|
+
private snapshotQueued = 0
|
|
272
|
+
|
|
273
|
+
constructor(deps: HandleDeps) {
|
|
274
|
+
this.deps = deps
|
|
275
|
+
this.organization = deps.organization
|
|
276
|
+
this.ledgerTenant = deps.ledgerTenant
|
|
277
|
+
this.epoch = deps.lease.epoch
|
|
278
|
+
this.restoredFrom = deps.restored.restoredFrom
|
|
279
|
+
this.raw = deps.restored.ledger
|
|
280
|
+
// Work that began before a handoff may finish and new work is refused at `run`; the view itself stays open while
|
|
281
|
+
// the drain waits, so writes made in that time are in the final image, and closes when the image is taken.
|
|
282
|
+
this.ledger = guardedLedger(this.raw, () => this.assertViewUsable())
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
isOpen(): boolean {
|
|
286
|
+
return this.state === 'serving'
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
isDraining(): boolean {
|
|
290
|
+
return this.state === 'draining' || this.state === 'flushing'
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The state as it is now, for a check after an await: another call may have changed it, which a narrowed `this.state` or getter does not show. */
|
|
294
|
+
private phaseNow(): TenantState {
|
|
295
|
+
return this.state
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Called once by the router, after the handle is registered. */
|
|
299
|
+
begin(): void {
|
|
300
|
+
this.scheduleRenewal()
|
|
301
|
+
this.scheduleSnapshot()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
run<T>(work: (ledger: StateLedger) => T): T {
|
|
305
|
+
this.assertServing()
|
|
306
|
+
const result = work(this.ledger)
|
|
307
|
+
if (isThenable(result)) {
|
|
308
|
+
// The callback is already running, and what is left of it is outside the lease bracket and the drain. Nobody awaits
|
|
309
|
+
// the promise it returned, so its failure is taken here instead of becoming an unhandled rejection.
|
|
310
|
+
void Promise.resolve(result).catch(() => undefined)
|
|
311
|
+
throw new TypeError('TenantHandle.run takes a synchronous callback: use runAsync for work that awaits')
|
|
312
|
+
}
|
|
313
|
+
this.deps.lease.assertHeld()
|
|
314
|
+
return result
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async runAsync<T>(work: (ledger: StateLedger) => Promise<T>): Promise<T> {
|
|
318
|
+
this.assertServing()
|
|
319
|
+
this.inFlight += 1
|
|
320
|
+
try {
|
|
321
|
+
const result = await work(this.ledger)
|
|
322
|
+
this.deps.lease.assertHeld()
|
|
323
|
+
return result
|
|
324
|
+
} finally {
|
|
325
|
+
this.inFlight -= 1
|
|
326
|
+
if (this.inFlight === 0) this.wakeIdleWaiters()
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async renew(): Promise<void> {
|
|
331
|
+
try {
|
|
332
|
+
await this.deps.lease.renew()
|
|
333
|
+
this.emit({ type: 'renewed', key: this.deps.key, epoch: this.epoch })
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (endsTenure(error)) this.markLost()
|
|
336
|
+
throw error
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
snapshot(): Promise<SnapshotManifest> {
|
|
341
|
+
this.snapshotQueued += 1
|
|
342
|
+
const run = this.snapshotTail.then(() => this.publishNow(), () => this.publishNow())
|
|
343
|
+
this.snapshotTail = run.catch(() => undefined).finally(() => { this.snapshotQueued -= 1 })
|
|
344
|
+
return run
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async handoff(): Promise<void> {
|
|
348
|
+
if (this.state === 'closed') return
|
|
349
|
+
if (this.state !== 'serving') throw new TenantDrainingError(this.deps.key)
|
|
350
|
+
this.setState('draining')
|
|
351
|
+
// Only the periodic snapshots stop. The lease keeps being renewed through the drain and the final upload: both can
|
|
352
|
+
// take longer than a lease, and a handoff that let it lapse would flush into a prefix no successor reads.
|
|
353
|
+
this.stopSnapshotTimer()
|
|
354
|
+
try {
|
|
355
|
+
const drained = await this.drain()
|
|
356
|
+
if (drained === 'timeout') {
|
|
357
|
+
// The lease is renewed through the drain, so a call that never returns would hold the tenant here for ever.
|
|
358
|
+
this.emit({ type: 'drain_timeout', key: this.deps.key, epoch: this.epoch, inFlight: this.inFlight })
|
|
359
|
+
throw new DrainTimeoutError(this.deps.key, this.inFlight, this.deps.drainTimeoutMs)
|
|
360
|
+
}
|
|
361
|
+
// Closed meanwhile (abandoned, forgotten, lease lost): there is nothing left to flush, and a closed handle stays closed.
|
|
362
|
+
if (this.phaseNow() !== 'draining') throw new TenantDrainingError(this.deps.key)
|
|
363
|
+
this.setState('flushing')
|
|
364
|
+
await this.snapshot()
|
|
365
|
+
// The same if it happened while the image was uploading. A forget deletes what this flush uploaded and needs the lease
|
|
366
|
+
// to finish; an abandon or a lost lease has ended the tenure. Releasing now, and reporting a flush, would be false.
|
|
367
|
+
if (this.phaseNow() !== 'flushing') throw new TenantDrainingError(this.deps.key)
|
|
368
|
+
} catch (error) {
|
|
369
|
+
// A flush that failed for want of a store leaves the tenant serving; one that failed because the lease is gone ends it.
|
|
370
|
+
if (endsTenure(error)) this.markLost()
|
|
371
|
+
else if (this.isDraining()) {
|
|
372
|
+
this.setState('serving')
|
|
373
|
+
this.begin()
|
|
374
|
+
}
|
|
375
|
+
throw error
|
|
376
|
+
}
|
|
377
|
+
await this.finish(true)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async abandon(): Promise<void> {
|
|
381
|
+
// A forget that is under way owns the end of the tenure: it hands the lease back itself.
|
|
382
|
+
if (this.state === 'closed' || this.state === 'forgetting') return
|
|
383
|
+
this.stopTimers()
|
|
384
|
+
await this.finish(false)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async forget(): Promise<{ readonly deletedObjects: number; readonly floor: number }> {
|
|
388
|
+
this.deps.lease.assertHeld()
|
|
389
|
+
// Closed with the lease still held is an abandon on its way out: it ends the tenure, and a forget on top of it would run
|
|
390
|
+
// the deletion over a tenure that is already over and report a second release.
|
|
391
|
+
if (this.state === 'forgetting' || this.state === 'closed') throw new TenantDrainingError(this.deps.key)
|
|
392
|
+
// Nothing is served from here on, but the lease keeps being renewed: waiting for an upload under way can take longer than
|
|
393
|
+
// the lease, and a forget that let it lapse could no longer delete.
|
|
394
|
+
this.stopSnapshotTimer()
|
|
395
|
+
this.setState('forgetting')
|
|
396
|
+
this.closeLedger()
|
|
397
|
+
this.deps.detach(this)
|
|
398
|
+
const work = this.completeForget()
|
|
399
|
+
// The handle has left the router's table, but the deletion is not over: closing the router waits for it.
|
|
400
|
+
this.deps.retain(work.then(() => undefined, () => undefined))
|
|
401
|
+
return work
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async completeForget(): Promise<{ readonly deletedObjects: number; readonly floor: number }> {
|
|
405
|
+
try {
|
|
406
|
+
// An upload already under way would land after the deletion and leave the tenant's image in the store, where nothing
|
|
407
|
+
// would look for it again. Snapshots queued behind it refuse to start now that the tenant is being forgotten.
|
|
408
|
+
await this.snapshotTail.catch(() => undefined)
|
|
409
|
+
const outcome = await forgetTenant(this.deps.context)
|
|
410
|
+
this.emit({ type: 'released', key: this.deps.key, epoch: this.epoch, flushed: false })
|
|
411
|
+
return outcome
|
|
412
|
+
} finally {
|
|
413
|
+
this.setState('closed')
|
|
414
|
+
this.stopTimers()
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private assertServing(): void {
|
|
419
|
+
if (this.state === 'serving') {
|
|
420
|
+
this.deps.lease.assertHeld()
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
// A lease that is gone says so first. With the lease still held, a handoff, a forget or an abandon is under way.
|
|
424
|
+
if (this.state === 'closed') this.deps.lease.assertHeld()
|
|
425
|
+
throw new TenantDrainingError(this.deps.key)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The gate of the ledger a consumer holds: the lease, and a view that is open only while the tenant is served or draining. */
|
|
429
|
+
private assertViewUsable(): void {
|
|
430
|
+
this.deps.lease.assertHeld()
|
|
431
|
+
if (this.state !== 'serving' && this.state !== 'draining') throw new TenantDrainingError(this.deps.key)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private setState(next: TenantState): void {
|
|
435
|
+
this.state = next
|
|
436
|
+
// Whoever waits for the in-flight work to end is also waiting for the tenant to stay a tenant: closing it ends the wait.
|
|
437
|
+
if (next === 'closed' || next === 'forgetting') this.wakeIdleWaiters()
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private emit(event: RouterEvent): void {
|
|
441
|
+
try {
|
|
442
|
+
this.deps.emit(event)
|
|
443
|
+
} catch {
|
|
444
|
+
// A broken observer must never take a tenant down.
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private async publishNow(): Promise<SnapshotManifest> {
|
|
449
|
+
// A snapshot queued before a forget, an abandon or a lost lease must not start after it.
|
|
450
|
+
if (this.state === 'closed' || this.state === 'forgetting') throw new TenantDrainingError(this.deps.key)
|
|
451
|
+
try {
|
|
452
|
+
const manifest = await publishSnapshot(this.deps.context)
|
|
453
|
+
this.emit({ type: 'snapshot', key: this.deps.key, epoch: this.epoch, seq: manifest.seq })
|
|
454
|
+
return manifest
|
|
455
|
+
} catch (error) {
|
|
456
|
+
this.emit({ type: 'snapshot_failed', key: this.deps.key, epoch: this.epoch, code: (error as { code?: string }).code ?? 'unknown' })
|
|
457
|
+
throw error
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Waits until the calls that were running are done, the tenant is closed, or `drainTimeoutMs` has passed. The timer
|
|
463
|
+
* keeps the process alive on purpose: a graceful stop that gave up the moment nothing else was pending would exit
|
|
464
|
+
* without flushing.
|
|
465
|
+
*/
|
|
466
|
+
private drain(): Promise<'idle' | 'closed' | 'timeout'> {
|
|
467
|
+
if (this.inFlight === 0) return Promise.resolve('idle')
|
|
468
|
+
return new Promise((resolve) => {
|
|
469
|
+
const end = (outcome: 'idle' | 'closed' | 'timeout'): void => {
|
|
470
|
+
clearTimeout(timer)
|
|
471
|
+
this.idleWaiters.delete(wake)
|
|
472
|
+
resolve(outcome)
|
|
473
|
+
}
|
|
474
|
+
const wake = (): void => end(this.state === 'draining' ? 'idle' : 'closed')
|
|
475
|
+
const timer = setTimeout(() => end('timeout'), this.deps.drainTimeoutMs)
|
|
476
|
+
this.idleWaiters.add(wake)
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private wakeIdleWaiters(): void {
|
|
481
|
+
for (const wake of [...this.idleWaiters]) wake()
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private closeLedger(): void {
|
|
485
|
+
try {
|
|
486
|
+
this.raw.close()
|
|
487
|
+
} catch {
|
|
488
|
+
// Already closed.
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private async finish(flushed: boolean): Promise<void> {
|
|
493
|
+
this.setState('closed')
|
|
494
|
+
this.stopTimers()
|
|
495
|
+
this.closeLedger()
|
|
496
|
+
// With everything flushed the durable copy is the authority, and this replica keeps no copy of the tenant's data. An
|
|
497
|
+
// abandoned tenant keeps its file: it may hold writes that exist nowhere else, until the next open of that tenant on this
|
|
498
|
+
// replica, whose restore replaces it.
|
|
499
|
+
if (flushed) {
|
|
500
|
+
try { discardLocalLedger(this.deps.context) } catch { /* a leftover file is discarded at the next restore */ }
|
|
501
|
+
}
|
|
502
|
+
this.deps.detach(this)
|
|
503
|
+
try {
|
|
504
|
+
await this.deps.lease.release()
|
|
505
|
+
} finally {
|
|
506
|
+
this.emit({ type: 'released', key: this.deps.key, epoch: this.epoch, flushed })
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** The lease is gone for good: stop serving, close the ledger, leave the stale local file to be replaced by the next restore. */
|
|
511
|
+
private markLost(): void {
|
|
512
|
+
if (this.state === 'closed') return
|
|
513
|
+
this.setState('closed')
|
|
514
|
+
this.stopTimers()
|
|
515
|
+
this.closeLedger()
|
|
516
|
+
this.deps.detach(this)
|
|
517
|
+
this.emit({ type: 'lost', key: this.deps.key, epoch: this.epoch })
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private stopTimers(): void {
|
|
521
|
+
if (this.renewTimer) clearTimeout(this.renewTimer)
|
|
522
|
+
this.renewTimer = null
|
|
523
|
+
this.stopSnapshotTimer()
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private stopSnapshotTimer(): void {
|
|
527
|
+
if (this.snapshotTimer) clearTimeout(this.snapshotTimer)
|
|
528
|
+
this.snapshotTimer = null
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private scheduleRenewal(): void {
|
|
532
|
+
// Idempotent, and never beside a renewal that is under way: a handoff that fails resumes service while its renewals never
|
|
533
|
+
// stopped, and a second chain would double them for the rest of the tenure.
|
|
534
|
+
if (!this.deps.autoRenew || this.state === 'closed' || this.renewTimer || this.renewTicking) return
|
|
535
|
+
this.renewTimer = setTimeout(() => { void this.renewTick() }, this.deps.renewEveryMs)
|
|
536
|
+
this.renewTimer.unref()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private async renewTick(): Promise<void> {
|
|
540
|
+
this.renewTimer = null
|
|
541
|
+
this.renewTicking = true
|
|
542
|
+
try {
|
|
543
|
+
await this.renew()
|
|
544
|
+
} catch (error) {
|
|
545
|
+
if (this.state !== 'closed') this.emit({ type: 'renew_failed', key: this.deps.key, epoch: this.epoch, code: (error as { code?: string }).code ?? 'unknown' })
|
|
546
|
+
} finally {
|
|
547
|
+
this.renewTicking = false
|
|
548
|
+
}
|
|
549
|
+
this.scheduleRenewal()
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private scheduleSnapshot(): void {
|
|
553
|
+
if (this.deps.snapshotEveryMs <= 0 || this.state === 'closed' || this.snapshotTimer) return
|
|
554
|
+
this.snapshotTimer = setTimeout(() => { void this.snapshotTick() }, this.deps.snapshotEveryMs)
|
|
555
|
+
this.snapshotTimer.unref()
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private async snapshotTick(): Promise<void> {
|
|
559
|
+
this.snapshotTimer = null
|
|
560
|
+
if (this.snapshotQueued === 0) await this.snapshot().catch(() => undefined)
|
|
561
|
+
this.scheduleSnapshot()
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---- the router -------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
const DEFAULT_TTL_MS = 10_000
|
|
568
|
+
const DEFAULT_MARGIN_MS = 2_000
|
|
569
|
+
const DEFAULT_DRAIN_TIMEOUT_MS = 120_000
|
|
570
|
+
const DEFAULT_CLOSE_CONCURRENCY = 8
|
|
571
|
+
const MAX_TIMER_MS = 2_147_483_647
|
|
572
|
+
|
|
573
|
+
export class TenantRouter {
|
|
574
|
+
private readonly options: TenantRouterOptions
|
|
575
|
+
private readonly clock: MonotonicClock
|
|
576
|
+
private readonly handles = new Map<string, OpenTenant>()
|
|
577
|
+
private readonly opening = new Map<string, Promise<TenantHandle>>()
|
|
578
|
+
private readonly forgets = new Set<Promise<unknown>>()
|
|
579
|
+
private closed = false
|
|
580
|
+
|
|
581
|
+
constructor(options: TenantRouterOptions) {
|
|
582
|
+
if (typeof options.replicaId !== 'string' || options.replicaId.length === 0) throw new RangeError('replicaId must be a non-empty string')
|
|
583
|
+
if (options.maxOpenTenants !== undefined && (!Number.isSafeInteger(options.maxOpenTenants) || options.maxOpenTenants < 1)) throw new RangeError('maxOpenTenants must be a positive integer')
|
|
584
|
+
if (options.drainTimeoutMs !== undefined && (!Number.isSafeInteger(options.drainTimeoutMs) || options.drainTimeoutMs < 1 || options.drainTimeoutMs > MAX_TIMER_MS)) throw new RangeError(`drainTimeoutMs must be an integer within [1, ${MAX_TIMER_MS}]`)
|
|
585
|
+
if (options.closeConcurrency !== undefined && (!Number.isSafeInteger(options.closeConcurrency) || options.closeConcurrency < 1)) throw new RangeError('closeConcurrency must be a positive integer')
|
|
586
|
+
this.options = options
|
|
587
|
+
this.clock = options.clock ?? systemMonotonicClock
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The handle of a tenant this replica owns, acquiring the lease and restoring the ledger first if needed.
|
|
592
|
+
* Concurrent calls for one tenant share one acquisition.
|
|
593
|
+
*
|
|
594
|
+
* @throws InvalidTenantIdError for an id that is not in the fixed format, before any store or file is touched
|
|
595
|
+
* @throws LeaseHeldError when another replica owns the tenant; the record names it
|
|
596
|
+
*/
|
|
597
|
+
openTenant(organization: string, ledgerTenant: string): Promise<TenantHandle> {
|
|
598
|
+
if (this.closed) return Promise.reject(new RouterClosedError())
|
|
599
|
+
let key: string
|
|
600
|
+
try {
|
|
601
|
+
key = leaseKeyFor(organization, ledgerTenant)
|
|
602
|
+
} catch (error) {
|
|
603
|
+
return Promise.reject(error)
|
|
604
|
+
}
|
|
605
|
+
const existing = this.handles.get(key)
|
|
606
|
+
if (existing?.isOpen()) return Promise.resolve(existing)
|
|
607
|
+
// Our own lease is still held while a handoff drains, so an acquisition would only be told it is held by us.
|
|
608
|
+
if (existing?.isDraining()) return Promise.reject(new TenantDrainingError(key))
|
|
609
|
+
const pending = this.opening.get(key)
|
|
610
|
+
if (pending) return pending
|
|
611
|
+
const cap = this.options.maxOpenTenants
|
|
612
|
+
// A tenant being opened already holds a slot, and so does one being handed off: both still hold their descriptors.
|
|
613
|
+
if (cap !== undefined && new Set([...this.handles.keys(), ...this.opening.keys()]).size >= cap) return Promise.reject(new ReplicaAtCapacityError(cap))
|
|
614
|
+
const attempt = this.open(organization, ledgerTenant, key).finally(() => { this.opening.delete(key) })
|
|
615
|
+
this.opening.set(key, attempt)
|
|
616
|
+
return attempt
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** The open handle of a tenant, if this replica currently serves it. */
|
|
620
|
+
handleOf(organization: string, ledgerTenant: string): TenantHandle | null {
|
|
621
|
+
const existing = this.handles.get(leaseKeyFor(organization, ledgerTenant))
|
|
622
|
+
return existing?.isOpen() ? existing : null
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
openCount(): number {
|
|
626
|
+
return [...this.handles.values()].filter(handle => handle.isOpen()).length
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The directory of one tenant on this replica: its ledger and everything that must travel with it (planner working
|
|
631
|
+
* files, tickets). A consumer that keeps per-tenant files puts them here, never beside another tenant's.
|
|
632
|
+
*/
|
|
633
|
+
tenantRootOf(organization: string, ledgerTenant: string): string {
|
|
634
|
+
return tenantRootFor(this.options.stateRoot, organization, ledgerTenant)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Hands every open tenant off gracefully and refuses new ones. Resolves with the tenants whose flush failed, among them
|
|
639
|
+
* those whose drain ran past `drainTimeoutMs`. Tenants are handed off several at a time, so one whose drain cannot finish
|
|
640
|
+
* holds back only the slot it sits in, and for no longer than that deadline. A forget that is under way is waited for, for
|
|
641
|
+
* as long as a drain may take. A durable store call that never returns is the binding's to time out (`DurableStore`).
|
|
642
|
+
*/
|
|
643
|
+
async close(): Promise<string[]> {
|
|
644
|
+
this.closed = true
|
|
645
|
+
const queue = [...this.handles]
|
|
646
|
+
const flushed: boolean[] = new Array<boolean>(queue.length).fill(false)
|
|
647
|
+
let next = 0
|
|
648
|
+
const lane = async (): Promise<void> => {
|
|
649
|
+
for (;;) {
|
|
650
|
+
const index = next
|
|
651
|
+
next += 1
|
|
652
|
+
if (index >= queue.length) return
|
|
653
|
+
try {
|
|
654
|
+
await queue[index]![1].handoff()
|
|
655
|
+
flushed[index] = true
|
|
656
|
+
} catch {
|
|
657
|
+
// Reported below, in the order the tenants were opened.
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
await Promise.all(Array.from({ length: Math.min(this.options.closeConcurrency ?? DEFAULT_CLOSE_CONCURRENCY, queue.length) }, lane))
|
|
662
|
+
await this.forgetsUnderWay()
|
|
663
|
+
return queue.filter((_, index) => !flushed[index]).map(([key]) => key)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Waits for the forgets that have left the table but are not over, but not longer than a drain may take. */
|
|
667
|
+
private async forgetsUnderWay(): Promise<void> {
|
|
668
|
+
if (this.forgets.size === 0) return
|
|
669
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
670
|
+
const deadline = new Promise<void>((resolve) => { timer = setTimeout(resolve, this.options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS) })
|
|
671
|
+
try {
|
|
672
|
+
await Promise.race([Promise.allSettled([...this.forgets]), deadline])
|
|
673
|
+
} finally {
|
|
674
|
+
clearTimeout(timer)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private async open(organization: string, ledgerTenant: string, key: string): Promise<TenantHandle> {
|
|
679
|
+
const { options } = this
|
|
680
|
+
// A broken observer must never leave a lease held and unrenewed: it is told what happened, and nothing it throws matters.
|
|
681
|
+
const emit = (event: RouterEvent): void => {
|
|
682
|
+
try {
|
|
683
|
+
options.onEvent?.(event)
|
|
684
|
+
} catch {
|
|
685
|
+
// Ignored on purpose.
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const lease = await TenantLease.acquire({
|
|
689
|
+
store: options.leases,
|
|
690
|
+
key,
|
|
691
|
+
replicaId: options.replicaId,
|
|
692
|
+
ttlMs: options.ttlMs ?? DEFAULT_TTL_MS,
|
|
693
|
+
marginMs: options.marginMs ?? DEFAULT_MARGIN_MS,
|
|
694
|
+
clock: this.clock,
|
|
695
|
+
})
|
|
696
|
+
emit({ type: 'acquired', key, epoch: lease.epoch })
|
|
697
|
+
const context: EpochCopyContext = {
|
|
698
|
+
durable: options.durable,
|
|
699
|
+
lease,
|
|
700
|
+
organization,
|
|
701
|
+
ledgerTenant,
|
|
702
|
+
tenantRoot: tenantRootFor(options.stateRoot, organization, ledgerTenant),
|
|
703
|
+
}
|
|
704
|
+
let restored: RestoreResult
|
|
705
|
+
try {
|
|
706
|
+
restored = await restoreTenantLedger(context)
|
|
707
|
+
} catch (error) {
|
|
708
|
+
await lease.release().catch(() => undefined)
|
|
709
|
+
throw error
|
|
710
|
+
}
|
|
711
|
+
emit({ type: 'restored', key, epoch: lease.epoch, from: restored.restoredFrom, skipped: restored.skipped.length })
|
|
712
|
+
if (this.closed) {
|
|
713
|
+
restored.ledger.close()
|
|
714
|
+
await lease.release().catch(() => undefined)
|
|
715
|
+
throw new RouterClosedError()
|
|
716
|
+
}
|
|
717
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
|
|
718
|
+
const handle = new OpenTenant({
|
|
719
|
+
key,
|
|
720
|
+
organization,
|
|
721
|
+
ledgerTenant,
|
|
722
|
+
lease,
|
|
723
|
+
context,
|
|
724
|
+
restored,
|
|
725
|
+
autoRenew: options.autoRenew ?? true,
|
|
726
|
+
renewEveryMs: options.renewEveryMs ?? Math.max(50, Math.floor(ttlMs / 3)),
|
|
727
|
+
snapshotEveryMs: options.snapshotEveryMs ?? 0,
|
|
728
|
+
drainTimeoutMs: options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS,
|
|
729
|
+
emit,
|
|
730
|
+
detach: (gone) => { if (this.handles.get(key) === gone) this.handles.delete(key) },
|
|
731
|
+
retain: (work) => {
|
|
732
|
+
this.forgets.add(work)
|
|
733
|
+
void work.then(() => { this.forgets.delete(work) })
|
|
734
|
+
},
|
|
735
|
+
})
|
|
736
|
+
this.handles.set(key, handle)
|
|
737
|
+
handle.begin()
|
|
738
|
+
// What a superseded owner uploaded after this tenant was forgotten is dead whoever wrote it. It is deleted here, off the
|
|
739
|
+
// open path, so a slow store cannot use up the lease before the first renewal; a failure is tried again at the next open.
|
|
740
|
+
if (lease.forgottenAtEpoch > 0) void sweepMaskedCopies(options.durable, key, lease.forgottenAtEpoch).catch(() => undefined)
|
|
741
|
+
return handle
|
|
742
|
+
}
|
|
743
|
+
}
|