Ctrl K
ComunidadGuías…

lib/durable-scheduler.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
var crypto = require("crypto");
var path = require("path");
var attachStore = require("./durable-scheduler-store").attachDurableSchedulerStore;
var validateSavedJobs = require("./durable-scheduler-record").validateSavedJobs;

function copy(value) {
  if (value === undefined) return undefined;
  return JSON.parse(JSON.stringify(value));
}

function requiredString(value, name) {
  if (typeof value !== "string" || !value.trim()) throw new Error(name + " is required");
  if (value.length > 256) throw new Error(name + " is too long");
  return value;
}

function safeValue(value, name, maxBytes) {
  var cloned;
  var serialized;
  try {
    cloned = copy(value);
    serialized = JSON.stringify(cloned);
  } catch (error) {
    throw new Error(name + " must be JSON serializable");
  }
  if (Buffer.byteLength(serialized || "", "utf8") > maxBytes) throw new Error(name + " is too large");
  return cloned;
}

function attachDurableScheduler(ctx) {
  var namespace = requiredString(ctx.namespace, "namespace");
  var now = ctx.now || Date.now;
  var makeId = ctx.makeId || function () { return crypto.randomUUID(); };
  var setTimer = ctx.setTimer || setTimeout;
  var clearTimer = ctx.clearTimer || clearTimeout;
  var maxTimerDelay = Math.min(Math.max(ctx.maxTimerDelay || 30000, 10), 2147483647);
  var globalConcurrency = Math.max(1, ctx.globalConcurrency || 4);
  var perOwnerConcurrency = Math.max(1, ctx.perOwnerConcurrency || 1);
  var store = ctx.store || attachStore({
    filePath: ctx.filePath || path.join(ctx.storageDir, "scheduler-" + namespace + ".json"),
    namespace: namespace,
  });
  var handlers = Object.create(null);
  var eligibility = Object.create(null);
  var active = Object.create(null);
  var timer = null;
  var started = false;
  var closed = false;
  var unhealthyError = null;
  var ownerCursor = 0;
  var data;

  function persist(nextData) {
    if (unhealthyError) throw unhealthyError;
    try {
      store.save(nextData);
    } catch (error) {
      unhealthyError = error;
      stopTimer();
      throw error;
    }
    data = nextData;
  }

  function recover() {
    try {
      data = store.load();
      validateSavedJobs(data.jobs);
    } catch (error) {
      store.close();
      var loadError = new Error("Scheduler store contains invalid durable data: " + error.message);
      loadError.code = error.code || "SCHEDULER_STORE_CORRUPT";
      loadError.cause = error;
      throw loadError;
    }
    var changed = false;
    var recoveredAt = now();
    for (var i = 0; i < data.jobs.length; i++) {
      var job = data.jobs[i];
      if (job.state === "claimed" || job.state === "running") {
        job.state = "interrupted";
        job.updatedAt = recoveredAt;
        job.execution = job.execution || {};
        job.execution.interruptedAt = recoveredAt;
        job.execution.interruptionReason = "server_restarted_after_claim";
        changed = true;
      }
    }
    if (changed) persist(data);
  }

  function ensureReady() {
    if (closed) throw new Error("Scheduler is shut down");
    if (unhealthyError) throw unhealthyError;
  }

  function findJob(id) {
    for (var i = 0; i < data.jobs.length; i++) if (data.jobs[i].id === id) return data.jobs[i];
    return null;
  }

  function findIdempotency(key, exceptId) {
    for (var i = 0; i < data.jobs.length; i++) {
      if (data.jobs[i].id !== exceptId && data.jobs[i].idempotencyKey === key) return data.jobs[i];
    }
    return null;
  }

  function validateSpec(spec) {
    var runAt = Number(spec.runAt);
    if (!isFinite(runAt) || runAt < 0) throw new Error("runAt must be a non-negative timestamp");
    var target = safeValue(spec.target, "target", 4096);
    if (!target || typeof target !== "object" || Array.isArray(target)) throw new Error("target is required");
    requiredString(target.kind, "target.kind");
    requiredString(target.id, "target.id");
    return {
      type: requiredString(spec.type, "type"),
      idempotencyKey: requiredString(spec.idempotencyKey, "idempotencyKey"),
      ownerId: requiredString(spec.ownerId, "ownerId"),
      projectId: requiredString(spec.projectId, "projectId"),
      target: target,
      payload: safeValue(spec.payload === undefined ? null : spec.payload, "payload", 256 * 1024),
      runAt: runAt,
    };
  }

  function enqueue(spec) {
    ensureReady();
    var valid = validateSpec(spec || {});
    var existing = findIdempotency(valid.idempotencyKey);
    if (existing) {
      var same = existing.type === valid.type && existing.ownerId === valid.ownerId &&
        existing.projectId === valid.projectId && existing.runAt === valid.runAt &&
        JSON.stringify(existing.target) === JSON.stringify(valid.target) &&
        JSON.stringify(existing.payload) === JSON.stringify(valid.payload);
      if (!same) throw new Error("idempotencyKey was already used with different job data");
      return copy(existing);
    }
    var createdAt = now();
    var job = {
      id: spec.id ? requiredString(spec.id, "id") : makeId(),
      idempotencyKey: valid.idempotencyKey,
      type: valid.type,
      ownerId: valid.ownerId,
      projectId: valid.projectId,
      target: valid.target,
      payload: valid.payload,
      runAt: valid.runAt,
      state: "queued",
      createdAt: createdAt,
      updatedAt: createdAt,
      execution: null,
    };
    if (findJob(job.id)) throw new Error("job id already exists");
    var next = copy(data);
    next.jobs.push(job);
    persist(next);
    scheduleTimer();
    return copy(job);
  }

  function cancel(id, reason) {
    ensureReady();
    var current = findJob(id);
    if (!current) return null;
    if (current.state !== "queued") throw new Error("Only queued jobs can be cancelled");
    var next = copy(data);
    var job = null;
    for (var i = 0; i < next.jobs.length; i++) if (next.jobs[i].id === id) job = next.jobs[i];
    job.state = "cancelled";
    job.updatedAt = now();
    job.cancelledReason = typeof reason === "string" ? reason.slice(0, 1024) : null;
    persist(next);
    scheduleTimer();
    return copy(job);
  }

  function replaceQueued(id, spec) {
    ensureReady();
    var current = findJob(id);
    if (!current) return null;
    if (current.state !== "queued") throw new Error("Only queued jobs can be replaced");
    var merged = {
      type: spec.type === undefined ? current.type : spec.type,
      idempotencyKey: spec.idempotencyKey === undefined ? current.idempotencyKey : spec.idempotencyKey,
      ownerId: spec.ownerId === undefined ? current.ownerId : spec.ownerId,
      projectId: spec.projectId === undefined ? current.projectId : spec.projectId,
      target: spec.target === undefined ? current.target : spec.target,
      payload: spec.payload === undefined ? current.payload : spec.payload,
      runAt: spec.runAt === undefined ? current.runAt : spec.runAt,
    };
    var valid = validateSpec(merged);
    if (findIdempotency(valid.idempotencyKey, id)) throw new Error("idempotencyKey already exists");
    var next = copy(data);
    var job = null;
    for (var i = 0; i < next.jobs.length; i++) if (next.jobs[i].id === id) job = next.jobs[i];
    job.type = valid.type;
    job.idempotencyKey = valid.idempotencyKey;
    job.ownerId = valid.ownerId;
    job.projectId = valid.projectId;
    job.target = valid.target;
    job.payload = valid.payload;
    job.runAt = valid.runAt;
    job.updatedAt = now();
    persist(next);
    scheduleTimer();
    return copy(job);
  }

  function activeOwnerCount(ownerId) {
    var ids = Object.keys(active);
    var count = 0;
    for (var i = 0; i < ids.length; i++) if (active[ids[i]].ownerId === ownerId) count++;
    return count;
  }

  function isEligible(job) {
    if (!eligibility[job.type]) return true;
    try { return eligibility[job.type](copy(job)) === true; } catch (error) { return false; }
  }

  function dueByFairOwner(timestamp) {
    var byOwner = Object.create(null);
    var owners = [];
    for (var i = 0; i < data.jobs.length; i++) {
      var job = data.jobs[i];
      if (job.state !== "queued" || job.runAt > timestamp || !handlers[job.type]) continue;
      if (!isEligible(job)) continue;
      if (!byOwner[job.ownerId]) {
        byOwner[job.ownerId] = [];
        owners.push(job.ownerId);
      }
      byOwner[job.ownerId].push(job);
    }
    for (var ownerName in byOwner) {
      byOwner[ownerName].sort(function (left, right) {
        return left.runAt - right.runAt || left.createdAt - right.createdAt || left.id.localeCompare(right.id);
      });
    }
    owners.sort();
    if (!owners.length) return [];
    var offset = ownerCursor % owners.length;
    var ordered = owners.slice(offset).concat(owners.slice(0, offset));
    ownerCursor = (offset + 1) % owners.length;
    var result = [];
    var selectedByOwner = Object.create(null);
    var available = globalConcurrency - Object.keys(active).length;
    while (available > 0) {
      var progressed = false;
      for (var oi = 0; oi < ordered.length && available > 0; oi++) {
        var owner = ordered[oi];
        if (activeOwnerCount(owner) + (selectedByOwner[owner] || 0) >= perOwnerConcurrency) continue;
        if (!byOwner[owner].length) continue;
        result.push(byOwner[owner].shift());
        selectedByOwner[owner] = (selectedByOwner[owner] || 0) + 1;
        available--;
        progressed = true;
      }
      if (!progressed) break;
    }
    return result;
  }

  function updateExecution(id, claimId, mutate) {
    ensureReady();
    var current = findJob(id);
    if (!current || !current.execution || current.execution.claimId !== claimId || current.state !== "running") {
      throw new Error("Job execution is no longer active");
    }
    var next = copy(data);
    var job = null;
    for (var i = 0; i < next.jobs.length; i++) if (next.jobs[i].id === id) job = next.jobs[i];
    mutate(job);
    job.updatedAt = now();
    persist(next);
    return copy(job.execution);
  }

  function settle(id, claimId, state, result) {
    if (closed || unhealthyError) return;
    var current = findJob(id);
    if (!current || !current.execution || current.execution.claimId !== claimId || current.state !== "running") return;
    var next = copy(data);
    var job = null;
    for (var i = 0; i < next.jobs.length; i++) if (next.jobs[i].id === id) job = next.jobs[i];
    job.state = state;
    job.updatedAt = now();
    if (state === "completed") {
      job.execution.completedAt = job.updatedAt;
      if (result !== undefined) {
        try {
          job.execution.result = safeValue(result, "handler result", 64 * 1024);
        } catch (error) {
          job.state = "failed";
          delete job.execution.completedAt;
          job.execution.failedAt = job.updatedAt;
          job.execution.error = error.message;
        }
      }
    } else {
      job.execution.failedAt = job.updatedAt;
      job.execution.error = String(result && result.message ? result.message : result).slice(0, 4096);
    }
    try { persist(next); } catch (error) {}
  }

  function requeueUnstarted(id, claimId) {
    if (closed || unhealthyError) return;
    var current = findJob(id);
    if (!current || !current.execution || current.execution.claimId !== claimId || current.state !== "running") return;
    var next = copy(data);
    var job = null;
    for (var i = 0; i < next.jobs.length; i++) if (next.jobs[i].id === id) job = next.jobs[i];
    job.state = "queued";
    job.updatedAt = now();
    job.execution = null;
    persist(next);
  }

  function dispatch(job) {
    if (active[job.id] || unhealthyError || closed) return;
    var handler = handlers[job.type];
    if (!handler) return;
    var claimId = makeId();
    var claimedAt = now();
    var claimed = copy(data);
    var claimedJob = null;
    for (var i = 0; i < claimed.jobs.length; i++) if (claimed.jobs[i].id === job.id) claimedJob = claimed.jobs[i];
    if (!claimedJob || claimedJob.state !== "queued") return;
    claimedJob.state = "claimed";
    claimedJob.updatedAt = claimedAt;
    claimedJob.execution = { claimId: claimId, claimedAt: claimedAt, attempt: 1, metadata: null, receipt: null };
    persist(claimed);

    var running = copy(data);
    var runningJob = null;
    for (var ri = 0; ri < running.jobs.length; ri++) if (running.jobs[ri].id === job.id) runningJob = running.jobs[ri];
    runningJob.state = "running";
    runningJob.updatedAt = now();
    runningJob.execution.startedAt = runningJob.updatedAt;
    persist(running);
    active[job.id] = { ownerId: job.ownerId, claimId: claimId };

    var execution = {
      claimId: claimId,
      recordMetadata: function (metadata) {
        return updateExecution(job.id, claimId, function (record) {
          record.execution.metadata = safeValue(metadata, "execution metadata", 64 * 1024);
        });
      },
      recordReceipt: function (receipt) {
        return updateExecution(job.id, claimId, function (record) {
          record.execution.receipt = safeValue(receipt, "execution receipt", 64 * 1024);
        });
      },
    };
    Promise.resolve().then(function () {
      if (closed || unhealthyError || !active[job.id]) return undefined;
      if (handlers[job.type] !== handler) {
        requeueUnstarted(job.id, claimId);
        return undefined;
      }
      if (!isEligible(runningJob)) {
        requeueUnstarted(job.id, claimId);
        return undefined;
      }
      return handler(copy(runningJob), execution);
    }).then(function (result) {
      settle(job.id, claimId, "completed", result);
    }, function (error) {
      settle(job.id, claimId, "failed", error);
    }).finally(function () {
      delete active[job.id];
      scheduleTimer();
      if (!closed && !unhealthyError) {
        try { tick(); } catch (error) {}
      }
    }).catch(function () {});
  }

  function tick() {
    ensureReady();
    var due = dueByFairOwner(now());
    for (var i = 0; i < due.length; i++) {
      dispatch(due[i]);
    }
    scheduleTimer();
    return due.length;
  }

  function stopTimer() {
    if (timer !== null) {
      clearTimer(timer);
      timer = null;
    }
  }

  function scheduleTimer() {
    stopTimer();
    if (!started || closed || unhealthyError) return;
    var timestamp = now();
    var earliest = null;
    var activeCount = Object.keys(active).length;
    if (activeCount < globalConcurrency) {
      for (var i = 0; i < data.jobs.length; i++) {
        var job = data.jobs[i];
        if (job.state !== "queued" || !handlers[job.type]) continue;
        if (!isEligible(job)) continue;
        if (activeOwnerCount(job.ownerId) >= perOwnerConcurrency) continue;
        if (earliest === null || job.runAt < earliest) earliest = job.runAt;
      }
    }
    var delay = maxTimerDelay;
    if (earliest !== null) delay = Math.min(maxTimerDelay, Math.max(0, earliest - timestamp));
    timer = setTimer(function () {
      timer = null;
      try { tick(); } catch (error) {}
    }, delay);
    if (timer && typeof timer.unref === "function") timer.unref();
  }

  function registerHandler(type, handler, options) {
    ensureReady();
    requiredString(type, "handler type");
    if (typeof handler !== "function") throw new Error("handler must be a function");
    if (options && options.canRun !== undefined && typeof options.canRun !== "function") throw new Error("canRun must be a function");
    if (handlers[type]) throw new Error("handler already registered for " + type);
    handlers[type] = handler;
    if (options && options.canRun) eligibility[type] = options.canRun;
    if (started) tick();
    return function () {
      if (handlers[type] !== handler) return;
      delete handlers[type];
      delete eligibility[type];
      scheduleTimer();
    };
  }

  function start() {
    ensureReady();
    if (started) return false;
    started = true;
    tick();
    return true;
  }

  function shutdown() {
    if (closed) return Promise.resolve();
    closed = true;
    started = false;
    stopTimer();
    var ids = Object.keys(active);
    if (ids.length && !unhealthyError) {
      var next = copy(data);
      var stoppedAt = now();
      for (var i = 0; i < next.jobs.length; i++) {
        if (!active[next.jobs[i].id] || next.jobs[i].state !== "running") continue;
        next.jobs[i].state = "interrupted";
        next.jobs[i].updatedAt = stoppedAt;
        next.jobs[i].execution.interruptedAt = stoppedAt;
        next.jobs[i].execution.interruptionReason = "scheduler_shutdown";
      }
      try { store.save(next); data = next; } catch (error) { unhealthyError = error; }
    }
    store.close();
    return Promise.resolve();
  }

  function getJob(id) { var job = findJob(id); return job ? copy(job) : null; }
  function listJobs() { return copy(data.jobs); }
  function getReceipt(id) { var job = findJob(id); return job && job.execution ? copy(job.execution.receipt) : null; }
  function status() { return { started: started, closed: closed, healthy: !unhealthyError, error: unhealthyError ? unhealthyError.message : null, active: Object.keys(active).length }; }

  try {
    recover();
  } catch (error) {
    store.close();
    throw error;
  }
  return {
    start: start,
    shutdown: shutdown,
    registerHandler: registerHandler,
    enqueue: enqueue,
    cancel: cancel,
    replaceQueued: replaceQueued,
    tick: tick,
    getJob: getJob,
    listJobs: listJobs,
    getReceipt: getReceipt,
    status: status,
  };
}

module.exports = { attachDurableScheduler: attachDurableScheduler };