// SPDX-License-Identifier: AGPL-3.0-or-later import type {UserID} from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; import {throwForSvcErrorReply} from '@app/api/infrastructure/SvcErrorReply'; import {Logger} from '@app/api/Logger'; import {awaitAll} from '@app/api/utils/ConcurrencyUtils'; import {readOptionalIntegerEnv, requireIntegerInRange} from '@app/api/utils/IntegerOptions'; import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils'; import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager'; import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager'; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const USERS_SERVICE_SUBJECT = process.env.FLUXER_USERS_SERVICE_SUBJECT || 'svc.users'; const DEFAULT_USERS_SERVICE_TIMEOUT_MS = 6000; const DEFAULT_USERS_SERVICE_INFLIGHT_MAX_ENTRIES = 10000; const MAX_REQUEST_TIMEOUT_MS = 2_147_483_647; export interface IUsersServiceClient { getUserPartialResponses(userIds: Array): Promise>; invalidateUserCache(userId: UserID): Promise; } type PendingUserPartials = Promise>; type UsersServiceRequest = | { op: 'GetApiPartialsByIds'; user_ids: Array; } | { op: 'Invalidate'; user_id: string; }; interface UserPartialsResponse { FoundApiPartials: Array; } function isUserPartialResponse(value: unknown): value is UserPartialResponse { return isJsonRecord(value) && typeof value.id === 'string'; } function isUserPartialsResponse(value: unknown): value is UserPartialsResponse { return ( isJsonRecord(value) && Array.isArray(value.FoundApiPartials) && value.FoundApiPartials.every(isUserPartialResponse) ); } export class NatsUsersServiceClient implements IUsersServiceClient { private readonly inflightPartials = new Map(); constructor( private readonly connectionManager: INatsConnectionManager, private readonly requestTimeoutMs = DEFAULT_USERS_SERVICE_TIMEOUT_MS, private readonly subject = USERS_SERVICE_SUBJECT, private readonly maxInflightEntries = DEFAULT_USERS_SERVICE_INFLIGHT_MAX_ENTRIES, ) { requireIntegerInRange('FLUXER_USERS_SERVICE_TIMEOUT_MS', requestTimeoutMs, 1, MAX_REQUEST_TIMEOUT_MS); requireIntegerInRange('FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES', maxInflightEntries, 0, Number.MAX_SAFE_INTEGER); } async getUserPartialResponses(userIds: Array): Promise> { const uniqueUserIds = uniqueSortedUserIds(userIds); if (uniqueUserIds.length === 0) { return new Map(); } const result = new Map(); const lookups = new Map>(); const misses: Array = []; for (const userId of uniqueUserIds) { const batch = this.inflightPartials.get(userId); if (!batch) { misses.push(userId); continue; } const assignedUserIds = lookups.get(batch); if (assignedUserIds) assignedUserIds.push(userId); else lookups.set(batch, [userId]); } const capacity = Math.max(0, this.maxInflightEntries - this.inflightPartials.size); const coalesced = misses.slice(0, capacity); const direct = misses.slice(capacity); if (coalesced.length > 0) { lookups.set(this.fetchCoalescedPartials(coalesced), coalesced); } if (direct.length > 0) { lookups.set(this.fetchUserPartialResponses(direct), direct); } await awaitAll( Array.from(lookups, async ([batch, assignedUserIds]) => { const partials = await batch; for (const userId of assignedUserIds) { const partial = partials.get(userId); if (partial) result.set(userId, partial); } }), '[users-service] failed to fetch user partials', ); return result; } private async fetchUserPartialResponses(userIds: Array): Promise> { const requestedUserIds = new Map(userIds.map((userId) => [userId.toString(), userId])); const response = await this.request( { op: 'GetApiPartialsByIds', user_ids: Array.from(requestedUserIds.keys()), }, isUserPartialsResponse, ); const result = new Map(); for (const partial of response.FoundApiPartials) { const userId = requestedUserIds.get(partial.id); if (userId === undefined) { throw new Error('[users-service] response contains an invalid or unrequested user ID'); } if (result.has(userId)) { throw new Error('[users-service] response contains a duplicate user ID'); } result.set(userId, partial); } return result; } private fetchCoalescedPartials(userIds: Array): PendingUserPartials { const batch = this.fetchUserPartialResponses(userIds).finally(() => { for (const userId of userIds) { if (this.inflightPartials.get(userId) === batch) { this.inflightPartials.delete(userId); } } }); for (const userId of userIds) { this.inflightPartials.set(userId, batch); } return batch; } async invalidateUserCache(userId: UserID): Promise { this.inflightPartials.delete(userId); try { await this.request( { op: 'Invalidate', user_id: userId.toString(), }, (value): value is 'Invalidated' => value === 'Invalidated', ); } finally { this.inflightPartials.delete(userId); } } private async request(payload: UsersServiceRequest, responseGuard: (value: unknown) => value is T): Promise { try { if (this.connectionManager.isClosed()) { await this.connectionManager.connect(); } const connection = this.connectionManager.getConnection(); const response = await connection.request(this.subject, textEncoder.encode(JSON.stringify(payload)), { timeout: this.requestTimeoutMs, }); const decoded = textDecoder.decode(response.data); const parsed = parseJsonWithGuard(decoded, responseGuard); if (parsed === null) { throwForSvcErrorReply('users-service', parseJsonRecord(decoded)); throw new Error('[users-service] invalid response payload'); } return parsed; } catch (error) { Logger.warn({error, op: payload.op}, '[users-service] request failed'); throw error; } } } let usersServiceClient: IUsersServiceClient | undefined; let injectedUsersServiceClient: IUsersServiceClient | undefined; export function setInjectedUsersServiceClient(client: IUsersServiceClient | undefined): void { injectedUsersServiceClient = client; usersServiceClient = undefined; } export function createUsersServiceClient(): IUsersServiceClient { if (injectedUsersServiceClient !== undefined) { return injectedUsersServiceClient; } if (usersServiceClient !== undefined) { return usersServiceClient; } const manager = new NatsConnectionManager({ url: Config.nats.coreUrl, token: Config.nats.authToken || undefined, name: process.env.FLUXER_USERS_SERVICE_NATS_CLIENT_NAME || 'fluxer-api-users', }); usersServiceClient = new NatsUsersServiceClient( manager, readOptionalIntegerEnv('FLUXER_USERS_SERVICE_TIMEOUT_MS'), USERS_SERVICE_SUBJECT, readOptionalIntegerEnv('FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES'), ); void manager.connect().catch((error) => { Logger.warn({error}, '[users-service] Failed to establish NATS connection'); }); return usersServiceClient; } function uniqueSortedUserIds(userIds: Array): Array { const seen = new Map(); for (const userId of userIds) { seen.set(userId.toString(), userId); } return Array.from(seen.entries()) .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([, userId]) => userId); }