@lde/pipeline 0.42.1 → 0.44.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.
package/dist/pipeline.js CHANGED
@@ -9,8 +9,9 @@ import { sourceFingerprint } from './provenance/sourceFingerprint.js';
9
9
  import { shouldReprocess } from './provenance/reprocessDecision.js';
10
10
  import { NetworkError, SparqlProbeResult, } from '@lde/distribution-probe';
11
11
  import { importOutcomeToVerdict, probeResultToVerdict, } from '@lde/distribution-health';
12
- import { NotSupported } from './sparql/reader.js';
13
- import { combineReporters } from './combineReporters.js';
12
+ import { NoItemsSelected, NotSupported } from './sparql/reader.js';
13
+ import { levelOf, } from './pipelineEvent.js';
14
+ import { UnreadValueTally } from './unreadValueTally.js';
14
15
  import { ConstantTimeoutPolicy, } from './sparql/timeoutPolicy.js';
15
16
  /**
16
17
  * Why a run was abandoned rather than committed: its selector produced no
@@ -237,7 +238,7 @@ export class Pipeline {
237
238
  beforeDatasetWrite;
238
239
  distributionResolver;
239
240
  chaining;
240
- reporter;
241
+ reporters;
241
242
  timeoutFactory;
242
243
  provenanceStore;
243
244
  pipelineVersion;
@@ -277,11 +278,7 @@ export class Pipeline {
277
278
  this.distributionResolver =
278
279
  options.distributionResolver ?? new SparqlDistributionResolver();
279
280
  this.chaining = options.chaining;
280
- // `Array.isArray` narrows the array branch but not the readonly-array out of
281
- // the else branch, so cast the single-reporter case explicitly.
282
- this.reporter = Array.isArray(options.reporter)
283
- ? combineReporters(options.reporter)
284
- : options.reporter;
281
+ this.reporters = [options.reporter ?? []].flat();
285
282
  this.timeoutFactory =
286
283
  options.timeout ?? (() => new ConstantTimeoutPolicy(300_000));
287
284
  this.provenanceStore = options.provenanceStore;
@@ -289,7 +286,8 @@ export class Pipeline {
289
286
  }
290
287
  async run() {
291
288
  const start = Date.now();
292
- this.reporter?.pipelineStart?.(this.name);
289
+ const runId = randomUUID();
290
+ this.report(runId, { type: 'pipelineStart', name: this.name });
293
291
  // Fail fast on a store that cannot persist records (e.g. a provenance
294
292
  // file on a mount the runtime user cannot write to): every dataset would
295
293
  // process fine, but the records would be lost and skip-unchanged would
@@ -297,17 +295,21 @@ export class Pipeline {
297
295
  await this.provenanceStore?.check?.();
298
296
  const selectStart = Date.now();
299
297
  const datasets = await this.datasetSelector.select();
300
- this.reporter?.datasetsSelected?.(datasets.total, Date.now() - selectStart);
298
+ this.report(runId, {
299
+ type: 'datasetsSelected',
300
+ count: datasets.total,
301
+ durationMs: Date.now() - selectStart,
302
+ });
301
303
  // The run transaction: one openRun → write* → commit/abort per pipeline
302
304
  // run. `selectedSources` accumulates every selected dataset – including
303
305
  // ones skipped as unchanged – so a writer's commit can sweep by registry
304
306
  // membership.
305
307
  const selectedSources = [];
306
308
  const context = {
307
- runId: randomUUID(),
309
+ runId,
308
310
  startedAt: new Date().toISOString(),
309
311
  selectedSources: () => selectedSources,
310
- itemsRejected: (dataset, rejection) => this.reporter?.itemsRejected?.(dataset, rejection),
312
+ itemsRejected: (dataset, rejection) => this.report(runId, { type: 'itemsRejected', dataset, rejection }),
311
313
  provenance: this.provenanceStore,
312
314
  };
313
315
  const openedWriter = await this.writer.openRun(context);
@@ -349,36 +351,61 @@ export class Pipeline {
349
351
  // meant not to do.
350
352
  if (abandoned) {
351
353
  const reason = new EmptySelection();
352
- this.reporter?.selectionEmpty?.(reason.message);
354
+ this.report(runId, { type: 'selectionEmpty', message: reason.message });
353
355
  await runWriter.abort(reason);
354
356
  }
355
357
  const finalMemory = process.memoryUsage();
356
- this.reporter?.pipelineComplete?.({
357
- duration: Date.now() - start,
358
+ this.report(runId, {
359
+ type: 'pipelineComplete',
360
+ durationMs: Date.now() - start,
358
361
  memoryUsageBytes: finalMemory.rss,
359
362
  heapUsedBytes: finalMemory.heapUsed,
360
363
  });
361
364
  }
362
365
  async processDataset(dataset, runWriter, context) {
363
- this.reporter?.datasetStart?.(dataset);
366
+ const { runId } = context;
367
+ const datasetStart = Date.now();
368
+ this.report(runId, { type: 'datasetStart', dataset });
369
+ const skip = (reason) => this.report(runId, { type: 'datasetSkipped', dataset, reason });
370
+ const skipOnError = (code, prefix, thrown) => {
371
+ const error = thrown instanceof Error
372
+ ? thrown
373
+ : new Error(String(thrown), { cause: thrown });
374
+ this.report(runId, {
375
+ type: 'datasetSkipped',
376
+ dataset,
377
+ reason: { code, message: `${prefix}: ${error.message}` },
378
+ error,
379
+ });
380
+ };
364
381
  // Probe phase: gather probe results and the source-to-be, without importing.
365
382
  let probed;
366
383
  try {
367
384
  probed = await this.distributionResolver.probe(dataset, {
368
385
  onProbe: (distribution, result) => {
369
- this.reporter?.distributionProbed?.(mapProbeResult(distribution, result));
386
+ this.report(runId, {
387
+ type: 'distributionProbed',
388
+ dataset,
389
+ distribution,
390
+ result: mapProbeResult(distribution, result),
391
+ });
370
392
  // Shallow validity: the probe parse-validates small RDF bodies as a
371
393
  // by-product. Surface that verdict per distribution, judged against
372
394
  // the distribution's own observed fingerprint.
373
395
  const verdict = probeResultToVerdict(result, sourceFingerprint(distribution, result));
374
396
  if (verdict) {
375
- this.reporter?.distributionValidated?.(distribution, verdict);
397
+ this.report(runId, {
398
+ type: 'distributionValidated',
399
+ dataset,
400
+ distribution,
401
+ verdict,
402
+ });
376
403
  }
377
404
  },
378
405
  });
379
406
  }
380
407
  catch (error) {
381
- this.reporter?.datasetSkipped?.(dataset, `Distribution probing failed: ${error instanceof Error ? error.message : String(error)}`);
408
+ skipOnError('probeFailed', 'Distribution probing failed', error);
382
409
  return;
383
410
  }
384
411
  // Derive the source-change fingerprint from the probed source: null for a
@@ -405,24 +432,26 @@ export class Pipeline {
405
432
  sourceFingerprint: fingerprint,
406
433
  pipelineVersion: this.pipelineVersion,
407
434
  }, stored)) {
408
- this.reporter?.datasetSkipped?.(dataset, 'Unchanged since last run');
435
+ skip({ code: 'unchanged', message: 'Unchanged since last run' });
409
436
  return;
410
437
  }
411
438
  }
412
439
  // Resolve phase: import a data dump only when the source is one.
440
+ const importCallbacks = {
441
+ onImportStart: () => this.report(runId, { type: 'importStarted', dataset }),
442
+ onImportFailed: (distribution, error) => this.report(runId, {
443
+ type: 'importFailed',
444
+ dataset,
445
+ distribution,
446
+ error,
447
+ }),
448
+ };
413
449
  let resolved;
414
450
  try {
415
- resolved = await this.distributionResolver.resolve(probed, {
416
- onImportStart: () => {
417
- this.reporter?.importStarted?.();
418
- },
419
- onImportFailed: (distribution, error) => {
420
- this.reporter?.importFailed?.(distribution, error);
421
- },
422
- });
451
+ resolved = await this.distributionResolver.resolve(probed, importCallbacks);
423
452
  }
424
453
  catch (error) {
425
- this.reporter?.datasetSkipped?.(dataset, `Distribution resolution failed: ${error instanceof Error ? error.message : String(error)}`);
454
+ skipOnError('resolveFailed', 'Distribution resolution failed', error);
426
455
  return;
427
456
  }
428
457
  if (resolved instanceof NoDistributionAvailable) {
@@ -431,24 +460,36 @@ export class Pipeline {
431
460
  // no summary, so an invalid distribution is recorded rather than silently
432
461
  // dropped.
433
462
  if (resolved.importFailed) {
434
- this.reporter?.distributionValidated?.(resolved.importFailed.distribution, importOutcomeToVerdict(resolved.importFailed, fingerprint));
463
+ this.report(runId, {
464
+ type: 'distributionValidated',
465
+ dataset,
466
+ distribution: resolved.importFailed.distribution,
467
+ verdict: importOutcomeToVerdict(resolved.importFailed, fingerprint),
468
+ });
435
469
  }
436
470
  // Record the failure so a dataset whose source is unchanged is not
437
471
  // re-imported every run; it is retried at the next fingerprint change or
438
472
  // version rotation.
439
- await this.recordOutcome(dataset, fingerprint, 'failed');
440
- this.reporter?.datasetSkipped?.(dataset, resolved.message);
473
+ await this.recordOutcome(runId, dataset, fingerprint, 'failed');
474
+ skip({ code: 'noDistributionAvailable', message: resolved.message });
441
475
  return;
442
476
  }
443
- this.reportSelectedDistribution(dataset, resolved, fingerprint);
477
+ this.reportSelectedDistribution(runId, dataset, resolved, fingerprint);
444
478
  const timeout = this.timeoutFactory();
445
479
  const unsubscribe = timeout.subscribe?.({
446
- onTighten: (event) => this.reporter?.timeoutTightened?.(event),
447
- onRelax: (event) => this.reporter?.timeoutRelaxed?.(event),
480
+ onTighten: (transition) => this.report(runId, {
481
+ ...transition,
482
+ type: 'timeoutTightened',
483
+ dataset,
484
+ }),
485
+ onRelax: (transition) => this.report(runId, { ...transition, type: 'timeoutRelaxed', dataset }),
448
486
  });
449
487
  let stageFailed = false;
488
+ // What the kept attempt’s stages could not read, reported once the dataset
489
+ // completes.
490
+ let unread;
450
491
  try {
451
- stageFailed = await this.runStages(dataset, resolved.distribution, timeout, runWriter, context);
492
+ ({ stageFailed, unread } = await this.runStages(dataset, resolved.distribution, timeout, runWriter, context));
452
493
  // Reactive fallback: an endpoint that passed probing but could not serve
453
494
  // the analysis stages is empirically incapable. Switch to the dataset’s
454
495
  // data dump and re-run all stages locally, discarding the
@@ -463,10 +504,7 @@ export class Pipeline {
463
504
  // path. The dataset stays recorded as failed (stageFailed is already
464
505
  // true) and processing continues with the next dataset.
465
506
  try {
466
- const fallback = await this.distributionResolver.resolveFallback(probed, {
467
- onImportStart: () => this.reporter?.importStarted?.(),
468
- onImportFailed: (distribution, error) => this.reporter?.importFailed?.(distribution, error),
469
- });
507
+ const fallback = await this.distributionResolver.resolveFallback(probed, importCallbacks);
470
508
  if (fallback instanceof ResolvedDistribution) {
471
509
  // The dump is now the dataset's effective source: report it as
472
510
  // selected/validated under its own change fingerprint so the next
@@ -480,11 +518,11 @@ export class Pipeline {
480
518
  fallbackFingerprint = sourceFingerprint(dump, dumpProbeResult);
481
519
  }
482
520
  }
483
- this.reportSelectedDistribution(dataset, fallback, fallbackFingerprint);
521
+ this.reportSelectedDistribution(runId, dataset, fallback, fallbackFingerprint);
484
522
  // Discard the endpoint-sourced partial output before the re-run so
485
523
  // the dump-sourced stats replace it rather than appending to it.
486
524
  await runWriter.reset?.(dataset);
487
- stageFailed = await this.runStages(dataset, fallback.distribution, timeout, runWriter, context);
525
+ ({ stageFailed, unread } = await this.runStages(dataset, fallback.distribution, timeout, runWriter, context));
488
526
  // Adopt the dump’s fingerprint only once the re-run has succeeded.
489
527
  // If the reset or the re-run throws, or a stage fails against the
490
528
  // dump, the dataset is recorded ‘failed’ under the endpoint’s
@@ -499,11 +537,16 @@ export class Pipeline {
499
537
  // A failed dump import is a deep validity verdict on that dump –
500
538
  // surface it rather than silently keeping the endpoint's partial
501
539
  // output, matching the primary NoDistributionAvailable path.
502
- this.reporter?.distributionValidated?.(fallback.importFailed.distribution, importOutcomeToVerdict(fallback.importFailed, fingerprint));
540
+ this.report(runId, {
541
+ type: 'distributionValidated',
542
+ dataset,
543
+ distribution: fallback.importFailed.distribution,
544
+ verdict: importOutcomeToVerdict(fallback.importFailed, fingerprint),
545
+ });
503
546
  }
504
547
  }
505
548
  catch (error) {
506
- this.reporter?.stageFailed?.('reactive-dump-fallback', error instanceof Error ? error : new Error(String(error)));
549
+ this.reportStageFailed(runId, dataset, 'reactive-dump-fallback', error);
507
550
  }
508
551
  }
509
552
  }
@@ -518,21 +561,60 @@ export class Pipeline {
518
561
  // A per-dataset writer failure must not abort the whole run: isolate it
519
562
  // like a stage failure so the remaining datasets still process. The
520
563
  // dataset is recorded 'failed' below and retried on the next run.
521
- this.reporter?.stageFailed?.('write', error instanceof Error ? error : new Error(String(error)));
564
+ this.reportStageFailed(runId, dataset, 'write', error);
522
565
  stageFailed = true;
523
566
  }
524
- await this.reportValidators(dataset);
567
+ await this.reportValidators(runId, dataset);
525
568
  // A dataset whose stages threw produced incomplete output; record it as
526
569
  // ‘failed’ rather than freezing a broken result under a ‘success’ record.
527
- await this.recordOutcome(dataset, fingerprint, stageFailed ? 'failed' : 'success');
570
+ await this.recordOutcome(runId, dataset, fingerprint, stageFailed ? 'failed' : 'success');
571
+ if (!stageFailed && unread !== undefined) {
572
+ for (const entry of unread.entries()) {
573
+ this.report(runId, { type: 'valuesUnread', dataset, unread: entry });
574
+ }
575
+ }
528
576
  const datasetMemory = process.memoryUsage();
529
- this.reporter?.datasetComplete?.(dataset, {
577
+ this.report(runId, {
578
+ type: 'datasetComplete',
579
+ dataset,
580
+ durationMs: Date.now() - datasetStart,
530
581
  memoryUsageBytes: datasetMemory.rss,
531
582
  heapUsedBytes: datasetMemory.heapUsed,
532
583
  });
533
584
  }
585
+ report(runId, payload) {
586
+ if (this.reporters.length === 0)
587
+ return;
588
+ const event = {
589
+ ...payload,
590
+ defaultLevel: levelOf(payload),
591
+ timestamp: new Date(),
592
+ runId,
593
+ };
594
+ for (const reporter of this.reporters) {
595
+ // A reporter only observes the run, so its failure must not change it:
596
+ // the other reporters still get the event, and the run carries on.
597
+ try {
598
+ reporter.onEvent(event);
599
+ }
600
+ catch (error) {
601
+ process.emitWarning(`A reporter threw on a ${event.type} event: ${error instanceof Error ? error.message : String(error)}`, {
602
+ type: 'ReporterWarning',
603
+ detail: error instanceof Error ? error.stack : undefined,
604
+ });
605
+ }
606
+ }
607
+ }
608
+ reportStageFailed(runId, dataset, stage, error) {
609
+ this.report(runId, {
610
+ type: 'stageFailed',
611
+ dataset,
612
+ stage,
613
+ error: error instanceof Error ? error : new Error(String(error)),
614
+ });
615
+ }
534
616
  /** Persist the processing record for a dataset, when a store is configured. */
