Add per-org Redis migration routing

This commit is contained in:
Owen Greenhalgh
2026-05-05 17:55:16 +01:00
parent 3b0a82fd53
commit dbae333156
36 changed files with 1247 additions and 22 deletions

View File

@@ -4,6 +4,7 @@ import {
ms,
orgToFeaturesByOrgEnv,
} from "@autumn/shared";
import { resolveCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects";
import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
import { ProductService } from "@/internal/products/ProductService";
@@ -77,6 +78,11 @@ export const runProductCron = async ({
customers: customersToDelete,
featuresByOrgEnv,
redisV2: ctx.redisV2,
getRedisForCustomer: ({ customer }) =>
resolveCustomerRedisRouting({
org,
customerId: customer.customerId,
}).redis,
});
console.log(`Expired ${rows.length} customer products`);
continue;

View File

@@ -8,7 +8,8 @@ import {
import { UTCDate } from "@date-fns/utc";
import { format } from "date-fns";
import type { RepoContext } from "@/db/repoContext";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { resolveCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import type { OrgWithRedisConfig } from "@/external/redis/orgRedisPool.js";
import { invalidateCustomerEntitlementBalance } from "@/internal/customers/cache/fullSubject/actions/invalidate/invalidateCustomerEntitlementBalance.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
@@ -26,15 +27,21 @@ const shortDurations = [EntInterval.Minute, EntInterval.Hour, EntInterval.Day];
const resetCustomerEntitlementInDb = async ({
ctx,
org,
cusEnt,
updatedCusEnts,
persistFreeOverage = false,
}: {
ctx: CronContext;
org: OrgWithRedisConfig;
cusEnt: ResetCusEnt;
updatedCusEnts: ResetCusEnt[];
persistFreeOverage?: boolean;
}) => {
const redisRouting = resolveCustomerRedisRouting({
org,
customerId: cusEnt.customer_id ?? "",
});
const repoContext: RepoContext = {
db: ctx.db,
logger: ctx.logger,
@@ -43,7 +50,7 @@ const resetCustomerEntitlementInDb = async ({
},
env: cusEnt.customer.env,
customerId: cusEnt.customer_id ?? "",
redisV2: resolveRedisV2(),
redisV2: redisRouting.redis,
};
try {
@@ -186,17 +193,25 @@ const resetCustomerEntitlementInDb = async ({
export const resetCustomerEntitlement = async ({
ctx,
org,
cusEnt,
updatedCusEnts,
persistFreeOverage = false,
}: {
ctx: CronContext;
org?: OrgWithRedisConfig;
cusEnt: ResetCusEnt;
updatedCusEnts: ResetCusEnt[];
persistFreeOverage?: boolean;
}) => {
const routingOrg = org ?? { id: cusEnt.customer.org_id, redis_config: null };
const redisRouting = resolveCustomerRedisRouting({
org: routingOrg,
customerId: cusEnt.customer_id ?? "",
});
const result = await resetCustomerEntitlementInDb({
ctx,
org: routingOrg,
cusEnt,
updatedCusEnts,
persistFreeOverage,
@@ -207,7 +222,7 @@ export const resetCustomerEntitlement = async ({
customerId: cusEnt.customer_id ?? "",
featureId: cusEnt.entitlement.feature.id,
customerEntitlementId: cusEnt.id,
redisV2: resolveRedisV2(),
redisV2: redisRouting.redis,
});
return result;
};

View File

@@ -121,6 +121,7 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
batchResets.push(
resetCustomerEntitlement({
ctx,
org: orgWithFeatures.org,
cusEnt: cusEnt,
updatedCusEnts,
persistFreeOverage:

View File

@@ -0,0 +1,77 @@
import type { Redis } from "ioredis";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getOrgRedis, type OrgWithRedisConfig } from "./orgRedisPool.js";
import { resolveRedisV2 } from "./resolveRedisV2.js";
export {
type CustomerRedisRoutingInfo,
getCustomerBucket,
getRedisUrlForCustomerFromOrg,
isRedisMigrationCacheStale,
} from "./customerRedisRoutingInfo.js";
import {
type CustomerRedisRoutingInfo,
getCustomerRedisRoutingInfoForOrg,
} from "./customerRedisRoutingInfo.js";
export const getCustomerRedisRoutingInfo = ({
org,
customerId,
}: {
org: OrgWithRedisConfig;
customerId?: string;
}): CustomerRedisRoutingInfo => {
return getCustomerRedisRoutingInfoForOrg({
org,
customerId,
});
};
export const resolveCustomerRedisRouting = ({
org,
customerId,
}: {
org: OrgWithRedisConfig;
customerId?: string;
}): CustomerRedisRoutingInfo & { redis: Redis } => {
const routingInfo = getCustomerRedisRoutingInfo({ org, customerId });
if (routingInfo.usesDedicatedRedis) {
return {
...routingInfo,
redis: getOrgRedis({ org }),
};
}
return {
...routingInfo,
redis: resolveRedisV2(),
};
};
export const setCustomerRedisRouting = ({
ctx,
customerId = ctx.customerId,
}: {
ctx: AutumnContext;
customerId?: string;
}): CustomerRedisRoutingInfo => {
const routingInfo = resolveCustomerRedisRouting({
org: ctx.org,
customerId,
});
ctx.redisV2 = routingInfo.redis;
return routingInfo;
};
export const getRedisUrlForCustomer = ({
org,
customerId,
}: {
org: OrgWithRedisConfig;
customerId?: string;
}): string | undefined => {
return getCustomerRedisRoutingInfo({ org, customerId }).redisUrl;
};

View File

@@ -0,0 +1,70 @@
import type { OrgRedisConfig } from "@autumn/shared";
type OrgWithRedisConfig = {
id: string;
redis_config?: OrgRedisConfig | null;
};
export type CustomerRedisRoutingInfo = {
bucket?: number;
redisUrl?: string;
usesDedicatedRedis: boolean;
};
export const getCustomerBucket = (customerId: string): number =>
Number(BigInt(Bun.hash(customerId)) % 100n);
export const getCustomerRedisRoutingInfoForOrg = ({
org,
customerId,
}: {
org: OrgWithRedisConfig;
customerId?: string;
}): CustomerRedisRoutingInfo => {
if (!org.redis_config || !customerId) {
return {
usesDedicatedRedis: false,
};
}
const bucket = getCustomerBucket(customerId);
const usesDedicatedRedis = bucket < org.redis_config.migrationPercent;
return {
bucket,
redisUrl: usesDedicatedRedis ? org.redis_config.url : undefined,
usesDedicatedRedis,
};
};
export const getRedisUrlForCustomerFromOrg = ({
org,
customerId,
}: {
org: OrgWithRedisConfig;
customerId?: string;
}): string | undefined =>
getCustomerRedisRoutingInfoForOrg({
org,
customerId,
}).redisUrl;
export const isRedisMigrationCacheStale = ({
cachedAt,
customerId,
redisConfig,
}: {
cachedAt?: number;
customerId?: string;
redisConfig?: OrgRedisConfig | null;
}): boolean => {
if (!redisConfig?.migrationChangedAt) return false;
if (!customerId) return false;
if (cachedAt === undefined) return false;
if (cachedAt >= redisConfig.migrationChangedAt) return false;
const bucket = getCustomerBucket(customerId);
const wasOnDedicated = bucket < redisConfig.previousMigrationPercent;
const isOnDedicated = bucket < redisConfig.migrationPercent;
return wasOnDedicated !== isOnDedicated;
};

View File

@@ -0,0 +1,106 @@
import type { OrgRedisConfig } from "@autumn/shared";
import type { Redis } from "ioredis";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { decryptData } from "@/utils/encryptUtils.js";
import { createRedisConnection, currentRegion } from "./initRedis.js";
import { REDIS_V2_COMMAND_TIMEOUT_MS } from "./initUtils/redisV2Config.js";
import { resolveRedisV2 } from "./resolveRedisV2.js";
export type OrgWithRedisConfig = {
id: string;
redis_config?: OrgRedisConfig | null;
};
type PoolEntry = {
instance: Redis;
url: string;
};
const pool = new Map<string, PoolEntry>();
const createOrgRedisConnection = ({
connectionString,
orgId,
}: {
connectionString: string;
orgId: string;
}): Redis => {
const instance = createRedisConnection({
cacheUrl: connectionString,
region: `org:${orgId}:v2:dragonfly`,
supportsUpstashShebang: false,
commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS,
});
instance.on("error", (error) => {
console.error(`[OrgRedis] org=${orgId}: ${error.message}`);
});
instance.on("ready", () => {
console.log(`[OrgRedis] org=${orgId}: connected`);
});
return instance;
};
export const getOrgRedis = ({ org }: { org: OrgWithRedisConfig }): Redis => {
if (!org.redis_config) return resolveRedisV2();
const existing = pool.get(org.id);
if (existing) {
if (existing.url === org.redis_config.url) return existing.instance;
existing.instance.disconnect();
pool.delete(org.id);
}
let connectionString: string;
try {
connectionString = decryptData(org.redis_config.connectionString);
} catch {
console.error(
`[OrgRedis] Failed to decrypt redis_config for org ${org.id}, falling back to shared Redis V2`,
);
return resolveRedisV2();
}
const instance = createOrgRedisConnection({
connectionString,
orgId: org.id,
});
pool.set(org.id, { instance, url: org.redis_config.url });
return instance;
};
export const getPooledOrgRedis = ({
orgId,
}: {
orgId: string;
}): Redis | null => {
return pool.get(orgId)?.instance ?? null;
};
export const removeOrgRedis = ({ orgId }: { orgId: string }): void => {
const existing = pool.get(orgId);
if (!existing) return;
existing.instance.disconnect();
pool.delete(orgId);
};
export const preWarmOrgRedisConnections = async ({
db,
}: {
db: DrizzleCli;
}): Promise<void> => {
const orgsWithRedis = await OrgService.listWithRedisConfig({ db });
if (orgsWithRedis.length === 0) return;
console.log(
`[OrgRedis] Pre-warming connections for ${orgsWithRedis.length} orgs in ${currentRegion}...`,
);
for (const org of orgsWithRedis) {
getOrgRedis({ org });
}
};

View File

@@ -6,12 +6,13 @@ import {
ProcessorType,
RecaseError,
} from "@shared/index";
import { setCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
import { CusService } from "@/internal/customers/CusService";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
import { ProductService } from "@/internal/products/ProductService";
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
import { getOrCreateCustomer } from "../../../internal/customers/cusUtils/getOrCreateCustomer";
/**
@@ -102,6 +103,7 @@ export const resolveRevenuecatResources = async ({
orgId: ctx.org.id,
customerId: ctx.customerId,
});
setCustomerRedisRouting({ ctx, customerId: ctx.customerId });
return { product, customer, cusProducts };
};

View File

@@ -10,7 +10,7 @@ import { StatusCodes } from "http-status-codes";
import type { Stripe } from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { resolveCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import { createStripeCustomer } from "@/external/stripe/customers";
import { CusService } from "@/internal/customers/CusService.js";
@@ -150,7 +150,10 @@ export const attachPmToCus = async ({
org,
env,
logger: logger,
redisV2: resolveRedisV2(),
redisV2: resolveCustomerRedisRouting({
org,
customerId: customer.id ?? customer.internal_id,
}).redis,
};
await CusService.update({

View File

@@ -1,5 +1,6 @@
import { RELEVANT_STATUSES } from "@autumn/shared";
import type { Context, Next } from "hono";
import { setCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
import { CusService } from "../../../internal/customers/CusService";
import type {
@@ -68,6 +69,7 @@ export const stripeToAutumnCustomerMiddleware = async (
orgId: ctx.org.id,
customerId,
});
setCustomerRedisRouting({ ctx, customerId });
}
await next();

View File

@@ -1,4 +1,5 @@
import type { Context, Next } from "hono";
import { setCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { CusService } from "@/internal/customers/CusService.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
@@ -43,6 +44,7 @@ export const vercelCustomerMiddleware = async (
orgId: ctx.org.id,
customerId,
});
setCustomerRedisRouting({ ctx, customerId });
}
await next();

View File

@@ -2,6 +2,7 @@ import { AppEnv, AuthType, type Organization } from "@autumn/shared";
import chalk from "chalk";
import type { Context, Next } from "hono";
import type { Logger } from "@/external/logtail/logtailUtils.js";
import { setCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
@@ -35,6 +36,9 @@ export const vercelSeederMiddleware = async (
orgId: ctx.org?.id,
customerId: ctx.customerId,
});
if (ctx.org) {
setCustomerRedisRouting({ ctx });
}
ctx.logger = addAppContextToLogs({
logger: ctx.logger,

View File

@@ -0,0 +1,12 @@
import type { Context, Next } from "hono";
import { setCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
export const orgRedisMiddleware = async (
c: Context<HonoEnv>,
next: Next,
): Promise<void> => {
const ctx = c.get("ctx");
setCustomerRedisRouting({ ctx });
await next();
};

View File

@@ -5,7 +5,7 @@ import cluster from "node:cluster";
import http from "node:http";
import os from "node:os";
import { getRequestListener } from "@hono/node-server";
import { client, clientCritical, clientReplica } from "./db/initDrizzle.js";
import { client, clientCritical, clientReplica, db } from "./db/initDrizzle.js";
import {
initPgHealthMonitor,
shutdownPgHealthMonitor,
@@ -38,6 +38,7 @@ import {
startRedisV2Monitor,
stopRedisV2Monitor,
} from "./external/redis/initUtils/redisV2Availability.js";
import { preWarmOrgRedisConnections } from "./external/redis/orgRedisPool.js";
import { createHonoApp } from "./initHono.js";
import { otelSdk } from "./instrumentation.js";
import { checkEnvVars } from "./utils/initUtils.js";
@@ -59,6 +60,9 @@ const init = async ({ startupStartedAt }: { startupStartedAt: number }) => {
void warmupRegionalRedis().catch((error) => {
logger.warn("[Redis] Warmup failed", { error });
});
void preWarmOrgRedisConnections({ db }).catch((error) => {
logger.warn("[OrgRedis] Warmup failed", { error });
});
await startAllEdgeConfigPolling({ logger });
await Promise.all([primeRedisMonitor(), primeRedisV2Monitor()]);
startRedisMonitor();

View File

@@ -12,6 +12,7 @@ import {
import type { Context, Next } from "hono";
import { rateLimiter } from "hono-rate-limiter";
import { StatusCodes } from "http-status-codes";
import { setCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv";
import { checkoutActions } from "@/internal/checkouts/actions";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
@@ -117,8 +118,7 @@ export const checkoutMiddleware = async (c: Context<HonoEnv>, next: Next) => {
});
}
// Set up context with org/env/features for handlers
c.set("ctx", {
const nextCtx = {
...ctx,
org: orgWithFeatures.org,
env,
@@ -129,7 +129,11 @@ export const checkoutMiddleware = async (c: Context<HonoEnv>, next: Next) => {
orgId: orgWithFeatures.org.id,
customerId: validCheckout.customer_id,
}),
});
};
setCustomerRedisRouting({ ctx: nextCtx });
// Set up context with org/env/features for handlers
c.set("ctx", nextCtx);
// Attach checkout to context for handlers
c.set("checkout", validCheckout);

View File

@@ -21,7 +21,7 @@ import {
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { resolveCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { triggerVerifyCacheConsistency } from "@/internal/billing/v2/workflows/verifyCacheConsistency/triggerVerifyCacheConsistency.js";
import { searchCusProducts } from "@/internal/customers/cusProducts/cusProductUtils.js";
@@ -363,7 +363,10 @@ export const createFullCusProduct = async ({
},
env: customer.env,
logger,
redisV2: resolveRedisV2(),
redisV2: resolveCustomerRedisRouting({
org,
customerId: customer.id ?? customer.internal_id,
}).redis,
};
if (

View File

@@ -1,4 +1,5 @@
import { type FullSubject, normalizedToFullSubject } from "@autumn/shared";
import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRouting.js";
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
@@ -154,6 +155,28 @@ export const getCachedFullSubject = async ({
};
}
if (
isRedisMigrationCacheStale({
cachedAt: cached._cachedAt,
customerId,
redisConfig: ctx.org.redis_config,
})
) {
logger.warn(
`[getCachedFullSubject] Stale Redis migration cache for ${customerId}${entityId ? `:${entityId}` : ""}, evicting`,
);
await invalidateCachedFullSubject({
ctx,
customerId,
entityId,
source: "stale-redis-migration",
});
return {
fullSubject: undefined,
subjectViewEpoch: currentSubjectViewEpoch,
};
}
const isCustomerSubject = !entityId;
const balancesOutcome = await getCachedFeatureBalancesBatch({
ctx,

View File

@@ -19,7 +19,7 @@ type BatchInvalidateCustomer = {
type FeaturesByOrgEnv = Record<string, Feature[]>;
export const batchInvalidateCachedFullSubjects = async ({
const batchInvalidateCachedFullSubjectsOnRedis = async ({
customers,
featuresByOrgEnv,
redisV2,
@@ -27,11 +27,8 @@ export const batchInvalidateCachedFullSubjects = async ({
customers: BatchInvalidateCustomer[];
featuresByOrgEnv: FeaturesByOrgEnv;
redisV2: Redis;
}): Promise<number> => {
if (customers.length === 0) return 0;
const deleted = await batchDeleteCachedFullCustomers({ customers });
if (redisV2.status !== "ready") return deleted;
}): Promise<void> => {
if (customers.length === 0 || redisV2.status !== "ready") return;
for (
let offset = 0;
@@ -98,6 +95,53 @@ export const batchInvalidateCachedFullSubjects = async ({
await tryRedisWrite(() => writePipeline.exec(), redisV2);
}
};
export const batchInvalidateCachedFullSubjects = async ({
customers,
featuresByOrgEnv,
redisV2,
getRedisForCustomer,
}: {
customers: BatchInvalidateCustomer[];
featuresByOrgEnv: FeaturesByOrgEnv;
redisV2: Redis;
getRedisForCustomer?: ({
customer,
}: {
customer: BatchInvalidateCustomer;
}) => Redis;
}): Promise<number> => {
if (customers.length === 0) return 0;
const deleted = await batchDeleteCachedFullCustomers({ customers });
if (!getRedisForCustomer) {
await batchInvalidateCachedFullSubjectsOnRedis({
customers,
featuresByOrgEnv,
redisV2,
});
return deleted;
}
const customersByRedis = new Map<Redis, BatchInvalidateCustomer[]>();
for (const customer of customers) {
const targetRedis = getRedisForCustomer({ customer });
const existing = customersByRedis.get(targetRedis) ?? [];
existing.push(customer);
customersByRedis.set(targetRedis, existing);
}
await Promise.all(
[...customersByRedis.entries()].map(([targetRedis, redisCustomers]) =>
batchInvalidateCachedFullSubjectsOnRedis({
customers: redisCustomers,
featuresByOrgEnv,
redisV2: targetRedis,
}),
),
);
return deleted;
};

View File

@@ -1,5 +1,6 @@
import type { FullSubject } from "@autumn/shared";
import { normalizedToFullSubject } from "@autumn/shared";
import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRouting.js";
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
@@ -185,6 +186,32 @@ export const getCachedPartialFullSubject = async ({
};
}
const redisMigrationOk = await tryOrInvalidate({
ctx,
operation: () =>
isRedisMigrationCacheStale({
cachedAt: cached._cachedAt,
customerId,
redisConfig: ctx.org.redis_config,
})
? undefined
: true,
invalidate: () =>
invalidateCachedFullSubject({
ctx,
customerId,
entityId,
source: "partial-stale-redis-migration",
}),
warnMessage: `[getCachedPartialFullSubject] Stale Redis migration cache for ${subjectLabel}, evicting`,
});
if (redisMigrationOk === undefined) {
return {
fullSubject: undefined,
subjectViewEpoch: currentSubjectViewEpoch,
};
}
const meteredFeatureIdsToFetch = featureIds.filter((featureId) =>
cached.meteredFeatures.includes(featureId),
);

View File

@@ -8,6 +8,7 @@ import {
import { Decimal } from "decimal.js";
import type { Redis } from "ioredis";
import { getDbHealth, PgHealth } from "@/db/pgHealthMonitor.js";
import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRouting.js";
import { redis } from "@/external/redis/initRedis.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js";
@@ -172,6 +173,25 @@ export const getCachedFullCustomer = async ({
return undefined;
}
if (
isRedisMigrationCacheStale({
cachedAt,
customerId,
redisConfig: ctx.org.redis_config,
})
) {
ctx.logger.warn(
`[getCachedFullCustomer] Stale Redis migration cache for ${customerId}, evicting`,
);
await deleteCachedFullCustomer({
ctx,
customerId,
source: "stale-redis-migration",
skipGuard: true,
});
return undefined;
}
const fullCustomer = normalizeFromSchema<FullCustomer>({
schema: FullCustomerSchema,
data: parsed,

View File

@@ -8,6 +8,7 @@ import {
} from "@autumn/shared";
import { and, asc, count, eq, gt, inArray } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { resolveCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
@@ -166,6 +167,11 @@ export const runClearCreditSystemCacheTask = async ({
customers: customersToDelete,
featuresByOrgEnv,
redisV2: resolveRedisV2(),
getRedisForCustomer: ({ customer }) =>
resolveCustomerRedisRouting({
org: orgWithFeatures.org,
customerId: customer.customerId,
}).redis,
});
totalDeleted += deleted;
}

View File

@@ -5,6 +5,7 @@ import {
type MigrationJob,
ProcessorType,
} from "@autumn/shared";
import { setCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { billingActions } from "@/internal/billing/v2/actions/index.js";
import { CusService } from "@/internal/customers/CusService.js";
@@ -34,7 +35,12 @@ export const migrateCustomer = async ({
customerId,
});
const customerCtx: AutumnContext = { ...ctx, logger: customerLogger };
const customerCtx: AutumnContext = {
...ctx,
customerId,
logger: customerLogger,
};
setCustomerRedisRouting({ ctx: customerCtx, customerId });
try {
const fullCus = await CusService.getFull({
@@ -95,7 +101,7 @@ export const migrateCustomer = async ({
await deleteCachedFullCustomer({
customerId: fullCus.id ?? "",
ctx,
ctx: customerCtx,
});
}

View File

@@ -469,6 +469,15 @@ export class OrgService {
await clearOrgCache({ db, orgId });
}
static async listWithRedisConfig({ db }: { db: DrizzleCli }) {
const result = await db.query.organizations.findMany({
where: isNotNull(organizations.redis_config),
columns: { id: true, redis_config: true },
});
return result;
}
static async listPreviewOrgsForDeletion({ db }: { db: DrizzleCli }) {
const PREVIEW_ORG_PATTERN = "preview|%";
// 1. Find all preview orgs with no memberships

View File

@@ -0,0 +1,139 @@
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
import { z } from "zod/v4";
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";
export const handleUpsertRedisConfig = createRoute({
scopes: [Scopes.Organisation.Write],
body: z.object({
connectionString: z.string().min(1),
}),
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, logger } = ctx;
const { connectionString: rawConnectionString } = c.req.valid("json");
const connectionString = rawConnectionString.trim();
if (!connectionString) {
throw new RecaseError({
message: "Connection string is required",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (org.redis_config) {
throw new RecaseError({
message:
"Redis config already exists. Remove it before creating a new one.",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
let redisUrl: URL;
try {
redisUrl = new URL(connectionString);
} catch {
throw new RecaseError({
message: "Invalid connection string: could not parse URL",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const now = Date.now();
const updatedOrg = await OrgService.update({
db,
orgId: org.id,
updates: {
redis_config: {
connectionString: encryptData(connectionString),
url: redisUrl.host,
migrationPercent: 0,
previousMigrationPercent: 0,
migrationChangedAt: now,
},
},
});
if (updatedOrg) {
getOrgRedis({ org: updatedOrg });
logger.info(
`[handleUpsertRedisConfig] org=${org.id}: redis_config created, url=${redisUrl.host}`,
);
}
return c.json({ success: true });
},
});
export const handleUpdateRedisMigration = createRoute({
scopes: [Scopes.Organisation.Write],
body: z.object({
migrationPercent: z.number().int().min(0).max(100),
}),
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, logger } = ctx;
const { migrationPercent } = c.req.valid("json");
if (!org.redis_config) {
throw new RecaseError({
message: "No Redis config set on this org",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
await OrgService.update({
db,
orgId: org.id,
updates: {
redis_config: {
...org.redis_config,
previousMigrationPercent: org.redis_config.migrationPercent,
migrationPercent,
migrationChangedAt: Date.now(),
},
},
});
logger.info(
`[handleUpdateRedisMigration] org=${org.id}: ${org.redis_config.migrationPercent}% -> ${migrationPercent}%`,
);
return c.json({ success: true });
},
});
export const handleDeleteRedisConfig = createRoute({
scopes: [Scopes.Organisation.Write],
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, logger } = ctx;
if (org.redis_config && org.redis_config.migrationPercent > 0) {
throw new RecaseError({
message: `Cannot remove Redis config while migrationPercent is ${org.redis_config.migrationPercent}%. Set it to 0 first.`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
await OrgService.update({
db,
orgId: org.id,
updates: { redis_config: null },
});
removeOrgRedis({ orgId: org.id });
logger.info(
`[handleDeleteRedisConfig] org=${org.id}: redis_config removed`,
);
return c.json({ success: true });
},
});

View File

@@ -7,6 +7,11 @@ import { handleDeleteOrg } from "./handlers/crudHandlers/handleDeleteOrg.js";
import { handleGetOrg } from "./handlers/crudHandlers/handleGetOrg.js";
import { handleGetOrgFlags } from "./handlers/handleGetOrgFlags.js";
import { handleGetUploadUrl } from "./handlers/handleGetUploadUrl.js";
import {
handleDeleteRedisConfig,
handleUpdateRedisMigration,
handleUpsertRedisConfig,
} from "./handlers/handleRedisConfig.js";
import { handleResetDefaultAccount } from "./handlers/handleResetDefaultAccount.js";
import {
handleGetRevenueCatConfig,
@@ -52,6 +57,10 @@ honoOrgRouter.post("/stripe", ...handleConnectStripe);
honoOrgRouter.get("/stripe/oauth_url", ...handleGetOAuthUrl);
honoOrgRouter.post("/reset_default_account", ...handleResetDefaultAccount);
honoOrgRouter.patch("/redis", ...handleUpsertRedisConfig);
honoOrgRouter.delete("/redis", ...handleDeleteRedisConfig);
honoOrgRouter.patch("/redis/migration", ...handleUpdateRedisMigration);
honoOrgRouter.patch("/vercel", ...handleUpsertVercelConfig);
honoOrgRouter.get("/vercel_sink", ...handleGetVercelSink);

View File

@@ -265,6 +265,12 @@ export const createOrgResponse = ({
live_pkey: org.live_pkey,
onboarded: org.onboarded ?? true,
deployed: org.deployed ?? true,
redis_config: org.redis_config
? {
url: org.redis_config.url,
migrationPercent: org.redis_config.migrationPercent,
}
: null,
};
};

View File

@@ -2,6 +2,7 @@ import { type AppEnv, AuthType, createdAtToVersion } from "@autumn/shared";
import { addAppContextToLogs } from "@/utils/logging/addContextToLogs.js";
import type { DrizzleCli } from "../db/initDrizzle.js";
import type { Logger } from "../external/logtail/logtailUtils.js";
import { setCustomerRedisRouting } from "../external/redis/customerRedisRouting.js";
import { resolveRedisV2 } from "../external/redis/resolveRedisV2.js";
import type { AutumnContext } from "../honoUtils/HonoEnv.js";
import { computeRolloutSnapshot } from "../internal/misc/rollouts/rolloutUtils.js";
@@ -86,6 +87,7 @@ export const createWorkerContext = async ({
extraLogs: {},
rolloutSnapshot,
};
setCustomerRedisRouting({ ctx, customerId });
return ctx;
};

View File

@@ -9,6 +9,7 @@ import { criticalDbMiddleware } from "../honoMiddlewares/criticalDbMiddleware.js
import { customerBlockMiddleware } from "../honoMiddlewares/customerBlockMiddleware.js";
import { idempotencyMiddleware } from "../honoMiddlewares/idempotencyMiddleware.js";
import { orgConfigMiddleware } from "../honoMiddlewares/orgConfigMiddleware.js";
import { orgRedisMiddleware } from "../honoMiddlewares/orgRedisMiddleware.js";
import { queryMiddleware } from "../honoMiddlewares/queryMiddleware.js";
import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware.js";
import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware.js";
@@ -48,6 +49,7 @@ apiRouter.use("*", secretKeyMiddleware);
apiRouter.use("*", requestBlockMiddleware);
apiRouter.use("*", orgConfigMiddleware);
apiRouter.use("*", rolloutMiddleware);
apiRouter.use("*", orgRedisMiddleware);
apiRouter.use("*", apiVersionMiddleware);
apiRouter.use("*", traceEnrichMiddleware);
apiRouter.use("*", refreshCacheMiddleware);

View File

@@ -8,6 +8,7 @@ import {
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { Logger } from "@/external/logtail/logtailUtils.js";
import { setCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
@@ -50,7 +51,7 @@ export const createWorkerAutumnContext = async ({
const rolloutSnapshot = computeRolloutSnapshot({ orgId: org.id });
return {
const ctx = {
org,
env,
features,
@@ -71,4 +72,6 @@ export const createWorkerAutumnContext = async ({
extraLogs: {},
rolloutSnapshot,
} satisfies AutumnContext;
setCustomerRedisRouting({ ctx });
return ctx;
};

View File

@@ -0,0 +1,122 @@
import { describe, expect, test } from "bun:test";
import type { Organization, OrgRedisConfig } from "@autumn/shared";
import {
getCustomerBucket,
getCustomerRedisRoutingInfoForOrg,
getRedisUrlForCustomerFromOrg,
} from "@/external/redis/customerRedisRoutingInfo.js";
const makeRedisConfig = (
overrides: Partial<OrgRedisConfig> = {},
): OrgRedisConfig => ({
connectionString: "encrypted",
url: "dragonfly.internal:6379",
migrationPercent: 50,
previousMigrationPercent: 0,
migrationChangedAt: 1000,
...overrides,
});
const makeOrg = ({
redisConfig = makeRedisConfig(),
}: {
redisConfig?: OrgRedisConfig | null;
}) =>
({
id: "org_test",
redis_config: redisConfig,
}) as Organization;
const findCustomerInBucketRange = ({
min,
max,
}: {
min: number;
max: number;
}): string => {
for (let index = 0; index < 10_000; index++) {
const customerId = `cus_routing_${index}`;
const bucket = getCustomerBucket(customerId);
if (bucket >= min && bucket < max) return customerId;
}
throw new Error(`No customer found in bucket range [${min}, ${max})`);
};
describe("customer Redis routing", () => {
test("assigns a deterministic bucket from 0 to 99", () => {
const bucket = getCustomerBucket("cus_abc123");
expect(bucket).toBe(getCustomerBucket("cus_abc123"));
expect(bucket).toBeGreaterThanOrEqual(0);
expect(bucket).toBeLessThan(100);
});
test("uses shared Redis when the org has no redis_config", () => {
const org = makeOrg({ redisConfig: null });
expect(
getCustomerRedisRoutingInfoForOrg({
org,
customerId: "cus_1",
}),
).toEqual({
usesDedicatedRedis: false,
});
});
test("uses shared Redis when customerId is missing", () => {
const org = makeOrg({
redisConfig: makeRedisConfig({ migrationPercent: 100 }),
});
expect(
getRedisUrlForCustomerFromOrg({
org,
}),
).toBeUndefined();
});
test("routes buckets below migrationPercent to dedicated Dragonfly", () => {
const config = makeRedisConfig({ migrationPercent: 50 });
const org = makeOrg({ redisConfig: config });
const customerId = findCustomerInBucketRange({ min: 0, max: 50 });
expect(
getRedisUrlForCustomerFromOrg({
org,
customerId,
}),
).toBe(config.url);
expect(
getCustomerRedisRoutingInfoForOrg({
org,
customerId,
}),
).toMatchObject({
redisUrl: config.url,
usesDedicatedRedis: true,
});
});
test("keeps buckets at or above migrationPercent on shared Redis", () => {
const org = makeOrg({
redisConfig: makeRedisConfig({ migrationPercent: 50 }),
});
const customerId = findCustomerInBucketRange({ min: 50, max: 100 });
expect(
getRedisUrlForCustomerFromOrg({
org,
customerId,
}),
).toBeUndefined();
expect(
getCustomerRedisRoutingInfoForOrg({
org,
customerId,
}),
).toMatchObject({
usesDedicatedRedis: false,
});
});
});

View File

@@ -0,0 +1,125 @@
import { describe, expect, test } from "bun:test";
import type { OrgRedisConfig } from "@autumn/shared";
import {
getCustomerBucket,
isRedisMigrationCacheStale,
} from "@/external/redis/customerRedisRoutingInfo.js";
const makeConfig = (
overrides: Partial<OrgRedisConfig> = {},
): OrgRedisConfig => ({
connectionString: "encrypted",
url: "dragonfly.internal:6379",
migrationPercent: 50,
previousMigrationPercent: 0,
migrationChangedAt: 1000,
...overrides,
});
const findCustomerInBucketRange = ({
min,
max,
}: {
min: number;
max: number;
}): string => {
for (let index = 0; index < 10_000; index++) {
const customerId = `cus_stale_${index}`;
const bucket = getCustomerBucket(customerId);
if (bucket >= min && bucket < max) return customerId;
}
throw new Error(`No customer found in bucket range [${min}, ${max})`);
};
describe("isRedisMigrationCacheStale", () => {
test("marks cache stale when a forward migration moves the customer to dedicated Redis", () => {
const customerId = findCustomerInBucketRange({ min: 0, max: 50 });
expect(
isRedisMigrationCacheStale({
cachedAt: 500,
customerId,
redisConfig: makeConfig({
migrationPercent: 50,
previousMigrationPercent: 0,
migrationChangedAt: 1000,
}),
}),
).toBe(true);
});
test("keeps cache fresh when a forward migration does not move the customer", () => {
const customerId = findCustomerInBucketRange({ min: 50, max: 100 });
expect(
isRedisMigrationCacheStale({
cachedAt: 500,
customerId,
redisConfig: makeConfig({
migrationPercent: 50,
previousMigrationPercent: 0,
migrationChangedAt: 1000,
}),
}),
).toBe(false);
});
test("marks cache stale when rollback moves the customer back to shared Redis", () => {
const customerId = findCustomerInBucketRange({ min: 20, max: 50 });
expect(
isRedisMigrationCacheStale({
cachedAt: 1500,
customerId,
redisConfig: makeConfig({
migrationPercent: 20,
previousMigrationPercent: 50,
migrationChangedAt: 2000,
}),
}),
).toBe(true);
});
test("keeps cache fresh when it was written after the migration changed", () => {
const customerId = findCustomerInBucketRange({ min: 0, max: 50 });
expect(
isRedisMigrationCacheStale({
cachedAt: 1000,
customerId,
redisConfig: makeConfig({
migrationPercent: 50,
previousMigrationPercent: 0,
migrationChangedAt: 1000,
}),
}),
).toBe(false);
});
test("keeps legacy entries without cachedAt fresh", () => {
expect(
isRedisMigrationCacheStale({
cachedAt: undefined,
customerId: "cus_legacy",
redisConfig: makeConfig(),
}),
).toBe(false);
});
test("keeps cache fresh without a redis config or customer ID", () => {
expect(
isRedisMigrationCacheStale({
cachedAt: 500,
customerId: "cus_1",
redisConfig: null,
}),
).toBe(false);
expect(
isRedisMigrationCacheStale({
cachedAt: 500,
customerId: undefined,
redisConfig: makeConfig(),
}),
).toBe(false);
});
});

View File

@@ -24,6 +24,12 @@ export const FrontendOrgSchema = z.object({
through_master: z.boolean(),
onboarded: z.boolean(),
deployed: z.boolean(),
redis_config: z
.object({
url: z.string(),
migrationPercent: z.number(),
})
.nullable(),
processor_configs: z.object({
vercel: z.object({
connected: z.boolean(),

View File

@@ -41,6 +41,19 @@ export type StripeConnectConfig = {
master_org_id?: string;
};
export type OrgRedisConfig = {
/** AES-256-CBC encrypted full Redis connection string via encryptData() */
connectionString: string;
/** Plain domain/host only, used for pool URL-change detection */
url: string;
/** Percentage of customers routed to the dedicated Redis (0-100) */
migrationPercent: number;
/** The migrationPercent before the last change, used for staleness detection */
previousMigrationPercent: number;
/** Epoch ms when migrationPercent was last changed */
migrationChangedAt: number;
};
export const organizations = pgTable(
"organizations",
{
@@ -86,6 +99,8 @@ export const organizations = pgTable(
created_by: text("created_by"),
onboarded: boolean("onboarded").default(false),
deployed: boolean("deployed").default(false),
redis_config: jsonb("redis_config").$type<OrgRedisConfig>(),
},
(table) => [
index("idx_organizations_name_trgm")

View File

@@ -6,6 +6,9 @@ import { useNavigate, useSearchParams } from "react-router";
type SecondaryTabType =
| "api_keys"
| "stripe"
| "vercel"
| "revenuecat"
| "redis"
| "products"
| "rewards"
| "features"

View File

@@ -6,9 +6,11 @@ import { useTheme } from "@/contexts/ThemeProvider";
import { useAppQueryStates } from "@/hooks/common/useAppQueryStates";
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
import { useDevQuery } from "@/hooks/queries/useDevQuery";
import { useAdmin } from "../admin/hooks/useAdmin";
import LoadingScreen from "../general/LoadingScreen";
import { OnboardingGuide } from "../onboarding4/OnboardingGuide";
import { ApiKeysPage } from "./api-keys/ApiKeysPage";
import { ConfigureRedis } from "./configure-redis/ConfigureRedis";
import { ConfigureRevenueCat } from "./configure-revenuecat/ConfigureRevenueCat";
import { ConfigureStripe } from "./configure-stripe/ConfigureStripe";
import { ConfigureVercel } from "./configure-vercel/ConfigureVercel";
@@ -17,6 +19,7 @@ import { PublishableKeySection } from "./publishable-key";
export default function DevScreen() {
const { apiKeys, svixDashboardUrl, isLoading, error } = useDevQuery();
const { queryStates } = useAppQueryStates({ defaultTab: "api_keys" });
const { isAdmin } = useAdmin();
const tab = queryStates.tab;
const { pkey, webhooks, vercel, revenuecat } = useAutumnFlags();
@@ -42,6 +45,8 @@ export default function DevScreen() {
{tab === "vercel" && vercel && <ConfigureVercel />}
{tab === "revenuecat" && revenuecat && <ConfigureRevenueCat />}
{tab === "redis" && isAdmin && <ConfigureRedis />}
</div>
);
}

View File

@@ -0,0 +1,328 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/v2/buttons/Button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/v2/cards/Card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/v2/dialogs/Dialog";
import { FormLabel } from "@/components/v2/form/FormLabel";
import { Input } from "@/components/v2/inputs/Input";
import { useOrg } from "@/hooks/common/useOrg";
import { useAxiosInstance } from "@/services/useAxiosInstance";
const CONFIRM_REMOVE_TEXT = "remove";
const getToastErrorMessage = ({
error,
fallback,
}: {
error: unknown;
fallback: string;
}) => {
if (!error || typeof error !== "object" || !("response" in error)) {
return fallback;
}
const response = error.response as
| { data?: { message?: string } }
| undefined;
return response?.data?.message ?? fallback;
};
export const ConfigureRedis = () => {
const { org, mutate } = useOrg();
const axiosInstance = useAxiosInstance();
const [connectionString, setConnectionString] = useState("");
const [saving, setSaving] = useState(false);
const [removing, setRemoving] = useState(false);
const [updatingMigration, setUpdatingMigration] = useState(false);
const [showConnectDialog, setShowConnectDialog] = useState(false);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [showMigrationDialog, setShowMigrationDialog] = useState(false);
const [removeConfirmText, setRemoveConfirmText] = useState("");
const [newMigrationPercent, setNewMigrationPercent] = useState("");
const redisConfig = org?.redis_config;
const isConfigured = !!redisConfig;
useEffect(() => {
if (!showRemoveDialog) setRemoveConfirmText("");
}, [showRemoveDialog]);
useEffect(() => {
if (showMigrationDialog) {
setNewMigrationPercent(String(redisConfig?.migrationPercent ?? 0));
}
}, [showMigrationDialog, redisConfig?.migrationPercent]);
const handleConnect = async () => {
if (!connectionString.trim()) return;
setSaving(true);
try {
await axiosInstance.patch("/v1/organization/redis", {
connectionString,
});
await mutate();
setConnectionString("");
setShowConnectDialog(false);
toast.success("Redis connection created");
} catch (error) {
toast.error(
getToastErrorMessage({
error,
fallback: "Failed to create Redis connection",
}),
);
}
setSaving(false);
};
const handleUpdateMigration = async () => {
const percent = Number(newMigrationPercent);
if (
Number.isNaN(percent) ||
!Number.isInteger(percent) ||
percent < 0 ||
percent > 100
) {
toast.error("Migration percent must be a whole number between 0 and 100");
return;
}
setUpdatingMigration(true);
try {
await axiosInstance.patch("/v1/organization/redis/migration", {
migrationPercent: percent,
});
await mutate();
setShowMigrationDialog(false);
toast.success(`Migration updated to ${percent}%`);
} catch (error) {
toast.error(
getToastErrorMessage({
error,
fallback: "Failed to update migration",
}),
);
}
setUpdatingMigration(false);
};
const handleRemove = async () => {
if (removeConfirmText !== CONFIRM_REMOVE_TEXT) return;
setRemoving(true);
try {
await axiosInstance.delete("/v1/organization/redis");
await mutate();
setShowRemoveDialog(false);
toast.success("Redis connection removed");
} catch (error) {
toast.error(
getToastErrorMessage({
error,
fallback: "Failed to remove Redis connection",
}),
);
}
setRemoving(false);
};
return (
<>
<Card>
<CardHeader>
<CardTitle>Redis</CardTitle>
<CardDescription>
Connect a dedicated Redis instance for this org. Customer cache and
balance operations route by migration percentage.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isConfigured ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<FormLabel>Connected Instance</FormLabel>
<Input
value={redisConfig.url}
readOnly
className="font-mono text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<FormLabel>Migration Percentage</FormLabel>
<div className="flex items-center gap-2">
<Input
value={`${redisConfig.migrationPercent}%`}
readOnly
className="w-24 font-mono text-xs"
/>
<span className="text-t3 text-xs">
of customers on dedicated Redis
</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => setShowMigrationDialog(true)}
>
Update Migration %
</Button>
<Button
variant="destructive"
onClick={() => setShowRemoveDialog(true)}
disabled={redisConfig.migrationPercent > 0}
>
Remove
</Button>
</div>
{redisConfig.migrationPercent > 0 && (
<p className="text-t3 text-xs">
Set migration to 0% before removing the Redis connection.
</p>
)}
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<FormLabel>Connection String</FormLabel>
<Input
placeholder="rediss://default:password@host:6379"
value={connectionString}
onChange={(event) => setConnectionString(event.target.value)}
className="font-mono text-xs"
/>
</div>
<div>
<Button
onClick={() => setShowConnectDialog(true)}
disabled={!connectionString.trim()}
>
Connect Redis
</Button>
</div>
</div>
)}
</CardContent>
</Card>
<Dialog open={showConnectDialog} onOpenChange={setShowConnectDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect Redis</DialogTitle>
<DialogDescription>
Migration starts at 0%. No customers will be routed until the
migration percentage is increased.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setShowConnectDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleConnect}
isLoading={saving}
disabled={!connectionString.trim()}
>
Connect
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showMigrationDialog} onOpenChange={setShowMigrationDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Update Migration Percentage</DialogTitle>
<DialogDescription>
Customers are assigned deterministically by customer ID.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1">
<FormLabel>
Current: {redisConfig?.migrationPercent ?? 0}% / New:
</FormLabel>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={100}
value={newMigrationPercent}
onChange={(event) => setNewMigrationPercent(event.target.value)}
className="w-24 font-mono text-xs"
/>
<span className="text-t3 text-sm">%</span>
</div>
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setShowMigrationDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleUpdateMigration}
isLoading={updatingMigration}
>
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Redis Connection</DialogTitle>
<DialogDescription>
This org will revert to the shared Redis instance. Type{" "}
<span className="font-bold">"{CONFIRM_REMOVE_TEXT}"</span> to
confirm.
</DialogDescription>
</DialogHeader>
<Input
placeholder={`Type "${CONFIRM_REMOVE_TEXT}" to confirm`}
value={removeConfirmText}
onChange={(event) => setRemoveConfirmText(event.target.value)}
variant="destructive"
/>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setShowRemoveDialog(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleRemove}
isLoading={removing}
disabled={removeConfirmText !== CONFIRM_REMOVE_TEXT}
>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -4,6 +4,7 @@ import {
ChartBarIcon,
CoinVerticalIcon,
CubeIcon,
DatabaseIcon,
LegoIcon,
OptionIcon,
TerminalWindowIcon,
@@ -20,6 +21,7 @@ import { useLocalStorage } from "@/hooks/common/useLocalStorage";
import { useScopes } from "@/hooks/useScopes";
import { cn } from "@/lib/utils";
import { useEnv } from "@/utils/envUtils";
import { useAdmin } from "@/views/admin/hooks/useAdmin";
import { CollapsibleNavGroup } from "./CollapsibleNavGroup";
import { OrgDropdown } from "./components/OrgDropdown";
import { EnvDropdown } from "./EnvDropdown";
@@ -30,12 +32,14 @@ import { SidebarRail } from "./SidebarRail";
const buildDevSubTabs = ({
flags,
isAdmin,
}: {
flags: {
webhooks: boolean;
vercel: boolean;
revenuecat: boolean;
};
isAdmin: boolean;
}) => {
return [
{
@@ -76,6 +80,15 @@ const buildDevSubTabs = ({
},
]
: []),
...(isAdmin
? [
{
title: "Redis",
value: "redis",
icon: <DatabaseIcon size={16} weight="fill" />,
},
]
: []),
];
};
@@ -88,6 +101,7 @@ export const MainSidebar = ({
const flags = useAutumnFlags();
const { has } = useScopes();
const { isAdmin } = useAdmin();
const canSeeDev = has("apiKeys:read");
const [storedExpanded, setExpanded] = useLocalStorage<boolean>(
@@ -194,7 +208,7 @@ export const MainSidebar = ({
env={env}
isOpen={devGroupOpen}
onToggle={() => setDevGroupOpen((prev) => !prev)}
subTabs={buildDevSubTabs({ flags })}
subTabs={buildDevSubTabs({ flags, isAdmin })}
/>
)}
</div>