All files / services/crawler-service-worker/src/workflows job-polling.js

80.47% Statements 169/210
36% Branches 18/50
80% Functions 4/5
80.47% Lines 169/210

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 2111x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 12x 12x 12x 6x 6x 12x     12x     12x     12x     12x 6x 6x 12x     12x 12x 1x 3x 3x 3x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                                                 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 6x 6x 6x 6x 6x 6x 6x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x     6x 6x 6x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x       3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x     3x 3x 3x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x  
/**
 * Job polling — logique PURE et déterministe utilisée par le workflow CrawlerTW.
 *
 * Objectif (résilience au redéploiement) : l'attente d'un job d'extraction/synthèse
 * n'est plus faite DANS une activité longue (SSE bloquant jusqu'à 60 min, tué au
 * redéploiement sans heartbeat) mais par une **boucle d'attente dans le workflow**,
 * qui est durable : après un redémarrage, Temporal rejoue la boucle au dernier poll.
 *
 * Ce module ne fait AUCUNE I/O : `pollOnce` et `sleep` sont injectés par le workflow
 * (`sleep` = temporal.sleep, `pollOnce` = proxyActivity `waitFor…`). Il est donc
 * directement testable en Node, sans Temporal.
 *
 * ⚠️ Contraintes Temporal (voir services/workflow-service-worker/README.md) :
 *    - aucune I/O, aucun Date.now()/Math.random() (déterminisme) ;
 *    - variables en `var` (scope hoisting webpack).
 */
 
'use strict';
 
// Statuts considérés comme terminaux (arrêt de la boucle d'attente).
var TERMINAL_STATUSES = [
  'completed',
  'failed',
  'canceled',
  'cancelled',
  'terminated',
  'timed_out',
];
 
// Politique de polling métier (borne globale de l'attente).
var POLL_INTERVAL_MS = 5000; // 5 s entre deux polls
var EXTRACTION_TIMEOUT_MS = 60 * 60 * 1000; // 60 min (extraction web/fichier)
var SYNTHESIS_TIMEOUT_MS = 10 * 60 * 1000; // 10 min (raffinage + dispatch)
var EXTRACTION_MAX_POLLS = Math.ceil(EXTRACTION_TIMEOUT_MS / POLL_INTERVAL_MS); // 720
var SYNTHESIS_MAX_POLLS = Math.ceil(SYNTHESIS_TIMEOUT_MS / POLL_INTERVAL_MS); // 120
 
/**
 * Normalise un statut hétérogène (Temporal `desc.status.name` en MAJUSCULES,
 * job-manager `running`/`completed`/`failed`, `succeeded`, `timedout`…) vers un
 * vocabulaire unique.
 */
function normalizeJobStatus(rawStatus) {
  var s = String(rawStatus == null ? '' : rawStatus).toLowerCase();
  if (s === 'succeeded' || s === 'success' || s === 'completed' || s === 'complete') {
    return 'completed';
  }
  if (s === 'failed' || s === 'failure' || s === 'error') {
    return 'failed';
  }
  if (s === 'canceled' || s === 'cancelled') {
    return 'canceled';
  }
  if (s === 'terminated') {
    return 'terminated';
  }
  if (s === 'timed_out' || s === 'timedout' || s === 'timeout') {
    return 'timed_out';
  }
  if (s === 'running' || s === 'pending' || s === 'started' || s === 'open') {
    return 'running';
  }
  if (s === 'not_found' || s === 'notfound' || s === 'unknown') {
    return s;
  }
  return s || 'unknown';
}
 
function isTerminalStatus(status) {
  return TERMINAL_STATUSES.indexOf(normalizeJobStatus(status)) !== -1;
}
 
/**
 * Boucle d'attente durable.
 *
 * @param {Object} options
 * @param {Function} options.pollOnce  async () => { status, ... } — un seul poll
 *                                     (déjà un proxyActivity côté workflow).
 * @param {Function} options.sleep     async (ms) => void — temporal.sleep.
 * @param {number}   options.intervalMs intervalle entre polls.
 * @param {number}   options.maxPolls   borne globale (timeout métier).
 * @param {Function} [options.isTerminal] prédicat d'arrêt (défaut : statut terminal).
 * @returns {Promise<{result: Object|null, polls: number, timedOut: boolean}>}
 */
