import { MethodLogger, ManageStatusUpdates, ConvertStringToRequestID } from '../internal.js';
import { Errors } from '../common.js';
import { asleep } from '../../utils/asleep.js';
export default class KeetaAnchorQueueStorageDriverPostgres {
    logger;
    poolInternal = null;
    tablePrefix;
    tableNameEntries;
    tableNameSchemaVersion;
    tableNameIdempotentKeys;
    dbInitializationPromise = null;
    serializationRetryCount = 0;
    debugForceIndexScan = false;
    name = 'KeetaAnchorQueueStorageDriverPostgres';
    id;
    path = [];
    pathStr;
    toctouDelay = undefined;
    constructor(options) {
        this.id = options?.id ?? crypto.randomUUID();
        this.logger = options?.logger;
        this.tablePrefix = options?.tablePrefix;
        this.poolInternal = options.pool;
        this.path = options.path ?? [];
        this.pathStr = ['root', ...this.path].join('.');
        Object.freeze(this.path);
        this.tableNameEntries = `${this.tablePrefix ?? 'queue'}_entries`;
        this.tableNameIdempotentKeys = `${this.tablePrefix ?? 'queue'}_idempotent_keys`;
        this.tableNameSchemaVersion = `${this.tablePrefix ?? 'queue'}_schema_version`;
        this.methodLogger('new')?.debug('Initialized Postgres queue storage driver');
    }
    async initializeDBConnection(pool) {
        const logger = this.methodLogger('initializeDBConnection');
        if (this.dbInitializationPromise !== null) {
            logger?.debug('DB schema initialization already in progress or completed, waiting for it to finish');
            await this.dbInitializationPromise;
            return (pool);
        }
        this.dbInitializationPromise = (async () => {
            logger?.debug('Initializing DB schema for queue storage driver on tables', [this.tableNameEntries, this.tableNameIdempotentKeys]);
            const client = await pool.connect();
            try {
                /*
                 * Random lock key (32-bit integer), to ensure
                 * that multiple instances of this driver
                 * (potentially across different
                 * instances/process) will run the migration
                 * sequentially to avoid multiple concurrent
                 * migrations from competing with each other
                 * and causing delays
                 */
                const lockId = 0x24995E48;
                logger?.debug('Acquiring advisory lock for schema migration');
                await client.query('SELECT pg_advisory_lock($1)', [lockId]);
                try {
                    // Create schema version table if it doesn't exist
                    await client.query(`
						CREATE TABLE IF NOT EXISTS ${this.tableNameSchemaVersion} (
							version INTEGER NOT NULL,
							applied_at BIGINT NOT NULL,
							PRIMARY KEY (version)
						)`);
                    // Check current schema version
                    const versionResult = await client.query(`SELECT MAX(version) as version FROM ${this.tableNameSchemaVersion}`);
                    const currentVersion = versionResult.rows[0]?.version ?? 0;
                    logger?.debug(`Current queue schema version: ${currentVersion}`);
                    // Version 1: Initial schema
                    if (currentVersion < 1) {
                        logger?.debug('Applying schema version 1: Initial tables and indexes');
                        await client.query('BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED');
                        try {
                            await client.query(`
								CREATE TABLE IF NOT EXISTS ${this.tableNameEntries} (
									id TEXT NOT NULL,
									path TEXT NOT NULL,
									request TEXT NOT NULL,
									output TEXT,
									last_error TEXT,
									status TEXT NOT NULL,
									created BIGINT NOT NULL,
									updated BIGINT NOT NULL,
									worker BIGINT,
									failures INTEGER NOT NULL DEFAULT 0,
									PRIMARY KEY (id, path)
								)`);
                            await client.query(`
								CREATE TABLE IF NOT EXISTS ${this.tableNameIdempotentKeys} (
									entry_id TEXT NOT NULL,
									idempotent_id TEXT NOT NULL,
									path TEXT NOT NULL,
									UNIQUE (idempotent_id, path),
									PRIMARY KEY (entry_id, idempotent_id, path),
									FOREIGN KEY (entry_id, path) REFERENCES ${this.tableNameEntries}(id, path)
								)`);
                            // Old single-column indexes (for pre-version-2 schemas)
                            await client.query(`CREATE INDEX IF NOT EXISTS idx_${this.tableNameEntries}_status ON ${this.tableNameEntries}(status)`);
                            await client.query(`CREATE INDEX IF NOT EXISTS idx_${this.tableNameEntries}_updated ON ${this.tableNameEntries}(updated)`);
                            await client.query(`CREATE INDEX IF NOT EXISTS idx_${this.tableNameIdempotentKeys}_idempotent_id ON ${this.tableNameIdempotentKeys}(idempotent_id)`);
                            await client.query(`INSERT INTO ${this.tableNameSchemaVersion} (version, applied_at) VALUES (1, $1)`, [Date.now()]);
                            await client.query('COMMIT');
                            logger?.debug('Applied schema version 1');
                        }
                        catch (error) {
                            await client.query('ROLLBACK');
                            throw (error);
                        }
                    }
                    // Version 2: Partition-aware composite indexes
                    if (currentVersion < 2) {
                        logger?.debug('Applying schema version 2: Partition-aware composite indexes');
                        // Now drop old indexes and record version
                        await client.query('BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED');
                        try {
                            // Create new partition-aware indexes
                            logger?.debug('Creating partition-aware indexes...');
                            await client.query(`CREATE INDEX IF NOT EXISTS idx_${this.tableNameEntries}_path_status ON ${this.tableNameEntries}(path, status)`);
                            await client.query(`CREATE INDEX IF NOT EXISTS idx_${this.tableNameEntries}_path_updated ON ${this.tableNameEntries}(path, updated)`);
                            await client.query(`CREATE INDEX IF NOT EXISTS idx_${this.tableNameEntries}_path_status_updated ON ${this.tableNameEntries}(path, status, updated)`);
                            await client.query(`CREATE INDEX IF NOT EXISTS idx_${this.tableNameIdempotentKeys}_path_idempotent_id ON ${this.tableNameIdempotentKeys}(path, idempotent_id)`);
                            // Drop old indexes that are now redundant (these will fail gracefully if indexes don't exist)
                            logger?.debug('Dropping old single-column indexes...');
                            await client.query(`DROP INDEX IF EXISTS idx_${this.tableNameEntries}_status`);
                            await client.query(`DROP INDEX IF EXISTS idx_${this.tableNameEntries}_updated`);
                            await client.query(`DROP INDEX IF EXISTS idx_${this.tableNameIdempotentKeys}_idempotent_id`);
                            await client.query(`INSERT INTO ${this.tableNameSchemaVersion} (version, applied_at) VALUES (2, $1)`, [Date.now()]);
                            await client.query('COMMIT');
                            logger?.debug('Applied schema version 2');
                        }
                        catch (error) {
                            await client.query('ROLLBACK');
                            throw (error);
                        }
                    }
                    logger?.debug('Schema is up to date');
                }
                finally {
                    // Always release the advisory lock
                    // Note: Advisory locks are session-based and auto-release on disconnect,
                    // but we explicitly unlock for clarity and to avoid holding locks longer than needed
                    try {
                        logger?.debug('Releasing advisory lock');
                        await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
                    }
                    catch (unlockError) {
                        // Log but don't throw - the lock will be auto-released when connection closes
                        logger?.debug('Failed to explicitly release advisory lock (will auto-release on disconnect):', unlockError);
                    }
                }
            }
            finally {
                client.release();
            }
            logger?.debug('Completed DB schema initialization for queue storage driver');
            return (true);
        })();
        await this.dbInitializationPromise;
        return (pool);
    }
    methodLogger(method) {
        return (MethodLogger(this.logger, {
            class: 'KeetaAnchorQueueStorageDriverPostgres',
            file: 'src/lib/queue/drivers/queue_postgres.ts',
            method: method,
            instanceID: this.id
        }));
    }
    async runWithRetry(fn) {
        const logger = this.methodLogger('runWithRetry');
        let lastError;
        for (let retry = 0; retry < 16; retry++) {
            if (this.poolInternal === null) {
                this.methodLogger('runWithRetry')?.debug('Aborting DB operation retries because the instance was destroyed');
                if (lastError !== undefined) {
                    // eslint-disable-next-line @typescript-eslint/only-throw-error
                    throw (lastError);
                }
                throw (new Error('Aborting because the instance was destroyed'));
            }
            try {
                return (await fn());
            }
            catch (error) {
                lastError = error;
                let detectedRetryableError = false;
                if (error instanceof Error) {
                    const errorCode = 'code' in error ? error.code : null;
                    if (errorCode === '40001' || errorCode === '40P01') {
                        // Track serialization retries for instrumentation
                        this.serializationRetryCount++;
                        detectedRetryableError = true;
                        logger?.debug('Detected retryable error with code', errorCode);
                    }
                }
                if (detectedRetryableError) {
                    const minBackoff = 100;
                    const maxBackoff = 30_000;
                    const backoffIntervalSize = Math.min(maxBackoff - minBackoff, (retry + 50) ** 2);
                    const backoff = Math.round((Math.random() * backoffIntervalSize)) + minBackoff;
                    this.methodLogger('runWithRetry')?.debug(`Retrying DB operation in ${backoff}ms (retry #${retry}) from`, new Error().stack);
                    await asleep(backoff);
                    continue;
                }
                throw (error);
            }
        }
        throw (lastError);
    }
    async newDBConnection() {
        if (this.poolInternal === null) {
            throw (new Error('Database connection is not available'));
        }
        return (await this.runWithRetry(async () => {
            if (this.poolInternal === null) {
                throw (new Error('Database connection is not available'));
            }
            this.methodLogger('newDBConnection')?.debug('Getting DB pool');
            const pool = await this.poolInternal();
            return (await this.initializeDBConnection(pool));
        }));
    }
    async dbTransaction(className, fn) {
        const pool = await this.newDBConnection();
        const logger = this.methodLogger(className);
        const debugForceIndexScan = this.debugForceIndexScan;
        const result = await this.runWithRetry(async function () {
            const client = await pool.connect();
            try {
                logger?.debug('Starting DB transaction');
                await client.query('BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED');
                logger?.debug('DB transaction started');
                if (debugForceIndexScan) {
                    await client.query('SET LOCAL enable_seqscan TO off');
                }
                const retval = await fn(client, logger);
                logger?.debug('Committing DB transaction');
                await client.query('COMMIT');
                logger?.debug('DB transaction committed');
                return (retval);
            }
            catch (error) {
                try {
                    logger?.debug('Rolling back DB transaction due to error:', error);
                    await client.query('ROLLBACK');
                    logger?.debug('DB transaction rolled back');
                }
                catch {
                    logger?.debug('Error rolling back DB transaction !!');
                }
                throw (error);
            }
            finally {
                client.release();
            }
        });
        return (result);
    }
    async add(request, info) {
        return (await this.dbTransaction('add', async (client, logger) => {
            let entryID = ConvertStringToRequestID(info?.id);
            entryID ??= ConvertStringToRequestID(crypto.randomUUID());
            logger?.debug(`Enqueuing request with id ${String(entryID)}`);
            const currentTime = Date.now();
            const requestJSON = JSON.stringify(request);
            /**
             * The status to use for the new entry
             */
            const status = info?.status ?? 'pending';
            const insertedEntry = await client.query(`
				INSERT INTO ${this.tableNameEntries} (
					id, path, request, output, last_error, status,
					created, updated, worker, failures
				) VALUES ($1, $2, $3, NULL, NULL, $4, $5, $6, NULL, 0)
				ON CONFLICT (id, path) DO NOTHING
				RETURNING id`, [entryID, this.pathStr, requestJSON, status, currentTime, currentTime]);
            if (insertedEntry.rows.length === 0) {
                logger?.debug(`Request with id ${String(entryID)} already exists, ignoring`);
                return (entryID);
            }
            await this.toctouDelay?.();
            const idempotentIDs = info?.idempotentKeys;
            if (idempotentIDs && idempotentIDs.size > 0) {
                const keys = Array.from(idempotentIDs);
                const insertedKeys = await client.query(`
					INSERT INTO ${this.tableNameIdempotentKeys}(entry_id, path, idempotent_id)
					SELECT $1, $2, unnest($3::text[])
					ON CONFLICT (idempotent_id, path) DO NOTHING
					RETURNING idempotent_id`, [entryID, this.pathStr, keys]);
                const foundKeySet = new Set(keys);
                for (const row of insertedKeys.rows) {
                    foundKeySet.delete(ConvertStringToRequestID(row.idempotent_id));
                }
                if (insertedKeys.rows.length !== keys.length) {
                    throw (new Errors.IdempotentExistsError('One or more idempotent entries already exist in the queue', foundKeySet));
                }
                await this.toctouDelay?.();
            }
            return (entryID);
        }));
    }
    async setStatus(id, status, ancillary) {
        const { oldStatus } = ancillary ?? {};
        return (await this.dbTransaction('setStatus', async (client, logger) => {
            const existingEntry = await client.query(`SELECT status, failures, last_error, output FROM ${this.tableNameEntries} WHERE id = $1 AND path = $2 FOR UPDATE`, [id, this.pathStr]);
            if (existingEntry.rows.length === 0) {
                throw (new Error(`Request with ID ${String(id)} not found`));
            }
            await this.toctouDelay?.();
            const currentEntry = existingEntry.rows[0];
            if (!currentEntry) {
                throw (new Error(`Request with ID ${String(id)} not found`));
            }
            const newEntry = ManageStatusUpdates(id, currentEntry, status, ancillary, logger);
            const currentTime = newEntry.updated.getTime();
            const workerValue = newEntry.worker;
            const newFailures = newEntry.failures ?? currentEntry.failures;
            const newLastError = newEntry.lastError !== undefined ? newEntry.lastError : currentEntry.last_error;
            const newOutput = newEntry.output !== undefined ? JSON.stringify(newEntry.output) : currentEntry.output;
            let updateQuery;
            let updateParams;
            if (oldStatus) {
                updateQuery = `UPDATE ${this.tableNameEntries}
				               SET status = $1, updated = $2, worker = $3, failures = $4, last_error = $5, output = $6
				               WHERE id = $7 AND path = $8 AND status = $9`;
                updateParams = [status, currentTime, workerValue, newFailures, newLastError, newOutput, id, this.pathStr, oldStatus];
            }
            else {
                updateQuery = `UPDATE ${this.tableNameEntries}
				               SET status = $1, updated = $2, worker = $3, failures = $4, last_error = $5, output = $6
				               WHERE id = $7 AND path = $8`;
                updateParams = [status, currentTime, workerValue, newFailures, newLastError, newOutput, id, this.pathStr];
            }
            const result = await client.query(updateQuery, updateParams);
            await this.toctouDelay?.();
            if (oldStatus && result.rowCount === 0) {
                const currentEntry = await client.query(`SELECT status FROM ${this.tableNameEntries} WHERE id = $1 AND path = $2`, [id, this.pathStr]);
                const currentStatus = currentEntry.rows[0]?.status;
                if (currentEntry.rows.length > 0) {
                    if (currentStatus === undefined) {
                        throw (new Error(`internal error: could not retrieve current status for request with ID ${String(id)}`));
                    }
                    throw (new Errors.IncorrectStateAssertedError(id, oldStatus, currentStatus));
                }
                else {
                    throw (new Error(`Request with ID ${String(id)} not found`));
                }
            }
        }));
    }
    async get(id) {
        return (await this.dbTransaction('get', async (client) => {
            const row = await client.query(`SELECT id, request, output, last_error, status, created, updated, worker, failures
				 FROM ${this.tableNameEntries} WHERE id = $1 AND path = $2`, [id, this.pathStr]);
            if (row.rows.length === 0) {
                return (null);
            }
            const entry = row.rows[0];
            if (!entry) {
                return (null);
            }
            const idempotentRows = await client.query(`SELECT idempotent_id FROM ${this.tableNameIdempotentKeys} WHERE entry_id = $1 AND path = $2`, [id, this.pathStr]);
            const idempotentKeys = idempotentRows.rows.length > 0
                ? new Set(idempotentRows.rows.map(function (idempotentRow) {
                    return (ConvertStringToRequestID(idempotentRow.idempotent_id));
                }))
                : undefined;
            return ({
                id: ConvertStringToRequestID(entry.id),
                // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
                request: JSON.parse(entry.request),
                // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
                output: entry.output ? JSON.parse(entry.output) : null,
                lastError: entry.last_error,
                status: entry.status,
                created: new Date(Number(entry.created)),
                updated: new Date(Number(entry.updated)),
                // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
                worker: entry.worker,
                failures: entry.failures,
                idempotentKeys: idempotentKeys
            });
        }));
    }
    async query(filter) {
        return (await this.dbTransaction('query', async (client, logger) => {
            logger?.debug(`Querying queue with id ${this.id} with filter:`, filter);
            const conditions = [];
            const params = [];
            let paramIndex = 1;
            conditions.push(`path = $${paramIndex++}`);
            params.push(this.pathStr);
            if (filter?.status) {
                conditions.push(`status = $${paramIndex++}`);
                params.push(filter.status);
            }
            if (filter?.updatedBefore) {
                conditions.push(`updated < $${paramIndex++}`);
                params.push(filter.updatedBefore.getTime());
            }
            let query = `SELECT id, request, output, last_error, status, created, updated, worker, failures FROM ${this.tableNameEntries}`;
            if (conditions.length > 0) {
                query += ' WHERE ' + conditions.join(' AND ');
            }
            // Use random ordering to prevent multiple workers from contending for the same rows
            // This spreads the load when multiple workers query simultaneously
            query += ' ORDER BY RANDOM()';
            if (filter?.limit !== undefined) {
                query += ` LIMIT $${paramIndex++}`;
                params.push(filter.limit);
            }
            const rows = await client.query(query, params);
            const entries = [];
            for (const row of rows.rows) {
                const idempotentRows = await client.query(`SELECT idempotent_id FROM ${this.tableNameIdempotentKeys} WHERE entry_id = $1 AND path = $2`, [row.id, this.pathStr]);
                const idempotentKeys = idempotentRows.rows.length > 0
                    ? new Set(idempotentRows.rows.map(function (idempotentRow) {
                        return (ConvertStringToRequestID(idempotentRow.idempotent_id));
                    }))
                    : undefined;
                entries.push({
                    id: ConvertStringToRequestID(row.id),
                    // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
                    request: JSON.parse(row.request),
                    // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
                    output: row.output ? JSON.parse(row.output) : null,
                    lastError: row.last_error,
                    status: row.status,
                    created: new Date(Number(row.created)),
                    updated: new Date(Number(row.updated)),
                    // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
                    worker: row.worker,
                    failures: row.failures,
                    idempotentKeys: idempotentKeys
                });
            }
            logger?.debug(`Queried queue with id ${this.id} with filter:`, filter, '-- found', entries.length, 'entries');
            return (entries);
        }));
    }
    async partition(path) {
        this.methodLogger('partition')?.debug(`Creating partitioned queue storage driver for path: ${path}`);
        if (this.poolInternal === null) {
            throw (new Error('Asked to partition but the instance has been destroyed'));
        }
        const retval = new KeetaAnchorQueueStorageDriverPostgres({
            id: `${this.id}::${path}`,
            logger: this.logger,
            pool: this.poolInternal,
            path: [...this.path, path],
            tablePrefix: this.tablePrefix
        });
        return (retval);
    }
    async destroy() {
        this.methodLogger('destroy')?.debug('Destroying instance');
        this.poolInternal = null;
    }
    async [Symbol.asyncDispose]() {
        return (await this.destroy());
    }
    /** @internal */
    _Testing(key) {
        if (key !== 'bc81abf8-e43b-490b-b486-744fb49a5082') {
            throw (new Error('This is a testing only method'));
        }
        return ({
            setToctouDelay: (delay) => {
                this.toctouDelay = async () => {
                    return (await asleep(delay));
                };
            },
            unsetToctouDelay: () => {
                this.toctouDelay = undefined;
            },
            getSerializationRetryCount: () => {
                return (this.serializationRetryCount);
            },
            resetSerializationRetryCount: () => {
                this.serializationRetryCount = 0;
            },
            enableDebugForceIndexScan: () => {
                this.debugForceIndexScan = true;
            },
            disableDebugForceIndexScan: () => {
                this.debugForceIndexScan = false;
            }
        });
    }
}
//# sourceMappingURL=queue_postgres.js.map