@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.
Files changed (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. 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
+ }