Add per-org Redis migration routing
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -121,6 +121,7 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
|
||||
batchResets.push(
|
||||
resetCustomerEntitlement({
|
||||
ctx,
|
||||
org: orgWithFeatures.org,
|
||||
cusEnt: cusEnt,
|
||||
updatedCusEnts,
|
||||
persistFreeOverage:
|
||||
|
||||
77
server/src/external/redis/customerRedisRouting.ts
vendored
Normal file
77
server/src/external/redis/customerRedisRouting.ts
vendored
Normal 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;
|
||||
};
|
||||
70
server/src/external/redis/customerRedisRoutingInfo.ts
vendored
Normal file
70
server/src/external/redis/customerRedisRoutingInfo.ts
vendored
Normal 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;
|
||||
};
|
||||
106
server/src/external/redis/orgRedisPool.ts
vendored
Normal file
106
server/src/external/redis/orgRedisPool.ts
vendored
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
7
server/src/external/stripe/stripeCusUtils.ts
vendored
7
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
server/src/honoMiddlewares/orgRedisMiddleware.ts
Normal file
12
server/src/honoMiddlewares/orgRedisMiddleware.ts
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
139
server/src/internal/orgs/handlers/handleRedisConfig.ts
Normal file
139
server/src/internal/orgs/handlers/handleRedisConfig.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
122
server/tests/unit/redis/customer-redis-routing.test.ts
Normal file
122
server/tests/unit/redis/customer-redis-routing.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
125
server/tests/unit/redis/migration-staleness.test.ts
Normal file
125
server/tests/unit/redis/migration-staleness.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useNavigate, useSearchParams } from "react-router";
|
||||
type SecondaryTabType =
|
||||
| "api_keys"
|
||||
| "stripe"
|
||||
| "vercel"
|
||||
| "revenuecat"
|
||||
| "redis"
|
||||
| "products"
|
||||
| "rewards"
|
||||
| "features"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
328
vite/src/views/developer/configure-redis/ConfigureRedis.tsx
Normal file
328
vite/src/views/developer/configure-redis/ConfigureRedis.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user