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
+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,