import { asValue } from 'awilix' import { Pool } from 'pg' import type { AppContainer } from '@open-mercato/shared/lib/di/container' import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection' import { EmbeddingService, createPgVectorDriver, createChromaDbDriver, createQdrantDriver } from '../../vector' import type { PgPool } from '../../vector/drivers/pgvector' import { createVectorIndexingQueue, type VectorIndexJobPayload } from '../../queue/vector-indexing' import { createFulltextIndexingQueue, type FulltextIndexJobPayload } from '../../queue/fulltext-indexing' import { createEmbeddingProviderProbe } from './lib/provider-probe' import type { Queue } from '@open-mercato/queue' const EMBEDDING_SERVICE_KEY = '__omSearchEmbeddingService__' const VECTOR_DRIVERS_KEY = '__omSearchVectorDrivers__' const VECTOR_INDEX_QUEUE_KEY = '__omSearchVectorIndexQueue__' const FULLTEXT_INDEX_QUEUE_KEY = '__omSearchFulltextIndexQueue__' const PG_POOL_KEY = '__omSearchVectorPgPool__' const SHUTDOWN_KEY = '__omSearchSingletonsShutdown__' type SearchSingletonCache = { [EMBEDDING_SERVICE_KEY]?: EmbeddingService [VECTOR_DRIVERS_KEY]?: ReturnType[] [VECTOR_INDEX_QUEUE_KEY]?: Queue [FULLTEXT_INDEX_QUEUE_KEY]?: Queue [PG_POOL_KEY]?: PgPool [SHUTDOWN_KEY]?: boolean } function getSearchGlobals(): SearchSingletonCache { // L1: these globals are NOT cleared on HMR — module re-evaluation hits the // cache-hit fast-path so no new connections are opened. Restart the dev server // (or set SEARCH_DISABLE_SINGLETON_CACHE=1) when a clean slate is needed. return globalThis as unknown as SearchSingletonCache } function isSingletonCacheEnabled(): boolean { return process.env.SEARCH_DISABLE_SINGLETON_CACHE !== '1' } function buildQueueOptions(): { queueStrategy: 'local' | 'async' queueConnection: { connection: { url: string } } | undefined } { const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async' const queueConnection = queueStrategy === 'async' ? { connection: { url: getRedisUrlOrThrow('QUEUE') } } : undefined return { queueStrategy, queueConnection } } function getOrCreateSingletons(): { embeddingService: EmbeddingService drivers: ReturnType[] vectorIndexQueue: Queue fulltextIndexQueue: Queue } { const g = getSearchGlobals() if ( g[EMBEDDING_SERVICE_KEY] && g[VECTOR_DRIVERS_KEY] && g[VECTOR_INDEX_QUEUE_KEY] && g[FULLTEXT_INDEX_QUEUE_KEY] ) { return { embeddingService: g[EMBEDDING_SERVICE_KEY], drivers: g[VECTOR_DRIVERS_KEY], vectorIndexQueue: g[VECTOR_INDEX_QUEUE_KEY], fulltextIndexQueue: g[FULLTEXT_INDEX_QUEUE_KEY], } } const embeddingService = new EmbeddingService() // Create the pgvector pool separately so we can close it in the shutdown hook let pgPool: PgPool | undefined const dbUrl = process.env.DATABASE_URL if (dbUrl) { pgPool = new Pool({ connectionString: dbUrl }) as unknown as PgPool g[PG_POOL_KEY] = pgPool } const drivers = [ createPgVectorDriver(pgPool ? { pool: pgPool } : {}), createChromaDbDriver(), createQdrantDriver(), ] const { queueStrategy, queueConnection } = buildQueueOptions() const vectorIndexQueue: Queue = createVectorIndexingQueue( queueStrategy, queueConnection, ) const fulltextIndexQueue: Queue = createFulltextIndexingQueue( queueStrategy, queueConnection, ) g[EMBEDDING_SERVICE_KEY] = embeddingService g[VECTOR_DRIVERS_KEY] = drivers g[VECTOR_INDEX_QUEUE_KEY] = vectorIndexQueue g[FULLTEXT_INDEX_QUEUE_KEY] = fulltextIndexQueue if (!g[SHUTDOWN_KEY]) { const shutdown = () => { // L2: chromadb/qdrant drivers use stateless HTTP clients — no persistent // connection to close. Only the pg.Pool and BullMQ queues need teardown. g[PG_POOL_KEY]?.end().catch(() => {}) g[VECTOR_INDEX_QUEUE_KEY]?.close().catch(() => {}) g[FULLTEXT_INDEX_QUEUE_KEY]?.close().catch(() => {}) } process.once('SIGTERM', shutdown) process.once('SIGINT', shutdown) g[SHUTDOWN_KEY] = true } return { embeddingService, drivers, vectorIndexQueue, fulltextIndexQueue } } /** * Register search module dependencies. * * This registers: * - vectorEmbeddingService: EmbeddingService for creating embeddings * - vectorDrivers: Array of vector database drivers (pgvector, chromadb, qdrant) * - vectorIndexQueue: Queue for vector indexing jobs * - fulltextIndexQueue: Queue for fulltext indexing jobs * * These four are process-scoped singletons (globalThis) to avoid per-request pg.Pool * creation, DDL re-runs, and BullMQ/ioredis connection leaks. Disable with * SEARCH_DISABLE_SINGLETON_CACHE=1. * * Note: VectorIndexService is no longer registered here. Use SearchIndexer instead, * which is registered in the main search module DI (packages/search/src/di.ts). */ export function register(container: AppContainer) { if (!isSingletonCacheEnabled()) { const { queueStrategy, queueConnection } = buildQueueOptions() container.register({ vectorEmbeddingService: asValue(new EmbeddingService()), vectorDrivers: asValue([ createPgVectorDriver(), createChromaDbDriver(), createQdrantDriver(), ]), vectorIndexQueue: asValue(createVectorIndexingQueue(queueStrategy, queueConnection)), fulltextIndexQueue: asValue(createFulltextIndexingQueue(queueStrategy, queueConnection)), }) return } const { embeddingService, drivers, vectorIndexQueue, fulltextIndexQueue } = getOrCreateSingletons() container.register({ vectorEmbeddingService: asValue(embeddingService), vectorDrivers: asValue(drivers), vectorIndexQueue: asValue(vectorIndexQueue), fulltextIndexQueue: asValue(fulltextIndexQueue), embeddingProviderProbe: asValue(createEmbeddingProviderProbe(container)), }) }