Compare commits

...

3 Commits

Author SHA1 Message Date
Renato Alcara d119cb064a fix(lint): flatten max-depth in storeTcTokensFromHistorySync
CI lint failed with:
  process-message.ts:127:6 error Blocks are nested too deeply (5). Maximum allowed is 4 max-depth

The previous shape nested `if (pnsToResolve.length) → try → if (mappings) → for → if (pn && lid)` = 5 levels. Flattened to 4 by:
- Using `mappings ?? []` so the for-of can iterate without an outer guard
- Inverting the inner predicate to `if (!pn || !lid) continue`

No behavior change. CI should now pass.
2026-04-25 23:49:55 -03:00
Renato Alcara b5910c591a perf(history-sync): true batch + partial-failure resilience for tcToken sync
Addresses all 4 PR #387 review findings (Copilot + CodeRabbit):

#1 (Copilot, Major) — Promise.all still issued one getLIDForPN call per
chat. Replaced with a single getLIDsForPNs batch over deduped PN inputs:
turns O(N) round-trips into 1, AND shares USync retry across PNs that
miss cache. LID inputs (and @hosted.lid) skip the lookup entirely.

#4 (CodeRabbit, Major) — Promise.all is all-or-nothing: a single
resolveTcTokenJid rejection (transient DB error) would reject the whole
storeTcTokensFromHistorySync call AND abort the surrounding
HISTORY_SYNC_NOTIFICATION handler — meaning messaging-history.set
never fires and EVERY tctoken in the chunk is lost. Wrapped the batch
call in try/catch with per-chat fallback (storage under unresolved jid),
matching resolveTcTokenJid's null-LID branch.

#2 (Copilot/CodeRabbit, Minor) — `if (!candidates.length) return` was
dead code: after the early return for empty tokenChats, candidates is a
1:1 map of tokenChats and can never be empty. Removed.

#3 (CodeRabbit, Minor) — `as { ... }[]` type assertion was redundant
(TypeScript already infers the shape from the async map callback).
Removed — inferred typing now catches future shape drift.

Bonus cleanup: dropped unused `resolveTcTokenJid` import.

Tests: 35/35 suites, 824/824 passing. Customizations untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:43:48 -03:00
Renato Alcara ae333b2910 perf(history-sync): parallelise tcToken JID resolution per chunk
PR #386 added storeTcTokensFromHistorySync which iterates chats in a
sync chunk and calls resolveTcTokenJid (an async getLIDForPN lookup)
per candidate. The original shape used sequential `await` inside a
for-of, so each chunk blocked for O(N) round-trips before
messaging-history.set could fire downstream.

In production this surfaced as systemic message-delivery latency:
under heavy history sync (re-scan / multi-device pairing) the event
buffer backed up, the adaptive flush mode escalated to "aggressive",
and outbound sends competed with the sync queue for socket time.
QR re-scans made it worse because a fresh history sync triggered
another round of sequential resolutions.

Fix:
1. Pre-filter chats with `tcToken && tcTokenTimestamp > 0` so we
   never spin up promises for the empty cases (most chats in a chunk).
2. Parallelise the resolveTcTokenJid lookups via Promise.all — same
   semantics, but all N getLIDForPN calls run concurrently instead
   of serially.

Downstream (loop that builds `entries`) is untouched: it remains
synchronous and keeps the same-batch dedup guard, so monotonicity
guarantees stay intact.

Customizations untouched: zero diff in carousel/buttons/lists/
LID-PN normalization/Bad MAC/proto.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:26:17 -03:00
+50 -17
View File
@@ -40,7 +40,7 @@ import { getKeyAuthor, toNumber } from './generics'
import { downloadAndProcessHistorySyncNotification } from './history'
import type { ILogger } from './logger'
import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js'
import { buildMergedTcTokenIndexWrite, resolveTcTokenJid } from './tc-token-utils'
import { buildMergedTcTokenIndexWrite } from './tc-token-utils'
type ProcessMessageContext = {
shouldProcessHistoryMsg: boolean
@@ -88,26 +88,59 @@ async function storeTcTokensFromHistorySync(
keyStore: SignalKeyStoreWithTransaction,
logger?: ILogger
) {
const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
const candidates: { storageJid: string; token: Buffer; ts: number; senderTs?: number }[] = []
for (const chat of chats) {
// Cheap filter first — most chats in a sync chunk don't carry tcToken at all,
// and we want to avoid spinning up promises for them.
const tokenChats = chats.filter(chat => {
const ts = chat.tcTokenTimestamp ? toNumber(chat.tcTokenTimestamp) : 0
if (chat.tcToken?.length && ts > 0) {
const jid = jidNormalizedUser(chat.id!)
const storageJid = await resolveTcTokenJid(jid, getLIDForPN)
candidates.push({
storageJid,
token: Buffer.from(chat.tcToken),
ts,
senderTs: chat.tcTokenSenderTimestamp ? toNumber(chat.tcTokenSenderTimestamp) : undefined
})
return !!chat.tcToken?.length && ts > 0
})
if (!tokenChats.length) {
return
}
// Pre-normalize so the rest of the pipeline is a synchronous join.
const normalized = tokenChats.map(chat => ({
chat,
ts: toNumber(chat.tcTokenTimestamp!),
jid: jidNormalizedUser(chat.id!)
}))
// BATCHED LID resolution. The previous shape called getLIDForPN once per
// chat (sequential await inside a for-of), which became the bottleneck
// during heavy history sync — every cold-cache hit was a DB round-trip,
// stalling messaging-history.set and spilling into the event-buffer.
// `getLIDsForPNs` resolves a deduped list in ONE batched query (and shares
// USync retry across PNs that miss cache), turning O(N) round-trips into 1.
//
// LID inputs (and `@hosted.lid`) skip the lookup entirely — they're already
// the storage form. Failures degrade gracefully: a missing mapping just
// stores under the original jid, matching `resolveTcTokenJid`'s null branch.
const pnsToResolve = [...new Set(normalized.filter(({ jid }) => !isLidUser(jid)).map(({ jid }) => jid))]
const pnToLid = new Map<string, string>()
if (pnsToResolve.length) {
try {
const mappings = await signalRepository.lidMapping.getLIDsForPNs(pnsToResolve)
// Flat loop (continue-on-skip) keeps max nesting depth at 4 for lint.
for (const { pn, lid } of mappings ?? []) {
if (!pn || !lid) continue
pnToLid.set(jidNormalizedUser(pn), lid)
}
} catch (err) {
// Per-chat fallback below (storageJid := jid). Don't abort the chunk —
// CodeRabbit noted that all-or-nothing rejection here would drop every
// tctoken in the batch AND prevent messaging-history.set from firing.
logger?.warn({ err }, 'storeTcTokensFromHistorySync: getLIDsForPNs batch failed; falling back to per-chat jid')
}
}
if (!candidates.length) {
return
}
const candidates = normalized.map(({ chat, ts, jid }) => ({
storageJid: pnToLid.get(jid) ?? jid,
token: Buffer.from(chat.tcToken!),
ts,
senderTs: chat.tcTokenSenderTimestamp ? toNumber(chat.tcTokenSenderTimestamp) : undefined
}))
const jids = candidates.map(c => c.storageJid)
const existing = await keyStore.get('tctoken', jids)