Ctrl K
ComunidadGuías…

services/retl-runner/src/recovery.ts

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

Archivo original ↗Preguntar a KiBot
import type { JsonObject, ReverseEtlContext, ResumePoint } from "@jitsu/protocols/reverse-etl";
import {
  ensureNoRejection,
  ReverseEtlRejectionError,
  validateBatchResult,
  validateFinishResult,
} from "@jitsu/destination-functions/src/reverse-etl/meta";
import type { openPersistence } from "./persistence";
import { ensure } from "./persistence/types";
import type { RuntimeRecovery } from "./adapters";
import { mirrorDeliveryBatch } from "./mirror";

type Session = Awaited<ReturnType<typeof openPersistence>>;
export type RecoveryReport = { rejectionCode?: string; rejectionDetails?: ReverseEtlRejectionError["details"] };

/** Recover only durable work. Never infer source completion or reopen SQL here. */
export async function recoverRun(
  run: Session,
  ctx: ReverseEtlContext<JsonObject, JsonObject>,
  hooks?: RuntimeRecovery,
  independentBatches = false,
  /** Filled in with the first saved rejection code seen, so the caller can show it (never used to block recovery). */
  report: RecoveryReport = {}
) {
  const status = await run.core.recoveryStatus();
  const store = () => ctx.store.snapshot();
  if (status.phase === "init_prepared") {
    ensure(hooks?.reconcileInit, "Initialization requires provider reconciliation");
    const resolution = await hooks.reconcileInit(ctx);
    await run.core.resetInitAfterReconciliation(resolution, store());
    return "restart" as const;
  }
  if (["finish_prepared", "finish_pending", "finish_resolving", "finish_accepted"].includes(status.phase)) {
    if (["finish_prepared", "finish_pending"].includes(status.phase)) {
      ensure(hooks?.reconcileFinish, "Finalization requires provider reconciliation");
      const result = validateFinishResult(await hooks.reconcileFinish(status.finish, ctx));
      await run.core.acknowledgeRecoveredFinish(result, store());
      if (result.delivery === "pending") return "pending" as const;
    } else if (status.phase === "finish_resolving") {
      await run.core.acknowledgeRecoveredFinish({ delivery: "accepted" }, store());
    }
    const point = await finalPoint(run, status.nextSequence);
    await run.delivery.commitCheckpoint(point, store(), true);
    return "accepted" as const;
  }
  ensure(["running", "batches_pending", "abort_prepared"].includes(status.phase), "Unsupported recovery phase");
  if (["running", "batches_pending"].includes(status.phase)) {
    let after = "";
    let pending = false;
    let rejected = false;
    for (;;) {
      const page = await run.core.recoveryPage(after);
      if (!page.length) break;
      for (const entry of page) {
        ctx.signal.throwIfAborted();
        const saved = await run.core.recoveryBatch(entry.batchId);
        rejected ||= saved.operations.some(op => op.status === "rejected");
        if (rejected && report.rejectionCode === undefined) {
          // A rejection that was acknowledged durably earlier is not raised again (that would make recovery fail every time),
          // but its code is kept so the run that cleans up can still show it. The same pattern check as for a fresh rejection.
          const saved_ = saved.result?.outcomes.find(outcome => outcome.status === "rejected");
          if (saved_?.status === "rejected") {
            const rejection = new ReverseEtlRejectionError(saved_.code, saved_.details);
            report.rejectionCode = rejection.code;
            report.rejectionDetails = rejection.details;
          }
        }
        if (saved.operations.some(op => ["prepared", "unknown", "staged"].includes(op.status))) {
          ensure(hooks?.reconcileBatch, "Batch requires provider reconciliation");
          // Upsert manifests already contain provider input. Mirror partial-source
          // recovery cannot have batches: it never writes before sealing.
          const wire =
            run.scope.mode === "mirror"
              ? mirrorDeliveryBatch(saved.batch)
              : (saved.batch as import("@jitsu/protocols/reverse-etl").WriteBatch<JsonObject>);
          const result = validateBatchResult(
            saved.batch,
            await hooks.reconcileBatch(wire, saved.batch.action, saved.result, ctx)
          );
          await run.core.acknowledgeRecovered(entry.batchId, result, store());
          pending ||= result.outcomes.some(outcome => outcome.status === "staged");
          // Known outcomes are durable before stopping on a permanent row failure.
          ensureNoRejection(result.outcomes);
        }
        after = entry.batchId;
      }
    }
    // Finish-staged sessions may require verified cleanup to resolve incomplete
    // extraction. Independent jobs must settle first; abort cannot cancel them.
    if (pending && (independentBatches || status.phase === "batches_pending")) return "pending" as const;
    if (status.phase === "batches_pending" && !rejected) {
      ensure(hooks?.attachWriter, "Finalization requires provider attachment");
      ctx.signal.throwIfAborted();
      const writer = await hooks.attachWriter(ctx);
      await run.delivery.prepareFinish(status.nextSequence, store());
      ctx.signal.throwIfAborted();
      const result = validateFinishResult(await writer.finish());
      await run.core.acknowledgeRecoveredFinish(result, store());
      if (result.delivery === "pending") return "pending" as const;
      await run.delivery.commitCheckpoint(await finalPoint(run, status.nextSequence), store(), true);
      return "accepted" as const;
    }
    ensure(hooks?.reconcileAbort, "Incomplete extraction requires verified cleanup");
    await run.delivery.prepareAbort();
  }
  ensure(hooks?.reconcileAbort, "Cleanup requires provider reconciliation");
  ctx.signal.throwIfAborted();
  await run.core.recoveryStatus();
  await hooks.reconcileAbort(ctx);
  await run.delivery.acknowledgeAbort();
  return "restart" as const;
}

async function finalPoint(run: Session, sequence: number): Promise<ResumePoint> {
  return run.core.finalPoint(sequence);
}