48ec5c658a
PROBLEM: Request coalescing infrastructure (inflightLIDLookups, inflightPNLookups Maps and coalesceRequest() method) was implemented in commita9374bdbut never integrated into the public API. The Maps remained empty and the coalescing method was never called, wasting the optimization potential. In message burst scenarios (10+ concurrent messages from same user), each concurrent call to getLIDForPN() would execute a separate database lookup for the same user, causing: - Redundant database queries (9 wasted queries in 10 concurrent calls) - Increased database load during high traffic - Higher latency for duplicate lookups - Missed optimization opportunity SOLUTION: Integrated request coalescing into single-item lookup methods: 1. getLIDForPN(pn): Now uses coalesceRequest() with inflightLIDLookups Map - First concurrent call creates Promise and stores in Map - Subsequent calls for same PN return existing Promise - Result: 1 DB lookup shared by all concurrent calls 2. getPNForLID(lid): Now uses coalesceRequest() with inflightPNLookups Map - Same deduplication pattern for reverse lookups - Optimizes LID→PN translation in message processing 3. Updated Maps documentation to reflect active usage - Clarified that Maps are now actively used (not "future optimization") - Documented usage in getLIDForPN() and getPNForLID() Implementation Details: - Both methods now use trackOperation() wrapper (ensures thread safety via V4) - Early validation before coalescing (isAnyPnUser/isAnyLidUser, jidDecode) - Coalescing keyed by user (not full JID) for maximum deduplication - Batch methods (getLIDsForPNs, getPNsForLIDs) unchanged (already optimized) BENEFITS: - Reduces DB load by 90% in 10-concurrent-call burst scenarios - Improves latency for duplicate lookups (instant return from existing Promise) - No downside: if no concurrency, behaves exactly as before - Completes optimization work started in commita9374bdSAFETY: ✓ Protected by trackOperation() (V4 fix - prevents UAF during destroy) ✓ Maps cleared in destroy() (V5 fix - prevents memory leaks) ✓ No TOCTOU in coalesceRequest() (V6 fix - single check at operation start) ✓ Thread-safe: Maps protected by operationsInProgress counter VALIDATION: ✓ Protocolo de Análise complete (5 steps) ✓ TypeScript compilation verified (no new errors) ✓ E2E scenarios simulated (burst of 10 concurrent calls) ✓ Backward compatible (batch methods unchanged, single methods enhanced) FILES MODIFIED: - src/Signal/lid-mapping.ts:165-181 - Updated Maps documentation (now active) - src/Signal/lid-mapping.ts:470-502 - Integrated coalescing in getLIDForPN() - src/Signal/lid-mapping.ts:659-691 - Integrated coalescing in getPNForLID() https://claude.ai/code/session_01NTVq3RHgGpgKL289JGvw55
1167 lines
35 KiB
TypeScript
1167 lines
35 KiB
TypeScript
import { LRUCache } from 'lru-cache'
|
|
import type { LIDMapping, SignalKeyStoreWithTransaction } from '../Types'
|
|
import type { ILogger } from '../Utils/logger'
|
|
import { isAnyLidUser, isAnyPnUser, isHostedPnUser, jidDecode, jidNormalizedUser, WAJIDDomains } from '../WABinary'
|
|
|
|
// ============================================================================
|
|
// CONFIGURATION
|
|
// ============================================================================
|
|
|
|
/**
|
|
* LID Mapping Store configuration
|
|
* Configurable via environment variables with BAILEYS_LID_* prefix
|
|
*/
|
|
export interface LIDMappingConfig {
|
|
/** Cache TTL in milliseconds (default: 3 days) */
|
|
cacheTtlMs: number
|
|
/** Maximum cache size (default: 50000 entries) */
|
|
maxCacheSize: number
|
|
/** Enable cache auto-purge (default: true) */
|
|
cacheAutoPurge: boolean
|
|
/** Update cache age on get (default: true) */
|
|
updateAgeOnGet: boolean
|
|
/** Enable Prometheus metrics (default: false) */
|
|
enableMetrics: boolean
|
|
/** Batch size for bulk operations (default: 100) */
|
|
batchSize: number
|
|
/** Retry attempts for failed operations (default: 3, max: 10) */
|
|
retryAttempts: number
|
|
/** Base retry delay in ms (default: 1000). Uses exponential backoff: delay * 2^(attempt-1) */
|
|
retryDelayMs: number
|
|
/** Enable debug logging (default: false) */
|
|
debugLogging: boolean
|
|
}
|
|
|
|
/**
|
|
* Load configuration from environment variables
|
|
* Includes bounds validation to prevent DoS from malicious values
|
|
*/
|
|
export function loadLIDMappingConfig(): LIDMappingConfig {
|
|
// Helper to clamp values within safe bounds
|
|
const clamp = (value: number, min: number, max: number): number =>
|
|
Math.max(min, Math.min(max, isNaN(value) ? min : value))
|
|
|
|
const cacheTtlMs = parseInt(process.env.BAILEYS_LID_CACHE_TTL_MS || String(3 * 24 * 60 * 60 * 1000), 10)
|
|
const maxCacheSize = parseInt(process.env.BAILEYS_LID_MAX_CACHE_SIZE || '50000', 10)
|
|
const batchSize = parseInt(process.env.BAILEYS_LID_BATCH_SIZE || '100', 10)
|
|
const retryAttempts = parseInt(process.env.BAILEYS_LID_RETRY_ATTEMPTS || '3', 10)
|
|
const retryDelayMs = parseInt(process.env.BAILEYS_LID_RETRY_DELAY_MS || '1000', 10)
|
|
|
|
return {
|
|
// Cache TTL: minimum 1 minute, maximum 30 days
|
|
cacheTtlMs: clamp(cacheTtlMs, 60_000, 30 * 24 * 60 * 60 * 1000),
|
|
// Cache size: minimum 100, maximum 1,000,000
|
|
maxCacheSize: clamp(maxCacheSize, 100, 1_000_000),
|
|
cacheAutoPurge: process.env.BAILEYS_LID_CACHE_AUTO_PURGE !== 'false',
|
|
updateAgeOnGet: process.env.BAILEYS_LID_UPDATE_AGE_ON_GET !== 'false',
|
|
enableMetrics: process.env.BAILEYS_LID_METRICS === 'true',
|
|
// Batch size: minimum 1 (prevents infinite loop), maximum 1000
|
|
batchSize: clamp(batchSize, 1, 1000),
|
|
// Retry attempts: minimum 1, maximum 10
|
|
retryAttempts: clamp(retryAttempts, 1, 10),
|
|
// Retry delay: minimum 100ms, maximum 60 seconds
|
|
retryDelayMs: clamp(retryDelayMs, 100, 60_000),
|
|
debugLogging: process.env.BAILEYS_LID_DEBUG === 'true'
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// STATISTICS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Statistics for monitoring and debugging
|
|
*/
|
|
export interface LIDMappingStatistics {
|
|
/** Total cache hits */
|
|
cacheHits: number
|
|
/** Total cache misses */
|
|
cacheMisses: number
|
|
/** Total database hits */
|
|
dbHits: number
|
|
/** Total database misses */
|
|
dbMisses: number
|
|
/** Total USync fetches */
|
|
usyncFetches: number
|
|
/** Total USync failures */
|
|
usyncFailures: number
|
|
/** Total mappings stored */
|
|
mappingsStored: number
|
|
/** Total invalid mappings rejected */
|
|
invalidMappings: number
|
|
/** Current cache size */
|
|
cacheSize: number
|
|
/** Cache hit rate (percentage) */
|
|
cacheHitRate: number
|
|
/** Total operations */
|
|
totalOperations: number
|
|
/** Failed operations */
|
|
failedOperations: number
|
|
/** Store creation timestamp */
|
|
createdAt: number
|
|
/** Last operation timestamp */
|
|
lastOperationAt: number | null
|
|
}
|
|
|
|
// ============================================================================
|
|
// ERROR TYPES
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Custom error for LID mapping operations
|
|
*/
|
|
export class LIDMappingError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly code: LIDMappingErrorCode,
|
|
public readonly details?: Record<string, unknown>
|
|
) {
|
|
super(message)
|
|
this.name = 'LIDMappingError'
|
|
}
|
|
}
|
|
|
|
export enum LIDMappingErrorCode {
|
|
INVALID_JID = 'INVALID_JID',
|
|
INVALID_MAPPING = 'INVALID_MAPPING',
|
|
DATABASE_ERROR = 'DATABASE_ERROR',
|
|
USYNC_ERROR = 'USYNC_ERROR',
|
|
CACHE_ERROR = 'CACHE_ERROR',
|
|
DESTROYED = 'DESTROYED'
|
|
}
|
|
|
|
// ============================================================================
|
|
// MAIN CLASS
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Enterprise-grade LID Mapping Store
|
|
*
|
|
* Features:
|
|
* - Environment variable configuration
|
|
* - LRU cache with configurable TTL and size
|
|
* - Comprehensive statistics and metrics
|
|
* - Batch operations for bulk mappings
|
|
* - Retry logic for failed operations
|
|
* - Proper resource cleanup
|
|
* - Prometheus metrics integration
|
|
*/
|
|
export class LIDMappingStore {
|
|
private readonly mappingCache: LRUCache<string, string>
|
|
private readonly keys: SignalKeyStoreWithTransaction
|
|
private readonly logger: ILogger
|
|
private readonly config: LIDMappingConfig
|
|
private destroyed: boolean = false
|
|
|
|
/**
|
|
* Operation counter for safe resource cleanup
|
|
* Tracks number of operations currently in progress to prevent UAF in destroy()
|
|
* Incremented at operation start, decremented at operation end
|
|
*/
|
|
private operationsInProgress: number = 0
|
|
|
|
private pnToLIDFunc?: (jids: string[]) => Promise<LIDMapping[] | undefined>
|
|
|
|
/**
|
|
* Request coalescing Maps - deduplicates concurrent lookups
|
|
*
|
|
* USAGE: Active in getLIDForPN() and getPNForLID() to deduplicate
|
|
* concurrent lookups for the same user. In message bursts, multiple
|
|
* concurrent calls share a single database lookup.
|
|
*
|
|
* MEMORY SAFETY: Cleared in destroy() to prevent memory leaks.
|
|
* Pending Promises complete but won't be returned to new callers.
|
|
*
|
|
* THREAD SAFETY: Protected by operationsInProgress counter (V4 fix).
|
|
* - Maps are only cleared when operationsInProgress === 0
|
|
* - Operations using coalesceRequest() MUST be wrapped with trackOperation()
|
|
* - This ensures maps won't be cleared while coalesceRequest() is accessing them
|
|
*/
|
|
private readonly inflightLIDLookups = new Map<string, Promise<string | null>>()
|
|
private readonly inflightPNLookups = new Map<string, Promise<string | null>>()
|
|
|
|
// Statistics tracking
|
|
private stats: LIDMappingStatistics = {
|
|
cacheHits: 0,
|
|
cacheMisses: 0,
|
|
dbHits: 0,
|
|
dbMisses: 0,
|
|
usyncFetches: 0,
|
|
usyncFailures: 0,
|
|
mappingsStored: 0,
|
|
invalidMappings: 0,
|
|
cacheSize: 0,
|
|
cacheHitRate: 0,
|
|
totalOperations: 0,
|
|
failedOperations: 0,
|
|
createdAt: Date.now(),
|
|
lastOperationAt: null
|
|
}
|
|
|
|
constructor(
|
|
keys: SignalKeyStoreWithTransaction,
|
|
logger: ILogger,
|
|
pnToLIDFunc?: (jids: string[]) => Promise<LIDMapping[] | undefined>,
|
|
configOverride?: Partial<LIDMappingConfig>
|
|
) {
|
|
this.keys = keys
|
|
this.pnToLIDFunc = pnToLIDFunc
|
|
this.logger = logger
|
|
this.config = { ...loadLIDMappingConfig(), ...configOverride }
|
|
|
|
// Initialize LRU cache with configuration
|
|
this.mappingCache = new LRUCache<string, string>({
|
|
max: this.config.maxCacheSize,
|
|
ttl: this.config.cacheTtlMs,
|
|
ttlAutopurge: this.config.cacheAutoPurge,
|
|
updateAgeOnGet: this.config.updateAgeOnGet
|
|
})
|
|
|
|
this.logger.debug({ config: this.config }, 'LIDMappingStore initialized')
|
|
}
|
|
|
|
// ========================================================================
|
|
// CONFIGURATION & STATISTICS
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Get current configuration
|
|
* Safe to call after destroy for debugging purposes
|
|
*/
|
|
getConfig(): LIDMappingConfig {
|
|
return { ...this.config }
|
|
}
|
|
|
|
/**
|
|
* Get current statistics
|
|
* Safe to call after destroy for final metrics collection
|
|
*/
|
|
getStatistics(): LIDMappingStatistics {
|
|
const totalLookups = this.stats.cacheHits + this.stats.cacheMisses
|
|
return {
|
|
...this.stats,
|
|
cacheSize: this.destroyed ? 0 : this.mappingCache.size,
|
|
cacheHitRate: totalLookups > 0 ? (this.stats.cacheHits / totalLookups) * 100 : 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if store has been destroyed
|
|
*/
|
|
isDestroyed(): boolean {
|
|
return this.destroyed
|
|
}
|
|
|
|
// ========================================================================
|
|
// CACHE MANAGEMENT
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Warm cache with pre-loaded mappings
|
|
* Useful for initialization with known mappings
|
|
*/
|
|
async warmCache(mappings: LIDMapping[]): Promise<{ loaded: number; skipped: number }> {
|
|
this.checkDestroyed()
|
|
let loaded = 0
|
|
let skipped = 0
|
|
|
|
for (const { lid, pn } of mappings) {
|
|
if (!this.isValidMapping(lid, pn)) {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
const lidDecoded = jidDecode(lid)
|
|
const pnDecoded = jidDecode(pn)
|
|
if (!lidDecoded || !pnDecoded) {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
const pnUser = pnDecoded.user
|
|
const lidUser = lidDecoded.user
|
|
|
|
this.mappingCache.set(`pn:${pnUser}`, lidUser)
|
|
this.mappingCache.set(`lid:${lidUser}`, pnUser)
|
|
loaded++
|
|
}
|
|
|
|
this.logger.debug({ loaded, skipped }, 'Cache warmed with mappings')
|
|
return { loaded, skipped }
|
|
}
|
|
|
|
/**
|
|
* Clear all cached mappings
|
|
*/
|
|
clearCache(): void {
|
|
this.checkDestroyed()
|
|
const previousSize = this.mappingCache.size
|
|
this.mappingCache.clear()
|
|
this.logger.debug({ previousSize }, 'Cache cleared')
|
|
}
|
|
|
|
/**
|
|
* Get cache info for monitoring
|
|
* Safe to call after destroy for final reporting
|
|
*/
|
|
getCacheInfo(): { size: number; maxSize: number; ttlMs: number } {
|
|
return {
|
|
size: this.destroyed ? 0 : this.mappingCache.size,
|
|
maxSize: this.config.maxCacheSize,
|
|
ttlMs: this.config.cacheTtlMs
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// STORE OPERATIONS
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Store LID-PN mapping - USER LEVEL
|
|
* Enhanced with batch operations and retry logic
|
|
*
|
|
* @param pairs - Array of LID-PN mappings to store
|
|
* @returns Statistics about the operation (stored, skipped, errors)
|
|
*
|
|
* Note: Return type changed from void to statistics object.
|
|
* Existing callers that ignore the return value remain compatible.
|
|
*/
|
|
async storeLIDPNMappings(pairs: LIDMapping[]): Promise<{ stored: number; skipped: number; errors: number }> {
|
|
this.checkDestroyed()
|
|
|
|
// Track operation to prevent UAF during destroy()
|
|
return this.trackOperation(async () => {
|
|
this.stats.totalOperations++
|
|
this.stats.lastOperationAt = Date.now()
|
|
|
|
const result = { stored: 0, skipped: 0, errors: 0 }
|
|
|
|
// Phase 1: Validate and collect cache misses
|
|
const cacheMissPnUsers: string[] = []
|
|
const pendingValidation = new Map<string, { pnUser: string; lidUser: string }>()
|
|
|
|
for (const { lid, pn } of pairs) {
|
|
if (!this.isValidMapping(lid, pn)) {
|
|
this.logger.warn({ lid, pn }, 'Invalid LID-PN mapping rejected')
|
|
this.stats.invalidMappings++
|
|
result.skipped++
|
|
continue
|
|
}
|
|
|
|
const lidDecoded = jidDecode(lid)
|
|
const pnDecoded = jidDecode(pn)
|
|
|
|
if (!lidDecoded || !pnDecoded) {
|
|
result.skipped++
|
|
continue
|
|
}
|
|
|
|
const pnUser = pnDecoded.user
|
|
const lidUser = lidDecoded.user
|
|
|
|
// Check cache first
|
|
const existingLidUser = this.mappingCache.get(`pn:${pnUser}`)
|
|
|
|
if (existingLidUser !== undefined) {
|
|
// Cache hit
|
|
this.stats.cacheHits++
|
|
if (existingLidUser === lidUser) {
|
|
if (this.config.debugLogging) {
|
|
this.logger.debug({ pnUser, lidUser }, 'LID mapping already exists, skipping')
|
|
}
|
|
result.skipped++
|
|
} else {
|
|
// Different mapping - will be stored
|
|
pendingValidation.set(pnUser, { pnUser, lidUser })
|
|
}
|
|
} else {
|
|
// Cache miss - queue for batch DB fetch
|
|
this.stats.cacheMisses++
|
|
cacheMissPnUsers.push(pnUser)
|
|
pendingValidation.set(pnUser, { pnUser, lidUser })
|
|
}
|
|
}
|
|
|
|
// Phase 2: Batch fetch all cache misses from DB
|
|
if (cacheMissPnUsers.length > 0) {
|
|
const batches = this.chunkArray(cacheMissPnUsers, this.config.batchSize)
|
|
|
|
for (const batch of batches) {
|
|
try {
|
|
const stored = await this.retryOperation(
|
|
() => this.keys.get('lid-mapping', batch),
|
|
'batch-get-mappings'
|
|
)
|
|
|
|
// Update cache and validate against DB
|
|
for (const pnUser of batch) {
|
|
const existingLidUser = stored[pnUser]
|
|
|
|
if (existingLidUser) {
|
|
this.stats.dbHits++
|
|
// Update cache with database value
|
|
this.mappingCache.set(`pn:${pnUser}`, existingLidUser)
|
|
this.mappingCache.set(`lid:${existingLidUser}`, pnUser)
|
|
|
|
// Check if this mapping should be skipped
|
|
const pending = pendingValidation.get(pnUser)
|
|
if (pending && existingLidUser === pending.lidUser) {
|
|
if (this.config.debugLogging) {
|
|
this.logger.debug(
|
|
{ pnUser, lidUser: pending.lidUser },
|
|
'LID mapping already exists in DB, skipping'
|
|
)
|
|
}
|
|
result.skipped++
|
|
pendingValidation.delete(pnUser)
|
|
}
|
|
} else {
|
|
this.stats.dbMisses++
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logger.error({ error, batchSize: batch.length }, 'Failed to batch fetch existing mappings')
|
|
result.errors += batch.length
|
|
// Remove failed fetches from pending validation to avoid storing them
|
|
for (const pnUser of batch) {
|
|
pendingValidation.delete(pnUser)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 3: Store new/updated mappings
|
|
const validPairs = Array.from(pendingValidation.values())
|
|
|
|
if (validPairs.length === 0) {
|
|
return result
|
|
}
|
|
|
|
const storeBatches = this.chunkArray(validPairs, this.config.batchSize)
|
|
|
|
for (const batch of storeBatches) {
|
|
try {
|
|
await this.retryOperation(async () => {
|
|
await this.keys.transaction(async () => {
|
|
for (const { pnUser, lidUser } of batch) {
|
|
await this.keys.set({
|
|
'lid-mapping': {
|
|
[pnUser]: lidUser,
|
|
[`${lidUser}_reverse`]: pnUser
|
|
}
|
|
})
|
|
|
|
this.mappingCache.set(`pn:${pnUser}`, lidUser)
|
|
this.mappingCache.set(`lid:${lidUser}`, pnUser)
|
|
result.stored++
|
|
this.stats.mappingsStored++
|
|
}
|
|
}, 'lid-mapping')
|
|
}, 'store-mappings')
|
|
} catch (error) {
|
|
this.logger.error({ error, batchSize: batch.length }, 'Failed to store mapping batch')
|
|
result.errors += batch.length
|
|
this.stats.failedOperations++
|
|
}
|
|
}
|
|
|
|
this.logger.trace({ result, totalPairs: pairs.length, cacheMisses: cacheMissPnUsers.length }, 'Stored LID-PN mappings with batch optimization')
|
|
this.recordMetrics('store', result.stored)
|
|
|
|
return result
|
|
}) // End trackOperation
|
|
}
|
|
|
|
/**
|
|
* Get LID for PN - Returns device-specific LID based on user mapping
|
|
*
|
|
* OPTIMIZATION: Uses request coalescing to deduplicate concurrent lookups
|
|
* for the same PN. In message bursts, multiple concurrent calls for the same
|
|
* user will share a single database lookup, reducing load and improving latency.
|
|
*
|
|
* Thread Safety: Protected by trackOperation() wrapper (V4 fix)
|
|
*/
|
|
async getLIDForPN(pn: string): Promise<string | null> {
|
|
this.checkDestroyed()
|
|
|
|
return this.trackOperation(async () => {
|
|
// Early validation
|
|
if (!isAnyPnUser(pn)) return null
|
|
|
|
const decoded = jidDecode(pn)
|
|
if (!decoded) return null
|
|
|
|
const pnUser = decoded.user
|
|
|
|
// Use request coalescing to deduplicate concurrent lookups
|
|
// Safe because: wrapped in trackOperation() prevents resource cleanup
|
|
return this.coalesceRequest(
|
|
pnUser,
|
|
this.inflightLIDLookups,
|
|
async () => {
|
|
const results = await this.getLIDsForPNs([pn])
|
|
return results?.[0]?.lid || null
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get LIDs for multiple PNs - Optimized batch operation
|
|
*
|
|
* Note: PNs that fail database lookup are silently skipped and queued for
|
|
* USync retry. Check statistics.failedOperations for failure counts.
|
|
* The returned array may be smaller than input if some lookups failed.
|
|
*/
|
|
async getLIDsForPNs(pns: string[]): Promise<LIDMapping[] | null> {
|
|
this.checkDestroyed()
|
|
|
|
// Track operation to prevent UAF during destroy()
|
|
return this.trackOperation(async () => {
|
|
this.stats.totalOperations++
|
|
this.stats.lastOperationAt = Date.now()
|
|
|
|
const usyncFetch: { [_: string]: number[] } = {}
|
|
const successfulPairs: { [_: string]: LIDMapping } = {}
|
|
const failedPns = new Set<string>()
|
|
const pendingByPnUser = new Map<string, Array<{ pn: string; decoded: ReturnType<typeof jidDecode> }>>()
|
|
|
|
for (const pn of pns) {
|
|
if (!isAnyPnUser(pn)) continue
|
|
|
|
const decoded = jidDecode(pn)
|
|
if (!decoded) continue
|
|
|
|
const pnUser = decoded.user
|
|
const cachedLidUser = this.mappingCache.get(`pn:${pnUser}`)
|
|
|
|
if (cachedLidUser) {
|
|
this.stats.cacheHits++
|
|
const lidUser = cachedLidUser.toString()
|
|
if (!lidUser) {
|
|
this.logger.warn({ pn, lidUser }, 'Invalid or empty LID user')
|
|
continue
|
|
}
|
|
|
|
const pnDevice = decoded.device ?? 0
|
|
const deviceSpecificLid = this.buildDeviceSpecificJid(
|
|
lidUser,
|
|
pnDevice,
|
|
decoded.server === 'hosted' ? 'hosted.lid' : 'lid'
|
|
)
|
|
|
|
if (this.config.debugLogging) {
|
|
this.logger.trace({ pn, deviceSpecificLid, pnDevice }, 'getLIDForPN: mapping found')
|
|
}
|
|
|
|
successfulPairs[pn] = { lid: deviceSpecificLid, pn }
|
|
continue
|
|
}
|
|
|
|
this.stats.cacheMisses++
|
|
const pendingForUser = pendingByPnUser.get(pnUser) ?? []
|
|
pendingForUser.push({ pn, decoded })
|
|
pendingByPnUser.set(pnUser, pendingForUser)
|
|
}
|
|
|
|
if (pendingByPnUser.size > 0) {
|
|
const pnUsers = [...pendingByPnUser.keys()]
|
|
const dbFailedPnUsers = new Set<string>()
|
|
|
|
for (const batch of this.chunkArray(pnUsers, this.config.batchSize)) {
|
|
try {
|
|
const stored = await this.retryOperation(
|
|
() => this.keys.get('lid-mapping', batch),
|
|
'get-lid-for-pn'
|
|
)
|
|
|
|
for (const pnUser of batch) {
|
|
const lidUser = stored[pnUser]
|
|
if (lidUser) {
|
|
this.stats.dbHits++
|
|
this.mappingCache.set(`pn:${pnUser}`, lidUser)
|
|
this.mappingCache.set(`lid:${lidUser}`, pnUser)
|
|
} else {
|
|
this.stats.dbMisses++
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logger.error({ error, batch }, 'Failed to get LID mapping batch from database')
|
|
this.stats.failedOperations += batch.length
|
|
batch.forEach(pnUser => dbFailedPnUsers.add(pnUser))
|
|
}
|
|
}
|
|
|
|
for (const [pnUser, items] of pendingByPnUser.entries()) {
|
|
const lidUser = this.mappingCache.get(`pn:${pnUser}`)
|
|
for (const { pn, decoded } of items) {
|
|
if (lidUser && decoded) {
|
|
const lidUserString = lidUser.toString()
|
|
if (!lidUserString) {
|
|
this.logger.warn({ pn, lidUser }, 'Invalid or empty LID user')
|
|
continue
|
|
}
|
|
|
|
const pnDevice = decoded.device ?? 0
|
|
const deviceSpecificLid = this.buildDeviceSpecificJid(
|
|
lidUserString,
|
|
pnDevice,
|
|
decoded.server === 'hosted' ? 'hosted.lid' : 'lid'
|
|
)
|
|
|
|
if (this.config.debugLogging) {
|
|
this.logger.trace({ pn, deviceSpecificLid, pnDevice }, 'getLIDForPN: mapping found')
|
|
}
|
|
|
|
successfulPairs[pn] = { lid: deviceSpecificLid, pn }
|
|
continue
|
|
}
|
|
|
|
if (dbFailedPnUsers.has(pnUser)) {
|
|
failedPns.add(pn)
|
|
}
|
|
|
|
if (!decoded) continue
|
|
|
|
// Need to fetch from USync
|
|
if (this.config.debugLogging) {
|
|
this.logger.trace({ pnUser }, 'No LID mapping found, queuing for USync')
|
|
}
|
|
|
|
const device = decoded.device || 0
|
|
let normalizedPn = jidNormalizedUser(pn)
|
|
if (isHostedPnUser(normalizedPn)) {
|
|
normalizedPn = `${pnUser}@s.whatsapp.net`
|
|
}
|
|
|
|
if (!usyncFetch[normalizedPn]) {
|
|
usyncFetch[normalizedPn] = [device]
|
|
} else {
|
|
usyncFetch[normalizedPn]?.push(device)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch from USync if needed
|
|
if (Object.keys(usyncFetch).length > 0) {
|
|
await this.fetchFromUSync(usyncFetch, successfulPairs)
|
|
}
|
|
|
|
// Log warning if some PNs failed lookup
|
|
if (failedPns.size > 0) {
|
|
this.logger.warn(
|
|
{ failedCount: failedPns.size, totalRequested: pns.length },
|
|
'Some PNs failed during getLIDsForPNs - results may be incomplete'
|
|
)
|
|
}
|
|
|
|
this.recordMetrics('get-lid', Object.keys(successfulPairs).length)
|
|
return Object.keys(successfulPairs).length > 0 ? Object.values(successfulPairs) : null
|
|
}) // End trackOperation
|
|
}
|
|
|
|
/**
|
|
* Get PN for LID - USER LEVEL with device construction
|
|
*
|
|
* OPTIMIZATION: Uses request coalescing to deduplicate concurrent lookups
|
|
* for the same LID. In message bursts, multiple concurrent calls for the same
|
|
* user will share a single database lookup, reducing load and improving latency.
|
|
*
|
|
* Thread Safety: Protected by trackOperation() wrapper (V4 fix)
|
|
*/
|
|
async getPNForLID(lid: string): Promise<string | null> {
|
|
this.checkDestroyed()
|
|
|
|
return this.trackOperation(async () => {
|
|
// Early validation
|
|
if (!isAnyLidUser(lid)) return null
|
|
|
|
const decoded = jidDecode(lid)
|
|
if (!decoded) return null
|
|
|
|
const lidUser = decoded.user
|
|
|
|
// Use request coalescing to deduplicate concurrent lookups
|
|
// Safe because: wrapped in trackOperation() prevents resource cleanup
|
|
return this.coalesceRequest(
|
|
lidUser,
|
|
this.inflightPNLookups,
|
|
async () => {
|
|
const results = await this.getPNsForLIDs([lid])
|
|
return results?.[0]?.pn || null
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get PNs for multiple LIDs - Optimized batch operation
|
|
*/
|
|
async getPNsForLIDs(lids: string[]): Promise<LIDMapping[] | null> {
|
|
this.checkDestroyed()
|
|
|
|
// Track operation to prevent UAF during destroy()
|
|
return this.trackOperation(async () => {
|
|
this.stats.totalOperations++
|
|
this.stats.lastOperationAt = Date.now()
|
|
|
|
const successfulPairs: { [_: string]: LIDMapping } = {}
|
|
const failedLids = new Set<string>()
|
|
const pendingByLidUser = new Map<string, Array<{ lid: string; decoded: ReturnType<typeof jidDecode> }>>()
|
|
|
|
const addResolvedPair = (lid: string, decoded: ReturnType<typeof jidDecode>, pnUser: string): void => {
|
|
const lidDevice = decoded?.device ?? 0
|
|
const server = decoded?.domainType === WAJIDDomains.HOSTED_LID ? 'hosted' : 's.whatsapp.net'
|
|
const pnJid = this.buildDeviceSpecificJid(pnUser, lidDevice, server)
|
|
|
|
if (this.config.debugLogging) {
|
|
this.logger.trace({ lid, pnJid }, 'Found reverse mapping')
|
|
}
|
|
|
|
successfulPairs[lid] = { lid, pn: pnJid }
|
|
}
|
|
|
|
for (const lid of lids) {
|
|
if (!isAnyLidUser(lid)) continue
|
|
|
|
const decoded = jidDecode(lid)
|
|
if (!decoded) continue
|
|
|
|
const lidUser = decoded.user
|
|
const cachedPnUser = this.mappingCache.get(`lid:${lidUser}`)
|
|
|
|
if (cachedPnUser && typeof cachedPnUser === 'string') {
|
|
this.stats.cacheHits++
|
|
addResolvedPair(lid, decoded, cachedPnUser)
|
|
continue
|
|
}
|
|
|
|
this.stats.cacheMisses++
|
|
const pendingForUser = pendingByLidUser.get(lidUser) ?? []
|
|
pendingForUser.push({ lid, decoded })
|
|
pendingByLidUser.set(lidUser, pendingForUser)
|
|
}
|
|
|
|
if (pendingByLidUser.size > 0) {
|
|
const reverseKeys = [...pendingByLidUser.keys()].map(lidUser => `${lidUser}_reverse`)
|
|
const dbFailedReverseKeys = new Set<string>()
|
|
|
|
for (const batch of this.chunkArray(reverseKeys, this.config.batchSize)) {
|
|
try {
|
|
const stored = await this.retryOperation(
|
|
() => this.keys.get('lid-mapping', batch),
|
|
'get-pn-for-lid'
|
|
)
|
|
|
|
for (const reverseKey of batch) {
|
|
const lidUser = reverseKey.replace(/_reverse$/, '')
|
|
const pnUser = stored[reverseKey]
|
|
if (pnUser && typeof pnUser === 'string') {
|
|
this.stats.dbHits++
|
|
this.mappingCache.set(`lid:${lidUser}`, pnUser)
|
|
this.mappingCache.set(`pn:${pnUser}`, lidUser)
|
|
} else {
|
|
this.stats.dbMisses++
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logger.error({ error, batch }, 'Failed to get PN mapping batch from database')
|
|
this.stats.failedOperations += batch.length
|
|
batch.forEach(reverseKey => dbFailedReverseKeys.add(reverseKey))
|
|
}
|
|
}
|
|
|
|
for (const [lidUser, items] of pendingByLidUser.entries()) {
|
|
const pnUser = this.mappingCache.get(`lid:${lidUser}`)
|
|
for (const { lid, decoded } of items) {
|
|
if (pnUser && typeof pnUser === 'string') {
|
|
addResolvedPair(lid, decoded, pnUser)
|
|
continue
|
|
}
|
|
|
|
if (dbFailedReverseKeys.has(`${lidUser}_reverse`)) {
|
|
failedLids.add(lid)
|
|
}
|
|
|
|
if (this.config.debugLogging) {
|
|
this.logger.trace({ lidUser }, 'No reverse mapping found')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failedLids.size > 0) {
|
|
this.logger.warn(
|
|
{ failedCount: failedLids.size, totalRequested: lids.length },
|
|
'Some LIDs failed during getPNsForLIDs - results may be incomplete'
|
|
)
|
|
}
|
|
|
|
this.recordMetrics('get-pn', Object.keys(successfulPairs).length)
|
|
return Object.keys(successfulPairs).length > 0 ? Object.values(successfulPairs) : null
|
|
}) // End trackOperation
|
|
}
|
|
|
|
/**
|
|
* Check if a mapping exists for a PN
|
|
*/
|
|
async hasMappingForPN(pn: string): Promise<boolean> {
|
|
this.checkDestroyed()
|
|
|
|
// Track operation to prevent UAF during destroy()
|
|
return this.trackOperation(async () => {
|
|
if (!isAnyPnUser(pn)) return false
|
|
|
|
const decoded = jidDecode(pn)
|
|
if (!decoded) return false
|
|
|
|
const pnUser = decoded.user
|
|
|
|
// Check cache first
|
|
if (this.mappingCache.has(`pn:${pnUser}`)) {
|
|
return true
|
|
}
|
|
|
|
// Check database
|
|
try {
|
|
const stored = await this.keys.get('lid-mapping', [pnUser])
|
|
return !!stored[pnUser]
|
|
} catch {
|
|
return false
|
|
}
|
|
}) // End trackOperation
|
|
}
|
|
|
|
/**
|
|
* Delete mapping from cache only (does not affect persistent storage)
|
|
* Use this to force a fresh lookup on next access
|
|
* @param pn - The phone number JID to remove from cache
|
|
* @returns true if the PN was valid and cache was cleared
|
|
*/
|
|
async deleteMappingFromCache(pn: string): Promise<boolean> {
|
|
this.checkDestroyed()
|
|
|
|
// Track operation to prevent UAF during destroy()
|
|
return this.trackOperation(async () => {
|
|
if (!isAnyPnUser(pn)) return false
|
|
|
|
const decoded = jidDecode(pn)
|
|
if (!decoded) return false
|
|
|
|
const pnUser = decoded.user
|
|
const lidUser = this.mappingCache.get(`pn:${pnUser}`)
|
|
|
|
// Remove from cache only - persistent storage maintains history
|
|
this.mappingCache.delete(`pn:${pnUser}`)
|
|
if (lidUser) {
|
|
this.mappingCache.delete(`lid:${lidUser}`)
|
|
}
|
|
|
|
this.logger.debug({ pnUser }, 'Mapping deleted from cache')
|
|
return true
|
|
}) // End trackOperation
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use deleteMappingFromCache instead - name clarifies cache-only behavior
|
|
*/
|
|
async deleteMapping(pn: string): Promise<boolean> {
|
|
return this.deleteMappingFromCache(pn)
|
|
}
|
|
|
|
// ========================================================================
|
|
// CLEANUP
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Destroy the store and clean up resources
|
|
* CRITICAL: Call this when done to prevent memory leaks
|
|
*
|
|
* IMPORTANT BEHAVIOR (following auth-utils.ts pattern):
|
|
* - Always sets destroyed=true to prevent NEW operations
|
|
* - If operations are active (operationsInProgress > 0), returns early WITHOUT destroying resources
|
|
* - This creates intentional temporary inconsistent state:
|
|
* * destroyed=true (new operations rejected)
|
|
* * resources exist (active operations complete safely)
|
|
* * resources cleaned up by GC after active operations finish
|
|
* - If no active operations, destroys resources immediately
|
|
*/
|
|
destroy(): void {
|
|
if (this.destroyed) {
|
|
this.logger.debug('LIDMappingStore already destroyed')
|
|
return
|
|
}
|
|
|
|
// CRITICAL: Set destroyed flag FIRST to prevent new operations
|
|
// Note: Flag is set even if early return occurs (see doc above)
|
|
this.destroyed = true
|
|
this.logger.debug('🗑️ Cleaning up LIDMappingStore resources')
|
|
|
|
// Check if there are operations in progress
|
|
if (this.operationsInProgress > 0) {
|
|
this.logger.warn(
|
|
{ operationsInProgress: this.operationsInProgress },
|
|
'⚠️ Cannot destroy LIDMappingStore - operations still in progress. Resources will be cleaned by GC after completion.'
|
|
)
|
|
// Return early WITHOUT destroying resources
|
|
// This allows active operations to complete safely
|
|
return
|
|
}
|
|
|
|
// No active operations - safe to destroy resources immediately
|
|
this.logger.debug('No operations in progress - destroying resources immediately')
|
|
|
|
// Clear cache
|
|
this.mappingCache.clear()
|
|
|
|
// Clear inflight request Maps to prevent memory leaks
|
|
// Pending Promises will complete but won't be returned to new callers
|
|
this.inflightLIDLookups.clear()
|
|
this.inflightPNLookups.clear()
|
|
|
|
this.logger.debug('✅ LIDMappingStore destroyed successfully')
|
|
}
|
|
|
|
// ========================================================================
|
|
// PRIVATE HELPERS
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Check if store has been destroyed and throw if so
|
|
*
|
|
* NOTE: This is a fail-fast guard with TOCTOU window.
|
|
* Critical operations must use trackOperation() wrapper for atomic safety.
|
|
*/
|
|
private checkDestroyed(): void {
|
|
if (this.destroyed) {
|
|
throw new LIDMappingError(
|
|
'LIDMappingStore has been destroyed',
|
|
LIDMappingErrorCode.DESTROYED
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Track operation lifecycle for safe resource cleanup
|
|
* Wraps operation execution with counter increment/decrement
|
|
*
|
|
* CRITICAL SAFETY: Prevents UAF by tracking active operations.
|
|
* destroy() will NOT clean resources if operations are in progress.
|
|
*
|
|
* @param operation - Async operation to execute
|
|
* @returns Promise with operation result
|
|
*/
|
|
private async trackOperation<T>(operation: () => Promise<T>): Promise<T> {
|
|
// Increment counter BEFORE starting operation
|
|
this.operationsInProgress++
|
|
|
|
try {
|
|
// Recheck destroyed after incrementing counter
|
|
// This ensures we fail fast if destroyed between checkDestroyed() and here
|
|
if (this.destroyed) {
|
|
throw new LIDMappingError(
|
|
'LIDMappingStore has been destroyed',
|
|
LIDMappingErrorCode.DESTROYED
|
|
)
|
|
}
|
|
|
|
return await operation()
|
|
} finally {
|
|
// ALWAYS decrement counter, even on error
|
|
this.operationsInProgress--
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a LID-PN mapping pair
|
|
* Checks that one is a LID and the other is a PN (in either order)
|
|
*/
|
|
private isValidMapping(lid: string, pn: string): boolean {
|
|
return (isAnyLidUser(lid) && isAnyPnUser(pn)) || (isAnyPnUser(lid) && isAnyLidUser(pn))
|
|
}
|
|
|
|
/**
|
|
* Build device-specific JID
|
|
*/
|
|
private buildDeviceSpecificJid(user: string, device: number, server: string): string {
|
|
return `${user}${device ? `:${device}` : ''}@${server}`
|
|
}
|
|
|
|
/**
|
|
* Chunk array into smaller arrays for batch processing
|
|
*/
|
|
private chunkArray<T>(array: T[], size: number): T[][] {
|
|
const chunks: T[][] = []
|
|
for (let i = 0; i < array.length; i += size) {
|
|
chunks.push(array.slice(i, i + size))
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
/**
|
|
* Retry an operation with exponential backoff
|
|
* Supports both Promise and Awaitable return types
|
|
*
|
|
* Delay pattern: baseDelay * 2^(attempt-1)
|
|
* - Attempt 1: immediate
|
|
* - Attempt 2: baseDelay * 1 (e.g., 1000ms)
|
|
* - Attempt 3: baseDelay * 2 (e.g., 2000ms)
|
|
* - Attempt 4: baseDelay * 4 (e.g., 4000ms)
|
|
*
|
|
* Configure via: BAILEYS_LID_RETRY_ATTEMPTS (default: 3)
|
|
* BAILEYS_LID_RETRY_DELAY_MS (default: 1000)
|
|
*/
|
|
private async retryOperation<T>(
|
|
operation: () => T | Promise<T>,
|
|
operationName: string
|
|
): Promise<T> {
|
|
let lastError: Error | undefined
|
|
|
|
for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) {
|
|
try {
|
|
return await operation()
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error : new Error(String(error))
|
|
|
|
if (attempt < this.config.retryAttempts) {
|
|
const delay = this.config.retryDelayMs * Math.pow(2, attempt - 1)
|
|
this.logger.warn(
|
|
{ operationName, attempt, maxAttempts: this.config.retryAttempts, delay },
|
|
'Operation failed, retrying'
|
|
)
|
|
await this.sleep(delay)
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new LIDMappingError(
|
|
`Operation ${operationName} failed after ${this.config.retryAttempts} attempts: ${lastError?.message}`,
|
|
LIDMappingErrorCode.DATABASE_ERROR,
|
|
{ operationName, lastError: lastError?.message }
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Sleep for specified milliseconds
|
|
*/
|
|
private sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
}
|
|
|
|
/**
|
|
* Fetch mappings from USync
|
|
*/
|
|
private async fetchFromUSync(
|
|
usyncFetch: { [pn: string]: number[] },
|
|
successfulPairs: { [pn: string]: LIDMapping }
|
|
): Promise<void> {
|
|
if (!this.pnToLIDFunc) {
|
|
this.logger.warn('No pnToLIDFunc provided, cannot fetch from USync')
|
|
return
|
|
}
|
|
|
|
this.stats.usyncFetches++
|
|
|
|
try {
|
|
const result = await this.pnToLIDFunc(Object.keys(usyncFetch))
|
|
|
|
if (result && result.length > 0) {
|
|
await this.storeLIDPNMappings(result)
|
|
|
|
for (const pair of result) {
|
|
const pnDecoded = jidDecode(pair.pn)
|
|
const pnUser = pnDecoded?.user
|
|
if (!pnUser) continue
|
|
|
|
const lidUser = jidDecode(pair.lid)?.user
|
|
if (!lidUser) continue
|
|
|
|
const devices = usyncFetch[pair.pn]
|
|
if (!devices) continue
|
|
|
|
for (const device of devices) {
|
|
const server = device === 99 ? 'hosted.lid' : 'lid'
|
|
const deviceSpecificLid = this.buildDeviceSpecificJid(lidUser, device, server)
|
|
|
|
const pnServer = device === 99 ? 'hosted' : 's.whatsapp.net'
|
|
const deviceSpecificPn = this.buildDeviceSpecificJid(pnUser, device, pnServer)
|
|
|
|
if (this.config.debugLogging) {
|
|
this.logger.trace(
|
|
{ pn: pair.pn, deviceSpecificLid, device },
|
|
'USync fetch successful'
|
|
)
|
|
}
|
|
|
|
successfulPairs[deviceSpecificPn] = { lid: deviceSpecificLid, pn: deviceSpecificPn }
|
|
}
|
|
}
|
|
} else {
|
|
this.stats.usyncFailures++
|
|
}
|
|
} catch (error) {
|
|
this.logger.error({ error }, 'USync fetch failed')
|
|
this.stats.usyncFailures++
|
|
this.stats.failedOperations++
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request coalescing helper - deduplicates concurrent lookups for same key
|
|
*
|
|
* SAFETY GUARANTEES:
|
|
* - No UAF (Use-After-Free): Caller must use trackOperation() wrapper, which prevents
|
|
* resource cleanup during execution via operationsInProgress counter
|
|
* - No TOCTOU: Destroyed check done once at operation start (no redundant rechecks)
|
|
* - Thread-safe: Maps protected by operationsInProgress (V4) and usage contract (V5)
|
|
*
|
|
* USAGE REQUIREMENTS:
|
|
* - MUST be called from within trackOperation() (enforced by V5 documentation)
|
|
* - Caller MUST have called checkDestroyed() before entering tracked operation
|
|
* - DO NOT call directly from unwrapped operations
|
|
*
|
|
* @param key - Lookup key (e.g., pnUser for LID lookup)
|
|
* @param map - The inflight Map to use
|
|
* @param fetchFn - Function to execute if no inflight request exists
|
|
* @returns Promise that resolves to the result
|
|
*/
|
|
private async coalesceRequest<T>(
|
|
key: string,
|
|
map: Map<string, Promise<T>>,
|
|
fetchFn: () => Promise<T>
|
|
): Promise<T> {
|
|
// Check if request is already in-flight
|
|
const existing = map.get(key)
|
|
if (existing) {
|
|
// Return cached Promise - safe because:
|
|
// 1. Caller already checked destroyed (via checkDestroyed() in parent operation)
|
|
// 2. Operation is protected by trackOperation() (resources won't be freed)
|
|
// 3. Rechecking here would add TOCTOU window without benefit
|
|
return existing
|
|
}
|
|
|
|
// Create new request
|
|
const promise = fetchFn()
|
|
map.set(key, promise)
|
|
|
|
try {
|
|
const result = await promise
|
|
return result
|
|
} finally {
|
|
// Always cleanup from Map after completion (success or failure)
|
|
map.delete(key)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record metrics if enabled (with buffer support for async loading)
|
|
* Note: Actual metric recording is not yet implemented
|
|
*/
|
|
private recordMetrics(operation: string, count: number): void {
|
|
// Metrics implementation pending - currently a no-op
|
|
// When implemented, should record LID mapping operations to Prometheus
|
|
// For now, we don't buffer since there's no actual recording function
|
|
}
|
|
}
|