Chain-event side effects happen once across running instances
A Coolify deploy starts the new container and stops the old one only once the new one is healthy, so for about half a minute two copies of this app watch the chain. On 25 September, 06:26 CT, both saw Marty's $5 purchase: two confirmation emails, and the Five Dollar Friday bonus granted twice (+100 and +100, nineteen seconds apart). Every dedupe until now lived in process memory or a JSON file on the volume; a second process shares neither in time. marks.js is the shared version: a row in MySQL that only one INSERT IGNORE can win. The chain-event fan-out claims 'ev:<tx>:<logIndex>' before the email, Telegram post, Friday bonus and sponsor sync; the in-memory feed stays per instance. The Friday scheduler claims its Thursday email, kickoff and wrap-up the same way, with the file marker kept as the second line of defence and the thing an operator can edit by hand. Without a database it degrades to a per-process Set, which is exactly the old single-instance behaviour. Proven on the production MySQL before deploy: first insert 1 row, second 0. Marty's duplicate 100 credits reversed with a labelled adjust row; no other member was double-granted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
// One-time claims shared by every running copy of the app (Marty, 2026-09-25).
|
||||
//
|
||||
// Why: a Coolify deploy starts the new container and only stops the old one once the new one
|
||||
// is healthy, so for half a minute two copies of this app watch the chain. On 25 September
|
||||
// both saw Marty's $5 purchase: two confirmation emails, and the Five Dollar Friday bonus
|
||||
// granted twice (+100 and +100, 19 seconds apart). Every dedupe until now lived in process
|
||||
// memory or a JSON file on the volume, neither of which a second process shares in time.
|
||||
//
|
||||
// This is the shared version: a row in MySQL that only one INSERT can win. `claim(key)` is
|
||||
// true for exactly one caller across all instances, false for everyone else. Gate anything
|
||||
// that must happen once per event, and never gate the in-memory feed (each instance keeps
|
||||
// its own).
|
||||
//
|
||||
// Without a database (local QA, the JSON store) it degrades to a per-process Set, which is
|
||||
// exactly the old behaviour: one instance, one memory.
|
||||
'use strict';
|
||||
|
||||
let db = null;
|
||||
const local = new Set();
|
||||
|
||||
function init(deps) { db = deps && deps.db; }
|
||||
|
||||
async function ensureTable() {
|
||||
if (!db || !db.enabled()) return;
|
||||
await db.q('CREATE TABLE IF NOT EXISTS event_marks (k VARCHAR(160) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB');
|
||||
}
|
||||
|
||||
// true = this caller owns the key; false = someone (possibly another instance) already does.
|
||||
async function claim(key) {
|
||||
key = String(key).slice(0, 160);
|
||||
if (!db || !db.enabled()) { if (local.has(key)) return false; local.add(key); return true; }
|
||||
try {
|
||||
const r = await db.q('INSERT IGNORE INTO event_marks (k, ts) VALUES (?, ?)', [key, Date.now()]);
|
||||
return !!(r && r.affectedRows === 1);
|
||||
} catch (e) {
|
||||
// The table missing, or the DB unreachable, must not silently double-send: log it and
|
||||
// fall back to the per-process memory, which at least keeps ONE instance honest.
|
||||
console.error('marks: claim fell back to memory for ' + key + ': ' + e.message);
|
||||
if (local.has(key)) return false; local.add(key); return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the table from growing forever: marks older than 60 days are of no use, every event
|
||||
// they guard has long since been handled.
|
||||
async function prune() {
|
||||
if (!db || !db.enabled()) return;
|
||||
try { await db.q('DELETE FROM event_marks WHERE ts < ?', [Date.now() - 60 * 86400000]); } catch (e) {}
|
||||
}
|
||||
|
||||
module.exports = { init, ensureTable, claim, prune };
|
||||
Reference in New Issue
Block a user