import { resolver } from "@rocicorp/resolver";
import { CustomKeyMap } from "../../../../shared/src/custom-key-map.js";
import { must } from "../../../../shared/src/must.js";
import { promiseVoid } from "../../../../shared/src/resolved-promises.js";
import { READ_COMMITTED, READONLY } from "../../db/mode-enum.js";
import { runTx } from "../../db/run-transaction.js";
import { TransactionPool } from "../../db/transaction-pool.js";
import { getOrCreateHistogram, getOrCreateCounter } from "../../observability/metrics.js";
import "../../types/pg.js";
import { rowIDString } from "../../types/row-key.js";
import { cvrSchema } from "../../types/shards.js";
import { checkVersion } from "./cvr-store.js";
import { rowsRowToRowRecord, rowRecordToRowsRow } from "./schema/cvr.js";
import { versionString, versionToNullableCookie, cmpVersions } from "./schema/types.js";
const FLUSH_TYPE_ATTRIBUTE = "flush.type";
class RowRecordCache {
  // The state in the #cache is always in sync with the CVR metadata
  // (i.e. cvr.instances). It may contain information that has not yet
  // been flushed to cvr.rows.
  #cache;
  #lc;
  #db;
  #schema;
  #cvrID;
  #failService;
  #deferredRowFlushThreshold;
  #setTimeout;
  // Write-back cache state.
  #pending = new CustomKeyMap(rowIDString);
  #pendingRowsVersion = null;
  #flushedRowsVersion = null;
  #flushing = null;
  #cvrFlushTime = getOrCreateHistogram("sync", "cvr.flush-time", {
    description: "Time to flush a CVR transaction. This includes both synchronous and asynchronous flushes, distinguished by the flush.type attribute",
    unit: "s"
  });
  #cvrRowsFlushed = getOrCreateCounter(
    "sync",
    "cvr.rows-flushed",
    "Number of (changed) rows flushed to a CVR"
  );
  constructor(lc, db, shard, cvrID, failService, deferredRowFlushThreshold = 100, setTimeoutFn = setTimeout) {
    this.#lc = lc;
    this.#db = db;
    this.#schema = cvrSchema(shard);
    this.#cvrID = cvrID;
    this.#failService = failService;
    this.#deferredRowFlushThreshold = deferredRowFlushThreshold;
    this.#setTimeout = setTimeoutFn;
  }
  recordSyncFlushStats(stats, elapsedMs) {
    this.#cvrFlushTime.record(elapsedMs / 1e3, {
      [FLUSH_TYPE_ATTRIBUTE]: "sync"
    });
    if (stats.rowsDeferred === 0) {
      this.#cvrRowsFlushed.add(stats.rows);
    }
  }
  #recordAsyncFlushStats(rows, elapsedMs) {
    this.#cvrFlushTime.record(elapsedMs / 1e3, {
      [FLUSH_TYPE_ATTRIBUTE]: "async"
    });
    this.#cvrRowsFlushed.add(rows);
  }
  #cvr(table) {
    return this.#db(`${this.#schema}.${table}`);
  }
  async #ensureLoaded() {
    if (this.#cache) {
      return this.#cache;
    }
    const start = Date.now();
    const r = resolver();
    r.promise.catch(() => {
    });
    this.#cache = r.promise;
    try {
      const cache = new CustomKeyMap(
        rowIDString
      );
      for await (const rows of this.#db`
        SELECT * FROM ${this.#cvr(`rows`)}
          WHERE "clientGroupID" = ${this.#cvrID} AND "refCounts" IS NOT NULL`.cursor(5e3)) {
        for (const row of rows) {
          const rowRecord = rowsRowToRowRecord(row);
          cache.set(rowRecord.id, rowRecord);
        }
      }
      this.#lc.debug?.(
        `Loaded ${cache.size} row records in ${Date.now() - start} ms`
      );
      r.resolve(cache);
      return this.#cache;
    } catch (e) {
      r.reject(e);
      throw e;
    }
  }
  getRowRecords() {
    return this.#ensureLoaded();
  }
  /**
   * Applies the `rowRecords` corresponding to the `rowsVersion`
   * to the cache, indicating whether the corresponding updates
   * (generated by {@link executeRowUpdates}) were `flushed`.
   *
   * If `flushed` is false, the RowRecordCache will flush the records
   * asynchronously.
   *
   * Note that `apply()` indicates that the CVR metadata associated with
   * the `rowRecords` was successfully committed, which essentially means
   * that this process has the unconditional right (and responsibility) of
   * following up with a flush of the `rowRecords`. In particular, the
   * commit of row records are not conditioned on the version or ownership
   * columns of the `cvr.instances` row.
   */
  async apply(rowRecords, rowsVersion, flushed) {
    const cache = await this.#ensureLoaded();
    for (const [id, row] of rowRecords.entries()) {
      if (row === null || row.refCounts === null) {
        cache.delete(id);
      } else {
        cache.set(id, row);
      }
      if (!flushed) {
        this.#pending.set(id, row);
      }
    }
    this.#pendingRowsVersion = rowsVersion;
    if (!flushed && this.#flushing === null) {
      this.#flushing = resolver();
      this.#flushing.promise.catch(() => {
      });
      this.#setTimeout(() => this.#flush(), 0);
    }
    return cache.size;
  }
  async #flush() {
    const flushing = must(this.#flushing);
    try {
      while (this.#pendingRowsVersion !== this.#flushedRowsVersion) {
        const start = performance.now();
        const { rows, rowsVersion } = await runTx(
          this.#db,
          (tx) => {
            const rows2 = this.#pending.size;
            const rowsVersion2 = must(this.#pendingRowsVersion);
            void Promise.all(
              this.executeRowUpdates(tx, rowsVersion2, this.#pending, "force")
            ).catch((e) => this.#lc.error?.(`error flushing cvr rows`, e));
            this.#pending.clear();
            return { rows: rows2, rowsVersion: rowsVersion2 };
          },
          { mode: READ_COMMITTED }
        );
        const elapsed = performance.now() - start;
        this.#lc.debug?.(
          `flushed ${rows} rows@${versionString(rowsVersion)} (${elapsed} ms)`
        );
        this.#recordAsyncFlushStats(rows, elapsed);
        this.#flushedRowsVersion = rowsVersion;
      }
      this.#lc.debug?.(
        `up to date rows@${versionToNullableCookie(this.#flushedRowsVersion)}`
      );
      flushing.resolve();
      this.#flushing = null;
    } catch (e) {
      flushing.reject(e);
      this.#failService(e);
    }
  }
  hasPendingUpdates() {
    return this.#flushing !== null;
  }
  /**
   * Returns a promise that resolves when all outstanding row-records
   * have been committed.
   */
  flushed(lc) {
    if (this.#flushing) {
      lc.debug?.("awaiting pending row flush");
      return this.#flushing.promise;
    }
    return promiseVoid;
  }
  clear() {
    this.#cache = void 0;
  }
  async *catchupRowPatches(lc, afterVersion, upToCVR, current, excludeQueryHashes = []) {
    if (cmpVersions(afterVersion, upToCVR.version) >= 0) {
      return;
    }
    const startMs = Date.now();
    const start = afterVersion ? versionString(afterVersion) : "";
    const end = versionString(upToCVR.version);
    lc.debug?.(`scanning row patches for clients from ${start}`);
    await this.flushed(lc);
    const flushMs = Date.now() - startMs;
    const reader = new TransactionPool(lc, READONLY).run(this.#db);
    try {
      await reader.processReadTask(
        (tx) => checkVersion(tx, this.#schema, this.#cvrID, current)
      );
      const { query } = await reader.processReadTask((tx) => {
        const query2 = excludeQueryHashes.length === 0 ? tx`SELECT * FROM ${this.#cvr("rows")}
        WHERE "clientGroupID" = ${this.#cvrID}
          AND "patchVersion" > ${start}
          AND "patchVersion" <= ${end}` : (
          // Exclude rows that were already sent as part of query hydration.
          tx`SELECT * FROM ${this.#cvr("rows")}
        WHERE "clientGroupID" = ${this.#cvrID}
          AND "patchVersion" > ${start}
          AND "patchVersion" <= ${end}
          AND ("refCounts" IS NULL OR NOT "refCounts" ?| ${excludeQueryHashes})`
        );
        return { query: query2 };
      });
      yield* query.cursor(1e4);
    } finally {
      reader.setDone();
    }
    const totalMs = Date.now() - startMs;
    lc.debug?.(
      `finished row catchup (flush: ${flushMs} ms, total: ${totalMs} ms)`
    );
  }
  executeRowUpdates(tx, version, rowUpdates, mode, lc = this.#lc) {
    if (mode === "allow-defer" && // defer if pending rows are being flushed
    (this.#flushing !== null || // or if the new batch is above the limit.
    rowUpdates.size > this.#deferredRowFlushThreshold)) {
      return [];
    }
    const rowsVersion = {
      clientGroupID: this.#cvrID,
      version: versionString(version)
    };
    const pending = [
      tx`INSERT INTO ${this.#cvr("rowsVersion")} ${tx(rowsVersion)}
           ON CONFLICT ("clientGroupID") 
           DO UPDATE SET ${tx(rowsVersion)}`
    ];
    const rowRecordRows = [];
    for (const [id, row] of rowUpdates.entries()) {
      if (row === null) {
        pending.push(
          tx`
          DELETE FROM ${this.#cvr("rows")}
            WHERE "clientGroupID" = ${this.#cvrID}
              AND "schema" = ${id.schema}
              AND "table" = ${id.table}
              AND "rowKey" = ${id.rowKey}
       `
        );
      } else {
        rowRecordRows.push(rowRecordToRowsRow(this.#cvrID, row));
      }
    }
    if (rowRecordRows.length) {
      pending.push(
        tx`
  INSERT INTO ${this.#cvr("rows")}(
      "clientGroupID", "schema", "table", "rowKey", "rowVersion", "patchVersion", "refCounts"
  ) SELECT
      "clientGroupID", "schema", "table", "rowKey", "rowVersion", "patchVersion", "refCounts"
    FROM json_to_recordset(${rowRecordRows}) AS x(
      "clientGroupID" TEXT,
      "schema" TEXT,
      "table" TEXT,
      "rowKey" JSONB,
      "rowVersion" TEXT,
      "patchVersion" TEXT,
      "refCounts" JSONB
  ) ON CONFLICT ("clientGroupID", "schema", "table", "rowKey")
    DO UPDATE SET "rowVersion" = excluded."rowVersion",
      "patchVersion" = excluded."patchVersion",
      "refCounts" = excluded."refCounts"
    `
      );
      lc.debug?.(
        `flushing ${rowUpdates.size} rows (${rowRecordRows.length} inserts, ${rowUpdates.size - rowRecordRows.length} deletes)`
      );
    }
    return pending;
  }
}
export {
  RowRecordCache
};
//# sourceMappingURL=row-record-cache.js.map
