fix: org redis for lock flow

This commit is contained in:
John Yeo
2026-05-06 22:36:05 +08:00
parent 3d22ce89da
commit 5ecf4f3fc3
14 changed files with 267 additions and 14 deletions

View File

@@ -64,9 +64,24 @@ export const assignCustomerRedisToCtx = ({
});
ctx.redisV2 = routingInfo.redis;
return routingInfo;
};
export const overrideCtxRedisV2 = ({
ctx,
redisV2,
}: {
ctx: AutumnContext;
redisV2: Redis;
}): AutumnContext => {
if (ctx.redisV2 === redisV2) return ctx;
const injectedCtx = Object.create(ctx) as AutumnContext;
injectedCtx.redisV2 = redisV2;
return injectedCtx;
};
export const getRedisUrlForCustomer = ({
org,
customerId,

View File

@@ -0,0 +1,37 @@
import type { Redis } from "ioredis";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getOrgRedis } from "../orgRedisPool.js";
import { resolveRedisV2 } from "../resolveRedisV2.js";
const dedupeRedisInstances = ({ candidates }: { candidates: Redis[] }) =>
candidates.filter(
(candidate, index) => candidates.indexOf(candidate) === index,
);
export const getRedisV2LockReceiptCandidates = ({
ctx,
}: {
ctx: AutumnContext;
}): Redis[] => {
const candidates: Redis[] = [ctx.redisV2];
if (ctx.org.redis_config && ctx.org.redis_config.migrationPercent > 0) {
candidates.push(getOrgRedis({ org: ctx.org }), resolveRedisV2());
}
return dedupeRedisInstances({ candidates });
};
export const getRedisV2OrgCleanupCandidates = ({
ctx,
}: {
ctx: AutumnContext;
}): Redis[] => {
const candidates: Redis[] = [ctx.redisV2, resolveRedisV2()];
if (ctx.org.redis_config) {
candidates.push(getOrgRedis({ org: ctx.org }));
}
return dedupeRedisInstances({ candidates });
};

View File

@@ -48,6 +48,7 @@ const runFinalizeLockInner = async ({ ctx, params }: RunFinalizeLockArgs) => {
receipt: fetchedReceipt.receipt,
lockReceiptKey: fetchedReceipt.lockReceiptKey,
claimed: fetchedReceipt.claimed,
lockRedisInstance: fetchedReceipt.redisInstance,
});
}

View File

