Skip to content

Commit 26397db

Browse files
authored
fix: migrate set -> sorted set for existing nonce-recycled keys (#693)
* fix: migrate set -> sorted set for existing nonce-recycled keys * blocking poll * exit 0
1 parent fbf6d5f commit 26397db

File tree

6 files changed

+122
-11
lines changed

6 files changed

+122
-11
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"generate:sdk": "npx tsx ./src/scripts/generate-sdk && cd ./sdk && yarn build",
1717
"prisma:setup:dev": "npx tsx ./src/scripts/setup-db.ts",
1818
"prisma:setup:prod": "npx tsx ./dist/scripts/setup-db.js",
19-
"start": "yarn prisma:setup:prod && yarn start:run",
19+
"start": "yarn prisma:setup:prod && yarn start:migrations && yarn start:run",
20+
"start:migrations": "npx tsx ./dist/scripts/apply-migrations.js",
2021
"start:run": "node --experimental-specifier-resolution=node ./dist/index.js",
2122
"start:docker": "docker compose --profile engine --env-file ./.env up --remove-orphans",
2223
"start:docker-force-build": "docker compose --profile engine --env-file ./.env up --remove-orphans --build",

src/db/wallets/walletNonce.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {
2-
Address,
32
eth_getTransactionCount,
43
getAddress,
54
getRpcClient,
5+
type Address,
66
} from "thirdweb";
77
import { getChain } from "../../utils/chain";
88
import { logger } from "../../utils/logger";
@@ -37,7 +37,7 @@ export const getUsedBackendWallets = async (
3737
return keys.map((key) => {
3838
const tokens = key.split(":");
3939
return {
40-
chainId: parseInt(tokens[1]),
40+
chainId: Number.parseInt(tokens[1]),
4141
walletAddress: getAddress(tokens[2]),
4242
};
4343
});
@@ -61,7 +61,7 @@ export const lastUsedNonceKey = (chainId: number, walletAddress: Address) =>
6161
export const splitLastUsedNonceKey = (key: string) => {
6262
const _splittedKeys = key.split(":");
6363
const walletAddress = normalizeAddress(_splittedKeys[2]);
64-
const chainId = parseInt(_splittedKeys[1]);
64+
const chainId = Number.parseInt(_splittedKeys[1]);
6565
return { walletAddress, chainId };
6666
};
6767

@@ -87,7 +87,7 @@ export const sentNoncesKey = (chainId: number, walletAddress: Address) =>
8787
export const splitSentNoncesKey = (key: string) => {
8888
const _splittedKeys = key.split(":");
8989
const walletAddress = normalizeAddress(_splittedKeys[2]);
90-
const chainId = parseInt(_splittedKeys[1]);
90+
const chainId = Number.parseInt(_splittedKeys[1]);
9191
return { walletAddress, chainId };
9292
};
9393

@@ -166,7 +166,7 @@ export const acquireNonce = async (args: {
166166
nonce,
167167
queueId,
168168
});
169-
return { nonce, isRecycledNonce: false };
169+
return { nonce, isRecycledNonce };
170170
};
171171

172172
/**
@@ -181,7 +181,7 @@ export const recycleNonce = async (
181181
walletAddress: Address,
182182
nonce: number,
183183
) => {
184-
if (isNaN(nonce)) {
184+
if (Number.isNaN(nonce)) {
185185
logger({
186186
level: "warn",
187187
message: `[recycleNonce] Invalid nonce: ${nonce}`,
@@ -209,7 +209,7 @@ const _acquireRecycledNonce = async (
209209
if (result.length === 0) {
210210
return null;
211211
}
212-
return parseInt(result[0]);
212+
return Number.parseInt(result[0]);
213213
};
214214

215215
/**
@@ -246,7 +246,7 @@ export const syncLatestNonceFromOnchain = async (
246246
export const inspectNonce = async (chainId: number, walletAddress: Address) => {
247247
const key = lastUsedNonceKey(chainId, walletAddress);
248248
const nonce = await redis.get(key);
249-
return nonce ? parseInt(nonce) : 0;
249+
return nonce ? Number.parseInt(nonce) : 0;
250250
};
251251

252252
/**

src/scripts/apply-migrations.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { logger } from "../utils/logger";
2+
import { acquireLock, releaseLock, waitForLock } from "../utils/redis/lock";
3+
import { redis } from "../utils/redis/redis";
4+
5+
const MIGRATION_LOCK_TTL_SECONDS = 60;
6+
7+
const main = async () => {
8+
// Acquire a lock to allow only one host to run migrations.
9+
// Other hosts block until the migration is completed or lock times out.
10+
const acquiredLock = await acquireLock(
11+
"lock:apply-migrations",
12+
MIGRATION_LOCK_TTL_SECONDS,
13+
);
14+
if (!acquiredLock) {
15+
logger({
16+
level: "info",
17+
message: "Migration in progress. Waiting for the lock to release...",
18+
service: "server",
19+
});
20+
await waitForLock("lock:apply-migrations");
21+
process.exit(0);
22+
}
23+
24+
try {
25+
await migrateRecycledNonces();
26+
27+
logger({
28+
level: "info",
29+
message: "Completed migrations without errors.",
30+
service: "server",
31+
});
32+
} catch (e) {
33+
logger({
34+
level: "error",
35+
message: `Failed to complete migrations: ${e}`,
36+
service: "server",
37+
});
38+
process.exit(1);
39+
} finally {
40+
await releaseLock("lock:apply-migrations");
41+
}
42+
43+
process.exit(0);
44+
};
45+
46+
const migrateRecycledNonces = async () => {
47+
const keys = await redis.keys("nonce-recycled:*");
48+
49+
// For each `nonce-recycled:*` key that is a "set" in Redis,
50+
// migrate all members to a sorted set with score == nonce.
51+
for (const key of keys) {
52+
const type = await redis.type(key);
53+
if (type !== "set") {
54+
continue;
55+
}
56+
57+
const members = await redis.smembers(key);
58+
await redis.del(key);
59+
if (members.length > 0) {
60+
const args = members.flatMap((member) => {
61+
const score = Number.parseInt(member);
62+
return Number.isNaN(score) ? [] : [score, member];
63+
});
64+
await redis.zadd(key, ...args);
65+
}
66+
}
67+
};
68+
69+
main();

src/server/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export const initServer = async () => {
9797
logger({
9898
service: "server",
9999
level: "fatal",
100-
message: `Failed to start server`,
100+
message: "Failed to start server",
101101
error: err,
102102
});
103103
process.exit(1);

src/utils/redis/lock.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { redis } from "./redis";
2+
3+
// Add more locks here.
4+
type LockType = "lock:apply-migrations";
5+
6+
/**
7+
* Acquire a lock to prevent duplicate runs of a workflow.
8+
*
9+
* @param key string The lock identifier.
10+
* @param ttlSeconds number The number of seconds before the lock is automatically released.
11+
* @returns true if the lock was acquired. Else false.
12+
*/
13+
export const acquireLock = async (
14+
key: LockType,
15+
ttlSeconds: number,
16+
): Promise<boolean> => {
17+
const result = await redis.set(key, Date.now(), "EX", ttlSeconds, "NX");
18+
return result === "OK";
19+
};
20+
21+
/**
22+
* Release a lock.
23+
*
24+
* @param key The lock identifier.
25+
* @returns true if the lock was active before releasing.
26+
*/
27+
export const releaseLock = async (key: LockType) => {
28+
const result = await redis.del(key);
29+
return result > 0;
30+
};
31+
32+
/**
33+
* Blocking polls a lock every second until it's released.
34+
*
35+
* @param key The lock identifier.
36+
*/
37+
export const waitForLock = async (key: LockType) => {
38+
while (await redis.get(key)) {
39+
await new Promise((resolve) => setTimeout(resolve, 1_000));
40+
}
41+
};

src/utils/redis/redis.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const isRedisReachable = async () => {
3838
try {
3939
await redis.ping();
4040
return true;
41-
} catch (error) {
41+
} catch {
4242
return false;
4343
}
4444
};

0 commit comments

Comments
 (0)