feat: swappable redis

This commit is contained in:
John Yeo
2026-04-21 00:08:34 +01:00
parent ea56739d1e
commit 8b2a07a7d9
68 changed files with 601 additions and 158 deletions

View File

@@ -34,7 +34,7 @@ const main = async () => {
withTrialsUsed: false, withTrialsUsed: false,
withSubs: true, withSubs: true,
withEvents: false, withEvents: false,
cusProductLimit: 15, cusProductLimit: 10,
}); });
// Run the actual query to measure wall-clock time // Run the actual query to measure wall-clock time

View File

@@ -24,6 +24,7 @@ local function init_context(params)
env = params.env, env = params.env,
customer_id = params.customer_id, customer_id = params.customer_id,
customer_entitlement_deductions = params.customer_entitlement_deductions, customer_entitlement_deductions = params.customer_entitlement_deductions,
balance_keys_by_feature_id = params.balance_keys_by_feature_id,
}) })
local context = { local context = {

View File

@@ -94,6 +94,7 @@ local context = init_context({
env = env, env = env,
customer_id = customer_id, customer_id = customer_id,
customer_entitlement_deductions = customer_entitlement_deductions, customer_entitlement_deductions = customer_entitlement_deductions,
balance_keys_by_feature_id = params.balance_keys_by_feature_id,
}) })
if #(context.missing_customer_entitlement_ids or {}) > 0 then if #(context.missing_customer_entitlement_ids or {}) > 0 then

View File

@@ -1,19 +1,11 @@
-- ============================================================================ -- ============================================================================
-- READ SUBJECT BALANCES -- READ SUBJECT BALANCES
-- Functions to read shared SubjectBalance payloads from Redis hashes -- Functions to read shared SubjectBalance payloads from Redis hashes.
-- Balance keys are built by the TS caller and passed in via
-- params.balance_keys_by_feature_id to keep this script portable across
-- Redis/Dragonfly cluster modes (no key construction inside Lua).
-- ============================================================================ -- ============================================================================
local function build_shared_subject_balance_key(params)
return '{'
.. params.customer_id
.. '}:'
.. params.org_id
.. ':'
.. params.env
.. ':full_subject:shared_balances:'
.. params.feature_id
end
local function decode_subject_balance(raw_value) local function decode_subject_balance(raw_value)
local decoded = safe_decode(raw_value) local decoded = safe_decode(raw_value)
if type(decoded) ~= 'table' then if type(decoded) ~= 'table' then
@@ -26,19 +18,17 @@ local function read_subject_balances(params)
local balances_by_id = {} local balances_by_id = {}
local missing_customer_entitlement_ids = {} local missing_customer_entitlement_ids = {}
local entries_by_balance_key = {} local entries_by_balance_key = {}
local balance_keys_by_feature_id = safe_table(params.balance_keys_by_feature_id)
for _, ent_obj in ipairs(params.customer_entitlement_deductions or {}) do for _, ent_obj in ipairs(params.customer_entitlement_deductions or {}) do
local customer_entitlement_id = ent_obj.customer_entitlement_id local customer_entitlement_id = ent_obj.customer_entitlement_id
local feature_id = ent_obj.feature_id local feature_id = ent_obj.feature_id
if customer_entitlement_id and feature_id then if customer_entitlement_id and feature_id then
local balance_key = build_shared_subject_balance_key({ local balance_key = balance_keys_by_feature_id[feature_id]
org_id = params.org_id, if not balance_key then
env = params.env, table.insert(missing_customer_entitlement_ids, customer_entitlement_id)
customer_id = params.customer_id, else
feature_id = feature_id,
})
if entries_by_balance_key[balance_key] == nil then if entries_by_balance_key[balance_key] == nil then
entries_by_balance_key[balance_key] = { entries_by_balance_key[balance_key] = {
feature_id = feature_id, feature_id = feature_id,
@@ -52,6 +42,7 @@ local function read_subject_balances(params)
) )
end end
end end
end
for balance_key, entry in pairs(entries_by_balance_key) do for balance_key, entry in pairs(entries_by_balance_key) do
local hmget_args = { 'HMGET', balance_key } local hmget_args = { 'HMGET', balance_key }

View File

@@ -4,4 +4,13 @@ await initInfisical();
const { warmupRegionalRedis } = await import("./external/redis/initRedis.js"); const { warmupRegionalRedis } = await import("./external/redis/initRedis.js");
await warmupRegionalRedis(); await warmupRegionalRedis();
// Edge config modules self-register on import (cron reads redis-v2-cache
// so resolveRedisV2 picks the right instance on each ctx build).
await import("./internal/misc/redisV2Cache/redisV2CacheStore.js");
const { logger } = await import("./external/logtail/logtailUtils.js");
const { startAllEdgeConfigPolling } = await import(
"./internal/misc/edgeConfig/edgeConfigRegistry.js"
);
await startAllEdgeConfigPolling({ logger });
await import("./cron/cronInit.js"); await import("./cron/cronInit.js");

View File

