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:
martbost
2026-09-25 06:40:11 -05:00
parent 18aabb3cb2
commit 742c271469
3 changed files with 75 additions and 6 deletions
+5 -3
View File
@@ -21,6 +21,8 @@ const isFriday = ts => weekdayOf(ts) === 'Fri';
function init(opts) { X = opts; FILE = path.join(opts.dataDir, 'friday.json'); S = load(); }
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (e) { return { granted: {}, posts: {}, badges: {}, sent: {} }; } }
function save() { try { fs.writeFileSync(FILE, JSON.stringify(S)); } catch (e) {} }
// shared across instances when the app is wired with marks (server.js); true when nobody else has it
async function claimOnce(key) { try { return X.marks ? await X.marks.claim(key) : true; } catch (e) { return true; } }
function cfg() {
const c = (X.siteConfig && X.siteConfig()) || {};
@@ -106,19 +108,19 @@ async function tick() {
const c = cfg(); if (!c.on) return;
const now = Date.now(); const day = dayOf(now); const wd = weekdayOf(now); const h = hourOf(now);
const next = nextFriday(now);
if (wd === 'Thu' && h >= 18 && next && !S.sent['thu:' + next]) {
if (wd === 'Thu' && h >= 18 && next && !S.sent['thu:' + next] && await claimOnce('fri:thu:' + next)) {
S.sent['thu:' + next] = now; save();
const subject = 'Tomorrow is Five Dollar Friday';
const text = 'Tomorrow is Five Dollar Friday on InstantAdPay.\n\nAny ad package of $5 or more bought on Friday (Central time) earns +' + c.pct + '% bonus credits, added the moment the purchase settles. Your $5 pays your sponsor line the second it clears, and your line’s $5s pay you. Friday is when we all push at once.\n\nLine up your pack: https://instantadpay.com/my#buy\nWatch the wave on the live ledger: https://instantadpay.com/ledger';
await broadcast(subject, text);
}
if (wd === 'Fri' && h >= 9 && day >= c.start && !S.sent['kick:' + day]) {
if (wd === 'Fri' && h >= 9 && day >= c.start && !S.sent['kick:' + day] && await claimOnce('fri:kick:' + day)) {
S.sent['kick:' + day] = now; save();
X.telegram('\u{1F4B5} <b>It’s Five Dollar Friday.</b> Any ad package from $5 up earns <b>+' + c.pct + '% credits</b> today, and every pack pays its sponsor line in the same transaction. Buy yours, then watch the ledger fill up: https://instantadpay.com/my#buy').catch(() => {});
}
if (wd === 'Sat' && h < 3) {
const fri = dayOf(now - 86400000);
if (isFriday(now - 86400000) && fri >= c.start && !S.sent['wrap:' + fri]) {
if (isFriday(now - 86400000) && fri >= c.start && !S.sent['wrap:' + fri] && await claimOnce('fri:wrap:' + fri)) {
S.sent['wrap:' + fri] = now; save();
const st = stats(fri); const top = await topLines(fri);
X.telegram('\u{1F3C1} <b>Five Dollar Friday wrap-up</b> (' + labelOf(fri) + ')\n<b>' + st.packs + ' packs</b> from ' + st.buyers + ' buyers · $' + st.usd.toFixed(0) + ' in ad packages · <b>' + st.polPaid.toFixed(2) + ' POL</b> paid to members · ' + st.bonusCredits.toLocaleString() + ' bonus credits handed out'
+50
View File
@@ -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 };
+20 -3
View File
@@ -36,7 +36,8 @@ const syndicate = require('./syndicate');
const releases = require('./releases');
const ledger = require('./ledger');
const videosweep = require('./videosweep');
const sbcheck = require('./sbcheck'); // Google Safe Browsing screen on advertiser destinations
const sbcheck = require('./sbcheck');
const marks = require('./marks'); // one-time claims shared across running instances (deploy overlap) // Google Safe Browsing screen on advertiser destinations
const updates = require('./updates');
const audit = require('./audit'); // counter audit: views vs delivery logs, charges vs shows (Marty, 2026-09-15) // member update emails from Admin > Releases (Marty, 2026-09-14)
const leaderboard = require('./leaderboard');
@@ -434,7 +435,23 @@ async function frameCheck(url) {
}
async function boot() {
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); telegramOnEvent(ev).catch(() => {}); friday.onEvent(ev).catch(e => console.error('friday bonus', e.message)); sponsorSyncOnEvent(ev).catch(e => console.error('sponsor sync', e.message)); } });
marks.init({ db });
marks.ensureTable().catch(e => console.error('marks table', e.message));
setInterval(() => marks.prune().catch(() => {}), 6 * 3600 * 1000).unref();
chain.init({ onEvent: ev => {
// the in-memory feed is per instance and always runs
attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev));
// everything outward (email, Telegram, the Friday bonus, sponsor sync) happens ONCE across
// every running copy of the app: during a deploy two copies watch the chain for ~30 s,
// and on 2026-09-25 both emailed Marty and both granted his Friday bonus
marks.claim('ev:' + ev.tx + ':' + ev.li).then(mine => {
if (!mine) return;
emailOnEvent(ev).catch(() => {});
telegramOnEvent(ev).catch(() => {});
friday.onEvent(ev).catch(e => console.error('friday bonus', e.message));
sponsorSyncOnEvent(ev).catch(e => console.error('sponsor sync', e.message));
}).catch(e => console.error('marks claim', e.message));
} });
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain, tiers: () => geo.tierLists(siteConfig()) });
@@ -453,7 +470,7 @@ async function boot() {
snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, publicDir: PUBLIC_DIR,
send: async (c, t, th) => { await telegramSend(c, t, th); return true; },
sendPhoto: (c, jpeg, cap, th) => telegramSendPhoto(c, jpeg, cap, th) });
friday.init({ dataDir: DATA_DIR, siteConfig, chain, ads, accounts, mailer, drip,
friday.init({ dataDir: DATA_DIR, siteConfig, chain, ads, accounts, mailer, drip, marks,
telegram: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return false; return telegramSend(sc.telegramEchoChatId, '\u{1F7E0} <b>InstantAdPay</b> \u00b7 ' + text, sc.telegramEchoTopicId); },
notify: async (memberId, subject, text, kind) => { const a = await accounts.byMemberId(memberId); if (!a || !a.email) return; const html = '<p>' + String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/(https:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>').split('\n\n').join('</p><p>').replace(/\n/g, '<br>') + '</p>'; await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [a.email], subject, html, kind || 'notice'); } });
// Campaign refill notices: an advertiser is told when an ad is nearly out and when it has stopped,