diff --git a/chain.js b/chain.js index 73b3618..38a5ccb 100644 --- a/chain.js +++ b/chain.js @@ -62,16 +62,40 @@ function emit(evt) { if (onEvent) { try { onEvent(evt); } catch (e) { console.er // Keyed on tx + type + id, so each on-chain event announces exactly once no // matter which code path notices it first. The scan window still bounds it: // we only ever look at blocks past lastBlock, so this cannot replay history. +// The record lives in its OWN small file, read fresh and written immediately on every +// announcement, rather than riding in the big index state that is only flushed at the end of a +// tick. Two reasons, both learned on 2026-09-18: +// +// 1. Across a restart: the announcement used to go out before the state recording it was +// saved, so anything that interrupted the tick replayed the announcement. +// 2. Across PROCESSES: during a deploy the outgoing container and the incoming one are both +// alive for a moment, both tailing the chain. With the record held in memory each had its +// own copy, so each announced the same payout — which is why members saw payment lines +// twice, roughly a poll interval apart. A file both processes read and write makes the +// first one to announce visible to the second. +// +// Events are rare (a few an hour), so reading and writing a small file per event costs nothing. +const ANNOUNCED_FILE = path.join(DATA_DIR, 'announced.json'); +function readAnnounced() { + try { const o = JSON.parse(fs.readFileSync(ANNOUNCED_FILE, 'utf8')); return (o && typeof o === 'object') ? o : {}; } + catch (e) { return (state && state.announced) || {}; } // first run: inherit the in-state record +} function announceOnce(key) { - if (!state.announced) state.announced = {}; - if (state.announced[key]) return false; - state.announced[key] = Date.now(); - const keys = Object.keys(state.announced); + const rec = readAnnounced(); + if (rec[key]) return false; + rec[key] = Date.now(); + const keys = Object.keys(rec); if (keys.length > 4000) { - keys.sort(function (a, b) { return state.announced[a] - state.announced[b]; }) + keys.sort(function (a, b) { return rec[a] - rec[b]; }) .slice(0, keys.length - 3000) - .forEach(function (k) { delete state.announced[k]; }); + .forEach(function (k) { delete rec[k]; }); } + try { + const tmp = ANNOUNCED_FILE + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(rec)); + fs.renameSync(tmp, ANNOUNCED_FILE); // atomic: a concurrent reader sees old or new, never half + } catch (e) { console.error('announced write failed', e.message); } + state.announced = rec; // keep the in-state copy so existing readers/migrations still work return true; }