@bjornpagen/bumbledb-log 0.18.0 → 0.19.0

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/src/tenants.ts CHANGED
@@ -2,19 +2,36 @@
2
2
  * Per-tenant replicas (50, case 4): an LRU of replicas keyed by tenant
3
3
  * id — a tenant is a prefix (`<root>/t/<tenant>`), eviction closes and
4
4
  * deletes the local dir (the disposable law), and `t/_shared` is
5
- * pinned. Braids shard within a tenant; tenants shard the world.
5
+ * pinned. Braids shard within a tenant; tenants shard the world. A
6
+ * tenant id is one StoreKey segment. `get` returns a live handle whose
7
+ * refcount pins it against eviction for the duration of the borrow
8
+ * (findings 38, 71) — the pin outlives `get` and drops only on
9
+ * `release`; a disposed handle is a distinct type. One fenced lease
10
+ * owns each replica directory and is CAS-renewed while the slot is
11
+ * open, so a second live replica on that dir is unrepresentable
12
+ * (findings 39, 48).
6
13
  */
7
14
 
8
15
  import * as fs from "node:fs/promises"
9
16
  import * as path from "node:path"
10
17
  import type { Schema, SchemaRelations } from "@bjornpagen/bumbledb"
11
18
  import * as errors from "@superbuilders/errors"
19
+ import { regex } from "arkregex"
12
20
  import { tenantPrefix } from "#keys.ts"
13
21
  import type { Replica } from "#replica.ts"
14
22
  import { openReplica } from "#replica.ts"
15
- import type { ObjectStore } from "#store.ts"
23
+ import type { FsLease, ObjectStore } from "#store.ts"
24
+ import { acquireFsLease, releaseFsLease } from "#store.ts"
16
25
 
17
26
  const PINNED_TENANT = "_shared"
27
+ const SIGNED_DECIMAL = regex("^-?\\d+$")
28
+ const UNSIGNED_DECIMAL = regex("^\\d+$")
29
+ const TENANT_ID = regex("^[A-Za-z0-9._-]+$")
30
+
31
+ /** Directory-lease lifetime: one owner per replica directory. The pool
32
+ * CAS-extends `expires` at one-third this TTL while the slot is open,
33
+ * so expiry cannot mint a second owner under a live replica. */
34
+ const DIR_LEASE_MS = 300_000
18
35
 
