Files
fluxer/fluxer_api/src/api/app/APILifecycle.ts
T

290 lines
11 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import {initCassandra, shutdownCassandra} from '@pkgs/cassandra/src/Client';
import {ensureGeoipDatabaseOnStartup} from '@pkgs/geoip/src/GeoipStartup';
import {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client';
import type {APIConfig} from '../config/APIConfig';
import {hasDatabaseQueryExecutor, setDatabaseQueryExecutor} from '../database/CassandraQueryExecution';
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '../database/PostgresKvQueryExecutor';
import {GuildDataRepository} from '../guild/repositories/GuildDataRepository';
import type {ILogger} from '../ILogger';
import {JobLedgerRepository} from '../jobs/JobLedgerRepository';
import {startAbuseReplicationSubscriber, stopAbuseReplicationSubscriber} from '../middleware/AbusiveIpAutoBanner';
import {ipBanCache} from '../middleware/IpBanMiddleware';
import {initializeServiceSingletons, shutdownReportService} from '../middleware/ServiceMiddleware';
import {
ensureVoiceResourcesInitialized,
getKVClient,
getSnowflakeService,
setInjectedWorkerService,
} from '../middleware/ServiceRegistry';
import {
getCacheService,
getInstanceConfigRepository,
getKVAccountDeletionQueue,
getReportRepository,
getUserRepository,
} from '../middleware/ServiceSingletons';
import {torExitListCache} from '../middleware/TorExitListCache';
import {initializeSearch, shutdownSearch} from '../SearchFactory';
import {warmupAdminSearchIndexes} from '../search/SearchWarmup';
import {VisionarySlotInitializer} from '../stripe/VisionarySlotInitializer';
import {VoiceDataInitializer} from '../voice/VoiceDataInitializer';
import {JetStreamWorkerQueue} from '../worker/JetStreamWorkerQueue';
import {WorkerService} from '../worker/WorkerService';
let jsConnectionManager: JetStreamConnectionManager | null = null;
function unsupportedDatabaseBackend(backend: never): never {
throw new Error(`Unsupported database backend during shutdown: ${String(backend)}`);
}
export function createInitializer(config: APIConfig, logger: ILogger): () => Promise<void> {
return async (): Promise<void> => {
try {
logger.info('Initializing API service...');
const geoipStartupResult = await ensureGeoipDatabaseOnStartup({
geoip: config.geoip,
s3Config: {
endpoint: config.s3.endpoint,
region: config.s3.region,
accessKeyId: config.s3.accessKeyId,
secretAccessKey: config.s3.secretAccessKey,
},
});
if (geoipStartupResult.mode === 's3') {
logger.info(
{
maxmind_db_path: geoipStartupResult.maxmindDbPath,
city: geoipStartupResult.city,
asn: geoipStartupResult.asn,
s3_bucket: geoipStartupResult.bucket,
s3_key: geoipStartupResult.key,
},
'GeoIP databases downloaded from S3',
);
}
if (config.database.backend === 'postgres' && !hasDatabaseQueryExecutor()) {
await initPostgres(config.postgres);
const postgres = getDefaultPostgresClient();
await ensurePostgresKvSchema(postgres);
setDatabaseQueryExecutor(new PostgresKvQueryExecutor(postgres));
logger.info('Postgres KV client initialized');
} else if (config.database.backend === 'postgres') {
logger.info('Using injected database query executor for tests');
}
if (config.database.backend === 'cassandra' && !hasDatabaseQueryExecutor()) {
await initCassandra({
hosts: config.cassandra.hosts.split(',').filter(Boolean),
port: config.cassandra.port,
keyspace: config.cassandra.keyspace,
localDc: config.cassandra.localDc,
username: config.cassandra.username || undefined,
password: config.cassandra.password || undefined,
});
logger.info('Cassandra client initialized');
} else if (config.database.backend === 'cassandra') {
logger.info('Using injected database query executor for tests');
}
const kvClient = getKVClient();
ipBanCache.setRefreshSubscriber(kvClient);
await ipBanCache.initialize();
logger.info('IP ban cache initialized');
await startAbuseReplicationSubscriber(kvClient);
logger.info('Abusive-IP auto-banner replication started');
torExitListCache.setKvClient(kvClient);
await torExitListCache.initialize();
logger.info('Tor exit list cache initialized');
const {urlBlocklistCache} = await import('../middleware/UrlBlocklistCache');
urlBlocklistCache.setRefreshSubscriber(kvClient);
const {getStorageService} = await import('../middleware/ServiceSingletons');
urlBlocklistCache.setStorageService(getStorageService());
await urlBlocklistCache.initialize();
logger.info('URL blocklist cache initialized');
const {fileShaCache} = await import('../middleware/FileShaCache');
fileShaCache.setRefreshSubscriber(kvClient);
await fileShaCache.initialize();
logger.info('File SHA blocklist cache initialized');
const {phraseBlocklistCache} = await import('../middleware/PhraseBlocklistCache');
phraseBlocklistCache.setRefreshSubscriber(kvClient);
await phraseBlocklistCache.initialize();
logger.info('Phrase blocklist cache initialized');
const {bannedAvatarHashCache} = await import('../middleware/BannedAvatarHashCache');
bannedAvatarHashCache.setRefreshSubscriber(kvClient);
await bannedAvatarHashCache.initialize();
logger.info('Banned avatar hash cache initialized');
const {profileSubstringBlocklistCache} = await import('../middleware/ProfileSubstringBlocklistCache');
profileSubstringBlocklistCache.setRefreshSubscriber(kvClient);
await profileSubstringBlocklistCache.initialize();
logger.info('Profile substring blocklist cache initialized');
await initializeServiceSingletons();
logger.info('Service singletons initialized');
if (!config.dev.testModeEnabled) {
jsConnectionManager = new JetStreamConnectionManager({
url: config.nats.jetStreamUrl,
token: config.nats.authToken || undefined,
name: 'api-worker',
});
await jsConnectionManager.connect();
const workerQueue = new JetStreamWorkerQueue(jsConnectionManager);
await workerQueue.ensureStream();
setInjectedWorkerService(new WorkerService(workerQueue, getSnowflakeService(), new JobLedgerRepository()));
logger.info('JetStream worker service initialized');
}
try {
const kvDeletionQueue = getKVAccountDeletionQueue();
if (await kvDeletionQueue.needsRebuild()) {
logger.info('KV deletion queue needs rebuild, rebuilding...');
await kvDeletionQueue.rebuildState();
} else {
logger.info('KV deletion queue state is healthy');
}
} catch (error) {
logger.error({error}, 'Failed to verify KV deletion queue state');
throw error;
}
logger.info('Initializing search indexes...');
let searchInitialized = false;
try {
await initializeSearch(getCacheService());
searchInitialized = true;
logger.info('Search initialized');
} catch (error) {
logger.error({error}, 'Search initialisation failed');
throw error;
}
if (searchInitialized) {
const warmupLockKey = 'fluxer:search:warmup:admin';
const warmupLockToken = randomUUID();
const warmupLockTtlSeconds = 60 * 60;
const acquiredWarmupLock = await kvClient.acquireLock(warmupLockKey, warmupLockToken, warmupLockTtlSeconds);
if (!acquiredWarmupLock) {
logger.info('Another API instance is warming search indexes, skipping warmup');
} else {
try {
await warmupAdminSearchIndexes({
userRepository: getUserRepository(),
guildRepository: new GuildDataRepository(),
reportRepository: getReportRepository(),
logger,
});
} catch (error) {
logger.error({error}, 'Admin search warmup failed (continuing startup)');
} finally {
try {
await kvClient.releaseLock(warmupLockKey, warmupLockToken);
} catch (error) {
logger.warn({error}, 'Failed to release admin search warmup lock');
}
}
}
}
if (config.voice.enabled && config.voice.defaultRegion) {
const voiceDataInitializer = new VoiceDataInitializer();
await voiceDataInitializer.initialize();
await ensureVoiceResourcesInitialized();
logger.info('Voice data initialized');
}
if (config.dev.testModeEnabled && config.stripe.enabled) {
const visionarySlotInitializer = new VisionarySlotInitializer();
await visionarySlotInitializer.initialize();
logger.info('Stripe visionary slots initialized');
}
if (config.dev.testModeEnabled) {
const instanceConfigRepository = getInstanceConfigRepository();
try {
await instanceConfigRepository.setSsoConfig({
enabled: false,
authorizationUrl: null,
tokenUrl: null,
clientId: null,
});
logger.info('Reset SSO config to disabled for test mode');
} catch (error) {
logger.warn({error}, 'Failed to reset SSO config for test mode');
}
}
logger.info('API service initialization complete');
} catch (error) {
logger.error({error}, 'API service initialization failed');
await createShutdown(config, logger)();
throw error;
}
};
}
export function createShutdown(config: APIConfig, logger: ILogger): () => Promise<void> {
return async (): Promise<void> => {
logger.info('Shutting down API service...');
if (jsConnectionManager) {
try {
await jsConnectionManager.drain();
jsConnectionManager = null;
} catch (error) {
logger.error({error}, 'Error draining JetStream worker connection');
}
}
setInjectedWorkerService(undefined);
try {
await shutdownSearch();
logger.info('Search service shut down');
} catch (error) {
logger.error({error}, 'Error shutting down search service');
}
try {
ipBanCache.shutdown();
logger.info('IP ban cache shut down');
} catch (error) {
logger.error({error}, 'Error shutting down IP ban cache');
}
try {
await stopAbuseReplicationSubscriber();
logger.info('Abusive-IP auto-banner replication stopped');
} catch (error) {
logger.error({error}, 'Error stopping abusive-IP auto-banner replication');
}
try {
torExitListCache.shutdown();
logger.info('Tor exit list cache shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Tor exit list cache');
}
try {
getInstanceConfigRepository().shutdown();
logger.info('Instance config repository shut down');
} catch (error) {
logger.error({error}, 'Error shutting down instance config repository');
}
try {
shutdownReportService();
logger.info('Report service shut down');
} catch (error) {
logger.error({error}, 'Error shutting down report service');
}
setDatabaseQueryExecutor(null);
switch (config.database.backend) {
case 'postgres':
try {
await shutdownPostgres();
logger.info('Postgres client shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Postgres client');
}
break;
case 'cassandra':
try {
await shutdownCassandra();
logger.info('Cassandra client shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Cassandra client');
}
break;
default:
unsupportedDatabaseBackend(config.database.backend);
}
logger.info('API service shutdown complete');
};
}