@@ -1,5 +1,6 @@
import { type AppEnv, CusProductStatus } from "@autumn/shared"; import { type AppEnv, CusProductStatus } from "@autumn/shared";
import type { RepoContext } from "@/db/repoContext.js"; import type { RepoContext } from "@/db/repoContext.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { getOneOffCustomerProductsToCleanup } from "@/internal/customers/cusProducts/actions/cleanupOneOff/getOneOffToCleanup.js"; import { getOneOffCustomerProductsToCleanup } from "@/internal/customers/cusProducts/actions/cleanupOneOff/getOneOffToCleanup.js";
import { batchUpdateCustomerProducts } from "@/internal/customers/cusProducts/repos/batchUpdateCustomerProducts.js"; import { batchUpdateCustomerProducts } from "@/internal/customers/cusProducts/repos/batchUpdateCustomerProducts.js";
import type { CronContext } from "../utils/CronContext.js"; import type { CronContext } from "../utils/CronContext.js";
@@ -63,6 +64,7 @@ export const runOneOffCleanup = async ({ ctx }: { ctx: CronContext }) => {
}, },
env: group.env, env: group.env,
logger: logger, logger: logger,
redisV2: resolveRedisV2(),
}; };
await batchUpdateCustomerProducts({ await batchUpdateCustomerProducts({
ctx: repoContext, ctx: repoContext,

View File

@@ -76,6 +76,7 @@ export const runProductCron = async ({
await batchInvalidateCachedFullSubjects({ await batchInvalidateCachedFullSubjects({
customers: customersToDelete, customers: customersToDelete,
featuresByOrgEnv, featuresByOrgEnv,
redisV2: ctx.redisV2,
}); });
console.log(`Expired ${rows.length} customer products`); console.log(`Expired ${rows.length} customer products`);
continue; continue;

View File

@@ -8,6 +8,7 @@ import {
import { UTCDate } from "@date-fns/utc"; import { UTCDate } from "@date-fns/utc";
import { format } from "date-fns"; import { format } from "date-fns";
import type { RepoContext } from "@/db/repoContext"; import type { RepoContext } from "@/db/repoContext";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { invalidateCustomerEntitlementBalance } from "@/internal/customers/cache/fullSubject/actions/invalidate/invalidateCustomerEntitlementBalance.js"; import { invalidateCustomerEntitlementBalance } from "@/internal/customers/cache/fullSubject/actions/invalidate/invalidateCustomerEntitlementBalance.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
@@ -42,6 +43,7 @@ const resetCustomerEntitlementInDb = async ({
}, },
env: cusEnt.customer.env, env: cusEnt.customer.env,
customerId: cusEnt.customer_id ?? "", customerId: cusEnt.customer_id ?? "",
redisV2: resolveRedisV2(),
}; };
try { try {
@@ -205,6 +207,7 @@ export const resetCustomerEntitlement = async ({
customerId: cusEnt.customer_id ?? "", customerId: cusEnt.customer_id ?? "",
featureId: cusEnt.entitlement.feature.id, featureId: cusEnt.entitlement.feature.id,
customerEntitlementId: cusEnt.id, customerEntitlementId: cusEnt.id,
redisV2: resolveRedisV2(),
}); });
return result; return result;
}; };

View File

@@ -1,4 +1,5 @@
import type { AppEnv } from "@autumn/shared"; import type { AppEnv } from "@autumn/shared";
import type { Redis } from "ioredis";
import type { Logger } from "@/external/logtail/logtailUtils"; import type { Logger } from "@/external/logtail/logtailUtils";
import type { DrizzleCli } from "./initDrizzle.js"; import type { DrizzleCli } from "./initDrizzle.js";
@@ -10,5 +11,6 @@ export interface RepoContext {
db: DrizzleCli; db: DrizzleCli;
logger: Logger; logger: Logger;
redisV2: Redis;
customerId?: string; customerId?: string;
} }

View File

@@ -4,6 +4,8 @@ export const ADMIN_FEATURE_FLAGS_CONFIG_KEY = "admin/feature-flags-config.json";
export const ADMIN_CUSTOMER_BLOCK_CONFIG_KEY = export const ADMIN_CUSTOMER_BLOCK_CONFIG_KEY =
"admin/customer-block-config.json"; "admin/customer-block-config.json";
export const ADMIN_ORG_LIMITS_CONFIG_KEY = "admin/org-limits-config.json"; export const ADMIN_ORG_LIMITS_CONFIG_KEY = "admin/org-limits-config.json";
export const ADMIN_REDIS_V2_CACHE_CONFIG_KEY =
"admin/redis-v2-cache-config.json";
const bucket = process.env.S3_BUCKET || "autumn-prod-server"; const bucket = process.env.S3_BUCKET || "autumn-prod-server";
const region = process.env.S3_REGION || "us-east-2"; const region = process.env.S3_REGION || "us-east-2";

View File

@@ -1,4 +1,6 @@
import type { Redis } from "ioredis"; import type { Redis } from "ioredis";
import { logger } from "@/external/logtail/logtailUtils.js";
import type { RedisV2InstanceName } from "@/internal/misc/redisV2Cache/redisV2CacheSchemas.js";
import { import {
createRedisConnection, createRedisConnection,
currentRegion, currentRegion,
@@ -17,6 +19,39 @@ export const redisV2: Redis =
}) })
: redis; : redis;
const alternateInstanceUrls: Partial<Record<RedisV2InstanceName, string>> = {
canary: process.env.CACHE_V2_CANARY_URL?.trim() || undefined,
dragonfly: process.env.CACHE_V2_DRAGONFLY_URL?.trim() || undefined,
};
const instancePool = new Map<RedisV2InstanceName, Redis>();
const missingUrlWarned = new Set<RedisV2InstanceName>();
export const getAlternateRedisV2Instance = (
name: RedisV2InstanceName,
): Redis | null => {
const cacheUrl = alternateInstanceUrls[name];
if (!cacheUrl) {
if (!missingUrlWarned.has(name)) {
missingUrlWarned.add(name);
logger.warn(
`[resolveRedisV2] activeInstance=${name} but URL is not set; falling back to primary`,
);
}
return null;
}
const existing = instancePool.get(name);
if (existing) return existing;
const instance = createRedisConnection({
cacheUrl,
region: `${currentRegion}:v2:${name}`,
});
instancePool.set(name, instance);
return instance;
};
export const warmupRedisV2 = async (): Promise<void> => { export const warmupRedisV2 = async (): Promise<void> => {
if (redisV2 === redis) return; if (redisV2 === redis) return;

View File

@@ -0,0 +1,29 @@
import type { Redis } from "ioredis";
import { logger } from "@/external/logtail/logtailUtils.js";
import type { RedisV2InstanceName } from "@/internal/misc/redisV2Cache/redisV2CacheSchemas.js";
import { getActiveRedisV2Instance } from "@/internal/misc/redisV2Cache/redisV2CacheStore.js";
import {
getAlternateRedisV2Instance,
redisV2 as redisV2Primary,
} from "./initRedisV2.js";
let lastLoggedInstance: RedisV2InstanceName | null = null;
/** Returns the active V2 Redis instance, selected by the redis-v2-cache edge config.
* Called by every ctx-building middleware/worker — request-path code reads
* ctx.redisV2 rather than calling this. */
export const resolveRedisV2 = (): Redis => {
const activeInstance = getActiveRedisV2Instance();
if (activeInstance !== lastLoggedInstance) {
logger.info(
`[resolveRedisV2] activeInstance switched to "${activeInstance}"`,
);
lastLoggedInstance = activeInstance;
}
if (activeInstance === "primary") return redisV2Primary;
const alternate = getAlternateRedisV2Instance(activeInstance);
return alternate ?? redisV2Primary;
};

View File

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

View File

@@ -1,5 +1,6 @@
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
import { addAppContextToLogs } from "@/utils/logging/addContextToLogs"; import { addAppContextToLogs } from "@/utils/logging/addContextToLogs";
import { logRequestResult } from "./requestLogging/logRequestResult.js"; import { logRequestResult } from "./requestLogging/logRequestResult.js";
@@ -80,6 +81,11 @@ export const analyticsMiddleware = async (c: Context<HonoEnv>, next: Next) => {
const customerId = ctx.customerId; const customerId = ctx.customerId;
const entityId = ctx.entityId; const entityId = ctx.entityId;
const fullSubjectBucket =
customerId && ctx.rolloutSnapshot?.customerBucket !== undefined
? (ctx.rolloutSnapshot.customerBucket ?? undefined)
: undefined;
ctx.logger = addAppContextToLogs({ ctx.logger = addAppContextToLogs({
logger: ctx.logger, logger: ctx.logger,
appContext: { appContext: {
@@ -92,6 +98,10 @@ export const analyticsMiddleware = async (c: Context<HonoEnv>, next: Next) => {
user_id: ctx.userId || undefined, user_id: ctx.userId || undefined,
user_email: ctx.user?.email || undefined, user_email: ctx.user?.email || undefined,
api_version: ctx.apiVersion?.semver, api_version: ctx.apiVersion?.semver,
full_subject_bucket: fullSubjectBucket ?? undefined,
full_subject_rollout_enabled: customerId
? isFullSubjectRolloutEnabled({ ctx })
: undefined,
}, },
}); });

View File

@@ -9,6 +9,7 @@ import {
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import { db, dbGeneral } from "@/db/initDrizzle.js"; import { db, dbGeneral } from "@/db/initDrizzle.js";
import { logger } from "@/external/logtail/logtailUtils.js"; import { logger } from "@/external/logtail/logtailUtils.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { generateId } from "@/utils/genUtils.js"; import { generateId } from "@/utils/genUtils.js";
import { addRequestToLogs } from "@/utils/logging/addContextToLogs"; import { addRequestToLogs } from "@/utils/logging/addContextToLogs";
@@ -71,6 +72,7 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
db, db,
dbGeneral, dbGeneral,
logger: childLogger, logger: childLogger,
redisV2: resolveRedisV2(),
// Request info // Request info
id, id,

View File

@@ -7,6 +7,7 @@ import type {
Organization, Organization,
} from "@autumn/shared"; } from "@autumn/shared";
import type { User } from "better-auth"; import type { User } from "better-auth";
import type { Redis } from "ioredis";
import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { Logger } from "@/external/logtail/logtailUtils.js"; import type { Logger } from "@/external/logtail/logtailUtils.js";
import type { OidcClaims } from "@/external/vercel/misc/vercelAuth.js"; import type { OidcClaims } from "@/external/vercel/misc/vercelAuth.js";
@@ -34,6 +35,10 @@ export type RequestContext = {
db: DrizzleCli; db: DrizzleCli;
dbGeneral: DrizzleCli; dbGeneral: DrizzleCli;
logger: Logger; logger: Logger;
/** V2 Redis instance for this request. Populated by every ctx-building
* middleware/worker via resolveRedisV2. Never import the singleton directly
* in request-path code. */
redisV2: Redis;
// Info // Info
id: string; id: string;

View File

@@ -23,6 +23,7 @@ import "./internal/misc/featureFlags/featureFlagStore.js";
import "./internal/misc/customerBlocks/customerBlockStore.js"; import "./internal/misc/customerBlocks/customerBlockStore.js";
import "./internal/misc/edgeConfig/orgLimitsStore.js"; import "./internal/misc/edgeConfig/orgLimitsStore.js";
import "./internal/misc/stripeSync/stripeSyncStore.js"; import "./internal/misc/stripeSync/stripeSyncStore.js";
import "./internal/misc/redisV2Cache/redisV2CacheStore.js";
import { closeStripeSyncEngine } from "@autumn/stripe-sync"; import { closeStripeSyncEngine } from "@autumn/stripe-sync";
import { import {
startRedisMonitor, startRedisMonitor,

View File

@@ -5,6 +5,7 @@ import { handleGetAdminFeatureFlagsConfig } from "./handleGetAdminFeatureFlagsCo
import { handleGetAdminOrgLimitsConfig } from "./handleGetAdminOrgLimitsConfig"; import { handleGetAdminOrgLimitsConfig } from "./handleGetAdminOrgLimitsConfig";
import { handleGetAdminOrgRequestBlock } from "./handleGetAdminOrgRequestBlock"; import { handleGetAdminOrgRequestBlock } from "./handleGetAdminOrgRequestBlock";
import { handleGetAdminRequestBlockConfig } from "./handleGetAdminRequestBlockConfig"; import { handleGetAdminRequestBlockConfig } from "./handleGetAdminRequestBlockConfig";
import { handleGetAdminRedisV2CacheConfig } from "./handleGetAdminRedisV2CacheConfig";
import { handleGetAdminStripeSyncConfig } from "./handleGetAdminStripeSyncConfig"; import { handleGetAdminStripeSyncConfig } from "./handleGetAdminStripeSyncConfig";
import { handleGetInvoiceLineItems } from "./handleGetInvoiceLineItems"; import { handleGetInvoiceLineItems } from "./handleGetInvoiceLineItems";
@@ -18,6 +19,7 @@ import { handleUpsertAdminFeatureFlagsConfig } from "./handleUpsertAdminFeatureF
import { handleUpsertAdminOrgLimitsConfig } from "./handleUpsertAdminOrgLimitsConfig"; import { handleUpsertAdminOrgLimitsConfig } from "./handleUpsertAdminOrgLimitsConfig";
import { handleUpsertAdminOrgRequestBlock } from "./handleUpsertAdminOrgRequestBlock"; import { handleUpsertAdminOrgRequestBlock } from "./handleUpsertAdminOrgRequestBlock";
import { handleUpsertAdminRequestBlockConfig } from "./handleUpsertAdminRequestBlockConfig"; import { handleUpsertAdminRequestBlockConfig } from "./handleUpsertAdminRequestBlockConfig";
import { handleUpsertAdminRedisV2CacheConfig } from "./handleUpsertAdminRedisV2CacheConfig";
import { handleUpsertAdminStripeSyncConfig } from "./handleUpsertAdminStripeSyncConfig"; import { handleUpsertAdminStripeSyncConfig } from "./handleUpsertAdminStripeSyncConfig";
import { handleDeleteRolloutOrg } from "./rollouts/handleDeleteRolloutOrg"; import { handleDeleteRolloutOrg } from "./rollouts/handleDeleteRolloutOrg";
import { handleGetRollouts } from "./rollouts/handleGetRollouts"; import { handleGetRollouts } from "./rollouts/handleGetRollouts";
@@ -67,6 +69,14 @@ honoAdminRouter.put(
"/stripe-sync-config", "/stripe-sync-config",
...handleUpsertAdminStripeSyncConfig, ...handleUpsertAdminStripeSyncConfig,
); );
honoAdminRouter.get(
"/redis-v2-cache-config",
...handleGetAdminRedisV2CacheConfig,
);
honoAdminRouter.put(
"/redis-v2-cache-config",
...handleUpsertAdminRedisV2CacheConfig,
);
honoAdminRouter.get("/org-member", ...handleGetOrgMember); honoAdminRouter.get("/org-member", ...handleGetOrgMember);
honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount); honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount);
honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients); honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients);

View File

@@ -0,0 +1,18 @@
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import {
getActiveRedisV2Instance,
getRedisV2CacheStatus,
} from "@/internal/misc/redisV2Cache/redisV2CacheStore.js";
export const handleGetAdminRedisV2CacheConfig = createRoute({
handler: async (c) => {
const status = getRedisV2CacheStatus();
return c.json({
activeInstance: getActiveRedisV2Instance(),
configHealthy: status.healthy,
configConfigured: status.configured,
lastSuccessAt: status.lastSuccessAt ?? null,
error: status.error ?? null,
});
},
});

View File

@@ -0,0 +1,12 @@
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { RedisV2CacheConfigSchema } from "@/internal/misc/redisV2Cache/redisV2CacheSchemas.js";
import { updateActiveRedisV2Instance } from "@/internal/misc/redisV2Cache/redisV2CacheStore.js";
export const handleUpsertAdminRedisV2CacheConfig = createRoute({
body: RedisV2CacheConfigSchema,
handler: async (c) => {
const { activeInstance } = c.req.valid("json");
await updateActiveRedisV2Instance({ activeInstance });
return c.json({ success: true, activeInstance });
},
});

View File

@@ -11,7 +11,6 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { triggerAutoTopUp } from "@/internal/balances/autoTopUp/triggerAutoTopUp.js"; import { triggerAutoTopUp } from "@/internal/balances/autoTopUp/triggerAutoTopUp.js";
import { fireTrackWebhooks } from "@/internal/balances/trackWebhooks/fireTrackWebhooks.js"; import { fireTrackWebhooks } from "@/internal/balances/trackWebhooks/fireTrackWebhooks.js";
import { createAllocatedInvoice } from "@/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.js"; import { createAllocatedInvoice } from "@/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.js";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import { saveLockReceipt } from "@/internal/balances/utils/lock/saveLockReceipt.js"; import { saveLockReceipt } from "@/internal/balances/utils/lock/saveLockReceipt.js";
import type { DeductionOptions } from "../types/deductionTypes.js"; import type { DeductionOptions } from "../types/deductionTypes.js";
import type { DeductionUpdate } from "../types/deductionUpdate.js"; import type { DeductionUpdate } from "../types/deductionUpdate.js";
@@ -232,7 +231,7 @@ export const executePostgresDeductionV2 = async ({
featureId: feature.id, featureId: feature.id,
entityId, entityId,
items: mutation_logs ?? [], items: mutation_logs ?? [],
redisInstance: redisV2, redisInstance: ctx.redisV2,
}); });
} }
} catch (error) { } catch (error) {

View File

@@ -5,12 +5,12 @@ import {
} from "@autumn/shared"; } from "@autumn/shared";
import type { Redis } from "ioredis"; import type { Redis } from "ioredis";
import { currentRegion } from "@/external/redis/initRedis.js"; import { currentRegion } from "@/external/redis/initRedis.js";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { triggerAutoTopUp } from "@/internal/balances/autoTopUp/triggerAutoTopUp.js"; import { triggerAutoTopUp } from "@/internal/balances/autoTopUp/triggerAutoTopUp.js";
import { fireTrackWebhooks } from "@/internal/balances/trackWebhooks/fireTrackWebhooks.js"; import { fireTrackWebhooks } from "@/internal/balances/trackWebhooks/fireTrackWebhooks.js";
import { createAllocatedInvoice } from "@/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.js"; import { createAllocatedInvoice } from "@/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.js";
import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js"; import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js";
import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import type { DeductionOptions } from "../types/deductionTypes.js"; import type { DeductionOptions } from "../types/deductionTypes.js";
import type { DeductionUpdate } from "../types/deductionUpdate.js"; import type { DeductionUpdate } from "../types/deductionUpdate.js";
@@ -125,11 +125,28 @@ export const executeRedisDeductionV2 = async ({
continue; continue;
} }
const balanceKeysByFeatureId: Record<string, string> = {};
for (const deductionEntry of customerEntitlementDeductions) {
const targetFeatureId =
(deductionEntry as { feature_id?: string }).feature_id ?? feature.id;
if (!balanceKeysByFeatureId[targetFeatureId]) {
balanceKeysByFeatureId[targetFeatureId] = buildSharedFullSubjectBalanceKey(
{
orgId: org.id,
env,
customerId,
featureId: targetFeatureId,
},
);
}
}
const luaParams = { const luaParams = {
org_id: org.id, org_id: org.id,
env, env,
customer_id: customerId, customer_id: customerId,
customer_entitlement_deductions: customerEntitlementDeductions, customer_entitlement_deductions: customerEntitlementDeductions,
balance_keys_by_feature_id: balanceKeysByFeatureId,
spend_limit_by_feature_id: spendLimitByFeatureId ?? null, spend_limit_by_feature_id: spendLimitByFeatureId ?? null,
usage_based_cus_ent_ids_by_feature_id: usage_based_cus_ent_ids_by_feature_id:
usageBasedCusEntIdsByFeatureId ?? null, usageBasedCusEntIdsByFeatureId ?? null,
@@ -151,7 +168,7 @@ export const executeRedisDeductionV2 = async ({
lock_receipt_key: lockReceiptKey ?? null, lock_receipt_key: lockReceiptKey ?? null,
}; };
const targetRedis = redisInstance ?? redisV2; const targetRedis = redisInstance ?? ctx.redisV2;
const result = await tryRedisWrite( const result = await tryRedisWrite(
() => () =>

View File

@@ -1,5 +1,4 @@
import type { EntityRolloverBalance, FullSubject } from "@autumn/shared"; import type { EntityRolloverBalance, FullSubject } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js";
import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js"; import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js";
@@ -75,7 +74,7 @@ export const syncDeductionUpdatesToFullSubjectCache = async ({
customerEntitlement.next_reset_at ?? null; customerEntitlement.next_reset_at ?? null;
} }
const { org, env } = ctx; const { org, env, redisV2 } = ctx;
// Group updates by featureId and build per-feature update arrays // Group updates by featureId and build per-feature update arrays
const updatesByFeatureId: Record<string, SubjectBalanceUpdate[]> = {}; const updatesByFeatureId: Record<string, SubjectBalanceUpdate[]> = {};

View File

@@ -1,6 +1,5 @@
import { ErrCode, RecaseError } from "@autumn/shared"; import { ErrCode, RecaseError } from "@autumn/shared";
import { redis } from "@/external/redis/initRedis.js"; import { redis } from "@/external/redis/initRedis.js";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js"; import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js";
import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js";
@@ -46,6 +45,7 @@ export const fetchLockReceipt = async ({
ctx: AutumnContext; ctx: AutumnContext;
lockId: string; lockId: string;
}) => { }) => {
const { redisV2 } = ctx;
const hashedKey = Bun.hash(lockId).toString(); const hashedKey = Bun.hash(lockId).toString();
const lockReceiptKey = buildLockReceiptKey({ const lockReceiptKey = buildLockReceiptKey({
orgId: ctx.org.id, orgId: ctx.org.id,

View File

@@ -1,7 +1,6 @@
import type { Feature, FullSubject } from "@autumn/shared"; import type { Feature, FullSubject } from "@autumn/shared";
import { type FinalizeLockParamsV0, findFeatureById } from "@autumn/shared"; import { type FinalizeLockParamsV0, findFeatureById } from "@autumn/shared";
import type { Redis } from "ioredis"; import type { Redis } from "ioredis";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { LockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js"; import type { LockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js";
import { import {
@@ -62,7 +61,7 @@ export const buildFinalizeLockContextV2 = async ({
return { return {
receipt, receipt,
lockReceiptKey, lockReceiptKey,
redisInstance: redisV2, redisInstance: ctx.redisV2,
fullSubject, fullSubject,
feature, feature,
lockValue, lockValue,

View File

@@ -1,5 +1,4 @@
import type { AppEnv } from "@autumn/shared"; import type { AppEnv } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js";
import { AGGREGATED_BALANCE_FIELD } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js"; import { AGGREGATED_BALANCE_FIELD } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js";
@@ -34,6 +33,7 @@ export const refreshEntityAggregateCache = async ({
if (aggregated.length === 0) return; if (aggregated.length === 0) return;
const affectedFeatureIds = new Set(featureIds); const affectedFeatureIds = new Set(featureIds);
const { redisV2 } = ctx;
const pipeline = redisV2.pipeline(); const pipeline = redisV2.pipeline();
let writeCount = 0; let writeCount = 0;

View File

@@ -148,8 +148,7 @@ export const syncItemV4 = async ({
modifiedCusEntIdsByFeatureId, modifiedCusEntIdsByFeatureId,
)) { )) {
const result = await getCachedFeatureBalance({ const result = await getCachedFeatureBalance({
orgId, ctx,
env,
customerId, customerId,
featureId, featureId,
customerEntitlementIds, customerEntitlementIds,

View File

@@ -1,4 +1,3 @@
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { ResetCusEntParam } from "@/internal/balances/utils/sql/client.js"; import type { ResetCusEntParam } from "@/internal/balances/utils/sql/client.js";
import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js";
@@ -45,7 +44,7 @@ export const resetSubjectCache = async ({
if (resets.length === 0) return; if (resets.length === 0) return;
try { try {
const { org, env } = ctx; const { org, env, redisV2 } = ctx;
const updatesByFeatureId: Record<string, SubjectBalanceUpdate[]> = {}; const updatesByFeatureId: Record<string, SubjectBalanceUpdate[]> = {};

View File

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

View File

@@ -1,5 +1,4 @@
import { type FullSubject, normalizedToFullSubject } from "@autumn/shared"; import { type FullSubject, normalizedToFullSubject } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js"; import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
@@ -28,7 +27,7 @@ export const getCachedFullSubject = async ({
entityId?: string; entityId?: string;
source?: string; source?: string;
}): Promise<FullSubject | undefined> => { }): Promise<FullSubject | undefined> => {
const { org, env, logger } = ctx; const { org, env, logger, redisV2 } = ctx;
const subjectKey = buildFullSubjectKey({ const subjectKey = buildFullSubjectKey({
orgId: org.id, orgId: org.id,
env, env,
@@ -97,8 +96,7 @@ export const getCachedFullSubject = async ({
const isCustomerSubject = !entityId; const isCustomerSubject = !entityId;
const balances = await getCachedFeatureBalancesBatch({ const balances = await getCachedFeatureBalancesBatch({
orgId: org.id, ctx,
env,
customerId, customerId,
featureIds: cached.meteredFeatures, featureIds: cached.meteredFeatures,
customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId, customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId,

View File

@@ -1,5 +1,5 @@
import type { AppEnv, Feature } from "@autumn/shared"; import type { AppEnv, Feature } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js"; import type { Redis } from "ioredis";
import { batchDeleteCachedFullCustomers } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.js"; import { batchDeleteCachedFullCustomers } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.js";
import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js"; import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
@@ -22,9 +22,11 @@ type FeaturesByOrgEnv = Record<string, Feature[]>;
export const batchInvalidateCachedFullSubjects = async ({ export const batchInvalidateCachedFullSubjects = async ({
customers, customers,
featuresByOrgEnv, featuresByOrgEnv,
redisV2,
}: { }: {
customers: BatchInvalidateCustomer[]; customers: BatchInvalidateCustomer[];
featuresByOrgEnv: FeaturesByOrgEnv; featuresByOrgEnv: FeaturesByOrgEnv;
redisV2: Redis;
}): Promise<number> => { }): Promise<number> => {
if (customers.length === 0) return 0; if (customers.length === 0) return 0;

View File

@@ -1,4 +1,3 @@
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js"; import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js";
@@ -11,6 +10,7 @@ export const getOrInitFullSubjectViewEpoch = async ({
ctx: AutumnContext; ctx: AutumnContext;
customerId: string; customerId: string;
}): Promise<number> => { }): Promise<number> => {
const { redisV2 } = ctx;
const epochKey = buildFullSubjectViewEpochKey({ const epochKey = buildFullSubjectViewEpochKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,

View File

@@ -1,4 +1,3 @@
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js"; import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js";
@@ -11,6 +10,7 @@ export const incrementFullSubjectViewEpoch = async ({
ctx: AutumnContext; ctx: AutumnContext;
customerId: string; customerId: string;
}): Promise<number | null> => { }): Promise<number | null> => {
const { redisV2 } = ctx;
const epochKey = buildFullSubjectViewEpochKey({ const epochKey = buildFullSubjectViewEpochKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,

View File

@@ -1,4 +1,4 @@
import { redisV2 } from "@/external/redis/initRedisV2.js"; import type { Redis } from "ioredis";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js"; import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js";
import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js"; import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js";
@@ -9,12 +9,14 @@ export const invalidateCustomerEntitlementBalance = async ({
customerId, customerId,
featureId, featureId,
customerEntitlementId, customerEntitlementId,
redisV2,
}: { }: {
orgId: string; orgId: string;
env: string; env: string;
customerId: string; customerId: string;
featureId: string; featureId: string;
customerEntitlementId: string; customerEntitlementId: string;
redisV2: Redis;
}): Promise<void> => { }): Promise<void> => {
if ( if (
!orgId || !orgId ||

View File

@@ -1,4 +1,3 @@
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js"; import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
@@ -14,7 +13,7 @@ export const invalidateCachedFullSubjectExact = async ({
ctx: AutumnContext; ctx: AutumnContext;
source?: string; source?: string;
}): Promise<void> => { }): Promise<void> => {
const { org, env, logger } = ctx; const { org, env, logger, redisV2 } = ctx;
if (!customerId || redisV2.status !== "ready") return; if (!customerId || redisV2.status !== "ready") return;
const subjectKey = buildFullSubjectKey({ const subjectKey = buildFullSubjectKey({

View File

@@ -1,4 +1,3 @@
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js"; import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
@@ -22,9 +21,9 @@ export const invalidateSharedBalanceFields = async ({
ctx: AutumnContext; ctx: AutumnContext;
customerId: string; customerId: string;
}): Promise<void> => { }): Promise<void> => {
const { org, env, redisV2 } = ctx;
if (!customerId || redisV2.status !== "ready") return; if (!customerId || redisV2.status !== "ready") return;
const { org, env } = ctx;
const subjectKey = buildFullSubjectKey({ orgId: org.id, env, customerId }); const subjectKey = buildFullSubjectKey({ orgId: org.id, env, customerId });
const cachedRaw = await tryRedisRead(() => redisV2.get(subjectKey), redisV2); const cachedRaw = await tryRedisRead(() => redisV2.get(subjectKey), redisV2);
@@ -46,7 +45,7 @@ async function deleteFieldsFromManifest({
customerId: string; customerId: string;
cachedRaw: string; cachedRaw: string;
}) { }) {
const { org, env, logger } = ctx; const { org, env, logger, redisV2 } = ctx;
let manifest: CachedFullSubject; let manifest: CachedFullSubject;
try { try {
@@ -93,7 +92,7 @@ async function deleteAllBalanceKeys({
ctx: AutumnContext; ctx: AutumnContext;
customerId: string; customerId: string;
}) { }) {
const { org, env, features, logger } = ctx; const { org, env, features, logger, redisV2 } = ctx;
if (features.length === 0) return; if (features.length === 0) return;
const pipeline = redisV2.pipeline(); const pipeline = redisV2.pipeline();

View File

@@ -1,6 +1,5 @@
import type { FullSubject } from "@autumn/shared"; import type { FullSubject } from "@autumn/shared";
import { normalizedToFullSubject } from "@autumn/shared"; import { normalizedToFullSubject } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js"; import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
@@ -51,7 +50,7 @@ export const getCachedPartialFullSubject = async ({
featureIds: string[]; featureIds: string[];
source?: string; source?: string;
}): Promise<FullSubject | undefined> => { }): Promise<FullSubject | undefined> => {
const { org, env, logger } = ctx; const { org, env, logger, redisV2 } = ctx;
const subjectKey = buildFullSubjectKey({ const subjectKey = buildFullSubjectKey({
orgId: org.id, orgId: org.id,
env, env,
@@ -124,8 +123,7 @@ export const getCachedPartialFullSubject = async ({
const isCustomerSubject = !entityId; const isCustomerSubject = !entityId;
const featureBalances = await getCachedFeatureBalancesBatch({ const featureBalances = await getCachedFeatureBalancesBatch({
orgId: org.id, ctx,
env,
customerId, customerId,
featureIds: meteredFeatureIdsToFetch, featureIds: meteredFeatureIdsToFetch,
customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId, customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId,

View File

@@ -1,5 +1,4 @@
import type { NormalizedFullSubject } from "@autumn/shared"; import type { NormalizedFullSubject } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js"; import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
@@ -20,7 +19,7 @@ export const setCachedFullSubject = async ({
normalized: NormalizedFullSubject; normalized: NormalizedFullSubject;
fetchedSubjectViewEpoch: number; fetchedSubjectViewEpoch: number;
}): Promise<SetCachedFullSubjectResult> => { }): Promise<SetCachedFullSubjectResult> => {
const { logger, org, env } = ctx; const { logger, org, env, redisV2 } = ctx;
const { customerId, entityId } = normalized; const { customerId, entityId } = normalized;
const cached = normalizedToCachedFullSubject({ const cached = normalizedToCachedFullSubject({

View File

@@ -1,5 +1,4 @@
import type { Customer } from "@autumn/shared"; import type { Customer } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { logAlertEvent } from "@/utils/logging/logAlertEvent.js"; import { logAlertEvent } from "@/utils/logging/logAlertEvent.js";
@@ -21,7 +20,7 @@ export const updateCachedCustomerData = async ({
}): Promise<void> => { }): Promise<void> => {
if (Object.keys(updates).length === 0) return; if (Object.keys(updates).length === 0) return;
const { org, env, logger } = ctx; const { org, env, logger, redisV2 } = ctx;
const subjectKey = buildFullSubjectKey({ const subjectKey = buildFullSubjectKey({
orgId: org.id, orgId: org.id,
env, env,

View File

@@ -1,5 +1,4 @@
import type { InsertCustomerProduct } from "@autumn/shared"; import type { InsertCustomerProduct } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { updateCachedCustomerProduct } from "@/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.js"; import { updateCachedCustomerProduct } from "@/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
@@ -48,7 +47,7 @@ export const updateCachedCustomerProductV2 = async ({
} }
if (Object.keys(updates).length === 0) return null; if (Object.keys(updates).length === 0) return null;
const { org, env, logger } = ctx; const { org, env, logger, redisV2 } = ctx;
const subjectKey = buildFullSubjectKey({ const subjectKey = buildFullSubjectKey({
orgId: org.id, orgId: org.id,
env, env,

View File

@@ -1,5 +1,4 @@
import type { Entity } from "@autumn/shared"; import type { Entity } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { updateEntityInCache } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/updateEntityInCache.js"; import { updateEntityInCache } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/updateEntityInCache.js";
import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
@@ -37,7 +36,7 @@ export const updateCachedEntityData = async ({
); );
}); });
const { org, env, logger } = ctx; const { org, env, logger, redisV2 } = ctx;
const subjectKey = buildFullSubjectKey({ const subjectKey = buildFullSubjectKey({
orgId: org.id, orgId: org.id,
env, env,

View File

@@ -1,5 +1,4 @@
import type { Invoice } from "@autumn/shared"; import type { Invoice } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { logAlertEvent } from "@/utils/logging/logAlertEvent.js"; import { logAlertEvent } from "@/utils/logging/logAlertEvent.js";
@@ -38,7 +37,7 @@ export const upsertCachedInvoiceV2 = async ({
return null; return null;
} }
const { org, env, logger } = ctx; const { org, env, logger, redisV2 } = ctx;
const subjectKey = buildFullSubjectKey({ const subjectKey = buildFullSubjectKey({
orgId: org.id, orgId: org.id,
env, env,

View File

@@ -1,5 +1,5 @@
import type { AggregatedFeatureBalance, SubjectBalance } from "@autumn/shared"; import type { AggregatedFeatureBalance, SubjectBalance } from "@autumn/shared";
import { redisV2 } from "@/external/redis/initRedisV2.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js";
import { buildSharedFullSubjectBalanceKey } from "../builders/buildSharedFullSubjectBalanceKey.js"; import { buildSharedFullSubjectBalanceKey } from "../builders/buildSharedFullSubjectBalanceKey.js";
import { AGGREGATED_BALANCE_FIELD } from "../config/fullSubjectCacheConfig.js"; import { AGGREGATED_BALANCE_FIELD } from "../config/fullSubjectCacheConfig.js";
@@ -16,12 +16,15 @@ export type FeatureBalanceResult = {
}; };
const readFeatureBalancesFromMaster = async ({ const readFeatureBalancesFromMaster = async ({
ctx,
balanceKey, balanceKey,
customerEntitlementIds, customerEntitlementIds,
}: { }: {
ctx: AutumnContext;
balanceKey: string; balanceKey: string;
customerEntitlementIds: string[]; customerEntitlementIds: string[];
}): Promise<(string | null)[] | null> => { }): Promise<(string | null)[] | null> => {
const { redisV2 } = ctx;
const multi = redisV2.multi(); const multi = redisV2.multi();
multi.hmget(balanceKey, ...customerEntitlementIds); multi.hmget(balanceKey, ...customerEntitlementIds);
const multiResults = await multi.exec(); const multiResults = await multi.exec();
@@ -34,22 +37,21 @@ const readFeatureBalancesFromMaster = async ({
}; };
export const getCachedFeatureBalance = async ({ export const getCachedFeatureBalance = async ({
orgId, ctx,
env,
customerId, customerId,
featureId, featureId,
customerEntitlementIds, customerEntitlementIds,
readMaster = false, readMaster = false,
}: { }: {
orgId: string; ctx: AutumnContext;
env: string;
customerId: string; customerId: string;
featureId: string; featureId: string;
customerEntitlementIds: string[]; customerEntitlementIds: string[];
readMaster?: boolean; readMaster?: boolean;
}): Promise<FeatureBalanceResult | undefined> => { }): Promise<FeatureBalanceResult | undefined> => {
const { org, env, redisV2 } = ctx;
const balanceKey = buildSharedFullSubjectBalanceKey({ const balanceKey = buildSharedFullSubjectBalanceKey({
orgId, orgId: org.id,
env, env,
customerId, customerId,
featureId, featureId,
@@ -63,6 +65,7 @@ export const getCachedFeatureBalance = async ({
? await tryRedisRead( ? await tryRedisRead(
() => () =>
readFeatureBalancesFromMaster({ readFeatureBalancesFromMaster({
ctx,
balanceKey, balanceKey,
customerEntitlementIds, customerEntitlementIds,
}), }),
@@ -96,15 +99,13 @@ export const getCachedFeatureBalance = async ({
}; };
export const getCachedFeatureBalancesBatch = async ({ export const getCachedFeatureBalancesBatch = async ({
orgId, ctx,
env,
customerId, customerId,
featureIds, featureIds,
customerEntitlementIdsByFeatureId, customerEntitlementIdsByFeatureId,
includeAggregated = false, includeAggregated = false,
}: { }: {
orgId: string; ctx: AutumnContext;
env: string;
customerId: string; customerId: string;
featureIds: string[]; featureIds: string[];
customerEntitlementIdsByFeatureId: Record<string, string[]>; customerEntitlementIdsByFeatureId: Record<string, string[]>;
@@ -112,6 +113,7 @@ export const getCachedFeatureBalancesBatch = async ({
}): Promise<FeatureBalanceResult[] | undefined> => { }): Promise<FeatureBalanceResult[] | undefined> => {
if (featureIds.length === 0) return []; if (featureIds.length === 0) return [];
const { org, env, redisV2 } = ctx;
const pipeline = redisV2.pipeline(); const pipeline = redisV2.pipeline();
for (const featureId of featureIds) { for (const featureId of featureIds) {
const customerEntitlementIds = const customerEntitlementIds =
@@ -121,7 +123,7 @@ export const getCachedFeatureBalancesBatch = async ({
: customerEntitlementIds; : customerEntitlementIds;
pipeline.hmget( pipeline.hmget(
buildSharedFullSubjectBalanceKey({ buildSharedFullSubjectBalanceKey({
orgId, orgId: org.id,
env, env,
customerId, customerId,
featureId, featureId,

View File

@@ -1,5 +1,4 @@
import type { RepoContext } from "@/db/repoContext.js"; import type { RepoContext } from "@/db/repoContext.js";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js";
import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js"; import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
@@ -24,6 +23,7 @@ export const adjustSubjectBalanceCache = async ({
delta: number; delta: number;
}): Promise<AdjustSubjectBalanceCacheResult | null> => { }): Promise<AdjustSubjectBalanceCacheResult | null> => {
try { try {
const { redisV2 } = ctx;
const balanceKey = buildSharedFullSubjectBalanceKey({ const balanceKey = buildSharedFullSubjectBalanceKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,

View File

@@ -1,4 +1,3 @@
import { redisV2 } from "@/external/redis/initRedisV2.js";
import type { RepoContext } from "@/db/repoContext.js"; import type { RepoContext } from "@/db/repoContext.js";
import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js";
import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js"; import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js";
@@ -23,6 +22,7 @@ export const updateSubjectBalanceCache = async ({
next_reset_at?: number | null; next_reset_at?: number | null;
}; };
}) => { }) => {
const { redisV2 } = ctx;
const balanceKey = buildSharedFullSubjectBalanceKey({ const balanceKey = buildSharedFullSubjectBalanceKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,

View File

@@ -36,6 +36,7 @@ export const handleClearCustomerCache = createRoute({
await batchInvalidateCachedFullSubjects({ await batchInvalidateCachedFullSubjects({
customers: customersToDelete, customers: customersToDelete,
featuresByOrgEnv, featuresByOrgEnv,
redisV2: ctx.redisV2,
}); });
} }

View File

@@ -107,13 +107,7 @@ export const handleDeleteEntity = createRoute({
} }
await CusEntService.update({ await CusEntService.update({
ctx: { ctx: { ...ctx, customerId: customer_id },
db,
logger,
org,
env,
customerId: customer_id,
},
id: linkedCusEnt.id, id: linkedCusEnt.id,
updates: { updates: {
entities: newEntities, entities: newEntities,
@@ -123,13 +117,7 @@ export const handleDeleteEntity = createRoute({
if (!replaceable) { if (!replaceable) {
await CusEntService.increment({ await CusEntService.increment({
ctx: { ctx: { ...ctx, customerId: customer_id },
db,
logger,
org,
env,
customerId: customer_id,
},
id: mainCusEnt.id, id: mainCusEnt.id,
amount: 1, amount: 1,
}); });

View File

@@ -8,6 +8,7 @@ import {
} from "@autumn/shared"; } from "@autumn/shared";
import { and, asc, count, eq, gt, inArray } from "drizzle-orm"; import { and, asc, count, eq, gt, inArray } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { DrizzleCli } from "@/db/initDrizzle.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.js"; import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.js";
import { OrgService } from "@/internal/orgs/OrgService.js"; import { OrgService } from "@/internal/orgs/OrgService.js";
import type { Logger } from "../../../external/logtail/logtailUtils"; import type { Logger } from "../../../external/logtail/logtailUtils";
@@ -151,6 +152,7 @@ export const runClearCreditSystemCacheTask = async ({
const deleted = await batchInvalidateCachedFullSubjects({ const deleted = await batchInvalidateCachedFullSubjects({
customers: customersToDelete, customers: customersToDelete,
featuresByOrgEnv, featuresByOrgEnv,
redisV2: resolveRedisV2(),
}); });
totalDeleted += deleted; totalDeleted += deleted;
} }

View File

@@ -0,0 +1,10 @@
import { z } from "zod/v4";
export const RedisV2InstanceName = z.enum(["primary", "canary", "dragonfly"]);
export type RedisV2InstanceName = z.infer<typeof RedisV2InstanceName>;
export const RedisV2CacheConfigSchema = z.object({
activeInstance: RedisV2InstanceName.default("primary"),
});
export type RedisV2CacheConfig = z.infer<typeof RedisV2CacheConfigSchema>;

View File

@@ -0,0 +1,40 @@
import { ms } from "@autumn/shared";
import { ADMIN_REDIS_V2_CACHE_CONFIG_KEY } from "@/external/aws/s3/adminS3Config.js";
import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js";
import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js";
import {
type RedisV2CacheConfig,
RedisV2CacheConfigSchema,
type RedisV2InstanceName,
} from "./redisV2CacheSchemas.js";
const store = createEdgeConfigStore<RedisV2CacheConfig>({
s3Key: ADMIN_REDIS_V2_CACHE_CONFIG_KEY,
schema: RedisV2CacheConfigSchema,
defaultValue: () => ({ activeInstance: "primary" }),
pollIntervalMs: ms.seconds(10),
});
registerEdgeConfig({ store });
export const getActiveRedisV2Instance = (): RedisV2InstanceName =>
store.get().activeInstance;
export const getRedisV2CacheStatus = () => store.getStatus();
export const updateActiveRedisV2Instance = async ({
activeInstance,
}: {
activeInstance: RedisV2InstanceName;
}) => {
await store.writeToSource({ config: { activeInstance } });
};
/** Test-only: override the in-memory active instance without writing to S3. */
export const _setActiveRedisV2InstanceForTesting = ({
activeInstance,
}: {
activeInstance: RedisV2InstanceName;
}) => {
store._setRuntimeConfigForTesting({ activeInstance });
};

View File

@@ -2,6 +2,7 @@ import { type AppEnv, AuthType, createdAtToVersion } from "@autumn/shared";
import { addAppContextToLogs } from "@/utils/logging/addContextToLogs.js"; import { addAppContextToLogs } from "@/utils/logging/addContextToLogs.js";
import type { DrizzleCli } from "../db/initDrizzle.js"; import type { DrizzleCli } from "../db/initDrizzle.js";
import type { Logger } from "../external/logtail/logtailUtils.js"; import type { Logger } from "../external/logtail/logtailUtils.js";
import { resolveRedisV2 } from "../external/redis/resolveRedisV2.js";
import type { AutumnContext } from "../honoUtils/HonoEnv.js"; import type { AutumnContext } from "../honoUtils/HonoEnv.js";
import { computeRolloutSnapshot } from "../internal/misc/rollouts/rolloutUtils.js"; import { computeRolloutSnapshot } from "../internal/misc/rollouts/rolloutUtils.js";
import { OrgService } from "../internal/orgs/OrgService.js"; import { OrgService } from "../internal/orgs/OrgService.js";
@@ -39,6 +40,11 @@ export const createWorkerContext = async ({
createdAt: org.created_at ?? Date.now(), createdAt: org.created_at ?? Date.now(),
}); });
const rolloutSnapshot = computeRolloutSnapshot({
orgId: org.id,
customerId,
});
const workerLogger = addAppContextToLogs({ const workerLogger = addAppContextToLogs({
logger: logger, logger: logger,
appContext: { appContext: {
@@ -48,14 +54,15 @@ export const createWorkerContext = async ({
env: env, env: env,
auth_type: AuthType.Worker, auth_type: AuthType.Worker,
api_version: apiVersion.semver, api_version: apiVersion.semver,
full_subject_bucket: customerId
? (rolloutSnapshot.customerBucket ?? undefined)
: undefined,
full_subject_rollout_enabled: customerId
? rolloutSnapshot.enabled
: undefined,
}, },
}); });
const rolloutSnapshot = computeRolloutSnapshot({
orgId: org.id,
customerId,
});
const ctx: AutumnContext = { const ctx: AutumnContext = {
org, org,
env, env,
@@ -65,6 +72,7 @@ export const createWorkerContext = async ({
db, db,
dbGeneral: db, dbGeneral: db,
logger: workerLogger, logger: workerLogger,
redisV2: resolveRedisV2(),
id: generateId("job"), id: generateId("job"),
timestamp: Date.now(), timestamp: Date.now(),

View File

@@ -31,6 +31,8 @@ export type LogAppContext = {
user_id?: string; user_id?: string;
user_email?: string; user_email?: string;
api_version: string; api_version: string;
full_subject_bucket?: number;
full_subject_rollout_enabled?: boolean;
}; };
/** Stripe webhook event context */ /** Stripe webhook event context */

View File

@@ -8,6 +8,7 @@ import {
import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { Logger } from "@/external/logtail/logtailUtils.js"; import type { Logger } from "@/external/logtail/logtailUtils.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js"; import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js"; import { OrgService } from "@/internal/orgs/OrgService.js";
@@ -57,6 +58,7 @@ export const createWorkerAutumnContext = async ({
db, db,
dbGeneral: db, dbGeneral: db,
logger, logger,
redisV2: resolveRedisV2(),
expand: [], expand: [],
id: workerId, id: workerId,

View File

@@ -9,6 +9,7 @@ import {
} from "./internal/misc/edgeConfig/edgeConfigRegistry.js"; } from "./internal/misc/edgeConfig/edgeConfigRegistry.js";
import "./internal/misc/requestBlocks/requestBlockStore.js"; import "./internal/misc/requestBlocks/requestBlockStore.js";
import "./internal/misc/rollouts/rolloutConfigStore.js"; import "./internal/misc/rollouts/rolloutConfigStore.js";
import "./internal/misc/redisV2Cache/redisV2CacheStore.js";
// Number of worker processes (defaults to CPU cores) // Number of worker processes (defaults to CPU cores)
const NUM_PROCESSES = process.env.NODE_ENV === "development" ? 3 : 4; const NUM_PROCESSES = process.env.NODE_ENV === "development" ? 3 : 4;

View File

@@ -67,12 +67,12 @@ test.concurrent(`${chalk.yellowBright("batchDeleteCachedFullCustomers: leaves fu
featureId: TestFeature.Messages, featureId: TestFeature.Messages,
}); });
expect(await redisV2.exists(primarySubjectKey)).toBe(1); expect(await ctx.redisV2.exists(primarySubjectKey)).toBe(1);
expect(await redisV2.exists(otherSubjectKey)).toBe(1); expect(await ctx.redisV2.exists(otherSubjectKey)).toBe(1);
expect(await redisV2.exists(sharedBalanceKey)).toBe(1); expect(await ctx.redisV2.exists(sharedBalanceKey)).toBe(1);
expect(await redisV2.exists(otherSharedBalanceKey)).toBe(1); expect(await ctx.redisV2.exists(otherSharedBalanceKey)).toBe(1);
await redisV2.unlink(otherSubjectKey); await ctx.redisV2.unlink(otherSubjectKey);
await batchDeleteCachedFullCustomers({ await batchDeleteCachedFullCustomers({
customers: [ customers: [
@@ -81,10 +81,10 @@ test.concurrent(`${chalk.yellowBright("batchDeleteCachedFullCustomers: leaves fu
], ],
}); });
expect(await redisV2.exists(primarySubjectKey)).toBe(1); expect(await ctx.redisV2.exists(primarySubjectKey)).toBe(1);
expect(await redisV2.exists(otherSubjectKey)).toBe(0); expect(await ctx.redisV2.exists(otherSubjectKey)).toBe(0);
expect(await redisV2.exists(sharedBalanceKey)).toBe(1); expect(await ctx.redisV2.exists(sharedBalanceKey)).toBe(1);
expect(await redisV2.exists(otherSharedBalanceKey)).toBe(1); expect(await ctx.redisV2.exists(otherSharedBalanceKey)).toBe(1);
const primaryFromDb = await autumnV2.customers.get<ApiCustomer>(customerId, { const primaryFromDb = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true", skip_cache: "true",

View File

@@ -54,6 +54,7 @@ export const resetAndGetCusEnt = async ({
customerId: customer.id ?? "", customerId: customer.id ?? "",
featureId, featureId,
customerEntitlementId: resetCusEnt.id, customerEntitlementId: resetCusEnt.id,
redisV2: ctx.redisV2,
}); });
await clearCusEntsFromCache({ await clearCusEntsFromCache({

View File

@@ -11,7 +11,6 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk"; import chalk from "chalk";
import { addSeconds } from "date-fns"; import { addSeconds } from "date-fns";
import { redis } from "@/external/redis/initRedis"; import { redis } from "@/external/redis/initRedis";
import { redisV2 } from "@/external/redis/initRedisV2";
import { expireLock } from "@/internal/balances/finalizeLock/expireLock"; import { expireLock } from "@/internal/balances/finalizeLock/expireLock";
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey"; import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey";
import { fetchLockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt"; import { fetchLockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt";
@@ -203,7 +202,7 @@ test.concurrent(`${chalk.yellowBright("check-lock-expiry-4: no expires_at sets T
}); });
const { lockReceiptKey, source } = await fetchLockReceipt({ ctx, lockId: customerId }); const { lockReceiptKey, source } = await fetchLockReceipt({ ctx, lockId: customerId });
const redisInstance = source === "redis_v2" ? redisV2 : redis; const redisInstance = source === "redis_v2" ? ctx.redisV2 : redis;
const expireAt = await redisInstance.expiretime(lockReceiptKey); const expireAt = await redisInstance.expiretime(lockReceiptKey);
const expectedTtl = beforeCheck + 24 * 60 * 60; const expectedTtl = beforeCheck + 24 * 60 * 60;
@@ -240,7 +239,7 @@ test.concurrent(`${chalk.yellowBright("check-lock-expiry-5: expires_at set, TTL
}); });
const { lockReceiptKey, source } = await fetchLockReceipt({ ctx, lockId: customerId }); const { lockReceiptKey, source } = await fetchLockReceipt({ ctx, lockId: customerId });
const redisInstance = source === "redis_v2" ? redisV2 : redis; const redisInstance = source === "redis_v2" ? ctx.redisV2 : redis;
const expireAt = await redisInstance.expiretime(lockReceiptKey); const expireAt = await redisInstance.expiretime(lockReceiptKey);
const expectedTtl = Math.ceil(expiresAt / 1000) + 60 * 60; const expectedTtl = Math.ceil(expiresAt / 1000) + 60 * 60;

View File

@@ -156,6 +156,7 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (cache): lazy reset de
customerEntitlementId: cusEnt!.id, customerEntitlementId: cusEnt!.id,
field: "balance", field: "balance",
value: -50, value: -50,
redisV2: ctx.redisV2,
}); });
await expireCusEntForReset({ await expireCusEntForReset({
ctx, ctx,

View File

@@ -1,6 +1,5 @@
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { redis } from "@/external/redis/initRedis.js"; import { redis } from "@/external/redis/initRedis.js";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js"; import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
export const deleteLock = async ({ export const deleteLock = async ({
@@ -17,5 +16,8 @@ export const deleteLock = async ({
lockKey: hashedKey, lockKey: hashedKey,
}); });
await Promise.all([redis.del(redisReceiptKey), redisV2.del(redisReceiptKey)]); await Promise.all([
redis.del(redisReceiptKey),
ctx.redisV2.del(redisReceiptKey),
]);
}; };

View File

@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk"; import chalk from "chalk";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import { import {
buildFullSubjectKey, buildFullSubjectKey,
getCachedFullSubject, getCachedFullSubject,
@@ -78,7 +77,7 @@ describe(`${chalk.yellowBright("fullSubject cache rollout staleness")}`, () => {
expect(cached).toBeUndefined(); expect(cached).toBeUndefined();
const subjectExists = await redisV2.get( const subjectExists = await ctx.redisV2.get(
buildFullSubjectKey({ buildFullSubjectKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,

View File

@@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from "bun:test";
import { normalizedToFullSubject } from "@autumn/shared"; import { normalizedToFullSubject } from "@autumn/shared";
import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk"; import chalk from "chalk";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import { getOrInitFullSubjectViewEpoch } from "@/internal/customers/cache/fullSubject/actions/invalidate/getOrInitFullSubjectViewEpoch.js"; import { getOrInitFullSubjectViewEpoch } from "@/internal/customers/cache/fullSubject/actions/invalidate/getOrInitFullSubjectViewEpoch.js";
import type { CachedFullSubject } from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js"; import type { CachedFullSubject } from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js";
import { import {
@@ -36,7 +35,7 @@ const cleanupKeys = async ({
customerId, customerId,
entityId, entityId,
}); });
const subjectRaw = (await redisV2.get(subjectKey)) as string | null; const subjectRaw = (await ctx.redisV2.get(subjectKey)) as string | null;
const keys = [ const keys = [
subjectKey, subjectKey,
buildFullSubjectViewEpochKey({ buildFullSubjectViewEpochKey({
@@ -60,7 +59,7 @@ const cleanupKeys = async ({
} }
} }
await redisV2.del(...keys); await ctx.redisV2.del(...keys);
}; };
afterEach(async () => {}); afterEach(async () => {});
@@ -78,7 +77,7 @@ const getSharedBalanceHash = async ({
customerId: string; customerId: string;
featureId: string; featureId: string;
}) => }) =>
redisV2.hgetall( ctx.redisV2.hgetall(
buildSharedFullSubjectBalanceKey({ buildSharedFullSubjectBalanceKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,
@@ -113,7 +112,7 @@ describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => {
}); });
expect(result).toBe("OK"); expect(result).toBe("OK");
const subjectRaw = await redisV2.get( const subjectRaw = await ctx.redisV2.get(
buildFullSubjectKey({ buildFullSubjectKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,
@@ -172,7 +171,7 @@ describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => {
}), }),
}); });
expect(result).toBe("OK"); expect(result).toBe("OK");
const subjectRaw = await redisV2.get( const subjectRaw = await ctx.redisV2.get(
buildFullSubjectKey({ buildFullSubjectKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,
@@ -265,7 +264,7 @@ describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => {
expect(Object.keys(hashBeforeEntityWrite).length).toBeGreaterThan(0); expect(Object.keys(hashBeforeEntityWrite).length).toBeGreaterThan(0);
expect( expect(
await redisV2.exists( await ctx.redisV2.exists(
`{${customerId}}:${ctx.org.id}:${ctx.env}:full_subject:shared_balances`, `{${customerId}}:${ctx.org.id}:${ctx.env}:full_subject:shared_balances`,
), ),
).toBe(0); ).toBe(0);
@@ -296,7 +295,7 @@ describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => {
} }
expect( expect(
await redisV2.exists( await ctx.redisV2.exists(
`{${customerId}}:${ctx.org.id}:${ctx.env}:full_subject:shared_balances`, `{${customerId}}:${ctx.org.id}:${ctx.env}:full_subject:shared_balances`,
), ),
).toBe(0); ).toBe(0);
@@ -334,7 +333,7 @@ describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => {
}); });
expect(result).toBe("OK"); expect(result).toBe("OK");
await redisV2.incr( await ctx.redisV2.incr(
buildFullSubjectViewEpochKey({ buildFullSubjectViewEpochKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,
@@ -396,10 +395,10 @@ describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => {
env: ctx.env, env: ctx.env,
customerId: scenario.ids.customerId, customerId: scenario.ids.customerId,
}); });
const subjectRaw = (await redisV2.get(subjectKey)) as string | null; const subjectRaw = (await ctx.redisV2.get(subjectKey)) as string | null;
const subject = JSON.parse(subjectRaw!) as CachedFullSubject; const subject = JSON.parse(subjectRaw!) as CachedFullSubject;
await redisV2.del( await ctx.redisV2.del(
buildSharedFullSubjectBalanceKey({ buildSharedFullSubjectBalanceKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,
@@ -444,7 +443,7 @@ describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => {
}); });
expect(result).toBe("OK"); expect(result).toBe("OK");
await redisV2.del( await ctx.redisV2.del(
buildFullSubjectKey({ buildFullSubjectKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,
@@ -534,7 +533,7 @@ describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => {
env: ctx.env, env: ctx.env,
customerId: scenario.ids.customerId, customerId: scenario.ids.customerId,
}); });
const firstSubjectRaw = (await redisV2.get(subjectKey)) as const firstSubjectRaw = (await ctx.redisV2.get(subjectKey)) as
| string | string
| null; | null;
expect(firstSubjectRaw).toBeDefined(); expect(firstSubjectRaw).toBeDefined();
@@ -548,7 +547,7 @@ describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => {
}); });
expect(secondResult).toBe("CACHE_EXISTS"); expect(secondResult).toBe("CACHE_EXISTS");
const secondSubjectRaw = (await redisV2.get(subjectKey)) as const secondSubjectRaw = (await ctx.redisV2.get(subjectKey)) as
| string | string
| null; | null;
expect(secondSubjectRaw).toEqual(firstSubjectRaw); expect(secondSubjectRaw).toEqual(firstSubjectRaw);

View File

@@ -2,7 +2,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { Hono } from "hono"; import { Hono } from "hono";
import { redis } from "@/external/redis/initRedis.js"; import { redis } from "@/external/redis/initRedis.js";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import { baseMiddleware } from "@/honoMiddlewares/baseMiddleware.js"; import { baseMiddleware } from "@/honoMiddlewares/baseMiddleware.js";
import { import {
REFRESH_CACHE_ROUTE_CONFIGS, REFRESH_CACHE_ROUTE_CONFIGS,
@@ -174,9 +173,9 @@ describeDb("refreshCacheMiddleware routes", () => {
}); });
afterAll(async () => { afterAll(async () => {
const customerKeys = await redisV2.keys(`{${scenario.ids.customerId}}:*`); const customerKeys = await ctx.redisV2.keys(`{${scenario.ids.customerId}}:*`);
if (customerKeys.length > 0) { if (customerKeys.length > 0) {
await redisV2.unlink(...customerKeys); await ctx.redisV2.unlink(...customerKeys);
} }
const oldCacheKey = buildFullCustomerCacheKey({ const oldCacheKey = buildFullCustomerCacheKey({
orgId: ctx.org.id, orgId: ctx.org.id,
@@ -242,8 +241,8 @@ describeDb("refreshCacheMiddleware routes", () => {
}); });
expect(await redis.exists(oldCacheKey)).toBe(0); expect(await redis.exists(oldCacheKey)).toBe(0);
expect(await redisV2.exists(customerSubjectKey)).toBe(0); expect(await ctx.redisV2.exists(customerSubjectKey)).toBe(0);
expect(await redisV2.get(epochKey)).toBe("1"); expect(await ctx.redisV2.get(epochKey)).toBe("1");
if (touchedEntityId) { if (touchedEntityId) {
const touchedEntityKey = buildFullSubjectKey({ const touchedEntityKey = buildFullSubjectKey({
@@ -252,17 +251,17 @@ describeDb("refreshCacheMiddleware routes", () => {
customerId: scenario.ids.customerId, customerId: scenario.ids.customerId,
entityId: touchedEntityId, entityId: touchedEntityId,
}); });
expect(await redisV2.exists(touchedEntityKey)).toBe(0); expect(await ctx.redisV2.exists(touchedEntityKey)).toBe(0);
const untouchedEntityKey = const untouchedEntityKey =
touchedEntityId === scenario.ids.entityIds[0] touchedEntityId === scenario.ids.entityIds[0]
? entityBSubjectKey ? entityBSubjectKey
: entityASubjectKey; : entityASubjectKey;
expect(await redisV2.exists(untouchedEntityKey)).toBe(1); expect(await ctx.redisV2.exists(untouchedEntityKey)).toBe(1);
return; return;
} }
expect(await redisV2.exists(entityASubjectKey)).toBe(1); expect(await ctx.redisV2.exists(entityASubjectKey)).toBe(1);
expect(await redisV2.exists(entityBSubjectKey)).toBe(1); expect(await ctx.redisV2.exists(entityBSubjectKey)).toBe(1);
}); });
}); });

View File

@@ -191,7 +191,8 @@ const buildCtx = (): AutumnContext =>
expand: [], expand: [],
skipCache: false, skipCache: false,
extraLogs: {}, extraLogs: {},
}) as AutumnContext; redisV2: {} as never,
}) as unknown as AutumnContext;
const buildFeatureDeduction = (): FeatureDeduction => const buildFeatureDeduction = (): FeatureDeduction =>
({ ({

View File

@@ -7,7 +7,6 @@ import {
test, test,
} from "bun:test"; } from "bun:test";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import { import {
buildFullSubjectKey, buildFullSubjectKey,
buildFullSubjectViewEpochKey, buildFullSubjectViewEpochKey,
@@ -58,9 +57,9 @@ describeDb("invalidateCachedFullSubject", () => {
}); });
afterEach(async () => { afterEach(async () => {
const customerKeys = await redisV2.keys(`{${scenario.ids.customerId}}:*`); const customerKeys = await ctx.redisV2.keys(`{${scenario.ids.customerId}}:*`);
if (customerKeys.length > 0) { if (customerKeys.length > 0) {
await redisV2.unlink(...customerKeys); await ctx.redisV2.unlink(...customerKeys);
} }
await cleanupFullSubjectScenario({ ctx, scenario }); await cleanupFullSubjectScenario({ ctx, scenario });
@@ -91,11 +90,11 @@ describeDb("invalidateCachedFullSubject", () => {
source: "invalidateCachedFullSubjectTest", source: "invalidateCachedFullSubjectTest",
}); });
expect(await redisV2.exists(customerKey)).toBe(0); expect(await ctx.redisV2.exists(customerKey)).toBe(0);
expect(await redisV2.exists(entityAKey)).toBe(0); expect(await ctx.redisV2.exists(entityAKey)).toBe(0);
expect(await redisV2.exists(entityBKey)).toBe(1); expect(await ctx.redisV2.exists(entityBKey)).toBe(1);
expect( expect(
await redisV2.get( await ctx.redisV2.get(
buildFullSubjectViewEpochKey({ buildFullSubjectViewEpochKey({
orgId: ctx.org.id, orgId: ctx.org.id,
env: ctx.env, env: ctx.env,
@@ -130,9 +129,9 @@ describeDb("invalidateCachedFullSubject", () => {
source: "invalidateCachedFullSubjectTest", source: "invalidateCachedFullSubjectTest",
}); });
expect(await redisV2.exists(entityAKey)).toBe(1); expect(await ctx.redisV2.exists(entityAKey)).toBe(1);
expect(await redisV2.exists(entityBKey)).toBe(1); expect(await ctx.redisV2.exists(entityBKey)).toBe(1);
expect(await redisV2.get(epochKey)).toBe("1"); expect(await ctx.redisV2.get(epochKey)).toBe("1");
}); });
test("sibling entity cache becomes stale after direct entity invalidation", async () => { test("sibling entity cache becomes stale after direct entity invalidation", async () => {
@@ -150,7 +149,7 @@ describeDb("invalidateCachedFullSubject", () => {
source: "invalidateCachedFullSubjectTest", source: "invalidateCachedFullSubjectTest",
}); });
expect(await redisV2.exists(entityBKey)).toBe(1); expect(await ctx.redisV2.exists(entityBKey)).toBe(1);
const cachedEntityB = await getCachedFullSubject({ const cachedEntityB = await getCachedFullSubject({
ctx, ctx,

View File

@@ -6,8 +6,8 @@ import {
import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Redis } from "ioredis";
import { redis } from "@/external/redis/initRedis.js"; import { redis } from "@/external/redis/initRedis.js";
import { redisV2 } from "@/external/redis/initRedisV2.js";
import { CusService } from "@/internal/customers/CusService.js"; import { CusService } from "@/internal/customers/CusService.js";
import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js";
import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js";
@@ -77,6 +77,7 @@ export const setCachedSubjectBalanceField = async ({
customerEntitlementId, customerEntitlementId,
field, field,
value, value,
redisV2,
}: { }: {
orgId: string; orgId: string;
env: string; env: string;
@@ -85,6 +86,7 @@ export const setCachedSubjectBalanceField = async ({
customerEntitlementId: string; customerEntitlementId: string;
field: string; field: string;
value: number | string | null; value: number | string | null;
redisV2: Redis;
}): Promise<void> => { }): Promise<void> => {
const balanceKey = buildSharedFullSubjectBalanceKey({ const balanceKey = buildSharedFullSubjectBalanceKey({
orgId, orgId,
@@ -160,6 +162,7 @@ export const expireCusEntForReset = async ({
customerEntitlementId: cusEnt.id, customerEntitlementId: cusEnt.id,
field: "next_reset_at", field: "next_reset_at",
value: pastTime, value: pastTime,
redisV2: ctx.redisV2,
}); });
return cusEnt; return cusEnt;
@@ -221,6 +224,7 @@ export const expireAllCusEntsForReset = async ({
customerEntitlementId: cusEnt.id, customerEntitlementId: cusEnt.id,
field: "next_reset_at", field: "next_reset_at",
value: pastTime, value: pastTime,
redisV2: ctx.redisV2,
}); });
} }

View File

@@ -13,6 +13,7 @@ import {
import type Stripe from "stripe"; import type Stripe from "stripe";
import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { FeatureService } from "@/internal/features/FeatureService.js"; import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js"; import { OrgService } from "@/internal/orgs/OrgService.js";
import { import {
@@ -73,6 +74,7 @@ export const createTestContext = async () => {
dbGeneral: db, dbGeneral: db,
features, features,
logger, logger,
redisV2: resolveRedisV2(),
orgSecretKey, orgSecretKey,
id: generateId("test"), id: generateId("test"),

View File

@@ -5,6 +5,7 @@ import { EdgeConfigDialog } from "./EdgeConfigDialog";
import { FeatureFlagsDialog } from "./FeatureFlagsDialog"; import { FeatureFlagsDialog } from "./FeatureFlagsDialog";
import { OrgLimitsDialog } from "./OrgLimitsDialog"; import { OrgLimitsDialog } from "./OrgLimitsDialog";
import { RawEdgeConfigDialog } from "./RawEdgeConfigDialog"; import { RawEdgeConfigDialog } from "./RawEdgeConfigDialog";
import { RedisV2CacheDialog } from "./RedisV2CacheDialog";
import { StripeSyncDialog } from "./StripeSyncDialog"; import { StripeSyncDialog } from "./StripeSyncDialog";
export function EdgeConfigTab() { export function EdgeConfigTab() {
@@ -14,6 +15,7 @@ export function EdgeConfigTab() {
const [customerBlockOpen, setCustomerBlockOpen] = useState(false); const [customerBlockOpen, setCustomerBlockOpen] = useState(false);
const [orgLimitsOpen, setOrgLimitsOpen] = useState(false); const [orgLimitsOpen, setOrgLimitsOpen] = useState(false);
const [stripeSyncOpen, setStripeSyncOpen] = useState(false); const [stripeSyncOpen, setStripeSyncOpen] = useState(false);
const [redisV2CacheOpen, setRedisV2CacheOpen] = useState(false);
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -108,6 +110,23 @@ export function EdgeConfigTab() {
Edit Edit
</Button> </Button>
</div> </div>
<div className="flex items-center justify-between border-t border-border p-4 last:border-b-0">
<div className="flex flex-col gap-0.5">
<div className="text-sm font-medium text-t1">V2 Redis Instance</div>
<div className="text-xs text-t3">
Switch the active V2 Redis between primary, canary, and dragonfly
at runtime.
</div>
</div>
<Button
variant="primary"
size="sm"
onClick={() => setRedisV2CacheOpen(true)}
>
Edit
</Button>
</div>
</div> </div>
<FeatureFlagsDialog <FeatureFlagsDialog
@@ -139,6 +158,11 @@ export function EdgeConfigTab() {
open={stripeSyncOpen} open={stripeSyncOpen}
onOpenChange={setStripeSyncOpen} onOpenChange={setStripeSyncOpen}
/> />
<RedisV2CacheDialog
open={redisV2CacheOpen}
onOpenChange={setRedisV2CacheOpen}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,214 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/v2/badges/Badge";
import { Button } from "@/components/v2/buttons/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/v2/dialogs/Dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
const INSTANCE_OPTIONS = [
{
value: "primary",
label: "Primary",
description: "CACHE_V2_URL (or CACHE_URL fallback)",
},
{
value: "canary",
label: "Canary",
description: "CACHE_V2_CANARY_URL",
},
{
value: "dragonfly",
label: "Dragonfly",
description: "CACHE_V2_DRAGONFLY_URL",
},
] as const;
type InstanceName = (typeof INSTANCE_OPTIONS)[number]["value"];
type RedisV2CacheConfig = {
activeInstance: InstanceName;
configHealthy: boolean;
configConfigured: boolean;
lastSuccessAt: string | null;
error: string | null;
};
const DEFAULT_CONFIG: RedisV2CacheConfig = {
activeInstance: "primary",
configHealthy: false,
configConfigured: false,
lastSuccessAt: null,
error: null,
};
export function RedisV2CacheDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const axiosInstance = useAxiosInstance();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [config, setConfig] = useState<RedisV2CacheConfig>(DEFAULT_CONFIG);
const [selected, setSelected] = useState<InstanceName>("primary");
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
void axiosInstance
.get<RedisV2CacheConfig>("/admin/redis-v2-cache-config")
.then(({ data }) => {
if (cancelled) return;
const merged = { ...DEFAULT_CONFIG, ...data };
setConfig(merged);
setSelected(merged.activeInstance);
})
.catch((error) => {
if (!cancelled) {
toast.error(
getBackendErr(error, "Failed to load redis v2 cache config"),
);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [axiosInstance, open]);
const handleSave = async () => {
setSaving(true);
try {
await axiosInstance.put("/admin/redis-v2-cache-config", {
activeInstance: selected,
});
toast.success(`Active V2 Redis set to "${selected}"`);
onOpenChange(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to save redis v2 cache config"));
} finally {
setSaving(false);
}
};
const dirty = selected !== config.activeInstance;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl bg-card">
<DialogHeader>
<DialogTitle>V2 Redis Instance</DialogTitle>
<DialogDescription>
Switch the active Redis instance used for V2 cache reads and writes.
Change propagates to all servers, workers, and cron jobs within ~10
seconds.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-8 text-center text-sm text-t3">Loading...</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="text-xs font-medium uppercase tracking-wide text-t3">
Active Instance
</div>
<Select
value={selected}
onValueChange={(value) => setSelected(value as InstanceName)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INSTANCE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col">
<span className="text-sm text-t1">{option.label}</span>
<span className="text-xs text-t3">
{option.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-t3">
Currently active:{" "}
<span className="font-mono text-t1">
{config.activeInstance}
</span>
</div>
</div>
<div className="rounded-lg border border-border p-3 text-xs text-t3">
<div className="mb-2 flex items-center gap-2">
<Badge
variant="muted"
className={
config.configHealthy
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700"
}
>
{config.configHealthy
? "Config healthy"
: "Config unavailable"}
</Badge>
{config.lastSuccessAt && (
<span>
Last refresh:{" "}
{new Date(config.lastSuccessAt).toLocaleString()}
</span>
)}
</div>
<div>
{config.configConfigured === false
? "S3 redis v2 cache config is not configured."
: config.error ||
"Instance switches propagate within ~10 seconds."}
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
isLoading={saving}
disabled={loading || !dirty}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}