feat(ack): activity-driven check loop + prune — geen 30s-polling in rust

This commit is contained in:
Eddy de Vink 2026-09-24 22:44:10 +02:00
commit 1f6789b9cf
3 changed files with 59 additions and 6 deletions

View file

@ -23,7 +23,8 @@ MAX_REQUESTS_PER_DAY=5
# Ack tracking (ghost-send detectie; vereist message_ack aangezet in wwebjs-api .env)
ACK_STUCK_TIMEOUT_MS=300000 # 5 min zonder ack 2 = stuck
ACK_CHECK_INTERVAL_MS=30000 # checkinterval
ACK_PRUNED_AFTER_MS=1800000 # 30 min: stale trackings opruimen (loop stopt dan)
ACK_CHECK_INTERVAL_MS=30000 # checkinterval (alleen actief als er iets pending is)
# Admin notifications
ADMIN_NUMBER=31612345678@c.us

View file

@ -6,17 +6,31 @@
// Findings (2026-09-24): an ack-1 hang is a bestand×ontvanger interaction, NOT
// the session. The same file hung (ack 1) for one recipient yet delivered
// (ack 3) to another in the same fresh session. A session restart does NOT fix
// an ack-1 hang (the same file kept hanging after restart) and needlessly
// disrupts production — so we deliberately do NOT auto-restart here. We only
// detect + notify + name the file, so it can be re-rendered and re-tested.
// an ack-1 hang and needlessly disrupts production — so we deliberately do NOT
// auto-restart here. We only detect + notify + name the file, so it can be
// re-rendered and re-tested.
//
// Activity-driven: the caller (index.js) is notified via the pending listener
// when the tracked set becomes non-empty, and starts a check loop only then.
// No polling when nothing is tracked.
//
// Requires the bridge (wwebjs-api) to emit message_ack: remove 'message_ack'
// from DISABLED_CALLBACKS in the wwebjs-api .env and restart the container.
const STUCK_TIMEOUT_MS = parseInt(process.env.ACK_STUCK_TIMEOUT_MS, 10) || 5 * 60 * 1000
const PRUNE_AFTER_MS = parseInt(process.env.ACK_PRUNED_AFTER_MS, 10) || 30 * 60 * 1000
// messageId -> { chatId, filename, sentAt, ack, notifiedStuck }
const sends = new Map()
let pendingListener = null
function setPendingListener(fn) {
pendingListener = typeof fn === 'function' ? fn : null
}
function notifyPendingChange() {
if (pendingListener) pendingListener(sends.size)
}
function registerSend(messageId, chatId, filename) {
if (!messageId) return
@ -28,6 +42,7 @@ function registerSend(messageId, chatId, filename) {
notifiedStuck: false
})
console.log(`[ack] track ${messageId} (${filename} → ${chatId})`)
notifyPendingChange()
}
function handleAck(messageId, ack) {
@ -38,6 +53,25 @@ function handleAck(messageId, ack) {
if (ack >= 2) {
sends.delete(messageId) // delivered; nothing left to track
console.log(`[ack] delivered ack=${ack} ${messageId} (${entry.filename})`)
notifyPendingChange()
}
}
// Remove stale entries (older than PRUNE_AFTER_MS). Stuck entries were notified
// once around STUCK_TIMEOUT_MS; keeping them longer only leaks memory and keeps
// the check-loop alive, so they are dropped here.
function prune() {
const now = Date.now()
let removed = 0
for (const [id, entry] of sends) {
if (now - entry.sentAt > PRUNE_AFTER_MS) {
sends.delete(id)
removed++
}
}
if (removed > 0) {
console.log(`[ack] pruned ${removed} stale entr${removed === 1 ? 'y' : 'ies'} (leeftijd > ${Math.round(PRUNE_AFTER_MS / 60000)} min)`)
notifyPendingChange()
}
}
@ -65,6 +99,8 @@ module.exports = {
registerSend,
handleAck,
stuckEntries,
prune,
markNotified,
pendingCount
pendingCount,
setPendingListener
}

View file

@ -116,6 +116,7 @@ async function startServer() {
}
async function checkStuckAcks() {
ackTracker.prune()
const stuck = ackTracker.stuckEntries()
if (stuck.length === 0) return
for (const s of stuck) {
@ -130,7 +131,22 @@ async function checkStuckAcks() {
}
}
// Lazy, activity-driven check loop: only runs while there is a tracked send
// pending, and stops as soon as the tracked set is empty. No 30s polling in
// idle. Re-arms itself after every tick while pendingCount() > 0.
const ackCheckIntervalMs = parseInt(process.env.ACK_CHECK_INTERVAL_MS, 10) || 30000
setInterval(checkStuckAcks, ackCheckIntervalMs)
let ackTimer = null
function scheduleAckCheck() {
if (ackTimer) return
if (ackTracker.pendingCount() === 0) return
ackTimer = setTimeout(() => {
ackTimer = null
checkStuckAcks()
if (ackTracker.pendingCount() > 0) scheduleAckCheck()
}, ackCheckIntervalMs)
}
ackTracker.setPendingListener(() => scheduleAckCheck())
startServer()