@@ -5,6 +5,7 @@ import {
RecaseError,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { Redis } from "ioredis";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { cancelLockExpiry } from "@/internal/balances/utils/lock/cancelLockExpiry.js";
import type { LockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js";
@@ -24,12 +25,14 @@ export const runFinalizeLockV2 = async ({
receipt,
lockReceiptKey,
claimed,
lockRedisInstance,
}: {
ctx: AutumnContext;
params: FinalizeLockParamsV0;
receipt: LockReceipt;
lockReceiptKey: string;
claimed: boolean;
lockRedisInstance: Redis;
}) => {
if (!claimed) {
throw new RecaseError({
@@ -45,6 +48,7 @@ export const runFinalizeLockV2 = async ({
params,
receipt,
lockReceiptKey,
redisInstance: lockRedisInstance,
});
const { redisInstance, finalValue, lockValue } = finalizeLockContext;

View File

@@ -15,7 +15,7 @@ export const runRedisFinalizeLockV2 = async ({
ctx: AutumnContext;
finalizeLockContext: FinalizeLockContextV2;
}) => {
const { receipt, fullSubject, deduction, deductionOptions } =
const { receipt, fullSubject, deduction, deductionOptions, redisInstance } =
finalizeLockContext;
let redisResult: Awaited<ReturnType<typeof executeRedisDeductionV2>>;
@@ -27,6 +27,7 @@ export const runRedisFinalizeLockV2 = async ({
entityId: receipt.entity_id ?? undefined,
deductions: [deduction],
deductionOptions,
redisInstance,
});
} catch (error) {
if (error instanceof RedisDeductionError && error.shouldFallback()) {

View File

@@ -1,5 +1,6 @@
import { ErrCode, RecaseError } from "@autumn/shared";
import { redis } from "@/external/redis/initRedis.js";
import { getRedisV2LockReceiptCandidates } from "@/external/redis/orgRedisUtils/orgRedisMigrationUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { fetchAndClaimLockReceiptV2 } from "@/internal/balances/utils/lockV2/fetchAndClaimLockReceiptV2.js";
import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js";
@@ -39,6 +40,28 @@ const normalizeLockReceiptItems = ({
});
};
const fetchAndClaimLockReceiptV2FromCandidates = async ({
ctx,
lockId,
}: {
ctx: AutumnContext;
lockId: string;
}) => {
const candidates = getRedisV2LockReceiptCandidates({ ctx });
for (const redisInstance of candidates) {
const result = await fetchAndClaimLockReceiptV2({
ctx,
lockId,
redisInstance,
});
if (result.found) return result;
}
return { found: false as const };
};
export const fetchLockReceipt = async ({
ctx,
lockId,
@@ -46,7 +69,6 @@ export const fetchLockReceipt = async ({
ctx: AutumnContext;
lockId: string;
}) => {
const { redisV2 } = ctx;
const hashedKey = Bun.hash(lockId).toString();
const lockReceiptKey = buildLockReceiptKey({
orgId: ctx.org.id,
@@ -56,6 +78,7 @@ export const fetchLockReceipt = async ({
// V2 half doubles as a fetch+claim (pipelined GET + SET NX on a marker key)
// so the dispatcher can route to runFinalizeLockV2 without a follow-up claim RT.
// During org Redis migrations, V2 checks both shared and dedicated Redis.
// V1 half stays a plain JSON.GET — V1 finalize still claims via Lua afterwards.
const [rawReceiptV1, v2Result] = await Promise.all([
tryRedisRead(
@@ -63,10 +86,9 @@ export const fetchLockReceipt = async ({
redis.call("JSON.GET", lockReceiptKey, "$") as Promise<string | null>,
redis,
),
fetchAndClaimLockReceiptV2({
fetchAndClaimLockReceiptV2FromCandidates({
ctx,
lockId,
redisInstance: redisV2,
}),
]);
@@ -76,6 +98,7 @@ export const fetchLockReceipt = async ({
lockReceiptKey: v2Result.lockReceiptKey,
source: "redis_v2" as const,
claimed: v2Result.claimed,
redisInstance: v2Result.redisInstance,
};
}

View File

@@ -1,6 +1,7 @@
import type { Feature, FullSubject } from "@autumn/shared";
import { type FinalizeLockParamsV0, findFeatureById } from "@autumn/shared";
import type { Redis } from "ioredis";
import { overrideCtxRedisV2 } from "@/external/redis/customerRedisRouting.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { LockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js";
import {
@@ -30,14 +31,18 @@ export const buildFinalizeLockContextV2 = async ({
params,
receipt,
lockReceiptKey,
redisInstance,
}: {
ctx: AutumnContext;
params: FinalizeLockParamsV0;
receipt: LockReceipt;
lockReceiptKey: string;
redisInstance: Redis;
}): Promise<FinalizeLockContextV2> => {
const ctxWithRedisV2 = overrideCtxRedisV2({ ctx, redisV2: redisInstance });
const fullSubject = await getOrSetCachedFullSubject({
ctx,
ctx: ctxWithRedisV2,
customerId: receipt.customer_id,
entityId: receipt.entity_id ?? undefined,
source: "runFinalizeLockV2",
@@ -61,7 +66,7 @@ export const buildFinalizeLockContextV2 = async ({
return {
receipt,
lockReceiptKey,
redisInstance: ctx.redisV2,
redisInstance,
fullSubject,
feature,
lockValue,

View File

@@ -21,6 +21,7 @@ type FetchAndClaimResult =
claimed: boolean;
receipt: LockReceipt;
lockReceiptKey: string;
redisInstance: Redis;
};
const normalizeLockReceiptItems = ({
@@ -118,5 +119,6 @@ export const fetchAndClaimLockReceiptV2 = async ({
claimed: claimResult === "OK",
receipt,
lockReceiptKey,
redisInstance,
};
};

View File

@@ -4,6 +4,7 @@ import { getOrgRedis, removeOrgRedis } from "@/external/redis/orgRedisPool.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { encryptData } from "@/utils/encryptUtils.js";
import { OrgService } from "../OrgService.js";
import { clearOrgCache } from "../orgUtils/clearOrgCache.js";
const REDIS_PROTOCOLS = new Set(["redis:", "rediss:"]);
@@ -70,6 +71,7 @@ export const handleUpsertRedisConfig = createRoute({
if (updatedOrg) {
getOrgRedis({ org: updatedOrg });
await clearOrgCache({ db, orgId: org.id, env: ctx.env, logger });
logger.info(
`[handleUpsertRedisConfig] org=${org.id}: redis_config created, url=${redisUrl.host}, actor=${ctx.user?.email ?? ctx.userId ?? "unknown"}`,
);
@@ -109,6 +111,7 @@ export const handleUpdateRedisMigration = createRoute({
},
},
});
await clearOrgCache({ db, orgId: org.id, env: ctx.env, logger });
logger.info(
`[handleUpdateRedisMigration] org=${org.id}: ${org.redis_config.migrationPercent}% -> ${migrationPercent}%`,
@@ -139,6 +142,7 @@ export const handleDeleteRedisConfig = createRoute({
orgId: org.id,
updates: { redis_config: null },
});
await clearOrgCache({ db, orgId: org.id, env: ctx.env, logger });
removeOrgRedis({ orgId: org.id });
logger.info(

View File

@@ -100,7 +100,35 @@ if (cluster.isPrimary) {
startMemoryMonitor("worker", 60_000);
await startAllEdgeConfigPolling({ logger });
process.once("exit", stopAllEdgeConfigPolling);
const { db } = await import("./db/initDrizzle.js");
const { primeRedisMonitor } = await import(
"./external/redis/initUtils/redisAvailability.js"
);
const {
primeRedisV2Monitor,
startRedisV2Monitor,
stopRedisV2Monitor,
} = await import("./external/redis/initUtils/redisV2Availability.js");
const { startRedisMonitor, stopRedisMonitor } = await import(
"./external/redis/initRedis.js"
);
const { preWarmOrgRedisConnections } = await import(
"./external/redis/orgRedisPool.js"
);
await Promise.all([primeRedisMonitor(), primeRedisV2Monitor()]);
startRedisMonitor();
startRedisV2Monitor();
void preWarmOrgRedisConnections({ db }).catch((error) => {
logger.warn("[OrgRedis] Warmup failed", { error });
});
process.once("exit", () => {
stopAllEdgeConfigPolling();
stopRedisMonitor();
stopRedisV2Monitor();
});
const { initWorkers } = await import("./queue/initWorkers.js");
await initWorkers({ startupStartedAt, queueImplementation });

View File

@@ -201,10 +201,13 @@ test.concurrent(`${chalk.yellowBright("check-lock-expiry-4: no expires_at sets T
lock: { enabled: true, lock_id: customerId },
});
const { lockReceiptKey, source } = await fetchLockReceipt({ ctx, lockId: customerId });
const redisInstance = source === "redis_v2" ? ctx.redisV2 : redis;
const fetchedReceipt = await fetchLockReceipt({ ctx, lockId: customerId });
const redisInstance =
fetchedReceipt.source === "redis_v2" ? fetchedReceipt.redisInstance : redis;
const expireAt = await redisInstance.expiretime(lockReceiptKey);
const expireAt = await redisInstance.expiretime(
fetchedReceipt.lockReceiptKey,
);
const expectedTtl = beforeCheck + 24 * 60 * 60;
// TTL should be within 5s of now + 1 day
@@ -238,10 +241,13 @@ test.concurrent(`${chalk.yellowBright("check-lock-expiry-5: expires_at set, TTL
lock: { enabled: true, lock_id: customerId, expires_at: expiresAt },
});
const { lockReceiptKey, source } = await fetchLockReceipt({ ctx, lockId: customerId });
const redisInstance = source === "redis_v2" ? ctx.redisV2 : redis;
const fetchedReceipt = await fetchLockReceipt({ ctx, lockId: customerId });
const redisInstance =
fetchedReceipt.source === "redis_v2" ? fetchedReceipt.redisInstance : redis;
const expireAt = await redisInstance.expiretime(lockReceiptKey);
const expireAt = await redisInstance.expiretime(
fetchedReceipt.lockReceiptKey,
);
const expectedTtl = Math.ceil(expiresAt / 1000) + 60 * 60;
// TTL should be within 5s of expires_at + 1 hour

View File

@@ -1,5 +1,6 @@
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { redis } from "@/external/redis/initRedis.js";
import { getRedisV2OrgCleanupCandidates } from "@/external/redis/orgRedisUtils/orgRedisMigrationUtils.js";
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
import { buildClaimMarkerKey } from "@/internal/balances/utils/lockV2/buildClaimMarkerKey.js";
@@ -20,6 +21,8 @@ export const deleteLock = async ({
await Promise.all([
redis.del(redisReceiptKey),
ctx.redisV2.del(redisReceiptKey, claimMarkerKey),
...getRedisV2OrgCleanupCandidates({ ctx }).map((redisInstance) =>
redisInstance.del(redisReceiptKey, claimMarkerKey),
),
]);
};

View File

@@ -1,6 +1,7 @@
import { expect } from "bun:test";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { redis } from "@/external/redis/initRedis.js";
import { getRedisV2OrgCleanupCandidates } from "@/external/redis/orgRedisUtils/orgRedisMigrationUtils.js";
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
/** Asserts that the lock receipt for the given ID no longer exists in Redis. */
@@ -20,4 +21,13 @@ export const expectLockReceiptDeleted = async ({
const receipt = await redis.call("JSON.GET", redisReceiptKey, "$");
expect(receipt).toBeNull();
const v2ReceiptCounts = await Promise.all(
getRedisV2OrgCleanupCandidates({ ctx }).map((redisInstance) =>
redisInstance.exists(redisReceiptKey),
),
);
for (const receiptCount of v2ReceiptCounts) {
expect(receiptCount).toBe(0);
}
};

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import type { Redis } from "ioredis";
const sharedRedis = { name: "shared" } as unknown as Redis;
const dedicatedRedis = { name: "dedicated" } as unknown as Redis;
const mockState = {
jsonGetResult: null as string | null,
v2ResultsByRedis: new Map<object, { found: boolean }>(),
v2Calls: [] as object[],
};
mock.module("@/external/redis/initRedis.js", () => ({
redis: {
call: async () => mockState.jsonGetResult,
},
}));
mock.module("@/external/redis/orgRedisPool.js", () => ({
getOrgRedis: () => dedicatedRedis,
}));
mock.module("@/external/redis/resolveRedisV2.js", () => ({
resolveRedisV2: () => sharedRedis,
}));
mock.module("@/utils/cacheUtils/cacheUtils.js", () => ({
tryRedisRead: async (operation: () => Promise<unknown>) => operation(),
}));
mock.module(
"@/internal/balances/utils/lockV2/fetchAndClaimLockReceiptV2.js",
() => ({
fetchAndClaimLockReceiptV2: async ({
redisInstance,
}: {
redisInstance: object;
}) => {
mockState.v2Calls.push(redisInstance);
const result = mockState.v2ResultsByRedis.get(redisInstance);
if (!result?.found) return { found: false };
return {
found: true,
claimed: true,
lockReceiptKey: "lock:receipt",
redisInstance,
receipt: {
customer_id: "customer_123",
feature_id: "messages",
items: [],
},
};
},
}),
);
import { fetchLockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js";
const makeContext = ({ migrationPercent }: { migrationPercent: number }) =>
({
org: {
id: "org_123",
redis_config: {
connectionString: "encrypted",
url: "dragonfly.internal:6379",
migrationPercent,
previousMigrationPercent: 0,
migrationChangedAt: 1,
},
},
env: "sandbox",
redisV2: sharedRedis,
}) as never;
beforeEach(() => {
mockState.jsonGetResult = null;
mockState.v2ResultsByRedis.clear();
mockState.v2Calls = [];
});
describe("fetchLockReceipt", () => {
test("checks dedicated Redis during an org Redis migration and returns the source instance", async () => {
mockState.v2ResultsByRedis.set(sharedRedis, { found: false });
mockState.v2ResultsByRedis.set(dedicatedRedis, { found: true });
const result = await fetchLockReceipt({
ctx: makeContext({ migrationPercent: 50 }),
lockId: "lock_123",
});
expect(result.source).toBe("redis_v2");
if (result.source !== "redis_v2")
throw new Error("Expected Redis V2 receipt");
expect(result.redisInstance).toBe(dedicatedRedis);
expect(mockState.v2Calls).toEqual([sharedRedis, dedicatedRedis]);
});
test("does not check dedicated Redis when the org migration is complete", async () => {
mockState.v2ResultsByRedis.set(sharedRedis, { found: true });
const result = await fetchLockReceipt({
ctx: makeContext({ migrationPercent: 100 }),
lockId: "lock_123",
});
expect(result.source).toBe("redis_v2");
if (result.source !== "redis_v2")
throw new Error("Expected Redis V2 receipt");
expect(result.redisInstance).toBe(sharedRedis);
expect(mockState.v2Calls).toEqual([sharedRedis]);
});
});