@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.4
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/out/client-sync.js +9 -9
- package/out/graph.js +1 -1
- package/out/view.js +52 -50
- package/package.json +1 -1
- package/server/objstore/blob-fs.ts +2 -2
- package/server/objstore/blob-vercel.ts +5 -5
- package/server/objstore/blob.ts +8 -1
- package/server/objstore/reaper.ts +30 -10
- package/server/objstore/rest.ts +31 -7
- package/server/util.ts +9 -0
package/package.json
CHANGED
|
@@ -48,13 +48,13 @@ async function fsOpenLiveReader(dir: string, tag: string, contentHash: string):
|
|
|
48
48
|
const path = liveFilePath(dir, tag, contentHash)
|
|
49
49
|
let fh
|
|
50
50
|
try { fh = await open(path, 'r') } catch (err: unknown) {
|
|
51
|
-
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable' }
|
|
51
|
+
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable', detail: 'fs-enoent' }
|
|
52
52
|
throw err
|
|
53
53
|
}
|
|
54
54
|
let size: number
|
|
55
55
|
try { size = (await fh.stat()).size } catch {
|
|
56
56
|
await fh.close().catch(() => {})
|
|
57
|
-
return { ok: false, reason: 'unavailable' }
|
|
57
|
+
return { ok: false, reason: 'unavailable', detail: 'fs-stat-failed' }
|
|
58
58
|
}
|
|
59
59
|
const stream = fh.createReadStream()
|
|
60
60
|
let closed = false
|
|
@@ -364,19 +364,19 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
364
364
|
// same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
|
|
365
365
|
// the client the resource is gone for good when it should refetch.
|
|
366
366
|
// See server/README.md's GET status table.
|
|
367
|
-
if (isNotFound(err)) return { ok: false, reason: 'unavailable' }
|
|
367
|
+
if (isNotFound(err)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
|
|
368
368
|
throw err
|
|
369
369
|
}
|
|
370
370
|
// SDK returned null (no blob) — same "bytes missing for a live row"
|
|
371
371
|
// transient as the BlobNotFoundError branch above → `unavailable`, not
|
|
372
372
|
// `not-found`.
|
|
373
|
-
if (res == null) return { ok: false, reason: 'unavailable' }
|
|
373
|
+
if (res == null) return { ok: false, reason: 'unavailable', detail: 'vercel-get-null' }
|
|
374
374
|
// statusCode 304 doesn't reach here in practice — the REST
|
|
375
375
|
// GET layer doesn't pass If-None-Match — but a future call
|
|
376
376
|
// site could. Treat as unavailable rather than streaming a
|
|
377
377
|
// null body.
|
|
378
378
|
if (res.statusCode !== 200 || res.stream == null) {
|
|
379
|
-
return { ok: false, reason: 'unavailable' }
|
|
379
|
+
return { ok: false, reason: 'unavailable', detail: `vercel-get-status-${res.statusCode}` }
|
|
380
380
|
}
|
|
381
381
|
// `@vercel/blob@2.x`'s streaming `get()` for private blobs returns
|
|
382
382
|
// the body but does NOT populate `blob.size` nor pass a
|
|
@@ -395,11 +395,11 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
395
395
|
// Blob vanished between get() and the head() size fallback (a
|
|
396
396
|
// racing reaper GC) — still the "live row present, bytes gone"
|
|
397
397
|
// transient, so `unavailable` (503), matching the get() path above.
|
|
398
|
-
if (isNotFound(headErr)) return { ok: false, reason: 'unavailable' }
|
|
398
|
+
if (isNotFound(headErr)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
|
|
399
399
|
throw headErr
|
|
400
400
|
}
|
|
401
401
|
}
|
|
402
|
-
if (size == null) return { ok: false, reason: 'unavailable' }
|
|
402
|
+
if (size == null) return { ok: false, reason: 'unavailable', detail: 'vercel-no-size' }
|
|
403
403
|
// SDK returns a web ReadableStream<Uint8Array>; the REST layer
|
|
404
404
|
// expects a Node Readable for pipeline(). Convert via
|
|
405
405
|
// Readable.fromWeb — built-in and zero-copy where possible.
|
package/server/objstore/blob.ts
CHANGED
|
@@ -94,9 +94,16 @@ export type LiveReader = {
|
|
|
94
94
|
// `unavailable`/503 contract, which the client retries. Both backends MUST
|
|
95
95
|
// map a missing blob to `unavailable` (FS: ENOENT; Vercel: BlobNotFoundError
|
|
96
96
|
// / null get()). No 404-mapping variant exists here so that bug can't recur.
|
|
97
|
+
//
|
|
98
|
+
// `detail` is a short, NON-SENSITIVE machine tag for the specific cause
|
|
99
|
+
// (e.g. 'vercel-get-not-found', 'fs-enoent', 'vercel-no-size'). Every
|
|
100
|
+
// byte-side failure collapses to the same 503 on the wire, so a permanent
|
|
101
|
+
// loss (reaper GC'd the bytes) and a transient read fault are otherwise
|
|
102
|
+
// indistinguishable — the REST layer logs `detail` so an operator can tell
|
|
103
|
+
// them apart. Purely diagnostic; the REST status is unchanged.
|
|
97
104
|
export type OpenLiveResult =
|
|
98
105
|
| { ok: true; reader: LiveReader }
|
|
99
|
-
| { ok: false; reason: 'unavailable' }
|
|
106
|
+
| { ok: false; reason: 'unavailable'; detail?: string }
|
|
100
107
|
|
|
101
108
|
export type BlobBackend = {
|
|
102
109
|
// Per-workspace setup. FS creates the on-disk staging directory;
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
// predicate can't match a row a concurrent upload just refreshed.
|
|
28
28
|
|
|
29
29
|
import { type Handle, STAGING_TTL_MS_DEFAULT, isValidContentHash, isValidStagingId, isValidTag } from './store.ts'
|
|
30
|
+
import { debugId, debugTag } from '../util.ts'
|
|
30
31
|
|
|
31
32
|
type StagingRow = {
|
|
32
33
|
workspace_tag: string
|
|
@@ -67,12 +68,19 @@ type StagingRow = {
|
|
|
67
68
|
// live-set re-read, not via mutual exclusion.
|
|
68
69
|
async function gcBlobIfUnreferenced(
|
|
69
70
|
handle: Handle, tag: string, hash: string, modifiedMs: number, now: number, grace: number,
|
|
70
|
-
): Promise<
|
|
71
|
-
if (!isValidContentHash(hash)) return
|
|
72
|
-
if (now - modifiedMs < grace) return
|
|
71
|
+
): Promise<boolean> {
|
|
72
|
+
if (!isValidContentHash(hash)) return false
|
|
73
|
+
if (now - modifiedMs < grace) return false
|
|
73
74
|
const refs = await liveHashSet(handle, tag)
|
|
74
|
-
if (refs.has(hash)) return
|
|
75
|
+
if (refs.has(hash)) return false
|
|
75
76
|
await handle.blob.unlinkLive(tag, hash)
|
|
77
|
+
// Log EVERY live-blob deletion unconditionally (not behind `debug`):
|
|
78
|
+
// this is the only record that the GC removed bytes. A handful per
|
|
79
|
+
// sweep is normal (superseded versions aging out); a burst across many
|
|
80
|
+
// workspaces is the smoking gun for the "all uploaded data went
|
|
81
|
+
// missing" failure — pair it with the sweep summary in `reapOrphans`.
|
|
82
|
+
console.warn(`objstore-reaper: GC live blob ${debugTag(tag)}/${debugId(hash)} (unreferenced, age ${Math.round((now - modifiedMs) / 1000)}s ≥ grace ${Math.round(grace / 1000)}s)`)
|
|
83
|
+
return true
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
// The set of content hashes referenced by the workspace's live rows.
|
|
@@ -86,18 +94,22 @@ async function liveHashSet(handle: Handle, tag: string): Promise<Set<string>> {
|
|
|
86
94
|
// snapshot we read up front can race a concurrent commit; the
|
|
87
95
|
// reference re-read inside `gcBlobIfUnreferenced` (plus the grace
|
|
88
96
|
// window) ensures we never unlink a blob a live row names.
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
// Returns the number of live blobs GC'd for this tag, so `reapOrphans`
|
|
98
|
+
// can surface a sweep-wide total (the headline signal for mass loss).
|
|
99
|
+
async function reapUnreferencedForTag(handle: Handle, tag: string, now: number, grace: number): Promise<number> {
|
|
100
|
+
if (!isValidTag(tag)) return 0
|
|
91
101
|
const blobs = await handle.blob.listLiveBlobs(tag)
|
|
92
|
-
if (blobs.length === 0) return
|
|
102
|
+
if (blobs.length === 0) return 0
|
|
93
103
|
const referenced = await liveHashSet(handle, tag)
|
|
104
|
+
let gc = 0
|
|
94
105
|
for (const { hash, modifiedMs } of blobs) {
|
|
95
106
|
if (!isValidContentHash(hash)) continue
|
|
96
107
|
// Referenced in our snapshot → skip the grace + re-read path
|
|
97
108
|
// entirely; only unreferenced blobs need it.
|
|
98
109
|
if (referenced.has(hash)) continue
|
|
99
|
-
await gcBlobIfUnreferenced(handle, tag, hash, modifiedMs, now, grace)
|
|
110
|
+
if (await gcBlobIfUnreferenced(handle, tag, hash, modifiedMs, now, grace)) gc++
|
|
100
111
|
}
|
|
112
|
+
return gc
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
// Drop staging rows older than the TTL and unlink their on-storage
|
|
@@ -172,9 +184,10 @@ export async function reapOrphans(handle: Handle, stagingTtlMs: number = STAGING
|
|
|
172
184
|
const grace = stagingTtlMs
|
|
173
185
|
// Pass 1: tags the live table knows about — GC unreferenced live
|
|
174
186
|
// blobs (past the grace window) against the referenced-hash set.
|
|
187
|
+
let liveGc = 0
|
|
175
188
|
const liveTagsRows = await handle.listLiveTags.all()
|
|
176
189
|
const liveTags = liveTagsRows.map((r) => r.workspace_tag)
|
|
177
|
-
for (const tag of liveTags) await reapUnreferencedForTag(handle, tag, now, grace)
|
|
190
|
+
for (const tag of liveTags) liveGc += await reapUnreferencedForTag(handle, tag, now, grace)
|
|
178
191
|
// Whole-workspace deletes leave residue (dirs / blob-prefixes) the
|
|
179
192
|
// live table no longer lists. Walk the backend's top-level workspace
|
|
180
193
|
// listing to find them; for each straggler tag, GC its unreferenced
|
|
@@ -186,7 +199,14 @@ export async function reapOrphans(handle: Handle, stagingTtlMs: number = STAGING
|
|
|
186
199
|
const liveSet = new Set(liveTags)
|
|
187
200
|
for (const tag of topLevel) {
|
|
188
201
|
if (liveSet.has(tag) || !isValidTag(tag)) continue
|
|
189
|
-
await reapUnreferencedForTag(handle, tag, now, grace)
|
|
202
|
+
liveGc += await reapUnreferencedForTag(handle, tag, now, grace)
|
|
203
|
+
}
|
|
204
|
+
// Sweep-wide total. A nonzero count means the GC deleted live bytes
|
|
205
|
+
// this pass — logged unconditionally so "all data went missing"
|
|
206
|
+
// leaves an obvious server-side trail (a large count over a short
|
|
207
|
+
// window is the signature). Per-blob lines above carry which/why.
|
|
208
|
+
if (liveGc > 0) {
|
|
209
|
+
console.warn(`objstore-reaper: swept ${liveTags.length} live + ${topLevel.length} store tag(s); GC'd ${liveGc} live blob(s)`)
|
|
190
210
|
}
|
|
191
211
|
// Pass 2: stale staging rows + orphan staging blobs. The orphan
|
|
192
212
|
// sweep does per-blob row lookups (no caller-side snapshot), so a
|
package/server/objstore/rest.ts
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
} from './store.ts'
|
|
42
42
|
import type { LiveReader } from './blob.ts'
|
|
43
43
|
import { type TokenSecret, extractBearer, verifyToken } from './tokens.ts'
|
|
44
|
-
import { errStack } from '../util.ts'
|
|
44
|
+
import { debugId, debugTag, errMsg, errStack } from '../util.ts'
|
|
45
45
|
|
|
46
46
|
// Server-side fault codes that should surface as 500 `io-error`
|
|
47
47
|
// rather than 400 `aborted`. `pipeline(req, ws)` rejects with the
|
|
@@ -431,7 +431,10 @@ async function runUploadAndCommit(
|
|
|
431
431
|
type GetOpened =
|
|
432
432
|
| { reason: 'ok'; reader: LiveReader }
|
|
433
433
|
| { reason: 'not-found' }
|
|
434
|
-
|
|
434
|
+
// `detail` is a short non-sensitive cause tag (backend sub-reason +
|
|
435
|
+
// content-hash prefix) the GET handler logs so a 503 is diagnosable —
|
|
436
|
+
// every byte-side failure collapses to the same wire 503 otherwise.
|
|
437
|
+
| { reason: 'unavailable'; detail: string }
|
|
435
438
|
|
|
436
439
|
async function openLiveSnapshot(
|
|
437
440
|
deps: ObjstoreRestDeps, route: RouteMatch, payload: { ver: number; inc: string },
|
|
@@ -448,17 +451,27 @@ async function openLiveSnapshot(
|
|
|
448
451
|
// returns a pinned fd; for the Vercel backend a fetch-backed stream.)
|
|
449
452
|
const live = await deps.handle.selectLiveOne.get(route.tag, route.resourceTag)
|
|
450
453
|
if (!live || live.version !== payload.ver || live.incarnation !== payload.inc) return { reason: 'not-found' }
|
|
454
|
+
// Tag the content hash into every `unavailable` detail so an operator
|
|
455
|
+
// can go check the byte store directly for THIS blob (gone → reaper /
|
|
456
|
+
// deletion; present → transient read fault).
|
|
457
|
+
const hashTag = `hash=${debugId(live.content_hash)}`
|
|
451
458
|
let opened
|
|
452
459
|
try { opened = await deps.handle.blob.openLiveReader(route.tag, live.content_hash) }
|
|
453
|
-
|
|
454
|
-
|
|
460
|
+
// Length-cap the thrown error text: today a non-BlobNotFound SDK throw
|
|
461
|
+
// (BlobServiceNotAvailable / store-not-found) carries no credential
|
|
462
|
+
// (the RW token rides the Authorization header, never `.message`), but
|
|
463
|
+
// a future SDK could embed a signed URL / token fragment — bound the
|
|
464
|
+
// log line so it can't dump one verbatim. Mirrors the `.slice(0, 200)`
|
|
465
|
+
// cap used on SDK error text in blob-vercel.ts.
|
|
466
|
+
catch (err) { return { reason: 'unavailable', detail: `open-threw ${hashTag} ${String(errMsg(err)).slice(0, 200)}` } }
|
|
467
|
+
if (!opened.ok) return { reason: 'unavailable', detail: `${opened.detail ?? 'backend'} ${hashTag}` }
|
|
455
468
|
// Size mismatch between the live row and the on-storage bytes
|
|
456
469
|
// is a transient inconsistency — reaper will reconcile. Close
|
|
457
470
|
// the reader before returning so we don't leak the fd / fetch
|
|
458
471
|
// reader. PR #4 review H8.
|
|
459
472
|
if (opened.reader.size !== live.content_length) {
|
|
460
473
|
await opened.reader.close().catch(() => {})
|
|
461
|
-
return { reason: 'unavailable' }
|
|
474
|
+
return { reason: 'unavailable', detail: `size-mismatch row=${live.content_length} blob=${opened.reader.size} ${hashTag}` }
|
|
462
475
|
}
|
|
463
476
|
return { reason: 'ok', reader: opened.reader }
|
|
464
477
|
}
|
|
@@ -478,8 +491,19 @@ async function handleRestGet(
|
|
|
478
491
|
// If the live row is there but the bytes are missing / wrong size,
|
|
479
492
|
// it's a transient inconsistency the reaper will sort out — 503
|
|
480
493
|
// (vs 404) tells the client this is a server-side state, not a
|
|
481
|
-
// "the resource truly isn't there" answer.
|
|
482
|
-
|
|
494
|
+
// "the resource truly isn't there" answer. Log the cause
|
|
495
|
+
// UNCONDITIONALLY (not behind `debug`): a 503 means a live row whose
|
|
496
|
+
// bytes can't be served, and the wire response can't distinguish a
|
|
497
|
+
// permanent loss (reaper GC'd referenced bytes) from a transient read
|
|
498
|
+
// fault. `detail` carries the backend sub-reason + content-hash prefix
|
|
499
|
+
// so an operator can tell which — the only server-side breadcrumb for
|
|
500
|
+
// the "all data turned into 503" failure. (Volume is bounded: a 503 is
|
|
501
|
+
// an error path; a workspace-wide outage is exactly when these are
|
|
502
|
+
// wanted.)
|
|
503
|
+
if (opened.reason === 'unavailable') {
|
|
504
|
+
console.warn(`objstore-get: 503 unavailable ${debugTag(route.tag)}/${route.resourceTag.slice(0, 8)}… v${payload.ver} ${opened.detail}`)
|
|
505
|
+
deny(res, 503, 'unavailable'); return
|
|
506
|
+
}
|
|
483
507
|
res.writeHead(200, {
|
|
484
508
|
'content-type': 'application/octet-stream',
|
|
485
509
|
'content-length': String(opened.reader.size),
|
package/server/util.ts
CHANGED
|
@@ -8,6 +8,15 @@ import { randomBytes } from 'node:crypto'
|
|
|
8
8
|
// is an Ed25519 public key; operator logs shouldn't carry it verbatim.
|
|
9
9
|
export function debugTag(s: string): string { return `${s.slice(0, 12)}…` }
|
|
10
10
|
|
|
11
|
+
// Truncated view of a content hash / staging id for logs (objstore GC +
|
|
12
|
+
// 503 diagnostics). Same 12-char prefix convention as `debugTag`; named
|
|
13
|
+
// separately so call sites read as "this is a blob id, not a workspace
|
|
14
|
+
// tag". Tolerates a non-string (logs a placeholder) so a diagnostic
|
|
15
|
+
// path can't itself throw on bad input.
|
|
16
|
+
export function debugId(s: unknown): string {
|
|
17
|
+
return typeof s === 'string' ? `${s.slice(0, 12)}…` : '<no-id>'
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
// 16 random bytes → 22 base64url chars (no padding). The shared shape
|
|
12
21
|
// for per-socket challenge nonces and staging ids — collision is
|
|
13
22
|
// 1/2^128. `isValidStagingId` in objstore/store.ts validates exactly
|