import { resolver } from "@rocicorp/resolver";
import { getDefaultHighWaterMark } from "node:stream";
import { unreachable } from "../../../../shared/src/asserts.js";
import { getOrCreateCounter } from "../../observability/metrics.js";
import { min } from "../../types/lexi-version.js";
import { Subscription } from "../../types/subscription.js";
import "../change-source/protocol/current/downstream.js";
import { publishReplicationError } from "../replicator/replication-status.js";
import { RunningState, UnrecoverableError, DEFAULT_MAX_RETRY_DELAY_MS } from "../running-state.js";
import "./change-streamer.js";
import { WrongReplicaVersion } from "./error-type-enum.js";
import { Forwarder } from "./forwarder.js";
import { initChangeStreamerSchema } from "./schema/init.js";
import { ensureReplicationConfig, markResetRequired, AutoResetSignal } from "./schema/tables.js";
import { Storer } from "./storer.js";
import { Subscriber } from "./subscriber.js";
async function initializeStreamer(lc, shard, taskID, discoveryAddress, discoveryProtocol, changeDB, changeSource, subscriptionState, autoReset, backPressureLimitHeapProportion, setTimeoutFn = setTimeout) {
  await initChangeStreamerSchema(lc, changeDB, shard);
  await ensureReplicationConfig(
    lc,
    changeDB,
    subscriptionState,
    shard,
    autoReset
  );
  const { replicaVersion } = subscriptionState;
  return new ChangeStreamerImpl(
    lc,
    shard,
    taskID,
    discoveryAddress,
    discoveryProtocol,
    changeDB,
    replicaVersion,
    changeSource,
    autoReset,
    backPressureLimitHeapProportion,
    setTimeoutFn
  );
}
class ChangeStreamerImpl {
  id;
  #lc;
  #shard;
  #changeDB;
  #replicaVersion;
  #source;
  #storer;
  #forwarder;
  #autoReset;
  #state;
  #initialWatermarks = /* @__PURE__ */ new Set();
  // Starting the (Postgres) ChangeStream results in killing the previous
  // Postgres subscriber, potentially creating a gap in which the old
  // change-streamer has shut down and the new change-streamer has not yet
  // been recognized as "healthy" (and thus does not get any requests).
  //
  // To minimize this gap, delay starting the ChangeStream until the first
  // request from a `serving` replicator, indicating that higher level
  // load-balancing / routing logic has begun routing requests to this task.
  #serving = resolver();
  #txCounter = getOrCreateCounter(
    "replication",
    "transactions",
    "Count of replicated transactions"
  );
  #stream;
  constructor(lc, shard, taskID, discoveryAddress, discoveryProtocol, changeDB, replicaVersion, source, autoReset, backPressureLimitHeapProportion, setTimeoutFn = setTimeout) {
    this.id = `change-streamer`;
    this.#lc = lc.withContext("component", "change-streamer");
    this.#shard = shard;
    this.#changeDB = changeDB;
    this.#replicaVersion = replicaVersion;
    this.#source = source;
    this.#storer = new Storer(
      lc,
      shard,
      taskID,
      discoveryAddress,
      discoveryProtocol,
      changeDB,
      replicaVersion,
      (consumed) => this.#stream?.acks.push(["status", consumed[1], consumed[2]]),
      (err) => this.stop(err),
      backPressureLimitHeapProportion
    );
    this.#forwarder = new Forwarder();
    this.#autoReset = autoReset;
    this.#state = new RunningState(this.id, void 0, setTimeoutFn);
  }
  async run() {
    this.#lc.info?.("starting change stream");
    await this.#storer.assumeOwnership();
    const flushBytesThreshold = getDefaultHighWaterMark(false);
    while (this.#state.shouldRun()) {
      let err;
      let watermark = null;
      let unflushedBytes = 0;
      try {
        const { lastWatermark, backfillRequests } = await this.#storer.getStartStreamInitializationParameters();
        const stream = await this.#source.startStream(
          lastWatermark,
          backfillRequests
        );
        this.#storer.run().catch((e) => stream.changes.cancel(e));
        this.#stream = stream;
        this.#state.resetBackoff();
        watermark = null;
        for await (const change of stream.changes) {
          const [type, msg] = change;
          switch (type) {
            case "status":
              if (msg.ack) {
                this.#storer.status(change);
              }
              continue;
            case "control":
              await this.#handleControlMessage(msg);
              continue;
            // control messages are not stored/forwarded
            case "begin":
              watermark = change[2].commitWatermark;
              break;
            case "commit":
              if (watermark !== change[2].watermark) {
                throw new UnrecoverableError(
                  `commit watermark ${change[2].watermark} does not match 'begin' watermark ${watermark}`
                );
              }
              this.#txCounter.add(1);
              break;
            default:
              if (watermark === null) {
                throw new UnrecoverableError(
                  `${type} change (${msg.tag}) received before 'begin' message`
                );
              }
              break;
          }
          unflushedBytes += this.#storer.store([watermark, change]);
          const sent = this.#forwarder.forward([watermark, change]);
          if (unflushedBytes >= flushBytesThreshold) {
            await sent;
            unflushedBytes = 0;
          }
          if (type === "commit" || type === "rollback") {
            watermark = null;
          }
          const readyForMore = this.#storer.readyForMore();
          if (readyForMore) {
            await readyForMore;
          }
        }
      } catch (e) {
        err = e;
      } finally {
        this.#stream?.changes.cancel();
        this.#stream = void 0;
      }
      if (watermark) {
        this.#lc.warn?.(`aborting interrupted transaction ${watermark}`);
        this.#storer.abort();
        await this.#forwarder.forward([
          watermark,
          ["rollback", { tag: "rollback" }]
        ]);
      }
      await Promise.all([
        this.#storer.stop(),
        this.#state.backoff(this.#lc, err)
      ]);
    }
    this.#lc.info?.("ChangeStreamer stopped");
  }
  async #handleControlMessage(msg) {
    this.#lc.info?.("received control message", msg);
    const { tag } = msg;
    switch (tag) {
      case "reset-required":
        await markResetRequired(this.#changeDB, this.#shard);
        await publishReplicationError(
          this.#lc,
          "Replicating",
          msg.message ?? "Resync required",
          msg.errorDetails
        );
        if (this.#autoReset) {
          this.#lc.warn?.("shutting down for auto-reset");
          await this.stop(new AutoResetSignal());
        }
        break;
      default:
        unreachable();
    }
  }
  subscribe(ctx) {
    const { protocolVersion, id, mode, replicaVersion, watermark, initial } = ctx;
    if (mode === "serving") {
      this.#serving.resolve();
    }
    const downstream = Subscription.create({
      cleanup: () => this.#forwarder.remove(subscriber)
    });
    const subscriber = new Subscriber(
      protocolVersion,
      id,
      watermark,
      downstream
    );
    if (replicaVersion !== this.#replicaVersion) {
      this.#lc.warn?.(
        `rejecting subscriber at replica version ${replicaVersion}`
      );
      subscriber.close(
        WrongReplicaVersion,
        `current replica version is ${this.#replicaVersion} (requested ${replicaVersion})`
      );
    } else {
      this.#lc.debug?.(`adding subscriber ${subscriber.id}`);
      this.#forwarder.add(subscriber);
      this.#storer.catchup(subscriber, mode);
      if (initial) {
        this.scheduleCleanup(watermark);
      }
    }
    return Promise.resolve(downstream);
  }
  scheduleCleanup(watermark) {
    const origSize = this.#initialWatermarks.size;
    this.#initialWatermarks.add(watermark);
    if (origSize === 0) {
      this.#state.setTimeout(() => this.#purgeOldChanges(), CLEANUP_DELAY_MS);
    }
  }
  async getChangeLogState() {
    const minWatermark = await this.#storer.getMinWatermarkForCatchup();
    if (!minWatermark) {
      this.#lc.warn?.(
        `Unexpected empty changeLog. Resync if "Local replica watermark" errors arise`
      );
    }
    return {
      replicaVersion: this.#replicaVersion,
      minWatermark: minWatermark ?? this.#replicaVersion
    };
  }
  /**
   * Makes a best effort to purge the change log. In the event of a database
   * error, exceptions will be logged and swallowed, so this method is safe
   * to run in a timeout.
   */
  async #purgeOldChanges() {
    const initial = [...this.#initialWatermarks];
    if (initial.length === 0) {
      this.#lc.warn?.("No initial watermarks to check for cleanup");
      return;
    }
    const current = [...this.#forwarder.getAcks()];
    if (current.length === 0) {
      this.#lc.warn?.("No subscribers to confirm cleanup");
      return;
    }
    try {
      const earliestInitial = min(...initial);
      const earliestCurrent = min(...current);
      if (earliestCurrent < earliestInitial) {
        this.#lc.info?.(
          `At least one client is behind backup (${earliestCurrent} < ${earliestInitial})`
        );
      } else {
        this.#lc.info?.(`Purging changes before ${earliestInitial} ...`);
        const start = performance.now();
        const deleted = await this.#storer.purgeRecordsBefore(earliestInitial);
        const elapsed = (performance.now() - start).toFixed(2);
        this.#lc.info?.(
          `Purged ${deleted} changes before ${earliestInitial} (${elapsed} ms)`
        );
        this.#initialWatermarks.delete(earliestInitial);
      }
    } catch (e) {
      this.#lc.warn?.(`error purging change log`, e);
    } finally {
      if (this.#initialWatermarks.size) {
        this.#state.setTimeout(() => this.#purgeOldChanges(), CLEANUP_DELAY_MS);
      }
    }
  }
  async stop(err) {
    this.#state.stop(this.#lc, err);
    this.#stream?.changes.cancel();
    await this.#storer.stop();
  }
}
const CLEANUP_DELAY_MS = DEFAULT_MAX_RETRY_DELAY_MS * 3;
export {
  initializeStreamer
};
//# sourceMappingURL=change-streamer-service.js.map
