mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
222 lines
7.6 KiB
TypeScript
222 lines
7.6 KiB
TypeScript
// 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<UserID>): Promise<Map<UserID, UserPartialResponse>>;
|
|
invalidateUserCache(userId: UserID): Promise<void>;
|
|
}
|
|
|
|
type PendingUserPartials = Promise<Map<UserID, UserPartialResponse>>;
|
|
|
|
type UsersServiceRequest =
|
|
| {
|
|
op: 'GetApiPartialsByIds';
|
|
user_ids: Array<string>;
|
|
}
|
|
| {
|
|
op: 'Invalidate';
|
|
user_id: string;
|
|
};
|
|
|
|
interface UserPartialsResponse {
|
|
FoundApiPartials: Array<UserPartialResponse>;
|
|
}
|
|
|
|
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<UserID, PendingUserPartials>();
|
|
|
|
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<UserID>): Promise<Map<UserID, UserPartialResponse>> {
|
|
const uniqueUserIds = uniqueSortedUserIds(userIds);
|
|
if (uniqueUserIds.length === 0) {
|
|
return new Map();
|
|
}
|
|
const result = new Map<UserID, UserPartialResponse>();
|
|
const lookups = new Map<PendingUserPartials, Array<UserID>>();
|
|
const misses: Array<UserID> = [];
|
|
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<UserID>): Promise<Map<UserID, UserPartialResponse>> {
|
|
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<UserID, UserPartialResponse>();
|
|
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<UserID>): 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<void> {
|
|
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<T>(payload: UsersServiceRequest, responseGuard: (value: unknown) => value is T): Promise<T> {
|
|
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<UserID>): Array<UserID> {
|
|
const seen = new Map<string, UserID>();
|
|
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);
|
|
}
|