@preventive/triage 1.0.0-alpha.2 → 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 +11 -11
- package/out/graph.js +1 -1
- package/out/view.js +52 -49
- package/package.json +2 -2
- package/server/bus-receiver.ts +7 -7
- package/server/config.ts +4 -4
- package/server/db-revision-sql.ts +6 -9
- package/server/db.ts +95 -134
- package/server/hub.ts +4 -5
- package/server/index.ts +22 -26
- package/server/objstore/blob-fs.ts +6 -8
- package/server/objstore/blob-vercel.ts +56 -44
- package/server/objstore/blob.ts +23 -8
- package/server/objstore/handlers.ts +11 -13
- package/server/objstore/init.ts +18 -9
- package/server/objstore/reaper.ts +30 -10
- package/server/objstore/rest.ts +91 -75
- package/server/objstore/store-neon.ts +4 -4
- package/server/objstore/store.ts +92 -112
- package/server/objstore/tokens.ts +9 -12
- package/server/peer.ts +7 -9
- package/server/pubsub.ts +17 -27
- package/server/sign.ts +8 -10
- package/server/sse-server.ts +10 -12
- package/server/sse-session.ts +2 -8
- package/server/static.ts +8 -12
- package/server/sync-handlers.ts +32 -39
- package/server/util.ts +9 -0
- package/server/ws-server.ts +9 -14
package/server/objstore/rest.ts
CHANGED
|
@@ -41,20 +41,18 @@ 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
|
|
48
|
-
// first stream error;
|
|
49
|
-
//
|
|
50
|
-
// `createWriteStream`
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// the default `aborted` branch and the operator-facing log line
|
|
57
|
-
// carries the SDK's error message.
|
|
48
|
+
// first stream error; client-side codes (socket close, the manual
|
|
49
|
+
// `overrun`) are everything else. ENOENT is included because
|
|
50
|
+
// `createWriteStream` rejects with it if the `${tag}/.staging` dir
|
|
51
|
+
// was removed out from under us (operator / external fs activity) —
|
|
52
|
+
// a server-side state, not a client-fixable abort. Vercel-blob
|
|
53
|
+
// failures arrive without a `code` (rejected put promise → plain
|
|
54
|
+
// Error); those land in the default `aborted` branch, with the SDK
|
|
55
|
+
// error in the operator log line.
|
|
58
56
|
const IO_FAULT_CODES = new Set(['ENOSPC', 'EACCES', 'EROFS', 'EIO', 'EMFILE', 'ENFILE', 'EDQUOT', 'EPERM', 'ENOENT'])
|
|
59
57
|
|
|
60
58
|
export type ObjstoreRestDeps = {
|
|
@@ -71,35 +69,34 @@ export type ObjstoreRestDeps = {
|
|
|
71
69
|
debug: boolean
|
|
72
70
|
}
|
|
73
71
|
|
|
74
|
-
// Concurrent commits need no lock
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// slot; it never makes distinct commits wait.)
|
|
72
|
+
// Concurrent commits need no lock: the live blob is content-addressed
|
|
73
|
+
// (`${tag}/${contentHash}.bin`) so two racing commits write to
|
|
74
|
+
// DIFFERENT immutable addresses, and the commit is an atomic version
|
|
75
|
+
// compare-and-set on the live row (see commitPut in store.ts). Exactly
|
|
76
|
+
// one racer wins the CAS; the loser gets a 409 `conflict` and rebases.
|
|
77
|
+
// Holds within a process and across replicas — no in-process mutex
|
|
78
|
+
// serialises commits. (The PUT *body* takes a per-process single-
|
|
79
|
+
// writer reservation — `inFlightSids` below — but that only rejects a
|
|
80
|
+
// duplicate upload of one staging slot; distinct commits never wait.)
|
|
84
81
|
|
|
85
82
|
// Per-process single-writer guard for the REST PUT body. A put-token is
|
|
86
83
|
// a REUSABLE bearer capability (tokens.ts) valid for its whole TTL, so a
|
|
87
|
-
// client replaying it on overlapping PUTs
|
|
88
|
-
// the in-flight request, a proxy re-issuing the PUT, a double-submit
|
|
84
|
+
// client replaying it on overlapping PUTs (a retry that doesn't cancel
|
|
85
|
+
// the in-flight request, a proxy re-issuing the PUT, a double-submit)
|
|
89
86
|
// would otherwise have two requests stream into the SAME staging file
|
|
90
|
-
// (same sid). On
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
87
|
+
// (same sid). On FS, `createWriteStream(…, { flags: 'w' })` truncates,
|
|
88
|
+
// so the two writers clobber each other's bytes/size and BOTH can fail
|
|
89
|
+
// (size-mismatch 400 + promote-race 500) with neither committing.
|
|
90
|
+
// `inFlightSids` admits exactly ONE in-flight upload per staging id; a
|
|
91
|
+
// concurrent same-token PUT is rejected (409) before it can open a
|
|
92
|
+
// second writer. The slot is held only across body + commit, released
|
|
93
|
+
// in a `finally`, and bounded by the REST idle-body timeout in
|
|
94
|
+
// server/http.ts so a slow-loris can't pin it. NOT a commit lock —
|
|
95
|
+
// commits stay lock-free on the version-CAS above. Per-process: the
|
|
96
|
+
// Vercel multi-replica path leans on `allowOverwrite: true` + the
|
|
97
|
+
// commit CAS + content-addressing for cross-replica correctness (a
|
|
98
|
+
// duplicate landing on another replica can't clobber a shared local
|
|
99
|
+
// file and still loses the CAS), not on this set.
|
|
103
100
|
const inFlightSids = new Set<string>()
|
|
104
101
|
|
|
105
102
|
// `/api/objstore/${workspaceTag}/${resourceTag}` — base64url
|
|
@@ -121,14 +118,13 @@ export function matchRoute(url: string | undefined): RouteMatch | null {
|
|
|
121
118
|
|
|
122
119
|
function deny(res: ServerResponse, status: number, body: string): void {
|
|
123
120
|
// Uniform `{ error: <reason> }` JSON envelope for every failure so
|
|
124
|
-
// clients
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// there's no signal here a probe couldn't otherwise enumerate.
|
|
121
|
+
// clients parse one shape. Status + reason are NOT intentionally
|
|
122
|
+
// indistinguishable across causes — 401/404/405/410/411/500 each map
|
|
123
|
+
// to a documented reason in server/README.md and the client decides
|
|
124
|
+
// recovery from the code. Probe-distinguishing defense isn't a goal:
|
|
125
|
+
// every reason is reachable only after the route + bearer-token check
|
|
126
|
+
// passes (or as 401/404 from the public surface), so a probe gains no
|
|
127
|
+
// signal it couldn't otherwise enumerate.
|
|
132
128
|
res.writeHead(status, { 'content-type': 'application/json' })
|
|
133
129
|
res.end(JSON.stringify({ error: body }))
|
|
134
130
|
}
|
|
@@ -209,21 +205,20 @@ async function handleRestPut(
|
|
|
209
205
|
// No lock: the commit's version-CAS arbitrates concurrent commits
|
|
210
206
|
// (the live blob is content-addressed, so racers can't desync
|
|
211
207
|
// metadata vs bytes — the loser surfaces a 409 `conflict`). The
|
|
212
|
-
// staging row is protected from the reaper
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
208
|
+
// staging row is protected from the reaper by its `begun_at`, not a
|
|
209
|
+
// lock: an upload under the staging TTL stays fresh through the body,
|
|
210
|
+
// and the after-body `refreshStagingBegunAt` re-extends the TTL across
|
|
211
|
+
// the commit. (An upload exceeding the TTL during the body can be
|
|
212
|
+
// reaped mid-flight → commit 410s; documented accepted tradeoff —
|
|
213
|
+
// see commitPut in store.ts.)
|
|
218
214
|
await handleRestPutBody(deps, req, res, route, payload, declared)
|
|
219
215
|
}
|
|
220
216
|
|
|
221
217
|
// Map a `commitPut` failure to its wire response. Extracted from
|
|
222
|
-
// `handleRestPutBody` to keep it under the per-function line cap
|
|
223
|
-
//
|
|
224
|
-
// exhaustiveness `never` guard at the bottom catches a
|
|
225
|
-
//
|
|
226
|
-
// updating this dispatch.
|
|
218
|
+
// `handleRestPutBody` to keep it under the per-function line cap and
|
|
219
|
+
// to make the wire-mapping ladder its own audit surface — the
|
|
220
|
+
// exhaustiveness `never` guard at the bottom catches a new
|
|
221
|
+
// `CommitPutResult` reason landing without updating this dispatch.
|
|
227
222
|
function denyCommitFailure(res: ServerResponse, result: Exclude<CommitPutResult, { ok: true }>): void {
|
|
228
223
|
if (result.reason === 'conflict') {
|
|
229
224
|
denyConflict(res, result.conflict?.version ?? null, result.conflict?.incarnation ?? null)
|
|
@@ -273,10 +268,9 @@ async function handleRestPutBody(
|
|
|
273
268
|
}
|
|
274
269
|
inFlightSids.add(payload.sid)
|
|
275
270
|
try {
|
|
276
|
-
// Stream the upload + commit, no lock
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// mutex. See the rationale in handleRestPut above.
|
|
271
|
+
// Stream the upload + commit, no lock — version-CAS arbitrates
|
|
272
|
+
// commits, the staging row stays fresh for the reaper via its
|
|
273
|
+
// `begun_at` (+ after-body refresh). See handleRestPut above.
|
|
280
274
|
const result = await runUploadAndCommit(deps, route, payload, declared, req, res)
|
|
281
275
|
if (result.handled) return
|
|
282
276
|
if (!result.commit.ok) { denyCommitFailure(res, result.commit); return }
|
|
@@ -294,15 +288,13 @@ async function handleRestPutBody(
|
|
|
294
288
|
...objectMetaWire(row),
|
|
295
289
|
}, null)
|
|
296
290
|
// Cross-instance fan-out (Neon mode). The bus payload carries only
|
|
297
|
-
// (tag, resourceTag); peers on other instances re-fetch the live
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// notification; bus-receiver.ts drops that case silently. SQLite
|
|
305
|
-
// mode publishes to a no-op.
|
|
291
|
+
// (tag, resourceTag); peers on other instances re-fetch the live row
|
|
292
|
+
// from workspace_object to compose their broadcast. The committed
|
|
293
|
+
// row is durable by here (commitPut's version-CAS landed), so the
|
|
294
|
+
// receiver sees THIS version or a STRICTLY newer one (also valid —
|
|
295
|
+
// clients are idempotent on (resourceTag, version)), or no live row
|
|
296
|
+
// at all if a subsequent delete races the notification (bus-
|
|
297
|
+
// receiver.ts drops that silently). SQLite mode publishes to a no-op.
|
|
306
298
|
deps.publishObjPut(route.tag, route.resourceTag)
|
|
307
299
|
if (deps.debug) console.log(`objstore put → ${route.tag.slice(0, 12)}…/${route.resourceTag.slice(0, 8)}… v${row.version}`)
|
|
308
300
|
} finally {
|
|
@@ -439,7 +431,10 @@ async function runUploadAndCommit(
|
|
|
439
431
|
type GetOpened =
|
|
440
432
|
| { reason: 'ok'; reader: LiveReader }
|
|
441
433
|
| { reason: 'not-found' }
|
|
442
|
-
|
|
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 }
|
|
443
438
|
|
|
444
439
|
async function openLiveSnapshot(
|
|
445
440
|
deps: ObjstoreRestDeps, route: RouteMatch, payload: { ver: number; inc: string },
|
|
@@ -451,22 +446,32 @@ async function openLiveSnapshot(
|
|
|
451
446
|
// bytes behind `live.content_hash` can never change underneath us —
|
|
452
447
|
// the worst a race can do is have the reaper GC an already-superseded
|
|
453
448
|
// hash just before we open it, which surfaces as openLiveReader
|
|
454
|
-
//
|
|
449
|
+
// returning `unavailable` → 503, and the client refetches. We can
|
|
455
450
|
// never serve torn or wrong bytes. (For the FS backend the open also
|
|
456
451
|
// returns a pinned fd; for the Vercel backend a fetch-backed stream.)
|
|
457
452
|
const live = await deps.handle.selectLiveOne.get(route.tag, route.resourceTag)
|
|
458
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)}`
|
|
459
458
|
let opened
|
|
460
459
|
try { opened = await deps.handle.blob.openLiveReader(route.tag, live.content_hash) }
|
|
461
|
-
|
|
462
|
-
|
|
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}` }
|
|
463
468
|
// Size mismatch between the live row and the on-storage bytes
|
|
464
469
|
// is a transient inconsistency — reaper will reconcile. Close
|
|
465
470
|
// the reader before returning so we don't leak the fd / fetch
|
|
466
471
|
// reader. PR #4 review H8.
|
|
467
472
|
if (opened.reader.size !== live.content_length) {
|
|
468
473
|
await opened.reader.close().catch(() => {})
|
|
469
|
-
return { reason: 'unavailable' }
|
|
474
|
+
return { reason: 'unavailable', detail: `size-mismatch row=${live.content_length} blob=${opened.reader.size} ${hashTag}` }
|
|
470
475
|
}
|
|
471
476
|
return { reason: 'ok', reader: opened.reader }
|
|
472
477
|
}
|
|
@@ -486,8 +491,19 @@ async function handleRestGet(
|
|
|
486
491
|
// If the live row is there but the bytes are missing / wrong size,
|
|
487
492
|
// it's a transient inconsistency the reaper will sort out — 503
|
|
488
493
|
// (vs 404) tells the client this is a server-side state, not a
|
|
489
|
-
// "the resource truly isn't there" answer.
|
|
490
|
-
|
|
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
|
+
}
|
|
491
507
|
res.writeHead(200, {
|
|
492
508
|
'content-type': 'application/octet-stream',
|
|
493
509
|
'content-length': String(opened.reader.size),
|
|
@@ -46,10 +46,10 @@ const DDL_LOCK_KEY_OBJSTORE_SUB = 0x6f62_6a73 // 'objs'
|
|
|
46
46
|
// defend the commitPut conflict arithmetic — a manual `UPDATE
|
|
47
47
|
// workspace_object SET version = -1` would otherwise round-trip
|
|
48
48
|
// through `num()` (which only rejects non-safe-integers) and corrupt
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
49
|
+
// version monotonicity. SQLite carries the identical CHECKs (see
|
|
50
|
+
// `store.ts`): STRICT there enforces column TYPE but NOT this value
|
|
51
|
+
// domain (`version = -1` is a valid integer STRICT accepts), so both
|
|
52
|
+
// backends need the explicit CHECKs.
|
|
53
53
|
const SCHEMA_PG = [
|
|
54
54
|
`CREATE TABLE IF NOT EXISTS workspace_object (
|
|
55
55
|
workspace_tag TEXT NOT NULL,
|
package/server/objstore/store.ts
CHANGED
|
@@ -79,9 +79,8 @@ const SCHEMA = `
|
|
|
79
79
|
|
|
80
80
|
// One LIVE row, exactly the shape the `workspace-subscribed` ack's
|
|
81
81
|
// `resources` array carries on the wire, minus `keyframe`-style
|
|
82
|
-
// server-only flags. `put_at` is a
|
|
83
|
-
//
|
|
84
|
-
// it via the DB but the server never volunteers it.
|
|
82
|
+
// server-only flags. `put_at` is a debug aid the wire format never
|
|
83
|
+
// includes — inspectable via the DB but the server never volunteers it.
|
|
85
84
|
export type ObjectRow = {
|
|
86
85
|
resourceTag: string
|
|
87
86
|
version: number
|
|
@@ -118,13 +117,11 @@ export type BeginPutInput = {
|
|
|
118
117
|
// PUT will reference. `workspace-full` is the per-workspace resource-
|
|
119
118
|
// count cap rejection — see `MAX_RESOURCES_PER_WORKSPACE`.
|
|
120
119
|
//
|
|
121
|
-
// `filePath` is
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// Tests for the FS path use it as a convenience to write fixture
|
|
127
|
-
// bytes directly to the staging slot.
|
|
120
|
+
// `filePath` is the absolute on-disk staging path, set only for the
|
|
121
|
+
// FS handle; the Vercel handle omits it ("path" is meaningless against
|
|
122
|
+
// a remote store). Production (rest.ts) never reads it — it goes
|
|
123
|
+
// through `handle.blob.openStagingWriter(tag, sid)`. FS-path tests use
|
|
124
|
+
// it as a convenience to write fixture bytes to the staging slot.
|
|
128
125
|
export type BeginPutResult =
|
|
129
126
|
| { ok: true; stagingId: string; filePath?: string }
|
|
130
127
|
| { ok: false; reason: 'conflict'; conflict: ObjectRow | null }
|
|
@@ -134,17 +131,14 @@ export type CommitPutInput = {
|
|
|
134
131
|
workspaceTag: string
|
|
135
132
|
resourceTag: string
|
|
136
133
|
stagingId: string
|
|
137
|
-
// Optional
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// (this PUT) has already completed. Tests that drive commitPut
|
|
146
|
-
// directly without the REST layer should omit this and let
|
|
147
|
-
// commitPut stat for itself.
|
|
134
|
+
// Optional storage-side byte count the caller already verified
|
|
135
|
+
// post-upload. When provided, commitPut skips the redundant
|
|
136
|
+
// `statStaging` round-trip — one fewer Vercel HEAD per PUT. Caller
|
|
137
|
+
// must ONLY pass a value it observed for THIS stagingId after its
|
|
138
|
+
// upload finished (the REST PUT path's post-upload `statStaging`).
|
|
139
|
+
// Safe without a lock: staging ids are random (no other request
|
|
140
|
+
// writes this blob) and the sole writer (this PUT) has finished.
|
|
141
|
+
// Tests driving commitPut directly omit this and let it stat.
|
|
148
142
|
observedSize?: number
|
|
149
143
|
}
|
|
150
144
|
|
|
@@ -181,9 +175,9 @@ type DbRow = {
|
|
|
181
175
|
// - the reaper's age grace window + an atomic conditional staging
|
|
182
176
|
// delete (`deleteStagingIfStale`) so the stale-staging sweep
|
|
183
177
|
// can't race an upload that just finished.
|
|
184
|
-
// These hold both within a single process AND across replicas
|
|
185
|
-
//
|
|
186
|
-
// content-addressing
|
|
178
|
+
// These hold both within a single process AND across replicas, so no
|
|
179
|
+
// per-(tag, resourceTag) in-process mutex is needed — the CAS +
|
|
180
|
+
// content-addressing already cover everything one would.
|
|
187
181
|
export type Handle = {
|
|
188
182
|
// SQLite-only: the underlying `DatabaseSync`. Unset on the Neon
|
|
189
183
|
// backend (see ./store-neon.ts). Test-only fixture SQL routes
|
|
@@ -212,15 +206,14 @@ export type Handle = {
|
|
|
212
206
|
selectStagingByWsSid: GetStmt<[string, string], unknown>
|
|
213
207
|
refreshStagingBegunAt: RunStmt<[number, string, string, string]>
|
|
214
208
|
deleteStaging: RunStmt<[string, string, string]>
|
|
215
|
-
// Atomic conditional staging delete
|
|
209
|
+
// Atomic conditional staging delete for the reaper's stale-row
|
|
216
210
|
// sweep. `deleteStagingIfStale(tag, res, sid, staleBefore)` deletes
|
|
217
211
|
// the row IFF its `begun_at < staleBefore`, returning `{ ok: 1 }`
|
|
218
|
-
// when a row was actually removed and `undefined` otherwise.
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
// fails atomically) and the row survives for the commit. Mirrors the
|
|
212
|
+
// when a row was actually removed and `undefined` otherwise. Lock-
|
|
213
|
+
// free race guard (PR #4 "F1"): a slow PUT that finishes calls
|
|
214
|
+
// `refreshStagingBegunAt` to bump `begun_at` fresh, so a concurrent
|
|
215
|
+
// reaper's conditional delete doesn't match (predicate fails
|
|
216
|
+
// atomically) and the row survives for the commit. Mirrors the
|
|
224
217
|
// `insertLiveIfAbsent` RETURNING pattern.
|
|
225
218
|
deleteStagingIfStale: GetStmt<[string, string, string, number], { ok: number }>
|
|
226
219
|
selectLive: AllStmt<[string], DbRow>
|
|
@@ -279,9 +272,6 @@ export type SqliteHandle = Handle & { db: DatabaseSync }
|
|
|
279
272
|
// transient over-shoot under high concurrency across DIFFERENT
|
|
280
273
|
// resources is bounded by `(parallel new-resource begins - 1)` and is
|
|
281
274
|
// accepted (the cap is a soft policy bound, not a security invariant).
|
|
282
|
-
// This was already the contract under the old per-resource lock —
|
|
283
|
-
// that lock keyed on (tag, resourceTag), so concurrent NEW-resource
|
|
284
|
-
// begins held DIFFERENT locks and raced the count anyway.
|
|
285
275
|
export const MAX_RESOURCES_PER_WORKSPACE = 100
|
|
286
276
|
|
|
287
277
|
// Per-upload byte cap, shared by the WS plane (rejects oversize
|
|
@@ -372,12 +362,10 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
|
|
|
372
362
|
const blob = openFsBlobBackend(dir)
|
|
373
363
|
// No `close` method on the returned Handle: the underlying
|
|
374
364
|
// `DatabaseSync` is owned by the caller (in production, the
|
|
375
|
-
// workspace_revision handle in `server/db.ts`,
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
// either no-op'd (production) or left the connection open
|
|
380
|
-
// (tests construct their own DB and `db.close()` separately).
|
|
365
|
+
// workspace_revision handle in `server/db.ts`, closed from
|
|
366
|
+
// `shutdown()`; tests close their own DB). A `close()` here would
|
|
367
|
+
// mislead — it could only no-op or leak, never close the caller-
|
|
368
|
+
// owned connection.
|
|
381
369
|
return {
|
|
382
370
|
db,
|
|
383
371
|
blob,
|
|
@@ -522,11 +510,10 @@ export async function beginPut(handle: Handle, input: BeginPutInput): Promise<Be
|
|
|
522
510
|
if (liveVersion !== input.prevVersion || liveIncarnation !== input.prevIncarnation) {
|
|
523
511
|
return { ok: false, reason: 'conflict', conflict: live }
|
|
524
512
|
}
|
|
525
|
-
// Per-workspace resource cap
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
// bound, accepted to over-shoot under concurrent NEW-resource begins.
|
|
513
|
+
// Per-workspace resource cap, NEW resources only — re-uploads
|
|
514
|
+
// (live != null) don't change the count, so they're always allowed.
|
|
515
|
+
// Not atomic with the insert below; soft bound, see
|
|
516
|
+
// MAX_RESOURCES_PER_WORKSPACE.
|
|
530
517
|
if (!live) {
|
|
531
518
|
const count = await handle.countLive.get(input.workspaceTag) as { c: number } | undefined
|
|
532
519
|
if ((count?.c ?? 0) >= MAX_RESOURCES_PER_WORKSPACE) {
|
|
@@ -557,51 +544,48 @@ export async function beginPut(handle: Handle, input: BeginPutInput): Promise<Be
|
|
|
557
544
|
// IF ABSENT; a re-upload bumps the version IFF it still matches the
|
|
558
545
|
// precondition we read. Exactly one of N racing commits wins the CAS;
|
|
559
546
|
// the losers get `conflict` (with the current live row) and rebase.
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
// reaper-cleaned, never row-pointing-at-nothing" crash-safety contract.
|
|
547
|
+
// Each PUT is content-addressed at its OWN hash (distinct PUTs get
|
|
548
|
+
// distinct hashes — random nonce per encrypt), so N racers promote to
|
|
549
|
+
// N DIFFERENT immutable paths: no promote clobbers another's bytes, a
|
|
550
|
+
// loser's blob is just left unreferenced for the GC, and there's no
|
|
551
|
+
// metadata-vs-bytes desync to guard. The CAS gives atomicity both
|
|
552
|
+
// within a process and across replicas, so NO in-process lock is taken.
|
|
553
|
+
// A crash between the promote and the CAS leaves the staging blob/row
|
|
554
|
+
// intact alongside (at most) a stranded, unreferenced live blob — the
|
|
555
|
+
// stale-staging sweep cleans the row, the GC reaps the blob once past
|
|
556
|
+
// the grace window: the "stranded state, reaper-cleaned, never row-
|
|
557
|
+
// pointing-at-nothing" crash-safety contract.
|
|
572
558
|
//
|
|
573
|
-
// ACCEPTED TRADEOFF (lock removal):
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
// staging
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
// the TTL
|
|
583
|
-
//
|
|
584
|
-
// line").
|
|
559
|
+
// ACCEPTED TRADEOFF (lock removal): without a lock the reaper no longer
|
|
560
|
+
// waits for an in-flight upload on this key (the old lock blocked it on
|
|
561
|
+
// any in-flight upload, unbounded). An upload taking >1h FROM BEGIN
|
|
562
|
+
// (exceeding the staging TTL during the body) can have its staging row
|
|
563
|
+
// reaped mid-flight by `deleteStagingIfStale` → this commit sees no
|
|
564
|
+
// staging row → `no-staging` → REST 410. Sub-1h uploads are unaffected:
|
|
565
|
+
// `begun_at` (set at begin) stays within the TTL through the body, so
|
|
566
|
+
// the conditional delete can't match, and the after-body
|
|
567
|
+
// `refreshStagingBegunAt` re-extends the TTL to cover this commit step —
|
|
568
|
+
// matching the staging TTL's intent ("1h, comfortably over a 50 MiB
|
|
569
|
+
// upload on a slow line").
|
|
585
570
|
export async function commitPut(handle: Handle, input: CommitPutInput): Promise<CommitPutResult> {
|
|
586
571
|
const staging = await handle.selectStaging.get(input.workspaceTag, input.resourceTag, input.stagingId)
|
|
587
572
|
if (!staging) return { ok: false, reason: 'no-staging' }
|
|
588
573
|
let stagedSize: number | null
|
|
589
574
|
// statStaging failure here is a server-side issue (staging file
|
|
590
|
-
//
|
|
575
|
+
// unlinked by a racing abort / reaper, EACCES, EIO, backend
|
|
591
576
|
// unreachable, …) — not a client length-mismatch. Route through
|
|
592
577
|
// `io-error` so the REST layer returns 5xx, not 400. PR #4 review.
|
|
593
578
|
//
|
|
594
579
|
// The REST PUT layer already statted the staging blob post-upload
|
|
595
580
|
// and threads the result in via `observedSize` — skipping the
|
|
596
|
-
// round-trip saves one Vercel HEAD per PUT. The
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
// `observedSize` fall through to the explicit stat.
|
|
581
|
+
// round-trip saves one Vercel HEAD per PUT. The blob can't have been
|
|
582
|
+
// resized between that stat and here: staging ids are 16-byte random
|
|
583
|
+
// (no other request targets this blob) and the sole writer (this
|
|
584
|
+
// PUT's upload pipeline) has finished. The only other actor on a
|
|
585
|
+
// staging blob is the reaper, which UNLINKS (never resizes); a racing
|
|
586
|
+
// reaper unlink surfaces below as statStaging→io-error or a promote
|
|
587
|
+
// failure, not a wrong size. WS / test paths that omit `observedSize`
|
|
588
|
+
// fall through to the explicit stat.
|
|
605
589
|
if (input.observedSize === undefined) {
|
|
606
590
|
try { stagedSize = await handle.blob.statStaging(input.workspaceTag, input.stagingId) }
|
|
607
591
|
catch { return { ok: false, reason: 'io-error' } }
|
|
@@ -637,15 +621,14 @@ export async function commitPut(handle: Handle, input: CommitPutInput): Promise<
|
|
|
637
621
|
}
|
|
638
622
|
// Promote to the CONTENT-ADDRESSED live path `${tag}/${hash}.bin`.
|
|
639
623
|
// `promoteStagingToLive` returns false on any backend error (FS:
|
|
640
|
-
// EACCES / ENOSPC / EIO / a racing abort that already unlinked
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
// concurrent commits to the same resource write to DIFFERENT paths.)
|
|
624
|
+
// EACCES / ENOSPC / EIO / a racing abort that already unlinked the
|
|
625
|
+
// staging file; Vercel: copy failure) → 'io-error' → REST HTTP 500
|
|
626
|
+
// (server-side fault, not client-fixable). The destination path IS
|
|
627
|
+
// the content hash, so any write to it is byte-identical BY
|
|
628
|
+
// CONSTRUCTION: a retried or racing promote to the same path is an
|
|
629
|
+
// idempotent rewrite, never a clobber. (Distinct PUTs get distinct
|
|
630
|
+
// hashes — fresh random nonce per encrypt — so concurrent commits to
|
|
631
|
+
// the same resource write to DIFFERENT paths.)
|
|
649
632
|
if (!await handle.blob.promoteStagingToLive(input.workspaceTag, input.stagingId, staging.content_hash)) {
|
|
650
633
|
return { ok: false, reason: 'io-error' }
|
|
651
634
|
}
|
|
@@ -658,13 +641,12 @@ export async function commitPut(handle: Handle, input: CommitPutInput): Promise<
|
|
|
658
641
|
const committedIncarnation = staging.prev_version == null ? freshIncarnation : staging.prev_incarnation!
|
|
659
642
|
const putAt = Date.now()
|
|
660
643
|
// Version-CAS in try/catch so a Neon transient (5xx, network
|
|
661
|
-
// hiccup,
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
// GC) but the surface is a 500 the caller can retry.
|
|
644
|
+
// hiccup, pool exhaustion) doesn't throw out of commitPut and bypass
|
|
645
|
+
// the abortPut ladder. A thrown rejection would skip the REST layer's
|
|
646
|
+
// `if (!r.ok) abortPut` branch and bubble to handleRest's outer catch
|
|
647
|
+
// — live blob already promoted, staging blob + row left behind. The
|
|
648
|
+
// reaper reconciles (stale-staging sweep + unreferenced-blob GC); the
|
|
649
|
+
// surface is a 500 the caller can retry.
|
|
668
650
|
let won: { ok: number } | undefined
|
|
669
651
|
try {
|
|
670
652
|
if (staging.prev_version == null) {
|
|
@@ -753,30 +735,28 @@ export async function abortPut(handle: Handle, tag: string, resourceTag: string,
|
|
|
753
735
|
// `deletedVersion = 0` sentinel tells the broadcast path to skip.
|
|
754
736
|
//
|
|
755
737
|
// No lock: the drop is a version-CAS (`deleteLiveCAS` — DELETE WHERE
|
|
756
|
-
// version = prev), so every race resolves to exactly one winner
|
|
757
|
-
//
|
|
738
|
+
// version = prev), so every race resolves to exactly one winner with
|
|
739
|
+
// no lost update:
|
|
758
740
|
// - delete vs. a concurrent COMMIT on the same resource: whichever
|
|
759
741
|
// CAS lands first wins. A stale delete can NOT remove a row the
|
|
760
|
-
// commit just bumped —
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
// `conflict`. (
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
// version-CAS closes that.)
|
|
742
|
+
// commit just bumped — `WHERE version = prev` no longer matches, so
|
|
743
|
+
// the delete gets `conflict` (re-read sees the bumped version). If
|
|
744
|
+
// the delete wins, the commit's `updateLiveCAS` matches no row →
|
|
745
|
+
// `conflict`. (An UNCONDITIONAL delete here would let `getLive`
|
|
746
|
+
// read v1, a commit bump to v2 slip in, then the delete destroy v2:
|
|
747
|
+
// a lost update. The version-CAS closes that.)
|
|
767
748
|
// - two concurrent deletes with the same prevVersion: one CAS removes
|
|
768
749
|
// the row; the other matches no row → re-read → `not-found`.
|
|
769
750
|
//
|
|
770
751
|
// On success we ONLY drop the live row — we do NOT unlink the live
|
|
771
|
-
// blob.
|
|
752
|
+
// blob. Reclamation is deferred to the reaper's grace-window GC
|
|
772
753
|
// (unlinks once no live row references the hash AND it's older than the
|
|
773
|
-
// grace window)
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
// effectively 1:1; this delete simply orphans the blob for the GC.
|
|
754
|
+
// grace window) so the drop stays lock-free and can't race a concurrent
|
|
755
|
+
// commit's promote→CAS window (a just-promoted blob isn't referenced
|
|
756
|
+
// yet — the age grace protects it) or an in-flight GET still streaming
|
|
757
|
+
// the bytes. NOT because the hash might be shared — distinct PUTs get
|
|
758
|
+
// distinct hashes (random nonce → unique ciphertext), so the hash↔row
|
|
759
|
+
// mapping is effectively 1:1; this delete simply orphans the blob.
|
|
780
760
|
export async function deleteObject(
|
|
781
761
|
handle: Handle, tag: string, resourceTag: string, prevVersion: number | null, prevIncarnation: string | null,
|
|
782
762
|
): Promise<DeleteResult> {
|