pg-transactional-outbox
Version:
A PostgreSQL based transactional outbox and inbox pattern implementation to support exactly once message processing (with at least once message delivery).
196 lines • 9.62 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.justDoIt = exports.processPool = exports.getClient = exports.executeTransaction = exports.IsolationLevel = exports.awaitWithTimeout = exports.sleep = void 0;
const database_1 = require("./database");
const error_1 = require("./error");
/**
* Sleep for a given amount of milliseconds
* @param milliseconds The time in milliseconds to sleep
* @returns The (void) promise to await
*/
const sleep = (milliseconds) => __awaiter(void 0, void 0, void 0, function* () { return new Promise((resolve) => setTimeout(resolve, milliseconds)); });
exports.sleep = sleep;
/**
* Run a promise but make sure to wait only a maximum amount of time for it to finish.
* @param promise The promise to execute
* @param timeoutInMs The amount of time in milliseconds to wait for the promise to finish
* @param failureMessage The message for the error if the timeout was reached
* @returns The promise return value or a timeout error is thrown
*/
const awaitWithTimeout = (promise, timeoutInMs, failureMessage) => {
let timeoutHandle;
const timeoutPromise = new Promise((_resolve, reject) => {
timeoutHandle = setTimeout(() => reject(new error_1.TransactionalOutboxInboxError(failureMessage !== null && failureMessage !== void 0 ? failureMessage : 'Timeout', 'TIMEOUT')), timeoutInMs);
});
return Promise.race([promise(), timeoutPromise])
.then((result) => {
return result;
})
.finally(() => clearTimeout(timeoutHandle));
};
exports.awaitWithTimeout = awaitWithTimeout;
/**
* PostgreSQL available isolation levels. The isolation level "Read uncommitted"
* is the same as "Read committed" in PostgreSQL. And the readonly variants are
* not usable as the message must be marked as processed or the
* "finished_attempts" counter is updated for the message.
*/
var IsolationLevel;
(function (IsolationLevel) {
/** Highest protection - no serialization anomaly */
IsolationLevel["Serializable"] = "SERIALIZABLE";
/** Second highest protection - no non-repeatable read */
IsolationLevel["RepeatableRead"] = "REPEATABLE READ";
/** Lowest protection (same as read uncommitted in PG) - non-repeatable reads possible */
IsolationLevel["ReadCommitted"] = "READ COMMITTED";
})(IsolationLevel || (exports.IsolationLevel = IsolationLevel = {}));
/**
* Open a transaction and execute the callback as part of the transaction.
* @param client The PostgreSQL database client
* @param callback The callback to execute DB commands with.
* @param isolationLevel The database transaction isolation level. Falls back to the default PostgreSQL transaction level if not provided.
* @returns The result of the callback (if any).
* @throws Any error from the database or the callback.
*/
const executeTransaction = (client, callback, isolationLevel) => __awaiter(void 0, void 0, void 0, function* () {
const isolation = Object.values(IsolationLevel).includes(isolationLevel)
? isolationLevel
: undefined;
try {
yield client.query(isolation ? `START TRANSACTION ISOLATION LEVEL ${isolation}` : 'BEGIN');
const result = yield callback(client);
yield client.query('COMMIT');
(0, database_1.releaseIfPoolClient)(client);
return result;
}
catch (err) {
const error = (0, error_1.ensureExtendedError)(err, 'DB_ERROR');
try {
yield client.query('ROLLBACK');
}
catch (rollbackError) {
error.innerError = (0, error_1.ensureExtendedError)(rollbackError, 'DB_ERROR');
}
(0, database_1.releaseIfPoolClient)(client, error);
throw error;
}
});
exports.executeTransaction = executeTransaction;
/**
* Creates a PoolClient from the given pool and attaches the logger for error logging.
* If the logger is provided and has the log level 'trace' the client will track
* all the queries that were done during its lifetime. The queries are appended
* to errors that are thrown from that client/connection in the custom property
* `queryStack` on the pg library error.
* @param pool A PostgreSQL database pool from which clients can be created.
* @param logger A logger that will be registered for the on-error event of the client.
* @returns The PoolClient object
*/
const getClient = (pool, logger) => __awaiter(void 0, void 0, void 0, function* () {
var _a;
const client = yield pool.connect();
if (logger) {
const appendQueryStack = (error) => {
if ('queryStack' in client && error instanceof Error) {
error.queryStack =
client.queryStack;
}
};
// The pool can return a new or an old client - thus we remove existing event listeners
client.removeAllListeners('error');
client.on('error', (err) => {
const error = (0, error_1.ensureExtendedError)(err, 'DB_ERROR');
appendQueryStack(error);
logger.error(error, 'PostgreSQL client error');
});
// Track the queries in case the log level is 'trace'
if (((_a = logger.level) === null || _a === void 0 ? void 0 : _a.toLocaleLowerCase()) === 'trace') {
const c = client;
if (c.queryStack === undefined) {
// Wrap the client query to track all queries (only once as clients are reused)
const query = client.query;
const queryWrapper = (queryOrConfig, values, callback) => __awaiter(void 0, void 0, void 0, function* () {
var _a;
if (typeof queryOrConfig === 'object' && 'text' in queryOrConfig) {
c.queryStack.push({
query: queryOrConfig.text,
values: (_a = queryOrConfig.values) !== null && _a !== void 0 ? _a : values,
});
}
else {
c.queryStack.push({ query: queryOrConfig, values });
}
// This matches the pg implementation - TypeScript types are too strict
return query.call(client, queryOrConfig, values, callback);
});
client.query = queryWrapper;
// Subscribing to "message" and append the stack if the parameter is an error object
c.connection.removeAllListeners('message');
c.connection.on('message', (error) => {
appendQueryStack(error);
});
}
c.queryStack = [];
}
}
return client;
});
exports.getClient = getClient;
/**
* Process incoming promises in a pool with a maximum size. When that size is not
* reached it tries to fill the processing pool up to the `getBatchSize`
* number. It runs in an endless loop until the signal `stopped` variable is set
* to true.
* @param processingPool The promise items in the pool which are being worked on
* @param getNextBatch Get the next items to fill up the pool. Gets the batch size input parameter from the `getBatchSize` parameter
* @param getBatchSize Async function that gets the number of batch items that are currently processed
* @param signal A signal object to stop the processing. Setting the stopped property to true will stop the loop
* @param maxDelayInMs The maximum number of milliseconds to wait before checking if new items are there
*/
const processPool = (processingPool, getNextBatch, getBatchSize, signal, maxDelayInMs) => __awaiter(void 0, void 0, void 0, function* () {
while (!signal.stopped) {
// get the dynamic pool size - and one await is needed to allow event loop to continue
const poolSize = yield getBatchSize(processingPool.size);
const diff = poolSize - processingPool.size;
if (diff > 0) {
const items = yield getNextBatch(diff);
items.forEach((item) => {
processingPool.add(item);
item.then(() => {
processingPool.delete(item);
});
});
const awaitItems = Array.from(processingPool);
if (awaitItems.length < poolSize) {
// If there are not enough items then try again at least after this duration
awaitItems.push((0, exports.sleep)(maxDelayInMs));
}
yield Promise.race(awaitItems);
}
}
});
exports.processPool = processPool;
/**
* Execute a function in a safe way e.g. pool.end where multiple callers could
* have called it where the second one will throw an error.
* @param it the function to execute
*/
const justDoIt = (it) => __awaiter(void 0, void 0, void 0, function* () {
try {
yield it();
}
catch (_a) {
// noop
}
});
exports.justDoIt = justDoIt;
//# sourceMappingURL=utils.js.map