"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.ImmediateTransaction = exports.DatabaseTransaction = exports.TRANSACTION_STATE = void 0;
exports.replicationConfirmation = replicationConfirmation;
exports.setTxnExpiration = setTxnExpiration;
const commonUtility_js_1 = require("../utility/lmdb/commonUtility.js");
const hdbError_js_1 = require("../utility/errors/hdbError.js");
const harperLogger = __importStar(require("../utility/logging/harper_logger.js"));
const envMngr = __importStar(require("../utility/environment/environmentManager.js"));
const hdbTerms_ts_1 = require("../utility/hdbTerms.js");
const common_utils_js_1 = require("../utility/common_utils.js");
const when_ts_1 = require("../utility/when.js");
const promises_1 = require("node:timers/promises");
const rocksdb_js_1 = require("@harperfast/rocksdb-js");
const trackedTxns = new Set();
const MAX_OUTSTANDING_TXN_DURATION = (0, common_utils_js_1.convertToMS)(envMngr.get(hdbTerms_ts_1.CONFIG_PARAMS.STORAGE_MAXTRANSACTIONQUEUETIME)) || 45000; // Allow write transactions to be queued for up to 25 seconds before we start rejecting them
const DEBUG_LONG_TXNS = envMngr.get(hdbTerms_ts_1.CONFIG_PARAMS.STORAGE_DEBUGLONGTRANSACTIONS);
exports.TRANSACTION_STATE = {
    CLOSED: 0, // the transaction has been committed or aborted and can no longer be used for writes (if read txn is active, it can be used for reads)
    OPEN: 1, // the transaction is open and can be used for reads and writes
    LINGERING: 2, // the transaction has completed a read, but can be used for immediate writes
};
const MAX_RETRIES = 40;
let outstandingCommit, outstandingCommitStart;
let confirmReplication;
function replicationConfirmation(callback) {
    confirmReplication = callback;
}
let txnExpiration = envMngr.get(hdbTerms_ts_1.CONFIG_PARAMS.STORAGE_MAXTRANSACTIONOPENTIME) ?? 30000;
class StartedTransaction extends Error {
}
class DatabaseTransaction {
    #context;
    writes = []; // the set of writes to commit if the conditions are met
    completions = []; // the set of outstanding async operations to complete
    db;
    transaction;
    readTxn;
    readTxnRefCount;
    readTxnsUsed;
    timeout;
    validated = 0;
    timestamp = 0;
    retries = 0;
    overloadChecked;
    open = exports.TRANSACTION_STATE.OPEN;
    replicatedConfirmation;
    getReadTxn() {
        this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
        this.timeout = txnExpiration; // reset the timeout
        if (this.transaction) {
            if (this.transaction.openTimer)
                this.transaction.openTimer = 0;
            return this.transaction;
        }
        if (this.open !== exports.TRANSACTION_STATE.OPEN)
            return; // can not start a new read transaction as there is no future commit that will take place, just have to allow the read to latest database state
        this.transaction = new rocksdb_js_1.Transaction(this.db.store);
        if (this.timestamp) {
            this.transaction.setTimestamp(this.timestamp);
        }
        this.readTxnsUsed = 1;
        if (DEBUG_LONG_TXNS) {
            this.stackTraces = [new StartedTransaction()];
        }
        if (this.transaction.openTimer)
            this.transaction.openTimer = 0;
        trackedTxns.add(this);
        return this.transaction;
    }
    useReadTxn() {
        const readTxn = this.getReadTxn();
        if (DEBUG_LONG_TXNS)
            this.stackTraces.push(new StartedTransaction());
        this.readTxnsUsed++;
        return readTxn;
    }
    doneReadTxn() {
        if (!this.transaction)
            return;
        if (--this.readTxnsUsed === 0) {
            trackedTxns.delete(this);
            if (this.open === exports.TRANSACTION_STATE.LINGERING) {
                // if we have lingering writes, we have to call commit to finish them
                this.commit();
            }
            else {
                this.transaction?.abort();
                this.transaction = null;
            }
        }
    }
    disregardReadTxn() {
        if (--this.readTxnRefCount === 0 && this.readTxnsUsed === 1) {
            this.doneReadTxn();
        }
    }
    checkOverloaded() {
        if (outstandingCommit &&
            !this.overloadChecked &&
            performance.now() - outstandingCommitStart > MAX_OUTSTANDING_TXN_DURATION) {
            throw new hdbError_js_1.ServerError('Outstanding write transactions have too long of queue, please try again later', 503);
        }
        this.overloadChecked = true; // only check this once, don't interrupt ongoing transactions that have already made writes
    }
    addWrite(operation) {
        this.writes.push(operation);
        if (!operation.deferSave) {
            // Setting saved to false means to defer saving
            this.save(operation);
        }
        return operation;
    }
    save(operation, transaction, reloadEntry = false) {
        let txnTime = this.timestamp;
        transaction ??= this.transaction;
        let immediateCommit = false;
        if (!transaction) {
            transaction = new rocksdb_js_1.Transaction(operation.store.store);
            if (operation.store.rootStore !== this.db.rootStore) {
                harperLogger.warn?.('Created new transaction in save, but the store does match existing store', transaction.id);
            }
            if (this.open === exports.TRANSACTION_STATE.OPEN) {
                this.transaction = transaction;
            }
            else {
                // if it is closed, we have to immediately commit, using our immediate transaction
                immediateCommit = true;
            }
            if (txnTime) {
                transaction.setTimestamp(txnTime);
            }
        }
        else {
        }
        if (this.retries > 0) {
            // This marks the Rocks transaction as a retry so we don't write the transaction log again
            transaction.isRetry = true;
        }
        if (!txnTime)
            txnTime = this.timestamp = transaction.getTimestamp();
        if (reloadEntry || operation.entry === undefined) {
            operation.entry = operation.store.getEntry(operation.key, { transaction });
        }
        if (!operation.saved) {
            operation.saved = true;
            // immediately execute in this transaction
            if (operation.validate?.(txnTime) === false) {
                operation.commit = () => { }; // noop if we try again
                return;
            }
            let result = operation.before?.();
            if (result?.then)
                this.completions.push(result);
            result = operation.beforeIntermediate?.();
            if (result?.then)
                this.completions.push(result);
        }
        operation.commit(txnTime, operation.entry, this.retries > 0, transaction);
        if (immediateCommit) {
            return this.commit({ transaction }); // immediately commit if the harper transaction is closed
        }
    }
    /**
     * Resolves with information on the timestamp and success of the commit
     */
    commit(options = {}) {
        let transaction = options.transaction ?? this.transaction; // we need to preserve this transaction as we might to resurrect it if we have to retry
        for (let i = 0; i < this.writes.length; i++) {
            let operation = this.writes[i];
            if (this.retries === 0 && operation.saved)
                continue;
            this.save(operation, transaction, i < this.validated);
        }
        this.validated = this.writes.length;
        const completions = this.completions;
        if (completions.length > 0)
            this.completions = []; // reset
        return (0, when_ts_1.when)(completions.length > 0 ? Promise.all(completions) : null, () => {
            if (this.writes.length > this.validated) {
                // check just in case we got any more transactions while we were waiting, if so just recursively continue to finish the additional writes now
                return this.commit(options);
            }
            this.open = exports.TRANSACTION_STATE.CLOSED;
            let commitResolution;
            if (--this.readTxnsUsed > 0) {
                // we still have outstanding iterators using the transaction, we can't just commit/abort it, we will still
                // need to use it
                if (this.writes.length > 0) {
                    // if there are outstanding writes, we have to call commit later to finish them
                    this.open = exports.TRANSACTION_STATE.LINGERING;
                    /* TODO: This is not really the intended behavior though, we want to immediately commit writes, but continue to use
                     * the transaction, as there is likely existing references to the transaction in other parts of the codebase,
                     * particularly in the query iterator */
                }
                /*
                commitResolution =
                    this.writes.length > 0
                        ? transaction?.commit({ renewAfterCommit: true }) // Try to use RocksDB's CommitAndTryCreateSnapshot
            : // don't abort, we still have outstanding reads to complete
                            null;
                */
            }
            else {
                // no more reads need to be performed, just commit/abort based if there are any writes
                trackedTxns.delete(this);
                this.transaction = null; // clear transaction so any further operations operate immediately
                if (transaction) {
                    if (this.writes.length > 0) {
                        commitResolution = transaction.commit();
                    }
                    else {
                        commitResolution = transaction.abort();
                    }
                }
            }
            if (commitResolution) {
                if (!outstandingCommit) {
                    outstandingCommit = commitResolution;
                    outstandingCommitStart = performance.now();
                    outstandingCommit.finally(() => {
                        outstandingCommit = null;
                    });
                }
                const completions = [];
                return commitResolution.then(() => {
                    transaction.onCommit?.();
                    if (this.next) {
                        completions.push(this.next.commit(options));
                    }
                    if (options?.flush) {
                        completions.push(this.writes[0].store.flushed);
                    }
                    if (this.replicatedConfirmation) {
                        // if we want to wait for replication confirmation, we need to track the transaction times
                        // and when replication notifications come in, we count the number of confirms until we reach the desired number
                        const databaseName = this.writes[0].store.rootStore.databaseName;
                        const lastWrite = this.writes[this.writes.length - 1];
                        if (confirmReplication && lastWrite) {
                            completions.push(confirmReplication(databaseName, lastWrite.store.getEntry(lastWrite.key).version, this.replicatedConfirmation));
                        }
                    }
                    // now reset transactions tracking; this transaction be reused and committed again
                    this.writes = [];
                    if (this.#context?.resourceCache)
                        this.#context.resourceCache = null;
                    this.next = null;
                    let txnTime = this.timestamp;
                    this.timestamp = 0; // reset the timestamp as well
                    return Promise.all(completions).then(() => {
                        return {
                            txnTime,
                        };
                    });
                }, (error) => {
                    if (error.code === 'ERR_BUSY') {
                        // if the transaction failed due to concurrent changes, we need to retry. First record this as an increased risk of contention/retry
                        // for future transactions
                        this.retries++;
                        harperLogger.debug?.('retrying', transaction.id, this.retries);
                        if (this.retries > 2) {
                            if (this.retries > MAX_RETRIES) {
                                throw new hdbError_js_1.ServerError(`After ${MAX_RETRIES} retries, unable to commit transaction, transaction is in conflict with ongoing writes`);
                            }
                            // start delaying, back off to try to space out transactions and avoid excessive conflicts
                            return (0, promises_1.setTimeout)(this.retries * this.retries).then(() => this.commit({ transaction }));
                        }
                        return this.commit({ transaction }); // try again
                    }
                    else
                        throw error;
                });
            }
            const txnResolution = {
                txnTime: this.timestamp,
            };
            if (this.next) {
                // now run any other transactions
                options.timestamp = this.timestamp;
                const nextResolution = this.next?.commit(options);
                if (nextResolution?.then)
                    return nextResolution?.then((nextResolution) => ({
                        txnTime: this.timestamp,
                        next: nextResolution,
                    }));
                txnResolution.next = nextResolution;
            }
            return txnResolution;
        });
    }
    abort() {
        while (this.readTxnsUsed > 0)
            this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
        this.open = exports.TRANSACTION_STATE.CLOSED;
        // reset the transaction
        this.writes = [];
        if (this.#context?.resourceCache)
            this.#context.resourceCache = null;
    }
    directCommitSync() {
        trackedTxns.delete(this);
        this.transaction?.commitSync();
    }
    getContext() {
        return this.#context;
    }
    setContext(context) {
        this.#context = context;
    }
}
exports.DatabaseTransaction = DatabaseTransaction;
class ImmediateTransaction extends DatabaseTransaction {
    isCommitting = false;
    constructor(db) {
        super();
        this.db = db;
    }
    save(transaction) {
        if (this.isCommitting) {
            // if we are in the commit, do the save and force a reload so we get a read within the transaction
            super.save(transaction, null, true);
        }
        else {
            this.isCommitting = true;
            return (0, when_ts_1.when)(this.commit(), () => {
                this.isCommitting = false;
            });
        }
    }
    get timestamp() {
        return this._timestamp || (this._timestamp = (0, commonUtility_js_1.getNextMonotonicTime)());
    }
    getReadTxn() {
        return; // no transaction means read latest
    }
}
exports.ImmediateTransaction = ImmediateTransaction;
let timer;
function startMonitoringTxns() {
    timer = setInterval(function () {
        for (const txn of trackedTxns) {
            if (txn.timeout <= 0) {
                const url = txn.getContext()?.url;
                harperLogger.error(`Transaction was open too long and has been committed, from table: ${txn.db?.name + (url ? ' path: ' + url : '')}`, ...(txn.startedFrom ? [`was started from ${txn.startedFrom.resourceName}.${txn.startedFrom.method}`] : []), ...(DEBUG_LONG_TXNS ? ['starting stack trace', txn.stackTraces] : []));
                // reset the transaction
                try {
                    const result = txn.commit();
                    if (result?.then) {
                        result.catch((error) => {
                            harperLogger.debug?.(`Error committing timed out transaction: ${error.message}`);
                        });
                    }
                }
                catch (error) {
                    harperLogger.debug?.(`Error committing timed out transaction: ${error.message}`);
                }
                txn.timeout = txnExpiration;
            }
            else {
                txn.timeout -= txnExpiration;
            }
        }
    }, txnExpiration).unref();
}
startMonitoringTxns();
function setTxnExpiration(ms) {
    clearInterval(timer);
    txnExpiration = ms;
    startMonitoringTxns();
    return trackedTxns;
}
//# sourceMappingURL=DatabaseTransaction.js.map