Files
instantadpay/marks.js
T
martbost 742c271469 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>
2026-09-25 06:40:11 -05:00

51 lines
2.3 KiB
JavaScript

// 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 };