@myelinbridge/cli 0.8.1 → 0.9.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.
Files changed (3) hide show
  1. package/README.md +5 -0
  2. package/bin/myelin.js +138 -18
  3. 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
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
- const numParts = Math.max(1, Math.ceil(size / partSize))
232
- const allParts = Array.from({ length: numParts }, (_, i) => i + 1)
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
- for (let i = 0; i < allParts.length; i += SIGN_WINDOW) {
237
- const window = allParts.slice(i, i + SIGN_WINDOW)
238
- const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: window }), 'sign upload parts')
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
- for (const p of j.uploaded_parts) etags.set(p.part_number, p.etag) // already landed → skip
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 = window.filter((n) => !etags.has(n))
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: a 524/timeout can kill the response after S3
248
- // committed the bytes, and the server then returns no URL for that part,
249
- // only its etag in uploaded_parts. That case is a success to record, not
250
- // a dead end to die on.
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) { etags.set(partNo, landed.etag); return null }
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) die(`upload part ${partNo}: could not re-sign (already finalized?)`)
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
- const url = attempt === 1 ? urlByPart.get(partNo) : await resign(partNo)
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
- return { upload_id: uploadId, parts: allParts.map((n) => ({ part_number: n, etag: etags.get(n) })) }
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.8.1",
3
+ "version": "0.9.0",
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": {