19
36
  interface OpenTenantsOptions<Rels extends SchemaRelations> {
20
37
  readonly store: ObjectStore
@@ -26,17 +43,117 @@ interface OpenTenantsOptions<Rels extends SchemaRelations> {
26
43
  * admission is not re-weighed until it is evicted and re-opened. */
27
44
  readonly budgetBytes?: number
28
45
  readonly maxOpen?: number
46
+ /** Directory-lease TTL. Renewed at one-third this value while the
47
+ * replica is open. Default 300_000. */
48
+ readonly dirLeaseMs?: number
49
+ }
50
+
51
+ const liveHandleBrand: unique symbol = Symbol("liveHandle")
52
+ const disposedHandleBrand: unique symbol = Symbol("disposedHandle")
53
+
54
+ type LiveHandle<Rels extends SchemaRelations> = Replica<Rels> & {
55
+ readonly [liveHandleBrand]: typeof liveHandleBrand
56
+ /** Drop one borrow. LRU and `evict` become legal only when `refs`
57
+ * reaches 0 — not when `get` returns. */
58
+ release(): void
59
+ }
60
+
61
+ /** A handle whose replica is gone. Every verb is a compile-time
62
+ * refusal — there is no replica field to call, so `waitFor` into a
63
+ * disposed replica is a type error. */
64
+ type DisposedHandle = {
65
+ readonly [disposedHandleBrand]: typeof disposedHandleBrand
29
66
  }
30
67
 
31
68
  interface Tenants<Rels extends SchemaRelations> extends AsyncDisposable {
32
- get(tenant: string): Promise<Replica<Rels>>
69
+ get(tenant: string): Promise<LiveHandle<Rels>>
70
+ /** Evicts one tenant: closes the replica and deletes its directory.
71
+ * `_shared` and a still-pinned borrow refuse by doing nothing. */
72
+ evict(tenant: string): Promise<DisposedHandle | null>
73
+ /** Drop one `get` borrow for `tenant`. */
74
+ release(tenant: string): void
33
75
  }
34
76
 
35
77
  interface TenantSlot<Rels extends SchemaRelations> {
36
- readonly replica: Replica<Rels>
78
+ readonly replica: LiveHandle<Rels>
37
79
  readonly dir: string
80
+ readonly lease: FsLease
38
81
  bytes: number
39
82
  lastUsed: number
83
+ refs: number
84
+ leaseLost?: Error
85
+ }
86
+
87
+ function codeOf(error: Error): string | undefined {
88
+ return (error as NodeJS.ErrnoException).code
89
+ }
90
+
91
+ function encodeLease(holder: bigint, token: bigint, expires: bigint): Uint8Array {
92
+ return new TextEncoder().encode(`${holder}\n${token}\n${expires}\n`)
93
+ }
94
+
95
+ function parseLease(raw: string): { holder: bigint; token: bigint; expires: bigint } | null {
96
+ const lines = raw.trim().split("\n")
97
+ if (lines.length !== 3) {
98
+ return null
99
+ }
100
+ const [holderLine, tokenLine, expiresLine] = lines
101
+ if (holderLine === undefined || tokenLine === undefined || expiresLine === undefined) {
102
+ return null
103
+ }
104
+ if (!SIGNED_DECIMAL.test(holderLine) || !UNSIGNED_DECIMAL.test(tokenLine) || !SIGNED_DECIMAL.test(expiresLine)) {
105
+ return null
106
+ }
107
+ return { holder: BigInt(holderLine), token: BigInt(tokenLine), expires: BigInt(expiresLine) }
108
+ }
109
+
110
+ async function fsyncDir(dir: string): Promise<void> {
111
+ const handle = await fs.open(dir, "r")
112
+ const synced = await errors.try(handle.sync())
113
+ await handle.close()
114
+ if (synced.error) {
115
+ throw errors.wrap(synced.error, `fsync directory ${dir}`)
116
+ }
117
+ }
118
+
119
+ /** CAS-extend `expires` of a token we already hold. A new token would
120
+ * be a second owner; we rewrite the same path so identity does not
121
+ * change. */
122
+ async function renewDirLease(held: FsLease, ttlMs: number): Promise<void> {
123
+ const read = await errors.try(fs.readFile(held.path, "utf8"))
124
+ if (read.error) {
125
+ if (codeOf(read.error) === "ENOENT") {
126
+ return
127
+ }
128
+ throw read.error
129
+ }
130
+ const lease = parseLease(read.data)
131
+ if (lease === null || lease.holder !== held.holder || lease.token !== held.token) {
132
+ throw errors.new("directory lease is no longer ours")
133
+ }
134
+ const body = encodeLease(held.holder, held.token, BigInt(Date.now() + ttlMs))
135
+ const tmp = `${held.path}.renew.${process.pid}`
136
+ const file = await fs.open(tmp, "w")
137
+ const written = await errors.try(
138
+ (async function writeAll() {
139
+ await file.writeFile(body)
140
+ await file.sync()
141
+ })()
142
+ )
143
+ await file.close()
144
+ if (written.error) {
145
+ await fs.rm(tmp, { force: true })
146
+ throw written.error
147
+ }
148
+ const replaced = await errors.try(fs.rename(tmp, held.path))
149
+ if (replaced.error) {
150
+ await fs.rm(tmp, { force: true })
151
+ if (codeOf(replaced.error) === "ENOENT") {
152
+ return
153
+ }
154
+ throw replaced.error
155
+ }
156
+ await fsyncDir(held.dir)
40
157
  }
41
158
 
42
159
  async function directoryBytes(dir: string): Promise<number> {
@@ -57,19 +174,101 @@ async function directoryBytes(dir: string): Promise<number> {
57
174
  }
58
175
 
59
176
  function checkTenantId(tenant: string): void {
60
- if (!/^[A-Za-z0-9._-]+$/.test(tenant)) {
177
+ if (!TENANT_ID.test(tenant)) {
61
178
  throw errors.new(`tenant id is not a single path segment: ${tenant}`)
62
179
  }
63
180
  }
64
181
 
182
+ function asDisposed(): DisposedHandle {
183
+ return { [disposedHandleBrand]: disposedHandleBrand }
184
+ }
185
+
186
+ async function disposeSlot<Rels extends SchemaRelations>(slot: TenantSlot<Rels>): Promise<void> {
187
+ await slot.replica[Symbol.asyncDispose]()
188
+ await fs.rm(slot.dir, { recursive: true, force: true })
189
+ await releaseFsLease(slot.lease)
190
+ }
191
+
65
192
  function openTenants<Rels extends SchemaRelations>(options: OpenTenantsOptions<Rels>): Tenants<Rels> {
66
193
  const budgetBytes = options.budgetBytes ?? 400_000_000
67
194
  const maxOpen = options.maxOpen ?? 32
195
+ const dirLeaseMs = options.dirLeaseMs ?? DIR_LEASE_MS
196
+ if (dirLeaseMs <= 0) {
197
+ throw errors.new("dirLeaseMs must be positive")
198
+ }
199
+ const renewEveryMs = Math.max(1, Math.floor(dirLeaseMs / 3))
68
200
  const open = new Map<string, TenantSlot<Rels>>()
201
+ const opening = new Map<string, Promise<LiveHandle<Rels>>>()
69
202
  let tick = 0
70
203
  let closed = false
204
+ let renewTimer: ReturnType<typeof setInterval> | undefined
205
+ let leaseGate = Promise.resolve()
206
+
207
+ function withLeaseGate<T>(body: () => Promise<T>): Promise<T> {
208
+ const run = leaseGate.then(body, body)
209
+ leaseGate = run.then(
210
+ function absorb() {
211
+ return undefined
212
+ },
213
+ function absorbFailure() {
214
+ return undefined
215
+ }
216
+ )
217
+ return run
218
+ }
219
+
220
+ function dropBorrow(tenant: string): void {
221
+ if (closed) {
222
+ throw errors.new("tenants pool is disposed")
223
+ }
224
+ const slot = open.get(tenant)
225
+ if (slot === undefined || slot.refs <= 0) {
226
+ throw errors.new(`no live borrow for tenant ${tenant}`)
227
+ }
228
+ slot.refs -= 1
229
+ }
230
+
231
+ function brandLive(replica: Replica<Rels>, tenant: string): LiveHandle<Rels> {
232
+ const handle = replica as LiveHandle<Rels>
233
+ Object.defineProperty(handle, liveHandleBrand, { value: liveHandleBrand })
234
+ Object.defineProperty(handle, "release", {
235
+ value: function release() {
236
+ dropBorrow(tenant)
237
+ },
238
+ enumerable: false
239
+ })
240
+ return handle
241
+ }
71
242
 
72
- async function evictUntilWithin(): Promise<void> {
243
+ function armRenew(): void {
244
+ if (renewTimer !== undefined) {
245
+ return
246
+ }
247
+ renewTimer = setInterval(function tickRenew() {
248
+ void errors.try(renewAllOpen())
249
+ }, renewEveryMs)
250
+ }
251
+
252
+ function disarmRenew(): void {
253
+ if (open.size > 0 || renewTimer === undefined) {
254
+ return
255
+ }
256
+ clearInterval(renewTimer)
257
+ renewTimer = undefined
258
+ }
259
+
260
+ async function renewAllOpen(): Promise<void> {
261
+ await withLeaseGate(async function renewHeld() {
262
+ for (const [tenant, slot] of open) {
263
+ const result = await errors.try(renewDirLease(slot.lease, dirLeaseMs))
264
+ if (result.error !== undefined && open.get(tenant) === slot) {
265
+ slot.leaseLost = result.error
266
+ }
267
+ }
268
+ })
269
+ }
270
+
271
+ async function evictUntilWithin(keep: string): Promise<void> {
73
272
  for (;;) {
74
273
  let bytes = 0
75
274
  for (const slot of open.values()) {
@@ -81,7 +280,7 @@ function openTenants<Rels extends SchemaRelations>(options: OpenTenantsOptions<R
81
280
  let victim: string | null = null
82
281
  let oldest = Number.POSITIVE_INFINITY
83
282
  for (const [tenant, slot] of open) {
84
- if (tenant === PINNED_TENANT) {
283
+ if (tenant === PINNED_TENANT || tenant === keep || slot.refs > 0) {
85
284
  continue
86
285
  }
87
286
  if (slot.lastUsed < oldest) {
@@ -95,12 +294,70 @@ function openTenants<Rels extends SchemaRelations>(options: OpenTenantsOptions<R
95
294
  const slot = open.get(victim)
96
295
  open.delete(victim)
97
296
  if (slot !== undefined) {
98
- await slot.replica[Symbol.asyncDispose]()
99
- await fs.rm(slot.dir, { recursive: true, force: true })
297
+ await withLeaseGate(function disposeVictim() {
298
+ return disposeSlot(slot)
299
+ })
300
+ }
301
+ }
302
+ }
303
+
304
+ /** Increment the borrow. The pin outlives `get` — a `finally`
305
+ * decrement here would make LRU/`evict` legal while the caller
306
+ * still holds the LiveHandle (finding 38). Decrement only if this
307
+ * `get` fails before the handle is in the caller's hands. */
308
+ async function pin(slot: TenantSlot<Rels>, tenant: string): Promise<LiveHandle<Rels>> {
309
+ if (slot.leaseLost !== undefined) {
310
+ throw slot.leaseLost
311
+ }
312
+ slot.refs += 1
313
+ slot.lastUsed = tick
314
+ try {
315
+ await evictUntilWithin(tenant)
316
+ const kept = open.get(tenant)
317
+ if (kept === undefined) {
318
+ throw errors.new("tenants pool evicted the handle it is returning")
100
319
  }
320
+ await withLeaseGate(function renewOnPin() {
321
+ return renewDirLease(kept.lease, dirLeaseMs)
322
+ })
323
+ return kept.replica
324
+ } catch (error) {
325
+ slot.refs -= 1
326
+ throw error
101
327
  }
102
328
  }
103
329
 
330
+ async function openOne(tenant: string): Promise<LiveHandle<Rels>> {
331
+ const dir = path.join(options.dir, tenant)
332
+ const lease = await acquireFsLease(dir, "dir", dirLeaseMs, "refuse")
333
+ let replica: LiveHandle<Rels>
334
+ try {
335
+ replica = brandLive(
336
+ await openReplica({
337
+ store: options.store,
338
+ prefix: tenantPrefix(options.root, tenant),
339
+ dir,
340
+ theory: options.theory
341
+ }),
342
+ tenant
343
+ )
344
+ } catch (error) {
345
+ await releaseFsLease(lease)
346
+ throw error
347
+ }
348
+ const slot: TenantSlot<Rels> = {
349
+ replica,
350
+ dir,
351
+ lease,
352
+ bytes: await directoryBytes(dir),
353
+ lastUsed: tick,
354
+ refs: 0
355
+ }
356
+ open.set(tenant, slot)
357
+ armRenew()
358
+ return await pin(slot, tenant)
359
+ }
360
+
104
361
  return {
105
362
  async get(tenant) {
106
363
  if (closed) {
@@ -110,31 +367,70 @@ function openTenants<Rels extends SchemaRelations>(options: OpenTenantsOptions<R
110
367
  tick += 1
111
368
  const hit = open.get(tenant)
112
369
  if (hit !== undefined) {
113
- hit.lastUsed = tick
114
- return hit.replica
115
- }
116
- const dir = path.join(options.dir, tenant)
117
- const replica = await openReplica({
118
- store: options.store,
119
- prefix: tenantPrefix(options.root, tenant),
120
- dir,
121
- theory: options.theory
370
+ return await pin(hit, tenant)
371
+ }
372
+ const inflight = opening.get(tenant)
373
+ if (inflight !== undefined) {
374
+ const ran = await errors.try(inflight)
375
+ if (ran.error) {
376
+ throw ran.error
377
+ }
378
+ const slot = open.get(tenant)
379
+ if (slot === undefined) {
380
+ throw errors.new("tenants pool dropped the tenant during open")
381
+ }
382
+ return await pin(slot, tenant)
383
+ }
384
+ const pending = openOne(tenant)
385
+ opening.set(tenant, pending)
386
+ const ran = await errors.try(pending)
387
+ opening.delete(tenant)
388
+ if (ran.error) {
389
+ throw ran.error
390
+ }
391
+ return ran.data
392
+ },
393
+
394
+ async evict(tenant) {
395
+ if (closed) {
396
+ throw errors.new("tenants pool is disposed")
397
+ }
398
+ checkTenantId(tenant)
399
+ if (tenant === PINNED_TENANT) {
400
+ return null
401
+ }
402
+ const slot = open.get(tenant)
403
+ if (slot === undefined || slot.refs > 0) {
404
+ return null
405
+ }
406
+ open.delete(tenant)
407
+ await withLeaseGate(function disposeEvicted() {
408
+ return disposeSlot(slot)
122
409
  })
123
- const slot: TenantSlot<Rels> = { replica, dir, bytes: await directoryBytes(dir), lastUsed: tick }
124
- open.set(tenant, slot)
125
- await evictUntilWithin()
126
- return replica
410
+ disarmRenew()
411
+ return asDisposed()
412
+ },
413
+
414
+ release(tenant) {
415
+ dropBorrow(tenant)
127
416
  },
128
417
 
129
418
  async [Symbol.asyncDispose]() {
130
419
  closed = true
131
- for (const slot of open.values()) {
132
- await slot.replica[Symbol.asyncDispose]()
420
+ if (renewTimer !== undefined) {
421
+ clearInterval(renewTimer)
422
+ renewTimer = undefined
133
423
  }
134
- open.clear()
424
+ await withLeaseGate(async function disposePool() {
425
+ for (const slot of open.values()) {
426
+ await releaseFsLease(slot.lease)
427
+ await slot.replica[Symbol.asyncDispose]()
428
+ }
429
+ open.clear()
430
+ })
135
431
  }
136
432
  }
137
433
  }
138
434
 
139
- export type { OpenTenantsOptions, Tenants }
435
+ export type { DisposedHandle, LiveHandle, OpenTenantsOptions, Tenants }
140
436
  export { openTenants }
package/src/value.ts CHANGED
@@ -19,6 +19,41 @@ interface Interval {
19
19
 
20
20
  type Value = boolean | bigint | string | Uint8Array | Interval
21
21
 
22
+ /** A string cell: well-formed UTF-8 by construction. A lone surrogate cannot enter. */
23
+ declare const wellFormedUtf8Brand: unique symbol
24
+ type WellFormedUtf8 = string & { readonly [wellFormedUtf8Brand]: typeof wellFormedUtf8Brand }
25
+
26
+ /** Fatal encoder: refuses lone surrogates rather than emitting U+FFFD. */
27
+ function wellFormedUtf8(text: string): WellFormedUtf8 {
28
+ if (!text.isWellFormed()) {
29
+ throw errors.new("string cell is not well-formed UTF-8")
30
+ }
31
+ return text as WellFormedUtf8
32
+ }
33
+
34
+ function parseWellFormedUtf8(raw: Uint8Array): WellFormedUtf8 | undefined {
35
+ const decoded = errors.trySync(function decodeUtf8() {
36
+ return utf8StrictDecoder.decode(raw)
37
+ })
38
+ if (decoded.error) {
39
+ return undefined
40
+ }
41
+ return decoded.data as WellFormedUtf8
42
+ }
43
+
44
+ function domainCeiling(element: "u64" | "i64"): bigint {
45
+ return element === "u64" ? U64_MAX : I64_MAX
46
+ }
47
+
48
+ /** Fixed-width `[start, start + width)`; the domain ceiling is not a value. */
49
+ function fixedInterval(start: bigint, width: bigint, element: "u64" | "i64"): Interval | undefined {
50
+ const end = start + width
51
+ if (end <= start || end >= domainCeiling(element)) {
52
+ return undefined
53
+ }
54
+ return { start, end }
55
+ }
56
+
22
57
  /** 20's tag table: the codec's own numbering, normative to the byte. */
23
58
  const TAG = {
24
59
  bool: 0,
@@ -72,9 +107,10 @@ function checkAgainst(context: string, type: ValueTypeSpec, value: Value): void
72
107
  return
73
108
  }
74
109
  case "string": {
75
- if (typeof value !== "string" || !value.isWellFormed()) {
110
+ if (typeof value !== "string") {
76
111
  throw errors.new(`${context}: expected well-formed string`)
77
112
  }
113
+ wellFormedUtf8(value)
78
114
  return
79
115
  }
80
116
  case "fixedBytes": {
@@ -92,8 +128,14 @@ function checkAgainst(context: string, type: ValueTypeSpec, value: Value): void
92
128
  if (value.start < lo || value.end > hi || value.start >= value.end) {
93
129
  throw errors.new(`${context}: interval bounds out of range or empty`)
94
130
  }
95
- if (type.width !== undefined && value.end - value.start !== type.width) {
96
- throw errors.new(`${context}: interval width must be ${type.width}`)
131
+ if (type.width !== undefined) {
132
+ const parsed = fixedInterval(value.start, type.width, type.element)
133
+ if (parsed === undefined) {
134
+ throw errors.new(`${context}: fixed interval end is the domain ceiling`)
135
+ }
136
+ if (parsed.end !== value.end) {
137
+ throw errors.new(`${context}: interval width must be ${type.width}`)
138
+ }
97
139
  }
98
140
  return
99
141
  }
@@ -119,7 +161,8 @@ function writeTagged(out: ByteWriter, type: ValueTypeSpec, value: Value): void {
119
161
  return
120
162
  }
121
163
  case "string": {
122
- const raw = utf8Encoder.encode(value as string)
164
+ const text = wellFormedUtf8(value as string)
165
+ const raw = utf8Encoder.encode(text)
123
166
  out.u8(TAG.string)
124
167
  out.u32le(raw.length)
125
168
  out.bytes(raw)
@@ -186,13 +229,11 @@ function readTagged(reader: ByteReader, type: ValueTypeSpec, refusal: TaggedRefu
186
229
  case "string": {
187
230
  const len = reader.u32le("string length")
188
231
  const raw = reader.bytes(len, "string payload")
189
- const decoded = errors.trySync(function decodeUtf8() {
190
- return utf8StrictDecoder.decode(raw)
191
- })
192
- if (decoded.error) {
232
+ const text = parseWellFormedUtf8(raw)
233
+ if (text === undefined) {
193
234
  refusal.invalidUtf8()
194
235
  }
195
- return decoded.data
236
+ return text
196
237
  }
197
238
  case "fixedBytes":
198
239
  return reader.bytes(type.len, "fixedBytes payload")
@@ -206,11 +247,11 @@ function readTagged(reader: ByteReader, type: ValueTypeSpec, refusal: TaggedRefu
206
247
  return { start, end }
207
248
  }
208
249
  const start = type.element === "u64" ? reader.u64le("interval start") : reader.i64le("interval start")
209
- const end = start + type.width
210
- if (type.element === "u64" ? end > U64_MAX : end > I64_MAX) {
250
+ const parsed = fixedInterval(start, type.width, type.element)
251
+ if (parsed === undefined) {
211
252
  refusal.intervalOverflow()
212
253
  }
213
- return { start, end }
254
+ return parsed
214
255
  }
215
256
  }
216
257
  }
@@ -281,5 +322,15 @@ function writeCanonicalLiteral(out: ByteWriter, type: ValueTypeSpec, value: Valu
281
322
  }
282
323
  }
283
324
 
284
- export type { Interval, TaggedRefusal, Value }
285
- export { checkAgainst, isInterval, readTagged, TAG, valuesEqual, wireTagOf, writeCanonicalLiteral, writeTagged }
325
+ export type { Interval, TaggedRefusal, Value, WellFormedUtf8 }
326
+ export {
327
+ checkAgainst,
328
+ isInterval,
329
+ readTagged,
330
+ TAG,
331
+ valuesEqual,
332
+ wellFormedUtf8,
333
+ wireTagOf,
334
+ writeCanonicalLiteral,
335
+ writeTagged
336
+ }