feat: synchronize account icons across game profiles
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import { isCanonicalIsoTimestamp, isRecord, type AccountIconProjection } from '@sammo-ts/common';
|
||||
|
||||
import type { AccountIconResetProjection, AccountIconResetSource } from '../auth/accountIconSource.js';
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
||||
|
||||
const BATCH_SIZE = 500;
|
||||
const MAX_TERMINAL_REQUEUES = 3;
|
||||
|
||||
type GeneralIconState = {
|
||||
id: number;
|
||||
userId: string | null;
|
||||
picture: string | null;
|
||||
imageServer: number;
|
||||
meta: unknown;
|
||||
};
|
||||
|
||||
export type AccountIconResetReconcilerHealth = {
|
||||
running: boolean;
|
||||
lastSuccessAt: string | null;
|
||||
lastErrorAt: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
const readCurrentRevision = (meta: unknown): string | null => {
|
||||
if (!isRecord(meta)) {
|
||||
return null;
|
||||
}
|
||||
const value = meta.accountIconUpdatedAt;
|
||||
return typeof value === 'string' && isCanonicalIsoTimestamp(value) ? value : null;
|
||||
};
|
||||
|
||||
const projectionForGeneral = (general: GeneralIconState, reset: AccountIconResetProjection): AccountIconProjection => {
|
||||
const currentRevision = readCurrentRevision(general.meta);
|
||||
if (currentRevision) {
|
||||
return {
|
||||
revision: reset.resetRevision,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Existing installations have no per-General watermark. If the rendered
|
||||
// tuple already equals a post-reset Gateway projection, seed that newer
|
||||
// revision instead of replaying the historical reset over it.
|
||||
if (
|
||||
reset.current.revision > reset.resetRevision &&
|
||||
general.picture === reset.current.picture &&
|
||||
general.imageServer === reset.current.imageServer
|
||||
) {
|
||||
return reset.current;
|
||||
}
|
||||
return {
|
||||
revision: reset.resetRevision,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const stableJson = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? 'null';
|
||||
};
|
||||
|
||||
export class AccountIconResetReconciler {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private inFlight: Promise<void> | null = null;
|
||||
private lastSuccessAt: string | null = null;
|
||||
private lastErrorAt: string | null = null;
|
||||
private lastError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseClient,
|
||||
private readonly source: AccountIconResetSource,
|
||||
private readonly turnDaemon: TurnDaemonTransport,
|
||||
private readonly intervalMs: number,
|
||||
private readonly onError: (error: unknown) => void = () => undefined
|
||||
) {}
|
||||
|
||||
getHealth(): AccountIconResetReconcilerHealth {
|
||||
return {
|
||||
running: this.timer !== null,
|
||||
lastSuccessAt: this.lastSuccessAt,
|
||||
lastErrorAt: this.lastErrorAt,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
private async sendWithTerminalRecovery(userId: string, projection: AccountIconProjection): Promise<void> {
|
||||
const baseRequestId = `general:adjustIcon:${userId}:${projection.revision}`;
|
||||
const events = await this.db.inputEvent.findMany({
|
||||
where: {
|
||||
OR: [{ requestId: baseRequestId }, { requestId: { startsWith: `${baseRequestId}:retry:` } }],
|
||||
},
|
||||
select: {
|
||||
requestId: true,
|
||||
status: true,
|
||||
eventType: true,
|
||||
payload: true,
|
||||
},
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
const latest = events.at(-1);
|
||||
if (latest) {
|
||||
const expected = {
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: latest.requestId,
|
||||
userId,
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
};
|
||||
if (latest.eventType !== 'adjustGeneralIcon' || stableJson(latest.payload) !== stableJson(expected)) {
|
||||
throw new Error('account icon reset event payload conflicts with the durable journal');
|
||||
}
|
||||
}
|
||||
if (latest?.status === 'PENDING' || latest?.status === 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const retryCount = events.filter(({ requestId }) => requestId !== baseRequestId).length;
|
||||
if (latest && retryCount >= MAX_TERMINAL_REQUEUES) {
|
||||
throw new Error(`account icon reset exhausted ${MAX_TERMINAL_REQUEUES} terminal retries`);
|
||||
}
|
||||
const requestId = latest ? `${baseRequestId}:retry:${retryCount + 1}` : baseRequestId;
|
||||
await this.turnDaemon.sendCommand({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId,
|
||||
userId,
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
});
|
||||
}
|
||||
|
||||
private async reconcilePage(generals: GeneralIconState[]): Promise<Error[]> {
|
||||
const userIds = [...new Set(generals.flatMap((general) => (general.userId ? [general.userId] : [])))];
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const resets = await this.source.listResets(userIds);
|
||||
const resetByUserId = new Map(resets.map((reset) => [reset.userId, reset]));
|
||||
const failures: Error[] = [];
|
||||
|
||||
for (const general of generals) {
|
||||
if (!general.userId) continue;
|
||||
const reset = resetByUserId.get(general.userId);
|
||||
if (!reset) continue;
|
||||
const currentRevision = readCurrentRevision(general.meta);
|
||||
if (currentRevision && currentRevision >= reset.resetRevision) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.sendWithTerminalRecovery(general.userId, projectionForGeneral(general, reset));
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
new Error(`account icon reset failed for user ${general.userId}: ${errorMessage(error)}`, {
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
async reconcileOnce(): Promise<void> {
|
||||
const failures: Error[] = [];
|
||||
let cursorId: number | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
const generals: GeneralIconState[] = await this.db.general.findMany({
|
||||
where: {
|
||||
userId: { not: null },
|
||||
npcState: 0,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
meta: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
take: BATCH_SIZE,
|
||||
...(cursorId === null ? {} : { cursor: { id: cursorId }, skip: 1 }),
|
||||
});
|
||||
failures.push(...(await this.reconcilePage(generals)));
|
||||
if (generals.length < BATCH_SIZE) break;
|
||||
cursorId = generals.at(-1)?.id ?? null;
|
||||
if (cursorId === null) break;
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, `${failures.length} account icon reset reconciliation(s) failed`);
|
||||
}
|
||||
this.lastSuccessAt = new Date().toISOString();
|
||||
this.lastErrorAt = null;
|
||||
this.lastError = null;
|
||||
} catch (error) {
|
||||
this.lastErrorAt = new Date().toISOString();
|
||||
this.lastError = errorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) {
|
||||
return;
|
||||
}
|
||||
const run = (): void => {
|
||||
if (this.inFlight) {
|
||||
return;
|
||||
}
|
||||
this.inFlight = this.reconcileOnce()
|
||||
.catch((error: unknown) => this.onError(error))
|
||||
.finally(() => {
|
||||
this.inFlight = null;
|
||||
});
|
||||
};
|
||||
run();
|
||||
this.timer = setInterval(run, this.intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
await this.inFlight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { isCanonicalIsoTimestamp, type AccountIconProjection } from '@sammo-ts/common';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
import { ConflictingTurnDaemonCommandError } from '../daemon/databaseTransport.js';
|
||||
import type { AccountIconSource } from '../auth/accountIconSource.js';
|
||||
import type { GatewayUserFlushEvent } from '../auth/flushStore.js';
|
||||
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
||||
|
||||
export const loadAuthoritativeAccountIcon = async (
|
||||
ctx: GameApiContext,
|
||||
userId: string
|
||||
): Promise<AccountIconProjection> => {
|
||||
if (!ctx.accountIconSource) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Gateway 계정 아이콘 원장이 구성되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const projection = await ctx.accountIconSource.get(userId);
|
||||
if (!projection) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Gateway에서 계정 정보를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
return projection;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Gateway 계정 아이콘 정보를 확인할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const adjustAccountIconForUser = async (
|
||||
ctx: GameApiContext,
|
||||
userId: string
|
||||
): Promise<{
|
||||
ok: true;
|
||||
generalId: number | null;
|
||||
updated: boolean;
|
||||
}> => {
|
||||
const projection = await loadAuthoritativeAccountIcon(ctx, userId);
|
||||
const requestId = `general:adjustIcon:${userId}:${projection.revision}`;
|
||||
try {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId,
|
||||
userId,
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
});
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: 'TIMEOUT',
|
||||
message: '요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
|
||||
});
|
||||
}
|
||||
if (result.type !== 'adjustGeneralIcon') {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '턴 데몬이 올바르지 않은 아이콘 적용 결과를 반환했습니다.',
|
||||
});
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({
|
||||
code: result.code,
|
||||
message: result.reason,
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
generalId: result.generalId,
|
||||
updated: result.updated,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ConflictingTurnDaemonCommandError ||
|
||||
(error instanceof Error && error.name === 'ConflictingTurnDaemonCommandError')
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 접수된 아이콘 적용 요청과 최신 계정 정보가 다릅니다.',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const enqueueProfileIconResetForUser = async (
|
||||
ctx: GameApiContext,
|
||||
userId: string,
|
||||
expectedResetRevision: string
|
||||
): Promise<boolean> => {
|
||||
const projection = await loadAuthoritativeAccountIcon(ctx, userId);
|
||||
if (
|
||||
projection.revision !== expectedResetRevision ||
|
||||
projection.picture !== 'default.jpg' ||
|
||||
projection.imageServer !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
await ctx.turnDaemon.sendCommand({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: `general:adjustIcon:${userId}:${projection.revision}`,
|
||||
userId,
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export const createAdminProfileIconResetFlushHandler =
|
||||
(source: AccountIconSource, turnDaemon: TurnDaemonTransport) =>
|
||||
async (event: GatewayUserFlushEvent): Promise<void> => {
|
||||
if (event.reason !== 'admin-profile-icon-reset') {
|
||||
return;
|
||||
}
|
||||
if (!event.iconRevision || !isCanonicalIsoTimestamp(event.iconRevision)) {
|
||||
return;
|
||||
}
|
||||
const projection = await source.get(event.userId);
|
||||
if (
|
||||
!projection ||
|
||||
projection.revision !== event.iconRevision ||
|
||||
projection.picture !== 'default.jpg' ||
|
||||
projection.imageServer !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await turnDaemon.sendCommand({
|
||||
type: 'adjustGeneralIcon',
|
||||
requestId: `general:adjustIcon:${event.userId}:${projection.revision}`,
|
||||
userId: event.userId,
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface ResourceCleanupStep {
|
||||
name: string;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종료 단계 하나가 실패해도 나머지 연결을 모두 닫고, 다음 호출에서는 실패한
|
||||
* 단계만 재시도합니다. 동시에 들어온 종료 요청은 같은 실행을 공유합니다.
|
||||
*/
|
||||
export const createBestEffortResourceCloser = (steps: readonly ResourceCleanupStep[]): (() => Promise<void>) => {
|
||||
const completed = new Set<string>();
|
||||
let closing: Promise<void> | null = null;
|
||||
|
||||
return async (): Promise<void> => {
|
||||
if (completed.size === steps.length) return;
|
||||
if (closing) return closing;
|
||||
|
||||
closing = (async () => {
|
||||
const errors: Error[] = [];
|
||||
for (const step of steps) {
|
||||
if (completed.has(step.name)) continue;
|
||||
try {
|
||||
await step.run();
|
||||
completed.add(step.name);
|
||||
} catch (error) {
|
||||
errors.push(new Error(`Failed to close resource: ${step.name}`, { cause: error }));
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, 'One or more resources failed to close.');
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await closing;
|
||||
} finally {
|
||||
closing = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user