From 742c2714692b7f8dfc990310e70fef63db1faddb Mon Sep 17 00:00:00 2001 From: martbost Date: Fri, 25 Sep 2026 06:40:11 -0500 Subject: [PATCH] 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::' 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 --- friday.js | 8 +++++--- marks.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 23 ++++++++++++++++++++--- 3 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 marks.js diff --git a/friday.js b/friday.js index cf7964b..4280672 100644 --- a/friday.js +++ b/friday.js @@ -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} It’s Five Dollar Friday. Any ad package from $5 up earns +' + c.pct + '% credits 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} Five Dollar Friday wrap-up (' + labelOf(fri) + ')\n' + st.packs + ' packs from ' + st.buyers + ' buyers · $' + st.usd.toFixed(0) + ' in ad packages · ' + st.polPaid.toFixed(2) + ' POL paid to members · ' + st.bonusCredits.toLocaleString() + ' bonus credits handed out' diff --git a/marks.js b/marks.js new file mode 100644 index 0000000..0af8acc --- /dev/null +++ b/marks.js @@ -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 }; diff --git a/server.js b/server.js index 566192f..b345b53 100644 --- a/server.js +++ b/server.js @@ -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} InstantAdPay \u00b7 ' + text, sc.telegramEchoTopicId); }, notify: async (memberId, subject, text, kind) => { const a = await accounts.byMemberId(memberId); if (!a || !a.email) return; const html = '

' + String(text).replace(/&/g, '&').replace(//g, '>').replace(/(https:\/\/[^\s]+)/g, '$1').split('\n\n').join('

').replace(/\n/g, '
') + '

'; 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,