fix(server): handle expired lock re-release & external locker injection (#6145)

This commit is contained in:
DarkSky
2024-03-19 02:16:46 +00:00
parent 4702c1a9ca
commit da32682afb
3 changed files with 31 additions and 11 deletions

View File

@@ -6,7 +6,7 @@ import { MutexService } from './mutex';
@Global() @Global()
@Module({ @Module({
providers: [MutexService, Locker], providers: [MutexService, Locker],
exports: [MutexService, Locker], exports: [MutexService],
}) })
export class MutexModule {} export class MutexModule {}

View File

@@ -14,11 +14,21 @@ export const MUTEX_WAIT = 100;
@Injectable({ scope: Scope.REQUEST }) @Injectable({ scope: Scope.REQUEST })
export class MutexService { export class MutexService {
protected logger = new Logger(MutexService.name); protected logger = new Logger(MutexService.name);
private readonly locker: Locker;
constructor( constructor(
@Inject(CONTEXT) private readonly context: GraphqlContext, @Inject(CONTEXT) private readonly context: GraphqlContext,
private readonly ref: ModuleRef private readonly ref: ModuleRef
) {} ) {
// nestjs will always find and injecting the locker from local module
// so the RedisLocker implemented by the plugin mechanism will not be able to overwrite the internal locker
// we need to use find and get the locker from the `ModuleRef` manually
//
// NOTE: when a `constructor` execute in normal service, the Locker module we expect may not have been initialized
// but in the Service with `Scope.REQUEST`, we will create a separate Service instance for each request
// at this time, all modules have been initialized, so we able to get the correct Locker instance in `constructor`
this.locker = this.ref.get(Locker, { strict: false });
}
protected getId() { protected getId() {
let id = this.context.req.headers['x-transaction-id'] as string; let id = this.context.req.headers['x-transaction-id'] as string;
@@ -55,10 +65,7 @@ export class MutexService {
async lock(key: string) { async lock(key: string) {
try { try {
return await retryable( return await retryable(
() => { () => this.locker.lock(this.getId(), key),
const locker = this.ref.get(Locker, { strict: false });
return locker.lock(this.getId(), key);
},
MUTEX_RETRY, MUTEX_RETRY,
MUTEX_WAIT MUTEX_WAIT
); );

View File

@@ -4,19 +4,33 @@ import { Command } from 'ioredis';
import { ILocker, Lock } from '../../fundamentals'; import { ILocker, Lock } from '../../fundamentals';
import { SessionRedis } from './instances'; import { SessionRedis } from './instances';
// === atomic mutex lock ===
// acquire lock
// return 1 if lock is acquired
// return 0 if lock is not acquired
const lockScript = `local key = KEYS[1] const lockScript = `local key = KEYS[1]
local clientId = ARGV[1] local owner = ARGV[1]
if redis.call("get", key) == clientId or redis.call("set", key, clientId, "NX", "EX", 60) then -- if lock is not exists or lock is owned by the owner
-- then set lock to the owner and return 1, otherwise return 0
-- if the lock is not released correctly due to unexpected reasons
-- lock will be released after 60 seconds
if redis.call("get", key) == owner or redis.call("set", key, owner, "NX", "EX", 60) then
return 1 return 1
else else
return 0 return 0
end`; end`;
// release lock
// return 1 if lock is released or lock is not exists
// return 0 if lock is not owned by the owner
const unlockScript = `local key = KEYS[1] const unlockScript = `local key = KEYS[1]
local clientId = ARGV[1] local owner = ARGV[1]
if redis.call("get", key) == clientId then local value = redis.call("get", key)
if value == owner then
return redis.call("del", key) return redis.call("del", key)
elseif value == nil then
return 1
else else
return 0 return 0
end`; end`;
@@ -40,7 +54,6 @@ export class RedisMutexLocker implements ILocker {
new Command('EVAL', [unlockScript, '1', lockKey, owner]) new Command('EVAL', [unlockScript, '1', lockKey, owner])
); );
// TODO(@darksky): lock expired condition is not handled
if (result === 0) { if (result === 0) {
throw new Error(`Failed to release lock ${key}`); throw new Error(`Failed to release lock ${key}`);
} }