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:
@@ -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 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 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) {} }
|
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() {
|
function cfg() {
|
||||||
const c = (X.siteConfig && X.siteConfig()) || {};
|
const c = (X.siteConfig && X.siteConfig()) || {};
|
||||||
@@ -106,19 +108,19 @@ async function tick() {
|
|||||||
const c = cfg(); if (!c.on) return;
|
const c = cfg(); if (!c.on) return;
|
||||||
const now = Date.now(); const day = dayOf(now); const wd = weekdayOf(now); const h = hourOf(now);
|
const now = Date.now(); const day = dayOf(now); const wd = weekdayOf(now); const h = hourOf(now);
|
||||||
const next = nextFriday(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();
|
S.sent['thu:' + next] = now; save();
|
||||||
const subject = 'Tomorrow is Five Dollar Friday';
|
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';
|
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);
|
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();
|
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(() => {});
|
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) {
|
if (wd === 'Sat' && h < 3) {
|
||||||
const fri = dayOf(now - 86400000);
|
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();
|
S.sent['wrap:' + fri] = now; save();
|
||||||
const st = stats(fri); const top = await topLines(fri);
|
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'
|
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'
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -36,7 +36,8 @@ const syndicate = require('./syndicate');
|
|||||||
const releases = require('./releases');
|
const releases = require('./releases');
|
||||||
const ledger = require('./ledger');
|
const ledger = require('./ledger');
|
||||||
const videosweep = require('./videosweep');
|
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 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 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');
|
const leaderboard = require('./leaderboard');
|
||||||
@@ -434,7 +435,23 @@ async function frameCheck(url) {
|
|||||||
}
|
}
|
||||||
async function boot() {
|
async function boot() {
|
||||||
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
|
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' });
|
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
|
||||||
accounts.init({ dataDir: DATA_DIR });
|
accounts.init({ dataDir: DATA_DIR });
|
||||||
ads.init({ dataDir: DATA_DIR, chain, tiers: () => geo.tierLists(siteConfig()) });
|
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,
|
snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, publicDir: PUBLIC_DIR,
|
||||||
send: async (c, t, th) => { await telegramSend(c, t, th); return true; },
|
send: async (c, t, th) => { await telegramSend(c, t, th); return true; },
|
||||||
sendPhoto: (c, jpeg, cap, th) => telegramSendPhoto(c, jpeg, cap, th) });
|
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); },
|
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, '&').replace(/</g, '<').replace(/>/g, '>').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'); } });
|
notify: async (memberId, subject, text, kind) => { const a = await accounts.byMemberId(memberId); if (!a || !a.email) return; const html = '<p>' + String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').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,
|
// Campaign refill notices: an advertiser is told when an ad is nearly out and when it has stopped,
|
||||||
|
|||||||
Reference in New Issue
Block a user