generated from ttipo/project-base
refactor(backend): 동시 수 제한과 직원마다 차례를 shared/utils 로 옮긴다
앱 새 메일 알림(푸시)도 14단계 검색 색인처럼 서버 전체 동시 수와 직원마다 한 번에 하나를 쓴다. 푸시 모듈이 mail-search 를 가져오지 않게 ConcurrencyGate 를 shared/utils 로 옮기고, 직원마다 차례를 KeyedQueue 로 뽑아 MailSearchRuns 가 상속하게 했다. 14단계 동작은 같다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,71 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { runOutsideRequestContext } from '@/shared/logger/request-context';
|
||||
import { KeyedQueue } from '@/shared/utils/keyed-queue';
|
||||
|
||||
/**
|
||||
* 직원마다 색인 일을 한 번에 하나씩 (14단계 설계 B2 — "같은 직원은 한 번에 하나") — 맞추기 · 채우기 한 묶음 · 전체 대조가 같은 줄에 선다.
|
||||
* 앞의 일이 실패해도 다음 일은 한다. 일은 요청 컨텍스트 밖에서 돈다 — 처음 부른 요청의 추적 ID 를 물려받지 않게.
|
||||
* 앞의 일이 실패해도 다음 일은 한다. 일은 요청 컨텍스트 밖에서 돈다 — 처음 부른 요청의 추적 ID 를 물려받지 않게 (차례는 shared/utils/keyed-queue.ts).
|
||||
*
|
||||
* ⚠️ 프로세스 안의 차례다 — 인스턴스를 늘리면 두 인스턴스가 같은 직원을 함께 맞춘다(쓰기는 상태 줄을 잠가 차례로 되지만 같은 일을 두 번 한다).
|
||||
* 그때는 잠금(Redis `SET NX PX` 또는 `pg_advisory_lock`)으로 옮긴다 (nestjs 스킬의 Redis 목록)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSearchRuns {
|
||||
private readonly tails = new Map<string, Promise<void>>();
|
||||
|
||||
/** 그 직원의 앞선 일이 끝난 뒤 `task` 를 한다 → 그 결과 */
|
||||
run<T>(user: string, task: () => Promise<T>): Promise<T> {
|
||||
const key = user.toLowerCase();
|
||||
const previous = this.tails.get(key) ?? Promise.resolve();
|
||||
const start = () => runOutsideRequestContext(task);
|
||||
const result = previous.then(start, start);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.tails.set(key, tail);
|
||||
void tail.then(() => {
|
||||
if (this.tails.get(key) === tail) this.tails.delete(key);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 그 직원의 일이 줄에 있거나 도는 중인가 */
|
||||
isBusy(user: string): boolean {
|
||||
return this.tails.has(user.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 함께 도는 일의 수 — 넘는 일은 먼저 온 차례로 기다린다 (14단계 — 실시간 소식이 직원 수만큼 한꺼번에 와도 맞추기는 이만큼만 함께).
|
||||
* 앞의 일이 실패해도 자리는 돌려준다.
|
||||
*
|
||||
* ⚠️ 프로세스 안의 셈이다 — 인스턴스를 늘리면 인스턴스마다 따로 센다 (nestjs 스킬의 Redis 목록)
|
||||
*/
|
||||
export class ConcurrencyGate {
|
||||
private active = 0;
|
||||
private readonly waiting: (() => void)[] = [];
|
||||
|
||||
constructor(private readonly limit: number) {}
|
||||
|
||||
/** 지금 도는 일 · 기다리는 일의 수 (시험용) */
|
||||
get load(): { readonly active: number; readonly waiting: number } {
|
||||
return { active: this.active, waiting: this.waiting.length };
|
||||
}
|
||||
|
||||
async run<T>(task: () => Promise<T>): Promise<T> {
|
||||
if (this.active >= this.limit) {
|
||||
await new Promise<void>((resolve) => this.waiting.push(resolve));
|
||||
} else {
|
||||
this.active += 1;
|
||||
}
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
// 기다리는 일에 자리를 그대로 넘긴다 (자리 수는 그대로) — 없으면 돌려준다
|
||||
const next = this.waiting.shift();
|
||||
if (next) next();
|
||||
else this.active -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
export class MailSearchRuns extends KeyedQueue {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PasswordExpiredException,
|
||||
} from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { ConcurrencyGate } from '@/shared/utils/concurrency-gate';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { settledWithin } from '@/shared/utils/settle';
|
||||
import {
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
MailSearchIndexRepository,
|
||||
type SearchStatePatch,
|
||||
} from './mail-search-index.repository';
|
||||
import { ConcurrencyGate, MailSearchRuns } from './mail-search-runs';
|
||||
import { MailSearchRuns } from './mail-search-runs';
|
||||
import {
|
||||
MAIL_SEARCH_TIMING,
|
||||
SYSTEM_SEARCH_TIMING,
|
||||
|
||||
Reference in New Issue
Block a user