535
- async recordOutcome(dataset, fingerprint, status) {
617
+ async recordOutcome(runId, dataset, fingerprint, status) {
536
618
  if (!this.provenanceStore)
537
619
  return;
538
620
  try {
@@ -548,10 +630,10 @@ export class Pipeline {
548
630
  // reprocesses next run, its record not yet updated – but it must be
549
631
  // surfaced: a store that fails every write silently disables
550
632
  // skip-unchanged.
551
- this.reporter?.stageFailed?.('provenance', error instanceof Error ? error : new Error(String(error)));
633
+ this.reportStageFailed(runId, dataset, 'provenance', error);
552
634
  }
553
635
  }
554
- async reportValidators(dataset) {
636
+ async reportValidators(runId, dataset) {
555
637
  const validators = new Set();
556
638
  for (const stage of this.collectStages(this.stages)) {
557
639
  if (stage.validator)
@@ -559,7 +641,7 @@ export class Pipeline {
559
641
  }
560
642
  for (const validator of validators) {
561
643
  const report = await validator.report(dataset);
562
- this.reporter?.datasetValidated?.(dataset, report);
644
+ this.report(runId, { type: 'datasetValidated', dataset, report });
563
645
  }
564
646
  }
565
647
  // Walks every stage and sub-stage to collect validators; it reads only
@@ -594,24 +676,36 @@ export class Pipeline {
594
676
  * yielded no triples); native SPARQL endpoints are not imported and carry no
595
677
  * deep verdict.
596
678
  */
597
- reportSelectedDistribution(dataset, resolved, fingerprint) {
598
- this.reporter?.distributionSelected?.(dataset, resolved.distribution, resolved.imported);
679
+ reportSelectedDistribution(runId, dataset, resolved, fingerprint) {
680
+ this.report(runId, {
681
+ type: 'distributionSelected',
682
+ dataset,
683
+ distribution: resolved.distribution,
684
+ imported: resolved.imported,
685
+ });
599
686
  if (resolved.imported) {
600
- this.reporter?.distributionValidated?.(resolved.imported.from, importOutcomeToVerdict({
601
- type: 'successful',
687
+ this.report(runId, {
688
+ type: 'distributionValidated',
689
+ dataset,
602
690
  distribution: resolved.imported.from,
603
- tripleCount: resolved.imported.tripleCount,
604
- warnings: [],
605
- }, fingerprint));
691
+ verdict: importOutcomeToVerdict({
692
+ type: 'successful',
693
+ distribution: resolved.imported.from,
694
+ tripleCount: resolved.imported.tripleCount,
695
+ warnings: [],
696
+ }, fingerprint),
697
+ });
606
698
  }
607
699
  }
608
700
  /**
609
701
  * Run every top-level stage against one distribution, catching and reporting
610
702
  * per-stage failures so one failing stage does not abort the rest. Returns
611
703
  * whether any stage hard-failed – the signal the reactive dump fallback
612
- * reacts to.
704
+ * reacts to – and what the stages could not read, which belongs to this
705
+ * attempt alone.
613
706
  */
614
707
  async runStages(dataset, distribution, timeout, runWriter, context) {
708
+ const unread = new UnreadValueTally();
615
709
  let stageFailed = false;
616
710
  for (const stage of this.stages) {
617
711
  try {
@@ -619,22 +713,22 @@ export class Pipeline {
619
713
  await this.runChain(dataset, distribution, stage, runWriter, context, timeout);
620
714
  }
621
715
  else {
622
- await this.runStage(dataset, distribution, stage, this.stageWriter(runWriter, stage.name), timeout);
716
+ await this.runStage(context.runId, dataset, distribution, stage, this.stageWriter(runWriter, stage.name), timeout, unread);
623
717
  }
624
718
  }
625
719
  catch (error) {
626
720
  stageFailed = true;
627
- this.reporter?.stageFailed?.(stage.name, error instanceof Error ? error : new Error(String(error)));
721
+ this.reportStageFailed(context.runId, dataset, stage.name, error);
628
722
  }
629
723
  }
630
- return stageFailed;
724
+ return { stageFailed, unread };
631
725
  }
632
726
  /**
633
727
  * Run a stage with reporting and return whether it was supported.
634
728
  * Returns `true` if the stage produced results, `false` if NotSupported.
635
729
  */
636
- async runStage(dataset, distribution, stage, writer, timeout) {
637
- this.reporter?.stageStart?.(stage.name);
730
+ async runStage(runId, dataset, distribution, stage, writer, timeout, unread) {
731
+ this.report(runId, { type: 'stageStart', dataset, stage: stage.name });
638
732
  const stageStart = Date.now();
639
733
  let itemsProcessed = 0;
640
734
  let quadsGenerated = 0;
@@ -643,7 +737,10 @@ export class Pipeline {
643
737
  itemsProcessed = items;
644
738
  quadsGenerated = quads;
645
739
  const stageMemory = process.memoryUsage();
646
- this.reporter?.stageProgress?.({
740
+ this.report(runId, {
741
+ type: 'stageProgress',
742
+ dataset,
743
+ stage: stage.name,
647
744
  itemsProcessed,
648
745
  quadsGenerated,
649
746
  memoryUsageBytes: stageMemory.rss,
@@ -651,21 +748,35 @@ export class Pipeline {
651
748
  });
652
749
  },
653
750
  timeout,
751
+ valueUnread: unread && ((target, field, value) => unread.add(target, field, value)),
654
752
  });
655
753
  if (result instanceof NotSupported) {
656
- this.reporter?.stageSkipped?.(stage.name, result.message);
754
+ this.report(runId, {
755
+ type: 'stageSkipped',
756
+ dataset,
757
+ stage: stage.name,
758
+ reason: {
759
+ code: result instanceof NoItemsSelected
760
+ ? 'noItemsSelected'
761
+ : 'notSupported',
762
+ message: result.message,
763
+ },
764
+ });
657
765
  return false;
658
766
  }
659
- this.reporter?.stageComplete?.(stage.name, {
767
+ this.report(runId, {
768
+ type: 'stageComplete',
769
+ dataset,
770
+ stage: stage.name,
660
771
  itemsProcessed,
661
772
  quadsGenerated,
662
- duration: Date.now() - stageStart,
773
+ durationMs: Date.now() - stageStart,
663
774
  });
664
775
  return true;
665
776
  }
666
777
  /** Run a stage in chained mode, throwing if the stage is not supported. */
667
- async runChainedStage(dataset, distribution, stage, writer, timeout) {
668
- const supported = await this.runStage(dataset, distribution, stage, writer, timeout);
778
+ async runChainedStage(runId, dataset, distribution, stage, writer, timeout) {
779
+ const supported = await this.runStage(runId, dataset, distribution, stage, writer, timeout);
669
780
  if (!supported) {
670
781
  throw new Error(`Stage '${stage.name}' returned NotSupported in chained mode`);
671
782
  }
@@ -686,7 +797,7 @@ export class Pipeline {
686
797
  });
687
798
  const scratchRun = await scratchWriter.openRun(context);
688
799
  try {
689
- await this.runChainedStage(dataset, stageDistribution, chainedStage, scratchRun, timeout);
800
+ await this.runChainedStage(context.runId, dataset, stageDistribution, chainedStage, scratchRun, timeout);
690
801
  await scratchRun.flush(dataset, 'success');
691
802
  await scratchRun.commit();
692
803
  }
@@ -739,7 +850,6 @@ function mapProbeResult(distribution, result) {
739
850
  const fingerprint = sourceFingerprint(distribution, result);
740
851
  if (result instanceof NetworkError) {
741
852
  return {
742
- distribution,
743
853
  type: 'network-error',
744
854
  available: false,
745
855
  error: result.message,
@@ -748,7 +858,6 @@ function mapProbeResult(distribution, result) {
748
858
  };
749
859
  }
750
860
  return {
751
- distribution,
752
861
  type: result instanceof SparqlProbeResult
753
862
  ? 'sparql'
754
863
  : 'data-dump',