Overview
Several list endpoints implement pagination by first fetching an entire unordered Redis Set via SMEMBERS, then slicing the resulting array in application code:
// src/services/airdrops.js
async function list(page = 1, limit = 20) {
const redis = cache.getClient();
const ids = await redis.smembers(IDS_KEY);
const start = (page - 1) * limit;
const end = start + limit;
const paginatedIds = ids.slice(start, end);
...
}
// src/services/alerts.js
async function listPaginated({ offset = 0, limit = 20 } = {}) {
const redis = cache.getClient();
const ids = await redis.smembers(IDS_KEY);
...
const paginatedIds = ids.slice(offset, offset + limit);
...
}
// src/services/apiKeys.js
async function listKeys() {
const redis = cache.getClient();
const ids = await redis.smembers(IDS_KEY);
...
}
Redis Sets have no guaranteed stable iteration order across separate SMEMBERS calls, especially as members are added/removed between requests (which is exactly what happens in production — airdrops and alerts are created and deleted continuously). A client paginating through GET /airdrops?page=1 then GET /airdrops?page=2 can observe the same item twice, skip an item entirely, or see the total item count (ids.length, used for total_pages) change between pages in a way that makes has_next/total_pages unreliable — because each request independently re-fetches and re-slices a set whose order is not guaranteed to be consistent between calls, particularly after any create/delete has occurred concurrently. This is functionally different from (and worse than) normal "data changed between page loads" pagination drift, because even with zero writes occurring, SMEMBERS order is an implementation detail of Redis's internal hash table and is not contractually stable.
Contrast this with deliveryRepository.js's listByWebhook, which correctly uses a Redis sorted set (ZADD/ZREVRANGE) keyed by creation timestamp — a stable, order-preserving structure — for exactly this reason.
Requirements
- Convert the id-tracking structures in
airdropsService, alertsService, and apiKeys from Redis Sets (SADD/SMEMBERS/SREM) to sorted sets scored by creation timestamp (ZADD score=Date.now()/ZREVRANGE/ZREM), matching the pattern already established in deliveryRepository.js.
- Pagination must then use
ZREVRANGE/ZRANGEBYSCORE with stable, deterministic ordering (e.g. newest-first) rather than slicing an unordered array fetched via SMEMBERS.
- Preserve existing public API response shapes (
pagination.total, pagination.total_pages, etc.) — only the internal storage/retrieval mechanism changes.
- Apply consistently across all three affected services in a single coordinated change so behavior is uniform.
Acceptance Criteria
Additional Notes
Additional edge cases / failure modes
- Two items created within the same millisecond (
ZADD score=Date.now()) get identical scores — ZREVRANGE's tie-breaking behavior for equal scores falls back to lexicographic ordering of the member (the id string), which is stable but not necessarily "creation order" for same-millisecond ties. Since ids are generated via crypto.randomUUID()-derived strings (generateId() in each service), this tie-break order is effectively random-but-stable across repeated calls — acceptable for pagination stability (the core bug being fixed) but worth a one-line doc note that "stable" here means "consistent across page requests," not "guaranteed strict creation-order for same-millisecond ties."
- Migration path: this is a schema change to the storage format of existing production keys (
airdrops:ids, alerts:ids, and whatever apiKeys's equivalent IDS_KEY is called) — if any of these have existing production data as plain Sets, a migration step is needed (read existing SMEMBERS, look up each record's created_at if available to backfill scores, ZADD into a new sorted-set key, then cut over) rather than just changing the code and expecting SMEMBERS-populated keys to work with ZREVRANGE (they won't — wrong Redis type, WRONGTYPE error). Confirm whether this system has any existing production/staging data that needs migrating, or whether this is greenfield enough that a clean cutover (delete and recreate the key type) is acceptable.
apiKeys.js was referenced in the Overview/Requirements but not read in depth for this notes pass — confirm its listKeys() function (and any pagination wrapper around it, if routes/keys.js exists) is updated with the same sorted-set conversion; the Requirements explicitly list it as in-scope alongside airdropsService/alertsService.
alertsService.listPaginated({ offset, limit }) uses offset-based pagination while airdropsService.list(page, limit) uses page-based pagination with an internally-computed start/end — these two services have different pagination call conventions even though both suffer the same underlying SMEMBERS bug; the sorted-set fix should preserve each service's existing external call signature (don't accidentally unify them as part of this fix, since that would be a separate, larger API-shape change out of scope here) while fixing the internal ordering mechanism for both.
total/total_pages computation (ids.length) becomes ZCARD on the sorted set — cheap and correct, but confirm this is computed from the same Redis call/transaction as the page slice where practical, to avoid a race where the reported total reflects a state slightly different from the actual page contents if a create/delete happens between the ZCARD and the ZREVRANGE calls (a MULTI/EXEC pipeline, or accepting this small residual race as "good enough" now that the core ordering bug is fixed, are both reasonable — but this should be a stated decision, not an oversight).
Implementation sketch
// airdrops.js
async function create(data) {
...
const redis = cache.getClient();
await cache.set(airdropKey(id), airdrop);
await redis.zadd(IDS_KEY, Date.now(), id); // was: redis.sadd(IDS_KEY, id)
...
}
async function list(page = 1, limit = 20) {
const redis = cache.getClient();
const total = await redis.zcard(IDS_KEY);
const start = (page - 1) * limit;
const end = start + limit - 1;
const paginatedIds = await redis.zrevrange(IDS_KEY, start, end); // was: smembers + slice
const airdrops = await Promise.all(paginatedIds.map((id) => cache.get(airdropKey(id))));
return {
airdrops: airdrops.filter(Boolean),
pagination: { page, limit, total, total_pages: Math.ceil(total / limit) },
};
}
async function remove(id) {
...
await redis.zrem(IDS_KEY, id); // was: redis.srem(IDS_KEY, id)
...
}
Apply the equivalent sadd→zadd, smembers+slice→zrevrange, srem→zrem substitution in alertsService and apiKeys.
Test / reproduction plan
- Create 25 airdrops sequentially (so creation order is well-defined); paginate with
limit=10 across pages 1-3; assert the concatenation of all three pages' ids exactly matches the creation order reversed (newest-first), with no duplicates or gaps.
- Interleave: fetch page 1, then delete an airdrop that was on page 1, then fetch page 2; assert page 2's contents are unaffected by the deletion on page 1 (the classic "shifting window" bug this fix targets) — contrast explicitly with the old
SMEMBERS+slice behavior in a comment or a "before" test snapshot if feasible.
- Repeat both tests for
alertsService.listPaginated and apiKeys.listKeys.
- If a migration script is written, test it against a pre-populated plain-Set key and confirm correct sorted-set output with sensible fallback scoring for records lacking a usable
created_at.
Related issues in this batch
Overview
Several list endpoints implement pagination by first fetching an entire unordered Redis Set via
SMEMBERS, then slicing the resulting array in application code:Redis Sets have no guaranteed stable iteration order across separate
SMEMBERScalls, especially as members are added/removed between requests (which is exactly what happens in production — airdrops and alerts are created and deleted continuously). A client paginating throughGET /airdrops?page=1thenGET /airdrops?page=2can observe the same item twice, skip an item entirely, or see the total item count (ids.length, used fortotal_pages) change between pages in a way that makeshas_next/total_pagesunreliable — because each request independently re-fetches and re-slices a set whose order is not guaranteed to be consistent between calls, particularly after any create/delete has occurred concurrently. This is functionally different from (and worse than) normal "data changed between page loads" pagination drift, because even with zero writes occurring,SMEMBERSorder is an implementation detail of Redis's internal hash table and is not contractually stable.Contrast this with
deliveryRepository.js'slistByWebhook, which correctly uses a Redis sorted set (ZADD/ZREVRANGE) keyed by creation timestamp — a stable, order-preserving structure — for exactly this reason.Requirements
airdropsService,alertsService, andapiKeysfrom Redis Sets (SADD/SMEMBERS/SREM) to sorted sets scored by creation timestamp (ZADD score=Date.now()/ZREVRANGE/ZREM), matching the pattern already established indeliveryRepository.js.ZREVRANGE/ZRANGEBYSCOREwith stable, deterministic ordering (e.g. newest-first) rather than slicing an unordered array fetched viaSMEMBERS.pagination.total,pagination.total_pages, etc.) — only the internal storage/retrieval mechanism changes.Acceptance Criteria
pagevalues, with no writes occurring in between, returns each airdrop exactly once, in a stable, deterministic order.GET /api/v1/alertsandGET /api/v1/keys.limitsmaller than N, and asserts the concatenation of all pages equals the full set with no duplicates or omissions.SMEMBERS-based approach (e.g. new items appended at the correct end rather than potentially shifting earlier pages).test/airdrops-service.test.js,test/alerts.test.js, and any API-key tests continue to pass against the new storage model.Additional Notes
Additional edge cases / failure modes
ZADD score=Date.now()) get identical scores —ZREVRANGE's tie-breaking behavior for equal scores falls back to lexicographic ordering of the member (the id string), which is stable but not necessarily "creation order" for same-millisecond ties. Since ids are generated viacrypto.randomUUID()-derived strings (generateId()in each service), this tie-break order is effectively random-but-stable across repeated calls — acceptable for pagination stability (the core bug being fixed) but worth a one-line doc note that "stable" here means "consistent across page requests," not "guaranteed strict creation-order for same-millisecond ties."airdrops:ids,alerts:ids, and whateverapiKeys's equivalentIDS_KEYis called) — if any of these have existing production data as plain Sets, a migration step is needed (read existingSMEMBERS, look up each record'screated_atif available to backfill scores,ZADDinto a new sorted-set key, then cut over) rather than just changing the code and expectingSMEMBERS-populated keys to work withZREVRANGE(they won't — wrong Redis type,WRONGTYPEerror). Confirm whether this system has any existing production/staging data that needs migrating, or whether this is greenfield enough that a clean cutover (delete and recreate the key type) is acceptable.apiKeys.jswas referenced in the Overview/Requirements but not read in depth for this notes pass — confirm itslistKeys()function (and any pagination wrapper around it, ifroutes/keys.jsexists) is updated with the same sorted-set conversion; the Requirements explicitly list it as in-scope alongsideairdropsService/alertsService.alertsService.listPaginated({ offset, limit })usesoffset-based pagination whileairdropsService.list(page, limit)usespage-based pagination with an internally-computedstart/end— these two services have different pagination call conventions even though both suffer the same underlyingSMEMBERSbug; the sorted-set fix should preserve each service's existing external call signature (don't accidentally unify them as part of this fix, since that would be a separate, larger API-shape change out of scope here) while fixing the internal ordering mechanism for both.total/total_pagescomputation (ids.length) becomesZCARDon the sorted set — cheap and correct, but confirm this is computed from the same Redis call/transaction as the page slice where practical, to avoid a race where the reportedtotalreflects a state slightly different from the actual page contents if a create/delete happens between theZCARDand theZREVRANGEcalls (a MULTI/EXEC pipeline, or accepting this small residual race as "good enough" now that the core ordering bug is fixed, are both reasonable — but this should be a stated decision, not an oversight).Implementation sketch
Apply the equivalent
sadd→zadd,smembers+slice→zrevrange,srem→zremsubstitution inalertsServiceandapiKeys.Test / reproduction plan
limit=10across pages 1-3; assert the concatenation of all three pages' ids exactly matches the creation order reversed (newest-first), with no duplicates or gaps.SMEMBERS+slice behavior in a comment or a "before" test snapshot if feasible.alertsService.listPaginatedandapiKeys.listKeys.created_at.Related issues in this batch
deliveryRepository.js's existing sorted-set pattern is the reference implementation this issue is generalizing to the other services; the two issues touch adjacent but distinct concerns (retention/TTL vs. ordering) on the same underlying data structure choice.airdropsService,alertsService, andapiKeys, landing it alongside or before Repository layer has zero Redis-outage error handling — a Redis blip returns 500s instead of degrading gracefully #83's error-handling wrapper avoids two separate PRs both touching the same lines in close succession.