@@ -20,9 +20,25 @@ function generateId() {
2020
2121const horizon = new Horizon . Server ( config . stellar . horizonUrl ) ;
2222
23+ // getCurrentLedger() is a live Horizon call. Callers that need to check many
24+ // airdrops in quick succession (the expiry reconciliation job, in
25+ // particular — see #88) would otherwise issue one Horizon request per
26+ // airdrop per cycle; cache the result briefly so bursts of calls within the
27+ // same window reuse one ledger read instead of hammering Horizon, the same
28+ // rate-limit concern already applied to CoinGecko/CoinMarketCap elsewhere.
29+ let cachedLedger = null ;
30+ let cachedLedgerAt = 0 ;
31+
2332async function getCurrentLedger ( ) {
33+ const now = Date . now ( ) ;
34+ if ( cachedLedger !== null && now - cachedLedgerAt < config . airdrops . ledgerCacheTtlMs ) {
35+ return cachedLedger ;
36+ }
37+
2438 const ledger = await horizon . ledgers ( ) . order ( 'desc' ) . limit ( 1 ) . call ( ) ;
25- return ledger . records [ 0 ] . sequence ;
39+ cachedLedger = ledger . records [ 0 ] . sequence ;
40+ cachedLedgerAt = now ;
41+ return cachedLedger ;
2642}
2743
2844async function create ( data ) {
@@ -53,6 +69,69 @@ async function create(data) {
5369 return airdrop ;
5470}
5571
72+ /**
73+ * Pages through the full airdrop ID set via SSCAN instead of SMEMBERS. Used
74+ * by the expiry reconciliation job (#88), which needs to visit every
75+ * airdrop every cycle: SMEMBERS returns the whole set in one blocking call
76+ * and would need it all held in memory at once, which doesn't scale as the
77+ * set grows. SSCAN pages incrementally with a small, bounded cursor cost per
78+ * call. `list()` above is unchanged — this is a separate, job-internal
79+ * scanning path, not a replacement for the paginated HTTP listing endpoint.
80+ */
81+ async function * scanIds ( batchSize = config . airdrops . expiryScanBatchSize ) {
82+ const redis = cache . getClient ( ) ;
83+ let cursor = '0' ;
84+ do {
85+ const [ nextCursor , batch ] = await redis . sscan ( IDS_KEY , cursor , 'COUNT' , batchSize ) ;
86+ cursor = nextCursor ;
87+ if ( batch . length > 0 ) {
88+ yield batch ;
89+ }
90+ } while ( cursor !== '0' ) ;
91+ }
92+
93+ // Statuses an airdrop cannot leave once reached.
94+ const TERMINAL_STATUSES = new Set ( [ 'completed' , 'failed' , 'cancelled' , 'expired' ] ) ;
95+
96+ /**
97+ * Atomically transitions an airdrop to 'expired' if — and only if — it's
98+ * still in a non-terminal status *and* its expiry_ledger has actually
99+ * passed, checked and written in a single Lua script so two processes (or
100+ * two overlapping job cycles) racing on the same airdrop can't both "win"
101+ * and each fire a duplicate webhook. Returns the updated airdrop on a
102+ * successful transition, or null if nothing changed (already terminal, not
103+ * yet expired, or the airdrop doesn't exist) — callers use that to decide
104+ * whether to dispatch a webhook.
105+ */
106+ const MARK_EXPIRED_SCRIPT = `
107+ local raw = redis.call('GET', KEYS[1])
108+ if not raw then return false end
109+ local airdrop = cjson.decode(raw)
110+ local terminal = { completed = true, failed = true, cancelled = true, expired = true }
111+ if terminal[airdrop.status] then return false end
112+ if not airdrop.expiry_ledger or tonumber(airdrop.expiry_ledger) > tonumber(ARGV[1]) then
113+ return false
114+ end
115+ airdrop.status = 'expired'
116+ airdrop.updated_at = ARGV[2]
117+ local updated = cjson.encode(airdrop)
118+ redis.call('SET', KEYS[1], updated)
119+ return updated
120+ ` ;
121+
122+ async function markExpired ( id , currentLedger ) {
123+ const redis = cache . getClient ( ) ;
124+ const result = await redis . eval (
125+ MARK_EXPIRED_SCRIPT ,
126+ 1 ,
127+ airdropKey ( id ) ,
128+ currentLedger ,
129+ new Date ( ) . toISOString ( ) ,
130+ ) ;
131+ if ( ! result ) return null ;
132+ return JSON . parse ( result ) ;
133+ }
134+
56135async function list ( page = 1 , limit = 20 ) {
57136 const redis = cache . getClient ( ) ;
58137 const ids = await redis . smembers ( IDS_KEY ) ;
@@ -156,4 +235,7 @@ module.exports = {
156235 addRecipients,
157236 listRecipients,
158237 getCurrentLedger,
238+ scanIds,
239+ markExpired,
240+ TERMINAL_STATUSES ,
159241} ;
0 commit comments