Skip to content

Commit 52b87ae

Browse files
authored
Merge pull request #104 from abayomicornelius/feat/airdrop-expiry-reconciliation-job
feat: automatic airdrop expiry reconciliation job
2 parents ee602f3 + 20f9a59 commit 52b87ae

9 files changed

Lines changed: 638 additions & 4 deletions

File tree

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ PRICE_STALE_THRESHOLD_MINUTES=5
4646
# PRICE_ANOMALY_THRESHOLD_PCT: number. Default: 20.
4747
PRICE_ANOMALY_THRESHOLD_PCT=20
4848

49+
# AIRDROP_EXPIRY_CHECK_INTERVAL_SECONDS: number. Default: 60.
50+
AIRDROP_EXPIRY_CHECK_INTERVAL_SECONDS=60
51+
52+
# AIRDROP_LEDGER_CACHE_TTL_MS: number. Default: 5000.
53+
AIRDROP_LEDGER_CACHE_TTL_MS=5000
54+
55+
# AIRDROP_EXPIRY_SCAN_BATCH_SIZE: number. Default: 100.
56+
AIRDROP_EXPIRY_SCAN_BATCH_SIZE=100
57+
4958
# API key auth
5059
ADMIN_API_KEY=
5160

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Registers subscriber endpoints for SmartDrop lifecycle events and delivers signe
3939
- `airdrop.created`
4040
- `airdrop.executing`
4141
- `airdrop.completed`
42-
- `airdrop.failed`
42+
- `airdrop.failed` — fired automatically when an airdrop expires (see below), in addition to any other failure path
4343
- `recipient.claimed`
4444

4545
**Features:**
@@ -49,6 +49,28 @@ Registers subscriber endpoints for SmartDrop lifecycle events and delivers signe
4949
- Delivery logs with response code, error, duration, and attempt count
5050
- Dead-letter storage after retry exhaustion
5151

52+
### Airdrop Expiry Reconciliation
53+
54+
Airdrops carry an `expiry_ledger`, validated as being in the future only at
55+
creation/update time. A background job (`src/jobs/airdropExpiry.js`, same
56+
`start()`/`stop()` pattern as the price-refresh and webhook-retry jobs)
57+
periodically re-checks that condition against the live network:
58+
59+
- Every `AIRDROP_EXPIRY_CHECK_INTERVAL_SECONDS` (default 60s), fetches the
60+
current Horizon ledger sequence and scans every airdrop still in a
61+
non-terminal status (`draft`, `executing`).
62+
- Any airdrop whose `expiry_ledger` has passed is atomically transitioned to
63+
`expired` and fires an `airdrop.failed` webhook event (`data.reason:
64+
"expired"`) to every subscriber registered for it — no client action
65+
required.
66+
- The transition is idempotent: re-running the check against an
67+
already-expired airdrop is a guaranteed no-op, so the webhook fires
68+
exactly once per airdrop even if the job runs again before anything else
69+
changes its status.
70+
- If Horizon is temporarily unreachable, the job logs a warning and skips
71+
that cycle rather than crashing — airdrops are simply re-checked on the
72+
next tick.
73+
5274
---
5375

5476
## 🚀 Quick Start (Docker Development)

