@myelinbridge/cli 0.8.1 → 0.9.1
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/README.md +11 -0
- package/bin/myelin.js +138 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,11 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
|
38
38
|
off, and retries up to 4 attempts before surfacing — an unattended pipeline
|
|
39
39
|
run survives a blip. A retried `--submit` whose first attempt actually landed
|
|
40
40
|
gets a success echo (`already_submitted: true`), not a false failure.
|
|
41
|
+
- **Slow connections shrink their parts.** Since 0.9.0, when the same part dies
|
|
42
|
+
twice on a timeout (the edge kills any PUT that runs too long — HTTP 524),
|
|
43
|
+
`push` restarts the file with smaller parts, 64 → 16 → 5 MB, instead of
|
|
44
|
+
retrying the same slice into the same wall. A hospital-grade uplink down to
|
|
45
|
+
roughly 30 KB/s can now complete a delivery; it is slower, but it finishes.
|
|
41
46
|
- **Every file is fingerprinted.** Since 0.7.0, `push` computes an MD5 of each
|
|
42
47
|
file while it uploads and records it with the delivery. If the client's
|
|
43
48
|
quality contract includes a checksum-manifest check, your delivery verifies
|
|
@@ -56,6 +61,12 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
|
56
61
|
`myelin status <batch> --json` returns the failed files, reviewer comments, and
|
|
57
62
|
rule remediation hints; fix, re-`push --submit`, unchanged files keep their
|
|
58
63
|
review votes.
|
|
64
|
+
- **Limits.** `push` declares 500 files per call and chunks automatically — nothing
|
|
65
|
+
for you to split. `check` sends your whole file list in one preflight
|
|
66
|
+
call, which accepts at most 10 000 files — a larger delivery cannot be preflighted
|
|
67
|
+
by `check` today (the API answers `400 too_many_files`); its checks still run at
|
|
68
|
+
submit. A delivery holds at most 25 000 files (submit refuses above that with
|
|
69
|
+
`422 submit_blocked`), and a checksum manifest is inspected up to 16 MiB.
|
|
59
70
|
|
|
60
71
|
## If you are the client, not the partner
|
|
61
72
|
|
package/bin/myelin.js
CHANGED
|
@@ -195,6 +195,43 @@ function fmtBytes(n) {
|
|
|
195
195
|
// them. Returns { upload_id, parts: [{part_number, etag}] } for confirm.
|
|
196
196
|
const SIGN_WINDOW = 1000 // ≤ the server's max_parts_per_sign
|
|
197
197
|
|
|
198
|
+
// Adaptive part geometry (mirrors lib/uploads/part-geometry.ts — this file
|
|
199
|
+
// ships dependency-free and cannot import it). The edge in front of the S3
|
|
200
|
+
// endpoint kills a PUT that outlives its patience (observed: HTTP 524 at
|
|
201
|
+
// ~175 s), so a fixed 64 MiB part gives a slow uplink (under ~375 KiB/s) no
|
|
202
|
+
// way to ever land one — every retry re-sends the same slice into the same
|
|
203
|
+
// wall. The second timeout-shaped failure on the same part restarts the file
|
|
204
|
+
// with smaller parts: 64 → 16 → 5 MiB (the S3 floor for any part but the
|
|
205
|
+
// last), which moves the workable floor to ~30 KiB/s.
|
|
206
|
+
const MIN_PART_SIZE = 5 * 1024 * 1024
|
|
207
|
+
const SLOW_FAILURE_MS = 45_000
|
|
208
|
+
const nextSmallerPartSize = (n) => Math.max(MIN_PART_SIZE, Math.floor(n / 4))
|
|
209
|
+
const isTimeoutClassFailure = (status, elapsedMs) =>
|
|
210
|
+
status === 408 || status === 504 || status === 522 || status === 524 ||
|
|
211
|
+
(status === 0 && elapsedMs >= SLOW_FAILURE_MS)
|
|
212
|
+
// A landed part is skippable on resume only when it holds the bytes the
|
|
213
|
+
// CURRENT geometry puts at that part number — a leftover from an abandoned
|
|
214
|
+
// geometry re-uploads under the same number, which replaces it. The part
|
|
215
|
+
// listing reports no sizes, but a part's ETag IS the MD5 of its bytes, so the
|
|
216
|
+
// check is: MD5 of the local slice == the landed ETag. This also catches a
|
|
217
|
+
// file edited between runs, which skip-by-part-number silently stitched into
|
|
218
|
+
// a mixed object.
|
|
219
|
+
const etagMd5 = (etag) => {
|
|
220
|
+
const m = /^(?:W\/)?"?([0-9a-fA-F]{32})"?$/.exec(etag ?? '')
|
|
221
|
+
return m ? m[1].toLowerCase() : null
|
|
222
|
+
}
|
|
223
|
+
const md5Hex = (buf) => createHash('md5').update(buf).digest('hex')
|
|
224
|
+
// Geometries a previous run may have used, most likely first: the requested
|
|
225
|
+
// size, then the downshift ladder from the 64 MiB default.
|
|
226
|
+
const partSizeCandidates = (requested) => {
|
|
227
|
+
const ladder = [requested]
|
|
228
|
+
for (let s = 64 * 1024 * 1024; ; s = nextSmallerPartSize(s)) {
|
|
229
|
+
if (!ladder.includes(s)) ladder.push(s)
|
|
230
|
+
if (s <= MIN_PART_SIZE) break
|
|
231
|
+
}
|
|
232
|
+
return ladder
|
|
233
|
+
}
|
|
234
|
+
|
|
198
235
|
async function readPart(absPath, start, end) {
|
|
199
236
|
const chunks = []
|
|
200
237
|
await new Promise((res, rej) => {
|
|
@@ -228,56 +265,133 @@ async function md5File(absPath) {
|
|
|
228
265
|
}
|
|
229
266
|
|
|
230
267
|
async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone) {
|
|
231
|
-
|
|
232
|
-
|
|
268
|
+
let adoptLandedGeometry = true
|
|
269
|
+
for (;;) {
|
|
270
|
+
const attempt = await uploadFileParts(absPath, size, fileId, partSize, adoptLandedGeometry, onPartDone)
|
|
271
|
+
if (!attempt.tooSlow) return attempt.result
|
|
272
|
+
const smaller = nextSmallerPartSize(attempt.partSize)
|
|
273
|
+
out(` ${fmtBytes(attempt.partSize)} parts keep timing out on this connection — restarting with ${fmtBytes(smaller)} parts`)
|
|
274
|
+
partSize = smaller
|
|
275
|
+
// The restart deliberately abandons the old geometry — re-adopting part
|
|
276
|
+
// 1's old size from the landed listing would undo it.
|
|
277
|
+
adoptLandedGeometry = false
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function uploadFileParts(absPath, size, fileId, partSize, adoptLandedGeometry, onPartDone) {
|
|
282
|
+
let numParts = Math.max(1, Math.ceil(size / partSize))
|
|
233
283
|
const etags = new Map() // part_number -> etag
|
|
284
|
+
const timeoutStrikes = new Map() // part_number -> timeout-shaped failures
|
|
234
285
|
let uploadId = null
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
286
|
+
let tooSlow = false
|
|
287
|
+
|
|
288
|
+
for (let i = 0; i < numParts; i += SIGN_WINDOW) {
|
|
289
|
+
const windowParts = () =>
|
|
290
|
+
Array.from({ length: Math.min(SIGN_WINDOW, numParts - i) }, (_, k) => i + k + 1)
|
|
291
|
+
let j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: windowParts() }), 'sign upload parts')
|
|
292
|
+
if (i === 0 && adoptLandedGeometry && size > 0) {
|
|
293
|
+
// Resume across runs: an earlier run may have downshifted on a slow
|
|
294
|
+
// link, so the landed parts can belong to a smaller geometry. The
|
|
295
|
+
// ladder geometry whose local part-1 slice hashes to the landed part-1
|
|
296
|
+
// ETag is the geometry that run used — adopt it, or every landed part
|
|
297
|
+
// fails the content check and re-uploads for nothing.
|
|
298
|
+
const landedFirst = j.uploaded_parts.find((p) => p.part_number === 1)
|
|
299
|
+
const landedFirstMd5 = landedFirst ? etagMd5(landedFirst.etag) : null
|
|
300
|
+
if (landedFirstMd5) {
|
|
301
|
+
for (const candidate of partSizeCandidates(partSize)) {
|
|
302
|
+
const probe = await readPart(absPath, 0, Math.min(candidate, size) - 1)
|
|
303
|
+
if (md5Hex(probe) !== landedFirstMd5) continue
|
|
304
|
+
if (candidate !== partSize) {
|
|
305
|
+
partSize = candidate
|
|
306
|
+
numParts = Math.max(1, Math.ceil(size / partSize))
|
|
307
|
+
j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: windowParts() }), 'sign upload parts')
|
|
308
|
+
}
|
|
309
|
+
break
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
239
313
|
uploadId = j.upload_id
|
|
240
|
-
|
|
314
|
+
const landedByPart = new Map() // part_number -> { etag, md5 } (content-checkable landed parts)
|
|
315
|
+
for (const p of j.uploaded_parts) {
|
|
316
|
+
const md5 = etagMd5(p.etag)
|
|
317
|
+
if (md5) landedByPart.set(p.part_number, { etag: p.etag, md5 })
|
|
318
|
+
// An ETag that is not MD5-shaped cannot be content-checked; trust it by
|
|
319
|
+
// number (the pre-adaptive behavior) unless this attempt is a geometry
|
|
320
|
+
// restart, where the listing is known stale and the part re-uploads.
|
|
321
|
+
else if (adoptLandedGeometry) etags.set(p.part_number, p.etag)
|
|
322
|
+
}
|
|
241
323
|
const urlByPart = new Map(j.urls.map((u) => [u.part_number, u.url]))
|
|
242
324
|
|
|
243
325
|
// upload the still-missing parts in this window, up to 4 concurrent.
|
|
244
|
-
const missing =
|
|
326
|
+
const missing = windowParts().filter((n) => !etags.has(n))
|
|
245
327
|
const queue = [...missing]
|
|
246
328
|
// Re-sign one part. Returns a fresh URL — or null when the server reports
|
|
247
|
-
// the part ALREADY LANDED
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
const resign = async (partNo) => {
|
|
329
|
+
// the part ALREADY LANDED with the bytes we are sending: a 524/timeout
|
|
330
|
+
// can kill the response after S3 committed the bytes. That case is a
|
|
331
|
+
// success to record, not a dead end to die on.
|
|
332
|
+
const resign = async (partNo, bytes) => {
|
|
252
333
|
const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: [partNo] }), 're-sign upload part')
|
|
253
334
|
uploadId = j.upload_id
|
|
254
335
|
const landed = j.uploaded_parts.find((p) => p.part_number === partNo)
|
|
255
|
-
if (landed) {
|
|
336
|
+
if (landed) {
|
|
337
|
+
const md5 = etagMd5(landed.etag)
|
|
338
|
+
// Trust an unverifiable ETag here: this is our own attempt's commit.
|
|
339
|
+
if (!md5 || md5 === md5Hex(bytes)) { etags.set(partNo, landed.etag); return null }
|
|
340
|
+
}
|
|
256
341
|
const fresh = j.urls.find((u) => u.part_number === partNo)?.url
|
|
257
|
-
if (!fresh)
|
|
342
|
+
if (!fresh) {
|
|
343
|
+
if (landed) die(`upload part ${partNo}: landed under a different part geometry and this server will not re-sign a landed part — delete the file from the draft and push again`)
|
|
344
|
+
die(`upload part ${partNo}: could not re-sign (already finalized?)`)
|
|
345
|
+
}
|
|
258
346
|
urlByPart.set(partNo, fresh)
|
|
259
347
|
return fresh
|
|
260
348
|
}
|
|
261
349
|
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
262
350
|
for (;;) {
|
|
351
|
+
if (tooSlow) return
|
|
263
352
|
const partNo = queue.shift()
|
|
264
353
|
if (partNo === undefined) return
|
|
265
354
|
const start = (partNo - 1) * partSize
|
|
266
355
|
const end = Math.min(start + partSize, size) - 1
|
|
267
356
|
const bytes = await readPart(absPath, start, end)
|
|
357
|
+
// Resume: a landed part holding exactly these bytes is recorded, not
|
|
358
|
+
// re-sent. A mismatch (abandoned geometry, or the file changed on
|
|
359
|
+
// disk since the last run) falls through and re-uploads the part
|
|
360
|
+
// number, which replaces it.
|
|
361
|
+
const landed = landedByPart.get(partNo)
|
|
362
|
+
if (landed && md5Hex(bytes) === landed.md5) {
|
|
363
|
+
etags.set(partNo, landed.etag)
|
|
364
|
+
if (onPartDone) onPartDone()
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
268
367
|
// Expired URLs (401/403), transient storage errors (5xx), edge kills
|
|
269
368
|
// (524) and throttling all deserve the same treatment: re-sign — always
|
|
270
369
|
// safe, upload-parts is idempotent — back off, try again. Bounded at 4
|
|
271
370
|
// attempts, then the error surfaces. An unattended pipeline should
|
|
272
371
|
// survive a hiccup, not page a human for it; `push` stays resumable
|
|
273
|
-
// either way.
|
|
372
|
+
// either way. EXCEPT timeout-shaped failures: the second one on the
|
|
373
|
+
// same part flags a geometry restart — retrying the same slice into
|
|
374
|
+
// the same timeout gets nowhere on a slow uplink.
|
|
274
375
|
let put = null
|
|
275
376
|
for (let attempt = 1; ; attempt++) {
|
|
276
|
-
|
|
377
|
+
if (tooSlow) return
|
|
378
|
+
let url = attempt === 1 ? urlByPart.get(partNo) : undefined
|
|
379
|
+
if (url === undefined) url = await resign(partNo, bytes)
|
|
277
380
|
if (url === null) break // part landed server-side — resign() recorded its etag
|
|
381
|
+
const startedAt = Date.now()
|
|
278
382
|
put = await fetch(url, { method: 'PUT', body: bytes }).catch(() => null)
|
|
279
383
|
if (put && put.status === 200) break
|
|
384
|
+
const status = put ? put.status : 0
|
|
280
385
|
const reason = put ? `HTTP ${put.status}` : 'network error'
|
|
386
|
+
if (isTimeoutClassFailure(status, Date.now() - startedAt)) {
|
|
387
|
+
const strikes = (timeoutStrikes.get(partNo) ?? 0) + 1
|
|
388
|
+
timeoutStrikes.set(partNo, strikes)
|
|
389
|
+
if (strikes >= 2 && partSize > MIN_PART_SIZE) {
|
|
390
|
+
out(` part ${partNo}: ${reason} again — parts too large for this connection`)
|
|
391
|
+
tooSlow = true
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
}
|
|
281
395
|
if (attempt >= 4) die(`upload part ${partNo}: ${reason} after ${attempt} attempts — re-run to resume`)
|
|
282
396
|
const wait = [1, 4, 10][attempt - 1] + Math.random()
|
|
283
397
|
out(` part ${partNo}: ${reason} — retrying in ${Math.round(wait)}s (${attempt}/3)`)
|
|
@@ -292,9 +406,15 @@ async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone)
|
|
|
292
406
|
}
|
|
293
407
|
})
|
|
294
408
|
await Promise.all(workers)
|
|
409
|
+
if (tooSlow) return { tooSlow: true, partSize }
|
|
295
410
|
}
|
|
296
411
|
|
|
297
|
-
|
|
412
|
+
const allParts = Array.from({ length: numParts }, (_, n) => n + 1)
|
|
413
|
+
return {
|
|
414
|
+
tooSlow: false,
|
|
415
|
+
partSize,
|
|
416
|
+
result: { upload_id: uploadId, parts: allParts.map((n) => ({ part_number: n, etag: etags.get(n) })) },
|
|
417
|
+
}
|
|
298
418
|
}
|
|
299
419
|
|
|
300
420
|
// ——— commands ———
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myelinbridge/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Myelin Partner Ingestion CLI — push R&D data deliveries from a pipeline: preflight against the client's quality rules, resumable upload, submit, track review outcomes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|