Make announce-once survive a deploy, not just a restart
Instrumenting every send found the real shape of this: the live process sends each payout exactly once, to each feed, with no duplicate to suppress. So the second copy members were seeing never came from the running container — it came from ANOTHER one. During a deploy the outgoing container and the incoming one are both alive for a moment, and both tail the chain. The announce-once record was held in memory, so each had its own copy and each announced the same payout, about a poll interval apart. That matches exactly what the proof channel showed: identical lines a minute apart, and more of them today because I deployed four times in half an hour. The record now lives in its own small file, read fresh and written atomically on every announcement, so whichever process gets there first is visible to the other. It also closes the original hole, where the announcement went out before the state recording it was flushed at the end of a tick. Events are a few an hour, so a small read and write per event costs nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
// 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:
|
// 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.
|
// we only ever look at blocks past lastBlock, so this cannot replay history.
|
||||||
function announceOnce(key) {
|
// The record lives in its OWN small file, read fresh and written immediately on every
|
||||||
if (!state.announced) state.announced = {};
|
// announcement, rather than riding in the big index state that is only flushed at the end of a
|
||||||
if (state.announced[key]) return false;
|
// tick. Two reasons, both learned on 2026-09-18:
|
||||||
state.announced[key] = Date.now();
|
//
|
||||||
const keys = Object.keys(state.announced);
|
// 1. Across a restart: the announcement used to go out before the state recording it was
|
||||||
if (keys.length > 4000) {
|
// saved, so anything that interrupted the tick replayed the announcement.
|
||||||
keys.sort(function (a, b) { return state.announced[a] - state.announced[b]; })
|
// 2. Across PROCESSES: during a deploy the outgoing container and the incoming one are both
|
||||||
.slice(0, keys.length - 3000)
|
// alive for a moment, both tailing the chain. With the record held in memory each had its
|
||||||
.forEach(function (k) { delete state.announced[k]; });
|
// 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) {
|
||||||
|
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 rec[a] - rec[b]; })
|
||||||
|
.slice(0, keys.length - 3000)
|
||||||
|
.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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user