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:
martbost
2026-09-18 10:02:14 -05:00
parent 812cbd03b8
commit f18ef592fa
+33 -9
View File
@@ -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;
} }