Skip to content
This repository was archived by the owner on Apr 30, 2026. It is now read-only.

Commit 6a8be17

Browse files
back2matchingclaude
andcommitted
Phase 12 batch 7: F06 — queue persistence
New queue_entries table + replace_queue_snapshot(jsonb) RPC (migration 20260422_003, applied prod). In-memory queue stays the hot path; every mutation fires an async snapshot-replace, and boot-time loadQueueFromDB() rehydrates the array before the server listener begins accepting requests. Mirror sites instrumented: - /join push - /join/bulk push (loop) - settlement observer paymentId stamp (F20) - ws.close paid → disconnected flag, unpaid → remove - startGame pull + rollback - stale-queue sweep filter Previously a pm2 restart silently lost every in-flight paid entry with no refund path. The stale-queue sweeper remains the final arbiter if a rehydrated entry's payment never reconciles. Tests: 192/192 including a new queue-persist round-trip suite. Boot-tested locally — rehydrate fires on startup, health endpoint responsive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 674f86b commit 6a8be17

5 files changed

Lines changed: 389 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Multi-agent audit ran 2026-04-22; produced 43 ranked findings (see internal `.lo
3030
- **F37** — Jackpot-pool deduction on a win is now atomic via the `deduct_jackpot_pool_for_win` RPC (migration `20260422_002_jackpot_pool_deduct_atomic.sql`). Previously `updateJackpotPoolAfterWin` wrote the pre-computed `remainingPool` as a raw overwrite, so any concurrent `/join` contribution landing between the roll-read and the roll-write was silently clobbered.
3131
- **F35**`POST /faucet` gained an IP-based rate limit (5/min prod, 30/min dev; `FAUCET_RATE_LIMIT` override). The per-wallet DB cap only applied AFTER the CDP transfer, so a script cycling fresh `walletAddress` values could exhaust the project-wide CDP faucet quota.
3232
- **F26** — Jackpot tier selection now uses cumulative thresholds (`roll < ultra``roll < ultra + mega``roll < ultra + mega + mini`) so the marginal MINI/MEGA/ULTRA probabilities exactly match the advertised 3% / 0.3% / 0.04%. Previously sequential `roll < tier.chance` checks made the effective MEGA probability 0.26% and MINI 2.7% — users under-delivered vs. the marketing copy.
33+
- **F06** — Queue entries now persist to a new `queue_entries` table (migration `20260422_003_queue_entries_persistence.sql`) via the atomic `replace_queue_snapshot(jsonb)` RPC after every mutation (push, splice, filter, paymentId stamp, disconnect flag, startGame pull). On server boot the in-memory queue is rehydrated from this table before the HTTP listener begins processing requests. Previously the queue was pure in-memory state, so every pm2 restart or crash silently dropped in-flight paid entries with no refund path. Persistence is fire-and-forget from request handlers (memory is still the hot path); the stale-queue sweeper is the final arbiter if a payment never reconciles.
3334

3435
Pre-Phase-12 items from this Unreleased window:
3536

scripts/queue-persist.test.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* F06 — Queue persistence round-trip tests.
3+
*
4+
* Pure-function tests for queueEntryToRow/rowToQueueEntry — no DB required.
5+
* Verifies that a full queue entry (bulk + single + paid + unpaid) survives
6+
* a serialize → deserialize round-trip so rehydrate on boot preserves state.
7+
*/
8+
9+
const { test, describe } = require('node:test');
10+
const assert = require('node:assert');
11+
12+
const { queueEntryToRow, rowToQueueEntry } = require('../src/lib/supabase');
13+
14+
describe('F06 — queue entry serialization', () => {
15+
test('single paid entry round-trips', () => {
16+
const entry = {
17+
playerId: 'agent-alice',
18+
displayName: 'Alice',
19+
walletAddress: '0xAbCdEf0123456789aBcDeF0123456789AbCdEf01',
20+
gamePoolContribution: 0.15,
21+
jackpotContribution: 0.10,
22+
callbackUrl: 'https://alice.example.com/hook',
23+
webhookSecret: 'whsec_abcdef0123456789abcdef0123456789abcdef0123456789',
24+
idempotencyKey: 'idem-12345',
25+
paymentId: 'pay_1234567890_abcd',
26+
joinedAt: 1700000000000,
27+
disconnected: false
28+
};
29+
30+
const row = queueEntryToRow(entry);
31+
const back = rowToQueueEntry({
32+
...row,
33+
// simulate what Supabase returns: nulls for '' fields, preserved types
34+
wallet_address: row.wallet_address || null,
35+
callback_url: row.callback_url || null,
36+
webhook_secret: row.webhook_secret || null,
37+
idempotency_key: row.idempotency_key || null,
38+
payment_id: row.payment_id || null,
39+
bulk_parent_id: null,
40+
bulk_index: null,
41+
bulk_count: null,
42+
disconnected_at: null
43+
});
44+
45+
assert.strictEqual(back.playerId, entry.playerId);
46+
assert.strictEqual(back.displayName, entry.displayName);
47+
assert.strictEqual(back.walletAddress, entry.walletAddress.toLowerCase());
48+
assert.strictEqual(back.gamePoolContribution, entry.gamePoolContribution);
49+
assert.strictEqual(back.jackpotContribution, entry.jackpotContribution);
50+
assert.strictEqual(back.callbackUrl, entry.callbackUrl);
51+
assert.strictEqual(back.webhookSecret, entry.webhookSecret);
52+
assert.strictEqual(back.idempotencyKey, entry.idempotencyKey);
53+
assert.strictEqual(back.paymentId, entry.paymentId);
54+
assert.strictEqual(back.joinedAt, entry.joinedAt);
55+
assert.strictEqual(back.disconnected, false);
56+
});
57+
58+
test('bulk sub-entry round-trips with index/count/parent', () => {
59+
const entry = {
60+
playerId: 'agent-bob#1700000000000-7',
61+
displayName: 'Bob',
62+
walletAddress: '0x1111111111111111111111111111111111111111',
63+
gamePoolContribution: 0.15,
64+
jackpotContribution: 0.10,
65+
callbackUrl: null,
66+
webhookSecret: null,
67+
idempotencyKey: null,
68+
bulkParentId: 'agent-bob',
69+
bulkIndex: 7,
70+
bulkCount: 50,
71+
joinedAt: 1700000000007,
72+
disconnected: false
73+
};
74+
75+
const row = queueEntryToRow(entry);
76+
assert.strictEqual(row.bulk_index, '7');
77+
assert.strictEqual(row.bulk_count, '50');
78+
assert.strictEqual(row.bulk_parent_id, 'agent-bob');
79+
80+
const back = rowToQueueEntry({
81+
...row,
82+
wallet_address: row.wallet_address,
83+
bulk_index: 7,
84+
bulk_count: 50,
85+
bulk_parent_id: 'agent-bob',
86+
callback_url: null,
87+
webhook_secret: null,
88+
idempotency_key: null,
89+
payment_id: null,
90+
disconnected_at: null
91+
});
92+
93+
assert.strictEqual(back.bulkIndex, 7);
94+
assert.strictEqual(back.bulkCount, 50);
95+
assert.strictEqual(back.bulkParentId, 'agent-bob');
96+
});
97+
98+
test('disconnected entry preserves disconnectedAt', () => {
99+
const now = 1700000500000;
100+
const entry = {
101+
playerId: 'agent-charlie',
102+
displayName: 'Charlie',
103+
walletAddress: null,
104+
gamePoolContribution: 0,
105+
jackpotContribution: 0,
106+
joinedAt: 1700000000000,
107+
disconnected: true,
108+
disconnectedAt: now
109+
};
110+
111+
const row = queueEntryToRow(entry);
112+
assert.strictEqual(row.disconnected, true);
113+
assert.strictEqual(new Date(row.disconnected_at).getTime(), now);
114+
115+
const back = rowToQueueEntry({
116+
...row,
117+
wallet_address: null,
118+
callback_url: null,
119+
webhook_secret: null,
120+
idempotency_key: null,
121+
payment_id: null,
122+
bulk_parent_id: null,
123+
bulk_index: null,
124+
bulk_count: null
125+
});
126+
127+
assert.strictEqual(back.disconnected, true);
128+
assert.strictEqual(back.disconnectedAt, now);
129+
});
130+
131+
test('unpaid entry with no wallet round-trips', () => {
132+
const entry = {
133+
playerId: 'guest-1',
134+
displayName: 'Guest',
135+
walletAddress: null,
136+
gamePoolContribution: 0,
137+
jackpotContribution: 0,
138+
joinedAt: 1700000000000,
139+
disconnected: false
140+
};
141+
142+
const row = queueEntryToRow(entry);
143+
assert.strictEqual(row.wallet_address, '');
144+
145+
const back = rowToQueueEntry({
146+
...row,
147+
wallet_address: null,
148+
callback_url: null,
149+
webhook_secret: null,
150+
idempotency_key: null,
151+
payment_id: null,
152+
bulk_parent_id: null,
153+
bulk_index: null,
154+
bulk_count: null,
155+
disconnected_at: null
156+
});
157+
158+
assert.strictEqual(back.walletAddress, null);
159+
assert.strictEqual(back.gamePoolContribution, 0);
160+
});
161+
});

src/lib/supabase.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,94 @@ async function deductFromJackpotPoolForWin(payoutAmount, tier) {
936936
}
937937
}
938938

939+
// =============================================================================
940+
// F06 — Queue persistence
941+
// =============================================================================
942+
// The in-memory `queue` array in src/server.js is the hot path; this module
943+
// mirrors it to the `queue_entries` table on every mutation. On boot the
944+
// queue is re-hydrated from the table so restarts/crashes don't lose paid
945+
// in-flight entries.
946+
947+
function queueEntryToRow(entry) {
948+
return {
949+
player_id: String(entry.playerId || ''),
950+
display_name: String(entry.displayName || ''),
951+
wallet_address: entry.walletAddress ? String(entry.walletAddress).toLowerCase() : '',
952+
game_pool_contribution: Number(entry.gamePoolContribution || 0),
953+
jackpot_contribution: Number(entry.jackpotContribution || 0),
954+
callback_url: entry.callbackUrl || '',
955+
webhook_secret: entry.webhookSecret || '',
956+
idempotency_key: entry.idempotencyKey || '',
957+
payment_id: entry.paymentId || '',
958+
bulk_parent_id: entry.bulkParentId || '',
959+
bulk_index: (entry.bulkIndex !== undefined && entry.bulkIndex !== null) ? String(entry.bulkIndex) : '',
960+
bulk_count: (entry.bulkCount !== undefined && entry.bulkCount !== null) ? String(entry.bulkCount) : '',
961+
joined_at: new Date(entry.joinedAt || Date.now()).toISOString(),
962+
disconnected: !!entry.disconnected,
963+
disconnected_at: entry.disconnectedAt ? new Date(entry.disconnectedAt).toISOString() : ''
964+
};
965+
}
966+
967+
function rowToQueueEntry(row) {
968+
return {
969+
playerId: row.player_id,
970+
displayName: row.display_name,
971+
walletAddress: row.wallet_address || null,
972+
gamePoolContribution: Number(row.game_pool_contribution || 0),
973+
jackpotContribution: Number(row.jackpot_contribution || 0),
974+
callbackUrl: row.callback_url || null,
975+
webhookSecret: row.webhook_secret || null,
976+
idempotencyKey: row.idempotency_key || null,
977+
paymentId: row.payment_id || null,
978+
bulkParentId: row.bulk_parent_id || null,
979+
bulkIndex: row.bulk_index !== null ? Number(row.bulk_index) : undefined,
980+
bulkCount: row.bulk_count !== null ? Number(row.bulk_count) : undefined,
981+
joinedAt: row.joined_at ? Date.parse(row.joined_at) : Date.now(),
982+
disconnected: !!row.disconnected,
983+
disconnectedAt: row.disconnected_at ? Date.parse(row.disconnected_at) : null
984+
};
985+
}
986+
987+
/**
988+
* Atomically replace the `queue_entries` table contents with the supplied
989+
* in-memory queue. Fire-and-forget callers should `.catch(log)` — a lost
990+
* snapshot write is a degradation (we still have the memory queue), not a
991+
* request failure.
992+
*/
993+
async function persistQueueSnapshot(queueArray) {
994+
if (!supabaseAdmin) return null;
995+
const payload = (queueArray || []).map(queueEntryToRow);
996+
try {
997+
const { data, error } = await supabaseAdmin.rpc('replace_queue_snapshot', {
998+
p_entries: payload
999+
});
1000+
if (error) throw error;
1001+
return data;
1002+
} catch (err) {
1003+
log.warn({ err: err.message, size: payload.length }, 'Queue snapshot write failed (non-fatal)');
1004+
return null;
1005+
}
1006+
}
1007+
1008+
/**
1009+
* Rehydrate the queue from the durable log on server boot.
1010+
* Returns an array ordered by joined_at (oldest first) to preserve FIFO.
1011+
*/
1012+
async function loadQueueFromDB() {
1013+
if (!supabaseAdmin) return [];
1014+
try {
1015+
const { data, error } = await supabaseAdmin
1016+
.from('queue_entries')
1017+
.select('*')
1018+
.order('joined_at', { ascending: true });
1019+
if (error) throw error;
1020+
return (data || []).map(rowToQueueEntry);
1021+
} catch (err) {
1022+
log.error({ err: err.message }, 'Queue rehydrate failed — starting with empty queue');
1023+
return [];
1024+
}
1025+
}
1026+
9391027
/**
9401028
* Check database connection
9411029
* @returns {Promise<boolean>}
@@ -973,6 +1061,11 @@ module.exports = {
9731061
updateJackpotPool,
9741062
addToJackpotPool,
9751063
deductFromJackpotPoolForWin,
1064+
// F06 — Queue persistence
1065+
persistQueueSnapshot,
1066+
loadQueueFromDB,
1067+
queueEntryToRow,
1068+
rowToQueueEntry,
9761069
// Jackpot Tickets
9771070
addJackpotTickets,
9781071
getAllJackpotTickets,

0 commit comments

Comments
 (0)