UNPKG

@daiso-tech/core

Version:

The library offers flexible, framework-agnostic solutions for modern web applications, built on adaptable components that integrate seamlessly with popular frameworks like Next Js.

822 lines 28.5 kB
/** * @module SharedLock */ import {} from "mongodb"; import {} from "../../../../execution-context/contracts/_module.js"; import {} from "../../../../shared-lock/contracts/_module.js"; import { TimeSpan } from "../../../../time-span/implementations/_module.js"; import { OPTION, optionNone, optionSome, UnexpectedError, } from "../../../../utilities/_module.js"; /** * To utilize the `MongodbSharedLockAdapter`, you must install the [`"mongodb"`](https://www.npmjs.com/package/mongodb) package. * * Note in order to use `MongodbSharedLockAdapter` correctly, ensure you use a single, consistent database across all server instances. * * IMPORT_PATH: `"@daiso-tech/core/shared-lock/mongodb-shared-lock-adapter"` * @group Adapters */ export class MongodbSharedLockAdapter { static isSlotNotExpired = (slot) => slot.expiration === null || slot.expiration > new Date(); collection; /** * @example * ```ts * import { MongodbSharedLockAdapter } from "@daiso-tech/core/shared-lock/mongodb-shared-lock-adapter"; * import { MongoClient } from "mongodb"; * * const client = await MongoClient.connect("YOUR_MONGODB_CONNECTION_STRING"); * const database = client.db("database"); * const sharedLockAdapter = new MongodbSharedLockAdapter({ * database * }); * // You need initialize the adapter once before using it. * await sharedLockAdapter.init() * ``` */ constructor(settings) { const { collectionName = "sharedLock", collectionSettings, database, } = settings; this.collection = database.collection(collectionName, collectionSettings); } /** * Creates all related indexes. * Note the `init` method needs to be called once before using the adapter. */ async init() { // Should throw if the index already exists thats why the try catch is used. try { await this.collection.createIndex({ key: 1, }, { unique: true, }); } catch { /* EMPTY */ } // Should throw if the index already exists thats why the try catch is used. try { await this.collection.createIndex("expiration", { expireAfterSeconds: 0, }); } catch { /* EMPTY */ } } /** * Removes the collection where the shared-lock keys are stored and all it's related indexes. * Note all shared-lock data will be removed. */ async deInit() { // Should throw if the collection already does not exists thats why the try catch is used. try { await this.collection.dropIndexes(); } catch { /* EMPTY */ } // Should throw if the collection already does not exists thats why the try catch is used. try { await this.collection.drop(); } catch { /* EMPTY */ } } updateSemaphoreExpiration() { const hasUnexpireableSlotQuery = { $in: [ null, { $map: { input: "$reader.slots", as: "slot", in: "$$slot.expiration", }, }, ], }; const hasNoSlotsQuery = { $eq: [{ $size: "$reader.slots" }, 0], }; return [ { $set: { expiration: { $cond: { if: { $ne: ["$reader", null], }, then: { $cond: { // Check if there is at least one unexpirable slot if: hasUnexpireableSlotQuery, // If there are at least one unexpirable slot we set the expiration to null then: null, // If all slots are expireable we set the expiration to highest expiration else: { $max: "$reader.slots.expiration", }, }, }, else: "$expiration", }, }, }, }, { $set: { expiration: { $cond: { if: { $ne: ["$reader", null], }, then: { $cond: { // Are there slots acquired if: hasNoSlotsQuery, // If there are no slots acquired we immediatley expire the semaphore then: TimeSpan.fromMinutes(1).toStartDate(), // If there are slots acquired we do nothing else: "$expiration", }, }, else: "$expiration", }, }, }, }, ]; } removeWriterWhenReaderIsActive() { return [ { $set: { writer: { $cond: { if: { $ne: ["$reader", null], }, then: null, else: "$writer", }, }, }, }, ...this.updateSemaphoreExpiration(), ]; } async acquireWriter(_context, key, lockId, ttl) { const expiration = ttl?.toEndDate() ?? null; const isExpiredQuery = { $and: [ { $ne: ["$writer.expiration", null], }, { $lte: ["$writer.expiration", new Date()], }, ], }; const sharedLock = await this.collection.findOneAndUpdate({ key, }, [ { $set: { key, reader: { $ifNull: ["$reader", null], }, "writer.owner": { $ifNull: ["$writer.owner", lockId], }, "writer.expiration": { $ifNull: ["$writer.expiration", expiration], }, expiration: { $ifNull: ["$expiration", expiration], }, }, }, { $set: { "writer.owner": { $cond: { if: isExpiredQuery, then: lockId, else: "$writer.owner", }, }, "writer.expiration": { $cond: { if: isExpiredQuery, then: expiration, else: "$writer.expiration", }, }, expiration: { $cond: { if: isExpiredQuery, then: expiration, else: "$expiration", }, }, }, }, ...this.removeWriterWhenReaderIsActive(), ], { upsert: true, projection: { _id: 0, writer: 1, reader: 1, }, }); if (sharedLock === null) { return true; } const { writer: writerLock, reader: readerSemaphore } = sharedLock; if (readerSemaphore !== null) { return false; } if (writerLock === null) { throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the writer field must be defined, but not both."); } if (writerLock.owner === lockId) { return true; } if (writerLock.expiration === null) { return false; } return writerLock.expiration <= new Date(); } async releaseWriter(_context, key, lockId) { const isWriterActive = { writer: { $ne: null, }, reader: { $eq: null, }, }; const isUnexpirableQuery = { "writer.expiration": { $eq: null, }, }; const isUnexpiredQuery = { "writer.expiration": { $gt: new Date(), }, }; const sharedLock = await this.collection.findOneAndDelete({ key, "writer.owner": lockId, $or: [isWriterActive, isUnexpirableQuery, isUnexpiredQuery], }, { projection: { _id: 0, writer: 1, reader: 1, }, }); if (sharedLock === null) { return false; } const { writer: writerLock, reader: readerSemaphore } = sharedLock; if (readerSemaphore !== null) { return false; } if (writerLock === null) { throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the writer field must be defined, but not both."); } const { expiration } = writerLock; const hasNoExpiration = expiration === null; if (hasNoExpiration) { return true; } const { owner: currentOwner } = writerLock; const isNotExpired = expiration > new Date(); const isCurrentOwner = lockId === currentOwner; return isNotExpired && isCurrentOwner; } async forceReleaseWriter(_context, key) { const sharedLock = await this.collection.findOneAndDelete({ key, writer: { $ne: null, }, reader: { $eq: null, }, }, { projection: { _id: 0, reader: 1, writer: 1, }, }); if (sharedLock === null) { return false; } const { writer: writerLock, reader: readerSemaphore } = sharedLock; if (readerSemaphore !== null) { return false; } if (writerLock === null) { throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the writer field must be defined, but not both."); } if (writerLock.expiration === null) { return true; } const isNotExpired = writerLock.expiration >= new Date(); return isNotExpired; } async refreshWriter(_context, key, lockId, ttl) { const isUnexpiredQuery = { $and: [ { $ne: ["$writer", null], }, { $eq: ["$reader", null], }, { $ne: ["$writer.expiration", null], }, { $gt: ["$writer.expiration", new Date()], }, ], }; const sharedLock = await this.collection.findOneAndUpdate({ key, }, [ { $set: { "writer.expiration": { $cond: { if: isUnexpiredQuery, then: ttl.toEndDate(), else: "$writer.expiration", }, }, expiration: { $cond: { if: isUnexpiredQuery, then: ttl.toEndDate(), else: "$expiration", }, }, }, }, ...this.removeWriterWhenReaderIsActive(), ], { projection: { _id: 0, reader: 1, writer: 1, }, }); if (sharedLock === null) { return false; } const { writer: writerLock, reader: readerSemaphore } = sharedLock; if (readerSemaphore !== null) { return false; } if (writerLock === null) { throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the writer field must be defined, but not both."); } if (writerLock.owner !== lockId) { return false; } if (writerLock.expiration === null) { return false; } if (writerLock.expiration <= new Date()) { return false; } return true; } initSemaphoreIfNotExistsStage(key, limit) { // Initialies the fields if they dont exist when upserting return { $set: { key, writer: { $ifNull: ["$writer", null], }, "reader.limit": { $ifNull: ["$reader.limit", limit], }, "reader.slots": { $ifNull: ["$reader.slots", []], }, }, }; } removeExpiredSlotsStage() { return { $set: { "reader.slots": { // We filter all slots that are not expired $filter: { input: "$reader.slots", as: "slot", cond: { $or: [ // We filter all slots that have no ttl { $eq: ["$$slot.expiration", null], }, // We filter all slots that have ttl but are not expired { $gt: ["$$slot.expiration", new Date()], }, ], }, }, }, }, }; } removeReaderWhenWriterIsActive() { return { $set: { reader: { $cond: { if: { $ne: ["$writer", null], }, then: null, else: "$reader", }, }, expiration: { $cond: { if: { $ne: ["$writer", null], }, then: "$writer.expiration", else: "$expiration", }, }, }, }; } async acquireReader(settings) { const { key, lockId, limit, ttl } = settings; const hasNotReachedLimitQuery = { $lt: [ { $size: "$reader.slots", }, "$reader.limit", ], }; const hasAlreadyAcquiredSlotQuery = { $not: [ { $in: [ lockId, // We select the ids of each element in the $slots { $map: { input: "$reader.slots", as: "slot", in: "$$slot.id", }, }, ], }, ], }; const sharedLock = await this.collection.findOneAndUpdate({ key, }, [ this.initSemaphoreIfNotExistsStage(key, limit), this.removeExpiredSlotsStage(), { $set: { "reader.limit": { $cond: { if: { $eq: [ { $size: "$reader.slots", }, 0, ], }, then: limit, else: "$reader.limit", }, }, }, }, { $set: { "reader.slots": { $cond: { // We check if the limit is not reached and slot id does not exist if: { $and: [ hasNotReachedLimitQuery, hasAlreadyAcquiredSlotQuery, ], }, // If the limit is not reached and slot id does not exist we append the slot then: { $concatArrays: [ "$reader.slots", [ { id: lockId, expiration: ttl?.toEndDate() ?? null, }, ], ], }, // If the limit is reached or slot id does exist we do nonthing else: "$reader.slots", }, }, }, }, ...this.updateSemaphoreExpiration(), this.removeReaderWhenWriterIsActive(), ], { upsert: true, projection: { _id: 0, writer: 1, reader: 1, }, }); if (sharedLock === null) { return true; } const { writer: writerLock, reader: readerSemaphore } = sharedLock; if (writerLock !== null) { return false; } if (readerSemaphore === null) { throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the writer field must be defined, but not both."); } // We need to filter out expired slots from semaphoreData to ensure the data is current. // The update that handles slot expiration runs after this function. const unexpiredSlots = readerSemaphore.slots.filter(MongodbSharedLockAdapter.isSlotNotExpired); const hasReachedLimit = unexpiredSlots.length >= readerSemaphore.limit; if (hasReachedLimit) { return false; } const hasAlreadyAcquiredSlot = unexpiredSlots.some((slot) => slot.id === lockId); if (hasAlreadyAcquiredSlot) { return true; } return true; } async releaseReader(_context, key, slotId) { const sharedLock = await this.collection.findOneAndUpdate({ key, writer: { $eq: null, }, reader: { $ne: null, }, }, [ this.removeExpiredSlotsStage(), { $set: { "reader.slots": { $filter: { input: "$reader.slots", as: "slot", cond: { $ne: ["$$slot.id", slotId], }, }, }, }, }, ...this.updateSemaphoreExpiration(), this.removeReaderWhenWriterIsActive(), ], { projection: { _id: 0, writer: 1, reader: 1, }, }); if (sharedLock === null) { return false; } const { writer: writerLock, reader: readerSemaphore } = sharedLock; if (writerLock !== null) { return false; } if (readerSemaphore === null) { throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the writer field must be defined, but not both."); } const hasSlot = readerSemaphore.slots.some((slot) => slot.id === slotId && MongodbSharedLockAdapter.isSlotNotExpired(slot)); if (hasSlot) { return true; } return false; } async forceReleaseAllReaders(_context, key) { const sharedLock = await this.collection.findOneAndDelete({ key, writer: { $eq: null, }, reader: { $ne: null, }, }, { projection: { _id: 0, writer: 1, reader: 1, }, }); if (sharedLock === null) { return false; } const { writer: writerLock, reader: readerSemaphore } = sharedLock; if (writerLock !== null) { return false; } if (readerSemaphore === null) { throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the writer field must be defined, but not both."); } const unexpiredSlots = readerSemaphore.slots.filter(MongodbSharedLockAdapter.isSlotNotExpired); return unexpiredSlots.length > 0; } async refreshReader(_context, key, slotId, ttl) { const isExpireableQuery = { $ne: ["$$slot.expiration", null], }; const isUnexpiredQuery = { $gt: ["$$slot.expiration", new Date()], }; const sharedLock = await this.collection.findOneAndUpdate({ key, writer: { $eq: null, }, reader: { $ne: null, }, }, [ this.removeExpiredSlotsStage(), { $set: { "reader.slots": { $map: { input: "$reader.slots", as: "slot", in: { $cond: { if: { $and: [ { $eq: ["$$slot.id", slotId], }, isExpireableQuery, isUnexpiredQuery, ], }, then: { id: "$$slot.id", expiration: ttl.toEndDate(), }, else: "$$slot", }, }, }, }, }, }, ...this.updateSemaphoreExpiration(), this.removeReaderWhenWriterIsActive(), ], { projection: { _id: 0, reader: 1, writer: 1, }, returnDocument: "after", }); if (sharedLock === null) { return false; } const { writer: writerLock, reader: readerSemaphore } = sharedLock; if (writerLock !== null) { return false; } if (readerSemaphore === null) { throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the writer field must be defined, but not both."); } const hasRefreshed = readerSemaphore.slots .filter(MongodbSharedLockAdapter.isSlotNotExpired) .some((slot) => slot.id === slotId && slot.expiration !== null); return hasRefreshed; } async forceRelease(_context, key) { const sharedLock = await this.collection.findOneAndDelete({ key, }); if (sharedLock === null) { return false; } const { writer: writerLock, reader: readerSemaphore } = sharedLock; if (writerLock !== null && writerLock.expiration === null) { return true; } if (writerLock !== null && writerLock.expiration !== null) { const isNotExpired = writerLock.expiration >= new Date(); return isNotExpired; } if (readerSemaphore !== null) { const unexpiredSlots = readerSemaphore.slots.filter(MongodbSharedLockAdapter.isSlotNotExpired); return unexpiredSlots.length > 0; } throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the writer field must be defined, but not both."); } static extractWriterState(reader, writer) { if (reader === null && writer !== null && writer.expiration === null) { return optionSome({ reader: null, writer: { owner: writer.owner, expiration: writer.expiration, }, }); } if (reader === null && writer !== null && writer.expiration !== null && writer.expiration <= new Date()) { return optionSome(null); } if (writer !== null) { return optionSome({ reader: null, writer: { owner: writer.owner, expiration: writer.expiration, }, }); } return optionNone(); } static extractReaderState(reader, writer) { const unexpiredSlots = reader?.slots.filter((slot) => { return slot.expiration === null || slot.expiration > new Date(); }); if (writer === null && reader !== null && unexpiredSlots !== undefined && unexpiredSlots.length === 0) { return optionSome(null); } if (writer === null && reader !== null && unexpiredSlots !== undefined) { return optionSome({ writer: null, reader: { limit: reader.limit, acquiredSlots: new Map(unexpiredSlots.map((slot) => [slot.id, slot.expiration])), }, }); } return optionNone(); } async getState(_context, key) { const sharedLock = await this.collection.findOne({ key }, { projection: { _id: 0, reader: 1, writer: 1, }, }); if (sharedLock === null) { return null; } const { writer, reader } = sharedLock; const writerState = MongodbSharedLockAdapter.extractWriterState(reader, writer); if (writerState.type === OPTION.SOME) { return writerState.value; } const readerState = MongodbSharedLockAdapter.extractReaderState(reader, writer); if (readerState.type === OPTION.SOME) { return readerState.value; } throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the writer field must be defined, but not both."); } } //# sourceMappingURL=mongodb-shared-lock-adapter.js.map