@sjcrh/proteinpaint-rust 2.191.6 → 2.191.7

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/index.js +10 -4
  2. package/package.json +1 -1
  3. package/src/gdcmaf.rs +262 -37
package/index.js CHANGED
@@ -88,7 +88,12 @@ export function run_rust(binfile, input_data, args = [], { signal } = {}) {
88
88
  // will already trigger an error. `stream_rust()` was heavily tested manually
89
89
  // while troubleshooting and fixing the idle rust processes the led to memory leaks
90
90
  // as part of `/gdc/mafBuild` handler code, leave this code as-is for now.
91
- export function stream_rust(binfile, input_data, emitJson) {
91
+ // opts.maxElapsed (ms): how long a tracked 'gdcmaf' process may run before the
92
+ // watchdog kills it. Defaults to 300000 (5 min) when omitted. This is a safety
93
+ // BACKSTOP against leaked/hung processes (e.g. a download that stalls against a
94
+ // slow GDC environment) - it is not itself the cause of a hang; tightening the
95
+ // rust-side per-request timeouts is what makes a stuck download fail fast.
96
+ export function stream_rust(binfile, input_data, emitJson, opts = {}) {
92
97
  const binpath = path.join(__dirname, '/target/release/', binfile)
93
98
 
94
99
  const ps = spawn(binpath)
@@ -99,7 +104,7 @@ export function stream_rust(binfile, input_data, emitJson) {
99
104
  }
100
105
  })
101
106
  // we only want to run this interval loop inside a container, not in dev/test CI
102
- if (binfile == 'gdcmaf') trackByPid(ps.pid, binfile)
107
+ if (binfile == 'gdcmaf') trackByPid(ps.pid, binfile, opts.maxElapsed)
103
108
  const stderr = []
104
109
  try {
105
110
  // from route handler -> input_data -> ps.stdin -> ps.stdout -> transformed stream -> express response.pipe()
@@ -193,8 +198,9 @@ const killedPids = new Set() // will be used to detect killed processes, to help
193
198
  const PSKILL_INTERVAL_MS = 30000 // every 30 seconds
194
199
  let psKillInterval
195
200
 
196
- // default maxElapsed = 5 * 60 * 1000 millisecond = 300000 or 5 minutes, change to 0 to test
197
- // may allow configuration of maxElapsed by dataset/argument
201
+ // maxElapsed: the watchdog kill threshold in ms; defaults to 5 minutes (300000).
202
+ // Configurable per call via stream_rust(..., { maxElapsed }) (wired from
203
+ // serverconfig.features.gdcMafMaxElapsed by the /gdc/mafBuild handler).
198
204
  function trackByPid(pid, name, maxElapsed = 300000) {
199
205
  if (!pid) return
200
206
  // only track by value (integer, string), not reference object
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.191.6",
2
+ "version": "2.191.7",
3
3
  "name": "@sjcrh/proteinpaint-rust",
4
4
  "type": "module",
5
5
  "description": "Rust-based utilities for proteinpaint",
package/src/gdcmaf.rs CHANGED
@@ -30,6 +30,30 @@ struct ErrorEntry {
30
30
  error: String,
31
31
  }
32
32
 
33
+ // Flatten a std::error::Error (e.g. reqwest::Error) and its `source()` chain into
34
+ // a single string, so the real transport-level cause (e.g. "connection closed
35
+ // before message completed", connection reset, HTTP/2 stream error) is visible in
36
+ // the per-file error rather than only the top-level wrapper message.
37
+ fn format_error_chain(e: &dyn std::error::Error) -> String {
38
+ let mut msg = e.to_string();
39
+ let mut src = e.source();
40
+ while let Some(s) = src {
41
+ msg.push_str(" -> ");
42
+ msg.push_str(&s.to_string());
43
+ src = s.source();
44
+ }
45
+ msg
46
+ }
47
+
48
+ // Render the first bytes of a response body for diagnostics: helps tell whether the
49
+ // server returned gzip (magic 1f 8b), plain text, or an HTML/JSON error page.
50
+ fn preview_bytes(content: &[u8]) -> String {
51
+ let n = content.len().min(64);
52
+ let hex: Vec<String> = content[..n].iter().map(|b| format!("{:02x}", b)).collect();
53
+ let ascii = String::from_utf8_lossy(&content[..n]);
54
+ format!("hex=[{}] ascii=\"{}\"", hex.join(" "), ascii.escape_default())
55
+ }
56
+
33
57
  fn select_maf_col(d: String, columns: &Vec<String>, url: &str) -> Result<(Vec<u8>, i32), (String, String)> {
34
58
  let mut maf_str: String = String::new();
35
59
  let mut header_indices: Vec<usize> = Vec::new();
@@ -204,7 +228,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
204
228
  let client = reqwest::Client::builder()
205
229
  .timeout(Duration::from_secs(30)) // 30-second timeout per request
206
230
  .connect_timeout(Duration::from_secs(15))
207
- .pool_max_idle_per_host(0) // avoid "connection closed before message completed" race
231
+ // Allow keep-alive connection reuse. Previously this was pool_max_idle_per_host(0)
232
+ // to dodge a "connection closed before message completed" race, but disabling reuse
233
+ // forces a brand-new connection per file, which is far harsher on a server that caps
234
+ // concurrent connections (e.g. qa-int). Mid-body drops are now handled by retrying the
235
+ // whole download (see the retry loop below) rather than by refusing to reuse connections.
208
236
  .build()
209
237
  .map_err(|e| {
210
238
  let client_error = ErrorEntry {
@@ -217,58 +245,123 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
217
245
  e
218
246
  })?;
219
247
 
248
+ // Number of files downloaded concurrently. Driven by the input JSON "concurrency" field,
249
+ // which the /gdc/mafBuild handler sets from serverconfig.features.gdcMafConcurrency, so it
250
+ // can be dialed down per environment (e.g. qa-int, which appears to cap simultaneous
251
+ // connections) without a rebuild; falls back to 20 when absent.
252
+ let concurrency = file_id_lst_js
253
+ .get("concurrency")
254
+ .and_then(|v| v.as_u64())
255
+ .filter(|n| *n >= 1)
256
+ .unwrap_or(20) as usize;
257
+
220
258
  //downloading maf files parallelly and merge them into single maf file
221
259
  let download_futures = futures::stream::iter(url.into_iter().map(|url| {
222
260
  let client = client.clone();
223
261
  let req_headers = req_headers.clone();
224
262
  async move {
225
- // bounded retry for transient transport failures
226
- // (IncompleteMessage / TimedOut / connect), up to 3 attempts with backoff
263
+ // Bounded retry for transient transport failures, up to MAX_ATTEMPTS with backoff.
264
+ // The whole download (send + body read) is inside the loop so a mid-body drop
265
+ // ("connection closed before message completed") is retried, not just connect/send
266
+ // failures. Non-2xx responses and decompression failures are NOT transient and break
267
+ // immediately. The metadata captured before consuming the body feeds the error text.
268
+ const MAX_ATTEMPTS: u32 = 3;
227
269
  let mut attempt: u32 = 0;
228
- let send_result = loop {
270
+ loop {
229
271
  attempt += 1;
272
+
230
273
  let mut request = client.get(&url);
231
274
  for (name, value) in req_headers.iter() {
232
- request = request.header(name.clone(), value.clone()); // == .header("X-Forwarded-Agent", …) etc.
275
+ request = request.header(name.clone(), value.clone());
233
276
  }
234
- match request.send().await {
235
- Ok(resp) => break Ok(resp),
236
- Err(e) if attempt < 3 && (e.is_request() || e.is_timeout() || e.is_connect()) => {
277
+
278
+ // --- send ---
279
+ let resp = match request.send().await {
280
+ Ok(resp) => resp,
281
+ Err(e) if attempt < MAX_ATTEMPTS && (e.is_request() || e.is_timeout() || e.is_connect()) => {
237
282
  tokio::time::sleep(Duration::from_millis(500 * attempt as u64)).await;
238
283
  continue;
239
284
  }
240
- Err(e) => break Err(e),
285
+ Err(e) => {
286
+ break Err((url.clone(), format!("Server request failed: {}", format_error_chain(&e))));
287
+ }
288
+ };
289
+
290
+ // Capture response metadata before the body is consumed, for diagnostics.
291
+ let status = resp.status();
292
+ let version = format!("{:?}", resp.version());
293
+ let content_type = resp
294
+ .headers()
295
+ .get(reqwest::header::CONTENT_TYPE)
296
+ .and_then(|v| v.to_str().ok())
297
+ .unwrap_or("")
298
+ .to_string();
299
+ let content_encoding = resp
300
+ .headers()
301
+ .get(reqwest::header::CONTENT_ENCODING)
302
+ .and_then(|v| v.to_str().ok())
303
+ .unwrap_or("")
304
+ .to_string();
305
+
306
+ if !status.is_success() {
307
+ // non-2xx: a server-side decision, not transient - capture a snippet of the
308
+ // error body (often an HTML/JSON page from a proxy/WAF) and stop.
309
+ let body_snippet = match resp.text().await {
310
+ Ok(t) => t.chars().take(200).collect::<String>().escape_default().to_string(),
311
+ Err(_) => String::new(),
312
+ };
313
+ break Err((
314
+ url.clone(),
315
+ format!(
316
+ "HTTP error {} ({}, content-type '{}', content-encoding '{}'), body: {}",
317
+ status, version, content_type, content_encoding, body_snippet
318
+ ),
319
+ ));
241
320
  }
242
- };
243
- match send_result {
244
- Ok(resp) if resp.status().is_success() => match resp.bytes().await {
245
- Ok(content) => {
246
- let mut decoder = GzDecoder::new(&content[..]);
247
- let mut decompressed_content = Vec::new();
248
- match decoder.read_to_end(&mut decompressed_content) {
249
- Ok(_) => {
250
- let text = String::from_utf8_lossy(&decompressed_content).to_string();
251
- return Ok((url.clone(), text));
252
- }
253
- Err(e) => {
254
- let error_msg = format!("Failed to decompress downloaded MAF file: {}", e);
255
- Err((url.clone(), error_msg))
256
- }
257
- }
321
+
322
+ // --- read body ---
323
+ let content = match resp.bytes().await {
324
+ Ok(content) => content,
325
+ Err(_e) if attempt < MAX_ATTEMPTS => {
326
+ // transport/read failure AFTER headers (e.g. "connection closed before
327
+ // message completed") - typically transient, so retry the whole download
328
+ tokio::time::sleep(Duration::from_millis(500 * attempt as u64)).await;
329
+ continue;
258
330
  }
259
331
  Err(e) => {
260
- let error_msg = format!("Failed to decompress downloaded MAF file: {}", e);
261
- Err((url.clone(), error_msg))
332
+ break Err((
333
+ url.clone(),
334
+ format!(
335
+ "Failed to read response body (HTTP {}, {}, content-type '{}', content-encoding '{}'): {}",
336
+ status,
337
+ version,
338
+ content_type,
339
+ content_encoding,
340
+ format_error_chain(&e)
341
+ ),
342
+ ));
262
343
  }
263
- },
264
- Ok(resp) => {
265
- let error_msg = format!("HTTP error: {:?}", resp.status());
266
- Err((url.clone(), error_msg))
267
- }
268
- Err(e) => {
269
- let error_msg = format!("Server request failed: {:?}", e);
270
- Err((url.clone(), error_msg))
271
- }
344
+ };
345
+
346
+ // --- decompress (not transient: do not retry) ---
347
+ let mut decoder = GzDecoder::new(&content[..]);
348
+ let mut decompressed_content = Vec::new();
349
+ break match decoder.read_to_end(&mut decompressed_content) {
350
+ Ok(_) => Ok((url.clone(), String::from_utf8_lossy(&decompressed_content).to_string())),
351
+ Err(e) => Err((
352
+ url.clone(),
353
+ format!(
354
+ "Failed to decompress MAF file (HTTP {}, {}, content-type '{}', content-encoding '{}', body {} bytes, first bytes {}): {}",
355
+ status,
356
+ version,
357
+ content_type,
358
+ content_encoding,
359
+ content.len(),
360
+ preview_bytes(&content),
361
+ e
362
+ ),
363
+ )),
364
+ };
272
365
  }
273
366
  }
274
367
  }));
@@ -286,7 +379,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
286
379
  }
287
380
 
288
381
  download_futures
289
- .buffer_unordered(10)
382
+ .buffer_unordered(concurrency)
290
383
  .for_each(|result| {
291
384
  let encoder = Arc::clone(&encoder); // Clone the Arc for each task
292
385
  let maf_col_cp = maf_col.clone();
@@ -338,3 +431,135 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
338
431
  io::stderr().flush().expect("Failed to flush stderr");
339
432
  Ok(())
340
433
  }
434
+
435
+ #[cfg(test)]
436
+ mod tests {
437
+ use super::*;
438
+
439
+ // ---- select_maf_col ----
440
+
441
+ // Minimal MAF-like content: a comment line, a header line (detected by the
442
+ // "Hugo_Symbol" substring), then data rows.
443
+ const MAF: &str = "# version 2.4\n\
444
+ Hugo_Symbol\tEntrez_Gene_Id\tChromosome\tStart_Position\n\
445
+ TP53\t7157\tchr17\t7577120\n\
446
+ KRAS\t3845\tchr12\t25398284\n";
447
+
448
+ fn cols(list: &[&str]) -> Vec<String> {
449
+ list.iter().map(|s| s.to_string()).collect()
450
+ }
451
+
452
+ #[test]
453
+ fn select_maf_col_selects_and_orders_requested_columns() {
454
+ // request a subset, in an order different from the file's column order
455
+ let (bytes, rows) = select_maf_col(MAF.to_string(), &cols(&["Chromosome", "Hugo_Symbol"]), "u").unwrap();
456
+ assert_eq!(rows, 2, "two data rows should be emitted");
457
+ assert_eq!(String::from_utf8(bytes).unwrap(), "chr17\tTP53\nchr12\tKRAS\n");
458
+ }
459
+
460
+ #[test]
461
+ fn select_maf_col_skips_comment_lines_and_header() {
462
+ // comment (#) and header lines must not be counted or emitted as data
463
+ let (bytes, rows) = select_maf_col(MAF.to_string(), &cols(&["Hugo_Symbol"]), "u").unwrap();
464
+ assert_eq!(rows, 2);
465
+ assert_eq!(String::from_utf8(bytes).unwrap(), "TP53\nKRAS\n");
466
+ }
467
+
468
+ #[test]
469
+ fn select_maf_col_errors_on_missing_column() {
470
+ let err = select_maf_col(MAF.to_string(), &cols(&["No_Such_Column"]), "the-url").unwrap_err();
471
+ assert_eq!(err.0, "the-url", "error should carry the url");
472
+ assert!(
473
+ err.1.contains("No_Such_Column"),
474
+ "error should name the missing column: {}",
475
+ err.1
476
+ );
477
+ }
478
+
479
+ #[test]
480
+ fn select_maf_col_header_only_yields_no_rows() {
481
+ let header_only = "Hugo_Symbol\tChromosome\n";
482
+ let (bytes, rows) = select_maf_col(header_only.to_string(), &cols(&["Hugo_Symbol"]), "u").unwrap();
483
+ assert_eq!(rows, 0);
484
+ assert!(bytes.is_empty());
485
+ }
486
+
487
+ // NOTE: a data row with fewer fields than the header currently panics
488
+ // (maf_cont_lst[*x] index out of bounds); that ragged-row hardening is
489
+ // deferred to Step 5, where a test for graceful handling should be added.
490
+
491
+ // ---- format_error_chain ----
492
+
493
+ #[derive(Debug)]
494
+ struct TestErr {
495
+ msg: String,
496
+ src: Option<Box<dyn std::error::Error + 'static>>,
497
+ }
498
+ impl std::fmt::Display for TestErr {
499
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500
+ write!(f, "{}", self.msg)
501
+ }
502
+ }
503
+ impl std::error::Error for TestErr {
504
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
505
+ self.src.as_deref()
506
+ }
507
+ }
508
+
509
+ #[test]
510
+ fn format_error_chain_single_error() {
511
+ let e = TestErr {
512
+ msg: "top".into(),
513
+ src: None,
514
+ };
515
+ assert_eq!(format_error_chain(&e), "top");
516
+ }
517
+
518
+ #[test]
519
+ fn format_error_chain_walks_source_chain() {
520
+ let root = TestErr {
521
+ msg: "root".into(),
522
+ src: None,
523
+ };
524
+ let middle = TestErr {
525
+ msg: "middle".into(),
526
+ src: Some(Box::new(root)),
527
+ };
528
+ let top = TestErr {
529
+ msg: "top".into(),
530
+ src: Some(Box::new(middle)),
531
+ };
532
+ assert_eq!(format_error_chain(&top), "top -> middle -> root");
533
+ }
534
+
535
+ // ---- preview_bytes ----
536
+
537
+ #[test]
538
+ fn preview_bytes_shows_gzip_magic() {
539
+ // gzip magic 1f 8b should be visible so we can tell gzip from plaintext
540
+ let out = preview_bytes(&[0x1f, 0x8b, 0x08, 0x00]);
541
+ assert!(out.contains("hex=[1f 8b 08 00]"), "got: {}", out);
542
+ }
543
+
544
+ #[test]
545
+ fn preview_bytes_renders_plaintext_in_ascii() {
546
+ let out = preview_bytes(b"Hugo_Symbol");
547
+ assert!(
548
+ out.contains("Hugo_Symbol"),
549
+ "ascii preview should show plaintext: {}",
550
+ out
551
+ );
552
+ }
553
+
554
+ #[test]
555
+ fn preview_bytes_truncates_to_64_bytes() {
556
+ // 100 'A' (0x41) bytes -> only 64 should be rendered in the hex section
557
+ let out = preview_bytes(&[0x41u8; 100]);
558
+ assert_eq!(
559
+ out.matches("41").count(),
560
+ 64,
561
+ "should cap the hex preview at 64 bytes: {}",
562
+ out
563
+ );
564
+ }
565
+ }