src/config.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ const env = cleanEnv(rawEnv, {
4040
PRICE_ANOMALY_THRESHOLD_PCT: num({ default: 20 }),
4141
PRICE_SOURCE_CIRCUIT_COOLDOWN_MS: num({ default: 15 * 60 * 1000 }),
4242
PRICE_SOURCE_CIRCUIT_REMINDER_MS: num({ default: 5 * 60 * 1000 }),
43+
AIRDROP_EXPIRY_CHECK_INTERVAL_SECONDS: num({ default: 60 }),
44+
AIRDROP_LEDGER_CACHE_TTL_MS: num({ default: 5000 }),
45+
AIRDROP_EXPIRY_SCAN_BATCH_SIZE: num({ default: 100 }),
4346
LOG_LEVEL: str({
4447
default: 'info',
4548
choices: ['debug', 'info', 'warn', 'error'],
@@ -86,6 +89,19 @@ module.exports = {
8689
// line per fetch cycle for the entire cooldown window.
8790
circuitReminderIntervalMs: env.PRICE_SOURCE_CIRCUIT_REMINDER_MS,
8891
},
92+
airdrops: {
93+
// How often the expiry reconciliation job scans non-terminal airdrops
94+
// against the live Horizon ledger sequence.
95+
expiryCheckIntervalSeconds: env.AIRDROP_EXPIRY_CHECK_INTERVAL_SECONDS,
96+
// getCurrentLedger() is a live Horizon call with no caching; a job that
97+
// polls frequently should reuse the same ledger sequence for this long
98+
// rather than hitting Horizon once per airdrop per cycle.
99+
ledgerCacheTtlMs: env.AIRDROP_LEDGER_CACHE_TTL_MS,
100+
// SSCAN batch size used when scanning the full airdrop ID set — keeps
101+
// each Redis round-trip small instead of loading the whole set (SMEMBERS)
102+
// into memory at once.
103+
expiryScanBatchSize: env.AIRDROP_EXPIRY_SCAN_BATCH_SIZE,
104+
},
89105
auth: {
90106
adminApiKey: env.ADMIN_API_KEY,
91107
},

src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const cache = require('./services/cache');
88
const priceOracle = require('./services/priceOracle');
99
const priceRefreshJob = require('./jobs/priceRefresh');
1010
const webhookRetryWorker = require('./jobs/webhookRetryWorker');
11+
const airdropExpiryJob = require('./jobs/airdropExpiry');
1112
const buildCorsMiddleware = require('./middleware/cors');
1213
const { requestIdMiddleware } = require('./middleware/requestId');
1314
const { requireApiKey } = require('./middleware/auth');
@@ -56,6 +57,7 @@ function shutdown(signal) {
5657
logger.info(`${signal} received, shutting down`);
5758
priceRefreshJob.stop();
5859
webhookRetryWorker.stop();
60+
airdropExpiryJob.stop();
5961
require('./ws/PriceSubscriptionManager').stopHeartbeat();
6062
if (server) server.close();
6163
await cache.disconnect();
@@ -69,6 +71,7 @@ if (require.main === module) {
6971
priceWebSocket.attach(server);
7072
priceRefreshJob.start();
7173
webhookRetryWorker.start();
74+
airdropExpiryJob.start();
7275
});
7376

7477
process.on('SIGTERM', shutdown('SIGTERM'));

src/jobs/airdropExpiry.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
'use strict';
2+
3+
const cron = require('node-cron');
4+
const airdropsService = require('../services/airdrops');
5+
const webhookDispatcher = require('../services/webhookDispatcher');
6+
const config = require('../config');
7+
const logger = require('../logger');
8+
9+
let scheduledTask = null;
10+
11+
/**
12+
* One reconciliation pass: scans every non-terminal airdrop and expires any
13+
* whose expiry_ledger has passed the current Horizon ledger. Exported
14+
* separately from start() so tests can drive a single tick deterministically
15+
* instead of waiting on cron.
16+
*/
17+
async function tick() {
18+
let currentLedger;
19+
try {
20+
currentLedger = await airdropsService.getCurrentLedger();
21+
} catch (err) {
22+
// Matches priceOracle.js's graceful-degradation style: Horizon being
23+
// temporarily unreachable is expected and recoverable — log and skip
24+
// this cycle rather than crashing the job or throwing out of the cron
25+
// callback.
26+
logger.warn('Airdrop expiry check skipped, Horizon unreachable', { error: err.message });
27+
return;
28+
}
29+
30+
let expiredCount = 0;
31+
let scannedCount = 0;
32+
33+
for await (const batch of airdropsService.scanIds()) {
34+
for (const id of batch) {
35+
scannedCount += 1;
36+
37+
let airdrop;
38+
try {
39+
airdrop = await airdropsService.get(id);
40+
} catch (err) {
41+
logger.error('Airdrop expiry check failed to read airdrop, skipping', {
42+
airdrop_id: id,
43+
error: err.message,
44+
});
45+
continue;
46+
}
47+
48+
if (!airdrop || airdropsService.TERMINAL_STATUSES.has(airdrop.status)) continue;
49+
if (!airdrop.expiry_ledger || airdrop.expiry_ledger > currentLedger) continue;
50+
51+
// Cheap pre-filter above avoids an unnecessary Lua round trip for the
52+
// (typically vast majority of) airdrops nowhere near expiry.
53+
// markExpired re-checks status and expiry_ledger atomically — if this
54+
// pre-filter read was stale, or another cycle/process already
55+
// transitioned it, markExpired safely no-ops instead of double-firing.
56+
let updated;
57+
try {
58+
updated = await airdropsService.markExpired(id, currentLedger);
59+
} catch (err) {
60+
logger.error('Airdrop expiry transition failed, skipping', {
61+
airdrop_id: id,
62+
error: err.message,
63+
});
64+
continue;
65+
}
66+
if (!updated) continue;
67+
68+
expiredCount += 1;
69+
try {
70+
await webhookDispatcher.dispatch({
71+
event_type: 'airdrop.failed',
72+
event_id: `evt_airdrop_expired_${id}_${currentLedger}`,
73+
data: {
74+
airdrop_id: id,
75+
reason: 'expired',
76+
expiry_ledger: updated.expiry_ledger,
77+
current_ledger: currentLedger,
78+
},
79+
});
80+
} catch (err) {
81+
// The transition already committed — the airdrop is correctly
82+
// expired regardless of whether the webhook delivery attempt
83+
// itself failed to enqueue. Losing this specific delivery on a
84+
// dispatch-time error (as opposed to an individual subscriber's
85+
// endpoint failing, which webhookDispatcher already retries) is an
86+
// accepted gap here — see #84 for the broader non-atomic-writes
87+
// theme this falls under.
88+
logger.error('Airdrop expiry webhook dispatch failed', {
89+
airdrop_id: id,
90+
error: err.message,
91+
});
92+
}
93+
}
94+
}
95+
96+
logger.info('Airdrop expiry check completed', {
97+
currentLedger,
98+
scanned: scannedCount,
99+
expired: expiredCount,
100+
});
101+
}
102+
103+
function start() {
104+
if (scheduledTask) return;
105+
106+
const intervalSeconds = config.airdrops.expiryCheckIntervalSeconds;
107+
const cronExpression = `*/${intervalSeconds} * * * * *`;
108+
109+
scheduledTask = cron.schedule(
110+
cronExpression,
111+
() => {
112+
tick().catch((err) => {
113+
logger.error('Airdrop expiry check failed', { error: err.message });
114+
});
115+
},
116+
{ scheduled: true },
117+
);
118+
119+
logger.info('Airdrop expiry job started', { intervalSeconds });
120+
}
121+
122+
function stop() {
123+
if (scheduledTask) {
124+
scheduledTask.stop();
125+
scheduledTask = null;
126+
logger.info('Airdrop expiry job stopped');
127+
}
128+
}
129+
130+
module.exports = { start, stop, tick };

src/services/airdrops.js

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,25 @@ function generateId() {
2020

2121
const 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+
2332
async 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

2844
async 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+
56135
async 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
};

src/services/webhookEvents.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@ const POOL_EVENTS = Object.freeze([
1010

1111
const PRICE_EVENTS = Object.freeze(['price.alert']);
1212

13-
const ALL_EVENTS = Object.freeze([...POOL_EVENTS, ...PRICE_EVENTS]);
13+
// Only 'airdrop.failed' is registered here — it's the one event this
14+
// codebase actually dispatches today (the expiry reconciliation job, #88).
15+
// The README also documents airdrop.created/executing/completed, but
16+
// nothing in the codebase dispatches those yet; registering unused event
17+
// names here would let a client subscribe to something that can never
18+
// fire, so they're left out until whatever feature actually dispatches
19+
// them lands.
20+
const AIRDROP_EVENTS = Object.freeze(['airdrop.failed']);
21+
22+
const ALL_EVENTS = Object.freeze([...POOL_EVENTS, ...PRICE_EVENTS, ...AIRDROP_EVENTS]);
1423
const EVENT_SET = new Set(ALL_EVENTS);
1524

1625
const WILDCARD = '*';
@@ -33,6 +42,7 @@ function matchesSubscription(subscribedEvents, eventType) {
3342
module.exports = {
3443
POOL_EVENTS,
3544
PRICE_EVENTS,
45+
AIRDROP_EVENTS,
3646
ALL_EVENTS,
3747
WILDCARD,
3848
isKnownEvent,

0 commit comments

Comments
 (0)