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'