perf(signal): eliminate 3 post-restart latency sources in migrateSession
perf(signal): eliminate 3 post-restart latency sources in migrateSession
This commit is contained in:
+28
-5
@@ -281,7 +281,7 @@ export function makeLibSignalRepository(
|
|||||||
|
|
||||||
const parsedKeys = auth.keys as SignalKeyStoreWithTransaction
|
const parsedKeys = auth.keys as SignalKeyStoreWithTransaction
|
||||||
const migratedSessionCache = new LRUCache<string, true>({
|
const migratedSessionCache = new LRUCache<string, true>({
|
||||||
ttl: 3 * 24 * 60 * 60 * 1000, // 7 days
|
ttl: 3 * 24 * 60 * 60 * 1000, // 3 days
|
||||||
ttlAutopurge: true,
|
ttlAutopurge: true,
|
||||||
updateAgeOnGet: true
|
updateAgeOnGet: true
|
||||||
})
|
})
|
||||||
@@ -297,6 +297,11 @@ export function makeLibSignalRepository(
|
|||||||
ttlAutopurge: true
|
ttlAutopurge: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// In-flight deduplication map for migrateSession: if N concurrent callers arrive
|
||||||
|
// for the same PN user before the first migration completes, they all share one
|
||||||
|
// Promise instead of each spawning their own DB transactions.
|
||||||
|
const migrationInFlight = new Map<string, Promise<{ migrated: number; skipped: number; total: number }>>()
|
||||||
|
|
||||||
const repository: SignalRepositoryWithLIDStore = {
|
const repository: SignalRepositoryWithLIDStore = {
|
||||||
decryptGroupMessage({ group, authorJid, msg }) {
|
decryptGroupMessage({ group, authorJid, msg }) {
|
||||||
const senderName = jidToSignalSenderKeyName(group, authorJid)
|
const senderName = jidToSignalSenderKeyName(group, authorJid)
|
||||||
@@ -502,17 +507,28 @@ export function makeLibSignalRepository(
|
|||||||
|
|
||||||
const { user } = decoded1
|
const { user } = decoded1
|
||||||
|
|
||||||
logger.debug({ fromJid }, 'bulk device migration - loading all user devices')
|
// In-flight deduplication: if a migration is already running for this PN user,
|
||||||
|
// return the existing Promise. The check+set is synchronous (before any await)
|
||||||
|
// so it is safe in Node.js's single-threaded model.
|
||||||
|
const inFlight = migrationInFlight.get(user)
|
||||||
|
if (inFlight) {
|
||||||
|
logger.trace({ fromJid }, 'migrateSession: reusing in-flight migration for same user')
|
||||||
|
return inFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationPromise = (async (): Promise<{ migrated: number; skipped: number; total: number }> => {
|
||||||
// Get user's device list — use in-memory cache to avoid a DB round-trip on
|
// Get user's device list — use in-memory cache to avoid a DB round-trip on
|
||||||
// every incoming LID message. Cache is invalidated after 5 minutes.
|
// every incoming LID message. Cache is invalidated after 5 minutes.
|
||||||
// We use undefined to mean "not yet checked" and [] to mean "checked, no devices
|
// We use undefined to mean "not yet checked" and [] to mean "checked, no devices
|
||||||
// found" so that DB misses are also cached and don't cause a per-message lookup.
|
// found" so that DB misses are also cached and don't cause a per-message lookup.
|
||||||
let userDevices: string[] | undefined = deviceListCache.get(user)
|
let userDevices: string[] | undefined = deviceListCache.get(user)
|
||||||
if (userDevices === undefined) {
|
if (userDevices === undefined) {
|
||||||
|
logger.debug({ fromJid }, 'bulk device migration - loading all user devices from DB')
|
||||||
const result = await parsedKeys.get('device-list', [user])
|
const result = await parsedKeys.get('device-list', [user])
|
||||||
userDevices = result[user] ?? []
|
userDevices = result[user] ?? []
|
||||||
deviceListCache.set(user, userDevices)
|
deviceListCache.set(user, userDevices)
|
||||||
|
} else {
|
||||||
|
logger.trace({ fromJid, deviceCount: userDevices.length }, 'bulk device migration - device list from cache')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userDevices.length === 0) {
|
if (userDevices.length === 0) {
|
||||||
@@ -616,9 +632,11 @@ export function makeLibSignalRepository(
|
|||||||
const totalOps = migrationOps.length
|
const totalOps = migrationOps.length
|
||||||
let migratedCount = 0
|
let migratedCount = 0
|
||||||
|
|
||||||
// Bulk fetch PN sessions - already exist (verified during device discovery)
|
// Reuse existingSessions fetched above — for PN users on s.whatsapp.net the
|
||||||
const pnAddrStrings = Array.from(new Set(migrationOps.map(op => op.fromAddr.toString())))
|
// signal-address format (user.device) is identical to deviceSessionKeys, so
|
||||||
const pnSessions = await parsedKeys.get('session', pnAddrStrings)
|
// existingSessions already contains every session needed here.
|
||||||
|
// Avoids a redundant storage round-trip inside the transaction.
|
||||||
|
const pnSessions = existingSessions
|
||||||
|
|
||||||
// Prepare bulk session updates (PN → LID migration + deletion)
|
// Prepare bulk session updates (PN → LID migration + deletion)
|
||||||
const sessionUpdates: { [key: string]: Uint8Array | null } = {}
|
const sessionUpdates: { [key: string]: Uint8Array | null } = {}
|
||||||
@@ -658,6 +676,11 @@ export function makeLibSignalRepository(
|
|||||||
},
|
},
|
||||||
`migrate-${deviceJids.length}-sessions-${jidDecode(toJid)?.user}`
|
`migrate-${deviceJids.length}-sessions-${jidDecode(toJid)?.user}`
|
||||||
)
|
)
|
||||||
|
})()
|
||||||
|
|
||||||
|
migrationInFlight.set(user, migrationPromise)
|
||||||
|
migrationPromise.finally(() => migrationInFlight.delete(user))
|
||||||
|
return migrationPromise
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -352,6 +352,11 @@ export const makeSocket = (config: SocketConfig) => {
|
|||||||
// whose message contained "socket-query" → matched the circuit-breaker's
|
// whose message contained "socket-query" → matched the circuit-breaker's
|
||||||
// "socket" pattern → incorrectly tripped the breaker after 5 timeouts.
|
// "socket" pattern → incorrectly tripped the breaker after 5 timeouts.
|
||||||
const responsePromise = waitForMessage<any>(msgId, timeoutMs)
|
const responsePromise = waitForMessage<any>(msgId, timeoutMs)
|
||||||
|
// Prevent unhandled-rejection if sendNode throws before we reach
|
||||||
|
// `await responsePromise` below. The error from sendNode still propagates
|
||||||
|
// to the caller; this only silences the secondary rejection from
|
||||||
|
// responsePromise (which waitForMessage will emit when ws closes).
|
||||||
|
responsePromise.catch(() => {})
|
||||||
|
|
||||||
// Await the send so that real sendNode failures (e.g. serialisation errors)
|
// Await the send so that real sendNode failures (e.g. serialisation errors)
|
||||||
// are surfaced to the caller immediately rather than silently discarded.
|
// are surfaced to the caller immediately rather than silently discarded.
|
||||||
@@ -1548,10 +1553,17 @@ export const makeSocket = (config: SocketConfig) => {
|
|||||||
recordConnectionAttempt('success')
|
recordConnectionAttempt('success')
|
||||||
incrementActiveConnections()
|
incrementActiveConnections()
|
||||||
|
|
||||||
// Start session cleanup scheduler
|
// Defer session cleanup start by 5 s to avoid DB contention during the
|
||||||
sessionCleanup.start()
|
// initial message flood right after CB:success. The heavyweight
|
||||||
|
// getAllSessionKeys() scan would otherwise compete with migrateSession()
|
||||||
|
// for the same storage locks while the offline-message backlog is draining.
|
||||||
|
// start() is idempotent (guarded by cleanupInterval check) so deferring is safe.
|
||||||
|
const _cleanupStartTimer = setTimeout(() => sessionCleanup.start(), 5_000)
|
||||||
|
if (typeof (_cleanupStartTimer as NodeJS.Timeout).unref === 'function') {
|
||||||
|
;(_cleanupStartTimer as NodeJS.Timeout).unref()
|
||||||
|
}
|
||||||
|
|
||||||
// Start session activity tracker
|
// Start session activity tracker immediately (lightweight, no DB scan)
|
||||||
sessionActivityTracker.start()
|
sessionActivityTracker.start()
|
||||||
|
|
||||||
// Update server time offset from success node
|
// Update server time offset from success node
|
||||||
|
|||||||
Reference in New Issue
Block a user