@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -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.
@@ -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<void> {
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
- async function reapUnreferencedForTag(handle: Handle, tag: string, now: number, grace: number): Promise<void> {
90
- if (!isValidTag(tag)) return
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
@@ -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
- | { reason: 'unavailable' }
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
- catch { return { reason: 'unavailable' } }
454
- if (!opened.ok) return { reason: opened.reason }
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
- if (opened.reason === 'unavailable') { deny(res, 503, 'unavailable'); return }
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