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.

634 lines (557 loc) 23.7 kB
/** * @module SharedLock */ import {} from "ioredis"; import {} from "../../../../execution-context/contracts/_module.js"; import {} from "../../../../shared-lock/contracts/_module.js"; import {} from "../../../../time-span/implementations/_module.js"; import { UnexpectedError } from "../../../../utilities/errors.js"; /** * To utilize the `RedisSharedLockAdapter`, you must install the [`"ioredis"`](https://www.npmjs.com/package/ioredis) package. * * Note in order to use `RedisSharedLockAdapter` correctly, ensure you use a single, consistent database across all server instances. * * IMPORT_PATH: `"@daiso-tech/core/shared-lock/redis-shared-lock-adapter"` * @group Adapters */ export class RedisSharedLockAdapter { database; /** * @example * ```ts * import { RedisSharedLockAdapter } from "@daiso-tech/core/shared-lock/redis-shared-lock-adapter"; * import Redis from "ioredis"; * * const database = new Redis("YOUR_REDIS_CONNECTION_STRING"); * const sharedLockAdapter = new RedisSharedLockAdapter(database); * ``` */ constructor(database) { this.database = database; this.initAquireWriterCommand(); this.initReleaseWriterCommand(); this.initRefreshWriterCommand(); this.initForceReleaseWriterCommand(); this.initAquireReaderCommand(); this.initReleaseReaderCommand(); this.initRefreshReaderCommand(); this.initForceReleaseAllReadersCommand(); this.initForceReleaseCommand(); this.initGetStateCommand(); } static getWriterKey(keyVar) { return ` (function(key) return key .. "__writer"; end)(${keyVar}); `; } static getReaderKey(keyVar) { return ` (function(key) return key .. "__reader"; end)(${keyVar}); `; } initAquireWriterCommand() { if (typeof this.database.daiso_shared_lock_acquire_writer === "function") { return; } this.database.defineCommand("daiso_shared_lock_acquire_writer", { numberOfKeys: 1, lua: ` local key = KEYS[1]; local lockId = ARGV[1]; local writerKey = ${RedisSharedLockAdapter.getWriterKey("key")} local readerKey = ${RedisSharedLockAdapter.getReaderKey("key")} if redis.call("exists", readerKey) == 1 then return 0 end -- Expiration time as unix timestamp in ms local expiration = tonumber(ARGV[2]); if redis.call("exists", writerKey) == 1 then return redis.call("get", writerKey) == lockId; end if expiration == nil then redis.call("set", writerKey, lockId, "nx"); else redis.call("set", writerKey, lockId, "pxat", expiration, "nx"); end return 1; `, }); } initReleaseWriterCommand() { if (typeof this.database.daiso_shared_lock_release_writer === "function") { return; } this.database.defineCommand("daiso_shared_lock_release_writer", { numberOfKeys: 1, lua: ` local key = KEYS[1]; local lockId = ARGV[1]; local writerKey = ${RedisSharedLockAdapter.getWriterKey("key")} local readerKey = ${RedisSharedLockAdapter.getReaderKey("key")} if redis.call("exists", readerKey) == 1 then return 0 end if redis.call("exists", writerKey) == 0 then return 0 end local isNotCurrentOwner = redis.call("get", writerKey) ~= lockId if isNotCurrentOwner then return 0 end redis.call("del", writerKey) return 1 `, }); } initRefreshWriterCommand() { if (typeof this.database.daiso_shared_lock_refresh_writer === "function") { return; } this.database.defineCommand("daiso_shared_lock_refresh_writer", { numberOfKeys: 1, lua: ` -- Arguments local key = KEYS[1]; local lockId = ARGV[1]; local writerKey = ${RedisSharedLockAdapter.getWriterKey("key")} local readerKey = ${RedisSharedLockAdapter.getReaderKey("key")} if redis.call("exists", readerKey) == 1 then return 0 end -- Expiration time as unix timestamp in ms local expiration = tonumber(ARGV[2]); if redis.call("exists", writerKey) == 0 then return 0 end local isNotCurrentOwner = redis.call("get", writerKey) ~= lockId if redis.call("get", writerKey) ~= lockId then return 0 end local currentExpiration = redis.call("pttl", writerKey) local isUnexpireable = currentExpiration == -1 if isUnexpireable then return 0 end redis.call("pexpireat", writerKey, expiration) return 1 `, }); } static _forceReleaseWriter(keyVar) { return ` (function(key) local writerKey = ${RedisSharedLockAdapter.getWriterKey("key")} local readerKey = ${RedisSharedLockAdapter.getReaderKey("key")} if redis.call("exists", readerKey) == 1 then return 0 end return redis.call("del", writerKey); end)(${keyVar}); `; } initForceReleaseWriterCommand() { if (typeof this.database.daiso_shared_lock_force_release_writer === "function") { return; } this.database.defineCommand("daiso_shared_lock_force_release_writer", { numberOfKeys: 1, lua: ` -- Arguments local key = KEYS[1]; return ${RedisSharedLockAdapter._forceReleaseWriter("key")} `, }); } static getLimitKey(keyVar) { return ` (function(key) return key .. "__limit"; end)(${keyVar}); `; } static removeExpiredSlots(keyVar, nowVar) { return ` (function(key, now) redis.call("zremrangebyscore", key, 1, now + 1); end)(${keyVar}, ${nowVar}); `; } static getOrSetLimit(limitKeyVar, limitVar) { return ` (function(limitKey, limit) local currentLimit = tonumber(redis.call("set", limitKey, limit, "nx", "get")); if currentLimit == nil then currentLimit = limit; end return currentLimit; end)(${limitKeyVar}, ${limitVar}); `; } static isLimitReached(keyVar, limitVar) { return ` (function(key, limit) return tonumber(redis.call("zcard", key)) >= limit; end)(${keyVar}, ${limitVar}); `; } static hasSlotId(keyVar, slotIdVar) { return ` (function(key, slotId) return tonumber(redis.call("zscore", key, slotId)) ~= nil; end)(${keyVar}, ${slotIdVar}); `; } static addSlot(keyVar, slotIdVar, expirationVar) { return ` (function(key, slotId, expiration) local isSlotNotExpirable = expiration == nil; if isSlotNotExpirable then redis.call("zadd", key, 0, slotId); else redis.call("zadd", key, expiration, slotId); end end)(${keyVar}, ${slotIdVar}, ${expirationVar}); `; } static updateKeyExpiration(keyVar, limitKeyVar) { return ` (function(key, limitKey) local hasAtLeastOneUnexpirableSlot = tonumber(redis.call("zcount", key, 0, 0)) > 0; if hasAtLeastOneUnexpirableSlot then redis.call("persist", key); redis.call("persist", limitKey); return end local longestExpiration = tonumber(redis.call("zrange", key, 0, 0, "BYSCORE", "REV", "WITHSCORES")[2]); local hasAtLeastOneExpireableSlot = longestExpiration ~= nil and longestExpiration > 0; if hasAtLeastOneExpireableSlot then redis.call("pexpireat", key, longestExpiration); redis.call("pexpireat", limitKey, longestExpiration); end end)(${keyVar}, ${limitKeyVar}); `; } static removeSemaphoreIfEmpty(keyVar, limitKeyVar) { return ` (function(key, limitKey) local slotCount = redis.call("zcard", key) if slotCount <= 0 then redis.call("del", key) redis.call("del", limitKey) end end)(${keyVar}, ${limitKeyVar}); `; } initAquireReaderCommand() { if (typeof this.database.daiso_shared_lock_acquire_reader === "function") { return; } this.database.defineCommand("daiso_shared_lock_acquire_reader", { numberOfKeys: 1, lua: ` local key = KEYS[1]; local slotId = ARGV[1]; local limit = tonumber(ARGV[2]); local readerKey = ${RedisSharedLockAdapter.getReaderKey("key")} local writerKey = ${RedisSharedLockAdapter.getWriterKey("key")} if redis.call("exists", writerKey) == 1 then return 0 end -- Expiration time as unix timestamp in ms local expiration = tonumber(ARGV[3]); -- Current time as unix timestamp in ms local now = tonumber(ARGV[4]); local limitKey = ${RedisSharedLockAdapter.getLimitKey("readerKey")} limit = ${RedisSharedLockAdapter.getOrSetLimit("limitKey", "limit")} ${RedisSharedLockAdapter.removeExpiredSlots("readerKey", "now")} local isLimitReached = ${RedisSharedLockAdapter.isLimitReached("readerKey", "limit")} if isLimitReached then return 0; end local hasSlotId = ${RedisSharedLockAdapter.hasSlotId("readerKey", "slotId")} if hasSlotId then return 1; end ${RedisSharedLockAdapter.addSlot("readerKey", "slotId", "expiration")} ${RedisSharedLockAdapter.updateKeyExpiration("readerKey", "limitKey")} return 1; `, }); } initReleaseReaderCommand() { if (typeof this.database.daiso_shared_lock_release_reader === "function") { return; } this.database.defineCommand("daiso_shared_lock_release_reader", { numberOfKeys: 1, lua: ` local key = KEYS[1]; local lockId = ARGV[1]; local readerKey = ${RedisSharedLockAdapter.getReaderKey("key")} local writerKey = ${RedisSharedLockAdapter.getWriterKey("key")} if redis.call("exists", writerKey) == 1 then return 0 end -- The key where the slot limit will be stored local limitKey = ${RedisSharedLockAdapter.getLimitKey("readerKey")} -- Current time as unix timestamp in ms local now = tonumber(ARGV[2]); ${RedisSharedLockAdapter.removeExpiredSlots("readerKey", "now")} -- Removes the given slot local removedCount = redis.call("zrem", readerKey, lockId); local hasRemoved = removedCount == 1; ${RedisSharedLockAdapter.removeSemaphoreIfEmpty("readerKey", "limitKey")} ${RedisSharedLockAdapter.updateKeyExpiration("readerKey", "limitKey")} return removedCount; `, }); } initRefreshReaderCommand() { if (typeof this.database.daiso_shared_lock_refresh_reader === "function") { return; } this.database.defineCommand("daiso_shared_lock_refresh_reader", { numberOfKeys: 1, lua: ` local key = KEYS[1]; local lockId = ARGV[1]; local readerKey = ${RedisSharedLockAdapter.getReaderKey("key")} local writerKey = ${RedisSharedLockAdapter.getWriterKey("key")} if redis.call("exists", writerKey) == 1 then return 0 end -- unix timestamp in ms local newExpiration = tonumber(ARGV[2]); -- current time as unix timestamp in ms local now = tonumber(ARGV[3]); -- The key where the slot limit will be stored local limitKey = ${RedisSharedLockAdapter.getLimitKey("readerKey")} ${RedisSharedLockAdapter.removeExpiredSlots("readerKey", "now")} local expiration = tonumber(redis.call("zscore", readerKey, lockId)); local isExpiredOrDoesNotExist = expiration == nil; if isExpiredOrDoesNotExist then return 0 end local isUnexpireable = expiration == 0; if isUnexpireable then return 0 end -- Update expiration if lockId exists redis.call("zadd", readerKey, "xx", newExpiration, lockId); local hasAtLeastOneUnexpirableSlot = tonumber(redis.call("zcount", readerKey, 0, 0)) > 0; local longestExpiration = tonumber(redis.call("zrange", readerKey, 0, 0, "BYSCORE", "REV", "WITHSCORES")[2]); if hasAtLeastOneUnexpirableSlot then redis.call("persist", readerKey); redis.call("persist", limitKey); elseif longestExpiration ~= nil and longestExpiration > 0 then redis.call("pexpireat", readerKey, longestExpiration); redis.call("pexpireat", limitKey, longestExpiration); end return 1 `, }); } static _forceReleaseAllReaders(keyVar, nowVar) { return ` (function(key, now) local readerKey = ${RedisSharedLockAdapter.getReaderKey("key")} -- The key where the slot limit will be stored local limitKey = ${RedisSharedLockAdapter.getLimitKey("readerKey")} local writerKey = ${RedisSharedLockAdapter.getWriterKey("key")} if redis.call("exists", writerKey) == 1 then return 0 end ${RedisSharedLockAdapter.removeExpiredSlots("readerKey", "now")} local slotCount = redis.call("zcard", readerKey); redis.call("del", readerKey); redis.call("del", limitKey); local hasSlots = slotCount > 0 if hasSlots then return 1; end return 0; end)(${keyVar}, ${nowVar}); `; } initForceReleaseAllReadersCommand() { if (typeof this.database.daiso_shared_lock_force_release_all_readers === "function") { return; } this.database.defineCommand("daiso_shared_lock_force_release_all_readers", { numberOfKeys: 1, lua: ` local key = KEYS[1]; -- current time as unix timestamp in ms local now = ARGV[1]; return ${RedisSharedLockAdapter._forceReleaseAllReaders("key", "now")} `, }); } initForceReleaseCommand() { if (typeof this.database.daiso_shared_lock_force_release === "function") { return; } this.database.defineCommand("daiso_shared_lock_force_release", { numberOfKeys: 1, lua: ` local key = KEYS[1]; -- current time as unix timestamp in ms local now = ARGV[1]; local hasReleasedReader = ${RedisSharedLockAdapter._forceReleaseAllReaders("key", "now")} local hasReleasedWriter = ${RedisSharedLockAdapter._forceReleaseWriter("key")} return hasReleasedReader == 1 or hasReleasedWriter == 1 `, }); } static getWriterState(keyVar) { return ` (function(key) local writerKey = ${RedisSharedLockAdapter.getWriterKey("key")} if tonumber(redis.call("exists", writerKey)) == 0 then return nil; end local state = { owner = redis.call("get", writerKey), expiration = tonumber(redis.call("pexpiretime", writerKey)) }; return state; end)(${keyVar}); `; } static getReaderState(keyVar, nowVar) { return ` (function(key, now) local readerKey = ${RedisSharedLockAdapter.getReaderKey("key")} if tonumber(redis.call("exists", readerKey)) == 0 then return nil; end local limitKey = ${RedisSharedLockAdapter.getLimitKey("readerKey")} ${RedisSharedLockAdapter.removeExpiredSlots("readerKey", "now")} local limit = redis.call("get", limitKey); if limit == nil then return nil; end local acquiredSlots = {}; local unexpiredSlotsArr = redis.call("zrange", readerKey, now + 1, "+inf", "BYSCORE", "WITHSCORES"); for i = 1, #unexpiredSlotsArr, 2 do local member = unexpiredSlotsArr[i]; local score = unexpiredSlotsArr[i+1]; acquiredSlots[member] = tonumber(score); end local unexpireableSlotsArr = redis.call("zrange", readerKey, 0, 0, "BYSCORE", "WITHSCORES"); for i = 1, #unexpireableSlotsArr, 2 do local member = unexpireableSlotsArr[i]; local score = unexpireableSlotsArr[i+1]; acquiredSlots[member] = tonumber(score); end local isEmpty = next(acquiredSlots) == nil; if isEmpty then return nil; end local state = { limit = tonumber(limit), acquiredSlots = acquiredSlots } return state; end)(${keyVar}, ${nowVar}); `; } initGetStateCommand() { if (typeof this.database.daiso_shared_lock_get_state === "function") { return; } this.database.defineCommand("daiso_shared_lock_get_state", { numberOfKeys: 1, lua: ` local key = KEYS[1]; -- current time as unix timestamp in ms local now = tonumber(ARGV[1]); local writerState = ${RedisSharedLockAdapter.getWriterState("key")} local readerState = ${RedisSharedLockAdapter.getReaderState("key", "now")} if writerState == nil and readerState == nil then return cjson.encode(nil); end return cjson.encode({ writer = writerState, reader = readerState }); `, }); } async acquireWriter(_context, key, lockId, ttl) { const result = await this.database.daiso_shared_lock_acquire_writer(key, lockId, ttl?.toEndDate().getTime() ?? null); return result === 1; } async releaseWriter(_context, key, lockId) { const result = await this.database.daiso_shared_lock_release_writer(key, lockId); return result === 1; } async refreshWriter(_context, key, lockId, ttl) { const result = await this.database.daiso_shared_lock_refresh_writer(key, lockId, ttl.toEndDate().getTime()); return result === 1; } async forceReleaseWriter(_context, key) { const result = await this.database.daiso_shared_lock_force_release_writer(key); return result === 1; } async acquireReader(settings) { const { key, lockId, limit, ttl } = settings; const result = await this.database.daiso_shared_lock_acquire_reader(key, lockId, limit, ttl?.toEndDate().getTime() ?? null, Date.now()); return result === 1; } async releaseReader(_context, key, lockId) { const result = await this.database.daiso_shared_lock_release_reader(key, lockId, Date.now()); return result === 1; } async refreshReader(_context, key, lockId, ttl) { const result = await this.database.daiso_shared_lock_refresh_reader(key, lockId, ttl.toEndDate().getTime(), Date.now()); return result === 1; } async forceReleaseAllReaders(_context, key) { const result = await this.database.daiso_shared_lock_force_release_all_readers(key, Date.now()); return result === 1; } async forceRelease(_context, key) { const result = await this.database.daiso_shared_lock_force_release(key, Date.now()); return result === 1; } async getState(_context, key) { const json = JSON.parse(await this.database.daiso_shared_lock_get_state(key, Date.now())); if (json === null) { return null; } if (json.reader === undefined && json.writer !== undefined) { return { reader: null, writer: { owner: json.writer.owner, expiration: json.writer.expiration === -1 ? null : new Date(json.writer.expiration), }, }; } if (json.writer === undefined && json.reader !== undefined) { return { writer: null, reader: { limit: json.reader.limit, acquiredSlots: new Map(Object.entries(json.reader.acquiredSlots).map(([key_, expiration]) => { if (expiration === 0) { return [key_, null]; } return [key_, new Date(expiration)]; })), }, }; } throw new UnexpectedError("Invalid ISharedLockAdapterState, expected either the reader field must be defined or the writer field must be defined, but not both."); } } //# sourceMappingURL=redis-shared-lock-adapter.js.map