async function pollUntilTerminal(options) {
  var opts = options || {};
  var pollOnce = opts.pollOnce;
  var sleep = opts.sleep;
  var isTerminal = opts.isTerminal || (function (r) { return isTerminalStatus(r && r.status); });
  var intervalMs = typeof opts.intervalMs === 'number' ? opts.intervalMs : POLL_INTERVAL_MS;
  var maxPolls = typeof opts.maxPolls === 'number' ? opts.maxPolls : EXTRACTION_MAX_POLLS;

  var polls = 0;
  var last = null;

  while (polls < maxPolls) {
    polls += 1;
    last = await pollOnce();
    if (isTerminal(last)) {
      return { result: last, polls: polls, timedOut: false };
    }
    if (polls < maxPolls) {
      await sleep(intervalMs);
    }
  }

  return { result: last, polls: polls, timedOut: true };
}
 
/**
 * Construit le retour NORMALISÉ de `waitForExtractionJob` à partir de la réponse
 * brute du job-manager (`GET /jobs/:jobId/results`).
 *
 * Retour : `{ status, success, url, outputs, contentUrl?, title?, metadata?, error? }`.
 * - `completed` → `success: true`, `contentUrl` réécrite (localhost:9090 → MinIO interne) ;
 * - statut terminal non-complet (`failed`/`canceled`/`terminated`/`timed_out`) → `success: false` + `error` ;
 * - statut non terminal (`running`/`not_found`/`unknown`) → `success: false`, pas de sortie.
 */
function formatExtractionJobResult(raw, options) {
  var opts = options || {};
  var data = raw || {};
  var status = normalizeJobStatus(data.status);
  var result = { status: status, success: false, url: opts.url || data.url || null, outputs: [] };
 
  if (status === 'completed') {
    var outputs = Array.isArray(data.outputs) ? data.outputs : [];
    var minioUrl = opts.minioUrl || 'http://minio:9000';
    var contentUrl = null;
    var out =
      outputs.find(function (o) {
        return o && typeof o.outputPath === 'string' && /\.(md|html)$/.test(o.outputPath);
      }) || outputs[0];
    if (out && out.url) {
      contentUrl = String(out.url).replace('http://localhost:9090', minioUrl);
    }
    result.success = true;
    result.outputs = outputs;
    result.contentUrl = contentUrl;
    result.title = data.title || '';
    result.metadata = data.metadata || {};
  } else if (isTerminalStatus(status)) {
    result.error = data.error || 'extraction_' + status;
  }
 
  return result;
}
 
/**
 * Construit le retour NORMALISÉ de `waitForSynthesisJob` (synthèse + dispatch).
 */
function formatSynthesisJobResult(raw, options) {
  var opts = options || {};
  var data = raw || {};
  var status = normalizeJobStatus(data.status);
  var result = {
    status: status,
    success: false,
    workflowJobId: opts.jobId || data.jobId || null,
    url: opts.url || data.url || null,
    assertionsCount: 0,
    dispatchedTo: [],
    destinationsUsed: false,
    hasAssertions: false,
  };
 
  if (status === 'completed') {
    var outputs = Array.isArray(data.outputs) ? data.outputs : [];
    var assertionsOutput =
      outputs.find(function (o) { return o && (o.type === 'assertions' || o.type === 'facts'); }) || null;
    var dispatchOutputs = outputs.filter(function (o) { return o && o.type === 'dispatch'; });
    var dispatchedTo =
      dispatchOutputs.length > 0
        ? dispatchOutputs.reduce(function (acc, o) {
            var dests = (o.metadata && o.metadata.destinations) || [];
            return acc.concat(dests);
          }, [])
        : (data.metadata && data.metadata.dispatchedTo) || data.dispatchedTo || [];
    var destinationsUsed =
      dispatchedTo.length > 0 ||
      (dispatchOutputs.length > 0 &&
        dispatchOutputs.some(function (o) { return (o.metadata && o.metadata.dispatched) > 0; }));
 
    result.success = true;
    result.outputs = outputs;
    result.assertionsCount =
      (assertionsOutput && assertionsOutput.metadata && assertionsOutput.metadata.count) ||
      (data.metadata && (data.metadata.count || data.metadata.factsCount)) ||
      0;
    result.dispatchedTo = dispatchedTo;
    result.destinationsUsed = destinationsUsed;
    result.hasAssertions = result.assertionsCount > 0;
  } else if (isTerminalStatus(status)) {
    result.error = data.error || 'synthesis_' + status;
  }
 
  return result;
}
 
module.exports = {
  TERMINAL_STATUSES,
  POLL_INTERVAL_MS,
  EXTRACTION_TIMEOUT_MS,
  SYNTHESIS_TIMEOUT_MS,
  EXTRACTION_MAX_POLLS,
  SYNTHESIS_MAX_POLLS,
  normalizeJobStatus,
  isTerminalStatus,
  pollUntilTerminal,
  formatExtractionJobResult,
  formatSynthesisJobResult,
};