From de77f9265a154a4f4705df692f003b68802af3a9 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 18 Jun 2026 02:12:03 -0700 Subject: [PATCH] refactor: unify cache and integration flows --- packages/sdk/src/lib/sdks.ts | 5 +- server/experiments/benchmarkV2CacheFlows.ts | 6 +- server/experiments/benchmarkV2CacheRedis.ts | 6 +- .../resetCron/resetCustomerEntitlement.ts | 25 +- server/src/cron/runCronTick.ts | 2 + server/src/cron/utils/CronContext.ts | 2 + server/src/db/repoContext.ts | 2 + server/src/db/shed503OnTransientError.ts | 5 +- server/src/external/autumn/autumnCli.ts | 32 +- server/src/external/autumn/autumnRpcCli.ts | 4 +- server/src/external/redis/redisLockStore.ts | 13 +- .../redis/registerRedisRequestAdapters.ts | 5 + .../redis/requestRedisFailOpenAvailability.ts | 13 + .../external/redis/resolveRequestLockStore.ts | 16 +- .../external/redis/utils/withRedisFailOpen.ts | 4 +- .../handlers/handleGetRevenuecatProducts.ts | 7 +- .../handlers/handleGetRevenuecatProjects.ts | 14 +- .../handlers/handlePreflightRevenueCatSync.ts | 7 +- .../misc/getRevenuecatAccessToken.ts | 24 +- .../revenueCat/sync/syncRevenueCatProducts.ts | 15 +- .../stripe/handleStripeWebhookEvent.ts | 4 +- server/src/external/stripe/stripeCusUtils.ts | 8 +- .../external/stripe/stripeOnboardingUtils.ts | 3 +- .../setupInvoiceCreatedContext.ts | 2 +- .../setupInvoiceFinalizedContext.ts | 2 +- .../tasks/processVercelInvoice.ts | 7 + .../setupStripeInvoicePaidContext.ts | 2 +- .../expireAndActivateCustomerProducts.ts | 2 +- .../expireEndedCustomerProducts.ts | 2 +- .../releaseScheduleIfLastPhase.ts | 4 +- .../stripeConnectSeederMiddleware.ts | 21 +- .../stripeLegacySeederMiddleware.ts | 25 +- .../migrationItemEventsDataSource.ts | 2 +- server/src/external/tinybird/tinybirdUtils.ts | 7 +- .../installations/handleDeleteInstallation.ts | 2 +- .../installations/handleUpsertInstallation.ts | 1 + .../resources/handleCreateResource.ts | 2 +- .../resources/handleDeleteResource.ts | 2 +- .../resources/handleRotateResourceSecret.ts | 2 +- server/src/external/vercel/misc/vercelAuth.ts | 22 +- .../external/vercel/misc/vercelInvoicing.ts | 8 +- .../external/vercel/vercelTestApiRouter.ts | 13 + .../external/vercel/vercelWebhookRouter.ts | 2 +- server/src/init.ts | 3 - server/src/instrumentation.ts | 2 +- .../internal/admin/handleAdminCacheV2Ramp.ts | 2 +- .../admin/handleGetMasterStripeAccount.ts | 2 +- .../handlers/handleProductsUpdated.ts | 4 + .../auth/oauth/handleOAuthConsentWithEnv.ts | 4 +- .../auth/oauth/handleOAuthTokenWithApiKey.ts | 4 +- server/src/internal/auth/oauth/oauthRouter.ts | 20 +- .../balances/events/EventBatchingManager.ts | 1 + .../finalizeLock/insertFinalizeLockEvent.ts | 2 +- .../finalizeLock/insertFinalizeLockEventV2.ts | 2 +- .../balances/finalizeLock/runFinalizeLock.ts | 4 +- .../finalizeLock/runFinalizeLockV2.ts | 4 +- .../finalizeLock/runRedisFinalizeLock.ts | 6 +- .../finalizeLock/runRedisFinalizeLockV2.ts | 6 +- .../internal/balances/track/runAsyncTrack.ts | 4 +- .../internal/balances/track/runBatchTrack.ts | 4 +- .../src/internal/balances/track/runTrackV2.ts | 24 +- .../balances/track/utils/queueTrack.ts | 6 +- .../balances/track/utils/runPostgresTrack.ts | 4 +- .../balances/track/utils/runRedisTrack.ts | 11 +- .../balances/track/v3/runPostgresTrackV3.ts | 12 +- .../balances/track/v3/runRedisTrackV3.ts | 5 +- .../internal/balances/track/v3/runTrackV3.ts | 9 +- .../updateBalance/runRedisUpdateBalanceV2.ts | 75 +--- .../updateBalance/v2/updateRemainingV2.ts | 65 +--- .../updateBalance/v2/updateUsageV2.ts | 65 +--- .../utils/deduction/computeCreditCosts.ts | 6 +- .../utils/deduction/executeDeductionCache.ts | 30 +- .../deduction/executePostgresDeduction.ts | 5 + .../utils/deduction/executeRedisDeduction.ts | 17 +- .../deduction/prepareFeatureDeduction.ts | 6 +- .../deductionV2/executePostgresDeductionV2.ts | 5 + .../deductionV2/executeRedisDeductionV2.ts | 7 +- .../deductionV2/prepareFeatureDeductionV2.ts | 1 + .../syncDeductionUpdatesToFullSubjectCache.ts | 18 +- .../balances/utils/lock/saveLockReceipt.ts | 13 +- .../utils/lockV2/saveLockReceiptV2.ts | 11 +- .../refreshEntityAggregateCache.ts | 17 +- .../utils/sync/SyncBatchingManagerV2.ts | 5 +- .../utils/sync/SyncBatchingManagerV3.ts | 5 +- .../balances/utils/sync/syncItemV3.ts | 6 +- .../evaluateStripeBillingPlan.ts | 1 + .../middleware/checkoutMiddleware.ts | 17 +- .../executeAutumnCreateCustomerPlan.ts | 7 +- .../executeResetCache.ts | 52 +-- .../resetSubjectCache.ts | 108 +----- .../rollUsageWindowsCache.ts | 44 +-- .../add-product/createFullCusProduct.ts | 2 +- .../customers/cache/cacheRoutingStaleness.ts | 24 ++ .../fullSubjectSnapshotCacheStoreActions.ts | 6 +- .../actions/getOrCreateCachedFullSubject.ts | 8 +- .../actions/getOrSetCachedFullSubject.ts | 8 +- .../batchInvalidateCachedFullSubjects.ts | 153 ++------ .../getOrInitFullSubjectViewEpoch.ts | 36 +- .../incrementFullSubjectViewEpoch.ts | 19 +- .../invalidateCustomerEntitlementBalance.ts | 60 ++- .../getOrCreateCachedPartialFullSubject.ts | 4 +- .../getOrSetCachedPartialFullSubject.ts | 8 +- .../actions/rehydrateWithLiveBalances.ts | 7 +- .../setCachedFullSubject.ts | 88 +---- .../actions/updateCachedCustomerData.ts | 132 +------ .../actions/updateCachedCustomerProduct.ts | 96 +---- .../actions/updateCachedEntityData.ts | 152 +------- .../actions/upsertCachedInvoiceV2.ts | 72 +--- .../balances/getCachedFeatureBalances.ts | 225 +----------- .../cache/fullSubject/tryOrInvalidate.ts | 6 +- .../cache/updateCachedCustomerProduct.ts | 56 +-- .../cusEnts/CusEntitlementService.ts | 45 +-- .../cache/adjustSubjectBalanceCache.ts | 53 +-- .../cache/incrementCachedCusEntBalance.ts | 53 +-- .../cache/updateSubjectBalanceCache.ts | 44 +-- .../cusEnts/actions/updateCusEntDbAndCache.ts | 65 +--- .../fullCustomerCacheStoreActions.ts | 10 +- .../invalidateCustomerCacheStoreSnapshots.ts | 66 ++++ .../testFullCustomerCacheGuard.ts | 48 +-- .../handlers/handleClearCustomerCache.ts | 14 +- .../internal/dev/handlers/handleCliStripe.ts | 2 + .../dev/handlers/handleCreateSecretKey.ts | 1 + .../internal/dev/handlers/handleGetDevData.ts | 2 +- .../runClearCreditSystemCacheTask.ts | 16 +- .../features/featureActions/updateFeature.ts | 4 + .../src/internal/invoices/InvoiceService.ts | 4 +- server/src/internal/invoices/invoiceRouter.ts | 22 +- .../listMigrationItemEvents.ts | 4 +- .../misc/cacheV2Ramp/cacheV2RampClient.ts | 2 +- .../misc/idempotency/redisIdempotencyStore.ts | 13 +- .../resolveRequestIdempotencyStore.ts | 4 +- .../handlers/handleSetupPreviewOrg.ts | 2 +- .../misc/rateLimiter/rateLimitRedisStore.ts | 22 +- .../misc/rollouts/fullSubjectRolloutUtils.ts | 3 +- server/src/internal/misc/trmnl/trmnlRouter.ts | 18 +- .../src/internal/orgs/deleteOrg/deleteOrg.ts | 2 +- .../orgs/deleteOrg/deleteOrgSvixApps.ts | 11 +- .../orgs/deleteOrg/deletePlatformSubOrg.ts | 2 +- .../handlers/crudHandlers/handleDeleteOrg.ts | 11 +- .../orgs/handlers/handleRedisConfig.ts | 2 +- .../handlers/handleResetDefaultAccount.ts | 3 +- .../orgs/handlers/handleRevenueCatConfig.ts | 46 ++- .../orgs/handlers/handleVercelConfig.ts | 6 +- .../handleGetRevenueCatOAuthUrl.ts | 1 + .../handleRevenueCatOAuthCallback.ts | 35 +- .../handleRevenueCatWebhook.ts | 23 +- .../stripeHandlers/handleOAuthCallback.ts | 10 +- server/src/internal/orgs/orgUtils.ts | 8 +- .../orgs/orgUtils/createConnectAccount.ts | 6 +- .../internal/orgs/orgUtils/deleteOrgUtils.ts | 11 +- .../orgs/orgUtils/handleStripeSecretKey.ts | 2 +- .../handlers/handleCreatePlatformOrg.ts | 2 +- .../handlers/handleGetRevenueCatKeys.ts | 8 +- .../handlers/handleLegacyPlatformExchange.ts | 7 +- .../handlers/handleLinkRevenueCat.ts | 1 + .../internal/product/actions/createProduct.ts | 4 + .../internal/product/actions/updateProduct.ts | 5 + .../src/internal/products/ProductService.ts | 1 - .../handleCreateProduct/handleCreatePlan.ts | 5 + .../handlers/handleMigrateProductV2.ts | 4 + .../handleUpdatePlan/handleUpdatePlanV1.ts | 5 + .../products/handlers/handleVersionProduct.ts | 4 + .../hatchetWorkflows/createWorkflowTask.ts | 6 +- server/src/queue/processMessage.ts | 11 +- server/src/queue/queueUtils.ts | 7 +- .../trigger/cache/warmFullSubjectCacheTask.ts | 9 +- .../migrations/runMigrationCustomerTask.ts | 9 - .../trigger/migrations/runMigrationTask.ts | 13 - server/src/utils/authUtils/afterOrgCreated.ts | 17 +- server/src/utils/cacheUtils/cacheUtils.ts | 18 +- server/src/utils/scriptUtils/scriptUtils.ts | 6 +- .../revenuecat-expiration-no-entities.test.ts | 5 +- .../subscriptionCreatedTestUtils.ts | 5 +- .../revenuecat-cross-processor-oneoff.test.ts | 5 +- .../revenuecat/revenuecat-webhooks.test.ts | 5 +- .../revenuecat/revenuecat.test.ts | 5 +- .../revenuecat/revenuecatWebhooks.test.ts | 5 +- .../tests/unit/auth/atmnOAuthClients.test.ts | 4 + .../check-v2/runCheckWithRollout.test.ts | 7 +- .../balances/compute-credit-costs.test.ts | 4 + .../execute-deduction-cache-wiring.test.ts | 22 ++ .../finalizeLock/runFinalizeLock.test.ts | 18 +- ...resh-entity-aggregate-cache-wiring.test.ts | 28 ++ .../track-v3/runTrackV3Idempotency.test.ts | 46 ++- .../track/handle-track-queue-fallback.test.ts | 7 +- ...st-full-customer-cache-guard-store.test.ts | 25 +- ...t-full-customer-cache-guard-wiring.test.ts | 11 +- ...che-store-adapters-no-redis-wiring.test.ts | 26 ++ ...ubject-balance-invalidation-wiring.test.ts | 33 ++ ...ect-batch-invalidation-cache-store.test.ts | 105 ++++++ ...-subject-batch-invalidation-wiring.test.ts | 55 +++ .../full-subject-epoch-wiring.test.ts | 21 ++ ...ubject-feature-balances-cache-miss.test.ts | 74 ++++ ...ll-subject-feature-balances-wiring.test.ts | 23 ++ ...full-subject-invoice-upsert-wiring.test.ts | 22 ++ .../full-subject-set-wiring.test.ts | 23 ++ ...bject-snapshot-cache-store-adapter.test.ts | 20 + ...full-subject-update-actions-wiring.test.ts | 31 ++ ...rdinary-cache-write-paths-no-redis.test.ts | 175 +++++++++ ...ject-balance-cache-writes-no-redis.test.ts | 144 ++++++++ .../tryOrInvalidate.test.ts | 28 +- .../resolveRequestIdempotencyStore.test.ts | 34 +- .../resolve-rate-limit-store.test.ts | 9 +- .../batch-invalidate-full-subjects.test.ts | 189 ---------- .../redis/resolveRequestLockStore.test.ts | 14 +- ...ith-redis-fail-open-gate-rejection.test.ts | 9 +- .../getRevenuecatAccessToken.test.ts | 12 +- .../registerRevenuecatWebhook.test.ts | 32 +- .../unit/revenuecat/revenuecatOAuth.test.ts | 2 + server/tests/unit/runtime-env-imports.test.ts | 347 ++++++++++++++++++ server/tests/unit/vercelAuth.test.ts | 4 + server/tests/unit/vercelSdkOptions.test.ts | 33 +- .../testInitUtils/createSubOrgTestContext.ts | 10 +- .../utils/testInitUtils/createTestContext.ts | 6 +- server/tests/utils/testWorkerEnv.ts | 15 + 215 files changed, 2462 insertions(+), 2319 deletions(-) create mode 100644 server/src/external/redis/requestRedisFailOpenAvailability.ts create mode 100644 server/src/internal/customers/cache/cacheRoutingStaleness.ts create mode 100644 server/src/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.ts create mode 100644 server/tests/unit/balances/execute-deduction-cache-wiring.test.ts create mode 100644 server/tests/unit/balances/refresh-entity-aggregate-cache-wiring.test.ts create mode 100644 server/tests/unit/full-subject-cache/cache-store-adapters-no-redis-wiring.test.ts create mode 100644 server/tests/unit/full-subject-cache/full-subject-balance-invalidation-wiring.test.ts create mode 100644 server/tests/unit/full-subject-cache/full-subject-batch-invalidation-cache-store.test.ts create mode 100644 server/tests/unit/full-subject-cache/full-subject-batch-invalidation-wiring.test.ts create mode 100644 server/tests/unit/full-subject-cache/full-subject-epoch-wiring.test.ts create mode 100644 server/tests/unit/full-subject-cache/full-subject-feature-balances-cache-miss.test.ts create mode 100644 server/tests/unit/full-subject-cache/full-subject-feature-balances-wiring.test.ts create mode 100644 server/tests/unit/full-subject-cache/full-subject-invoice-upsert-wiring.test.ts create mode 100644 server/tests/unit/full-subject-cache/full-subject-set-wiring.test.ts create mode 100644 server/tests/unit/full-subject-cache/full-subject-update-actions-wiring.test.ts create mode 100644 server/tests/unit/full-subject-cache/ordinary-cache-write-paths-no-redis.test.ts create mode 100644 server/tests/unit/full-subject-cache/subject-balance-cache-writes-no-redis.test.ts delete mode 100644 server/tests/unit/redis/batch-invalidate-full-subjects.test.ts create mode 100644 server/tests/utils/testWorkerEnv.ts diff --git a/packages/sdk/src/lib/sdks.ts b/packages/sdk/src/lib/sdks.ts index cd209af68..39dc86a3d 100644 --- a/packages/sdk/src/lib/sdks.ts +++ b/packages/sdk/src/lib/sdks.ts @@ -249,7 +249,10 @@ export class ClientSDK { return retry( async () => { - const req = await this.#hooks.beforeRequest(context, request.clone()); + const req = await this.#hooks.beforeRequest( + context, + request.clone() as Request, + ); await logRequest(this.#logger, req).catch((e) => this.#logger?.log("Failed to log request:", e) ); diff --git a/server/experiments/benchmarkV2CacheFlows.ts b/server/experiments/benchmarkV2CacheFlows.ts index dbc7f8cbb..499a83cbf 100644 --- a/server/experiments/benchmarkV2CacheFlows.ts +++ b/server/experiments/benchmarkV2CacheFlows.ts @@ -425,7 +425,11 @@ const main = async () => { }); const { db } = initDrizzle(); - const redisV2 = resolveRedisV2(); + const workerEnv = process.env as unknown as Env; + const redisV2 = resolveRedisV2({ + env: workerEnv, + customerId: CONFIG.customerId, + }); await warmupRedisV2(); const ctx = makeCtx({ redisV2, db }); diff --git a/server/experiments/benchmarkV2CacheRedis.ts b/server/experiments/benchmarkV2CacheRedis.ts index 57b61c376..86eb04c0b 100644 --- a/server/experiments/benchmarkV2CacheRedis.ts +++ b/server/experiments/benchmarkV2CacheRedis.ts @@ -355,7 +355,11 @@ const main = async () => { }); const { db } = initDrizzle(); - const redisV2 = resolveRedisV2(); + const workerEnv = process.env as unknown as Env; + const redisV2 = resolveRedisV2({ + env: workerEnv, + customerId: CONFIG.customerId, + }); await warmupRedisV2(); const ctx = makeCtx({ redisV2, db }); diff --git a/server/src/cron/resetCron/resetCustomerEntitlement.ts b/server/src/cron/resetCron/resetCustomerEntitlement.ts index 8b3028e70..0c4a31afd 100644 --- a/server/src/cron/resetCron/resetCustomerEntitlement.ts +++ b/server/src/cron/resetCron/resetCustomerEntitlement.ts @@ -8,8 +8,6 @@ import { import { UTCDate } from "@date-fns/utc"; import { format } from "date-fns"; import type { RepoContext } from "@/db/repoContext"; -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"; @@ -18,6 +16,7 @@ import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cus import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils"; import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService.js"; import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; +import { createDisabledRedis } from "@/utils/disabledRedis.js"; import { getNextResetAt } from "@/utils/timeUtils.js"; import type { CronContext } from "../utils/CronContext"; import { getStripeSubscriptionAnchor } from "./getStripeSubscriptionAnchor"; @@ -27,21 +26,15 @@ 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, @@ -50,7 +43,8 @@ const resetCustomerEntitlementInDb = async ({ }, env: cusEnt.customer.env, customerId: cusEnt.customer_id ?? "", - redisV2: redisRouting.redis, + redisV2: createDisabledRedis(), + cacheStore: ctx.cacheStore, }; try { @@ -199,30 +193,23 @@ export const resetCustomerEntitlement = async ({ persistFreeOverage = false, }: { ctx: CronContext; - org?: OrgWithRedisConfig; + org?: unknown; 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 ?? "", - }); + void org; const result = await resetCustomerEntitlementInDb({ ctx, - org: routingOrg, cusEnt, updatedCusEnts, persistFreeOverage, }); await invalidateCustomerEntitlementBalance({ + ctx, orgId: cusEnt.customer.org_id, env: cusEnt.customer.env, customerId: cusEnt.customer_id ?? "", - featureId: cusEnt.entitlement.feature.id, - customerEntitlementId: cusEnt.id, - redisV2: redisRouting.redis, }); return result; }; diff --git a/server/src/cron/runCronTick.ts b/server/src/cron/runCronTick.ts index 93406c065..6499363c0 100644 --- a/server/src/cron/runCronTick.ts +++ b/server/src/cron/runCronTick.ts @@ -1,6 +1,7 @@ import { db, initDrizzleModules } from "../db/initDrizzle.js"; import { createLogger } from "../external/logtail/logtailUtils.js"; import type { Logger } from "../external/logtail/logtailUtils.js"; +import { resolveRequestCacheStore } from "../external/storage/cache/resolveRequestCacheStore.js"; import { setAllEdgeConfigEnvs } from "../internal/misc/edgeConfig/edgeConfigRegistry.js"; import { describeSlotGate, @@ -70,6 +71,7 @@ export const runCloudflareScheduledCron = async (env: Env) => { ctx: { db, logger, + cacheStore: resolveRequestCacheStore({ env }), workerEnv: env, }, }); diff --git a/server/src/cron/utils/CronContext.ts b/server/src/cron/utils/CronContext.ts index 1480f1652..c992a0c5a 100644 --- a/server/src/cron/utils/CronContext.ts +++ b/server/src/cron/utils/CronContext.ts @@ -1,8 +1,10 @@ import type { DrizzleCli } from "../../db/initDrizzle"; +import type { CacheStore } from "../../external/storage/cache/index.js"; import type { Logger } from "../../external/logtail/logtailUtils"; export interface CronContext { db: DrizzleCli; logger: Logger; + cacheStore?: CacheStore; workerEnv?: Env; } diff --git a/server/src/db/repoContext.ts b/server/src/db/repoContext.ts index 0a94bab55..252825ad8 100644 --- a/server/src/db/repoContext.ts +++ b/server/src/db/repoContext.ts @@ -1,5 +1,6 @@ import type { AppEnv } from "@autumn/shared"; import type { Logger } from "@/external/logtail/logtailUtils"; +import type { CacheStore } from "@/external/storage/cache/index.js"; import type { LegacyRedisClient } from "@/utils/legacyRedisClient.js"; import type { DrizzleCli } from "./initDrizzle.js"; @@ -12,5 +13,6 @@ export interface RepoContext { db: DrizzleCli; logger: Logger; redisV2: LegacyRedisClient; + cacheStore?: CacheStore; customerId?: string; } diff --git a/server/src/db/shed503OnTransientError.ts b/server/src/db/shed503OnTransientError.ts index e2d7c4482..64d9f0a40 100644 --- a/server/src/db/shed503OnTransientError.ts +++ b/server/src/db/shed503OnTransientError.ts @@ -1,5 +1,4 @@ import { RecaseError } from "@autumn/shared"; -import { isTransientRedisError } from "@/external/redis/utils/isTransientRedisError.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { isTransientDbError } from "./dbUtils.js"; @@ -15,9 +14,7 @@ export const shed503OnTransientError = async ({ try { return await run(); } catch (error) { - if (!(isTransientDbError({ error }) || isTransientRedisError({ error }))) { - throw error; - } + if (!isTransientDbError({ error })) throw error; ctx.logger.warn(`[${source}] transient DB error, shedding with 503`, { type: `${source}_fail_open`, error, diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 95a44b045..743331c4f 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -122,7 +122,10 @@ export class AutumnInt { } } - async get(path: string, headers?: Record) { + async get( + path: string, + headers?: Record, + ): Promise { const response = await fetch(`${this.baseUrl}${path}`, { headers: { ...this.headers, ...headers }, }); @@ -152,10 +155,14 @@ export class AutumnInt { }); } - return response.json(); + return (await response.json()) as T; } - async post(path: string, body: any, headers?: Record) { + async post( + path: string, + body: any, + headers?: Record, + ): Promise { const response = await fetch(`${this.baseUrl}${path}`, { method: "POST", headers: { ...this.headers, ...headers }, @@ -196,7 +203,7 @@ export class AutumnInt { }); } - return response.json(); + return (await response.json()) as T; } async patch(path: string, body: any) { const response = await fetch(`${this.baseUrl}${path}`, { @@ -423,7 +430,7 @@ export class AutumnInt { headers["x-strip-internal"] = "false"; } - const data = await this.get( + const data = await this.get( `/customers?${new URLSearchParams(listParams as Record).toString()}`, Object.keys(headers).length > 0 ? headers : undefined, ); @@ -493,7 +500,7 @@ export class AutumnInt { finalParams.with_autumn_id ? "true" : "false", ); } - const data = await this.get( + const data = await this.get( `/customers/${customerId}?${queryParams.toString()}`, Object.keys(headers).length > 0 ? headers : undefined, ); @@ -654,7 +661,9 @@ export class AutumnInt { }, list: async (customerId: string): Promise => { - const data = await this.get(`/customers/${customerId}/entities`); + const data = await this.get( + `/customers/${customerId}/entities`, + ); return data; }, @@ -788,7 +797,7 @@ export class AutumnInt { internalId: string; reward: any; }) => { - const data = await this.post( + const data = await this.post( `/rewards/${internalId}?legacyStripe=true`, reward, ); @@ -969,7 +978,7 @@ export class AutumnInt { queryParams.append("skip_cache", "true"); } - const data = await this.post( + const data = await this.post( `/check?${queryParams.toString()}`, params, headers, @@ -1255,7 +1264,10 @@ export class AutumnInt { params: TInput, { timeout }: { timeout?: number } = {}, ): Promise => { - const data = await this.post(`/billing.create_schedule`, params); + const data = await this.post( + `/billing.create_schedule`, + params, + ); const concurrency = Number(this._env?.TEST_FILE_CONCURRENCY || "0"); const defaultTimeout = concurrency > 1 ? 5000 : 4000; diff --git a/server/src/external/autumn/autumnRpcCli.ts b/server/src/external/autumn/autumnRpcCli.ts index cfb74f4f4..8d01cae6a 100644 --- a/server/src/external/autumn/autumnRpcCli.ts +++ b/server/src/external/autumn/autumnRpcCli.ts @@ -52,7 +52,7 @@ export class AutumnRpcCli { return path.startsWith("/") ? path : `/${path}`; } - async post(path: string, body: any) { + async post(path: string, body: any): Promise { const response = await fetch(`${this.baseUrl}${this.resolvePath(path)}`, { method: "POST", headers: this.headers, @@ -84,7 +84,7 @@ export class AutumnRpcCli { }); } - return response.json(); + return (await response.json()) as T; } rpc = { diff --git a/server/src/external/redis/redisLockStore.ts b/server/src/external/redis/redisLockStore.ts index a0878e5fe..670ebd405 100644 --- a/server/src/external/redis/redisLockStore.ts +++ b/server/src/external/redis/redisLockStore.ts @@ -1,6 +1,5 @@ -import type { Redis } from "ioredis"; import { registerDefaultLockStoreFactory } from "./defaultLockStore.js"; -import { redis } from "./initRedis.js"; +import type { LegacyRedisClient } from "@/utils/legacyRedisClient.js"; import type { LockStore, LockStoreAcquireResult, @@ -13,7 +12,7 @@ type StoredLockData = { }; export class RedisLockStore implements LockStore { - constructor(private readonly redisClient: Redis = redis) {} + constructor(private readonly redisClient: LegacyRedisClient) {} async acquire({ lockKey, @@ -72,7 +71,11 @@ export class RedisLockStore implements LockStore { } } -const redisLockStore = new RedisLockStore(); -registerDefaultLockStoreFactory(() => redisLockStore); +export const registerRedisLockStoreFactory = ( + redisClient: LegacyRedisClient, +): void => { + const redisLockStore = new RedisLockStore(redisClient); + registerDefaultLockStoreFactory(() => redisLockStore); +}; export { getDefaultLockStore } from "./defaultLockStore.js"; diff --git a/server/src/external/redis/registerRedisRequestAdapters.ts b/server/src/external/redis/registerRedisRequestAdapters.ts index fe955cd35..04bb6395e 100644 --- a/server/src/external/redis/registerRedisRequestAdapters.ts +++ b/server/src/external/redis/registerRedisRequestAdapters.ts @@ -8,6 +8,7 @@ import { getRegionalRedis, redis, } from "./initRedis.js"; +import { shouldUseRedisV2 } from "./initUtils/redisV2Availability.js"; import { registerRampDestinationClientFactory } from "@/internal/misc/cacheV2Ramp/index.js"; import { getOrgRedis, removeOrgRedis } from "./orgRedisPool.js"; import { getRedisV2LockReceiptCandidates } from "./orgRedisUtils/orgRedisMigrationUtils.js"; @@ -19,11 +20,15 @@ import { registerCustomerRedisContextRouter } from "./requestCustomerRedisRoutin import { registerOrgRedisLifecycle } from "./requestOrgRedisLifecycle.js"; import { registerRequestRedisTargetsForCustomer } from "./requestRedisTargetsForCustomer.js"; import { registerRequestRedisV2Resolver } from "./requestRedisV2Resolver.js"; +import { registerRedisFailOpenAvailabilityResolver } from "./requestRedisFailOpenAvailability.js"; import { resolveRedisV2 } from "./resolveRedisV2.js"; +import { registerRedisLockStoreFactory } from "./redisLockStore.js"; registerRequestRedisV2Resolver(resolveRedisV2); +registerRedisFailOpenAvailabilityResolver(shouldUseRedisV2); registerCustomerRedisContextRouter(getCtxWithCustomerRedis); registerRequestRedisTargetsForCustomer(getRedisTargetsForCustomer); +registerRedisLockStoreFactory(redis); registerLockReceiptCandidateResolver(getRedisV2LockReceiptCandidates); registerLegacyLockReceiptRedisResolver(({ region }) => region && region !== currentRegion ? getRegionalRedis(region) : redis, diff --git a/server/src/external/redis/requestRedisFailOpenAvailability.ts b/server/src/external/redis/requestRedisFailOpenAvailability.ts new file mode 100644 index 000000000..a35740d01 --- /dev/null +++ b/server/src/external/redis/requestRedisFailOpenAvailability.ts @@ -0,0 +1,13 @@ +type RedisFailOpenAvailabilityResolver = () => boolean; + +let redisFailOpenAvailabilityResolver: RedisFailOpenAvailabilityResolver = + () => false; + +export const registerRedisFailOpenAvailabilityResolver = ( + resolver: RedisFailOpenAvailabilityResolver, +): void => { + redisFailOpenAvailabilityResolver = resolver; +}; + +export const shouldUseRequestRedisFailOpen = (): boolean => + redisFailOpenAvailabilityResolver(); diff --git a/server/src/external/redis/resolveRequestLockStore.ts b/server/src/external/redis/resolveRequestLockStore.ts index 008b36cda..7b1693d77 100644 --- a/server/src/external/redis/resolveRequestLockStore.ts +++ b/server/src/external/redis/resolveRequestLockStore.ts @@ -8,20 +8,30 @@ type LockStoreEnv = Env & { USE_DO_LOCK?: string; }; +type RequestLockStoreResolution = { + backend: "durable_object" | "default"; + durableObjectEnabled: boolean; + durableObjectBound: boolean; +}; + const isTruthyEnvFlag = (value: string | undefined): boolean => value === "true" || value === "1"; -export const getRequestLockStoreResolution = (env: Env) => { +export const getRequestLockStoreResolution = ( + env: Env, +): RequestLockStoreResolution => { const lockEnv = env as LockStoreEnv; const durableObjectEnabled = isTruthyEnvFlag(lockEnv.USE_DO_LOCK); const durableObjectBound = !!lockEnv.LOCK_DO; return { backend: - durableObjectEnabled && durableObjectBound ? "durable_object" : "redis", + durableObjectEnabled && durableObjectBound + ? "durable_object" + : "default", durableObjectEnabled, durableObjectBound, - } as const; + }; }; export const resolveRequestLockStore = (env: Env): LockStore => { diff --git a/server/src/external/redis/utils/withRedisFailOpen.ts b/server/src/external/redis/utils/withRedisFailOpen.ts index 0b4fd6dde..a2523f661 100644 --- a/server/src/external/redis/utils/withRedisFailOpen.ts +++ b/server/src/external/redis/utils/withRedisFailOpen.ts @@ -1,5 +1,5 @@ import { isTransientDbError } from "@/db/dbUtils.js"; -import { shouldUseRedisV2 } from "@/external/redis/initUtils/redisV2Availability.js"; +import { shouldUseRequestRedisFailOpen } from "../requestRedisFailOpenAvailability.js"; import { RedisUnavailableError } from "./errors.js"; import { isTransientRedisError } from "./isTransientRedisError.js"; @@ -17,7 +17,7 @@ export const withRedisFailOpen = async ({ alsoFailOpen?: (error: unknown) => boolean; }): Promise => { try { - if (!shouldUseRedisV2()) { + if (!shouldUseRequestRedisFailOpen()) { throw new RedisUnavailableError({ source, reason: "not_ready" }); } diff --git a/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts b/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts index 1401b3f14..c43a487fc 100644 --- a/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts +++ b/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts @@ -18,7 +18,12 @@ export const handleGetRevenueCatProducts = createRoute({ } const projectId = getRevenuecatProjectId({ revenueCatConfig, env }); - const accessToken = await getRevenuecatAccessToken({ db, org, env }); + const accessToken = await getRevenuecatAccessToken({ + db, + org, + env, + workerEnv: c.env, + }); if (!projectId || !accessToken) { return c.json({ products: [] }, 404); diff --git a/server/src/external/revenueCat/handlers/handleGetRevenuecatProjects.ts b/server/src/external/revenueCat/handlers/handleGetRevenuecatProjects.ts index fae9585a1..f89d159a0 100644 --- a/server/src/external/revenueCat/handlers/handleGetRevenuecatProjects.ts +++ b/server/src/external/revenueCat/handlers/handleGetRevenuecatProjects.ts @@ -15,7 +15,12 @@ export const handleGetRevenueCatProjects = createRoute({ return c.json({ projects: [] }, 404); } - const accessToken = await getRevenuecatAccessToken({ db, org, env }); + const accessToken = await getRevenuecatAccessToken({ + db, + org, + env, + workerEnv: c.env, + }); if (!accessToken) { return c.json({ projects: [] }, 404); @@ -35,7 +40,12 @@ export const handleCreateRevenueCatProject = createRoute({ const { db, org, env } = c.get("ctx"); const { name } = c.req.valid("json"); - const accessToken = await getRevenuecatAccessToken({ db, org, env }); + const accessToken = await getRevenuecatAccessToken({ + db, + org, + env, + workerEnv: c.env, + }); if (!accessToken) { throw new RecaseError({ message: "Connect RevenueCat via OAuth before creating a project", diff --git a/server/src/external/revenueCat/handlers/handlePreflightRevenueCatSync.ts b/server/src/external/revenueCat/handlers/handlePreflightRevenueCatSync.ts index 5b0102c80..e4e6c5d6b 100644 --- a/server/src/external/revenueCat/handlers/handlePreflightRevenueCatSync.ts +++ b/server/src/external/revenueCat/handlers/handlePreflightRevenueCatSync.ts @@ -104,7 +104,12 @@ export const handlePreflightRevenueCatSync = createRoute({ if (!revenueCatConfig) return c.json({ items: [] }); const projectId = getRevenuecatProjectId({ revenueCatConfig, env }); - const accessToken = await getRevenuecatAccessToken({ db, org, env }); + const accessToken = await getRevenuecatAccessToken({ + db, + org, + env, + workerEnv: c.env, + }); if (!projectId || !accessToken) return c.json({ items: [] }); const rcCli = initRevenuecatCli({ projectId, accessToken }); diff --git a/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts b/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts index 0e4d2924e..fd3612f89 100644 --- a/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts +++ b/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts @@ -11,7 +11,7 @@ import { decryptData, encryptData } from "@/utils/encryptUtils.js"; const TOKEN_EXPIRY_SKEW_MS = 60_000; -const getOAuthConfigForEnv = (env: Env) => ({ +const getOAuthConfigForEnv = ({ revenueCatConfig, env, }: { @@ -58,20 +58,22 @@ const refreshAndPersistTokens = async ({ db, org, env, + workerEnv, oauthConfig, }: { db: DrizzleCli; org: Organization; env: AppEnv; + workerEnv: Env; oauthConfig: RevenueCatOAuthConfig; }): Promise => { - const refreshToken = decryptData(oauthConfig.refresh_token); - const tokens = await refreshRcTokens({ refreshToken }); + const refreshToken = decryptData(oauthConfig.refresh_token, workerEnv); + const tokens = await refreshRcTokens({ refreshToken, env: workerEnv }); const refreshedOAuthConfig: RevenueCatOAuthConfig = { ...oauthConfig, - access_token: encryptData(tokens.accessToken()), - refresh_token: encryptData(tokens.refreshToken()), + access_token: encryptData(tokens.accessToken(), workerEnv), + refresh_token: encryptData(tokens.refreshToken(), workerEnv), expires_at: tokens.accessTokenExpiresAt().getTime(), ...(tokens.hasScopes() ? { scope: tokens.scopes().join(" ") } : {}), }; @@ -90,27 +92,31 @@ export const refreshRevenuecatOAuthAccessToken = async ({ db, org, env, + workerEnv, }: { db: DrizzleCli; org: Organization; env: AppEnv; + workerEnv: Env; }): Promise => { const oauthConfig = getOAuthConfigForEnv({ revenueCatConfig: org.processor_configs?.revenuecat ?? {}, env, }); if (!oauthConfig) return null; - return refreshAndPersistTokens({ db, org, env, oauthConfig }); + return refreshAndPersistTokens({ db, org, env, workerEnv, oauthConfig }); }; export const getRevenuecatAccessToken = async ({ db, org, env, + workerEnv, }: { db: DrizzleCli; org: Organization; env: AppEnv; + workerEnv: Env; }): Promise => { const revenueCatConfig = org.processor_configs?.revenuecat; if (!revenueCatConfig) return null; @@ -119,10 +125,10 @@ export const getRevenuecatAccessToken = async ({ if (oauthConfig) { if (isOAuthAccessTokenValid(oauthConfig)) { - return decryptData(oauthConfig.access_token); + return decryptData(oauthConfig.access_token, workerEnv); } - return refreshAndPersistTokens({ db, org, env, oauthConfig }); + return refreshAndPersistTokens({ db, org, env, workerEnv, oauthConfig }); } const apiKey = @@ -130,7 +136,7 @@ export const getRevenuecatAccessToken = async ({ ? revenueCatConfig.api_key : revenueCatConfig.sandbox_api_key; - return apiKey ? decryptData(apiKey) : null; + return apiKey ? decryptData(apiKey, workerEnv) : null; }; export const getRevenuecatProjectId = ({ diff --git a/server/src/external/revenueCat/sync/syncRevenueCatProducts.ts b/server/src/external/revenueCat/sync/syncRevenueCatProducts.ts index eaafffae1..8af4faed9 100644 --- a/server/src/external/revenueCat/sync/syncRevenueCatProducts.ts +++ b/server/src/external/revenueCat/sync/syncRevenueCatProducts.ts @@ -269,6 +269,14 @@ export const syncProductsToRevenueCat = async ({ productIds: string[]; }): Promise => { const { db, org, env } = ctx; + const workerEnv = ctx.workerEnv; + if (!workerEnv) { + throw new RecaseError({ + message: "Worker env is required to sync RevenueCat products", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + }); + } const revenueCatConfig = org.processor_configs?.revenuecat; if (!revenueCatConfig || !isRevenueCatPushEnabled({ revenueCatConfig, env })) { @@ -280,7 +288,12 @@ export const syncProductsToRevenueCat = async ({ } const projectId = getRevenuecatProjectId({ revenueCatConfig, env }); - const accessToken = await getRevenuecatAccessToken({ db, org, env }); + const accessToken = await getRevenuecatAccessToken({ + db, + org, + env, + workerEnv, + }); if (!projectId || !accessToken) { throw new RecaseError({ message: "RevenueCat is not fully configured (missing project or token)", diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index 3a7394545..16c7b37f9 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -30,7 +30,7 @@ export const handleStripeWebhookEvent = async ( c: Context, ) => { const ctx = c.get("ctx") as StripeWebhookContext; - const { db, logger, org, env, stripeEvent } = ctx; + const { db, logger, org, env, stripeEvent, workerEnv } = ctx; const event = stripeEvent; try { @@ -122,7 +122,7 @@ export const handleStripeWebhookEvent = async ( } if ( - env.NODE_ENV === "development" && + workerEnv?.NODE_ENV === "development" && error instanceof Error && error.message.includes("No stripe account linked to organization") ) { diff --git a/server/src/external/stripe/stripeCusUtils.ts b/server/src/external/stripe/stripeCusUtils.ts index 2d8242eac..b0379868d 100644 --- a/server/src/external/stripe/stripeCusUtils.ts +++ b/server/src/external/stripe/stripeCusUtils.ts @@ -10,10 +10,9 @@ 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 { getCustomerRedisRoutingId } from "@/external/redis/customerRedisRoutingInfo.js"; -import { resolveRequestRedisV2 } from "@/external/redis/requestRedisV2Resolver.js"; import { createStripeCustomer } from "@/external/stripe/customers"; import { CusService } from "@/internal/customers/CusService.js"; +import { createDisabledRedis } from "@/utils/disabledRedis.js"; import type { TestContext } from "../../../tests/utils/testInitUtils/createTestContext"; import { createLogger } from "../logtail/logtailUtils"; @@ -154,10 +153,7 @@ export const attachPmToCus = async ({ org, env, logger: createLogger(effectiveWorkerEnv), - redisV2: resolveRequestRedisV2({ - env: effectiveWorkerEnv, - customerId: getCustomerRedisRoutingId({ customer }), - }), + redisV2: createDisabledRedis(), }; await CusService.update({ diff --git a/server/src/external/stripe/stripeOnboardingUtils.ts b/server/src/external/stripe/stripeOnboardingUtils.ts index 21b973a95..a1f25215e 100644 --- a/server/src/external/stripe/stripeOnboardingUtils.ts +++ b/server/src/external/stripe/stripeOnboardingUtils.ts @@ -17,10 +17,11 @@ export const createWebhookEndpoint = async ( apiKey: string, env: AppEnv, orgId: string, + workerEnv: Env, ) => { const stripe = new Stripe(apiKey); - const webhookBaseUrl = env.STRIPE_WEBHOOK_URL || env.SERVER_URL; + const webhookBaseUrl = workerEnv.STRIPE_WEBHOOK_URL || workerEnv.SERVER_URL; if (!webhookBaseUrl) { throw new RecaseError({ diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts index 1a70d5563..acb52fad3 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts @@ -95,7 +95,7 @@ export const setupInvoiceCreatedContext = async ({ await customerProductActions.expiredCache.getAndMerge({ customerProducts: currentCustomerProducts, stripeSubscriptionId, - cacheStore: ctx.cacheStore, + cacheStore: ctx.cacheStore!, }); const scheduledCustomerProducts = fullCustomer.customer_products.filter( diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts index 956b96102..ec8c38476 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts @@ -93,7 +93,7 @@ export const setupInvoiceFinalizedContext = async ({ await customerProductActions.expiredCache.getAndMerge({ customerProducts: currentCustomerProducts, stripeSubscriptionId, - cacheStore: ctx.cacheStore, + cacheStore: ctx.cacheStore!, }); if (customerProducts.length === 0) { diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts index 34e7d3a6d..a7acf0aa0 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts @@ -24,6 +24,7 @@ export const processVercelInvoice = async ({ stripeSubscription: Stripe.Subscription | null; }): Promise => { const { stripeCli, org, env, db, fullCustomer } = ctx; + const workerEnv = ctx.workerEnv; let { logger } = ctx; if (stripeInvoice.amount_due <= 0) { @@ -33,6 +34,10 @@ export const processVercelInvoice = async ({ if (!fullCustomer) { return; } + if (!workerEnv) { + logger.error("[vercel] missing Worker env while processing invoice"); + return; + } const invoiceMetadata = stripeInvoice.metadata as Record< string, @@ -107,6 +112,7 @@ export const processVercelInvoice = async ({ invoice: stripeInvoice, customer: fullCustomer, product, + env: workerEnv, testOptions: ctx.testOptions, }); @@ -118,6 +124,7 @@ export const processVercelInvoice = async ({ org, features, logger, + env: workerEnv, testOptions: ctx.testOptions, }); } catch (error) { diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts index 9ee014f40..19afb39e3 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts @@ -56,7 +56,7 @@ export const setupStripeInvoicePaidContext = async ({ customerProducts = await customerProductActions.expiredCache.getAndMerge({ customerProducts, stripeSubscriptionId, - cacheStore: ctx.cacheStore, + cacheStore: ctx.cacheStore!, }); fullCustomer.customer_products = customerProducts; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts index ac09cf7b9..14857aab0 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts @@ -91,6 +91,6 @@ export const expireAndActivateCustomerProducts = async ({ await customerProductActions.expiredCache.set({ stripeSubscriptionId: stripeSubscription.id, customerProducts: expiredCustomerProducts, - cacheStore: ctx.cacheStore, + cacheStore: ctx.cacheStore!, }); }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts index 77c15e7ea..4a1258ecd 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts @@ -68,7 +68,7 @@ export const expireEndedCustomerProducts = async ({ await customerProductActions.expiredCache.set({ stripeSubscriptionId: stripeSubscription.id, customerProducts: expiredCustomerProducts, - cacheStore: ctx.cacheStore, + cacheStore: ctx.cacheStore!, }); } }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/releaseScheduleIfLastPhase.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/releaseScheduleIfLastPhase.ts index 6a403b4aa..f2deaec1c 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/releaseScheduleIfLastPhase.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/releaseScheduleIfLastPhase.ts @@ -16,7 +16,7 @@ export const releaseScheduleIfLastPhase = async ({ ctx: StripeWebhookContext; eventContext: StripeSubscriptionUpdatedContext; }): Promise => { - const { db, org, env, logger } = ctx; + const { db, org, env, logger, workerEnv } = ctx; const { stripeSubscription, nowMs } = eventContext; const stripeSubscriptionSchedule = stripeSubscription.schedule; @@ -73,7 +73,7 @@ export const releaseScheduleIfLastPhase = async ({ return true; } catch (error: unknown) { if (error instanceof Error) { - if (env.NODE_ENV === "development") { + if (workerEnv?.NODE_ENV === "development") { logger.warn( `[handleSchedulePhaseChanges] failed to release schedule: ${error.message}`, ); diff --git a/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts index 300219266..69300b188 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts @@ -31,12 +31,13 @@ export const stripeConnectSeederMiddleware = async ( ) => { const ctx = c.get("ctx") as StripeWebhookContext; const { db, logger } = ctx; - const { env } = c.req.param() as { env: AppEnv }; + const { env: appEnv } = c.req.param() as { env: AppEnv }; + const workerEnv = c.env; // Step 1: Initialize master stripe client let masterStripe: Stripe; try { - masterStripe = initMasterStripe(); + masterStripe = initMasterStripe(workerEnv, { env: appEnv }); } catch (error) { logger.error(`Failed to initialize master stripe client ${error}`); return c.json({ error: "Failed to initialize stripe client" }, 500); @@ -46,7 +47,8 @@ export const stripeConnectSeederMiddleware = async ( const webhookSecret = await getStripeWebhookSecret({ db, orgId: c.req.query("org_id"), - env, + env: workerEnv, + appEnv, }); // Step 3: Verify webhook signature @@ -54,8 +56,8 @@ export const stripeConnectSeederMiddleware = async ( const signature = c.req.header("stripe-signature") || ""; const skipVerify = - env.STRIPE_WEBHOOK_SKIP_VERIFY === "true" && - env.NODE_ENV !== "production"; + workerEnv.STRIPE_WEBHOOK_SKIP_VERIFY === "true" && + workerEnv.NODE_ENV !== "production"; let event: Stripe.Event; if (skipVerify) { @@ -78,7 +80,7 @@ export const stripeConnectSeederMiddleware = async ( ); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - if (env.NODE_ENV !== "development") { + if (workerEnv.NODE_ENV !== "development") { logger.warn(`Webhook verification error: ${message}`); } return c.json({ error: message }, 400); @@ -114,7 +116,7 @@ export const stripeConnectSeederMiddleware = async ( return c.json({ error: "Failed to resolve org for Stripe webhook" }, 500); } - if (env.NODE_ENV !== "development") { + if (workerEnv.NODE_ENV !== "development") { logger.error( `Account ID ${accountId} not linked to any org, skipping Stripe webhook`, ); @@ -128,10 +130,11 @@ export const stripeConnectSeederMiddleware = async ( // Step 5: Set up context ctx.org = org; ctx.features = features; - ctx.env = env; + ctx.env = appEnv; + ctx.workerEnv = workerEnv; ctx.authType = AuthType.Stripe; ctx.stripeEvent = event; - ctx.stripeCli = createStripeCli({ org, env }); + ctx.stripeCli = createStripeCli({ org, env: appEnv, workerEnv }); await next(); }; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts index 81cca6853..39b68c9d3 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts @@ -24,13 +24,17 @@ export const stripeLegacySeederMiddleware = async ( ) => { const ctx = c.get("ctx") as StripeWebhookContext; const { db, logger } = ctx; - const { orgId, env } = c.req.param() as { orgId: string; env: AppEnv }; + const { orgId, env: appEnv } = c.req.param() as { + orgId: string; + env: AppEnv; + }; + const workerEnv = c.env; // Step 1: Get org and features const data = await OrgService.getWithFeatures({ db, orgId, - env, + env: appEnv, allowNotFound: true, }); @@ -41,10 +45,10 @@ export const stripeLegacySeederMiddleware = async ( const { org, features } = data; // Step 2: Check if org is connected to Stripe - if (!isStripeConnected({ org, env })) { - logger.info(`Org ${orgId} and env ${env} is not connected to stripe`); + if (!isStripeConnected({ org, env: appEnv })) { + logger.info(`Org ${orgId} and env ${appEnv} is not connected to stripe`); return c.json( - { message: `Org ${orgId} and env ${env} is not connected to stripe` }, + { message: `Org ${orgId} and env ${appEnv} is not connected to stripe` }, 200, ); } @@ -54,8 +58,8 @@ export const stripeLegacySeederMiddleware = async ( const signature = c.req.header("stripe-signature") || ""; const skipVerify = - env.STRIPE_WEBHOOK_SKIP_VERIFY === "true" && - env.NODE_ENV !== "production"; + workerEnv.STRIPE_WEBHOOK_SKIP_VERIFY === "true" && + workerEnv.NODE_ENV !== "production"; let event: Stripe.Event; if (skipVerify) { @@ -73,7 +77,7 @@ export const stripeLegacySeederMiddleware = async ( } } else { try { - const webhookSecret = getStripeWebhookSecret(org, env); + const webhookSecret = getStripeWebhookSecret(org, appEnv, workerEnv); event = await Stripe.webhooks.constructEventAsync( rawBody, signature, @@ -91,10 +95,11 @@ export const stripeLegacySeederMiddleware = async ( // Step 4: Set up context ctx.org = org; ctx.features = features; - ctx.env = env; + ctx.env = appEnv; + ctx.workerEnv = workerEnv; ctx.authType = AuthType.Stripe; ctx.stripeEvent = event; - ctx.stripeCli = createStripeCli({ org, env }); + ctx.stripeCli = createStripeCli({ org, env: appEnv, workerEnv }); await next(); }; diff --git a/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts b/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts index 8ea677279..bb78a7a04 100644 --- a/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts +++ b/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts @@ -13,7 +13,7 @@ export const getTinybirdApiUrl = (env: Env) => env.TINYBIRD_US_EAST_API_URL; export const getTinybirdToken = (env: Env) => env.TINYBIRD_US_EAST_TOKEN; /** Tinybird client for migration item events. Null until initMigrationTinybird(env) is called. */ -export let migrationTinybird: Tinybird | null = null; +export let migrationTinybird: any = null; export const initMigrationTinybird = (env: Env) => { const apiUrl = env.TINYBIRD_US_EAST_API_URL; diff --git a/server/src/external/tinybird/tinybirdUtils.ts b/server/src/external/tinybird/tinybirdUtils.ts index 051970fa4..ecbcb62ef 100644 --- a/server/src/external/tinybird/tinybirdUtils.ts +++ b/server/src/external/tinybird/tinybirdUtils.ts @@ -9,12 +9,11 @@ export type TinybirdConfig = { export let tinybirdConfig: TinybirdConfig | null = null; export const initTinybirdConfig = (env: Env) => { - tinybirdConfig = - TINYBIRD_API_URL && TINYBIRD_TOKEN + env.TINYBIRD_API_URL && env.TINYBIRD_TOKEN ? { - baseUrl: TINYBIRD_API_URL, - token: TINYBIRD_TOKEN, + baseUrl: env.TINYBIRD_API_URL, + token: env.TINYBIRD_TOKEN, } : null; }; diff --git a/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts b/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts index 8a2393719..651f4e84f 100644 --- a/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts @@ -32,7 +32,7 @@ export const handleDeleteInstallation = createRoute({ orgId, env: ctx.env, }), - sendCustomSvixEvent({ + sendCustomSvixEvent(c.env)({ appId: org.processor_configs?.vercel?.svix?.[ ctx.env === AppEnv.Live ? "live_id" : "sandbox_id" diff --git a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts index 79d614dd7..26fcbc2ed 100644 --- a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts @@ -31,6 +31,7 @@ export const handleUpsertInstallation = createRoute({ token, org: ctx.org, env: ctx.env, + workerEnv: c.env, testOptions, }); diff --git a/server/src/external/vercel/handlers/resources/handleCreateResource.ts b/server/src/external/vercel/handlers/resources/handleCreateResource.ts index d58c02fe3..83c8f2201 100644 --- a/server/src/external/vercel/handlers/resources/handleCreateResource.ts +++ b/server/src/external/vercel/handlers/resources/handleCreateResource.ts @@ -273,7 +273,7 @@ export const handleCreateResource = createRoute({ ); } - await sendCustomSvixEvent({ + await sendCustomSvixEvent(c.env)({ appId: org.processor_configs?.vercel?.svix?.[ env === AppEnv.Live ? "live_id" : "sandbox_id" diff --git a/server/src/external/vercel/handlers/resources/handleDeleteResource.ts b/server/src/external/vercel/handlers/resources/handleDeleteResource.ts index 106fb307d..a97838e70 100644 --- a/server/src/external/vercel/handlers/resources/handleDeleteResource.ts +++ b/server/src/external/vercel/handlers/resources/handleDeleteResource.ts @@ -42,7 +42,7 @@ export const handleDeleteResource = createRoute({ } try { - await sendCustomSvixEvent({ + await sendCustomSvixEvent(c.env)({ appId: org.processor_configs?.vercel?.svix?.[ env === AppEnv.Live ? "live_id" : "sandbox_id" diff --git a/server/src/external/vercel/handlers/resources/handleRotateResourceSecret.ts b/server/src/external/vercel/handlers/resources/handleRotateResourceSecret.ts index 0ff8da790..46804542b 100644 --- a/server/src/external/vercel/handlers/resources/handleRotateResourceSecret.ts +++ b/server/src/external/vercel/handlers/resources/handleRotateResourceSecret.ts @@ -44,7 +44,7 @@ export const handleRotateResourceSecret = createRoute({ ); } - await sendCustomSvixEvent({ + await sendCustomSvixEvent(c.env)({ appId: org.processor_configs?.vercel?.svix?.[ env === AppEnv.Live ? "live_id" : "sandbox_id" diff --git a/server/src/external/vercel/misc/vercelAuth.ts b/server/src/external/vercel/misc/vercelAuth.ts index a9e140840..43297a8a4 100644 --- a/server/src/external/vercel/misc/vercelAuth.ts +++ b/server/src/external/vercel/misc/vercelAuth.ts @@ -35,14 +35,16 @@ const synthesizeTestClaims = ({ token, org, env, + workerEnv, testOptions, }: { token: string; org: Organization; env: AppEnv; + workerEnv: Env; testOptions?: VercelOidcTestOptions; }): OidcClaims | null => { - if (env.NODE_ENV === "production") return null; + if (workerEnv.NODE_ENV === "production") return null; if (testOptions?.allowVercelTestOidc !== true) return null; if (!token.startsWith(TEST_OIDC_PREFIX)) return null; @@ -69,14 +71,22 @@ export async function verifyToken({ token, org, env, + workerEnv, testOptions, }: { token: string; org: Organization; env: AppEnv; + workerEnv: Env; testOptions?: VercelOidcTestOptions; }): Promise { - const testClaims = synthesizeTestClaims({ token, org, env, testOptions }); + const testClaims = synthesizeTestClaims({ + token, + org, + env, + workerEnv, + testOptions, + }); if (testClaims) return testClaims; try { @@ -245,7 +255,13 @@ export const vercelOidcAuthMiddleware = async (c: any, next: any) => { // Verify JWT using JWKS let claims: OidcClaims; try { - claims = await verifyToken({ token, org, env, testOptions }); + claims = await verifyToken({ + token, + org, + env, + workerEnv: c.env, + testOptions, + }); } catch (error: any) { logCaughtError({ logger, diff --git a/server/src/external/vercel/misc/vercelInvoicing.ts b/server/src/external/vercel/misc/vercelInvoicing.ts index 3515c7463..1cb99cedc 100644 --- a/server/src/external/vercel/misc/vercelInvoicing.ts +++ b/server/src/external/vercel/misc/vercelInvoicing.ts @@ -47,17 +47,19 @@ export const submitBillingDataToVercel = async ({ invoice, customer, product, + env, testOptions, }: { installationId: string; invoice: Stripe.Invoice; customer: Customer; product: FullProduct; + env: Env; testOptions?: VercelSdkTestOptions; }) => { const vercel = new Vercel({ bearerToken: customer.processors?.vercel?.access_token, - serverURL: getVercelSdkServerURL(testOptions), + serverURL: getVercelSdkServerURL({ env, testOptions }), }); const firstLineItem = invoice.lines.data[0]; @@ -120,6 +122,7 @@ export const submitInvoiceToVercel = async ({ org, features, logger, + env, testOptions, }: { installationId: string; @@ -129,11 +132,12 @@ export const submitInvoiceToVercel = async ({ org: Organization; features: Feature[]; logger?: Logger; + env: Env; testOptions?: VercelSdkTestOptions; }) => { const vercel = new Vercel({ bearerToken: customer.processors?.vercel?.access_token, - serverURL: getVercelSdkServerURL(testOptions), + serverURL: getVercelSdkServerURL({ env, testOptions }), }); const price = productV2ToBasePrice({ product: mapToProductV2({ product }) }); diff --git a/server/src/external/vercel/vercelTestApiRouter.ts b/server/src/external/vercel/vercelTestApiRouter.ts index a6a5fc78d..b9d34e528 100644 --- a/server/src/external/vercel/vercelTestApiRouter.ts +++ b/server/src/external/vercel/vercelTestApiRouter.ts @@ -31,9 +31,11 @@ type CapturedCall = { export const recordVercelTestCapture = async ({ call, cacheStore, + env, }: { call: CapturedCall; cacheStore?: CacheStore; + env?: Env; }) => { const key = captureCacheKey(call.installationId); if (cacheStore) { @@ -44,6 +46,7 @@ export const recordVercelTestCapture = async ({ return; } + if (!env) throw new Error("Worker env is required without cacheStore"); const redis = resolveRequestRedisV2({ env }); await redis.rpush(key, JSON.stringify(call)); await redis.expire(key, CAPTURE_TTL_SECONDS); @@ -52,13 +55,16 @@ export const recordVercelTestCapture = async ({ export const getVercelTestCaptures = async ({ installationId, cacheStore, + env, }: { installationId: string; cacheStore?: CacheStore; + env?: Env; }): Promise => { const key = captureCacheKey(installationId); if (cacheStore) return (await cacheStore.getJson(key)) || []; + if (!env) throw new Error("Worker env is required without cacheStore"); const redis = resolveRequestRedisV2({ env }); const raw = await redis.lrange(key, 0, -1); return raw @@ -75,9 +81,11 @@ export const getVercelTestCaptures = async ({ export const clearVercelTestCaptures = async ({ installationId, cacheStore, + env, }: { installationId: string; cacheStore?: CacheStore; + env?: Env; }): Promise => { const key = captureCacheKey(installationId); if (cacheStore) { @@ -85,6 +93,7 @@ export const clearVercelTestCaptures = async ({ return; } + if (!env) throw new Error("Worker env is required without cacheStore"); const redis = resolveRequestRedisV2({ env }); await redis.del(key); }; @@ -113,6 +122,7 @@ vercelTestApiRouter.post( const installationId = c.req.param("integrationConfigurationId"); const body = await parseJsonOrEmpty(c, "submitBillingData"); await recordVercelTestCapture({ + env: c.env, cacheStore: c.get("ctx")?.cacheStore, call: { method: "POST", @@ -139,6 +149,7 @@ vercelTestApiRouter.post( const body = await parseJsonOrEmpty(c, "submitInvoice"); const externalId = (body as { externalId?: string })?.externalId; await recordVercelTestCapture({ + env: c.env, cacheStore: c.get("ctx")?.cacheStore, call: { method: "POST", @@ -171,6 +182,7 @@ vercelTestApiRouter.get("/__captures/:installationId", async (c) => { const installationId = c.req.param("installationId"); const captures = await getVercelTestCaptures({ installationId, + env: c.env, cacheStore: c.get("ctx")?.cacheStore, }); return c.json({ captures }, 200); @@ -181,6 +193,7 @@ vercelTestApiRouter.delete("/__captures/:installationId", async (c) => { const installationId = c.req.param("installationId"); await clearVercelTestCaptures({ installationId, + env: c.env, cacheStore: c.get("ctx")?.cacheStore, }); return c.json({ cleared: true }, 200); diff --git a/server/src/external/vercel/vercelWebhookRouter.ts b/server/src/external/vercel/vercelWebhookRouter.ts index 66a990c60..126d25dae 100644 --- a/server/src/external/vercel/vercelWebhookRouter.ts +++ b/server/src/external/vercel/vercelWebhookRouter.ts @@ -159,7 +159,7 @@ vercelWebhookRouter.post( return c.json({ received: true }, 200); default: - await sendCustomSvixEvent({ + await sendCustomSvixEvent(c.env)({ appId: org.processor_configs?.vercel?.svix?.[ env === AppEnv.Live ? "live_id" : "sandbox_id" diff --git a/server/src/init.ts b/server/src/init.ts index 6c3b6ce18..b99b3dcbd 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -43,7 +43,6 @@ import { } from "./external/redis/initRedis.js"; import { ensureRedisV2 } from "./external/redis/initRedisV2.js"; import "./external/redis/registerRedisRequestAdapters.js"; -import "./external/redis/redisLockStore.js"; import { primeRedisMonitor } from "./external/redis/initUtils/redisAvailability.js"; import { primeRedisV2Monitor, @@ -58,8 +57,6 @@ import { initSentry } from "./sentry.js"; import { configureTrigger } from "./trigger/configureTrigger.js"; import { checkEnvVars } from "./utils/initUtils.js"; import { startMemoryMonitor } from "./utils/memoryMonitor.js"; -import "./internal/misc/idempotency/redisIdempotencyStore.js"; -import "./internal/misc/rateLimiter/rateLimitRedisStore.js"; let shuttingDown = false; diff --git a/server/src/instrumentation.ts b/server/src/instrumentation.ts index 3803acd0b..6eae03aa2 100644 --- a/server/src/instrumentation.ts +++ b/server/src/instrumentation.ts @@ -35,7 +35,7 @@ export const initTelemetry = (env: Env) => { const exportProcessor = new BatchSpanProcessor(traceExporter, { scheduledDelayMillis: isDev ? 1000 : 5000, }); - const filteredExportProcessor = new FilteringSpanProcessor(exportProcessor); + const filteredExportProcessor = new FilteringSpanProcessor(exportProcessor, env); const metricReader = env.AXIOM_METRICS_DATASET ? new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ diff --git a/server/src/internal/admin/handleAdminCacheV2Ramp.ts b/server/src/internal/admin/handleAdminCacheV2Ramp.ts index 5253eac39..9ccf14c82 100644 --- a/server/src/internal/admin/handleAdminCacheV2Ramp.ts +++ b/server/src/internal/admin/handleAdminCacheV2Ramp.ts @@ -83,7 +83,7 @@ export const handleUpsertAdminCacheV2Ramp = createRoute({ // snapshot (which can lag in multi-instance deployments). const wasConfigured = !!getCacheV2RampConfig(); await upsertCacheV2RampConnection({ - connectionString: encryptData(connectionString), + connectionString: encryptData(connectionString, c.env), url: redisUrl.host, }); diff --git a/server/src/internal/admin/handleGetMasterStripeAccount.ts b/server/src/internal/admin/handleGetMasterStripeAccount.ts index 51279fe77..8a13a1f3a 100644 --- a/server/src/internal/admin/handleGetMasterStripeAccount.ts +++ b/server/src/internal/admin/handleGetMasterStripeAccount.ts @@ -9,7 +9,7 @@ export const handleGetMasterStripeAccount = createRoute({ const { env, logger } = ctx; try { - const masterStripe = initMasterStripe({ env }); + const masterStripe = initMasterStripe(c.env, { env }); const account = await masterStripe.accounts.retrieve(); return c.json({ diff --git a/server/src/internal/analytics/handlers/handleProductsUpdated.ts b/server/src/internal/analytics/handlers/handleProductsUpdated.ts index 654dcb7bd..b217b2973 100644 --- a/server/src/internal/analytics/handlers/handleProductsUpdated.ts +++ b/server/src/internal/analytics/handlers/handleProductsUpdated.ts @@ -63,11 +63,15 @@ export const addProductsUpdatedWebhookTask = async ({ // Build action try { + if (!ctx?.workerEnv) { + throw new Error("Cloudflare Worker env is required to enqueue products updated webhook task"); + } ctx?.logger.info( `[addProductsUpdatedWebhookTask] Sending webhook for product ${cusProduct.product.name}, scenario: ${scenario}`, ); await addTaskToQueue({ jobName: JobName.HandleProductsUpdated, + env: ctx.workerEnv, payload: { reqCtx: ctx ? parseCtxForAction({ ctx }) : undefined, internalCustomerId, diff --git a/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts b/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts index 80cde1ab4..3c127cd96 100644 --- a/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts +++ b/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts @@ -123,7 +123,9 @@ const jsonOAuthError = ({ error }: { error: RecaseError }) => export const handleOAuthConsentWithEnv = async (c: Context) => { const auth = createAuth(c.env); - const { contentType, fields } = await parseRequestFields(c.req.raw.clone()); + const { contentType, fields } = await parseRequestFields( + c.req.raw.clone() as Request, + ); const clientId = getClientIdFromFields(fields); const redirectUri = getRedirectUriFromFields(fields); const env = parseEnv(fields.env); diff --git a/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts index d0cbb7130..9fc78655d 100644 --- a/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts +++ b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts @@ -111,7 +111,9 @@ const jsonTokenResponse = ({ export const handleOAuthTokenWithApiKey = async (c: Context) => { const auth = createAuth(c.env); - const resource = await getResourceFromOAuthTokenRequest(c.req.raw.clone()); + const resource = await getResourceFromOAuthTokenRequest( + c.req.raw.clone() as Request, + ); const response = await auth.handler(c.req.raw); if (!response.ok) return response; diff --git a/server/src/internal/auth/oauth/oauthRouter.ts b/server/src/internal/auth/oauth/oauthRouter.ts index 018a2383c..495452a3e 100644 --- a/server/src/internal/auth/oauth/oauthRouter.ts +++ b/server/src/internal/auth/oauth/oauthRouter.ts @@ -2,7 +2,7 @@ import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, } from "@better-auth/oauth-provider"; -import { type Context, Hono } from "hono"; +import { type Context, Hono, type Next } from "hono"; import { rateLimiter } from "hono-rate-limiter"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { createAuth } from "@/utils/auth.js"; @@ -20,12 +20,18 @@ const getClientLookupRateLimitKey = (c: Context) => c.req.header("cf-connecting-ip") ?? "unknown"; -const oauthClientLookupLimiter = rateLimiter({ - windowMs: 60 * 1000, - limit: (c) => (c.env.NODE_ENV === "development" ? 1000 : 60), - standardHeaders: "draft-6", - keyGenerator: getClientLookupRateLimitKey, -}); +let oauthClientLookupRateLimiter: ReturnType< + typeof rateLimiter +> | null = null; +const oauthClientLookupLimiter = (c: Context, next: Next) => { + oauthClientLookupRateLimiter ??= rateLimiter({ + windowMs: 60 * 1000, + limit: (ctx) => (ctx.env.NODE_ENV === "development" ? 1000 : 60), + standardHeaders: "draft-6", + keyGenerator: getClientLookupRateLimitKey, + }); + return oauthClientLookupRateLimiter(c, next); +}; oauthRouter.get("/api/auth/.well-known/openid-configuration", (c) => { const auth = createAuth(c.env); diff --git a/server/src/internal/balances/events/EventBatchingManager.ts b/server/src/internal/balances/events/EventBatchingManager.ts index ab50a5ead..800a1e605 100644 --- a/server/src/internal/balances/events/EventBatchingManager.ts +++ b/server/src/internal/balances/events/EventBatchingManager.ts @@ -52,6 +52,7 @@ class BatchingManager { await addTaskToQueue({ jobName: JobName.InsertEventBatch, payload: { events: eventItems }, + env, }); await sendEventsToTinybird({ diff --git a/server/src/internal/balances/finalizeLock/insertFinalizeLockEvent.ts b/server/src/internal/balances/finalizeLock/insertFinalizeLockEvent.ts index 605ef8525..d9de88a50 100644 --- a/server/src/internal/balances/finalizeLock/insertFinalizeLockEvent.ts +++ b/server/src/internal/balances/finalizeLock/insertFinalizeLockEvent.ts @@ -27,5 +27,5 @@ export const insertFinalizeLockEvent = ({ entityId: receipt.entity_id ?? undefined, }); - globalEventBatchingManager.addEvent(event); + globalEventBatchingManager.addEvent(event, ctx.workerEnv!); }; diff --git a/server/src/internal/balances/finalizeLock/insertFinalizeLockEventV2.ts b/server/src/internal/balances/finalizeLock/insertFinalizeLockEventV2.ts index a8abc63ce..e7cc3a559 100644 --- a/server/src/internal/balances/finalizeLock/insertFinalizeLockEventV2.ts +++ b/server/src/internal/balances/finalizeLock/insertFinalizeLockEventV2.ts @@ -27,5 +27,5 @@ export const insertFinalizeLockEventV2 = ({ entityId: receipt.entity_id ?? undefined, }); - globalEventBatchingManager.addEvent(event); + globalEventBatchingManager.addEvent(event, ctx.workerEnv!); }; diff --git a/server/src/internal/balances/finalizeLock/runFinalizeLock.ts b/server/src/internal/balances/finalizeLock/runFinalizeLock.ts index b99af6df0..92556b9a9 100644 --- a/server/src/internal/balances/finalizeLock/runFinalizeLock.ts +++ b/server/src/internal/balances/finalizeLock/runFinalizeLock.ts @@ -10,7 +10,7 @@ import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjec import { addToExtraLogs } from "@/utils/logging/addToExtraLogs.js"; import { buildFinalizeLockContext } from "./buildFinalizeLockContext.js"; import { runFinalizeLockV2 } from "./runFinalizeLockV2.js"; -import { runRedisFinalizeLock } from "./runRedisFinalizeLock.js"; +import { runPostgresFinalizeLock } from "./runPostgresFinalizeLock.js"; type RunFinalizeLockArgs = { workerEnv?: Env; @@ -92,7 +92,7 @@ const runFinalizeLockInner = async ({ return { success: true }; } - await runRedisFinalizeLock({ ctx, finalizeLockContext, redisInstance }); + await runPostgresFinalizeLock({ ctx, finalizeLockContext }); await deleteLockReceipt({ lockReceiptKey, redisInstance }); diff --git a/server/src/internal/balances/finalizeLock/runFinalizeLockV2.ts b/server/src/internal/balances/finalizeLock/runFinalizeLockV2.ts index 97009df06..670d2d492 100644 --- a/server/src/internal/balances/finalizeLock/runFinalizeLockV2.ts +++ b/server/src/internal/balances/finalizeLock/runFinalizeLockV2.ts @@ -11,7 +11,7 @@ import { cancelLockExpiry } from "@/internal/balances/utils/lock/cancelLockExpir import type { LockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js"; import { buildFinalizeLockContextV2 } from "@/internal/balances/utils/lockV2/buildFinalizeLockContextV2.js"; import { deleteLockReceiptV2 } from "@/internal/balances/utils/lockV2/deleteLockReceiptV2.js"; -import { runRedisFinalizeLockV2 } from "./runRedisFinalizeLockV2.js"; +import { runPostgresFinalizeLockV2 } from "./runPostgresFinalizeLockV2.js"; /** * V2 finalize. Receives the receipt + claim outcome from the dispatcher @@ -72,7 +72,7 @@ export const runFinalizeLockV2 = async ({ return { success: true }; } - await runRedisFinalizeLockV2({ ctx, finalizeLockContext }); + await runPostgresFinalizeLockV2({ ctx, finalizeLockContext }); await deleteLockReceiptV2({ lockReceiptKey, redisInstance }); return { success: true }; diff --git a/server/src/internal/balances/finalizeLock/runRedisFinalizeLock.ts b/server/src/internal/balances/finalizeLock/runRedisFinalizeLock.ts index a3189b7bc..d31c5e726 100644 --- a/server/src/internal/balances/finalizeLock/runRedisFinalizeLock.ts +++ b/server/src/internal/balances/finalizeLock/runRedisFinalizeLock.ts @@ -1,5 +1,4 @@ import type { Redis } from "ioredis"; -import { currentRegion } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { executeRedisDeduction } from "@/internal/balances/utils/deduction/executeRedisDeduction.js"; import { deductionUpdatesToModifiedIds } from "@/internal/balances/utils/sync/deductionUpdatesToModifiedIds.js"; @@ -9,6 +8,8 @@ import type { FinalizeLockContext } from "./buildFinalizeLockContext.js"; import { insertFinalizeLockEvent } from "./insertFinalizeLockEvent.js"; import { runPostgresFinalizeLock } from "./runPostgresFinalizeLock.js"; +const DEFAULT_SYNC_REGION = "us-west-2"; + export const runRedisFinalizeLock = async ({ ctx, finalizeLockContext, @@ -55,13 +56,14 @@ export const runRedisFinalizeLock = async ({ if (modifiedCusEntIds.length > 0 || rolloverIds.length > 0) { ctx.logger.info(`[QUEUE SYNC] (${receipt.customer_id})`); + const syncRegion = ctx.workerEnv?.AWS_REGION || DEFAULT_SYNC_REGION; globalSyncBatchingManagerV2.addSyncItem({ customerId: receipt.customer_id, orgId: ctx.org.id, env: ctx.env, cusEntIds: modifiedCusEntIds, rolloverIds, - region: currentRegion, + region: syncRegion, }); } diff --git a/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts b/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts index 7f84b3f13..d5790b91b 100644 --- a/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts +++ b/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts @@ -1,4 +1,3 @@ -import { currentRegion } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { executeRedisDeductionV2 } from "@/internal/balances/utils/deductionV2/executeRedisDeductionV2.js"; import type { FinalizeLockContextV2 } from "@/internal/balances/utils/lockV2/buildFinalizeLockContextV2.js"; @@ -8,6 +7,8 @@ import { RedisDeductionError } from "@/internal/balances/utils/types/redisDeduct import { insertFinalizeLockEventV2 } from "./insertFinalizeLockEventV2.js"; import { runPostgresFinalizeLockV2 } from "./runPostgresFinalizeLockV2.js"; +const DEFAULT_SYNC_REGION = "us-west-2"; + export const runRedisFinalizeLockV2 = async ({ ctx, finalizeLockContext, @@ -55,13 +56,14 @@ export const runRedisFinalizeLockV2 = async ({ rolloverIds.length > 0 || usageWindowUpdates.length > 0 ) { + const syncRegion = ctx.workerEnv?.AWS_REGION || DEFAULT_SYNC_REGION; globalSyncBatchingManagerV3.addSyncItem({ customerId: receipt.customer_id, orgId: ctx.org.id, env: ctx.env, cusEntIds: modifiedCusEntIds, rolloverIds, - region: currentRegion, + region: syncRegion, entityId: receipt.entity_id ?? undefined, modifiedCusEntIdsByFeatureId, usageWindowUpdates, diff --git a/server/src/internal/balances/track/runAsyncTrack.ts b/server/src/internal/balances/track/runAsyncTrack.ts index 9ba599a5f..3b6e756da 100644 --- a/server/src/internal/balances/track/runAsyncTrack.ts +++ b/server/src/internal/balances/track/runAsyncTrack.ts @@ -12,8 +12,8 @@ export const runAsyncTrack = async ({ ctx: AutumnContext; body: TrackParams; }): Promise => { - const workerEnv = ctx.workerEnv ?? (process.env as unknown as Env); - if (!workerEnv.TRACK_ASYNC_QUEUE) { + const workerEnv = ctx.workerEnv; + if (!workerEnv?.TRACK_ASYNC_QUEUE) { ctx.logger.error( "[track] async=true requested but no async track queue is configured", ); diff --git a/server/src/internal/balances/track/runBatchTrack.ts b/server/src/internal/balances/track/runBatchTrack.ts index 2bdc99fb7..84a36e0b4 100644 --- a/server/src/internal/balances/track/runBatchTrack.ts +++ b/server/src/internal/balances/track/runBatchTrack.ts @@ -15,8 +15,8 @@ export const runBatchTrack = async ({ ctx: AutumnContext; body: BatchTrackParams; }): Promise => { - const workerEnv = ctx.workerEnv ?? (process.env as unknown as Env); - if (!workerEnv.TRACK_ASYNC_QUEUE) { + const workerEnv = ctx.workerEnv; + if (!workerEnv?.TRACK_ASYNC_QUEUE) { ctx.logger.error( "[track] batch track requested but no async track queue is configured", ); diff --git a/server/src/internal/balances/track/runTrackV2.ts b/server/src/internal/balances/track/runTrackV2.ts index a977ee1b3..cb8c30a17 100644 --- a/server/src/internal/balances/track/runTrackV2.ts +++ b/server/src/internal/balances/track/runTrackV2.ts @@ -8,12 +8,10 @@ import { type TrackParams, type TrackResponseV3, } from "@autumn/shared"; -import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; -import { getOrCreateCachedFullCustomer } from "../../customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; import type { FeatureDeduction } from "../utils/types/featureDeduction.js"; import { handleEventIdempotencyKey } from "./utils/handleEventIdempotencyKey.js"; -import { runRedisTrack } from "./utils/runRedisTrack.js"; +import { runPostgresTrack } from "./utils/runPostgresTrack.js"; export const runTrackV2 = async ({ ctx, @@ -36,21 +34,6 @@ export const runTrackV2 = async ({ }); } - // 1. Get full customer from cache or DB - const { customer_id, entity_id } = body; - const fullCustomer = ctx.apiVersion.gte(ApiVersion.V2_1) - ? await getOrSetCachedFullCustomer({ - ctx, - customerId: customer_id, - entityId: entity_id, - source: "getCheckData", - }) - : await getOrCreateCachedFullCustomer({ - ctx, - params: body, - source: "runTrackV2", - }); - // If idempotency key is provided, insert event first and skip insertion later if (body.idempotency_key) { await handleEventIdempotencyKey({ @@ -59,12 +42,9 @@ export const runTrackV2 = async ({ }); } - // Try Redis deduction - returns TrackResponseV3 (with ApiBalanceV1) - const response: TrackResponseV3 = await runRedisTrack({ + const response: TrackResponseV3 = await runPostgresTrack({ ctx, - fullCustomer, featureDeductions, - overageBehavior: body.overage_behavior || "cap", body, }); diff --git a/server/src/internal/balances/track/utils/queueTrack.ts b/server/src/internal/balances/track/utils/queueTrack.ts index 0bd1e68f9..a31f48613 100644 --- a/server/src/internal/balances/track/utils/queueTrack.ts +++ b/server/src/internal/balances/track/utils/queueTrack.ts @@ -19,10 +19,8 @@ export const queueTrack = async ({ messageDeduplicationId?: string; }) => { try { - const workerEnv = ctx.workerEnv ?? (process.env as unknown as Env); - const hasCloudflareQueue = - !!workerEnv.TRACK_QUEUE || !!workerEnv.TRACK_ASYNC_QUEUE; - if (!hasCloudflareQueue) { + const workerEnv = ctx.workerEnv; + if (!workerEnv || (!workerEnv.TRACK_QUEUE && !workerEnv.TRACK_ASYNC_QUEUE)) { ctx.logger.warn( "[track] Redis unavailable and TRACK_QUEUE is unbound; falling back to synchronous track", ); diff --git a/server/src/internal/balances/track/utils/runPostgresTrack.ts b/server/src/internal/balances/track/utils/runPostgresTrack.ts index 7e206362a..653928d6d 100644 --- a/server/src/internal/balances/track/utils/runPostgresTrack.ts +++ b/server/src/internal/balances/track/utils/runPostgresTrack.ts @@ -54,7 +54,7 @@ export const runPostgresTrack = async ({ const { fullCus, updates } = result; // Insert event directly into database - if (!body.skip_event && !body.idempotency_key && fullCus) { + if (!body.skip_event && fullCus) { const eventInfo = buildEventInfo(body); const event = initEvent({ ctx, @@ -66,7 +66,7 @@ export const runPostgresTrack = async ({ }); // await EventService.insert({ db: ctx.db, event }); - globalEventBatchingManager.addEvent(event); + globalEventBatchingManager.addEvent(event, ctx.workerEnv!); } // Build response using unified deductionToTrackResponse diff --git a/server/src/internal/balances/track/utils/runRedisTrack.ts b/server/src/internal/balances/track/utils/runRedisTrack.ts index 9f5763c81..2346a02da 100644 --- a/server/src/internal/balances/track/utils/runRedisTrack.ts +++ b/server/src/internal/balances/track/utils/runRedisTrack.ts @@ -4,7 +4,6 @@ import type { TrackResponseV3, } from "@autumn/shared"; import { tryCatch } from "@autumn/shared"; -import { currentRegion } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { globalEventBatchingManager } from "../../events/EventBatchingManager.js"; import { buildEventInfo, initEvent } from "../../events/initEvent.js"; @@ -18,6 +17,8 @@ import type { RolloverUpdate } from "../../utils/types/rolloverUpdate.js"; import { buildAiCreditCostProperty } from "./buildAiCreditCostProperty.js"; import { handleRedisTrackError } from "./handleRedisTrackError.js"; +const DEFAULT_SYNC_REGION = "us-west-2"; + const aiCreditCostEntries = ({ updates, fullCustomer, @@ -58,13 +59,14 @@ const queueSyncItem = ({ if (modifiedCusEntIds.length === 0 && rolloverIds.length === 0) return; ctx.logger.info(`[QUEUE SYNC] (${body.customer_id})`); + const syncRegion = ctx.workerEnv?.AWS_REGION || DEFAULT_SYNC_REGION; globalSyncBatchingManagerV2.addSyncItem({ customerId: body.customer_id, orgId: ctx.org.id, env: ctx.env, cusEntIds: modifiedCusEntIds, rolloverIds, - region: currentRegion, + region: syncRegion, }); }; @@ -89,8 +91,9 @@ const queueEvent = ({ internalEntityId: fullCustomer.entity?.internal_id, customerId: body.customer_id, entityId: body.entity_id, - }), - ); + }), + ctx.workerEnv!, + ); }; /** diff --git a/server/src/internal/balances/track/v3/runPostgresTrackV3.ts b/server/src/internal/balances/track/v3/runPostgresTrackV3.ts index 8ea43cbee..1d63b06e9 100644 --- a/server/src/internal/balances/track/v3/runPostgresTrackV3.ts +++ b/server/src/internal/balances/track/v3/runPostgresTrackV3.ts @@ -7,11 +7,9 @@ import { initEvent, } from "@/internal/balances/events/initEvent.js"; import { resolveInternalProductIdForEvent } from "../../events/resolveInternalProductIdForEvent.js"; -import { - deductionToTrackResponseV2, - executePostgresDeductionV2, - projectMutationLogsToTrackDeductionsV2, -} from "@/internal/balances/utils/deductionV2/index.js"; +import { deductionToTrackResponseV2 } from "@/internal/balances/utils/deductionV2/deductionToTrackResponseV2.js"; +import { executePostgresDeductionV2 } from "@/internal/balances/utils/deductionV2/executePostgresDeductionV2.js"; +import { projectMutationLogsToTrackDeductionsV2 } from "@/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; import { handlePostgresTrackError } from "../utils/handlePostgresTrackError.js"; @@ -59,7 +57,7 @@ export const runPostgresTrackV3 = async ({ mutationLogs, }); - if (!body.skip_event && !body.idempotency_key) { + if (!body.skip_event) { const eventInfo = buildEventInfo(body); const event = initEvent({ ctx, @@ -72,7 +70,7 @@ export const runPostgresTrackV3 = async ({ deductions, }); - globalEventBatchingManager.addEvent(event); + globalEventBatchingManager.addEvent(event, ctx.workerEnv!); } const { balance, balances } = await deductionToTrackResponseV2({ diff --git a/server/src/internal/balances/track/v3/runRedisTrackV3.ts b/server/src/internal/balances/track/v3/runRedisTrackV3.ts index 47e53d96a..7199abdfd 100644 --- a/server/src/internal/balances/track/v3/runRedisTrackV3.ts +++ b/server/src/internal/balances/track/v3/runRedisTrackV3.ts @@ -89,8 +89,9 @@ const queueEvent = ({ entityId: body.entity_id, deductions, internalProductId, - }), - ); + }), + ctx.workerEnv!, + ); }; export const runRedisTrackV3 = async ({ diff --git a/server/src/internal/balances/track/v3/runTrackV3.ts b/server/src/internal/balances/track/v3/runTrackV3.ts index a9291195e..c621ab3e3 100644 --- a/server/src/internal/balances/track/v3/runTrackV3.ts +++ b/server/src/internal/balances/track/v3/runTrackV3.ts @@ -14,8 +14,7 @@ import { getOrCreateCachedFullSubject } from "@/internal/customers/cache/fullSub import { getOrSetCachedFullSubject } from "@/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; import { handleEventIdempotencyKey } from "../utils/handleEventIdempotencyKey.js"; -import { runRedisTrackV3 } from "./runRedisTrackV3.js"; -import { getTrackIdempotencyKey } from "./trackIdempotencyKey.js"; +import { runPostgresTrackV3 } from "./runPostgresTrackV3.js"; const getTrackFullSubject = async ({ ctx, @@ -72,15 +71,11 @@ export const runTrackV3 = async ({ }); } - const redisIdempotencyKey = getTrackIdempotencyKey({ ctx }); - - const response: TrackResponseV3 = await runRedisTrackV3({ + const response: TrackResponseV3 = await runPostgresTrackV3({ ctx, fullSubject, featureDeductions, - overageBehavior: body.overage_behavior || "cap", body, - idempotencyKey: redisIdempotencyKey, }); return applyResponseVersionChanges({ diff --git a/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts b/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts index e1521e380..bd05a070a 100644 --- a/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts +++ b/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts @@ -1,19 +1,12 @@ import type { CustomerEntitlementFilters, FullCustomer } from "@autumn/shared"; -import { tryCatch } from "@autumn/shared"; -import { currentRegion } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { executePostgresDeduction } from "../utils/deduction/executePostgresDeduction.js"; -import { executeRedisDeduction } from "../utils/deduction/executeRedisDeduction.js"; -import { deductionUpdatesToModifiedIds } from "../utils/sync/deductionUpdatesToModifiedIds.js"; -import { syncItemV3 } from "../utils/sync/syncItemV3.js"; import type { DeductionOptions } from "../utils/types/deductionTypes.js"; import type { FeatureDeduction } from "../utils/types/featureDeduction.js"; -import { RedisDeductionError } from "../utils/types/redisDeductionError.js"; /** - * Updates balance in Redis using featureDeductions with targetBalance. - * Falls back to Postgres if Redis fails with recoverable errors. - * Syncs to Postgres after successful Redis update. + * Updates balance using the DB-backed deduction path. + * Kept under the legacy filename while callers are migrated incrementally. */ export const runRedisUpdateBalanceV2 = async ({ ctx, @@ -26,7 +19,6 @@ export const runRedisUpdateBalanceV2 = async ({ featureDeductions: FeatureDeduction[]; customerEntitlementFilters?: CustomerEntitlementFilters; }) => { - const { org, env } = ctx; const customerId = fullCustomer.id || fullCustomer.internal_id; const entityId = fullCustomer.entity?.id ?? undefined; @@ -36,59 +28,12 @@ export const runRedisUpdateBalanceV2 = async ({ alterGrantedBalance: false, }; - const { data: result, error } = await tryCatch( - executeRedisDeduction({ - ctx, - fullCustomer, - entityId, - deductions: featureDeductions, - deductionOptions, - }), - ); - - // Handle errors - if (error) { - if (error instanceof RedisDeductionError && error.shouldFallback()) { - // Fallback to Postgres for recoverable errors - - ctx.logger.info( - `[runRedisUpdateBalanceV2] Falling back to Postgres (${error.code})`, - ); - - await executePostgresDeduction({ - ctx, - fullCustomer, - customerId, - entityId, - deductions: featureDeductions, - options: deductionOptions, - }); - - return; - } - - throw error; - } - - const { updates, rolloverUpdates } = result; - - const modifiedCusEntIds = deductionUpdatesToModifiedIds({ updates }); - const modifiedRolloverIds = Object.keys(rolloverUpdates); - - if (modifiedCusEntIds.length > 0 || modifiedRolloverIds.length > 0) { - await syncItemV3({ - payload: { - customerId, - orgId: org.id, - env, - cusEntIds: modifiedCusEntIds, - rolloverIds: modifiedRolloverIds, - region: currentRegion, - timestamp: Date.now(), - }, - ctx, - }); - } - - return result; + return executePostgresDeduction({ + ctx, + fullCustomer, + customerId, + entityId, + deductions: featureDeductions, + options: deductionOptions, + }); }; diff --git a/server/src/internal/balances/updateBalance/v2/updateRemainingV2.ts b/server/src/internal/balances/updateBalance/v2/updateRemainingV2.ts index 1123eba5b..9ad1dd59f 100644 --- a/server/src/internal/balances/updateBalance/v2/updateRemainingV2.ts +++ b/server/src/internal/balances/updateBalance/v2/updateRemainingV2.ts @@ -2,15 +2,12 @@ import { FeatureNotFoundError, type FullSubject, notNullish, - tryCatch, type UpdateBalanceParamsV0, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { executeRedisDeductionV2 } from "@/internal/balances/utils/deductionV2/executeRedisDeductionV2.js"; -import { syncItemV4 } from "@/internal/balances/utils/sync/syncItemV4.js"; +import { executePostgresDeductionV2 } from "@/internal/balances/utils/deductionV2/executePostgresDeductionV2.js"; import { buildCustomerEntitlementFilters } from "../../utils/buildCustomerEntitlementFilters.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; -import { handleUpdateBalanceDeductionErrorV2 } from "./handleUpdateBalanceDeductionErrorV2.js"; /** Updates remaining balance using the FullSubject cache path. */ export const updateRemainingV2 = async ({ @@ -43,54 +40,16 @@ export const updateRemainingV2 = async ({ const entityId = fullSubject.entityId; - const { data: result, error } = await tryCatch( - executeRedisDeductionV2({ - ctx, - fullSubject, - entityId, - deductions: featureDeductions, - deductionOptions: { - overageBehaviour: "allow", - customerEntitlementFilters, - alterGrantedBalance: false, - }, - }), - ); - - if (error) { - return handleUpdateBalanceDeductionErrorV2({ - ctx, - error, - fullSubject, - featureDeductions, + return executePostgresDeductionV2({ + ctx, + fullSubject, + customerId: fullSubject.customerId, + entityId, + deductions: featureDeductions, + options: { + overageBehaviour: "allow", customerEntitlementFilters, - }); - } - - const { rolloverUpdates, modifiedCusEntIdsByFeatureId, usageWindowUpdates } = - result; - const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat(); - const rolloverIds = Object.keys(rolloverUpdates); - - if ( - cusEntIds.length > 0 || - rolloverIds.length > 0 || - usageWindowUpdates.length > 0 - ) { - await syncItemV4({ - ctx, - payload: { - customerId: fullSubject.customerId, - orgId: ctx.org.id, - env: ctx.env, - timestamp: Date.now(), - rolloverIds, - entityId: fullSubject.entityId, - modifiedCusEntIdsByFeatureId, - usageWindowUpdates, - }, - }); - } - - return result; + alterGrantedBalance: false, + }, + }); }; diff --git a/server/src/internal/balances/updateBalance/v2/updateUsageV2.ts b/server/src/internal/balances/updateBalance/v2/updateUsageV2.ts index cc189a066..bb8099151 100644 --- a/server/src/internal/balances/updateBalance/v2/updateUsageV2.ts +++ b/server/src/internal/balances/updateBalance/v2/updateUsageV2.ts @@ -6,16 +6,13 @@ import { type FullSubject, fullSubjectToCustomerEntitlements, nullish, - tryCatch, type UpdateBalanceParamsV0, } from "@autumn/shared"; import { Decimal } from "decimal.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { executeRedisDeductionV2 } from "@/internal/balances/utils/deductionV2/executeRedisDeductionV2.js"; -import { syncItemV4 } from "@/internal/balances/utils/sync/syncItemV4.js"; +import { executePostgresDeductionV2 } from "@/internal/balances/utils/deductionV2/executePostgresDeductionV2.js"; import { buildCustomerEntitlementFilters } from "../../utils/buildCustomerEntitlementFilters.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; -import { handleUpdateBalanceDeductionErrorV2 } from "./handleUpdateBalanceDeductionErrorV2.js"; const getUpdateUsageTargetBalance = ({ fullSubject, @@ -87,54 +84,16 @@ export const updateUsageV2 = async ({ }, ]; - const { data: result, error } = await tryCatch( - executeRedisDeductionV2({ - ctx, - fullSubject, - entityId, - deductions: featureDeductions, - deductionOptions: { - overageBehaviour: "allow", - customerEntitlementFilters, - alterGrantedBalance: false, - }, - }), - ); - - if (error) { - return handleUpdateBalanceDeductionErrorV2({ - ctx, - error, - fullSubject, - featureDeductions, + return executePostgresDeductionV2({ + ctx, + fullSubject, + customerId: fullSubject.customerId, + entityId, + deductions: featureDeductions, + options: { + overageBehaviour: "allow", customerEntitlementFilters, - }); - } - - const { rolloverUpdates, modifiedCusEntIdsByFeatureId, usageWindowUpdates } = - result; - const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat(); - const rolloverIds = Object.keys(rolloverUpdates); - - if ( - cusEntIds.length > 0 || - rolloverIds.length > 0 || - usageWindowUpdates.length > 0 - ) { - await syncItemV4({ - ctx, - payload: { - customerId: fullSubject.customerId, - orgId: ctx.org.id, - env: ctx.env, - timestamp: Date.now(), - rolloverIds, - entityId: fullSubject.entityId, - modifiedCusEntIdsByFeatureId, - usageWindowUpdates, - }, - }); - } - - return result; + alterGrantedBalance: false, + }, + }); }; diff --git a/server/src/internal/balances/utils/deduction/computeCreditCosts.ts b/server/src/internal/balances/utils/deduction/computeCreditCosts.ts index b72023cf2..21115cb7c 100644 --- a/server/src/internal/balances/utils/deduction/computeCreditCosts.ts +++ b/server/src/internal/balances/utils/deduction/computeCreditCosts.ts @@ -11,14 +11,14 @@ export type CreditCostLookup = (entitlementId: string) => number; export const computeCreditCosts = ({ cusEnts, deduction, - env + env, }: { cusEnts: FullCusEntWithFullCusProduct[]; deduction: FeatureDeduction; - env: Env + env: Env; }): CreditCostLookup => { const costMap = new Map(); - const logger = createLogger(env) + const logger = createLogger(env); for (const ce of cusEnts) { // Token cost is USD: 1:1 on its own ent; parents apply their ratio to it. if ( diff --git a/server/src/internal/balances/utils/deduction/executeDeductionCache.ts b/server/src/internal/balances/utils/deduction/executeDeductionCache.ts index abc8b1217..1510cee71 100644 --- a/server/src/internal/balances/utils/deduction/executeDeductionCache.ts +++ b/server/src/internal/balances/utils/deduction/executeDeductionCache.ts @@ -1,6 +1,9 @@ import type { EntityRolloverBalance, FullCustomer } from "@autumn/shared"; -import { redis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { + hasNonRedisSnapshotCacheStore, + invalidateCustomerCacheStoreSnapshots, +} from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import type { DeductionUpdate } from "../types/deductionUpdate.js"; @@ -15,8 +18,8 @@ export interface RolloverOverwrite { } /** - * Atomically updates cusEnt fields in the cached FullCustomer blob after a - * Postgres deduction. Uses the unified updateCustomerEntitlements Lua script. + * Keeps customer snapshots fresh after a Postgres deduction. CacheStore-backed + * snapshots are invalidated; legacy Redis snapshots still use the Lua patch. * Fire-and-forget -- failures are logged but don't propagate. */ export const syncCustomerEntitlementUpdatesToCache = async ({ @@ -50,7 +53,16 @@ export const syncCustomerEntitlementUpdatesToCache = async ({ ); if (cusEntIds.length === 0) return; - const { org, env } = ctx; + if (hasNonRedisSnapshotCacheStore({ ctx })) { + await invalidateCustomerCacheStoreSnapshots({ + ctx, + customerId, + source: "syncCustomerEntitlementUpdatesToCache", + }); + return; + } + + const { org, env, redisV2 } = ctx; const cacheKey = buildFullCustomerCacheKey({ orgId: org.id, @@ -89,8 +101,14 @@ export const syncCustomerEntitlementUpdatesToCache = async ({ }; }); - await tryRedisWrite(() => - redis.updateCustomerEntitlements(cacheKey, JSON.stringify({ updates })), + await tryRedisWrite( + ctx.workerEnv ?? ({} as Env), + () => + redisV2.updateCustomerEntitlements( + cacheKey, + JSON.stringify({ updates }), + ), + redisV2, ); } catch (error) { ctx.logger.error( diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index 4a225068a..13a41b0ab 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -51,6 +51,7 @@ export const executePostgresDeduction = async ({ mutationLogs: MutationLogItem[]; }> => { const { db, org, env } = ctx; + const deductionRegion = ctx.workerEnv?.AWS_REGION || "us-west-2"; ctx.logger.info( `executing postgres deduction, deductions: ${JSON.stringify( @@ -128,6 +129,8 @@ export const executePostgresDeduction = async ({ entityId, items: [], overrideLockValue: toDeduct, + redisInstance: ctx.redisV2, + region: deductionRegion, }); } continue; @@ -225,6 +228,8 @@ export const executePostgresDeduction = async ({ featureId: feature.id, entityId, items: mutation_logs ?? [], + redisInstance: ctx.redisV2, + region: deductionRegion, }); } } catch (error) { diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index c37ec0658..45b81063f 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -2,14 +2,13 @@ import type { FullCusEntWithFullCusProduct, FullCustomer, } from "@autumn/shared"; -import type { Redis } from "ioredis"; -import { currentRegion, redis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { triggerAutoTopUp } from "@/internal/balances/autoTopUp/triggerAutoTopUp.js"; import { handlePaidAllocatedCusEnt } from "@/internal/balances/utils/paidAllocatedFeature/handlePaidAllocatedCusEnt.js"; import { rollbackDeduction } from "@/internal/balances/utils/paidAllocatedFeature/rollbackDeduction.js"; import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import type { LegacyRedisClient } from "@/utils/legacyRedisClient.js"; import { fireTrackWebhooks } from "../../trackWebhooks/fireTrackWebhooks.js"; import { saveLockReceipt } from "../lock/saveLockReceipt.js"; import type { DeductionOptions } from "../types/deductionTypes.js"; @@ -29,6 +28,8 @@ import { mutationLogsToFeatures } from "./mutationLogsToFeatures.js"; import { prepareDeductionOptions } from "./prepareDeductionOptions.js"; import { prepareFeatureDeduction } from "./prepareFeatureDeduction.js"; +const DEFAULT_DEDUCTION_REGION = "us-west-2"; + export const executeRedisDeduction = async ({ ctx, entityId, @@ -42,7 +43,7 @@ export const executeRedisDeduction = async ({ deductions: FeatureDeduction[]; fullCustomer: FullCustomer; deductionOptions?: DeductionOptions; - redisInstance?: Redis; + redisInstance?: LegacyRedisClient; }): Promise<{ oldFullCus: FullCustomer; fullCus: FullCustomer | undefined; @@ -51,6 +52,8 @@ export const executeRedisDeduction = async ({ mutationLogs: MutationLogItem[]; }> => { const { org, env } = ctx; + const targetRedis = redisInstance ?? ctx.redisV2; + const deductionRegion = ctx.workerEnv?.AWS_REGION || DEFAULT_DEDUCTION_REGION; const oldFullCus = structuredClone(fullCustomer); const options = prepareDeductionOptions({ @@ -125,7 +128,8 @@ export const executeRedisDeduction = async ({ entityId, items: [], overrideLockValue: toDeduct, - redisInstance, + redisInstance: targetRedis, + region: deductionRegion, }); } continue; @@ -151,7 +155,7 @@ export const executeRedisDeduction = async ({ lock: preparedLock ? { ...preparedLock, - region: currentRegion, + region: deductionRegion, } : null, @@ -160,14 +164,13 @@ export const executeRedisDeduction = async ({ lock_receipt_key: lockReceiptKey ?? null, }; - const targetRedis = redisInstance ?? redis; const result = await tryRedisWrite( () => targetRedis.deductFromCustomerEntitlements( cacheKey, JSON.stringify(luaParams), ), - redisInstance, + targetRedis, ); if (!result) { diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts index bdb599fbf..5ea660461 100644 --- a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -101,7 +101,11 @@ export const prepareFeatureDeduction = ({ .map((ce) => ce.entitlement.feature.id), ); - const getCreditCostForEnt = computeCreditCosts({ cusEnts, deduction }); + const getCreditCostForEnt = computeCreditCosts({ + cusEnts, + deduction, + env: ctx.workerEnv!, + }); // Build input for each customer entitlement const customerEntitlementDeductions: CustomerEntitlementDeduction[] = diff --git a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts index f1a564400..54a6ab933 100644 --- a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts @@ -29,6 +29,8 @@ import { prepareFeatureDeductionV2 } from "./prepareFeatureDeductionV2.js"; import { rollbackDeductionV2 } from "./rollbackDeductionV2.js"; import { syncDeductionUpdatesToFullSubjectCache } from "./syncDeductionUpdatesToFullSubjectCache.js"; +const DEFAULT_DEDUCTION_REGION = "us-west-2"; + interface RolloverOverwrite { id: string; cus_ent_id: string; @@ -59,6 +61,7 @@ export const executePostgresDeductionV2 = async ({ modifiedCusEntIdsByFeatureId: Record; }> => { const { db, org, env } = ctx; + const deductionRegion = ctx.workerEnv?.AWS_REGION || DEFAULT_DEDUCTION_REGION; ctx.logger.info( `executing postgres deduction v2, deductions: ${JSON.stringify( @@ -132,6 +135,7 @@ export const executePostgresDeductionV2 = async ({ items: [], overrideLockValue: toDeduct, redisInstance: ctx.redisV2, + region: deductionRegion, }); } const unlimitedPlanLog = buildUnlimitedPlanMutationLog({ @@ -263,6 +267,7 @@ export const executePostgresDeductionV2 = async ({ entityId, items: mutation_logs ?? [], redisInstance: ctx.redisV2, + region: deductionRegion, }); } } catch (error) { diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index 89f379f13..9b003b022 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -6,7 +6,6 @@ import { notNullish, } from "@autumn/shared"; import type { Redis } from "ioredis"; -import { currentRegion } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { triggerAutoTopUp } from "@/internal/balances/autoTopUp/triggerAutoTopUp.js"; import { @@ -43,6 +42,8 @@ import { prepareDeductionOptionsV2 } from "./prepareDeductionOptionsV2.js"; import { prepareFeatureDeductionV2 } from "./prepareFeatureDeductionV2.js"; import { rollbackDeductionV2 } from "./rollbackDeductionV2.js"; +const DEFAULT_DEDUCTION_REGION = "us-west-2"; + export const executeRedisDeductionV2 = async ({ ctx, fullSubject, @@ -72,6 +73,7 @@ export const executeRedisDeductionV2 = async ({ usageWindowMutations: UsageWindowMutation[]; }> => { workerEnv ??= ctx.workerEnv ?? ({} as Env); + const deductionRegion = workerEnv.AWS_REGION || DEFAULT_DEDUCTION_REGION; const { org, env } = ctx; const oldFullSubject = structuredClone(fullSubject); @@ -162,6 +164,7 @@ export const executeRedisDeductionV2 = async ({ items: [], overrideLockValue: toDeduct, redisInstance: redisInstance ?? ctx.redisV2, + region: deductionRegion, }); } const unlimitedPlanLog = buildUnlimitedPlanMutationLog({ @@ -231,7 +234,7 @@ export const executeRedisDeductionV2 = async ({ lock: preparedLock ? { ...preparedLock, - region: currentRegion, + region: deductionRegion, } : null, unwind_value: unwindValue ?? null, diff --git a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts index 741f5ec26..eec1bdff8 100644 --- a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts @@ -161,6 +161,7 @@ export const prepareFeatureDeductionV2 = ({ const getCreditCostForEnt = computeCreditCosts({ cusEnts: customerEntitlements, deduction, + env: ctx.workerEnv!, }); const customerEntitlementDeductions: CustomerEntitlementDeduction[] = diff --git a/server/src/internal/balances/utils/deductionV2/syncDeductionUpdatesToFullSubjectCache.ts b/server/src/internal/balances/utils/deductionV2/syncDeductionUpdatesToFullSubjectCache.ts index 1db3cb12e..53d89406c 100644 --- a/server/src/internal/balances/utils/deductionV2/syncDeductionUpdatesToFullSubjectCache.ts +++ b/server/src/internal/balances/utils/deductionV2/syncDeductionUpdatesToFullSubjectCache.ts @@ -2,6 +2,10 @@ import type { EntityRolloverBalance, FullSubject } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.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 { + hasNonRedisSnapshotCacheStore, + invalidateCustomerCacheStoreSnapshots, +} from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import type { DeductionUpdate } from "../types/deductionUpdate.js"; @@ -110,9 +114,19 @@ export const syncDeductionUpdatesToFullSubjectCache = async ({ } } - if (Object.keys(updatesByFeatureId).length === 0) return; + if (Object.keys(updatesByFeatureId).length === 0) return; - const pipeline = redisV2.pipeline(); + if (hasNonRedisSnapshotCacheStore({ ctx })) { + await invalidateCustomerCacheStoreSnapshots({ + ctx, + customerId, + entityId: fullSubject.entityId, + source: "syncDeductionUpdatesToFullSubjectCache", + }); + return; + } + + const pipeline = redisV2.pipeline(); for (const [featureId, updates] of Object.entries(updatesByFeatureId)) { const balanceKey = buildSharedFullSubjectBalanceKey({ orgId: org.id, diff --git a/server/src/internal/balances/utils/lock/saveLockReceipt.ts b/server/src/internal/balances/utils/lock/saveLockReceipt.ts index 129304894..5d2e4437e 100644 --- a/server/src/internal/balances/utils/lock/saveLockReceipt.ts +++ b/server/src/internal/balances/utils/lock/saveLockReceipt.ts @@ -1,8 +1,9 @@ import { ErrCode, InternalError, RecaseError } from "@autumn/shared"; -import type { Redis } from "ioredis"; -import { currentRegion, redis } from "@/external/redis/initRedis.js"; import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import type { LegacyRedisClient } from "@/utils/legacyRedisClient.js"; + +const DEFAULT_LOCK_RECEIPT_REGION = "us-west-2"; export const saveLockReceipt = async ({ lock, @@ -12,6 +13,7 @@ export const saveLockReceipt = async ({ items, overrideLockValue, redisInstance, + region = DEFAULT_LOCK_RECEIPT_REGION, }: { lock: { lock_id?: string; @@ -26,9 +28,10 @@ export const saveLockReceipt = async ({ entityId?: string; items: MutationLogItem[]; overrideLockValue?: number; - redisInstance?: Redis; + redisInstance: LegacyRedisClient; + region?: string; }) => { - const targetRedis = redisInstance ?? redis; + const targetRedis = redisInstance; const existing = await targetRedis.call("EXISTS", lock.redis_receipt_key); if (existing === 1) { @@ -49,7 +52,7 @@ export const saveLockReceipt = async ({ lock_id: lock.lock_id ?? null, hashed_key: lock.hashed_key ?? null, status: "pending", - region: currentRegion, + region, customer_id: customerId, feature_id: featureId, entity_id: entityId ?? null, diff --git a/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts b/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts index 5937807f9..13d3752c3 100644 --- a/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts +++ b/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts @@ -1,9 +1,10 @@ import { ErrCode, RecaseError } from "@autumn/shared"; -import type { Redis } from "ioredis"; -import { currentRegion } from "@/external/redis/initRedis.js"; import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js"; +import type { LegacyRedisClient } from "@/utils/legacyRedisClient.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +const DEFAULT_LOCK_RECEIPT_REGION = "us-west-2"; + /** * V2 save-lock-receipt. Stores the receipt as a plain JSON string via a single * `SET key value NX EXAT ttl_at` call — one Redis round trip instead of the @@ -18,6 +19,7 @@ export const saveLockReceiptV2 = async ({ items, overrideLockValue, redisInstance, + region = DEFAULT_LOCK_RECEIPT_REGION, }: { lock: { lock_id?: string; @@ -32,13 +34,14 @@ export const saveLockReceiptV2 = async ({ entityId?: string; items: MutationLogItem[]; overrideLockValue?: number; - redisInstance: Redis; + redisInstance: LegacyRedisClient; + region?: string; }) => { const payload = JSON.stringify({ lock_id: lock.lock_id ?? null, hashed_key: lock.hashed_key ?? null, status: "pending", - region: currentRegion, + region, customer_id: customerId, feature_id: featureId, entity_id: entityId ?? null, diff --git a/server/src/internal/balances/utils/refreshEntityAggregate/refreshEntityAggregateCache.ts b/server/src/internal/balances/utils/refreshEntityAggregate/refreshEntityAggregateCache.ts index 089fbae49..a820e02c5 100644 --- a/server/src/internal/balances/utils/refreshEntityAggregate/refreshEntityAggregateCache.ts +++ b/server/src/internal/balances/utils/refreshEntityAggregate/refreshEntityAggregateCache.ts @@ -1,12 +1,16 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; import { AGGREGATED_BALANCE_FIELD } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js"; +import { + hasNonRedisSnapshotCacheStore, + invalidateCustomerCacheStoreSnapshots, +} from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; import { getEntityAggregateForSync } from "@/internal/customers/repos/getFullSubject/getEntityAggregateForSync.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; /** - * After DB sync, recompute entity aggregation from the now-authoritative DB - * and HSET `_aggregated` on the affected balance hashes. + * After DB sync, keep entity aggregation snapshots fresh. CacheStore-backed + * snapshots are invalidated; legacy Redis balance hashes refresh `_aggregated`. */ export const refreshEntityAggregateCache = async ({ ctx, @@ -27,6 +31,15 @@ export const refreshEntityAggregateCache = async ({ if (featureIds.length === 0) return; + if (hasNonRedisSnapshotCacheStore({ ctx })) { + await invalidateCustomerCacheStoreSnapshots({ + ctx, + customerId, + source: "refreshEntityAggregateCache", + }); + return; + } + const { redisV2 } = ctx; // Only refresh features whose balance hash already has `_aggregated`. diff --git a/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts b/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts index 57107dd90..2a2382f5a 100644 --- a/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts +++ b/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts @@ -1,9 +1,10 @@ import type { AppEnv } from "@autumn/shared"; import { createLogger } from "@/external/logtail/logtailUtils.js"; -import { currentRegion } from "@/external/redis/initRedis.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; +const DEFAULT_SYNC_REGION = "us-west-2"; + interface CustomerBatchContext { customerId: string; orgId: string; @@ -167,7 +168,7 @@ export class SyncBatchingManagerV2 { customerId, orgId, env, - region: region || currentRegion, + region: region || this.env?.AWS_REGION || DEFAULT_SYNC_REGION, timestamp: Date.now(), cusEntIds: new Set(), rolloverIds: new Set(), diff --git a/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts b/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts index d59ec2f24..dffd19b0d 100644 --- a/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts +++ b/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts @@ -1,10 +1,11 @@ import type { AppEnv } from "@autumn/shared"; import { createLogger } from "@/external/logtail/logtailUtils.js"; -import { currentRegion } from "@/external/redis/initRedis.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; import type { UsageWindowUpdate } from "../types/usageWindowUpdate.js"; +const DEFAULT_SYNC_REGION = "us-west-2"; + interface CustomerBatchContext { customerId: string; orgId: string; @@ -191,7 +192,7 @@ export class SyncBatchingManagerV3 { customerId, orgId, env, - region: region || currentRegion, + region: region || this.env?.AWS_REGION || DEFAULT_SYNC_REGION, timestamp: Date.now(), cusEntIds: new Set(), rolloverIds: new Set(), diff --git a/server/src/internal/balances/utils/sync/syncItemV3.ts b/server/src/internal/balances/utils/sync/syncItemV3.ts index 59c9cc90b..7c0136a1d 100644 --- a/server/src/internal/balances/utils/sync/syncItemV3.ts +++ b/server/src/internal/balances/utils/sync/syncItemV3.ts @@ -7,7 +7,6 @@ import { tryCatch, } from "@autumn/shared"; import { sql } from "drizzle-orm"; -import { getRegionalRedis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; import { getCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.js"; @@ -196,15 +195,12 @@ export const syncItemV3 = async ({ ctx: AutumnContext; payload: SyncItemV3; }): Promise => { - const { customerId, region, cusEntIds, rolloverIds } = payload; + const { customerId, cusEntIds, rolloverIds } = payload; const { db, logger } = ctx; - const redisInstance = region ? getRegionalRedis(region) : undefined; - const fullCustomer = await getCachedFullCustomer({ ctx, customerId, - redisInstance, skipRolloutCheck: true, }); diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts index 7d213acb9..9f63514bf 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts @@ -79,6 +79,7 @@ export const evaluateStripeBillingPlan = async ({ ctx, billingContext, stripeSubscriptionAction, + env: ctx.workerEnv!, }); const stripeRefundAction = await buildStripeRefundAction({ diff --git a/server/src/internal/checkouts/middleware/checkoutMiddleware.ts b/server/src/internal/checkouts/middleware/checkoutMiddleware.ts index 00aebbcda..89607c3f5 100644 --- a/server/src/internal/checkouts/middleware/checkoutMiddleware.ts +++ b/server/src/internal/checkouts/middleware/checkoutMiddleware.ts @@ -24,12 +24,17 @@ import { checkoutRepo } from "../repos/checkoutRepo"; * Rate limiter: 10 requests per minute per checkout ID. * Prevents enumeration attacks on checkout URLs. */ -export const checkoutRateLimiter = rateLimiter({ - windowMs: 60 * 1000, // 1 minute - limit: 10, - standardHeaders: "draft-6", - keyGenerator: (c) => c.req.param("checkout_id") ?? "unknown", -}); +let checkoutRateLimiterInstance: ReturnType> | null = + null; +export const checkoutRateLimiter = (c: Context, next: Next) => { + checkoutRateLimiterInstance ??= rateLimiter({ + windowMs: 60 * 1000, // 1 minute + limit: 10, + standardHeaders: "draft-6", + keyGenerator: (ctx) => ctx.req.param("checkout_id") ?? "unknown", + }); + return checkoutRateLimiterInstance(c, next); +}; // Extend HonoEnv to include checkout in context declare module "hono" { diff --git a/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts b/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts index b35656c13..d8a4cd9ba 100644 --- a/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts +++ b/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts @@ -103,9 +103,10 @@ export const executeAutumnCreateCustomerPlan = async ({ originalFullCustomer: context.fullCustomer, }); - if (ctx.authType === AuthType.SecretKey) { - await captureOrgEvent({ - orgId: ctx.org.id, + if (ctx.authType === AuthType.SecretKey) { + await captureOrgEvent({ + env: ctx.workerEnv!, + orgId: ctx.org.id, event: "customer_created_via_api", properties: { org_slug: ctx.org.slug, diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/executeResetCache.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/executeResetCache.ts index f25dfd37f..440a8deab 100644 --- a/server/src/internal/customers/actions/resetCustomerEntitlements/executeResetCache.ts +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/executeResetCache.ts @@ -1,21 +1,16 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { ResetCusEntParam } from "@/internal/balances/utils/sql/client.js"; -import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { invalidateCustomerCacheStoreSnapshots } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; import type { RolloverClearingInfo } from "./applyResetResults.js"; /** - * Atomically resets cusEnt fields in the cached FullCustomer blob. - * Uses the unified updateCustomerEntitlements Lua script. - * Skips gracefully if the cache doesn't exist or the cusEnt was already reset. - * Fire-and-forget -- failures are logged but don't propagate. + * Invalidates cached customer snapshots after entitlement resets. Reset values + * are persisted in DB and snapshots are rebuilt from DB on the next read. */ export const executeResetCache = async ({ ctx, customerId, resets, - oldNextResetAts, - clearingMap, }: { ctx: AutumnContext; customerId: string; @@ -25,44 +20,9 @@ export const executeResetCache = async ({ }): Promise => { if (resets.length === 0) return; - const { org, env, redisV2 } = ctx; - - const cacheKey = buildFullCustomerCacheKey({ - orgId: org.id, - env, + await invalidateCustomerCacheStoreSnapshots({ + ctx, customerId, - }); - - const updates = resets.map((r) => { - const clearing = clearingMap[r.cus_ent_id]; - - return { - cus_ent_id: r.cus_ent_id, - balance: r.balance, - additional_balance: r.additional_balance, - adjustment: r.adjustment, - entities: r.entities, - next_reset_at: r.next_reset_at, - expected_next_reset_at: oldNextResetAts[r.cus_ent_id] ?? null, - rollover_insert: r.rollover_insert, - rollover_overwrites: - clearing && clearing.overwrites.length > 0 ? clearing.overwrites : null, - rollover_delete_ids: - clearing && clearing.deletedIds.length > 0 ? clearing.deletedIds : null, - new_replaceables: null, - deleted_replaceable_ids: null, - }; - }); - - await tryRedisWrite( - ctx.workerEnv ?? ({} as Env), - () => - redisV2.updateCustomerEntitlements( - cacheKey, - JSON.stringify({ updates }), - ), - redisV2, - ).catch((error) => { - ctx.logger.warn(`[executeResetCache] Redis cache update skipped: ${error}`); + source: "executeResetCache", }); }; diff --git a/server/src/internal/customers/actions/resetCustomerEntitlementsV2/resetSubjectCache.ts b/server/src/internal/customers/actions/resetCustomerEntitlementsV2/resetSubjectCache.ts index c64e48d5f..3ab3c56d5 100644 --- a/server/src/internal/customers/actions/resetCustomerEntitlementsV2/resetSubjectCache.ts +++ b/server/src/internal/customers/actions/resetCustomerEntitlementsV2/resetSubjectCache.ts @@ -1,38 +1,16 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { ResetCusEntParam } from "@/internal/balances/utils/sql/client.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 { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { invalidateCustomerCacheStoreSnapshots } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; import type { RolloverClearingInfo } from "../resetCustomerEntitlements/applyResetResults.js"; -interface SubjectBalanceUpdate { - cus_ent_id: string; - balance: number | null; - additional_balance: number | null; - adjustment: number | null; - entities: Record | null; - next_reset_at: number | null; - expected_next_reset_at: number | null; - rollover_insert: unknown | null; - rollover_overwrites: unknown[] | null; - rollover_delete_ids: string[] | null; - new_replaceables: unknown[] | null; - deleted_replaceable_ids: string[] | null; -} - /** - * Patches shared FullSubject balance hashes after a lazy reset. - * Groups updates by feature_id and pipelines one updateSubjectBalances call per feature. - * Fire-and-forget -- failures are logged but don't propagate. - * Does not mutate cache_version in cache; version bumps are DB lifecycle concerns. + * Invalidates subject snapshots after a lazy reset. Reset values are persisted + * in DB; CacheStore snapshots are rebuilt from DB on the next read. */ export const resetSubjectCache = async ({ ctx, customerId, resets, - oldNextResetAts, - clearingMap, - customerEntitlementFeatureIds, }: { ctx: AutumnContext; customerId: string; @@ -44,81 +22,11 @@ export const resetSubjectCache = async ({ if (resets.length === 0) return; try { - const { org, env, redisV2 } = ctx; - - const updatesByFeatureId: Record = {}; - - for (const reset of resets) { - const featureId = customerEntitlementFeatureIds[reset.cus_ent_id]; - if (!featureId) continue; - - const clearing = clearingMap[reset.cus_ent_id]; - - const update: SubjectBalanceUpdate = { - cus_ent_id: reset.cus_ent_id, - balance: reset.balance, - additional_balance: reset.additional_balance, - adjustment: reset.adjustment, - entities: reset.entities, - next_reset_at: reset.next_reset_at, - expected_next_reset_at: oldNextResetAts[reset.cus_ent_id] ?? null, - rollover_insert: reset.rollover_insert, - rollover_overwrites: - clearing && clearing.overwrites.length > 0 - ? clearing.overwrites - : null, - rollover_delete_ids: - clearing && clearing.deletedIds.length > 0 - ? clearing.deletedIds - : null, - new_replaceables: null, - deleted_replaceable_ids: null, - }; - - if (!updatesByFeatureId[featureId]) { - updatesByFeatureId[featureId] = []; - } - updatesByFeatureId[featureId].push(update); - } - - if (Object.keys(updatesByFeatureId).length === 0) return; - - const pipeline = redisV2.pipeline(); - for (const [featureId, updates] of Object.entries(updatesByFeatureId)) { - const balanceKey = buildSharedFullSubjectBalanceKey({ - orgId: org.id, - env, - customerId, - featureId, - }); - pipeline.updateSubjectBalances( - balanceKey, - JSON.stringify({ - ttl_seconds: FULL_SUBJECT_CACHE_TTL_SECONDS, - updates, - }), - ); - } - - const pipelineResults = await tryRedisWrite(() => pipeline.exec(), redisV2); - - if (pipelineResults) { - for (const [, resultRaw] of pipelineResults) { - if (typeof resultRaw !== "string") continue; - try { - const parsed = JSON.parse(resultRaw) as { - applied?: Record; - skipped?: string[]; - logs?: string[]; - }; - if (parsed.logs && parsed.logs.length > 0) { - ctx.logger.debug( - `[resetSubjectCache] Lua logs:\n${parsed.logs.join("\n")}`, - ); - } - } catch {} - } - } + await invalidateCustomerCacheStoreSnapshots({ + ctx, + customerId, + source: "resetSubjectCache", + }); } catch (error) { ctx.logger.error( `[resetSubjectCache] customer=${customerId}, failed: ${error}`, diff --git a/server/src/internal/customers/actions/resetUsageWindows/rollUsageWindowsCache.ts b/server/src/internal/customers/actions/resetUsageWindows/rollUsageWindowsCache.ts index cdc2922c8..a8440d6b5 100644 --- a/server/src/internal/customers/actions/resetUsageWindows/rollUsageWindowsCache.ts +++ b/server/src/internal/customers/actions/resetUsageWindows/rollUsageWindowsCache.ts @@ -1,20 +1,15 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.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 { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { invalidateCustomerCacheStoreSnapshots } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; import type { UsageWindowRoll } from "./computeUsageWindowRolls.js"; /** - * Atomically patches rolled counters into each affected feature's - * '_usage_windows' field (one rollUsageWindows Lua call per feature, - * pipelined). Fire-and-forget -- reads and the deduction script both derive - * a closed window as 0, so a missed patch only delays the persisted roll. + * Invalidates cached customer snapshots after usage windows roll. The rolled + * window values are persisted in DB and snapshots are rebuilt on the next read. */ export const rollUsageWindowsCache = async ({ ctx, customerId, rolls, - now, }: { ctx: AutumnContext; customerId: string; @@ -24,34 +19,11 @@ export const rollUsageWindowsCache = async ({ if (rolls.length === 0) return; try { - const { org, env, redisV2 } = ctx; - - const rollsByFeatureId: Record = {}; - for (const roll of rolls) { - const featureRolls = rollsByFeatureId[roll.feature_id] ?? []; - featureRolls.push(roll); - rollsByFeatureId[roll.feature_id] = featureRolls; - } - - const pipeline = redisV2.pipeline(); - for (const [featureId, featureRolls] of Object.entries(rollsByFeatureId)) { - const balanceKey = buildSharedFullSubjectBalanceKey({ - orgId: org.id, - env, - customerId, - featureId, - }); - pipeline.rollUsageWindows( - balanceKey, - JSON.stringify({ - now, - ttl_seconds: FULL_SUBJECT_CACHE_TTL_SECONDS, - rolls: featureRolls, - }), - ); - } - - await tryRedisWrite(() => pipeline.exec(), redisV2); + await invalidateCustomerCacheStoreSnapshots({ + ctx, + customerId, + source: "rollUsageWindowsCache", + }); } catch (error) { ctx.logger.error( `[rollUsageWindowsCache] customer=${customerId}, failed: ${error}`, diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index ca24466b0..fa9710a3a 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -21,7 +21,6 @@ import { } from "@autumn/shared"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { RepoContext } from "@/db/repoContext.js"; -import { createDisabledRedis } from "@/external/redis/disabledRedis.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"; @@ -29,6 +28,7 @@ import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlement import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; +import { createDisabledRedis } from "@/utils/disabledRedis.js"; import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; import type { InsertCusProductParams } from "../cusProducts/AttachParams.js"; import { CusProductService } from "../cusProducts/CusProductService.js"; diff --git a/server/src/internal/customers/cache/cacheRoutingStaleness.ts b/server/src/internal/customers/cache/cacheRoutingStaleness.ts new file mode 100644 index 000000000..df6ef3fb5 --- /dev/null +++ b/server/src/internal/customers/cache/cacheRoutingStaleness.ts @@ -0,0 +1,24 @@ +import type { OrgRedisConfig } from "@autumn/shared"; + +const getCustomerRoutingBucket = (customerId: string): number => + Number(BigInt(Bun.hash(customerId)) % 100n); + +export const isCacheRoutingMigrationStale = ({ + 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 = getCustomerRoutingBucket(customerId); + const wasOnDedicated = bucket < redisConfig.previousMigrationPercent; + const isOnDedicated = bucket < redisConfig.migrationPercent; + return wasOnDedicated !== isOnDedicated; +}; diff --git a/server/src/internal/customers/cache/fullSubject/actions/fullSubjectSnapshotCacheStoreActions.ts b/server/src/internal/customers/cache/fullSubject/actions/fullSubjectSnapshotCacheStoreActions.ts index 0786aa272..442963bf7 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/fullSubjectSnapshotCacheStoreActions.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/fullSubjectSnapshotCacheStoreActions.ts @@ -1,6 +1,6 @@ import type { Invoice } from "@autumn/shared"; -import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRoutingInfo.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { isCacheRoutingMigrationStale } from "@/internal/customers/cache/cacheRoutingStaleness.js"; import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js"; import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js"; @@ -88,14 +88,14 @@ export const getFullSubjectBlobFromCacheStore = async ({ } if ( - isRedisMigrationCacheStale({ + isCacheRoutingMigrationStale({ cachedAt: sanitized._cachedAt, customerId, redisConfig: ctx.org.redis_config, }) ) { ctx.logger.warn( - `[getFullSubjectBlobFromCacheStore] Stale Redis migration cache for ${customerId}${entityId ? `:${entityId}` : ""}, evicting`, + `[getFullSubjectBlobFromCacheStore] Stale cache routing migration snapshot for ${customerId}${entityId ? `:${entityId}` : ""}, evicting`, ); await ctx.cacheStore.delete(subjectKey); return undefined; diff --git a/server/src/internal/customers/cache/fullSubject/actions/getOrCreateCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/getOrCreateCachedFullSubject.ts index fff1e6137..10f964317 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/getOrCreateCachedFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/getOrCreateCachedFullSubject.ts @@ -25,7 +25,7 @@ export const getOrCreateCachedFullSubject = async ({ source?: string; }): Promise => { const { skipCache, logger } = ctx; - const useRedis = !skipCache; + const useCache = !skipCache; const { customer_id: customerId, customer_data: customerData, @@ -38,9 +38,7 @@ export const getOrCreateCachedFullSubject = async ({ let setCache = true; let fetchedSubjectViewEpoch = 0; - if (customerId && useRedis) { - // Pipeline inside getCachedFullSubject already fetches the epoch, - // so we reuse it on miss instead of a second round trip. + if (customerId && useCache) { const cachedResult = await getCachedFullSubject({ ctx, customerId, @@ -117,7 +115,7 @@ export const getOrCreateCachedFullSubject = async ({ } } - if (useRedis && setCache) { + if (useCache && setCache) { if (!normalizedResult) { normalizedResult = await getFullSubjectNormalized({ ctx, diff --git a/server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts index 8e5547147..1ac29da29 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts @@ -23,13 +23,11 @@ export const getOrSetCachedFullSubject = async ({ staleWhileRevalidate?: boolean; }): Promise => { const { skipCache, logger } = ctx; - const useRedis = !skipCache; + const useCache = !skipCache; let fetchedSubjectViewEpoch = 0; - if (useRedis) { - // The pipeline inside getCachedFullSubject already fetches + refreshes - // the epoch, so we reuse it on miss instead of a second round trip. + if (useCache) { const { fullSubject: cached, subjectViewEpoch } = await getCachedFullSubject({ ctx, @@ -65,7 +63,7 @@ export const getOrSetCachedFullSubject = async ({ const { normalized, fullSubject } = result; - if (useRedis) { + if (useCache) { await setCachedFullSubject({ ctx, normalized, diff --git a/server/src/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.ts b/server/src/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.ts index 9cf59eed2..216ef6693 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.ts @@ -1,16 +1,7 @@ -import type { AppEnv, Feature } from "@autumn/shared"; -import type { Redis } from "ioredis"; -import { batchDeleteCachedFullCustomers } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.js"; +import type { AppEnv } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { batchDeleteCachedFullCustomers } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.js"; import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js"; -import { buildFullSubjectOrgEnvKey } from "../../builders/buildFullSubjectOrgEnvKey.js"; -import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js"; -import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js"; -import { FULL_SUBJECT_EPOCH_TTL_SECONDS } from "../../config/fullSubjectCacheConfig.js"; -import type { CachedFullSubject } from "../../fullSubjectCacheModel.js"; - -const PIPELINE_BATCH_SIZE = 1000; type BatchInvalidateCustomer = { orgId: string; @@ -18,133 +9,59 @@ type BatchInvalidateCustomer = { customerId: string; }; -type FeaturesByOrgEnv = Record; +const CACHE_STORE_DELETE_BATCH_SIZE = 1000; -const batchInvalidateCachedFullSubjectsOnRedis = async ({ +const batchDeleteFullSubjectSnapshots = async ({ + ctx, customers, - featuresByOrgEnv, - redisV2, - workerEnv, }: { + ctx?: Pick; customers: BatchInvalidateCustomer[]; - featuresByOrgEnv: FeaturesByOrgEnv; - redisV2: Redis; - workerEnv: Env; -}): Promise => { - if (customers.length === 0 || redisV2.status !== "ready") return; +}): Promise => { + if (customers.length === 0) return 0; + const cacheStore = ctx?.cacheStore; + if (!cacheStore || cacheStore.metadata.backend === "redis") { + ctx?.logger?.warn( + `[batchInvalidateCachedFullSubjects] no CacheStore available, customers (${customers.length})`, + ); + return 0; + } + + let deleted = 0; for ( let offset = 0; offset < customers.length; - offset += PIPELINE_BATCH_SIZE + offset += CACHE_STORE_DELETE_BATCH_SIZE ) { - const batch = customers.slice(offset, offset + PIPELINE_BATCH_SIZE); - const readPipeline = redisV2.pipeline(); - - for (const { orgId, env, customerId } of batch) { - if (!customerId) continue; - - const subjectKey = buildFullSubjectKey({ orgId, env, customerId }); - readPipeline.get(subjectKey); - } - - const readResults = await tryRedisRead( - workerEnv, - () => readPipeline.exec(), - redisV2, + const batch = customers.slice(offset, offset + CACHE_STORE_DELETE_BATCH_SIZE); + await cacheStore.deleteMany( + batch.map(({ orgId, env, customerId }) => + buildFullSubjectKey({ orgId, env, customerId }), + ), ); - if (!readResults) continue; - - const writePipeline = redisV2.pipeline(); - - for (let index = 0; index < batch.length; index++) { - const customer = batch[index]; - if (!customer?.customerId) continue; - - const { orgId, env, customerId } = customer; - const subjectKey = buildFullSubjectKey({ orgId, env, customerId }); - const epochKey = buildFullSubjectViewEpochKey({ orgId, env, customerId }); - const subjectTuple = readResults[index]; - const cachedRaw = - (subjectTuple?.[1] as string | null | undefined) ?? null; - - let featureIds: string[] = []; - if (cachedRaw) { - try { - const manifest = JSON.parse(cachedRaw) as CachedFullSubject; - featureIds = manifest.meteredFeatures ?? []; - } catch { - featureIds = []; - } - } - - if (featureIds.length === 0) { - const orgFeatures = - featuresByOrgEnv[buildFullSubjectOrgEnvKey({ orgId, env })] ?? []; - featureIds = orgFeatures.map((feature) => feature.id); - } - - for (const featureId of new Set(featureIds)) { - writePipeline.unlink( - buildSharedFullSubjectBalanceKey({ - orgId, - env, - customerId, - featureId, - }), - ); - } - - writePipeline.unlink(subjectKey); - writePipeline.incr(epochKey); - writePipeline.expire(epochKey, FULL_SUBJECT_EPOCH_TTL_SECONDS); - } - - await tryRedisWrite(workerEnv, () => writePipeline.exec(), redisV2); + deleted += batch.length; } + + ctx.logger?.info( + `[batchInvalidateCachedFullSubjects] cacheStore deleted ${deleted} subject snapshots`, + ); + return deleted; }; export const batchInvalidateCachedFullSubjects = async ({ ctx, customers, - featuresByOrgEnv, - getRedisTargetsForCustomer, }: { - ctx?: AutumnContext; + ctx?: Pick; customers: BatchInvalidateCustomer[]; - featuresByOrgEnv: FeaturesByOrgEnv; - getRedisTargetsForCustomer: ({ - customer, - }: { - customer: BatchInvalidateCustomer; - }) => Redis[]; }): Promise => { if (customers.length === 0) return 0; - const deleted = await batchDeleteCachedFullCustomers({ ctx, customers }); - const workerEnv = ctx?.workerEnv ?? ({} as Env); + const [fullCustomerDeleted, fullSubjectDeleted] = await Promise.all([ + batchDeleteCachedFullCustomers({ ctx, customers }), + batchDeleteFullSubjectSnapshots({ ctx, customers }), + ]); - const customersByRedis = new Map(); - for (const customer of customers) { - for (const targetRedis of new Set( - getRedisTargetsForCustomer({ 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, - workerEnv, - }), - ), - ); - - return deleted; + return Math.max(fullCustomerDeleted, fullSubjectDeleted); }; diff --git a/server/src/internal/customers/cache/fullSubject/actions/invalidate/getOrInitFullSubjectViewEpoch.ts b/server/src/internal/customers/cache/fullSubject/actions/invalidate/getOrInitFullSubjectViewEpoch.ts index ffb194ad9..1e2b67844 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/invalidate/getOrInitFullSubjectViewEpoch.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/invalidate/getOrInitFullSubjectViewEpoch.ts @@ -1,39 +1,9 @@ -import { runRedisOp } from "@/external/redis/utils/runRedisOp.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js"; -import { FULL_SUBJECT_EPOCH_TTL_SECONDS } from "../../config/fullSubjectCacheConfig.js"; export const getOrInitFullSubjectViewEpoch = async ({ - ctx, - customerId, + ctx: _ctx, + customerId: _customerId, }: { ctx: AutumnContext; customerId: string; -}): Promise => { - const { redisV2 } = ctx; - const epochKey = buildFullSubjectViewEpochKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId, - }); - - // GETEX reads the epoch and refreshes its TTL in one round trip. - const currentEpoch = await runRedisOp({ - operation: () => - redisV2.getex(epochKey, "EX", FULL_SUBJECT_EPOCH_TTL_SECONDS), - source: "getOrInitFullSubjectViewEpoch:getex", - redisInstance: redisV2, - }); - if (currentEpoch !== null && currentEpoch !== undefined) { - const parsedEpoch = Number.parseInt(currentEpoch, 10); - return Number.isNaN(parsedEpoch) ? 0 : parsedEpoch; - } - - await runRedisOp({ - operation: () => - redisV2.set(epochKey, "0", "EX", FULL_SUBJECT_EPOCH_TTL_SECONDS), - source: "getOrInitFullSubjectViewEpoch:init", - redisInstance: redisV2, - }); - return 0; -}; +}): Promise => 0; diff --git a/server/src/internal/customers/cache/fullSubject/actions/invalidate/incrementFullSubjectViewEpoch.ts b/server/src/internal/customers/cache/fullSubject/actions/invalidate/incrementFullSubjectViewEpoch.ts index 3966d82aa..ffcf79e46 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/invalidate/incrementFullSubjectViewEpoch.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/invalidate/incrementFullSubjectViewEpoch.ts @@ -1,7 +1,4 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; -import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js"; -import { FULL_SUBJECT_EPOCH_TTL_SECONDS } from "../../config/fullSubjectCacheConfig.js"; export const incrementFullSubjectViewEpoch = async ({ ctx, @@ -10,18 +7,8 @@ export const incrementFullSubjectViewEpoch = async ({ ctx: AutumnContext; customerId: string; }): Promise => { - const { redisV2 } = ctx; - const epochKey = buildFullSubjectViewEpochKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId, - }); - - const nextEpoch = await tryRedisWrite(() => redisV2.incr(epochKey), redisV2); - if (nextEpoch === null || nextEpoch === undefined) return null; - await tryRedisWrite( - () => redisV2.expire(epochKey, FULL_SUBJECT_EPOCH_TTL_SECONDS), - redisV2, + ctx.logger.debug( + `[incrementFullSubjectViewEpoch] CacheStore snapshots do not use Redis epoch state for ${customerId}`, ); - return nextEpoch; + return null; }; diff --git a/server/src/internal/customers/cache/fullSubject/actions/invalidate/invalidateCustomerEntitlementBalance.ts b/server/src/internal/customers/cache/fullSubject/actions/invalidate/invalidateCustomerEntitlementBalance.ts index 935aab688..a12211b01 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/invalidate/invalidateCustomerEntitlementBalance.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/invalidate/invalidateCustomerEntitlementBalance.ts @@ -1,44 +1,34 @@ -import type { Redis } from "ioredis"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; -import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js"; -import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js"; +import type { AppEnv } from "@autumn/shared"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; +import type { CacheStore } from "@/external/storage/cache/index.js"; +import { invalidateCustomerCacheStoreSnapshots } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; + +type InvalidateCustomerEntitlementBalanceArgs = { + ctx: { + cacheStore?: CacheStore; + logger: Logger; + }; + orgId: string; + env: AppEnv; + customerId: string; +}; export const invalidateCustomerEntitlementBalance = async ({ + ctx, orgId, env, customerId, - featureId, - customerEntitlementId, - redisV2, -}: { - orgId: string; - env: string; - customerId: string; - featureId: string; - customerEntitlementId: string; - redisV2: Redis; -}): Promise => { - if ( - !orgId || - !env || - !customerId || - !featureId || - !customerEntitlementId || - redisV2.status !== "ready" - ) { - return; - } +}: InvalidateCustomerEntitlementBalanceArgs): Promise => { + if (!orgId || !env || !customerId) return; - const balanceKey = buildSharedFullSubjectBalanceKey({ - orgId, - env, + await invalidateCustomerCacheStoreSnapshots({ + ctx: { + org: { id: orgId }, + env, + logger: ctx.logger, + cacheStore: ctx.cacheStore, + }, customerId, - featureId, + source: "invalidateCustomerEntitlementBalance", }); - - await tryRedisWrite( - () => - redisV2.hdel(balanceKey, customerEntitlementId, AGGREGATED_BALANCE_FIELD), - redisV2, - ); }; diff --git a/server/src/internal/customers/cache/fullSubject/actions/partial/getOrCreateCachedPartialFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/partial/getOrCreateCachedPartialFullSubject.ts index 9636f107a..c5f24b92d 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/partial/getOrCreateCachedPartialFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/partial/getOrCreateCachedPartialFullSubject.ts @@ -19,10 +19,10 @@ export const getOrCreateCachedPartialFullSubject = async ({ source?: string; }): Promise => { const { skipCache, logger } = ctx; - const useRedis = !skipCache; + const useCache = !skipCache; const { customer_id: customerId, entity_id: entityId } = params; - if (customerId && useRedis) { + if (customerId && useCache) { const { fullSubject: cached } = await getCachedPartialFullSubject({ ctx, customerId, diff --git a/server/src/internal/customers/cache/fullSubject/actions/partial/getOrSetCachedPartialFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/partial/getOrSetCachedPartialFullSubject.ts index ae927db4a..0268a31a6 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/partial/getOrSetCachedPartialFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/partial/getOrSetCachedPartialFullSubject.ts @@ -24,13 +24,11 @@ export const getOrSetCachedPartialFullSubject = async ({ source?: string; }): Promise => { const { skipCache, logger } = ctx; - const useRedis = !skipCache; + const useCache = !skipCache; let fetchedSubjectViewEpoch = 0; - if (useRedis) { - // Pipeline inside getCachedPartialFullSubject already fetches the - // epoch, so we reuse it on miss. + if (useCache) { const { fullSubject: cached, subjectViewEpoch } = await getCachedPartialFullSubject({ ctx, @@ -66,7 +64,7 @@ export const getOrSetCachedPartialFullSubject = async ({ const { normalized, fullSubject } = result; - if (useRedis) { + if (useCache) { await setCachedFullSubject({ ctx, normalized, diff --git a/server/src/internal/customers/cache/fullSubject/actions/rehydrateWithLiveBalances.ts b/server/src/internal/customers/cache/fullSubject/actions/rehydrateWithLiveBalances.ts index 44d0141e7..59100323f 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/rehydrateWithLiveBalances.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/rehydrateWithLiveBalances.ts @@ -12,10 +12,9 @@ import { getCachedFeatureBalancesBatch } from "../balances/getCachedFeatureBalan * To reflect those patches we need to re-read the balance hashes. We don't need * to re-read the subject blob itself because we just wrote it ourselves. * - * This helper does a single balance hmget batch (one RTT) and merges the live - * values into the already-known normalized subject. Returns undefined if the - * balance batch is incomplete — callers should fall back to the DB-derived - * fullSubject in that case. + * This helper merges live balances into the already-known normalized subject. + * Returns undefined when the live balance cache is unavailable or incomplete, + * so callers can fall back to the DB-derived fullSubject. */ export const rehydrateWithLiveBalances = async ({ ctx, diff --git a/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setCachedFullSubject.ts index 15252a8b2..e60cd34bb 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setCachedFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setCachedFullSubject.ts @@ -1,19 +1,11 @@ import type { NormalizedFullSubject } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; -import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js"; -import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js"; -import { - FULL_SUBJECT_CACHE_TTL_SECONDS, - FULL_SUBJECT_EPOCH_TTL_SECONDS, -} from "../../config/fullSubjectCacheConfig.js"; import { normalizedToCachedFullSubject } from "../../fullSubjectCacheModel.js"; import { isFullSubjectSnapshotCacheStoreEnabled, setFullSubjectBlobInCacheStore, } from "../fullSubjectSnapshotCacheStoreActions.js"; import type { SetCachedFullSubjectResult } from "./fullSubjectWriteTypes.js"; -import { buildSharedBalanceWrites } from "./setSharedFullSubjectBalances.js"; export type { SetCachedFullSubjectResult } from "./fullSubjectWriteTypes.js"; @@ -26,78 +18,34 @@ export const setCachedFullSubject = async ({ normalized: NormalizedFullSubject; fetchedSubjectViewEpoch: number; }): Promise => { - const { logger, org, env, redisV2 } = ctx; const { customerId, entityId } = normalized; + const subjectLabel = entityId ? `${customerId}:${entityId}` : customerId; const cached = normalizedToCachedFullSubject({ normalized, subjectViewEpoch: fetchedSubjectViewEpoch, }); - if (isFullSubjectSnapshotCacheStoreEnabled({ ctx })) { + if (!isFullSubjectSnapshotCacheStoreEnabled({ ctx })) { + ctx.logger.warn( + `[setCachedFullSubject] No CacheStore available for ${subjectLabel}`, + ); + return "FAILED"; + } + + try { await setFullSubjectBlobInCacheStore({ ctx, cached, - }).catch((error) => { - logger.warn( - `[setCachedFullSubject] cacheStore subject blob write failed for ${customerId}${entityId ? `:${entityId}` : ""}: ${error}`, - ); }); + ctx.logger.info( + `[setCachedFullSubject] ${subjectLabel}: OK, balances=${cached.meteredFeatures.length}`, + ); + return "OK"; + } catch (error) { + ctx.logger.warn( + `[setCachedFullSubject] cacheStore subject blob write failed for ${subjectLabel}: ${error}`, + ); + return "FAILED"; } - - const subjectKey = buildFullSubjectKey({ - orgId: org.id, - env, - customerId, - entityId, - }); - const epochKey = buildFullSubjectViewEpochKey({ - orgId: org.id, - env, - customerId, - }); - - const balanceWrites = buildSharedBalanceWrites({ - orgId: org.id, - env, - customerId, - customerEntitlements: normalized.customer_entitlements, - aggregatedCustomerEntitlements: - normalized.entity_aggregations?.aggregated_customer_entitlements ?? [], - usageWindows: normalized.usage_windows ?? [], - usageWindowFeatureIds: cached.usageWindowFeatureIds, - }); - - const keys: string[] = [subjectKey, epochKey]; - for (const { balanceKey } of balanceWrites) { - keys.push(balanceKey); - } - - const argv: string[] = [ - String(fetchedSubjectViewEpoch), - String(FULL_SUBJECT_CACHE_TTL_SECONDS), - String(FULL_SUBJECT_EPOCH_TTL_SECONDS), - JSON.stringify(cached), - String(balanceWrites.length), - ]; - - for (const { fields } of balanceWrites) { - const fieldEntries = Object.entries(fields); - argv.push(String(fieldEntries.length)); - for (const [fieldName, fieldValue] of fieldEntries) { - argv.push(fieldName, fieldValue); - } - } - - const result = await tryRedisWrite( - () => redisV2.setCachedFullSubject(keys.length, ...keys, ...argv), - redisV2, - ); - - const subjectLabel = entityId ? `${customerId}:${entityId}` : customerId; - logger.info( - `[setCachedFullSubject] ${subjectLabel}: ${result ?? "FAILED"}, balances=${cached.meteredFeatures.length}`, - ); - - return result ?? "FAILED"; }; diff --git a/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerData.ts b/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerData.ts index 111b231cc..84c2976a5 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerData.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerData.ts @@ -1,18 +1,9 @@ import type { Customer } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; -import { logAlertEvent } from "@/utils/logging/logAlertEvent.js"; -import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js"; -import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "../config/fullSubjectCacheConfig.js"; import { deleteFullSubjectBlobFromCacheStore, isFullSubjectSnapshotCacheStoreEnabled, } from "./fullSubjectSnapshotCacheStoreActions.js"; -import { invalidateCachedFullSubject } from "./invalidate/invalidateFullSubject.js"; - -const FULL_SUBJECT_ALERT_BYTES_THRESHOLD = 1024 * 1024; -const FULL_SUBJECT_UPDATE_SLOW_THRESHOLD_MS = 100; -const textEncoder = new TextEncoder(); export const updateCachedCustomerData = async ({ ctx, @@ -25,134 +16,19 @@ export const updateCachedCustomerData = async ({ }): Promise => { if (Object.keys(updates).length === 0) return; - const { org, env, logger, redisV2 } = ctx; - if (isFullSubjectSnapshotCacheStoreEnabled({ ctx })) { await deleteFullSubjectBlobFromCacheStore({ ctx, customerId, }).catch((error) => { - logger.warn( + ctx.logger.warn( `[updateCachedCustomerData] cacheStore subject delete failed for ${customerId}: ${error}`, ); }); return; } - const subjectKey = buildFullSubjectKey({ - orgId: org.id, - env, - customerId, - }); - - try { - const currentRaw = await tryRedisRead( - ctx.workerEnv ?? ({} as Env), - () => redisV2.get(subjectKey), - redisV2, - ); - if (!currentRaw) return; - - const payloadBytes = textEncoder.encode(String(currentRaw)).byteLength; - if (payloadBytes > FULL_SUBJECT_ALERT_BYTES_THRESHOLD) { - logAlertEvent({ - ctx, - severity: "warning", - category: "redis", - alertKey: "redis_full_subject_payload_large", - message: `FullSubject payload exceeded soft limit during customer cache update for ${customerId}`, - source: "updateCachedCustomerData", - component: "full_subject_cache", - data: { - subjectKey, - payload_bytes: payloadBytes, - threshold_bytes: FULL_SUBJECT_ALERT_BYTES_THRESHOLD, - redis_command: "updateFullSubjectCustomerDataV2", - }, - }); - } - - const updatesJson = JSON.stringify(updates); - const startTime = Date.now(); - const result = await tryRedisWrite( - ctx.workerEnv ?? ({} as Env), - () => - redisV2.updateFullSubjectCustomerDataV2( - subjectKey, - updatesJson, - String(FULL_SUBJECT_CACHE_TTL_SECONDS), - String(Date.now()), - ), - redisV2, - ); - const durationMs = Date.now() - startTime; - - if (durationMs > FULL_SUBJECT_UPDATE_SLOW_THRESHOLD_MS) { - logAlertEvent({ - ctx, - severity: "warning", - category: "redis", - alertKey: "redis_full_subject_customer_update_slow", - message: `FullSubject customer cache update was slow for ${customerId}`, - source: "updateCachedCustomerData", - component: "full_subject_cache", - data: { - subjectKey, - duration_ms: durationMs, - threshold_ms: FULL_SUBJECT_UPDATE_SLOW_THRESHOLD_MS, - redis_command: "updateFullSubjectCustomerDataV2", - payload_bytes: payloadBytes, - }, - }); - } - - if (result === null) { - logger.warn( - `[updateCachedCustomerData] Redis write failed for ${customerId}, invalidating cache`, - ); - await invalidateCachedFullSubject({ - ctx, - customerId, - source: "updateCachedCustomerData:redis_write_failed", - }); - return; - } - if (typeof result !== "string") { - logger.warn( - `[updateCachedCustomerData] Redis write returned unexpected result for ${customerId}, invalidating cache`, - ); - await invalidateCachedFullSubject({ - ctx, - customerId, - source: "updateCachedCustomerData:unexpected_redis_result", - }); - return; - } - - const parsed = JSON.parse(result) as { - success: boolean; - updated_fields?: string[]; - cache_miss?: boolean; - }; - if (parsed.cache_miss) return; - if (parsed.success) return; - - logger.warn( - `[updateCachedCustomerData] Lua update returned unsuccessful result for ${customerId}, invalidating cache`, - ); - await invalidateCachedFullSubject({ - ctx, - customerId, - source: "updateCachedCustomerData:lua_unsuccessful", - }); - } catch (error) { - logger.error( - `[updateCachedCustomerData] Failed to update subject for ${customerId}: ${error}`, - ); - await invalidateCachedFullSubject({ - ctx, - customerId, - source: "updateCachedCustomerData:error", - }); - } + ctx.logger.warn( + `[updateCachedCustomerData] No CacheStore available for customer ${customerId}`, + ); }; diff --git a/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerProduct.ts b/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerProduct.ts index ccdbff07b..bee3c4ad9 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerProduct.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerProduct.ts @@ -1,13 +1,9 @@ import type { InsertCustomerProduct } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { updateCachedCustomerProduct } from "@/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.js"; import { deleteFullCustomerSnapshotFromCacheStore, isFullCustomerCacheStoreEnabled, } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheStoreActions.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; -import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js"; -import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "../config/fullSubjectCacheConfig.js"; import { deleteFullSubjectBlobFromCacheStore, isFullSubjectSnapshotCacheStoreEnabled, @@ -70,92 +66,8 @@ export const updateCachedCustomerProductV2 = async ({ }; } - try { - // Update v1 cache, to remove later on - await updateCachedCustomerProduct({ - ctx, - customerId, - cusProductId: customerProductId, - updates, - }); - } catch (error) { - ctx.logger.error( - `[updateCachedCustomerProductV2] error updating v1 cache for customer ${customerId}, cusProduct ${customerProductId}: ${error}`, - ); - } - - try { - const { org, env, logger, redisV2 } = ctx; - const subjectKey = buildFullSubjectKey({ - orgId: org.id, - env, - customerId, - }); - const paramsJson = JSON.stringify({ - cus_product_id: customerProductId, - updates, - }); - - const result = await tryRedisWrite( - {} as Env, - () => - redisV2.updateFullSubjectCustomerProductV2( - subjectKey, - paramsJson, - String(FULL_SUBJECT_CACHE_TTL_SECONDS), - String(Date.now()), - ), - redisV2, - ); - - if (result === null) { - logger.warn( - `[updateCachedCustomerProductV2] Redis write failed for customer ${customerId}, cusProduct ${customerProductId}`, - ); - return null; - } - if (typeof result !== "string") { - logger.warn( - `[updateCachedCustomerProductV2] Redis write returned invalid result for customer ${customerId}, cusProduct ${customerProductId}`, - ); - return null; - } - - const parsed = JSON.parse(result) as { - success: boolean; - updated_fields?: string[]; - cache_miss?: boolean; - cus_product_not_found?: boolean; - error?: string; - }; - - if (parsed.cus_product_not_found) { - logger.warn( - `[updateCachedCustomerProductV2] customer product ${customerProductId} not found in cached subject for ${customerId}, skipping cache patch`, - ); - } - - if ( - !parsed.success && - !parsed.cache_miss && - !parsed.cus_product_not_found - ) { - logger.warn( - `[updateCachedCustomerProductV2] Lua script error for customer ${customerId}, cusProduct ${customerProductId}: ${parsed.error ?? "unknown_error"}`, - ); - } - - return { - success: parsed.success, - updatedFields: parsed.updated_fields, - cacheMiss: parsed.cache_miss, - cusProductNotFound: parsed.cus_product_not_found, - error: parsed.error, - }; - } catch (error) { - ctx.logger.error( - `[updateCachedCustomerProductV2] cusProduct ${customerProductId}: error, ${error}`, - ); - return null; - } + ctx.logger.warn( + `[updateCachedCustomerProductV2] No CacheStore available for customer ${customerId}, cusProduct ${customerProductId}`, + ); + return null; }; diff --git a/server/src/internal/customers/cache/fullSubject/actions/updateCachedEntityData.ts b/server/src/internal/customers/cache/fullSubject/actions/updateCachedEntityData.ts index f26818645..d9a5ccffd 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/updateCachedEntityData.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/updateCachedEntityData.ts @@ -1,20 +1,10 @@ import type { Entity } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { deleteFullCustomerSnapshotFromCacheStore } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheStoreActions.js"; -import { updateEntityInCache } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/updateEntityInCache.js"; -import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; -import { logAlertEvent } from "@/utils/logging/logAlertEvent.js"; -import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js"; -import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "../config/fullSubjectCacheConfig.js"; import { deleteFullSubjectBlobFromCacheStore, isFullSubjectSnapshotCacheStoreEnabled, } from "./fullSubjectSnapshotCacheStoreActions.js"; -import { invalidateCachedFullSubject } from "./invalidate/invalidateFullSubject.js"; - -const FULL_SUBJECT_ALERT_BYTES_THRESHOLD = 1024 * 1024; -const FULL_SUBJECT_UPDATE_SLOW_THRESHOLD_MS = 100; -const textEncoder = new TextEncoder(); export const updateCachedEntityData = async ({ ctx, @@ -34,15 +24,13 @@ export const updateCachedEntityData = async ({ }): Promise => { if (Object.keys(updates).length === 0) return; - const { org, env, logger, redisV2 } = ctx; - if (isFullSubjectSnapshotCacheStoreEnabled({ ctx })) { await Promise.all([ deleteFullCustomerSnapshotFromCacheStore({ ctx, customerId, }).catch((error) => { - logger.warn( + ctx.logger.warn( `[updateCachedEntityData] cacheStore full customer delete failed for ${customerId}: ${error}`, ); }), @@ -51,7 +39,7 @@ export const updateCachedEntityData = async ({ customerId, entityId, }).catch((error) => { - logger.warn( + ctx.logger.warn( `[updateCachedEntityData] cacheStore subject delete failed for ${customerId}:${entityId}: ${error}`, ); }), @@ -59,137 +47,7 @@ export const updateCachedEntityData = async ({ return; } - updateEntityInCache({ - ctx, - customerId, - idOrInternalId: entityId, - updates, - }).catch((error) => { - ctx.logger.error( - `[updateCachedEntityData] V1 cache update failed for ${customerId}:${entityId}: ${error}`, - ); - }); - - const subjectKey = buildFullSubjectKey({ - orgId: org.id, - env, - customerId, - entityId, - }); - - try { - const currentRaw = await tryRedisRead( - ctx.workerEnv ?? ({} as Env), - () => redisV2.get(subjectKey), - redisV2, - ); - if (!currentRaw) return; - - const payloadBytes = textEncoder.encode(String(currentRaw)).byteLength; - if (payloadBytes > FULL_SUBJECT_ALERT_BYTES_THRESHOLD) { - logAlertEvent({ - ctx, - severity: "warning", - category: "redis", - alertKey: "redis_full_subject_payload_large", - message: `FullSubject payload exceeded soft limit during entity cache update for ${customerId}:${entityId}`, - source: "updateCachedEntityData", - component: "full_subject_cache", - data: { - subjectKey, - payload_bytes: payloadBytes, - threshold_bytes: FULL_SUBJECT_ALERT_BYTES_THRESHOLD, - redis_command: "updateFullSubjectEntityDataV2", - }, - }); - } - - const updatesJson = JSON.stringify(updates); - const startTime = Date.now(); - const result = await tryRedisWrite( - ctx.workerEnv ?? ({} as Env), - () => - redisV2.updateFullSubjectEntityDataV2( - subjectKey, - updatesJson, - String(FULL_SUBJECT_CACHE_TTL_SECONDS), - String(Date.now()), - ), - redisV2, - ); - const durationMs = Date.now() - startTime; - - if (durationMs > FULL_SUBJECT_UPDATE_SLOW_THRESHOLD_MS) { - logAlertEvent({ - ctx, - severity: "warning", - category: "redis", - alertKey: "redis_full_subject_entity_update_slow", - message: `FullSubject entity cache update was slow for ${customerId}:${entityId}`, - source: "updateCachedEntityData", - component: "full_subject_cache", - data: { - subjectKey, - duration_ms: durationMs, - threshold_ms: FULL_SUBJECT_UPDATE_SLOW_THRESHOLD_MS, - redis_command: "updateFullSubjectEntityDataV2", - payload_bytes: payloadBytes, - }, - }); - } - - if (result === null) { - logger.warn( - `[updateCachedEntityData] Redis write failed for ${customerId}:${entityId}, invalidating cache`, - ); - await invalidateCachedFullSubject({ - ctx, - customerId, - entityId, - source: "updateCachedEntityData:redis_write_failed", - }); - return; - } - if (typeof result !== "string") { - logger.warn( - `[updateCachedEntityData] Redis write returned unexpected result for ${customerId}:${entityId}, invalidating cache`, - ); - await invalidateCachedFullSubject({ - ctx, - customerId, - entityId, - source: "updateCachedEntityData:unexpected_redis_result", - }); - return; - } - - const parsed = JSON.parse(result) as { - success: boolean; - updated_fields?: string[]; - cache_miss?: boolean; - no_entity?: boolean; - }; - if (parsed.cache_miss || parsed.no_entity) return; - if (parsed.success) return; - - logger.warn( - `[updateCachedEntityData] Lua update returned unsuccessful result for ${customerId}:${entityId}, invalidating cache`, - ); - await invalidateCachedFullSubject({ - ctx, - customerId, - entityId, - source: "updateCachedEntityData:lua_unsuccessful", - }); - } catch (error) { - logger.error( - `[updateCachedEntityData] Failed to update entity subject for ${customerId}:${entityId}: ${error}`, - ); - await invalidateCachedFullSubject({ - ctx, - customerId, - entityId, - source: "updateCachedEntityData:error", - }); - } + ctx.logger.warn( + `[updateCachedEntityData] No CacheStore available for customer ${customerId}, entity ${entityId}`, + ); }; diff --git a/server/src/internal/customers/cache/fullSubject/actions/upsertCachedInvoiceV2.ts b/server/src/internal/customers/cache/fullSubject/actions/upsertCachedInvoiceV2.ts index 3d4bb1f67..8b0bf7da0 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/upsertCachedInvoiceV2.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/upsertCachedInvoiceV2.ts @@ -1,9 +1,5 @@ import type { Invoice } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; -import { logAlertEvent } from "@/utils/logging/logAlertEvent.js"; -import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js"; -import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "../config/fullSubjectCacheConfig.js"; import { isFullSubjectSnapshotCacheStoreEnabled, upsertInvoiceInFullSubjectBlobCacheStore, @@ -11,20 +7,12 @@ import { type UpsertInvoiceAction = "appended" | "updated"; -type UpsertInvoiceLuaResult = { - success: boolean; - action?: UpsertInvoiceAction; - cache_miss?: boolean; -}; - export type UpsertCachedInvoiceV2Result = { success: boolean; action?: UpsertInvoiceAction; cacheMiss?: boolean; }; -const FULL_SUBJECT_INVOICE_UPSERT_SLOW_THRESHOLD_MS = 100; - export const upsertCachedInvoiceV2 = async ({ ctx, customerId, @@ -41,72 +29,20 @@ export const upsertCachedInvoiceV2 = async ({ return null; } - const { org, env, logger, redisV2 } = ctx; - if (isFullSubjectSnapshotCacheStoreEnabled({ ctx })) { const cacheStoreResult = await upsertInvoiceInFullSubjectBlobCacheStore({ ctx, customerId, invoice, }); - logger.info( + ctx.logger.info( `[upsertCachedInvoiceV2] cacheStore full subject invoice upsert for customer ${customerId}, invoice ${invoice.stripe_id}, action: ${cacheStoreResult.action ?? "none"}${cacheStoreResult.cacheMiss ? ", cache_miss" : ""}`, ); return cacheStoreResult; } - const subjectKey = buildFullSubjectKey({ - orgId: org.id, - env, - customerId, - }); - const invoiceJson = JSON.stringify(invoice); - - const startTime = Date.now(); - const result = await tryRedisWrite( - ctx.workerEnv ?? ({} as Env), - async () => - await redisV2.upsertInvoiceInFullSubjectV2( - subjectKey, - invoiceJson, - String(FULL_SUBJECT_CACHE_TTL_SECONDS), - String(Date.now()), - ), - redisV2, + ctx.logger.warn( + `[upsertCachedInvoiceV2] No CacheStore available for customer ${customerId}, invoice ${invoice.stripe_id}`, ); - const durationMilliseconds = Date.now() - startTime; - - if (durationMilliseconds > FULL_SUBJECT_INVOICE_UPSERT_SLOW_THRESHOLD_MS) { - logAlertEvent({ - ctx, - severity: "warning", - category: "redis", - alertKey: "redis_full_subject_invoice_upsert_slow", - message: `FullSubject invoice upsert was slow for ${customerId}`, - source: "upsertCachedInvoiceV2", - component: "full_subject_cache", - data: { - subject_key: subjectKey, - duration_ms: durationMilliseconds, - threshold_ms: FULL_SUBJECT_INVOICE_UPSERT_SLOW_THRESHOLD_MS, - redis_command: "upsertInvoiceInFullSubjectV2", - invoice_stripe_id: invoice.stripe_id ?? null, - }, - }); - } - - if (result === null) { - logger.warn( - `[upsertCachedInvoiceV2] Redis write failed for customer ${customerId}, invoice ${invoice.stripe_id}`, - ); - return null; - } - - const parsed = JSON.parse(result) as UpsertInvoiceLuaResult; - - return { - success: parsed.success, - action: parsed.action, - cacheMiss: parsed.cache_miss, - }; + return null; }; diff --git a/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts b/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts index a75b27736..652e84fe9 100644 --- a/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts +++ b/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts @@ -3,18 +3,7 @@ import type { SubjectBalance, UsageWindow, } from "@autumn/shared"; -import { runRedisOp } from "@/external/redis/utils/runRedisOp.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { buildSharedFullSubjectBalanceKey } from "../builders/buildSharedFullSubjectBalanceKey.js"; -import { - AGGREGATED_BALANCE_FIELD, - USAGE_WINDOWS_FIELD, -} from "../config/fullSubjectCacheConfig.js"; -import { roundSubjectBalance } from "../roundCacheBalance.js"; -import { - sanitizeCachedAggregatedFeatureBalance, - sanitizeCachedSubjectBalance, -} from "../sanitize/index.js"; export type FeatureBalanceResult = { featureId: string; @@ -25,21 +14,6 @@ export type FeatureBalanceResult = { usageWindows?: UsageWindow[]; }; -// Fail open: a missing/unparseable `_usage_windows` field reads as an empty -// counter set (the window restarts). cjson also encodes an empty Lua table as -// `{}`, so a non-array blob is an empty set, not corruption. -const parseUsageWindowsField = ( - usageWindowsJson: string | null, -): UsageWindow[] => { - if (!usageWindowsJson) return []; - try { - const parsed = JSON.parse(usageWindowsJson); - return Array.isArray(parsed) ? (parsed as UsageWindow[]) : []; - } catch { - return []; - } -}; - export type FeatureBalanceOutcome = | { kind: "ok"; value: FeatureBalanceResult } | { kind: "missing"; reason: string }; @@ -48,33 +22,14 @@ export type FeatureBalancesBatchOutcome = | { kind: "ok"; value: FeatureBalanceResult[] } | { kind: "missing"; reason: string }; -const readFeatureBalancesFromMaster = async ({ - ctx, - balanceKey, - customerEntitlementIds, -}: { - ctx: AutumnContext; - balanceKey: string; - customerEntitlementIds: string[]; -}): Promise<(string | null)[] | null> => { - const { redisV2 } = ctx; - const multi = redisV2.multi(); - multi.hmget(balanceKey, ...customerEntitlementIds); - const multiResults = await multi.exec(); - const firstResult = multiResults?.[0]; - if (!firstResult) return null; - - const [commandError, values] = firstResult; - if (commandError) throw commandError; - return (values ?? null) as (string | null)[] | null; -}; +const LIVE_BALANCE_CACHE_UNAVAILABLE_REASON = "live_balance_cache_unavailable"; export const getCachedFeatureBalance = async ({ - ctx, - customerId, + ctx: _ctx, + customerId: _customerId, featureId, customerEntitlementIds, - readMaster = false, + readMaster: _readMaster = false, }: { ctx: AutumnContext; customerId: string; @@ -82,68 +37,23 @@ export const getCachedFeatureBalance = async ({ customerEntitlementIds: string[]; readMaster?: boolean; }): Promise => { - const { org, env, redisV2 } = ctx; - const balanceKey = buildSharedFullSubjectBalanceKey({ - orgId: org.id, - env, - customerId, - featureId, - }); - if (customerEntitlementIds.length === 0) { return { kind: "ok", value: { featureId, balances: [] } }; } - const results = await runRedisOp({ - operation: () => - readMaster - ? readFeatureBalancesFromMaster({ - ctx, - balanceKey, - customerEntitlementIds, - }) - : redisV2.hmget(balanceKey, ...customerEntitlementIds), - source: "getCachedFeatureBalance", - redisInstance: redisV2, - }); - - if (!results) return { kind: "missing", reason: "single_pipeline_null" }; - - const balances: SubjectBalance[] = []; - for (let i = 0; i < customerEntitlementIds.length; i++) { - const entryJson = results[i]; - if (!entryJson) - return { - kind: "missing", - reason: `single_field_null:${featureId}:${customerEntitlementIds[i]}`, - }; - try { - const parsedBalance = JSON.parse(entryJson) as SubjectBalance; - balances.push( - roundSubjectBalance({ - subjectBalance: sanitizeCachedSubjectBalance({ - subjectBalance: parsedBalance, - }), - }), - ); - } catch { - return { - kind: "missing", - reason: `single_parse_failed:${featureId}:${customerEntitlementIds[i]}`, - }; - } - } - - return { kind: "ok", value: { featureId, balances } }; + return { + kind: "missing", + reason: LIVE_BALANCE_CACHE_UNAVAILABLE_REASON, + }; }; export const getCachedFeatureBalancesBatch = async ({ - ctx, - customerId, + ctx: _ctx, + customerId: _customerId, featureIds, - customerEntitlementIdsByFeatureId, - includeAggregated = false, - usageWindowFeatureIds, + customerEntitlementIdsByFeatureId: _customerEntitlementIdsByFeatureId, + includeAggregated: _includeAggregated = false, + usageWindowFeatureIds: _usageWindowFeatureIds, }: { ctx: AutumnContext; customerId: string; @@ -156,109 +66,8 @@ export const getCachedFeatureBalancesBatch = async ({ }): Promise => { if (featureIds.length === 0) return { kind: "ok", value: [] }; - const { org, env, redisV2 } = ctx; - const pipeline = redisV2.pipeline(); - for (const featureId of featureIds) { - const customerEntitlementIds = - customerEntitlementIdsByFeatureId[featureId] ?? []; - const fields = [...customerEntitlementIds]; - if (includeAggregated) fields.push(AGGREGATED_BALANCE_FIELD); - if (usageWindowFeatureIds?.has(featureId)) { - fields.push(USAGE_WINDOWS_FIELD); - } - pipeline.hmget( - buildSharedFullSubjectBalanceKey({ - orgId: org.id, - env, - customerId, - featureId, - }), - ...fields, - ); - } - - const results = await runRedisOp({ - operation: () => pipeline.exec(), - source: "getCachedFeatureBalancesBatch", - redisInstance: redisV2, - }); - - if (!results) return { kind: "missing", reason: "batch_pipeline_null" }; - - const featureBalances: FeatureBalanceResult[] = []; - - for (let i = 0; i < featureIds.length; i++) { - const customerEntitlementIds = - customerEntitlementIdsByFeatureId[featureIds[i]] ?? []; - const allValues = results[i]?.[1] as (string | null)[] | null; - if (!allValues) - return { - kind: "missing", - reason: `batch_hash_missing:${featureIds[i]}`, - }; - - let aggregated: AggregatedFeatureBalance | undefined; - let usageWindows: UsageWindow[] | undefined; - - // Pop reserved fields in reverse push order: [_aggregated?, _usage_windows?]. - if (usageWindowFeatureIds?.has(featureIds[i])) { - usageWindows = parseUsageWindowsField(allValues.pop() ?? null); - } - - if (includeAggregated) { - const aggregatedJson = allValues.pop() ?? null; - if (aggregatedJson) { - try { - const parsed = JSON.parse(aggregatedJson) as AggregatedFeatureBalance; - aggregated = sanitizeCachedAggregatedFeatureBalance({ - aggregated: parsed, - }); - } catch { - // Malformed _aggregated is non-fatal; fall back to subject string value - } - } - } - - const ceValues = allValues; - - if (ceValues.length !== customerEntitlementIds.length) - return { - kind: "missing", - reason: `batch_length_mismatch:${featureIds[i]}:got=${ceValues.length}:expected=${customerEntitlementIds.length}`, - }; - - const balances: SubjectBalance[] = []; - for (let j = 0; j < ceValues.length; j++) { - const entryJson = ceValues[j]; - if (!entryJson) - return { - kind: "missing", - reason: `batch_field_null:${featureIds[i]}:${customerEntitlementIds[j]}`, - }; - try { - const parsedBalance = JSON.parse(entryJson) as SubjectBalance; - balances.push( - roundSubjectBalance({ - subjectBalance: sanitizeCachedSubjectBalance({ - subjectBalance: parsedBalance, - }), - }), - ); - } catch { - return { - kind: "missing", - reason: `batch_parse_failed:${featureIds[i]}:${customerEntitlementIds[j]}`, - }; - } - } - - featureBalances.push({ - featureId: featureIds[i], - balances, - aggregated, - usageWindows, - }); - } - - return { kind: "ok", value: featureBalances }; + return { + kind: "missing", + reason: LIVE_BALANCE_CACHE_UNAVAILABLE_REASON, + }; }; diff --git a/server/src/internal/customers/cache/fullSubject/tryOrInvalidate.ts b/server/src/internal/customers/cache/fullSubject/tryOrInvalidate.ts index d7a6c116a..106bcef01 100644 --- a/server/src/internal/customers/cache/fullSubject/tryOrInvalidate.ts +++ b/server/src/internal/customers/cache/fullSubject/tryOrInvalidate.ts @@ -1,6 +1,8 @@ -import { RedisUnavailableError } from "@/external/redis/utils/errors.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +const isUnavailableError = (error: unknown): boolean => + error instanceof Error && error.name.endsWith("UnavailableError"); + /** Runs `operation`. If it throws or returns `undefined`, logs a warning, * calls `invalidate`, and returns `undefined`. Collapses the repetitive * "warn + invalidate + miss" pattern used by FullSubject cache readers @@ -22,7 +24,7 @@ export const tryOrInvalidate = async ({ if (result !== undefined) return result; ctx.logger.warn(warnMessage); } catch (error) { - if (error instanceof RedisUnavailableError) throw error; + if (isUnavailableError(error)) throw error; ctx.logger.warn(`${warnMessage}, error: ${error}`); } await invalidate(); diff --git a/server/src/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.ts b/server/src/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.ts index c58558cb7..22ff57aa2 100644 --- a/server/src/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.ts +++ b/server/src/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.ts @@ -1,7 +1,6 @@ import type { InsertCustomerProduct } from "@autumn/shared"; import type { RepoContext } from "@/db/repoContext.js"; -import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { invalidateCustomerCacheStoreSnapshots } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; type UpdateCachedCustomerProductResult = { ok: boolean; @@ -10,15 +9,13 @@ type UpdateCachedCustomerProductResult = { }; /** - * Atomically updates specific fields on a cusProduct in the Redis - * FullCustomer cache. Matches by cusProduct id and applies targeted - * JSON.SET per field. CRDT-safe. + * Invalidates customer snapshots after a customer product update. Product + * fields are persisted in DB and snapshots are rebuilt from DB on the next read. */ export const updateCachedCustomerProduct = async ({ ctx, customerId, cusProductId, - updates, }: { ctx: RepoContext; customerId: string; @@ -33,51 +30,12 @@ export const updateCachedCustomerProduct = async ({ return null; } - const { org, env, logger, redisV2 } = ctx; - - const cacheKey = buildFullCustomerCacheKey({ - orgId: org.id, - env, + await invalidateCustomerCacheStoreSnapshots({ + ctx, customerId, + source: "updateCachedCustomerProduct", }); - - const result = await tryRedisWrite( - async () => { - return await redisV2.updateCustomerProduct( - cacheKey, - JSON.stringify({ - cus_product_id: cusProductId, - updates, - }), - ); - }, - redisV2, - ); - - if (result === null) { - logger.warn( - `[updateCachedCustomerProduct] Redis write failed for cusProduct ${cusProductId}`, - ); - return null; - } - - const parsed = JSON.parse(result) as { - ok: boolean; - updated_count?: number; - error?: string; - }; - - if (!parsed.ok) { - logger.warn( - `[updateCachedCustomerProduct] Lua script error for cusProduct ${cusProductId}: ${parsed.error}`, - ); - } - - return { - ok: parsed.ok, - updatedCount: parsed.updated_count, - error: parsed.error, - }; + return null; } catch (error) { ctx.logger.error( `[updateCachedCustomerProduct] cusProduct ${cusProductId}: error, ${error}`, diff --git a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts index 28dc97ff4..42e5962c1 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts @@ -25,8 +25,7 @@ import { StatusCodes } from "http-status-codes"; import { buildConflictUpdateColumns } from "@/db/dbUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { RepoContext } from "@/db/repoContext"; -import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { invalidateCustomerCacheStoreSnapshots } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; import RecaseError from "@/utils/errorUtils.js"; export class CusEntService { @@ -368,14 +367,12 @@ export class CusEntService { static async syncUpdateToCache({ ctx, - cusEntId, - updates, }: { ctx: RepoContext; cusEntId: string; updates: Partial; }) { - const { org, env, customerId, redisV2 } = ctx; + const { customerId } = ctx; if (!customerId) { ctx.logger.warn( @@ -384,40 +381,10 @@ export class CusEntService { return; } - const cacheKey = buildFullCustomerCacheKey({ - orgId: org.id, - env, - customerId: customerId ?? "", - }); - - const cacheUpdates = [ - { - cus_ent_id: cusEntId, - balance: updates.balance ?? null, - additional_balance: updates.additional_balance ?? null, - adjustment: updates.adjustment ?? null, - entities: updates.entities ?? null, - next_reset_at: updates.next_reset_at ?? null, - expected_next_reset_at: null, - rollover_insert: null, - rollover_overwrites: null, - rollover_delete_ids: null, - new_replaceables: null, - deleted_replaceable_ids: null, - }, - ]; - - await tryRedisWrite( - () => - redisV2.updateCustomerEntitlements( - cacheKey, - JSON.stringify({ updates: cacheUpdates }), - ), - redisV2, - ).catch((error) => { - ctx.logger.warn( - `[CusEntService.syncUpdateToCache] Redis cache update skipped for cusEnt ${cusEntId}: ${error}`, - ); + await invalidateCustomerCacheStoreSnapshots({ + ctx, + customerId, + source: "CusEntService.syncUpdateToCache", }); } diff --git a/server/src/internal/customers/cusProducts/cusEnts/actions/cache/adjustSubjectBalanceCache.ts b/server/src/internal/customers/cusProducts/cusEnts/actions/cache/adjustSubjectBalanceCache.ts index 473f35cab..0547c9a75 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/actions/cache/adjustSubjectBalanceCache.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/actions/cache/adjustSubjectBalanceCache.ts @@ -1,7 +1,5 @@ import type { RepoContext } from "@/db/repoContext.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 { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { invalidateCustomerCacheStoreSnapshots } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; type AdjustSubjectBalanceCacheResult = { ok: boolean; @@ -12,9 +10,7 @@ type AdjustSubjectBalanceCacheResult = { export const adjustSubjectBalanceCache = async ({ ctx, customerId, - featureId, customerEntitlementId, - delta, }: { ctx: RepoContext; customerId: string; @@ -23,51 +19,12 @@ export const adjustSubjectBalanceCache = async ({ delta: number; }): Promise => { try { - const { redisV2 } = ctx; - const balanceKey = buildSharedFullSubjectBalanceKey({ - orgId: ctx.org.id, - env: ctx.env, + await invalidateCustomerCacheStoreSnapshots({ + ctx, customerId, - featureId, + source: "adjustSubjectBalanceCache", }); - - const result = await tryRedisWrite( - () => - redisV2.adjustSubjectBalance( - balanceKey, - JSON.stringify({ - cus_ent_id: customerEntitlementId, - delta, - ttl_seconds: FULL_SUBJECT_CACHE_TTL_SECONDS, - }), - ), - redisV2, - ); - - if (result === null) { - ctx.logger.warn( - `[adjustSubjectBalanceCache] Redis write failed for customer entitlement ${customerEntitlementId}`, - ); - return null; - } - - const parsed = JSON.parse(result) as { - ok: boolean; - new_balance?: number; - error?: string; - }; - - if (!parsed.ok) { - ctx.logger.warn( - `[adjustSubjectBalanceCache] Lua script no-op for customer entitlement ${customerEntitlementId}: ${parsed.error}`, - ); - } - - return { - ok: parsed.ok, - newBalance: parsed.new_balance, - error: parsed.error, - }; + return null; } catch (error) { ctx.logger.error( `[adjustSubjectBalanceCache] customer entitlement ${customerEntitlementId}: error, ${error}`, diff --git a/server/src/internal/customers/cusProducts/cusEnts/actions/cache/incrementCachedCusEntBalance.ts b/server/src/internal/customers/cusProducts/cusEnts/actions/cache/incrementCachedCusEntBalance.ts index 7f84f1ead..a1983a28d 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/actions/cache/incrementCachedCusEntBalance.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/actions/cache/incrementCachedCusEntBalance.ts @@ -1,6 +1,5 @@ import type { RepoContext } from "@/db/repoContext.js"; -import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { invalidateCustomerCacheStoreSnapshots } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; type IncrementCachedCusEntBalanceResult = { ok: boolean; @@ -8,15 +7,10 @@ type IncrementCachedCusEntBalanceResult = { newCacheVersion?: number; }; -/** - * Atomically increments a cusEnt's balance (and cache_version) in the Redis - * FullCustomer cache via JSON.NUMINCRBY. Safe with concurrent deductions. - */ export const incrementCachedCusEntBalance = async ({ ctx, customerId, cusEntId, - delta, }: { ctx: RepoContext; customerId: string; @@ -24,49 +18,12 @@ export const incrementCachedCusEntBalance = async ({ delta: number; }): Promise => { try { - const { org, env, logger, redisV2 } = ctx; - - const cacheKey = buildFullCustomerCacheKey({ - orgId: org.id, - env, + await invalidateCustomerCacheStoreSnapshots({ + ctx, customerId, + source: "incrementCachedCusEntBalance", }); - - const result = await tryRedisWrite( - async () => { - return await redisV2.adjustCustomerEntitlementBalance( - cacheKey, - JSON.stringify({ cus_ent_id: cusEntId, delta }), - ); - }, - redisV2, - ); - - if (result === null) { - logger.warn( - `[incrementCachedCusEntBalance] Redis write failed for cusEnt ${cusEntId}`, - ); - return null; - } - - const parsed = JSON.parse(result) as { - ok: boolean; - new_balance?: number; - new_cache_version?: number; - error?: string; - }; - - if (!parsed.ok) { - logger.warn( - `[incrementCachedCusEntBalance] Lua script error for cusEnt ${cusEntId}: ${parsed.error}`, - ); - } - - return { - ok: parsed.ok, - newBalance: parsed.new_balance, - newCacheVersion: parsed.new_cache_version, - }; + return null; } catch (error) { ctx.logger.error( `[incrementCachedCusEntBalance] cusEnt ${cusEntId}: error, ${error}`, diff --git a/server/src/internal/customers/cusProducts/cusEnts/actions/cache/updateSubjectBalanceCache.ts b/server/src/internal/customers/cusProducts/cusEnts/actions/cache/updateSubjectBalanceCache.ts index 77f471dfd..be96cc512 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/actions/cache/updateSubjectBalanceCache.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/actions/cache/updateSubjectBalanceCache.ts @@ -1,14 +1,9 @@ import type { RepoContext } from "@/db/repoContext.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 { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { invalidateCustomerCacheStoreSnapshots } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.js"; export const updateSubjectBalanceCache = async ({ ctx, customerId, - featureId, - customerEntitlementId, - updates, }: { ctx: RepoContext; customerId: string; @@ -22,40 +17,9 @@ export const updateSubjectBalanceCache = async ({ next_reset_at?: number | null; }; }) => { - const { redisV2 } = ctx; - const balanceKey = buildSharedFullSubjectBalanceKey({ - orgId: ctx.org.id, - env: ctx.env, + await invalidateCustomerCacheStoreSnapshots({ + ctx, customerId, - featureId, + source: "updateSubjectBalanceCache", }); - - // Runtime FullSubject cache patches must not mutate cache_version. - // cache_version is a DB-side stale-sync guard owned by lifecycle/billing flows. - await tryRedisWrite( - () => - redisV2.updateSubjectBalances( - balanceKey, - JSON.stringify({ - ttl_seconds: FULL_SUBJECT_CACHE_TTL_SECONDS, - updates: [ - { - cus_ent_id: customerEntitlementId, - balance: updates.balance ?? null, - additional_balance: updates.additional_balance ?? null, - adjustment: updates.adjustment ?? null, - entities: updates.entities ?? null, - next_reset_at: updates.next_reset_at ?? null, - expected_next_reset_at: null, - rollover_insert: null, - rollover_overwrites: null, - rollover_delete_ids: null, - new_replaceables: null, - deleted_replaceable_ids: null, - }, - ], - }), - ), - redisV2, - ); }; diff --git a/server/src/internal/customers/cusProducts/cusEnts/actions/updateCusEntDbAndCache.ts b/server/src/internal/customers/cusProducts/cusEnts/actions/updateCusEntDbAndCache.ts index 8592f7ffb..dd217eebc 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/actions/updateCusEntDbAndCache.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/actions/updateCusEntDbAndCache.ts @@ -1,12 +1,10 @@ import type { InsertCustomerEntitlement } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { updateSubjectBalanceCache } from "@/internal/customers/cusProducts/cusEnts/actions/cache/updateSubjectBalanceCache.js"; -import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { CusEntService } from "../CusEntitlementService.js"; /** - * Updates a cusEnt in both Postgres and the Redis FullCustomer cache. + * Updates a cusEnt in Postgres and invalidates customer snapshots. */ export const updateCusEntDbAndCache = async ({ ctx, @@ -30,56 +28,17 @@ export const updateCusEntDbAndCache = async ({ incrementCacheVersion, }); - const { redisV2 } = ctx; - const cacheKey = buildFullCustomerCacheKey({ - orgId: ctx.org.id, - env: ctx.env, + await updateSubjectBalanceCache({ + ctx, customerId, - }); - - const cacheUpdates = [ - { - cus_ent_id: cusEntId, - balance: updates.balance ?? null, - additional_balance: updates.additional_balance ?? null, - adjustment: updates.adjustment ?? null, - entities: updates.entities ?? null, - next_reset_at: updates.next_reset_at ?? null, - expected_next_reset_at: null, - rollover_insert: null, - rollover_overwrites: null, - rollover_delete_ids: null, - new_replaceables: null, - deleted_replaceable_ids: null, + featureId, + customerEntitlementId: cusEntId, + updates: { + balance: updates.balance, + additional_balance: updates.additional_balance, + adjustment: updates.adjustment, + entities: updates.entities, + next_reset_at: updates.next_reset_at, }, - ]; - - await Promise.all([ - tryRedisWrite( - ctx.workerEnv ?? ({} as Env), - () => - redisV2.updateCustomerEntitlements( - cacheKey, - JSON.stringify({ updates: cacheUpdates }), - ), - redisV2, - ).catch((error) => { - ctx.logger.warn( - `[updateCusEntDbAndCache] Redis cache update skipped for cusEnt ${cusEntId}: ${error}`, - ); - }), - updateSubjectBalanceCache({ - ctx, - customerId, - featureId, - customerEntitlementId: cusEntId, - updates: { - balance: updates.balance, - additional_balance: updates.additional_balance, - adjustment: updates.adjustment, - entities: updates.entities, - next_reset_at: updates.next_reset_at, - }, - }), - ]); + }); }; diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheStoreActions.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheStoreActions.ts index fb07e6994..e2e314f1f 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheStoreActions.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheStoreActions.ts @@ -1,7 +1,6 @@ import type { FullCustomer, Invoice } from "@autumn/shared"; -import type { Redis } from "ioredis"; -import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRoutingInfo.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { isCacheRoutingMigrationStale } from "@/internal/customers/cache/cacheRoutingStaleness.js"; import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js"; import { buildFullCustomerCacheKey, @@ -14,12 +13,9 @@ import { export const isFullCustomerCacheStoreEnabled = ({ ctx, - redisInstance, }: { ctx: AutumnContext; - redisInstance?: Redis; }): boolean => { - if (redisInstance) return false; if (!ctx.cacheStore) return false; return ctx.cacheStore.metadata.backend !== "redis"; }; @@ -77,14 +73,14 @@ export const getFullCustomerSnapshotFromCacheStore = async ({ } if ( - isRedisMigrationCacheStale({ + isCacheRoutingMigrationStale({ cachedAt, customerId, redisConfig: ctx.org.redis_config, }) ) { ctx.logger.warn( - `[getFullCustomerSnapshotFromCacheStore] Stale Redis migration cache for ${customerId}, evicting`, + `[getFullCustomerSnapshotFromCacheStore] Stale cache routing migration snapshot for ${customerId}, evicting`, ); await ctx.cacheStore.delete(cacheKey); return undefined; diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.ts new file mode 100644 index 000000000..2c392dba4 --- /dev/null +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/invalidateCustomerCacheStoreSnapshots.ts @@ -0,0 +1,66 @@ +import type { AppEnv } from "@autumn/shared"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; +import type { CacheStore } from "@/external/storage/cache/index.js"; +import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js"; +import { buildFullCustomerCacheKey } from "./fullCustomerCacheConfig.js"; + +type SnapshotCacheContext = { + org: { + id: string; + }; + env: AppEnv; + logger: Logger; + cacheStore?: CacheStore; +}; + +export const hasNonRedisSnapshotCacheStore = ({ + ctx, +}: { + ctx: SnapshotCacheContext; +}): boolean => { + return !!ctx.cacheStore && ctx.cacheStore.metadata.backend !== "redis"; +}; + +export const invalidateCustomerCacheStoreSnapshots = async ({ + ctx, + customerId, + entityId, + source, +}: { + ctx: SnapshotCacheContext; + customerId: string; + entityId?: string; + source: string; +}): Promise => { + if (!ctx.cacheStore || ctx.cacheStore.metadata.backend === "redis") return; + if (!customerId) return; + + const fullCustomerKey = buildFullCustomerCacheKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + }); + const subjectCustomerKey = buildFullSubjectKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + }); + const keys = [fullCustomerKey, subjectCustomerKey]; + + if (entityId) { + keys.push( + buildFullSubjectKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + entityId, + }), + ); + } + + await ctx.cacheStore.deleteMany(keys).catch((error) => { + ctx.logger.warn( + `[invalidateCustomerCacheStoreSnapshots] failed for ${customerId}${entityId ? `:${entityId}` : ""}, source: ${source}, error: ${error}`, + ); + }); +}; diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts index 149967c40..b0099136d 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts @@ -1,8 +1,18 @@ -import type { LockStore } from "@/external/redis/lockStore.js"; -import { getDefaultLockStore } from "@/external/redis/defaultLockStore.js"; -import { resolveRequestLockStore } from "@/external/redis/resolveRequestLockStore.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +export type FullCustomerCacheGuardStore = { + acquire({ + lockKey, + ttlMs, + errorMessage, + }: { + lockKey: string; + ttlMs: number; + errorMessage: string; + }): Promise<"acquired" | "already_locked" | "unavailable">; + release({ lockKey }: { lockKey: string }): Promise; +}; + /** * Builds the test cache delete guard key for fullCustomer cache */ @@ -16,18 +26,6 @@ export const buildTestFullCustomerCacheGuardKey = ({ customerId: string; }) => `{${orgId}}:${env}:test_full_customer_cache_guard:${customerId}`; -const resolveTestFullCustomerCacheGuardStore = ({ - ctx, - store, -}: { - ctx: AutumnContext; - store?: LockStore; -}): LockStore => { - if (store) return store; - if (ctx.workerEnv) return resolveRequestLockStore(ctx.workerEnv); - return getDefaultLockStore(); -}; - /** * Sets a test cache delete guard to prevent fullCustomer cache deletion during testing. * When this guard exists, deleteCachedFullCustomer will skip deletion. @@ -41,16 +39,20 @@ export const setTestFullCustomerCacheGuard = async ({ ctx: AutumnContext; customerId: string; ttlMs?: number; - store?: LockStore; + store?: FullCustomerCacheGuardStore; }): Promise => { + if (!store) { + ctx.logger.error("No test fullCustomer cache guard store provided"); + return false; + } + const key = buildTestFullCustomerCacheGuardKey({ orgId: ctx.org.id, env: ctx.env, customerId, }); try { - const guardStore = resolveTestFullCustomerCacheGuardStore({ ctx, store }); - const result = await guardStore.acquire({ + const result = await store.acquire({ lockKey: key, ttlMs, errorMessage: `Test fullCustomer cache guard already exists for customer ${customerId}`, @@ -72,16 +74,20 @@ export const removeTestFullCustomerCacheGuard = async ({ }: { ctx: AutumnContext; customerId: string; - store?: LockStore; + store?: FullCustomerCacheGuardStore; }): Promise => { + if (!store) { + ctx.logger.error("No test fullCustomer cache guard store provided"); + return false; + } + const key = buildTestFullCustomerCacheGuardKey({ orgId: ctx.org.id, env: ctx.env, customerId, }); try { - const guardStore = resolveTestFullCustomerCacheGuardStore({ ctx, store }); - await guardStore.release({ lockKey: key }); + await store.release({ lockKey: key }); return true; } catch (error) { ctx.logger.error(`Failed to remove test fullCustomer cache guard: ${error}`); diff --git a/server/src/internal/customers/handlers/handleClearCustomerCache.ts b/server/src/internal/customers/handlers/handleClearCustomerCache.ts index 3bc8efa4b..e22164db5 100644 --- a/server/src/internal/customers/handlers/handleClearCustomerCache.ts +++ b/server/src/internal/customers/handlers/handleClearCustomerCache.ts @@ -1,6 +1,5 @@ -import { orgToFeaturesByOrgEnv, Scopes } from "@autumn/shared"; +import { Scopes } from "@autumn/shared"; import { z } from "zod/v4"; -import { getRequestRedisTargetsForCustomer } from "@/external/redis/requestRedisTargetsForCustomer.js"; import { createRoute } from "../../../honoMiddlewares/routeHandler"; import { batchInvalidateCachedFullSubjects } from "../cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects"; import { deleteCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; @@ -29,21 +28,10 @@ export const handleClearCustomerCache = createRoute({ orgId: ctx.org.id, env: ctx.env, })); - const featuresByOrgEnv = orgToFeaturesByOrgEnv({ - org: ctx.org, - env: ctx.env, - features: ctx.features, - }); await batchInvalidateCachedFullSubjects({ ctx, customers: customersToDelete, - featuresByOrgEnv, - getRedisTargetsForCustomer: () => - getRequestRedisTargetsForCustomer({ - org: ctx.org, - currentRedis: ctx.redisV2, - }), }); } diff --git a/server/src/internal/dev/handlers/handleCliStripe.ts b/server/src/internal/dev/handlers/handleCliStripe.ts index aeb4c96c1..7e91cafdf 100644 --- a/server/src/internal/dev/handlers/handleCliStripe.ts +++ b/server/src/internal/dev/handlers/handleCliStripe.ts @@ -63,12 +63,14 @@ export const handleCliStripe = createRoute({ stripeTestKey, AppEnv.Sandbox, orgId, + c.env, ); const liveWebhook = await createWebhookEndpoint( stripeLiveKey, AppEnv.Live, orgId, + c.env, ); await OrgService.update({ diff --git a/server/src/internal/dev/handlers/handleCreateSecretKey.ts b/server/src/internal/dev/handlers/handleCreateSecretKey.ts index 276f9e9b1..9cafec800 100644 --- a/server/src/internal/dev/handlers/handleCreateSecretKey.ts +++ b/server/src/internal/dev/handlers/handleCreateSecretKey.ts @@ -70,6 +70,7 @@ export const handleCreateSecretKey = createRoute({ }); await captureOrgEvent({ + env: c.env, orgId: org.id, event: "api key created", properties: { diff --git a/server/src/internal/dev/handlers/handleGetDevData.ts b/server/src/internal/dev/handlers/handleGetDevData.ts index 2018185a1..8536afcd7 100644 --- a/server/src/internal/dev/handlers/handleGetDevData.ts +++ b/server/src/internal/dev/handlers/handleGetDevData.ts @@ -14,7 +14,7 @@ export const handleGetDevData = createRoute({ env, }); - const dashboardUrl = await getSvixDashboardUrl({ + const dashboardUrl = await getSvixDashboardUrl(c.env)({ env, org, }); diff --git a/server/src/internal/features/featureActions/runClearCreditSystemCacheTask.ts b/server/src/internal/features/featureActions/runClearCreditSystemCacheTask.ts index 11e3d0277..d1bd0e341 100644 --- a/server/src/internal/features/featureActions/runClearCreditSystemCacheTask.ts +++ b/server/src/internal/features/featureActions/runClearCreditSystemCacheTask.ts @@ -3,12 +3,11 @@ import { customerEntitlements, customerProducts, customers, - orgToFeaturesByOrgEnv, RELEVANT_STATUSES, } from "@autumn/shared"; import { and, asc, count, eq, gt, inArray } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { getRequestRedisTargetsForCustomer } from "@/external/redis/requestRedisTargetsForCustomer.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; import type { Logger } from "../../../external/logtail/logtailUtils"; @@ -27,10 +26,12 @@ export const runClearCreditSystemCacheTask = async ({ db, payload, logger, + ctx, }: { db: DrizzleCli; payload: ClearCreditSystemCachePayload; logger: Logger; + ctx?: Pick; }) => { const { orgId, env, internalFeatureId } = payload; @@ -53,11 +54,6 @@ export const runClearCreditSystemCacheTask = async ({ ); return; } - const featuresByOrgEnv = orgToFeaturesByOrgEnv({ - org: orgWithFeatures.org, - env, - features: orgWithFeatures.features, - }); logger.info( `Clearing cache for customers with credit system feature: ${internalFeatureId}`, @@ -163,12 +159,8 @@ export const runClearCreditSystemCacheTask = async ({ if (customersToDelete.length > 0) { const deleted = await batchInvalidateCachedFullSubjects({ + ctx, customers: customersToDelete, - featuresByOrgEnv, - getRedisTargetsForCustomer: () => - getRequestRedisTargetsForCustomer({ - org: orgWithFeatures.org, - }), }); totalDeleted += deleted; } diff --git a/server/src/internal/features/featureActions/updateFeature.ts b/server/src/internal/features/featureActions/updateFeature.ts index f40846c7a..4cee20cac 100644 --- a/server/src/internal/features/featureActions/updateFeature.ts +++ b/server/src/internal/features/featureActions/updateFeature.ts @@ -302,8 +302,12 @@ export const updateFeature = async ({ }); if (schemaChanged || markupsChanged || aiMarkupConfigChanged) { + if (!ctx.workerEnv) { + throw new Error("Cloudflare Worker env is required to enqueue cache clear task"); + } await addTaskToQueue({ jobName: JobName.ClearCreditSystemCustomerCache, + env: ctx.workerEnv, payload: { orgId: ctx.org.id, env: ctx.env, diff --git a/server/src/internal/invoices/InvoiceService.ts b/server/src/internal/invoices/InvoiceService.ts index 9c3d9457f..7a7da8e3c 100644 --- a/server/src/internal/invoices/InvoiceService.ts +++ b/server/src/internal/invoices/InvoiceService.ts @@ -30,7 +30,7 @@ export const processInvoice = ({ invoice: Invoice; withItems?: boolean; features?: Feature[]; - env: Env; + env?: Env; }): ApiInvoiceV1 => { const processorType = invoice.processor_type ?? ProcessorType.Stripe; const isStripe = processorType === ProcessorType.Stripe; @@ -45,7 +45,7 @@ export const processInvoice = ({ currency: invoice.currency, created_at: invoice.created_at, hosted_invoice_url: isStripe - ? `${env.BETTER_AUTH_URL}/invoices/hosted_invoice_url/${invoice.id}` + ? `${env?.BETTER_AUTH_URL ?? ""}/invoices/hosted_invoice_url/${invoice.id}` : null, // hosted_invoice_url: invoice.hosted_invoice_url, // items: withItems diff --git a/server/src/internal/invoices/invoiceRouter.ts b/server/src/internal/invoices/invoiceRouter.ts index d47d7e714..7e57c2e12 100644 --- a/server/src/internal/invoices/invoiceRouter.ts +++ b/server/src/internal/invoices/invoiceRouter.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import type { Context, Next } from "hono"; import { rateLimiter } from "hono-rate-limiter"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleGetStripeInvoice } from "./handlers/handleGetStripeInvoice.js"; @@ -7,13 +8,20 @@ import { handleRedirectToInvoice } from "./handlers/handleRedirectToInvoice.js"; export const publicInvoiceRouter = new Hono(); // Rate limiter: 10 requests per minute -const invoiceRedirectLimiter = rateLimiter({ - windowMs: 60 * 1000, // 1 minute - limit: 10, - standardHeaders: "draft-6", - keyGenerator: (c) => - c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? "unknown", -}); +let invoiceRedirectRateLimiter: ReturnType> | null = + null; +const invoiceRedirectLimiter = (c: Context, next: Next) => { + invoiceRedirectRateLimiter ??= rateLimiter({ + windowMs: 60 * 1000, // 1 minute + limit: 10, + standardHeaders: "draft-6", + keyGenerator: (ctx) => + ctx.req.header("x-forwarded-for") ?? + ctx.req.header("x-real-ip") ?? + "unknown", + }); + return invoiceRedirectRateLimiter(c, next); +}; publicInvoiceRouter.get( "/hosted_invoice_url/:invoiceId", diff --git a/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts b/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts index 8ea3aed66..ef152b414 100644 --- a/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts +++ b/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts @@ -141,7 +141,9 @@ const listMigrationItemEventsBySql = async ({ `listMigrationItemEventsBySql: querying ${itemIds.length} item_ids for migration=${migrationInternalId}`, ); - const result = await migrationTinybird!.sql(sql); + const result = (await migrationTinybird!.sql(sql)) as { + data?: TinybirdMigrationItemEvent[]; + }; const rows = result.data ?? []; ctx.logger.info( diff --git a/server/src/internal/misc/cacheV2Ramp/cacheV2RampClient.ts b/server/src/internal/misc/cacheV2Ramp/cacheV2RampClient.ts index d3f2ceeea..3ba7fcecc 100644 --- a/server/src/internal/misc/cacheV2Ramp/cacheV2RampClient.ts +++ b/server/src/internal/misc/cacheV2Ramp/cacheV2RampClient.ts @@ -93,7 +93,7 @@ export const getRampDestinationRedis = (env: Env): LegacyRedisClient | null => { env, }); - instance.on("error", (error) => { + instance.on("error", (error: Error) => { logger.error(`[cacheV2Ramp] destination=${url}: ${error.message}`); }); diff --git a/server/src/internal/misc/idempotency/redisIdempotencyStore.ts b/server/src/internal/misc/idempotency/redisIdempotencyStore.ts index 72c22a2fc..a4b864105 100644 --- a/server/src/internal/misc/idempotency/redisIdempotencyStore.ts +++ b/server/src/internal/misc/idempotency/redisIdempotencyStore.ts @@ -1,5 +1,4 @@ -import type { Redis } from "ioredis"; -import { redis } from "@/external/redis/initRedis.js"; +import type { LegacyRedisClient } from "@/utils/legacyRedisClient.js"; import { registerDefaultIdempotencyStoreFactory } from "./defaultIdempotencyStore.js"; import type { IdempotencyStore, @@ -7,7 +6,7 @@ import type { } from "./idempotencyStore.js"; export class RedisIdempotencyStore implements IdempotencyStore { - constructor(private readonly redisClient: Redis = redis) {} + constructor(private readonly redisClient: LegacyRedisClient) {} async acquire({ key, @@ -37,7 +36,11 @@ export class RedisIdempotencyStore implements IdempotencyStore { } } -const redisIdempotencyStore = new RedisIdempotencyStore(); -registerDefaultIdempotencyStoreFactory(() => redisIdempotencyStore); +export const registerRedisIdempotencyStoreFactory = ( + redisClient: LegacyRedisClient, +): void => { + const redisIdempotencyStore = new RedisIdempotencyStore(redisClient); + registerDefaultIdempotencyStoreFactory(() => redisIdempotencyStore); +}; export { getDefaultIdempotencyStore } from "./defaultIdempotencyStore.js"; diff --git a/server/src/internal/misc/idempotency/resolveRequestIdempotencyStore.ts b/server/src/internal/misc/idempotency/resolveRequestIdempotencyStore.ts index 07e2726bb..7421ade22 100644 --- a/server/src/internal/misc/idempotency/resolveRequestIdempotencyStore.ts +++ b/server/src/internal/misc/idempotency/resolveRequestIdempotencyStore.ts @@ -20,7 +20,9 @@ export const getRequestIdempotencyStoreResolution = (env: Env) => { return { backend: - durableObjectEnabled && durableObjectBound ? "durable_object" : "redis", + durableObjectEnabled && durableObjectBound + ? "durable_object" + : "default", durableObjectEnabled, durableObjectBound, } as const; diff --git a/server/src/internal/misc/pricingAgent/handlers/handleSetupPreviewOrg.ts b/server/src/internal/misc/pricingAgent/handlers/handleSetupPreviewOrg.ts index c612f5400..8ef9c42ca 100644 --- a/server/src/internal/misc/pricingAgent/handlers/handleSetupPreviewOrg.ts +++ b/server/src/internal/misc/pricingAgent/handlers/handleSetupPreviewOrg.ts @@ -88,7 +88,7 @@ export const handleSetupPreviewOrg = createRoute({ // They can only interact with it via the returned API key. // Initialize org (creates Stripe test account, svix apps, etc.) - await afterOrgCreated({ org: previewOrg, user }); + await afterOrgCreated({ org: previewOrg, user, env: c.env }); logger.info( `[Preview] Created preview org: ${previewOrg.id} (${previewSlug})`, diff --git a/server/src/internal/misc/rateLimiter/rateLimitRedisStore.ts b/server/src/internal/misc/rateLimiter/rateLimitRedisStore.ts index b1a7f9a35..88ca0d55c 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitRedisStore.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitRedisStore.ts @@ -1,31 +1,39 @@ import { RedisStore } from "@hono-rate-limiter/redis"; import type { Env, Input } from "hono"; -import { redis } from "@/external/redis/initRedis.js"; +import type { LegacyRedisClient } from "@/utils/legacyRedisClient.js"; import { registerDefaultRateLimitStoreFactory } from "./defaultRateLimitStore.js"; export const createRateLimitRedisStore = < TEnv extends Env = Env, P extends string = string, I extends Input = Input, ->() => +>(redisClient: LegacyRedisClient) => new RedisStore({ client: { scriptLoad: (script: string) => - redis.script("LOAD", script) as Promise, + redisClient.script("LOAD", script) as Promise, evalsha: ( sha: string, keys: string[], args: TArgs, ): Promise => - redis.evalsha( + redisClient.evalsha( sha, keys.length, ...keys, ...(args as (string | number | Buffer)[]), ) as Promise, - decr: (key: string) => redis.decr(key), - del: (key: string) => redis.del(key), + decr: (key: string) => redisClient.decr(key), + del: (key: string) => redisClient.del(key), }, }); -registerDefaultRateLimitStoreFactory(createRateLimitRedisStore); +export const registerRedisRateLimitStoreFactory = ( + redisClient: LegacyRedisClient, +): void => { + registerDefaultRateLimitStoreFactory(< + TEnv extends Env = Env, + P extends string = string, + I extends Input = Input, + >() => createRateLimitRedisStore(redisClient)); +}; diff --git a/server/src/internal/misc/rollouts/fullSubjectRolloutUtils.ts b/server/src/internal/misc/rollouts/fullSubjectRolloutUtils.ts index 0a8492787..84e96e26c 100644 --- a/server/src/internal/misc/rollouts/fullSubjectRolloutUtils.ts +++ b/server/src/internal/misc/rollouts/fullSubjectRolloutUtils.ts @@ -1,5 +1,4 @@ import { isTransientDbError } from "@/db/dbUtils.js"; -import { isTransientRedisError } from "@/external/redis/utils/isTransientRedisError.js"; import type { AutumnContext, RolloutSnapshot } from "@/honoUtils/HonoEnv.js"; export const FULL_SUBJECT_ROLLOUT_ID = "v2-cache"; @@ -25,4 +24,4 @@ export const isRetryableFullSubjectRolloutError = ({ error, }: { error: unknown; -}) => isTransientRedisError({ error }) || isTransientDbError({ error }); +}) => isTransientDbError({ error }); diff --git a/server/src/internal/misc/trmnl/trmnlRouter.ts b/server/src/internal/misc/trmnl/trmnlRouter.ts index 4e5e6fa93..6bb478e8e 100644 --- a/server/src/internal/misc/trmnl/trmnlRouter.ts +++ b/server/src/internal/misc/trmnl/trmnlRouter.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import type { Context, Next } from "hono"; import { rateLimiter } from "hono-rate-limiter"; import { traceEnrichMiddleware } from "@/honoMiddlewares/traceMiddleware.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; @@ -8,12 +9,17 @@ import { handlePostTrmnlDeviceId } from "./handlers/handlePostTrmnlDeviceId.js"; import { trmnlAuthMiddleware } from "./trmnlAuthMiddleware.js"; // TRMNL rate limiter: 10 requests per 30 minutes in production, 1000 in dev -const trmnlScreenLimiter = rateLimiter({ - windowMs: 60 * 1000 * 30, // 30 minutes - limit: (c) => (c.env.NODE_ENV === "development" ? 1000 : 10), - standardHeaders: "draft-6", - keyGenerator: (c) => c.req.header("x-trmnl-id") ?? "unknown", -}); +let trmnlScreenRateLimiter: ReturnType> | null = + null; +const trmnlScreenLimiter = (c: Context, next: Next) => { + trmnlScreenRateLimiter ??= rateLimiter({ + windowMs: 60 * 1000 * 30, // 30 minutes + limit: (ctx) => (ctx.env.NODE_ENV === "development" ? 1000 : 10), + standardHeaders: "draft-6", + keyGenerator: (ctx) => ctx.req.header("x-trmnl-id") ?? "unknown", + }); + return trmnlScreenRateLimiter(c, next); +}; export const internalTrmnlRouter = new Hono(); diff --git a/server/src/internal/orgs/deleteOrg/deleteOrg.ts b/server/src/internal/orgs/deleteOrg/deleteOrg.ts index ca5bbe69f..d0867db65 100644 --- a/server/src/internal/orgs/deleteOrg/deleteOrg.ts +++ b/server/src/internal/orgs/deleteOrg/deleteOrg.ts @@ -37,7 +37,7 @@ export const deleteOrg = async ({ }); await Promise.all([ - deleteOrgSvixApps({ org, logger }), + deleteOrgSvixApps({ org, logger, workerEnv }), deleteOrgStripeWebhooks({ org, logger }), deleteOrgStripeAccounts({ org, logger, workerEnv }), ]); diff --git a/server/src/internal/orgs/deleteOrg/deleteOrgSvixApps.ts b/server/src/internal/orgs/deleteOrg/deleteOrgSvixApps.ts index ea3c16ed9..88267445d 100644 --- a/server/src/internal/orgs/deleteOrg/deleteOrgSvixApps.ts +++ b/server/src/internal/orgs/deleteOrg/deleteOrgSvixApps.ts @@ -5,14 +5,21 @@ import { deleteSvixApp } from "@/external/svix/svixHelpers.js"; export const deleteOrgSvixApps = async ({ org, logger, + workerEnv, }: { org: Organization; logger: Logger; + workerEnv?: Env; }) => { + if (!workerEnv) { + logger.error(`Cannot delete svix apps for ${org.id}: missing Worker env`); + return; + } + const batch = []; if (org.svix_config?.sandbox_app_id) { batch.push( - deleteSvixApp({ + deleteSvixApp(workerEnv)({ appId: org.svix_config.sandbox_app_id, }), ); @@ -20,7 +27,7 @@ export const deleteOrgSvixApps = async ({ if (org.svix_config?.live_app_id) { batch.push( - deleteSvixApp({ + deleteSvixApp(workerEnv)({ appId: org.svix_config.live_app_id, }), ); diff --git a/server/src/internal/orgs/deleteOrg/deletePlatformSubOrg.ts b/server/src/internal/orgs/deleteOrg/deletePlatformSubOrg.ts index 5c0e62c7a..3307b83f3 100644 --- a/server/src/internal/orgs/deleteOrg/deletePlatformSubOrg.ts +++ b/server/src/internal/orgs/deleteOrg/deletePlatformSubOrg.ts @@ -50,7 +50,7 @@ export const deletePlatformSubOrg = async ({ } logger.info("1. Deleting svix webhooks"); - await deleteSvixWebhooks({ org, logger }); + await deleteSvixWebhooks({ org, logger, workerEnv }); logger.info("2. Deleting stripe webhooks"); await deleteStripeWebhooks({ org, logger }); diff --git a/server/src/internal/orgs/handlers/crudHandlers/handleDeleteOrg.ts b/server/src/internal/orgs/handlers/crudHandlers/handleDeleteOrg.ts index bce6a3181..0629c0925 100644 --- a/server/src/internal/orgs/handlers/crudHandlers/handleDeleteOrg.ts +++ b/server/src/internal/orgs/handlers/crudHandlers/handleDeleteOrg.ts @@ -12,14 +12,21 @@ import { deleteStripeWebhook } from "../../orgUtils.js"; const deleteSvixWebhooks = async ({ org, logger, + workerEnv, }: { org: Organization; logger: any; + workerEnv?: Env; }) => { + if (!workerEnv) { + logger.error(`Cannot delete svix webhooks for ${org.id}: missing Worker env`); + return; + } + const batch = []; if (org.svix_config?.sandbox_app_id) { batch.push( - deleteSvixApp({ + deleteSvixApp(workerEnv)({ appId: org.svix_config.sandbox_app_id, }), ); @@ -27,7 +34,7 @@ const deleteSvixWebhooks = async ({ if (org.svix_config?.live_app_id) { batch.push( - deleteSvixApp({ + deleteSvixApp(workerEnv)({ appId: org.svix_config.live_app_id, }), ); diff --git a/server/src/internal/orgs/handlers/handleRedisConfig.ts b/server/src/internal/orgs/handlers/handleRedisConfig.ts index e0b4208ca..2c250757c 100644 --- a/server/src/internal/orgs/handlers/handleRedisConfig.ts +++ b/server/src/internal/orgs/handlers/handleRedisConfig.ts @@ -63,7 +63,7 @@ export const handleUpsertRedisConfig = createRoute({ orgId: org.id, updates: { redis_config: { - connectionString: encryptData(connectionString), + connectionString: encryptData(connectionString, c.env), url: redisUrl.host, migrationPercent: 0, previousMigrationPercent: 0, diff --git a/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts b/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts index 15c28db00..ad1c44bf6 100644 --- a/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts +++ b/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts @@ -34,7 +34,7 @@ export const handleResetDefaultAccount = createRoute({ // Delete the current default account if it exists if (currentDefaultAccountId) { try { - const masterStripe = initMasterStripe({ env: AppEnv.Sandbox }); + const masterStripe = initMasterStripe(c.env, { env: AppEnv.Sandbox }); // Try to close the account try { @@ -63,6 +63,7 @@ export const handleResetDefaultAccount = createRoute({ const newAccount = await createConnectAccount({ org: org, user: dummyUser as any, + env: c.env, }); // Update the organization with the new default account ID diff --git a/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts b/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts index 1a9c3f579..ec66f1171 100644 --- a/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts +++ b/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts @@ -161,12 +161,14 @@ export const handleUpsertRevenueCatConfig = createRoute({ processor_configs: { ...org.processor_configs, revenuecat: { - ...existingRevenueCatConfig, - // Live fields - ...(body.api_key ? { api_key: encryptData(body.api_key) } : {}), - ...(body.sandbox_api_key - ? { sandbox_api_key: encryptData(body.sandbox_api_key) } - : {}), + ...existingRevenueCatConfig, + // Live fields + ...(body.api_key + ? { api_key: encryptData(body.api_key, c.env) } + : {}), + ...(body.sandbox_api_key + ? { sandbox_api_key: encryptData(body.sandbox_api_key, c.env) } + : {}), ...(body.project_id ? { project_id: body.project_id } : {}), ...(body.sandbox_project_id ? { sandbox_project_id: body.sandbox_project_id } @@ -188,18 +190,24 @@ export const handleUpsertRevenueCatConfig = createRoute({ projectId: body.sandbox_project_id, }); } - for (const { env, projectId } of targets) { - try { - const accessToken = await getRevenuecatAccessToken({ db, org, env }); - const secret = getRevenuecatWebhookSecret({ org, env }); - if (!accessToken || !secret) continue; - const rcCli = initRevenuecatCli({ accessToken, projectId }); + for (const { env, projectId } of targets) { + try { + const accessToken = await getRevenuecatAccessToken({ + db, + org, + env, + workerEnv: c.env, + }); + const secret = getRevenuecatWebhookSecret({ org, env }); + if (!accessToken || !secret) continue; + const rcCli = initRevenuecatCli({ accessToken, projectId }); await registerRevenuecatWebhook({ rcCli, - orgId: org.id, - env, - secret, - }); + orgId: org.id, + env, + secret, + serverEnv: c.env, + }); } catch (webhookError) { logger.warn( `[RC] webhook registration failed for org ${org.id} (${env}): ${webhookError}`, @@ -218,7 +226,7 @@ const handleGetVercelSink = createRoute({ handler: async (c) => { const { db, org, env } = c.get("ctx"); const vercelConfig = org.processor_configs?.vercel; - const svixCli = createSvixCli(); + const svixCli = createSvixCli(c.env); let liveApp: ApplicationOut | undefined; let sandboxApp: ApplicationOut | undefined; @@ -229,7 +237,7 @@ const handleGetVercelSink = createRoute({ } if (!vercelConfig?.svix?.live_id || !vercelConfig?.svix?.sandbox_id) { - liveApp = await createSvixApp({ + liveApp = await createSvixApp(c.env)({ name: `${org.slug}_live_vercel_sink`, orgId: org.id, env: AppEnv.Live, @@ -237,7 +245,7 @@ const handleGetVercelSink = createRoute({ } if (!vercelConfig?.svix?.sandbox_id) { - sandboxApp = await createSvixApp({ + sandboxApp = await createSvixApp(c.env)({ name: `${org.slug}_sandbox_vercel_sink`, orgId: org.id, env: AppEnv.Sandbox, diff --git a/server/src/internal/orgs/handlers/handleVercelConfig.ts b/server/src/internal/orgs/handlers/handleVercelConfig.ts index 0be9041c4..aea702817 100644 --- a/server/src/internal/orgs/handlers/handleVercelConfig.ts +++ b/server/src/internal/orgs/handlers/handleVercelConfig.ts @@ -178,7 +178,7 @@ export const handleGetVercelSink = createRoute({ handler: async (c) => { const { db, org, env } = c.get("ctx"); const vercelConfig = org.processor_configs?.vercel; - const svixCli = createSvixCli(); + const svixCli = createSvixCli(c.env); let liveApp: ApplicationOut | undefined; let sandboxApp: ApplicationOut | undefined; @@ -189,7 +189,7 @@ export const handleGetVercelSink = createRoute({ } if (!vercelConfig?.svix?.live_id || !vercelConfig?.svix?.sandbox_id) { - liveApp = await createSvixApp({ + liveApp = await createSvixApp(c.env)({ name: `${org.slug}_live_vercel_sink`, orgId: org.id, env: AppEnv.Live, @@ -197,7 +197,7 @@ export const handleGetVercelSink = createRoute({ } if (!vercelConfig?.svix?.sandbox_id) { - sandboxApp = await createSvixApp({ + sandboxApp = await createSvixApp(c.env)({ name: `${org.slug}_sandbox_vercel_sink`, orgId: org.id, env: AppEnv.Sandbox, diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts index 4be225644..365e63e3c 100644 --- a/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts +++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts @@ -50,6 +50,7 @@ export const handleGetRevenueCatOAuthUrl = createRoute({ const authUrl = createRcAuthorizationUrl({ state: stateKey, codeVerifier, + env: c.env, }); return c.json({ diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts index b0baf6621..6a29ac1b3 100644 --- a/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts +++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts @@ -10,6 +10,7 @@ import { generateRevenuecatWebhookSecret } from "@/external/revenueCat/misc/getR import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js"; import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js"; import { registerRevenuecatWebhook } from "@/external/revenueCat/misc/registerRevenuecatWebhook.js"; +import type { CacheStore } from "@/external/storage/cache/index.js"; import { exchangeRcCode, findMissingRcScopes, @@ -26,19 +27,21 @@ const getHyperdriveConnectionString = (env: Env): string | undefined => ?.connectionString; const getOptionalRequestContext = (c: Context) => { - const get = (c as { get?: Context["get"] }).get; + const get = (c as { get?: (key: string) => unknown }).get; return typeof get === "function" ? get.call(c, "ctx") : undefined; }; const buildOAuthConfig = ({ tokens, projectId, + env, }: { tokens: Awaited>; projectId?: string; + env: Env; }): RevenueCatOAuthConfig => ({ - access_token: encryptData(tokens.accessToken()), - refresh_token: encryptData(tokens.refreshToken()), + access_token: encryptData(tokens.accessToken(), env), + refresh_token: encryptData(tokens.refreshToken(), env), expires_at: tokens.accessTokenExpiresAt().getTime(), ...(tokens.hasScopes() ? { scope: tokens.scopes().join(" ") } : {}), ...(projectId ? { project_id: projectId } : {}), @@ -81,7 +84,7 @@ const mergeRevenueCatOAuth = ({ export const handleRevenueCatOAuthCallback = async (c: Context) => { const query = c.req.query(); const { code, state, error } = query; - const workerEnv = c.env ?? (process.env as unknown as Env); + const workerEnv = c.env; const frontendUrl = workerEnv.CLIENT_URL || "http://localhost:3000"; let redirectUrl = new URL(`${frontendUrl}`); @@ -102,7 +105,8 @@ export const handleRevenueCatOAuthCallback = async (c: Context) => { try { const redisState = await consumeOAuthState({ - cacheStore: getOptionalRequestContext(c)?.cacheStore, + cacheStore: (getOptionalRequestContext(c) as { cacheStore?: CacheStore }) + ?.cacheStore, stateKey: state, }); @@ -173,7 +177,11 @@ export const handleRevenueCatOAuthCallback = async (c: Context) => { return c.redirect(redirectUrl.toString()); } - const tokens = await exchangeRcCode({ code, codeVerifier: code_verifier }); + const tokens = await exchangeRcCode({ + code, + codeVerifier: code_verifier, + env: c.env, + }); const grantedScopes = tokens.hasScopes() ? tokens.scopes() : []; const missingScopes = findMissingRcScopes(grantedScopes); @@ -255,7 +263,7 @@ export const handleRevenueCatOAuthCallback = async (c: Context) => { projectId = existingProjectId; } - const oauthConfig = buildOAuthConfig({ tokens, projectId }); + const oauthConfig = buildOAuthConfig({ tokens, projectId, env: c.env }); // Ensure the env's webhook secret exists (the dashboard generates it lazily, which a // platform-managed org never triggers) so we can register the webhook below. @@ -296,12 +304,13 @@ export const handleRevenueCatOAuthCallback = async (c: Context) => { accessToken: tokens.accessToken(), projectId, }); - await registerRevenuecatWebhook({ - rcCli: webhookCli, - orgId: org.id, - env, - secret: webhookSecret, - }); + await registerRevenuecatWebhook({ + rcCli: webhookCli, + orgId: org.id, + env, + secret: webhookSecret, + serverEnv: c.env, + }); } catch (webhookError) { console.error( `[RC] webhook registration failed for org ${org.id} (${env}): ${webhookError}`, diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.ts index eec6aa7c2..9521969ee 100644 --- a/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.ts +++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.ts @@ -24,13 +24,22 @@ export const handleGetRevenueCatWebhook = createRoute({ handler: async (c) => { const { db, org, env } = c.get("ctx"); - const url = getRevenuecatWebhookUrl({ orgId: org.id, env }); + const url = getRevenuecatWebhookUrl({ + orgId: org.id, + env, + serverEnv: c.env, + }); const secret = getRevenuecatWebhookSecret({ org, env }) ?? null; const revenueCatConfig = org.processor_configs?.revenuecat; const projectId = revenueCatConfig ? getRevenuecatProjectId({ revenueCatConfig, env }) : undefined; - const accessToken = await getRevenuecatAccessToken({ db, org, env }); + const accessToken = await getRevenuecatAccessToken({ + db, + org, + env, + workerEnv: c.env, + }); let status: WebhookStatus = "unknown"; if (url && projectId && accessToken) { @@ -70,7 +79,12 @@ export const handleRegisterRevenueCatWebhook = createRoute({ const projectId = revenueCatConfig ? getRevenuecatProjectId({ revenueCatConfig, env }) : undefined; - const accessToken = await getRevenuecatAccessToken({ db, org, env }); + const accessToken = await getRevenuecatAccessToken({ + db, + org, + env, + workerEnv: c.env, + }); if (!projectId || !accessToken) { throw new RecaseError({ message: "Connect RevenueCat (and select a project) before registering a webhook", @@ -105,11 +119,12 @@ export const handleRegisterRevenueCatWebhook = createRoute({ orgId: org.id, env, secret, + serverEnv: c.env, }); return c.json({ status: result === "skipped" ? "unknown" : "registered", - url: getRevenuecatWebhookUrl({ orgId: org.id, env }), + url: getRevenuecatWebhookUrl({ orgId: org.id, env, serverEnv: c.env }), secret, }); }, diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts index 4d937de37..dc137fe47 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts @@ -3,6 +3,7 @@ import type { Context } from "hono"; import { initDrizzle } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { initMasterStripe } from "@/external/connect/initStripeCli.js"; +import type { CacheStore } from "@/external/storage/cache/index.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; import { consumeOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js"; @@ -12,7 +13,7 @@ const getHyperdriveConnectionString = (env: Env): string | undefined => ?.connectionString; const getOptionalRequestContext = (c: Context) => { - const get = (c as { get?: Context["get"] }).get; + const get = (c as { get?: (key: string) => unknown }).get; return typeof get === "function" ? get.call(c, "ctx") : undefined; }; @@ -23,7 +24,7 @@ const getOptionalRequestContext = (c: Context) => { export const handleOAuthCallback = async (c: Context) => { const query = c.req.query(); const { code, state, error } = query; - const workerEnv = c.env ?? (process.env as unknown as Env); + const workerEnv = c.env; // Build frontend redirect URL (default) const frontendUrl = workerEnv.CLIENT_URL || "http://localhost:3000"; @@ -47,7 +48,8 @@ export const handleOAuthCallback = async (c: Context) => { try { // Consume OAuth state from Redis const redisState = await consumeOAuthState({ - cacheStore: getOptionalRequestContext(c)?.cacheStore, + cacheStore: (getOptionalRequestContext(c) as { cacheStore?: CacheStore }) + ?.cacheStore, stateKey: state, }); @@ -93,7 +95,7 @@ export const handleOAuthCallback = async (c: Context) => { return c.redirect(redirectUrl.toString()); } - const stripe = initMasterStripe({ env }); + const stripe = initMasterStripe(workerEnv, { env }); const response = await stripe.oauth.token({ grant_type: "authorization_code", code, diff --git a/server/src/internal/orgs/orgUtils.ts b/server/src/internal/orgs/orgUtils.ts index 05863d950..23ae7e0f5 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -157,7 +157,11 @@ export const deleteStripeWebhook = async ({ } }; -export const getStripeWebhookSecret = (org: Organization, env: AppEnv) => { +export const getStripeWebhookSecret = ( + org: Organization, + env: AppEnv, + workerEnv: Env, +) => { const webhookSecret = env === AppEnv.Sandbox ? org.stripe_config?.test_webhook_secret @@ -171,7 +175,7 @@ export const getStripeWebhookSecret = (org: Organization, env: AppEnv) => { }); } - return decryptData(webhookSecret); + return decryptData(webhookSecret, workerEnv); }; const initDefaultConfig = () => { diff --git a/server/src/internal/orgs/orgUtils/createConnectAccount.ts b/server/src/internal/orgs/orgUtils/createConnectAccount.ts index e9dfe226d..d73933269 100644 --- a/server/src/internal/orgs/orgUtils/createConnectAccount.ts +++ b/server/src/internal/orgs/orgUtils/createConnectAccount.ts @@ -6,14 +6,16 @@ import { initMasterStripe } from "@/external/connect/initStripeCli.js"; export const createConnectAccount = async ({ org, user, + env, }: { org: Organization; user: User; + env: Env; }) => { // For v2 API, need to use specific API version - const stripe = initMasterStripe({ + const stripe = initMasterStripe(env, { env: AppEnv.Sandbox, - legacyVersion: false, // Ensure using latest API version + legacyVersion: false, }); console.log("Creating connect account for org:", org.name); diff --git a/server/src/internal/orgs/orgUtils/deleteOrgUtils.ts b/server/src/internal/orgs/orgUtils/deleteOrgUtils.ts index 418543a3e..63e21996b 100644 --- a/server/src/internal/orgs/orgUtils/deleteOrgUtils.ts +++ b/server/src/internal/orgs/orgUtils/deleteOrgUtils.ts @@ -10,14 +10,21 @@ import { deleteStripeWebhook } from "../orgUtils.js"; export const deleteSvixWebhooks = async ({ org, logger, + workerEnv, }: { org: Organization; logger: Logger; + workerEnv?: Env; }) => { + if (!workerEnv) { + logger.error(`Cannot delete svix webhooks for ${org.id}: missing Worker env`); + return; + } + const batch = []; if (org.svix_config?.sandbox_app_id) { batch.push( - deleteSvixApp({ + deleteSvixApp(workerEnv)({ appId: org.svix_config.sandbox_app_id, }), ); @@ -25,7 +32,7 @@ export const deleteSvixWebhooks = async ({ if (org.svix_config?.live_app_id) { batch.push( - deleteSvixApp({ + deleteSvixApp(workerEnv)({ appId: org.svix_config.live_app_id, }), ); diff --git a/server/src/internal/orgs/orgUtils/handleStripeSecretKey.ts b/server/src/internal/orgs/orgUtils/handleStripeSecretKey.ts index 6152607ee..ea5559bfd 100644 --- a/server/src/internal/orgs/orgUtils/handleStripeSecretKey.ts +++ b/server/src/internal/orgs/orgUtils/handleStripeSecretKey.ts @@ -36,7 +36,7 @@ export const handleStripeSecretKey = async ({ // 3. Create new webhook endpoint let webhook: Stripe.WebhookEndpoint | null = null; try { - webhook = await createWebhookEndpoint(secretKey, env, orgId); + webhook = await createWebhookEndpoint(secretKey, env, orgId, workerEnv); } catch (error) { logger.error("Error creating webhook endpoint:", error); } diff --git a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts index 38285879b..d8f17df77 100644 --- a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts +++ b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts @@ -128,7 +128,7 @@ export const handleCreatePlatformOrg = createRoute({ }); // Initialize org (creates default Stripe test account, svix apps, etc.) - await afterOrgCreated({ org, user }); + await afterOrgCreated({ org, user, env: c.env }); logger.info(`Created new organization: ${org.id} (${orgSlug})`); } else { diff --git a/server/src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts b/server/src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts index c4305c1ec..e93bf704f 100644 --- a/server/src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts +++ b/server/src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts @@ -40,11 +40,17 @@ export const handleGetRevenueCatKeys = createRoute({ db, org, env: appEnv, + workerEnv: c.env, }); // api-key orgs have no OAuth token — fall back to the api key for the CLI only. const accessToken = oauthAccessToken ?? - (await getRevenuecatAccessToken({ db, org, env: appEnv })); + (await getRevenuecatAccessToken({ + db, + org, + env: appEnv, + workerEnv: c.env, + })); if (!projectId || !accessToken) { return c.json({ apps: [], oauth_access_token: null }); } diff --git a/server/src/internal/platform/platformBeta/handlers/handleLegacyPlatformExchange.ts b/server/src/internal/platform/platformBeta/handlers/handleLegacyPlatformExchange.ts index 998e3d4fe..c822366ab 100644 --- a/server/src/internal/platform/platformBeta/handlers/handleLegacyPlatformExchange.ts +++ b/server/src/internal/platform/platformBeta/handlers/handleLegacyPlatformExchange.ts @@ -123,7 +123,12 @@ export const handleLegacyPlatformExchange = createRoute({ createdAt: new Date(), }); - await afterOrgCreated({ org, user, createStripeAccount: false }); + await afterOrgCreated({ + org, + user, + createStripeAccount: false, + env: c.env, + }); } else { org = membership.organizations as Organization; } diff --git a/server/src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts b/server/src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts index c0734dfed..ded764a8e 100644 --- a/server/src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts +++ b/server/src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts @@ -66,6 +66,7 @@ export const handleLinkRevenueCat = createRoute({ const authUrl = createRcAuthorizationUrl({ state: stateKey, codeVerifier, + env: c.env, }); logger.info( diff --git a/server/src/internal/product/actions/createProduct.ts b/server/src/internal/product/actions/createProduct.ts index 2fe40c00b..9112e5959 100644 --- a/server/src/internal/product/actions/createProduct.ts +++ b/server/src/internal/product/actions/createProduct.ts @@ -112,8 +112,12 @@ export const createProduct = async ({ }); } + if (!ctx.workerEnv) { + throw new Error("Cloudflare Worker env is required to enqueue product tasks"); + } await addTaskToQueue({ jobName: JobName.DetectBaseVariant, + env: ctx.workerEnv, payload: { curProduct: newFullProduct, }, diff --git a/server/src/internal/product/actions/updateProduct.ts b/server/src/internal/product/actions/updateProduct.ts index 57a240e34..c301936a8 100644 --- a/server/src/internal/product/actions/updateProduct.ts +++ b/server/src/internal/product/actions/updateProduct.ts @@ -227,8 +227,12 @@ export const updateProduct = async ({ }); logger.info("Adding task to queue to detect base variant"); + if (!ctx.workerEnv) { + throw new Error("Cloudflare Worker env is required to enqueue product tasks"); + } await addTaskToQueue({ jobName: JobName.DetectBaseVariant, + env: ctx.workerEnv, payload: { curProduct: newFullProduct, }, @@ -236,6 +240,7 @@ export const updateProduct = async ({ await addTaskToQueue({ jobName: JobName.RewardMigration, + env: ctx.workerEnv, payload: { oldPrices: fullProduct.prices, productId: latestProductId, diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index 4b8a187a2..dbcf02d25 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -28,7 +28,6 @@ import { import { StatusCodes } from "http-status-codes"; import type { CacheStore } from "@/external/storage/cache/index.js"; import type { Logger } from "@/external/logtail/logtailUtils"; -import type { CacheStore } from "@/external/storage/cache/index.js"; import { queryWithCache } from "@/utils/cacheUtils/queryWithCache"; import { buildAllVersionsProductsCacheKey, diff --git a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts index 674ef69fb..dc9838665 100644 --- a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts +++ b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts @@ -138,8 +138,12 @@ export const handleCreatePlan = createRoute({ }); } + if (!ctx.workerEnv) { + throw new Error("Cloudflare Worker env is required to enqueue product tasks"); + } await addTaskToQueue({ jobName: JobName.DetectBaseVariant, + env: ctx.workerEnv, payload: { curProduct: newFullProduct, }, @@ -162,6 +166,7 @@ export const handleCreatePlan = createRoute({ }); await captureOrgEvent({ + env: c.env, orgId: org.id, event: "plan created", properties: { diff --git a/server/src/internal/products/handlers/handleMigrateProductV2.ts b/server/src/internal/products/handlers/handleMigrateProductV2.ts index 7fccdf7cb..3f424bf66 100644 --- a/server/src/internal/products/handlers/handleMigrateProductV2.ts +++ b/server/src/internal/products/handlers/handleMigrateProductV2.ts @@ -132,8 +132,12 @@ export const handleMigrateProductV2 = createRoute({ // Add task to queue for processing ctx.logger.info(`CREATED MIGRATION JOB: ${migrationJob.id}`); + if (!ctx.workerEnv) { + throw new Error("Cloudflare Worker env is required to enqueue migration task"); + } await addTaskToQueue({ jobName: JobName.Migration, + env: ctx.workerEnv, payload: { migrationJobId: migrationJob.id, orgId: org.id, diff --git a/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts b/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts index a90a153c4..9a07e8388 100644 --- a/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts +++ b/server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts @@ -217,8 +217,12 @@ export const handleUpdatePlanV1 = createRoute({ }); logger.info("Adding task to queue to detect base variant"); + if (!ctx.workerEnv) { + throw new Error("Cloudflare Worker env is required to enqueue product tasks"); + } await addTaskToQueue({ jobName: JobName.DetectBaseVariant, + env: ctx.workerEnv, payload: { curProduct: newFullProduct, }, @@ -226,6 +230,7 @@ export const handleUpdatePlanV1 = createRoute({ await addTaskToQueue({ jobName: JobName.RewardMigration, + env: ctx.workerEnv, payload: { oldPrices: fullProduct.prices, productId: v1_2Body.id || fullProduct.id, diff --git a/server/src/internal/products/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index 398690b49..ae520f867 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -151,8 +151,12 @@ export const handleVersionProductV2 = async ({ } as FullProduct, }); + if (!ctx.workerEnv) { + throw new Error("Cloudflare Worker env is required to enqueue reward migration task"); + } await addTaskToQueue({ jobName: JobName.RewardMigration, + env: ctx.workerEnv, payload: { oldPrices: latestProduct.prices, productId: latestProduct.id, diff --git a/server/src/queue/hatchetWorkflows/createWorkflowTask.ts b/server/src/queue/hatchetWorkflows/createWorkflowTask.ts index e3e1f7d8e..bdedf2c94 100644 --- a/server/src/queue/hatchetWorkflows/createWorkflowTask.ts +++ b/server/src/queue/hatchetWorkflows/createWorkflowTask.ts @@ -30,9 +30,13 @@ export const createWorkflowTask = ({ input: TInput; autumnContext: AutumnContext; }) => Promise; - bindings: Env; + bindings?: Env; }): ((input: TInput, hatchetCtx: Context) => Promise) => { return async (input: TInput, hatchetCtx: Context) => { + if (!bindings) { + throw new Error("Worker bindings are required to run workflow tasks"); + } + const { orgId, env, customerId } = input; const logger = createLogger(bindings); diff --git a/server/src/queue/processMessage.ts b/server/src/queue/processMessage.ts index 1276893df..bb0145001 100644 --- a/server/src/queue/processMessage.ts +++ b/server/src/queue/processMessage.ts @@ -3,7 +3,6 @@ import chalk from "chalk"; import { isTransientDbError } from "@/db/dbUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createLogger, type Logger } from "@/external/logtail/logtailUtils.js"; -import { isTransientRedisError } from "@/external/redis/utils/isTransientRedisError.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; import { autoTopup } from "@/internal/balances/autoTopUp/autoTopup.js"; @@ -57,7 +56,7 @@ export const shouldRetryQueueJobError = ({ case JobName.RefreshEntityAggregate: return isTransientDbError({ error }); case JobName.Track: - return isTransientDbError({ error }) || isTransientRedisError({ error }); + return isTransientDbError({ error }); default: return false; } @@ -107,10 +106,18 @@ export const processMessage = async ({ } if (job.name === JobName.ClearCreditSystemCustomerCache) { + const ctx = await createWorkerContext({ + db, + payload: job.data, + logger: workerLogger, + workerEnv: env, + }); + workerCtx = ctx; await runClearCreditSystemCacheTask({ db, payload: job.data, logger: workerLogger, + ctx, }); return; } diff --git a/server/src/queue/queueUtils.ts b/server/src/queue/queueUtils.ts index 337def1b6..2f051f592 100644 --- a/server/src/queue/queueUtils.ts +++ b/server/src/queue/queueUtils.ts @@ -139,10 +139,9 @@ export const addTaskToQueue = async ({ generateDeduplicationId?: boolean; delayMs?: number; queueUrl?: string; - env?: Env; + env: Env; cloudflareQueueBinding?: CloudflareQueueBinding; }) => { - const workerEnv = env ?? (process.env as unknown as Env); const messageId = messageDeduplicationId ?? (generateDeduplicationId === false ? undefined : generateId("job")); @@ -154,7 +153,7 @@ export const addTaskToQueue = async ({ const delaySeconds = getDelaySeconds(delayMs); const cloudflareQueue = getCloudflareQueue({ - env: workerEnv, + env, binding: cloudflareQueueBinding, }); if (cloudflareQueue) { @@ -182,7 +181,7 @@ export const addTasksToQueueBatch = async ({ messageGroupId: string; messageDeduplicationId: string; }>; - env?: Env; + env: Env; cloudflareQueueBinding?: CloudflareQueueBinding; }): Promise<{ successCount: number; diff --git a/server/src/trigger/cache/warmFullSubjectCacheTask.ts b/server/src/trigger/cache/warmFullSubjectCacheTask.ts index e79e10c87..79b25f8cc 100644 --- a/server/src/trigger/cache/warmFullSubjectCacheTask.ts +++ b/server/src/trigger/cache/warmFullSubjectCacheTask.ts @@ -2,7 +2,6 @@ import { AppEnv } from "@autumn/shared"; import { metrics } from "@opentelemetry/api"; import { task } from "@trigger.dev/sdk/v3"; import { z } from "zod/v4"; -import { warmupRegionalRedis } from "@/external/redis/initUtils/redisWarmup.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { EntityService } from "@/internal/api/entities/EntityService.js"; import { CusService } from "@/internal/customers/CusService.js"; @@ -140,19 +139,13 @@ export const warmFullSubjectCacheTask = task({ run: async (raw: unknown, { ctx: triggerCtx }) => { const { orgId, env, customerId, source } = PayloadSchema.parse(raw); - const { ctx, logger } = await createTriggerContext({ + const { ctx } = await createTriggerContext({ orgId, env, triggerCtx, customerId, }); - await warmupRegionalRedis().catch((error) => - logger.warn("warm-full-subject-cache: redis warmup failed", { - data: { error: error instanceof Error ? error.message : String(error) }, - }), - ); - return runWarmFullSubjectCache({ ctx, customerId, source }); }, }); diff --git a/server/src/trigger/migrations/runMigrationCustomerTask.ts b/server/src/trigger/migrations/runMigrationCustomerTask.ts index e37a9023c..fa46349e2 100644 --- a/server/src/trigger/migrations/runMigrationCustomerTask.ts +++ b/server/src/trigger/migrations/runMigrationCustomerTask.ts @@ -1,7 +1,6 @@ import { AppEnv } from "@autumn/shared"; import { task } from "@trigger.dev/sdk/v3"; import { z } from "zod/v4"; -import { warmupRegionalRedis } from "@/external/redis/initUtils/redisWarmup.js"; import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; import { withMigrationItemTracking } from "@/internal/migrations/v2/actions/migrationItem/index.js"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; @@ -49,14 +48,6 @@ export const runMigrationCustomerTask = task({ customerId: customerId ?? customerInternalId, }); - await warmupRegionalRedis().catch((error) => { - logger.warn("run-migration-customer: redis warmup failed (continuing)", { - data: { - error: error instanceof Error ? error.message : String(error), - }, - }); - }); - logger.info("run-migration-customer: starting", { data: { migrationInternalId, migrationRunId, customerInternalId }, }); diff --git a/server/src/trigger/migrations/runMigrationTask.ts b/server/src/trigger/migrations/runMigrationTask.ts index f10ffe856..67e4a8ae0 100644 --- a/server/src/trigger/migrations/runMigrationTask.ts +++ b/server/src/trigger/migrations/runMigrationTask.ts @@ -1,7 +1,6 @@ import { AppEnv } from "@autumn/shared"; import { task } from "@trigger.dev/sdk/v3"; import { z } from "zod/v4"; -import { warmupRegionalRedis } from "@/external/redis/initUtils/redisWarmup.js"; import { withMigrationRunTracking } from "@/internal/migrations/v2/actions/migrationRun/index.js"; import { migrationRepo } from "@/internal/migrations/v2/repos/index.js"; import { runMigration } from "@/internal/migrations/v2/run/runMigration.js"; @@ -61,18 +60,6 @@ export const runMigrationTask = task({ triggerCtx, }); - // Trigger.dev tasks start with cold Redis connections — wait for - // readiness before touching the migration so cache invalidations - // (legacy Redis invalidation paths) actually fire instead of being - // short-circuited by the `not_ready` availability gate. - await warmupRegionalRedis().catch((error) => { - logger.warn("run-migration: redis warmup failed (continuing)", { - data: { - error: error instanceof Error ? error.message : String(error), - }, - }); - }); - logger.info("run-migration: starting", { data: { migrationId, diff --git a/server/src/utils/authUtils/afterOrgCreated.ts b/server/src/utils/authUtils/afterOrgCreated.ts index 8bc40b62a..9c3d5e5bc 100644 --- a/server/src/utils/authUtils/afterOrgCreated.ts +++ b/server/src/utils/authUtils/afterOrgCreated.ts @@ -11,17 +11,25 @@ import { createConnectAccount } from "@/internal/orgs/orgUtils/createConnectAcco import { generatePublishableKey } from "../encryptUtils.js"; import { captureOrgEvent } from "../posthog.js"; -const initOrgSvixApps = async ({ id, slug }: { id: string; slug: string }) => { +const initOrgSvixApps = async ({ + id, + slug, + env, +}: { + id: string; + slug: string; + env: Env; +}) => { const batchCreate = []; batchCreate.push( - createSvixApp({ + createSvixApp(env)({ name: `${slug}_${AppEnv.Sandbox}`, orgId: id, env: AppEnv.Sandbox, }), ); batchCreate.push( - createSvixApp({ + createSvixApp(env)({ name: `${slug}_${AppEnv.Live}`, orgId: id, env: AppEnv.Live, @@ -67,6 +75,7 @@ export const afterOrgCreated = async ({ const stripeConnectAccount = await createConnectAccount({ org: org, user, + env, }); await OrgService.update({ @@ -85,6 +94,7 @@ export const afterOrgCreated = async ({ const { sandboxApp, liveApp } = await initOrgSvixApps({ slug, id, + env, }); await OrgService.update({ @@ -106,6 +116,7 @@ export const afterOrgCreated = async ({ const orgHasCreatedBy = "created_by" in org && org.created_by; if (!orgHasCreatedBy) { await captureOrgEvent({ + env, orgId: id, event: "org created", properties: { diff --git a/server/src/utils/cacheUtils/cacheUtils.ts b/server/src/utils/cacheUtils/cacheUtils.ts index 8c142e48e..1db1b8d4f 100644 --- a/server/src/utils/cacheUtils/cacheUtils.ts +++ b/server/src/utils/cacheUtils/cacheUtils.ts @@ -69,7 +69,11 @@ const throwIfRedisUnavailable = ({ * - `null` (key already exists) → `onKeyAlreadyExists` * - Redis unavailable / error → `onRedisUnavailable` */ -export const tryRedisNx = async ({ +export const tryRedisNx = async < + TUnavailable = any, + TSuccess = any, + TExists = any, +>({ operation, redisInstance, env = {} as Env, @@ -120,16 +124,16 @@ export const tryRedisNx = async ({ * @param redisInstance - Optional Redis instance to use. * @returns Promise - The result if successful, null otherwise. Returns true if operation returns void/undefined. */ -export function tryRedisWrite( +export function tryRedisWrite( operation: () => Promise, redisInstance?: LegacyRedisClient, ): Promise; -export function tryRedisWrite( +export function tryRedisWrite( env: Env, operation: () => Promise, redisInstance?: LegacyRedisClient, ): Promise; -export async function tryRedisWrite( +export async function tryRedisWrite( first: Env | (() => Promise), second?: (() => Promise) | LegacyRedisClient, third?: LegacyRedisClient, @@ -178,16 +182,16 @@ export async function tryRedisWrite( * @param redisInstance - Optional Redis instance to use. * @returns Promise - The data if successful, null otherwise */ -export function tryRedisRead( +export function tryRedisRead( operation: () => Promise, redisInstance?: LegacyRedisClient, ): Promise; -export function tryRedisRead( +export function tryRedisRead( env: Env, operation: () => Promise, redisInstance?: LegacyRedisClient, ): Promise; -export async function tryRedisRead( +export async function tryRedisRead( first: Env | (() => Promise), second?: (() => Promise) | LegacyRedisClient, third?: LegacyRedisClient, diff --git a/server/src/utils/scriptUtils/scriptUtils.ts b/server/src/utils/scriptUtils/scriptUtils.ts index 726ca0b62..61c20fa40 100644 --- a/server/src/utils/scriptUtils/scriptUtils.ts +++ b/server/src/utils/scriptUtils/scriptUtils.ts @@ -249,7 +249,7 @@ export const initScript = async ({ }), ]); - const stripeCli: Stripe = createStripeCli({ org, env }); + const stripeCli: Stripe = createStripeCli({ org, env, workerEnv: bindings }); const logger = createLogger(bindings); @@ -286,9 +286,11 @@ export const initScript = async ({ export const initReadScript = async ({ orgId, env, + bindings, }: { orgId: string; env: AppEnv; + bindings: Env; }) => { const [org, autumnProducts, features] = await Promise.all([ OrgService.get({ db, orgId }), @@ -304,7 +306,7 @@ export const initReadScript = async ({ }), ]); - const stripeCliRaw: Stripe = createStripeCli({ org, env }); + const stripeCliRaw: Stripe = createStripeCli({ org, env, workerEnv: bindings }); const stripeCli = createReadOnlyStripeCli(stripeCliRaw); const logger = createLogger(bindings); diff --git a/server/tests/_temp/revenuecat-expiration-no-entities.test.ts b/server/tests/_temp/revenuecat-expiration-no-entities.test.ts index e57a1ebd0..943d752bb 100644 --- a/server/tests/_temp/revenuecat-expiration-no-entities.test.ts +++ b/server/tests/_temp/revenuecat-expiration-no-entities.test.ts @@ -25,6 +25,7 @@ import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import { testWorkerEnv } from "@tests/utils/testWorkerEnv.js"; import { expectWebhookSuccess, RevenueCatWebhookClient, @@ -49,8 +50,8 @@ beforeAll(async () => { processor_configs: { ...ctx.org.processor_configs, revenuecat: { - api_key: encryptData("mock_rc_api_key_live"), - sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), + api_key: encryptData("mock_rc_api_key_live", testWorkerEnv), + sandbox_api_key: encryptData("mock_rc_api_key_sandbox", testWorkerEnv), project_id: "mock_project_live", sandbox_project_id: "mock_project_sandbox", webhook_secret: RC_WEBHOOK_SECRET, diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts index ec3ca356f..3bb74c93d 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts @@ -3,6 +3,7 @@ import { createTestContext, type TestContext, } from "@tests/utils/testInitUtils/createTestContext"; +import { testWorkerEnv } from "@tests/utils/testWorkerEnv"; import { eq } from "drizzle-orm"; import { initDrizzle } from "@/db/initDrizzle"; import { OrgService } from "@/internal/orgs/OrgService"; @@ -44,8 +45,8 @@ const ensureTestOrgUsesStripeSandboxKey = async (): Promise => { stripe_connected: true, stripe_config: { ...(organization.stripe_config || {}), - test_api_key: encryptData(sandboxSecretKey), - test_webhook_secret: encryptData(sandboxWebhookSecret), + test_api_key: encryptData(sandboxSecretKey, testWorkerEnv), + test_webhook_secret: encryptData(sandboxWebhookSecret, testWorkerEnv), }, test_stripe_connect: {}, }) diff --git a/server/tests/integration/external-psps/revenuecat/revenuecat-cross-processor-oneoff.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat-cross-processor-oneoff.test.ts index 41f5362a5..d95d39333 100644 --- a/server/tests/integration/external-psps/revenuecat/revenuecat-cross-processor-oneoff.test.ts +++ b/server/tests/integration/external-psps/revenuecat/revenuecat-cross-processor-oneoff.test.ts @@ -29,6 +29,7 @@ import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { testWorkerEnv } from "@tests/utils/testWorkerEnv"; import chalk from "chalk"; import { eq } from "drizzle-orm"; import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; @@ -53,8 +54,8 @@ const setupRevenueCatOrg = async () => { processor_configs: { ...ctx.org.processor_configs, revenuecat: { - api_key: encryptData("mock_rc_api_key_live"), - sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), + api_key: encryptData("mock_rc_api_key_live", testWorkerEnv), + sandbox_api_key: encryptData("mock_rc_api_key_sandbox", testWorkerEnv), project_id: "mock_project_live", sandbox_project_id: "mock_project_sandbox", webhook_secret: RC_WEBHOOK_SECRET, diff --git a/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts index 20ae3a27e..d88281628 100644 --- a/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts +++ b/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts @@ -32,6 +32,7 @@ import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import { testWorkerEnv } from "@tests/utils/testWorkerEnv.js"; import chalk from "chalk"; import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; import { OrgService } from "@/internal/orgs/OrgService"; @@ -94,8 +95,8 @@ const setupRevenueCatOrg = async () => { processor_configs: { ...ctx.org.processor_configs, revenuecat: { - api_key: encryptData("mock_rc_api_key_live"), - sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), + api_key: encryptData("mock_rc_api_key_live", testWorkerEnv), + sandbox_api_key: encryptData("mock_rc_api_key_sandbox", testWorkerEnv), project_id: "mock_project_live", sandbox_project_id: "mock_project_sandbox", webhook_secret: RC_WEBHOOK_SECRET, diff --git a/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts index 9b013343e..c9ae6c016 100644 --- a/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts +++ b/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts @@ -29,6 +29,7 @@ import { products } from "@tests/utils/fixtures/products"; import { timeout } from "@tests/utils/genUtils"; import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { testWorkerEnv } from "@tests/utils/testWorkerEnv"; import chalk from "chalk"; import { eq, inArray } from "drizzle-orm"; import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; @@ -75,8 +76,8 @@ const setupRevenueCatOrg = async () => { processor_configs: { ...ctx.org.processor_configs, revenuecat: { - api_key: encryptData("mock_rc_api_key_live"), - sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), + api_key: encryptData("mock_rc_api_key_live", testWorkerEnv), + sandbox_api_key: encryptData("mock_rc_api_key_sandbox", testWorkerEnv), project_id: "mock_project_live", sandbox_project_id: "mock_project_sandbox", webhook_secret: RC_WEBHOOK_SECRET, diff --git a/server/tests/integration/external-psps/revenuecat/revenuecatWebhooks.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecatWebhooks.test.ts index fe377573f..a094f47ae 100644 --- a/server/tests/integration/external-psps/revenuecat/revenuecatWebhooks.test.ts +++ b/server/tests/integration/external-psps/revenuecat/revenuecatWebhooks.test.ts @@ -8,6 +8,7 @@ import { } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { testWorkerEnv } from "@tests/utils/testWorkerEnv.js"; import chalk from "chalk"; import { and, arrayOverlaps, eq } from "drizzle-orm"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; @@ -158,8 +159,8 @@ describe(chalk.yellowBright("rc1: RevenueCat webhook integration"), () => { processor_configs: { ...ctx.org.processor_configs, revenuecat: { - api_key: encryptData("mock_rc_api_key_live"), - sandbox_api_key: encryptData("mock_rc_api_key_sandbox"), + api_key: encryptData("mock_rc_api_key_live", testWorkerEnv), + sandbox_api_key: encryptData("mock_rc_api_key_sandbox", testWorkerEnv), project_id: "mock_project_live", sandbox_project_id: "mock_project_sandbox", webhook_secret: RC_WEBHOOK_SECRET, diff --git a/server/tests/unit/auth/atmnOAuthClients.test.ts b/server/tests/unit/auth/atmnOAuthClients.test.ts index afa752f3a..87369f99b 100644 --- a/server/tests/unit/auth/atmnOAuthClients.test.ts +++ b/server/tests/unit/auth/atmnOAuthClients.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test"; +import { testWorkerEnv } from "@tests/utils/testWorkerEnv.js"; import { isAtmnOAuthClientRecord } from "@/internal/auth/oauth/atmnOAuthClients.js"; describe("isAtmnOAuthClientRecord", () => { test("does not classify arbitrary metadata values as atmn", () => { expect( isAtmnOAuthClientRecord({ + env: testWorkerEnv, clientId: "client_123", name: "Third Party App", metadata: { description: "connects to atmn projects" }, @@ -15,6 +17,7 @@ describe("isAtmnOAuthClientRecord", () => { test("classifies explicit atmn metadata and names", () => { expect( isAtmnOAuthClientRecord({ + env: testWorkerEnv, clientId: "client_123", name: "Third Party App", metadata: { kind: "atmn" }, @@ -23,6 +26,7 @@ describe("isAtmnOAuthClientRecord", () => { expect( isAtmnOAuthClientRecord({ + env: testWorkerEnv, clientId: "client_123", name: "atmn", }), diff --git a/server/tests/unit/balances/check-v2/runCheckWithRollout.test.ts b/server/tests/unit/balances/check-v2/runCheckWithRollout.test.ts index f97aadfad..19089be91 100644 --- a/server/tests/unit/balances/check-v2/runCheckWithRollout.test.ts +++ b/server/tests/unit/balances/check-v2/runCheckWithRollout.test.ts @@ -16,10 +16,6 @@ const mockState = { warnCalls: [] as unknown[][], }; -mock.module("@/external/redis/initUtils/redisV2Availability.js", () => ({ - shouldUseRedisV2: () => mockState.shouldUseRedis, -})); - mock.module("@/internal/balances/check/runCheckLegacyFlow.js", () => ({ runCheckLegacyFlow: async (args: Record) => { mockState.legacyCalls.push(args); @@ -43,8 +39,11 @@ mock.module("@/internal/balances/check/runCheckV2.js", () => ({ })); import { RedisUnavailableError } from "@/external/redis/utils/errors.js"; +import { registerRedisFailOpenAvailabilityResolver } from "@/external/redis/requestRedisFailOpenAvailability.js"; import { runCheckWithRollout } from "@/internal/balances/check/runCheckWithRollout.js"; +registerRedisFailOpenAvailabilityResolver(() => mockState.shouldUseRedis); + const resetMockState = () => { mockState.shouldUseRedis = true; mockState.legacyCalls = []; diff --git a/server/tests/unit/balances/compute-credit-costs.test.ts b/server/tests/unit/balances/compute-credit-costs.test.ts index 38df654d1..af6c72dd0 100644 --- a/server/tests/unit/balances/compute-credit-costs.test.ts +++ b/server/tests/unit/balances/compute-credit-costs.test.ts @@ -5,6 +5,7 @@ import { FeatureUsageType, type FullCusEntWithFullCusProduct, } from "@autumn/shared"; +import { testWorkerEnv } from "@tests/utils/testWorkerEnv.js"; import { computeCreditCosts } from "@/internal/balances/utils/deduction/computeCreditCosts.js"; import type { FeatureDeduction } from "@/internal/balances/utils/types/featureDeduction.js"; @@ -44,6 +45,7 @@ describe("computeCreditCosts", () => { const lookup = computeCreditCosts({ cusEnts: [makeCusEnt("ce_msg", messages), makeCusEnt("ce_cred", credits)], deduction, + env: testWorkerEnv, }); expect(lookup("ce_msg")).toBe(1); @@ -66,6 +68,7 @@ describe("computeCreditCosts", () => { const lookup = computeCreditCosts({ cusEnts: [makeCusEnt("ce_ai", aiCredits), makeCusEnt("ce_orbs", orbs)], deduction, + env: testWorkerEnv, }); expect(lookup("ce_ai")).toBe(0.125); @@ -77,6 +80,7 @@ describe("computeCreditCosts", () => { const lookup = computeCreditCosts({ cusEnts: [makeCusEnt("ce_stale", staleCredits)], deduction, + env: testWorkerEnv, }); expect(lookup("ce_stale")).toBe(1); diff --git a/server/tests/unit/balances/execute-deduction-cache-wiring.test.ts b/server/tests/unit/balances/execute-deduction-cache-wiring.test.ts new file mode 100644 index 000000000..bd54f1cac --- /dev/null +++ b/server/tests/unit/balances/execute-deduction-cache-wiring.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const sourcePath = fileURLToPath( + new URL( + "../../../src/internal/balances/utils/deduction/executeDeductionCache.ts", + import.meta.url, + ), +); + +describe("deduction cache update wiring", () => { + test("invalidates CacheStore snapshots instead of patching them through Redis Lua", () => { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).toContain("hasNonRedisSnapshotCacheStore"); + expect(source).toContain("invalidateCustomerCacheStoreSnapshots"); + expect(source).toContain('source: "syncCustomerEntitlementUpdatesToCache"'); + expect(source).not.toContain("@/external/redis/initRedis"); + expect(source).not.toContain("redis.updateCustomerEntitlements"); + }); +}); diff --git a/server/tests/unit/balances/finalizeLock/runFinalizeLock.test.ts b/server/tests/unit/balances/finalizeLock/runFinalizeLock.test.ts index f80b69f81..ce7065924 100644 --- a/server/tests/unit/balances/finalizeLock/runFinalizeLock.test.ts +++ b/server/tests/unit/balances/finalizeLock/runFinalizeLock.test.ts @@ -9,17 +9,12 @@ import { } from "bun:test"; const mockState = { - shouldUseRedis: true, fetchError: null as unknown, finalizeV2Error: null as unknown, fetchCalls: [] as Record[], finalizeV2Calls: [] as Record[], }; -mock.module("@/external/redis/initUtils/redisV2Availability.js", () => ({ - shouldUseRedisV2: () => mockState.shouldUseRedis, -})); - mock.module("@/internal/balances/utils/lock/fetchLockReceipt.js", () => ({ fetchLockReceipt: async (args: Record) => { mockState.fetchCalls.push(args); @@ -44,10 +39,12 @@ mock.module("@/internal/balances/finalizeLock/runFinalizeLockV2.js", () => ({ })); import { RedisUnavailableError } from "@/external/redis/utils/errors.js"; +import { registerRedisFailOpenAvailabilityResolver } from "@/external/redis/requestRedisFailOpenAvailability.js"; import { runFinalizeLock } from "@/internal/balances/finalizeLock/runFinalizeLock.js"; +registerRedisFailOpenAvailabilityResolver(() => true); + const resetMockState = () => { - mockState.shouldUseRedis = true; mockState.fetchError = null; mockState.finalizeV2Error = null; mockState.fetchCalls = []; @@ -90,13 +87,16 @@ describe("runFinalizeLock", () => { expect(mockState.finalizeV2Calls).toHaveLength(1); }); - test("fails open when Redis is unavailable before fetching the receipt", async () => { - mockState.shouldUseRedis = false; + test("fails open when Redis is unavailable while fetching the receipt", async () => { + mockState.fetchError = new RedisUnavailableError({ + source: "unit-test", + reason: "not_ready", + }); const result = await runFinalizeLock({ ctx: rolloutCtx, params }); expect(result).toEqual({ success: true }); - expect(mockState.fetchCalls).toHaveLength(0); + expect(mockState.fetchCalls).toHaveLength(1); expect(mockState.finalizeV2Calls).toHaveLength(0); }); diff --git a/server/tests/unit/balances/refresh-entity-aggregate-cache-wiring.test.ts b/server/tests/unit/balances/refresh-entity-aggregate-cache-wiring.test.ts new file mode 100644 index 000000000..d4a48445d --- /dev/null +++ b/server/tests/unit/balances/refresh-entity-aggregate-cache-wiring.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const sourcePath = fileURLToPath( + new URL( + "../../../src/internal/balances/utils/refreshEntityAggregate/refreshEntityAggregateCache.ts", + import.meta.url, + ), +); + +describe("refresh entity aggregate cache wiring", () => { + test("invalidates CacheStore snapshots before falling back to Redis hash refresh", () => { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).toContain("hasNonRedisSnapshotCacheStore"); + expect(source).toContain("invalidateCustomerCacheStoreSnapshots"); + expect(source).toContain('source: "refreshEntityAggregateCache"'); + + const invalidationIndex = source.indexOf( + "hasNonRedisSnapshotCacheStore", + ); + const redisPipelineIndex = source.indexOf("redisV2.pipeline()"); + expect(invalidationIndex).toBeGreaterThanOrEqual(0); + expect(redisPipelineIndex).toBeGreaterThanOrEqual(0); + expect(invalidationIndex).toBeLessThan(redisPipelineIndex); + }); +}); diff --git a/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts b/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts index 663f1ea6e..80bf4e11c 100644 --- a/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts +++ b/server/tests/unit/balances/track-v3/runTrackV3Idempotency.test.ts @@ -10,7 +10,8 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { FeatureDeduction } from "@/internal/balances/utils/types/featureDeduction.js"; const mockState = { - runRedisTrackV3Calls: [] as Record[], + handleEventIdempotencyKeyCalls: [] as Record[], + runPostgresTrackV3Calls: [] as Record[], }; const fullSubject = { @@ -37,16 +38,17 @@ mock.module( }), ); -mock.module("@/internal/balances/track/v3/trackIdempotencyKey.js", () => ({ - getTrackIdempotencyKey: ({ ctx }: { ctx: { id: string } }) => - `track:${ctx.id}`, +mock.module("@/internal/balances/track/utils/handleEventIdempotencyKey.js", () => ({ + handleEventIdempotencyKey: async (args: Record) => { + mockState.handleEventIdempotencyKeyCalls.push(args); + }, })); -mock.module("@/internal/balances/track/v3/runRedisTrackV3.js", () => ({ - runRedisTrackV3: async ( +mock.module("@/internal/balances/track/v3/runPostgresTrackV3.js", () => ({ + runPostgresTrackV3: async ( args: Record, ): Promise => { - mockState.runRedisTrackV3Calls.push(args); + mockState.runPostgresTrackV3Calls.push(args); return { customer_id: "cus_123", value: 1, @@ -77,10 +79,11 @@ const buildFeatureDeduction = (featureId: string): FeatureDeduction => describe("runTrackV3 idempotency routing", () => { beforeEach(() => { - mockState.runRedisTrackV3Calls = []; + mockState.handleEventIdempotencyKeyCalls = []; + mockState.runPostgresTrackV3Calls = []; }); - test("uses the same request-level key for multi-feature requests", async () => { + test("checks client idempotency before multi-feature track requests", async () => { await runTrackV3({ ctx, body: { @@ -96,13 +99,14 @@ describe("runTrackV3 idempotency routing", () => { apiVersion: ApiVersion.V2_1, }); - expect(mockState.runRedisTrackV3Calls).toHaveLength(1); - expect(mockState.runRedisTrackV3Calls[0]?.idempotencyKey).toBe( - "track:req_123", + expect(mockState.handleEventIdempotencyKeyCalls).toHaveLength(1); + expect(mockState.runPostgresTrackV3Calls).toHaveLength(1); + expect(mockState.runPostgresTrackV3Calls[0]?.idempotencyKey).toBe( + undefined, ); }); - test("uses atomic Redis idempotency for single-feature requests", async () => { + test("checks client idempotency before single-feature track requests", async () => { await runTrackV3({ ctx, body: { @@ -115,13 +119,14 @@ describe("runTrackV3 idempotency routing", () => { apiVersion: ApiVersion.V2_1, }); - expect(mockState.runRedisTrackV3Calls).toHaveLength(1); - expect(mockState.runRedisTrackV3Calls[0]?.idempotencyKey).toBe( - "track:req_123", + expect(mockState.handleEventIdempotencyKeyCalls).toHaveLength(1); + expect(mockState.runPostgresTrackV3Calls).toHaveLength(1); + expect(mockState.runPostgresTrackV3Calls[0]?.idempotencyKey).toBe( + undefined, ); }); - test("uses the request id when client idempotency key is missing", async () => { + test("does not check idempotency when client idempotency key is missing", async () => { await runTrackV3({ ctx, body: { @@ -133,9 +138,10 @@ describe("runTrackV3 idempotency routing", () => { apiVersion: ApiVersion.V2_1, }); - expect(mockState.runRedisTrackV3Calls).toHaveLength(1); - expect(mockState.runRedisTrackV3Calls[0]?.idempotencyKey).toBe( - "track:req_123", + expect(mockState.handleEventIdempotencyKeyCalls).toHaveLength(0); + expect(mockState.runPostgresTrackV3Calls).toHaveLength(1); + expect(mockState.runPostgresTrackV3Calls[0]?.idempotencyKey).toBe( + undefined, ); }); }); diff --git a/server/tests/unit/balances/track/handle-track-queue-fallback.test.ts b/server/tests/unit/balances/track/handle-track-queue-fallback.test.ts index 9c75cd4b9..bc7215db0 100644 --- a/server/tests/unit/balances/track/handle-track-queue-fallback.test.ts +++ b/server/tests/unit/balances/track/handle-track-queue-fallback.test.ts @@ -7,6 +7,7 @@ import { test, } from "bun:test"; import { ApiVersion, ApiVersionClass, AppEnv } from "@autumn/shared"; +import { registerRedisFailOpenAvailabilityResolver } from "@/external/redis/requestRedisFailOpenAvailability.js"; import { RedisUnavailableError } from "@/external/redis/utils/errors.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; @@ -43,12 +44,10 @@ mock.module("@/internal/balances/track/v3/runTrackV3.js", () => ({ }, })); -mock.module("@/external/redis/initUtils/redisV2Availability.js", () => ({ - shouldUseRedisV2: () => true, -})); - import { runTrackWithRollout } from "@/internal/balances/track/runTrackWithRollout.js"; +registerRedisFailOpenAvailabilityResolver(() => true); + const createCtx = () => ({ id: "req_123", diff --git a/server/tests/unit/customers/test-full-customer-cache-guard-store.test.ts b/server/tests/unit/customers/test-full-customer-cache-guard-store.test.ts index 9c5cd7796..3783df310 100644 --- a/server/tests/unit/customers/test-full-customer-cache-guard-store.test.ts +++ b/server/tests/unit/customers/test-full-customer-cache-guard-store.test.ts @@ -2,16 +2,16 @@ import { AppEnv } from "@autumn/shared"; import { describe, expect, test } from "bun:test"; import { buildTestFullCustomerCacheGuardKey, + type FullCustomerCacheGuardStore, removeTestFullCustomerCacheGuard, setTestFullCustomerCacheGuard, } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.js"; -import type { - LockStore, - LockStoreAcquireResult, - LockStoreExistingData, -} from "@/external/redis/lockStore.js"; -class MemoryLockStore implements LockStore { +type LockStoreAcquireResult = Awaited< + ReturnType +>; + +class MemoryLockStore implements FullCustomerCacheGuardStore { readonly acquired: Array<{ lockKey: string; ttlMs: number; errorMessage: string }> = []; readonly released: string[] = []; @@ -31,10 +31,6 @@ class MemoryLockStore implements LockStore { return this.result; } - async getExistingData(): Promise { - return null; - } - async release({ lockKey }: { lockKey: string }): Promise { this.released.push(lockKey); } @@ -95,6 +91,15 @@ describe("test fullCustomer cache guard store", () => { ]); }); + test("returns false when no guard store is provided", async () => { + const result = await setTestFullCustomerCacheGuard({ + ctx: createCtx(), + customerId: "cus_123", + }); + + expect(result).toBe(false); + }); + test("keeps the existing guard key format stable", () => { expect( buildTestFullCustomerCacheGuardKey({ diff --git a/server/tests/unit/customers/test-full-customer-cache-guard-wiring.test.ts b/server/tests/unit/customers/test-full-customer-cache-guard-wiring.test.ts index 8dd09b2fd..fa996bf96 100644 --- a/server/tests/unit/customers/test-full-customer-cache-guard-wiring.test.ts +++ b/server/tests/unit/customers/test-full-customer-cache-guard-wiring.test.ts @@ -8,15 +8,16 @@ const readSource = (relativePath: string) => readFileSync(join(serverRoot, relativePath), "utf8"); describe("test fullCustomer cache guard wiring", () => { - test("uses LockStore instead of direct Redis set/delete", () => { + test("uses an injected LockStore without Redis defaults", () => { const source = readSource( "src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts", ); - expect(source).toContain("resolveRequestLockStore(ctx.workerEnv)"); - expect(source).toContain("guardStore.acquire"); - expect(source).toContain("guardStore.release"); - expect(source).not.toContain("@/external/redis/initRedis"); + expect(source).toContain("store.acquire"); + expect(source).toContain("store.release"); + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("resolveRequestLockStore"); + expect(source).not.toContain("getDefaultLockStore"); expect(source).not.toContain("redis.set"); expect(source).not.toContain("redis.del"); }); diff --git a/server/tests/unit/full-subject-cache/cache-store-adapters-no-redis-wiring.test.ts b/server/tests/unit/full-subject-cache/cache-store-adapters-no-redis-wiring.test.ts new file mode 100644 index 000000000..f25d98357 --- /dev/null +++ b/server/tests/unit/full-subject-cache/cache-store-adapters-no-redis-wiring.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const readSource = (relativePath: string) => + readFileSync( + fileURLToPath(new URL(`../../../src/${relativePath}`, import.meta.url)), + "utf8", + ); + +describe("CacheStore adapter Redis boundary", () => { + test.each([ + [ + "internal/customers/cache/fullSubject/actions/fullSubjectSnapshotCacheStoreActions.ts", + ], + [ + "internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheStoreActions.ts", + ], + ])("%s does not import Redis modules", (relativePath) => { + const source = readSource(relativePath); + + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("from \"ioredis\""); + expect(source).not.toContain("isRedisMigrationCacheStale"); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-balance-invalidation-wiring.test.ts b/server/tests/unit/full-subject-cache/full-subject-balance-invalidation-wiring.test.ts new file mode 100644 index 000000000..c741bf333 --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-balance-invalidation-wiring.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const sourcePath = fileURLToPath( + new URL( + "../../../src/internal/customers/cache/fullSubject/actions/invalidate/invalidateCustomerEntitlementBalance.ts", + import.meta.url, + ), +); +const resetSourcePath = fileURLToPath( + new URL("../../../src/cron/resetCron/resetCustomerEntitlement.ts", import.meta.url), +); + +describe("customer entitlement balance invalidation wiring", () => { + test("does not use Redis hash deletes for full subject balance invalidation", () => { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).toContain("invalidateCustomerCacheStoreSnapshots"); + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("tryRedisWrite"); + expect(source).not.toContain("redisV2"); + expect(source).not.toContain("hdel"); + expect(source).not.toContain("buildSharedFullSubjectBalanceKey"); + }); + + test("reset cron does not pass Redis into entitlement balance invalidation", () => { + const source = readFileSync(resetSourcePath, "utf8"); + + expect(source).not.toContain("redisV2: redisRouting.redis"); + expect(source).not.toContain("resolveCustomerRedisRouting({"); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-batch-invalidation-cache-store.test.ts b/server/tests/unit/full-subject-cache/full-subject-batch-invalidation-cache-store.test.ts new file mode 100644 index 000000000..478d59e35 --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-batch-invalidation-cache-store.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { AppEnv, SubjectType, type NormalizedFullSubject } from "@autumn/shared"; +import { MemoryCacheStore } from "@/external/storage/cache/MemoryCacheStore.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.js"; +import { + getFullSubjectBlobFromCacheStore, + setFullSubjectBlobInCacheStore, +} from "@/internal/customers/cache/fullSubject/actions/fullSubjectSnapshotCacheStoreActions.js"; +import { normalizedToCachedFullSubject } from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js"; +import { + getFullCustomerSnapshotFromCacheStore, + setFullCustomerSnapshotInCacheStore, +} from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheStoreActions.js"; + +const makeCtx = () => + ({ + org: { + id: "org_1", + redis_config: null, + }, + env: AppEnv.Sandbox, + cacheStore: new MemoryCacheStore(), + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }) as AutumnContext & { cacheStore: MemoryCacheStore }; + +const makeNormalized = (customerId: string): NormalizedFullSubject => + ({ + subjectType: SubjectType.Customer, + customerId, + internalCustomerId: `${customerId}_internal`, + customer: { + id: customerId, + internal_id: `${customerId}_internal`, + org_id: "org_1", + env: AppEnv.Sandbox, + created_at: 1, + name: "Customer", + email: null, + fingerprint: null, + processor: null, + processors: {}, + metadata: {}, + send_email_receipts: false, + usage_limits: [], + }, + customer_products: [], + customer_entitlements: [], + customer_prices: [], + flags: {}, + products: [], + entitlements: [], + prices: [], + free_trials: [], + subscriptions: [], + invoices: [], + migration_item_runs: [], + usage_windows: [], + }) as unknown as NormalizedFullSubject; + +describe("batchInvalidateCachedFullSubjects CacheStore behavior", () => { + test("deletes full subject and full customer snapshots without Redis targets", async () => { + const ctx = makeCtx(); + const customerId = "cus_1"; + + await setFullSubjectBlobInCacheStore({ + ctx, + cached: normalizedToCachedFullSubject({ + normalized: makeNormalized(customerId), + subjectViewEpoch: 1, + }), + }); + await setFullCustomerSnapshotInCacheStore({ + ctx, + customerId, + fullCustomer: { + id: customerId, + internal_id: `${customerId}_internal`, + org_id: "org_1", + env: AppEnv.Sandbox, + created_at: 1, + customer_products: [], + invoices: [], + } as never, + }); + + const deleted = await batchInvalidateCachedFullSubjects({ + ctx, + customers: [{ orgId: "org_1", env: AppEnv.Sandbox, customerId }], + }); + + expect(deleted).toBe(1); + await expect( + getFullSubjectBlobFromCacheStore({ ctx, customerId }), + ).resolves.toBeUndefined(); + await expect( + getFullCustomerSnapshotFromCacheStore({ ctx, customerId }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-batch-invalidation-wiring.test.ts b/server/tests/unit/full-subject-cache/full-subject-batch-invalidation-wiring.test.ts new file mode 100644 index 000000000..560be5acd --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-batch-invalidation-wiring.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const batchInvalidateSourcePath = fileURLToPath( + new URL( + "../../../src/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.ts", + import.meta.url, + ), +); + +const clearCustomerCacheSourcePath = fileURLToPath( + new URL( + "../../../src/internal/customers/handlers/handleClearCustomerCache.ts", + import.meta.url, + ), +); +const clearCreditSystemCacheTaskSourcePath = fileURLToPath( + new URL( + "../../../src/internal/features/featureActions/runClearCreditSystemCacheTask.ts", + import.meta.url, + ), +); + +describe("batch full subject invalidation wiring", () => { + test("does not use Redis fan-out for batch subject snapshot invalidation", () => { + const source = readFileSync(batchInvalidateSourcePath, "utf8"); + + expect(source).toContain("batchDeleteCachedFullCustomers"); + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("ioredis"); + expect(source).not.toContain("tryRedisRead"); + expect(source).not.toContain("tryRedisWrite"); + expect(source).not.toContain("redisV2"); + expect(source).not.toContain("getRedisTargetsForCustomer"); + expect(source).not.toContain("buildSharedFullSubjectBalanceKey"); + expect(source).not.toContain("buildFullSubjectViewEpochKey"); + }); + + test("clear customer cache handler does not import Redis routing", () => { + const source = readFileSync(clearCustomerCacheSourcePath, "utf8"); + + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("getRequestRedisTargetsForCustomer"); + expect(source).not.toContain("redisV2"); + }); + + test("credit-system clear task passes CacheStore context to batch invalidation", () => { + const source = readFileSync(clearCreditSystemCacheTaskSourcePath, "utf8"); + + expect(source).not.toContain("@/external/redis"); + expect(source).toContain("ctx?:"); + expect(source).toContain("ctx,"); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-epoch-wiring.test.ts b/server/tests/unit/full-subject-cache/full-subject-epoch-wiring.test.ts new file mode 100644 index 000000000..a9c8f402b --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-epoch-wiring.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const helperSourcePaths = [ + "../../../src/internal/customers/cache/fullSubject/actions/invalidate/getOrInitFullSubjectViewEpoch.ts", + "../../../src/internal/customers/cache/fullSubject/actions/invalidate/incrementFullSubjectViewEpoch.ts", +].map((relativePath) => + fileURLToPath(new URL(relativePath, import.meta.url)), +); + +describe("full subject epoch wiring", () => { + test.each(helperSourcePaths)("%s does not use Redis for epoch state", (sourcePath) => { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("tryRedisWrite"); + expect(source).not.toContain("redisV2"); + expect(source).not.toContain("buildFullSubjectViewEpochKey"); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-feature-balances-cache-miss.test.ts b/server/tests/unit/full-subject-cache/full-subject-feature-balances-cache-miss.test.ts new file mode 100644 index 000000000..341a7156f --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-feature-balances-cache-miss.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { + getCachedFeatureBalance, + getCachedFeatureBalancesBatch, +} from "@/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.js"; + +const makeCtx = () => + ({ + org: { id: "org_1" }, + env: AppEnv.Sandbox, + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }) as AutumnContext; + +describe("full subject feature balance cache misses", () => { + test("single balance lookup misses when live balance cache is unavailable", async () => { + await expect( + getCachedFeatureBalance({ + ctx: makeCtx(), + customerId: "cus_1", + featureId: "feat_1", + customerEntitlementIds: ["ce_1"], + }), + ).resolves.toEqual({ + kind: "missing", + reason: "live_balance_cache_unavailable", + }); + }); + + test("single balance lookup stays ok for empty entitlement lists", async () => { + await expect( + getCachedFeatureBalance({ + ctx: makeCtx(), + customerId: "cus_1", + featureId: "feat_1", + customerEntitlementIds: [], + }), + ).resolves.toEqual({ + kind: "ok", + value: { featureId: "feat_1", balances: [] }, + }); + }); + + test("batch balance lookup misses when any live feature balance is requested", async () => { + await expect( + getCachedFeatureBalancesBatch({ + ctx: makeCtx(), + customerId: "cus_1", + featureIds: ["feat_1"], + customerEntitlementIdsByFeatureId: { feat_1: ["ce_1"] }, + }), + ).resolves.toEqual({ + kind: "missing", + reason: "live_balance_cache_unavailable", + }); + }); + + test("batch balance lookup stays ok for empty feature lists", async () => { + await expect( + getCachedFeatureBalancesBatch({ + ctx: makeCtx(), + customerId: "cus_1", + featureIds: [], + customerEntitlementIdsByFeatureId: {}, + }), + ).resolves.toEqual({ kind: "ok", value: [] }); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-feature-balances-wiring.test.ts b/server/tests/unit/full-subject-cache/full-subject-feature-balances-wiring.test.ts new file mode 100644 index 000000000..d8cbd4182 --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-feature-balances-wiring.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const sourcePath = fileURLToPath( + new URL( + "../../../src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts", + import.meta.url, + ), +); + +describe("full subject feature balance cache wiring", () => { + test("does not use Redis hash reads for live balance cache lookups", () => { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("runRedisOp"); + expect(source).not.toContain("redisV2"); + expect(source).not.toContain("hmget"); + expect(source).not.toContain("pipeline"); + expect(source).not.toContain("buildSharedFullSubjectBalanceKey"); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-invoice-upsert-wiring.test.ts b/server/tests/unit/full-subject-cache/full-subject-invoice-upsert-wiring.test.ts new file mode 100644 index 000000000..73f62120d --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-invoice-upsert-wiring.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const sourcePath = fileURLToPath( + new URL( + "../../../src/internal/customers/cache/fullSubject/actions/upsertCachedInvoiceV2.ts", + import.meta.url, + ), +); + +describe("upsertCachedInvoiceV2 wiring", () => { + test("does not use Redis/Lua for full subject invoice cache updates", () => { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).toContain("upsertInvoiceInFullSubjectBlobCacheStore"); + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("tryRedisWrite"); + expect(source).not.toContain("redisV2"); + expect(source).not.toContain("upsertInvoiceInFullSubjectV2"); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-set-wiring.test.ts b/server/tests/unit/full-subject-cache/full-subject-set-wiring.test.ts new file mode 100644 index 000000000..b98ed89c5 --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-set-wiring.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const sourcePath = fileURLToPath( + new URL( + "../../../src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setCachedFullSubject.ts", + import.meta.url, + ), +); + +describe("setCachedFullSubject wiring", () => { + test("does not use Redis/Lua for subject snapshot writes", () => { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).toContain("setFullSubjectBlobInCacheStore"); + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("tryRedisWrite"); + expect(source).not.toContain("redisV2"); + expect(source).not.toContain("setCachedFullSubject("); + expect(source).not.toContain("buildSharedBalanceWrites"); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-snapshot-cache-store-adapter.test.ts b/server/tests/unit/full-subject-cache/full-subject-snapshot-cache-store-adapter.test.ts index e5510708b..ebe9cdc20 100644 --- a/server/tests/unit/full-subject-cache/full-subject-snapshot-cache-store-adapter.test.ts +++ b/server/tests/unit/full-subject-cache/full-subject-snapshot-cache-store-adapter.test.ts @@ -19,6 +19,7 @@ import { upsertCachedInvoiceV2 } from "@/internal/customers/cache/fullSubject/ac import { updateCachedCustomerProductV2 } from "@/internal/customers/cache/fullSubject/actions/updateCachedCustomerProduct.js"; import { updateCachedCustomerData } from "@/internal/customers/cache/fullSubject/actions/updateCachedCustomerData.js"; import { updateCachedEntityData } from "@/internal/customers/cache/fullSubject/actions/updateCachedEntityData.js"; +import { setCachedFullSubject } from "@/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setCachedFullSubject.js"; import { FULL_SUBJECT_CACHE_SCHEMA_VERSION, normalizedToCachedFullSubject, @@ -134,6 +135,25 @@ describe("FullSubject snapshot CacheStore adapter", () => { ).resolves.toBeUndefined(); }); + test("set action writes CacheStore subject snapshots without Redis", async () => { + const ctx = makeCtx(); + + await expect( + setCachedFullSubject({ + ctx, + normalized: makeNormalized(), + fetchedSubjectViewEpoch: 5, + }), + ).resolves.toBe("OK"); + + const read = await getFullSubjectBlobFromCacheStore({ + ctx, + customerId: "cus_1", + }); + expect(read?.subjectViewEpoch).toBe(5); + expect(read?.customer.id).toBe("cus_1"); + }); + test("get action reads CacheStore subject snapshots without Redis", async () => { const ctx = makeCtx(); await setFullSubjectBlobInCacheStore({ diff --git a/server/tests/unit/full-subject-cache/full-subject-update-actions-wiring.test.ts b/server/tests/unit/full-subject-cache/full-subject-update-actions-wiring.test.ts new file mode 100644 index 000000000..9e0b6f063 --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-update-actions-wiring.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const actionSource = (relativePath: string) => + readFileSync( + fileURLToPath( + new URL( + `../../../src/internal/customers/cache/fullSubject/actions/${relativePath}`, + import.meta.url, + ), + ), + "utf8", + ); + +describe("full subject update action wiring", () => { + test.each([ + ["updateCachedCustomerData.ts", "updateFullSubjectCustomerDataV2"], + ["updateCachedEntityData.ts", "updateFullSubjectEntityDataV2"], + ["updateCachedCustomerProduct.ts", "updateFullSubjectCustomerProductV2"], + ])("%s does not use Redis/Lua for subject snapshot updates", (file, luaCommand) => { + const source = actionSource(file); + + expect(source).toContain("deleteFullSubjectBlobFromCacheStore"); + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("tryRedisRead"); + expect(source).not.toContain("tryRedisWrite"); + expect(source).not.toContain("redisV2"); + expect(source).not.toContain(luaCommand); + }); +}); diff --git a/server/tests/unit/full-subject-cache/ordinary-cache-write-paths-no-redis.test.ts b/server/tests/unit/full-subject-cache/ordinary-cache-write-paths-no-redis.test.ts new file mode 100644 index 000000000..e9fed5c28 --- /dev/null +++ b/server/tests/unit/full-subject-cache/ordinary-cache-write-paths-no-redis.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { MemoryCacheStore } from "@/external/storage/cache/MemoryCacheStore.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { ResetCusEntParam } from "@/internal/balances/utils/sql/client.js"; +import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js"; +import { executeResetCache } from "@/internal/customers/actions/resetCustomerEntitlements/executeResetCache.js"; +import { resetSubjectCache } from "@/internal/customers/actions/resetCustomerEntitlementsV2/resetSubjectCache.js"; +import { rollUsageWindowsCache } from "@/internal/customers/actions/resetUsageWindows/rollUsageWindowsCache.js"; +import type { UsageWindowRoll } from "@/internal/customers/actions/resetUsageWindows/computeUsageWindowRolls.js"; +import { updateCachedCustomerProduct } from "@/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.js"; +import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; + +const sourcePaths = [ + "../../../src/internal/customers/actions/resetCustomerEntitlementsV2/resetSubjectCache.ts", + "../../../src/internal/customers/actions/resetCustomerEntitlements/executeResetCache.ts", + "../../../src/internal/customers/actions/resetUsageWindows/rollUsageWindowsCache.ts", + "../../../src/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.ts", +].map((path) => fileURLToPath(new URL(path, import.meta.url))); + +const makeCtx = () => + ({ + org: { id: "org_1", redis_config: null }, + env: AppEnv.Sandbox, + cacheStore: new MemoryCacheStore(), + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }) as AutumnContext & { cacheStore: MemoryCacheStore }; + +const seedSnapshots = async ({ + ctx, + customerId, +}: { + ctx: AutumnContext & { cacheStore: MemoryCacheStore }; + customerId: string; +}) => { + const fullCustomerKey = buildFullCustomerCacheKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + }); + const fullSubjectKey = buildFullSubjectKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + }); + + await ctx.cacheStore.putJson(fullCustomerKey, { cached: "customer" }); + await ctx.cacheStore.putJson(fullSubjectKey, { cached: "subject" }); + + return { fullCustomerKey, fullSubjectKey }; +}; + +const makeReset = (): ResetCusEntParam => ({ + cus_ent_id: "ce_1", + balance: 10, + additional_balance: 0, + adjustment: 0, + entities: null, + next_reset_at: 123, + rollover_insert: null, +}); + +const makeRoll = (): UsageWindowRoll => ({ + id: "uw_1", + feature_id: "feat_1", + internal_entity_id: null, + zero_usage: true, + window_start_at: 100, + window_end_at: 200, + anchor_customer_entitlement_id: "ce_1", +}); + +describe("ordinary cache write paths", () => { + test("do not call Redis or Lua-backed cache commands", () => { + for (const sourcePath of sourcePaths) { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).toContain("invalidateCustomerCacheStoreSnapshots"); + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("tryRedisWrite"); + expect(source).not.toContain("redisV2"); + expect(source).not.toContain(".pipeline("); + expect(source).not.toContain("updateSubjectBalances("); + expect(source).not.toContain("updateCustomerEntitlements("); + expect(source).not.toContain("rollUsageWindows("); + expect(source).not.toContain("updateCustomerProduct("); + expect(source).not.toContain("buildSharedFullSubjectBalanceKey"); + expect(source).not.toContain("buildFullCustomerCacheKey"); + } + }); + + test("resetSubjectCache invalidates CacheStore snapshots", async () => { + const ctx = makeCtx(); + const { fullCustomerKey, fullSubjectKey } = await seedSnapshots({ + ctx, + customerId: "cus_1", + }); + + await resetSubjectCache({ + ctx, + customerId: "cus_1", + resets: [makeReset()], + oldNextResetAts: { ce_1: 100 }, + clearingMap: {}, + customerEntitlementFeatureIds: { ce_1: "feat_1" }, + }); + + await expect(ctx.cacheStore.getJson(fullCustomerKey)).resolves.toBeNull(); + await expect(ctx.cacheStore.getJson(fullSubjectKey)).resolves.toBeNull(); + }); + + test("executeResetCache invalidates CacheStore snapshots", async () => { + const ctx = makeCtx(); + const { fullCustomerKey, fullSubjectKey } = await seedSnapshots({ + ctx, + customerId: "cus_1", + }); + + await executeResetCache({ + ctx, + customerId: "cus_1", + resets: [makeReset()], + oldNextResetAts: { ce_1: 100 }, + clearingMap: {}, + }); + + await expect(ctx.cacheStore.getJson(fullCustomerKey)).resolves.toBeNull(); + await expect(ctx.cacheStore.getJson(fullSubjectKey)).resolves.toBeNull(); + }); + + test("rollUsageWindowsCache invalidates CacheStore snapshots", async () => { + const ctx = makeCtx(); + const { fullCustomerKey, fullSubjectKey } = await seedSnapshots({ + ctx, + customerId: "cus_1", + }); + + await rollUsageWindowsCache({ + ctx, + customerId: "cus_1", + rolls: [makeRoll()], + now: 300, + }); + + await expect(ctx.cacheStore.getJson(fullCustomerKey)).resolves.toBeNull(); + await expect(ctx.cacheStore.getJson(fullSubjectKey)).resolves.toBeNull(); + }); + + test("updateCachedCustomerProduct invalidates CacheStore snapshots", async () => { + const ctx = makeCtx(); + const { fullCustomerKey, fullSubjectKey } = await seedSnapshots({ + ctx, + customerId: "cus_1", + }); + + await expect( + updateCachedCustomerProduct({ + ctx, + customerId: "cus_1", + cusProductId: "cp_1", + updates: { canceled: true }, + }), + ).resolves.toBeNull(); + + await expect(ctx.cacheStore.getJson(fullCustomerKey)).resolves.toBeNull(); + await expect(ctx.cacheStore.getJson(fullSubjectKey)).resolves.toBeNull(); + }); +}); diff --git a/server/tests/unit/full-subject-cache/subject-balance-cache-writes-no-redis.test.ts b/server/tests/unit/full-subject-cache/subject-balance-cache-writes-no-redis.test.ts new file mode 100644 index 000000000..b8010be83 --- /dev/null +++ b/server/tests/unit/full-subject-cache/subject-balance-cache-writes-no-redis.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { MemoryCacheStore } from "@/external/storage/cache/MemoryCacheStore.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js"; +import { adjustSubjectBalanceCache } from "@/internal/customers/cusProducts/cusEnts/actions/cache/adjustSubjectBalanceCache.js"; +import { incrementCachedCusEntBalance } from "@/internal/customers/cusProducts/cusEnts/actions/cache/incrementCachedCusEntBalance.js"; +import { updateSubjectBalanceCache } from "@/internal/customers/cusProducts/cusEnts/actions/cache/updateSubjectBalanceCache.js"; +import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; + +const cacheWriteHelperPaths = [ + "../../../src/internal/customers/cusProducts/cusEnts/actions/cache/adjustSubjectBalanceCache.ts", + "../../../src/internal/customers/cusProducts/cusEnts/actions/cache/updateSubjectBalanceCache.ts", + "../../../src/internal/customers/cusProducts/cusEnts/actions/cache/incrementCachedCusEntBalance.ts", +].map((path) => fileURLToPath(new URL(path, import.meta.url))); + +const cacheWritePathSources = [ + ...cacheWriteHelperPaths, + "../../../src/internal/customers/cusProducts/cusEnts/actions/updateCusEntDbAndCache.ts", + "../../../src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts", +].map((path) => fileURLToPath(new URL(path, import.meta.url))); + +const makeCtx = () => + ({ + org: { id: "org_1", redis_config: null }, + env: AppEnv.Sandbox, + cacheStore: new MemoryCacheStore(), + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }) as AutumnContext & { cacheStore: MemoryCacheStore }; + +const seedSnapshots = async ({ + ctx, + customerId, +}: { + ctx: AutumnContext & { cacheStore: MemoryCacheStore }; + customerId: string; +}) => { + const fullCustomerKey = buildFullCustomerCacheKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + }); + const fullSubjectKey = buildFullSubjectKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + }); + + await ctx.cacheStore.putJson(fullCustomerKey, { cached: "customer" }); + await ctx.cacheStore.putJson(fullSubjectKey, { cached: "subject" }); + + return { fullCustomerKey, fullSubjectKey }; +}; + +describe("subject balance cache write paths", () => { + test("do not call Redis or Lua-backed cache commands", () => { + for (const sourcePath of cacheWritePathSources) { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("tryRedisWrite"); + expect(source).not.toContain("redisV2"); + expect(source).not.toContain("adjustSubjectBalance("); + expect(source).not.toContain("updateSubjectBalances("); + expect(source).not.toContain("adjustCustomerEntitlementBalance("); + expect(source).not.toContain("buildSharedFullSubjectBalanceKey"); + } + }); + + test("cache helper writes use snapshot invalidation", () => { + for (const sourcePath of cacheWriteHelperPaths) { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).toContain("invalidateCustomerCacheStoreSnapshots"); + } + }); + + test("adjustSubjectBalanceCache invalidates CacheStore snapshots", async () => { + const ctx = makeCtx(); + const { fullCustomerKey, fullSubjectKey } = await seedSnapshots({ + ctx, + customerId: "cus_1", + }); + + await expect( + adjustSubjectBalanceCache({ + ctx, + customerId: "cus_1", + featureId: "feat_1", + customerEntitlementId: "ce_1", + delta: -1, + }), + ).resolves.toBeNull(); + + await expect(ctx.cacheStore.getJson(fullCustomerKey)).resolves.toBeNull(); + await expect(ctx.cacheStore.getJson(fullSubjectKey)).resolves.toBeNull(); + }); + + test("updateSubjectBalanceCache invalidates CacheStore snapshots", async () => { + const ctx = makeCtx(); + const { fullCustomerKey, fullSubjectKey } = await seedSnapshots({ + ctx, + customerId: "cus_1", + }); + + await updateSubjectBalanceCache({ + ctx, + customerId: "cus_1", + featureId: "feat_1", + customerEntitlementId: "ce_1", + updates: { balance: 5 }, + }); + + await expect(ctx.cacheStore.getJson(fullCustomerKey)).resolves.toBeNull(); + await expect(ctx.cacheStore.getJson(fullSubjectKey)).resolves.toBeNull(); + }); + + test("incrementCachedCusEntBalance invalidates CacheStore snapshots", async () => { + const ctx = makeCtx(); + const { fullCustomerKey, fullSubjectKey } = await seedSnapshots({ + ctx, + customerId: "cus_1", + }); + + await expect( + incrementCachedCusEntBalance({ + ctx, + customerId: "cus_1", + cusEntId: "ce_1", + delta: 1, + }), + ).resolves.toBeNull(); + + await expect(ctx.cacheStore.getJson(fullCustomerKey)).resolves.toBeNull(); + await expect(ctx.cacheStore.getJson(fullSubjectKey)).resolves.toBeNull(); + }); +}); diff --git a/server/tests/unit/full-subject-cache/tryOrInvalidate.test.ts b/server/tests/unit/full-subject-cache/tryOrInvalidate.test.ts index b9a26db3c..fbb8c9071 100644 --- a/server/tests/unit/full-subject-cache/tryOrInvalidate.test.ts +++ b/server/tests/unit/full-subject-cache/tryOrInvalidate.test.ts @@ -1,17 +1,33 @@ import { describe, expect, mock, test } from "bun:test"; -import { RedisUnavailableError } from "@/external/redis/utils/errors.js"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { tryOrInvalidate } from "@/internal/customers/cache/fullSubject/tryOrInvalidate.js"; +const sourcePath = fileURLToPath( + new URL("../../../src/internal/customers/cache/fullSubject/tryOrInvalidate.ts", import.meta.url), +); + +class CacheUnavailableError extends Error { + constructor() { + super("cache unavailable"); + this.name = "CacheUnavailableError"; + } +} + describe("tryOrInvalidate", () => { + test("does not import Redis-specific error classes", () => { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).not.toContain("@/external/redis"); + expect(source).not.toContain("RedisUnavailableError"); + }); + test.concurrent( - "rethrows RedisUnavailableError without invalidating", + "rethrows cache unavailable errors without invalidating", async () => { const invalidate = mock(async () => {}); const warn = mock(() => {}); - const error = new RedisUnavailableError({ - source: "tryOrInvalidateTest", - reason: "timeout", - }); + const error = new CacheUnavailableError(); await expect( tryOrInvalidate({ diff --git a/server/tests/unit/misc/idempotency/resolveRequestIdempotencyStore.test.ts b/server/tests/unit/misc/idempotency/resolveRequestIdempotencyStore.test.ts index 13231048f..7a06a4604 100644 --- a/server/tests/unit/misc/idempotency/resolveRequestIdempotencyStore.test.ts +++ b/server/tests/unit/misc/idempotency/resolveRequestIdempotencyStore.test.ts @@ -4,7 +4,6 @@ import { getRequestIdempotencyStoreResolution, resolveRequestIdempotencyStore, } from "@/internal/misc/idempotency/resolveRequestIdempotencyStore.js"; -import { RedisIdempotencyStore } from "@/internal/misc/idempotency/redisIdempotencyStore.js"; describe("resolveRequestIdempotencyStore", () => { test("uses Durable Object only when the binding exists and the flag is enabled", () => { @@ -24,33 +23,38 @@ describe("resolveRequestIdempotencyStore", () => { ); }); - test("falls back to Redis when the Durable Object flag is missing", () => { + test("falls back to the default store when the Durable Object flag is missing", async () => { const env = { IDEMPOTENCY_DO: {} as DurableObjectNamespace, } as unknown as Env; expect(getRequestIdempotencyStoreResolution(env)).toEqual({ - backend: "redis", + backend: "default", durableObjectEnabled: false, durableObjectBound: true, }); - expect(resolveRequestIdempotencyStore(env)).toBeInstanceOf( - RedisIdempotencyStore, - ); + await expect( + resolveRequestIdempotencyStore(env).acquire({ + key: "idempotency:test", + ttlMs: 1000, + }), + ).resolves.toBe("unavailable"); }); - test("falls back to Redis when the binding is missing", () => { + test("falls back to the default store when the binding is missing", async () => { const env = { USE_DO_IDEMPOTENCY: "true", } as unknown as Env; - expect(getRequestIdempotencyStoreResolution(env)).toEqual({ - backend: "redis", - durableObjectEnabled: true, - durableObjectBound: false, - }); - expect(resolveRequestIdempotencyStore(env)).toBeInstanceOf( - RedisIdempotencyStore, - ); + const resolution = getRequestIdempotencyStoreResolution(env); + expect(resolution.backend).toBe("default"); + expect(resolution.durableObjectEnabled).toBe(true); + expect(Boolean(resolution.durableObjectBound)).toBe(false); + await expect( + resolveRequestIdempotencyStore(env).acquire({ + key: "idempotency:test", + ttlMs: 1000, + }), + ).resolves.toBe("unavailable"); }); }); diff --git a/server/tests/unit/rate-limits/resolve-rate-limit-store.test.ts b/server/tests/unit/rate-limits/resolve-rate-limit-store.test.ts index 553dd039c..40be01c1e 100644 --- a/server/tests/unit/rate-limits/resolve-rate-limit-store.test.ts +++ b/server/tests/unit/rate-limits/resolve-rate-limit-store.test.ts @@ -49,10 +49,9 @@ describe("resolveRateLimitStore", () => { USE_DO_RATE_LIMIT: "true", } as unknown as Env; - expect(getRequestRateLimitStoreResolution(env)).toEqual({ - backend: "default", - durableObjectEnabled: true, - durableObjectBound: false, - }); + const resolution = getRequestRateLimitStoreResolution(env); + expect(resolution.backend).toBe("default"); + expect(resolution.durableObjectEnabled).toBe(true); + expect(resolution.durableObjectBound).toBe(false); }); }); diff --git a/server/tests/unit/redis/batch-invalidate-full-subjects.test.ts b/server/tests/unit/redis/batch-invalidate-full-subjects.test.ts deleted file mode 100644 index 6ef13f346..000000000 --- a/server/tests/unit/redis/batch-invalidate-full-subjects.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; -import type { AppEnv } from "@autumn/shared"; -import type { Redis } from "ioredis"; - -const mockState = { - deletedCount: 0, - ctx: undefined as unknown, -}; - -mock.module( - "@/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.js", - () => ({ - batchDeleteCachedFullCustomers: async ({ - ctx, - customers, - }: { - ctx?: unknown; - customers: unknown[]; - }) => { - mockState.ctx = ctx; - mockState.deletedCount = customers.length; - return customers.length; - }, - }), -); - -mock.module("@/utils/cacheUtils/cacheUtils.js", () => ({ - tryRedisRead: async (_env: unknown, fn: () => Promise) => fn(), - tryRedisWrite: async (_env: unknown, fn: () => Promise) => fn(), -})); - -import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.js"; - -type RedisCalls = { - readKeys: string[]; - writeOps: string[]; -}; - -const createFakeRedis = (): { redis: Redis; calls: RedisCalls } => { - const calls: RedisCalls = { - readKeys: [], - writeOps: [], - }; - let pipelineCount = 0; - - const redis = { - status: "ready", - pipeline: () => { - const isReadPipeline = pipelineCount % 2 === 0; - pipelineCount++; - - const readKeys: string[] = []; - const writeOps: string[] = []; - const pipeline = { - get: (key: string) => { - readKeys.push(key); - return pipeline; - }, - unlink: (key: string) => { - writeOps.push(`unlink:${key}`); - return pipeline; - }, - incr: (key: string) => { - writeOps.push(`incr:${key}`); - return pipeline; - }, - expire: (key: string, ttlSeconds: number) => { - writeOps.push(`expire:${key}:${ttlSeconds}`); - return pipeline; - }, - exec: async () => { - if (isReadPipeline) { - calls.readKeys.push(...readKeys); - return readKeys.map(() => [ - null, - JSON.stringify({ meteredFeatures: ["feature_metered"] }), - ]); - } - - calls.writeOps.push(...writeOps); - return []; - }, - }; - - return pipeline; - }, - } as unknown as Redis; - - return { redis, calls }; -}; - -describe("batchInvalidateCachedFullSubjects", () => { - beforeEach(() => { - mockState.deletedCount = 0; - mockState.ctx = undefined; - }); - - test("fans out invalidation to the Redis instance for each customer", async () => { - const primary = createFakeRedis(); - const dedicated = createFakeRedis(); - const customers = [ - { - orgId: "org_test", - env: "sandbox" as AppEnv, - customerId: "cus_primary", - }, - { - orgId: "org_test", - env: "sandbox" as AppEnv, - customerId: "cus_dedicated", - }, - ]; - - const deleted = await batchInvalidateCachedFullSubjects({ - customers, - featuresByOrgEnv: {}, - getRedisTargetsForCustomer: ({ customer }) => [ - customer.customerId === "cus_dedicated" - ? dedicated.redis - : primary.redis, - ], - }); - - expect(deleted).toBe(2); - expect(mockState.deletedCount).toBe(2); - - expect(primary.calls.readKeys).toHaveLength(1); - expect(primary.calls.readKeys[0]).toContain("cus_primary"); - expect(primary.calls.readKeys[0]).not.toContain("cus_dedicated"); - - expect(dedicated.calls.readKeys).toHaveLength(1); - expect(dedicated.calls.readKeys[0]).toContain("cus_dedicated"); - expect(dedicated.calls.readKeys[0]).not.toContain("cus_primary"); - - expect( - primary.calls.writeOps.some((op) => op.includes("cus_primary")), - ).toBe(true); - expect( - dedicated.calls.writeOps.some((op) => op.includes("cus_dedicated")), - ).toBe(true); - }); - - test("dedupes duplicate Redis targets for the same customer", async () => { - const primary = createFakeRedis(); - const customers = [ - { - orgId: "org_test", - env: "sandbox" as AppEnv, - customerId: "cus_primary", - }, - ]; - - await batchInvalidateCachedFullSubjects({ - customers, - featuresByOrgEnv: {}, - getRedisTargetsForCustomer: () => [primary.redis, primary.redis], - }); - - expect(primary.calls.readKeys).toHaveLength(1); - expect( - primary.calls.writeOps.some((op) => op.includes("cus_primary")), - ).toBe(true); - }); - - test("passes request context through to full customer batch deletion", async () => { - const primary = createFakeRedis(); - const ctx = { cacheStore: { metadata: { backend: "memory" } } }; - const customers = [ - { - orgId: "org_test", - env: "sandbox" as AppEnv, - customerId: "cus_primary", - }, - ]; - - await batchInvalidateCachedFullSubjects({ - ctx: ctx as never, - customers, - featuresByOrgEnv: {}, - getRedisTargetsForCustomer: () => [primary.redis], - }); - - expect(mockState.ctx).toBe(ctx); - }); -}); - -afterAll(() => { - mock.restore(); -}); diff --git a/server/tests/unit/redis/resolveRequestLockStore.test.ts b/server/tests/unit/redis/resolveRequestLockStore.test.ts index 95bad6bc6..21b7230be 100644 --- a/server/tests/unit/redis/resolveRequestLockStore.test.ts +++ b/server/tests/unit/redis/resolveRequestLockStore.test.ts @@ -7,7 +7,7 @@ import { getRequestLockStoreResolution, resolveRequestLockStore, } from "@/external/redis/resolveRequestLockStore.js"; -import { RedisLockStore } from "@/external/redis/redisLockStore.js"; +import { getDefaultLockStore } from "@/external/redis/defaultLockStore.js"; describe("resolveRequestLockStore", () => { test("routes each logical lock key to its own Durable Object", () => { @@ -34,29 +34,29 @@ describe("resolveRequestLockStore", () => { expect(resolveRequestLockStore(env)).toBeInstanceOf(DurableObjectLockStore); }); - test("falls back to Redis when the Durable Object flag is missing", () => { + test("falls back to the default lock store when the Durable Object flag is missing", () => { const env = { LOCK_DO: {} as DurableObjectNamespace, } as unknown as Env; expect(getRequestLockStoreResolution(env)).toEqual({ - backend: "redis", + backend: "default", durableObjectEnabled: false, durableObjectBound: true, }); - expect(resolveRequestLockStore(env)).toBeInstanceOf(RedisLockStore); + expect(resolveRequestLockStore(env)).toBe(getDefaultLockStore()); }); - test("falls back to Redis when the binding is missing", () => { + test("falls back to the default lock store when the binding is missing", () => { const env = { USE_DO_LOCK: "true", } as unknown as Env; expect(getRequestLockStoreResolution(env)).toEqual({ - backend: "redis", + backend: "default", durableObjectEnabled: true, durableObjectBound: false, }); - expect(resolveRequestLockStore(env)).toBeInstanceOf(RedisLockStore); + expect(resolveRequestLockStore(env)).toBe(getDefaultLockStore()); }); }); diff --git a/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts b/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts index fe2f1c297..ff8e84eb6 100644 --- a/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts +++ b/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts @@ -1,10 +1,6 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { ApiVersionClass, LATEST_VERSION, RecaseError } from "@autumn/shared"; -mock.module("@/external/redis/initUtils/redisV2Availability.js", () => ({ - shouldUseRedisV2: () => true, -})); - const gateRejection = () => new RecaseError({ message: "Too many concurrent requests for this customer.", @@ -48,6 +44,9 @@ let trackError: unknown = gateRejection(); const { withRedisFailOpen } = await import( "@/external/redis/utils/withRedisFailOpen.js" ); +const { registerRedisFailOpenAvailabilityResolver } = await import( + "@/external/redis/requestRedisFailOpenAvailability.js" +); const { isFullSubjectGateRejection } = await import( "@/internal/customers/repos/getFullSubject/getFullSubjectGate.js" ); @@ -94,6 +93,8 @@ const checkBody = ParsedCheckParamsSchema.parse({ feature_id: "messages", }); +registerRedisFailOpenAvailabilityResolver(() => true); + describe("withRedisFailOpen alsoFailOpen", () => { test("gate rejection falls open when alsoFailOpen matches", async () => { const rejection = gateRejection(); diff --git a/server/tests/unit/revenuecat/getRevenuecatAccessToken.test.ts b/server/tests/unit/revenuecat/getRevenuecatAccessToken.test.ts index c28862b69..4f11c0942 100644 --- a/server/tests/unit/revenuecat/getRevenuecatAccessToken.test.ts +++ b/server/tests/unit/revenuecat/getRevenuecatAccessToken.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { AppEnv, type Organization } from "@autumn/shared"; +import { testWorkerEnv } from "@tests/utils/testWorkerEnv.js"; import { OAuth2Tokens } from "arctic"; import { encryptData } from "@/utils/encryptUtils.js"; @@ -44,11 +45,11 @@ const buildOrg = ({ processor_configs: { revenuecat: { ...(withApiKey - ? { sandbox_api_key: encryptData("legacy_api_key") } + ? { sandbox_api_key: encryptData("legacy_api_key", testWorkerEnv) } : {}), sandbox_oauth: { - access_token: encryptData("cached_access_token"), - refresh_token: encryptData("cached_refresh_token"), + access_token: encryptData("cached_access_token", testWorkerEnv), + refresh_token: encryptData("cached_refresh_token", testWorkerEnv), expires_at: expiresAt, }, webhook_secret: "whsec", @@ -75,6 +76,7 @@ describe("getRevenuecatAccessToken", () => { db: {} as never, org, env: AppEnv.Sandbox, + workerEnv: testWorkerEnv, }); expect(token).toBe("cached_access_token"); @@ -89,6 +91,7 @@ describe("getRevenuecatAccessToken", () => { db: {} as never, org, env: AppEnv.Sandbox, + workerEnv: testWorkerEnv, }); expect(token).toBe("atk_refreshed"); @@ -109,7 +112,7 @@ describe("getRevenuecatAccessToken", () => { id: "org_123", processor_configs: { revenuecat: { - sandbox_api_key: encryptData("legacy_api_key"), + sandbox_api_key: encryptData("legacy_api_key", testWorkerEnv), }, }, } as Organization; @@ -118,6 +121,7 @@ describe("getRevenuecatAccessToken", () => { db: {} as never, org, env: AppEnv.Sandbox, + workerEnv: testWorkerEnv, }); expect(token).toBe("legacy_api_key"); diff --git a/server/tests/unit/revenuecat/registerRevenuecatWebhook.test.ts b/server/tests/unit/revenuecat/registerRevenuecatWebhook.test.ts index 2f453083c..7198cc263 100644 --- a/server/tests/unit/revenuecat/registerRevenuecatWebhook.test.ts +++ b/server/tests/unit/revenuecat/registerRevenuecatWebhook.test.ts @@ -4,6 +4,7 @@ */ import { AppEnv } from "@autumn/shared"; +import { makeTestWorkerEnv } from "@tests/utils/testWorkerEnv.js"; import { afterEach, beforeEach, expect, mock, test } from "bun:test"; import chalk from "chalk"; import { @@ -43,17 +44,32 @@ const makeCli = (existing: RevenueCatWebhookIntegration[] = []) => { }; }; +const serverEnv = () => + makeTestWorkerEnv({ + NODE_ENV: process.env.NODE_ENV, + NGROK_URL: process.env.NGROK_URL, + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, + }); + test(`${chalk.yellowBright("webhook url: dev uses NGROK_URL + AppEnv segment")}`, () => { - expect(getRevenuecatWebhookUrl({ orgId: "org_1", env: AppEnv.Sandbox })).toBe( - "https://ngrok.test/webhooks/revenuecat/org_1/sandbox", - ); + expect( + getRevenuecatWebhookUrl({ + orgId: "org_1", + env: AppEnv.Sandbox, + serverEnv: serverEnv(), + }), + ).toBe("https://ngrok.test/webhooks/revenuecat/org_1/sandbox"); }); test(`${chalk.yellowBright("webhook url: prod uses BETTER_AUTH_URL")}`, () => { process.env.NODE_ENV = "production"; - expect(getRevenuecatWebhookUrl({ orgId: "org_1", env: AppEnv.Live })).toBe( - "https://api.useautumn.com/webhooks/revenuecat/org_1/live", - ); + expect( + getRevenuecatWebhookUrl({ + orgId: "org_1", + env: AppEnv.Live, + serverEnv: serverEnv(), + }), + ).toBe("https://api.useautumn.com/webhooks/revenuecat/org_1/live"); }); test(`${chalk.yellowBright("register: no existing webhook → creates with secret + environment, no event/app scoping")}`, async () => { @@ -63,6 +79,7 @@ test(`${chalk.yellowBright("register: no existing webhook → creates with secre orgId: "org_1", env: AppEnv.Sandbox, secret: "whsec_abc", + serverEnv: serverEnv(), }); expect(status).toBe("created"); @@ -86,6 +103,7 @@ test(`${chalk.yellowBright("register: live env maps to environment=production")} orgId: "org_1", env: AppEnv.Live, secret: "whsec_live", + serverEnv: serverEnv(), }); expect( (createWebhookIntegration.mock.calls[0]?.[0] as { environment: string }) @@ -106,6 +124,7 @@ test(`${chalk.yellowBright("register: existing webhook with same url → exists, orgId: "org_1", env: AppEnv.Sandbox, secret: "whsec_abc", + serverEnv: serverEnv(), }); expect(status).toBe("exists"); expect(createWebhookIntegration).not.toHaveBeenCalled(); @@ -119,6 +138,7 @@ test(`${chalk.yellowBright("register: no base url → skipped, no list/create")} orgId: "org_1", env: AppEnv.Sandbox, secret: "whsec_abc", + serverEnv: serverEnv(), }); expect(status).toBe("skipped"); expect(listWebhookIntegrations).not.toHaveBeenCalled(); diff --git a/server/tests/unit/revenuecat/revenuecatOAuth.test.ts b/server/tests/unit/revenuecat/revenuecatOAuth.test.ts index 027dd1593..5b866082e 100644 --- a/server/tests/unit/revenuecat/revenuecatOAuth.test.ts +++ b/server/tests/unit/revenuecat/revenuecatOAuth.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { testWorkerEnv } from "@tests/utils/testWorkerEnv.js"; import { OAuth2Tokens } from "arctic"; const mockValidateAuthorizationCode = mock(() => @@ -48,6 +49,7 @@ describe("exchangeRcCode", () => { const tokens = await exchangeRcCode({ code: "auth_code_123", codeVerifier: "verifier_abc", + env: testWorkerEnv, }); expect(mockValidateAuthorizationCode).toHaveBeenCalledWith( diff --git a/server/tests/unit/runtime-env-imports.test.ts b/server/tests/unit/runtime-env-imports.test.ts index 8310df972..34816a14b 100644 --- a/server/tests/unit/runtime-env-imports.test.ts +++ b/server/tests/unit/runtime-env-imports.test.ts @@ -188,6 +188,9 @@ describe("runtime env access", () => { "src/honoUtils/HonoEnv.ts", "src/db/repoContext.ts", "src/utils/disabledRedis.ts", + "src/external/redis/disabledRedis.ts", + "src/external/redis/requestLockReceiptRedis.ts", + "src/external/redis/requestRedisV2Resolver.ts", "src/utils/legacyRedisClient.ts", ]; @@ -302,6 +305,9 @@ describe("runtime env access", () => { "external/redis/orgRedisPool", "external/redis/resolveRedisV2", "external/redis/customerRedisRouting", + "external/redis/customerRedisRoutingInfo", + "external/redis/requestRedisV2Resolver", + "external/redis/disabledRedis", ]; const offenders = checkedFiles.filter((relativePath) => { @@ -394,6 +400,25 @@ describe("runtime env access", () => { expect(offenders).toEqual([]); }); + test("Redis fail-open helper does not import Redis availability clients", () => { + const source = readFileSync( + join(serverRoot, "src/external/redis/utils/withRedisFailOpen.ts"), + "utf8", + ); + const illegalImports = [ + "external/redis/initRedis", + "external/redis/initRedisV2", + "external/redis/initUtils/redisV2Availability", + "external/redis/initUtils/redisAvailability", + ]; + + const offenders = illegalImports.filter((importPath) => + source.includes(importPath), + ); + + expect(offenders).toEqual([]); + }); + test("Cache V2 ramp client does not import the default Redis init module", () => { const source = readFileSync( join(serverRoot, "src/internal/misc/cacheV2Ramp/cacheV2RampClient.ts"), @@ -460,6 +485,7 @@ describe("runtime env access", () => { "src/internal/customers/cusProducts/cusEnts/actions/updateCusEntDbAndCache.ts", "src/internal/customers/cusProducts/cusEnts/actions/cache/incrementCachedCusEntBalance.ts", "src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts", + "src/internal/balances/utils/deduction/executeDeductionCache.ts", ]; const illegalRedisImports = [ "external/redis/initRedis", @@ -478,6 +504,327 @@ describe("runtime env access", () => { expect(offenders).toEqual([]); }); + test("legacy lock, idempotency, and rate-limit stores do not import Redis client modules", () => { + const checkedFiles = [ + "src/external/redis/redisLockStore.ts", + "src/internal/misc/idempotency/redisIdempotencyStore.ts", + "src/internal/misc/rateLimiter/rateLimitRedisStore.ts", + ]; + const illegalRedisImports = [ + "external/redis/initRedis", + "external/redis/initRedisV2", + "ioredis", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => + source.includes(importPath), + ); + }); + + expect(offenders).toEqual([]); + }); + + test("sync batching managers do not import Redis client modules for region metadata", () => { + const checkedFiles = [ + "src/internal/balances/utils/sync/SyncBatchingManagerV2.ts", + "src/internal/balances/utils/sync/SyncBatchingManagerV3.ts", + "src/internal/balances/utils/sync/syncItemV3.ts", + ]; + const illegalRedisImports = [ + "external/redis/initRedis", + "external/redis/initRedisV2", + "external/redis/resolveRedisV2", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => { + const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}(?:\\.js)?["']`).test(source); + }); + }); + + expect(offenders).toEqual([]); + }); + + test("v2 lock receipt write paths do not import Redis init for region metadata", () => { + const checkedFiles = [ + "src/internal/balances/utils/lockV2/saveLockReceiptV2.ts", + "src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts", + ]; + const illegalRedisImports = [ + "external/redis/initRedis", + "external/redis/initRedisV2", + "external/redis/resolveRedisV2", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => { + const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}(?:\\.js)?["']`).test(source); + }); + }); + + expect(offenders).toEqual([]); + }); + + test("Redis balance wrappers do not import Redis init for region metadata", () => { + const checkedFiles = [ + "src/internal/balances/track/utils/runRedisTrack.ts", + "src/internal/balances/finalizeLock/runRedisFinalizeLock.ts", + "src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts", + "src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts", + ]; + const illegalRedisImports = [ + "external/redis/initRedis", + "external/redis/initRedisV2", + "external/redis/resolveRedisV2", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => { + const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}(?:\\.js)?["']`).test(source); + }); + }); + + expect(offenders).toEqual([]); + }); + + test("track request entrypoints do not import Redis deduction wrappers", () => { + const checkedFiles = [ + "src/internal/balances/track/runTrackV2.ts", + "src/internal/balances/track/v3/runTrackV3.ts", + ]; + const illegalRedisImports = [ + "internal/balances/track/utils/runRedisTrack", + "internal/balances/track/v3/runRedisTrackV3", + "./utils/runRedisTrack", + "./runRedisTrackV3", + "./trackIdempotencyKey", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => + source.includes(importPath), + ); + }); + + expect(offenders).toEqual([]); + }); + + test("Postgres track paths do not import Redis deduction barrels", () => { + const checkedFiles = [ + "src/internal/balances/track/utils/runPostgresTrack.ts", + "src/internal/balances/track/v3/runPostgresTrackV3.ts", + ]; + const illegalImports = [ + "internal/balances/utils/deductionV2/index", + "utils/deductionV2/index", + "../utils/deductionV2/index", + "@/internal/balances/utils/deductionV2/index", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalImports.some((importPath) => source.includes(importPath)); + }); + + expect(offenders).toEqual([]); + }); + + test("Postgres deduction cache sync helpers do not import default Redis clients", () => { + const checkedFiles = [ + "src/internal/balances/utils/deduction/executeDeductionCache.ts", + "src/internal/balances/utils/deductionV2/syncDeductionUpdatesToFullSubjectCache.ts", + ]; + const illegalRedisImports = [ + "external/redis/initRedis", + "external/redis/initRedisV2", + "external/redis/resolveRedisV2", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => { + const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}(?:\\.js)?["']`).test(source); + }); + }); + + expect(offenders).toEqual([]); + }); + + test("finalize lock request entrypoints do not import Redis deduction wrappers", () => { + const checkedFiles = [ + "src/internal/balances/finalizeLock/runFinalizeLock.ts", + "src/internal/balances/finalizeLock/runFinalizeLockV2.ts", + ]; + const illegalRedisImports = [ + "./runRedisFinalizeLock", + "./runRedisFinalizeLockV2", + "internal/balances/finalizeLock/runRedisFinalizeLock", + "internal/balances/finalizeLock/runRedisFinalizeLockV2", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => + source.includes(importPath), + ); + }); + + expect(offenders).toEqual([]); + }); + + test("Trigger tasks do not import Redis warmup modules", () => { + const checkedFiles = [ + "src/trigger/migrations/runMigrationCustomerTask.ts", + "src/trigger/migrations/runMigrationTask.ts", + "src/trigger/cache/warmFullSubjectCacheTask.ts", + ]; + const illegalRedisImports = [ + "external/redis/initRedis", + "external/redis/initRedisV2", + "external/redis/initUtils/redisWarmup", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => { + const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}(?:\\.js)?["']`).test(source); + }); + }); + + expect(offenders).toEqual([]); + }); + + test("queue processor does not import Redis retry helpers", () => { + const source = readFileSync(join(serverRoot, "src/queue/processMessage.ts"), "utf8"); + const illegalRedisImports = [ + "external/redis/utils/isTransientRedisError", + "external/redis/initRedis", + "external/redis/initRedisV2", + ]; + + const offenders = illegalRedisImports.filter((importPath) => { + const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}(?:\\.js)?["']`).test(source); + }); + + expect(offenders).toEqual([]); + }); + + test("rollout and 503 shedding helpers do not import Redis retry helpers", () => { + const checkedFiles = [ + "src/db/shed503OnTransientError.ts", + "src/internal/misc/rollouts/fullSubjectRolloutUtils.ts", + ]; + const illegalRedisImports = [ + "external/redis/utils/isTransientRedisError", + "external/redis/utils/errors", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => { + const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}(?:\\.js)?["']`).test(source); + }); + }); + + expect(offenders).toEqual([]); + }); + + test("update balance request paths do not import Redis deduction wrappers", () => { + const checkedFiles = [ + "src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts", + "src/internal/balances/updateBalance/v2/updateUsageV2.ts", + "src/internal/balances/updateBalance/v2/updateRemainingV2.ts", + ]; + const illegalRedisImports = [ + "utils/deduction/executeRedisDeduction", + "utils/deductionV2/executeRedisDeductionV2", + "executeRedisDeduction", + "executeRedisDeductionV2", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => + source.includes(importPath), + ); + }); + + expect(offenders).toEqual([]); + }); + + test("check and track rollout paths do not import Redis fail-open wrappers", () => { + const checkedFiles = [ + "src/internal/balances/check/runCheckWithRollout.ts", + "src/internal/balances/track/runTrackWithRollout.ts", + ]; + const illegalRedisImports = [ + "external/redis/utils/withRedisFailOpen", + "external/redis/requestRedisFailOpenAvailability", + "external/redis/utils/errors", + "external/redis/utils/isTransientRedisError", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => { + const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}(?:\\.js)?["']`).test(source); + }); + }); + + expect(offenders).toEqual([]); + }); + + test("v1 lock receipt and Redis deduction write paths do not import Redis init", () => { + const checkedFiles = [ + "src/internal/balances/utils/lock/saveLockReceipt.ts", + "src/internal/balances/utils/deduction/executeRedisDeduction.ts", + ]; + const illegalRedisImports = [ + "external/redis/initRedis", + "external/redis/initRedisV2", + "external/redis/resolveRedisV2", + ]; + + const offenders = checkedFiles.filter((relativePath) => { + const source = readFileSync(join(serverRoot, relativePath), "utf8"); + return illegalRedisImports.some((importPath) => { + const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}(?:\\.js)?["']`).test(source); + }); + }); + + expect(offenders).toEqual([]); + }); + + test("node server init does not register legacy Redis lock, idempotency, or rate-limit stores", () => { + const source = readFileSync(join(serverRoot, "src/init.ts"), "utf8"); + const illegalImports = [ + "external/redis/redisLockStore", + "internal/misc/idempotency/redisIdempotencyStore", + "internal/misc/rateLimiter/rateLimitRedisStore", + ]; + + const offenders = illegalImports.filter((importPath) => + source.includes(importPath), + ); + + expect(offenders).toEqual([]); + }); + test("does not import AWS SDK packages in server source", () => { const offenders = getSourceFiles(srcRoot) .map((file) => { diff --git a/server/tests/unit/vercelAuth.test.ts b/server/tests/unit/vercelAuth.test.ts index e75c97370..3123b0c36 100644 --- a/server/tests/unit/vercelAuth.test.ts +++ b/server/tests/unit/vercelAuth.test.ts @@ -1,4 +1,5 @@ import { AppEnv } from "@autumn/shared"; +import { makeTestWorkerEnv } from "@tests/utils/testWorkerEnv.js"; import { describe, expect, test } from "bun:test"; import { verifyToken } from "@/external/vercel/misc/vercelAuth.js"; @@ -42,6 +43,7 @@ describe("verifyToken test OIDC bypass", () => { token: "test_oidc:icfg_test", org, env: AppEnv.Sandbox, + workerEnv: makeTestWorkerEnv({ NODE_ENV: "development" }), testOptions: { allowVercelTestOidc: true }, }); @@ -58,6 +60,7 @@ describe("verifyToken test OIDC bypass", () => { token: "test_oidc:icfg_test", org, env: AppEnv.Sandbox, + workerEnv: makeTestWorkerEnv({ NODE_ENV: "development" }), }), ).rejects.toThrow(); }); @@ -72,6 +75,7 @@ describe("verifyToken test OIDC bypass", () => { token: "test_oidc:icfg_test", org, env: AppEnv.Sandbox, + workerEnv: makeTestWorkerEnv({ NODE_ENV: "production" }), testOptions: { allowVercelTestOidc: true }, }), ).rejects.toThrow(); diff --git a/server/tests/unit/vercelSdkOptions.test.ts b/server/tests/unit/vercelSdkOptions.test.ts index e83ad6eec..8566be367 100644 --- a/server/tests/unit/vercelSdkOptions.test.ts +++ b/server/tests/unit/vercelSdkOptions.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { makeTestWorkerEnv } from "@tests/utils/testWorkerEnv.js"; import { getVercelSdkServerURL } from "@/external/vercel/misc/vercelSdkOptions.js"; describe("getVercelSdkServerURL", () => { @@ -18,23 +19,43 @@ describe("getVercelSdkServerURL", () => { process.env.NODE_ENV = "development"; process.env.BETTER_AUTH_URL = "http://localhost:8080"; - expect(getVercelSdkServerURL()).toBeUndefined(); - expect(getVercelSdkServerURL({ mockVercelApi: false })).toBeUndefined(); + const env = makeTestWorkerEnv({ + NODE_ENV: "development", + BETTER_AUTH_URL: "http://localhost:8080", + }); + expect(getVercelSdkServerURL({ env })).toBeUndefined(); + expect( + getVercelSdkServerURL({ env, testOptions: { mockVercelApi: false } }), + ).toBeUndefined(); }); test("returns the local mock URL when explicitly enabled", () => { process.env.NODE_ENV = "development"; process.env.BETTER_AUTH_URL = "http://localhost:8080/"; - expect(getVercelSdkServerURL({ mockVercelApi: true })).toBe( - "http://localhost:8080/__test/vercel/api", - ); + expect( + getVercelSdkServerURL({ + env: makeTestWorkerEnv({ + NODE_ENV: "development", + BETTER_AUTH_URL: "http://localhost:8080/", + }), + testOptions: { mockVercelApi: true }, + }), + ).toBe("http://localhost:8080/__test/vercel/api"); }); test("returns undefined in production even when enabled", () => { process.env.NODE_ENV = "production"; process.env.BETTER_AUTH_URL = "http://localhost:8080"; - expect(getVercelSdkServerURL({ mockVercelApi: true })).toBeUndefined(); + expect( + getVercelSdkServerURL({ + env: makeTestWorkerEnv({ + NODE_ENV: "production", + BETTER_AUTH_URL: "http://localhost:8080", + }), + testOptions: { mockVercelApi: true }, + }), + ).toBeUndefined(); }); }); diff --git a/server/tests/utils/testInitUtils/createSubOrgTestContext.ts b/server/tests/utils/testInitUtils/createSubOrgTestContext.ts index d8c4fe793..c1e336c0f 100644 --- a/server/tests/utils/testInitUtils/createSubOrgTestContext.ts +++ b/server/tests/utils/testInitUtils/createSubOrgTestContext.ts @@ -13,6 +13,7 @@ import { FeatureService } from "@/internal/features/FeatureService.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; import { generateId } from "../../../src/utils/genUtils.js"; import { createTestLogger, type TestContext } from "./createTestContext.js"; +import { testWorkerEnv } from "../testWorkerEnv.js"; export type TaxRegistrationCountry = | "AU" @@ -75,7 +76,11 @@ export const createSubOrgTestContext = async ({ } // 3. Stripe client scoped to the sub-org's connect account. - const subStripeCli = createStripeCli({ org: subOrg, env: AppEnv.Sandbox }); + const subStripeCli = createStripeCli({ + org: subOrg, + env: AppEnv.Sandbox, + workerEnv: testWorkerEnv, + }); // 4. Stripe Tax jurisdictions. Stripe requires a head office address // on Tax Settings before any tax.registrations.create. @@ -217,7 +222,8 @@ export const createSubOrgTestContext = async ({ dbGeneral: db, features, logger, - redisV2: resolveRedisV2(), + redisV2: resolveRedisV2({ env: testWorkerEnv }), + workerEnv: testWorkerEnv, orgSecretKey: testSecretKey, id: generateId("test"), isPublic: false, diff --git a/server/tests/utils/testInitUtils/createTestContext.ts b/server/tests/utils/testInitUtils/createTestContext.ts index 2f1844faf..aacbb9971 100644 --- a/server/tests/utils/testInitUtils/createTestContext.ts +++ b/server/tests/utils/testInitUtils/createTestContext.ts @@ -19,6 +19,7 @@ import { OrgService } from "@/internal/orgs/OrgService.js"; import type { Logger } from "../../../src/external/logtail/logtailUtils.js"; import type { AutumnContext } from "../../../src/honoUtils/HonoEnv.js"; import { generateId } from "../../../src/utils/genUtils.js"; +import { testWorkerEnv } from "../testWorkerEnv.js"; const DEFAULT_ENV = AppEnv.Sandbox; @@ -94,7 +95,7 @@ export const createTestContext = async () => { } const env = DEFAULT_ENV; - const stripeCli = createStripeCli({ org, env }); + const stripeCli = createStripeCli({ org, env, workerEnv: testWorkerEnv }); const features = await withRetry( () => FeatureService.list({ db, orgId: org.id, env }), { label: "FeatureService.list" }, @@ -117,7 +118,8 @@ export const createTestContext = async () => { dbGeneral: db, features, logger, - redisV2: resolveRedisV2(), + redisV2: resolveRedisV2({ env: testWorkerEnv }), + workerEnv: testWorkerEnv, orgSecretKey, id: generateId("test"), diff --git a/server/tests/utils/testWorkerEnv.ts b/server/tests/utils/testWorkerEnv.ts new file mode 100644 index 000000000..ba9984391 --- /dev/null +++ b/server/tests/utils/testWorkerEnv.ts @@ -0,0 +1,15 @@ +export const makeTestWorkerEnv = (overrides: Partial = {}): Env => + ({ + ...process.env, + NODE_ENV: process.env.NODE_ENV ?? "test", + ENCRYPTION_PASSWORD: + process.env.ENCRYPTION_PASSWORD ?? "test-encryption-password", + SERVER_URL: process.env.SERVER_URL ?? "http://localhost:8080", + STRIPE_WEBHOOK_URL: + process.env.STRIPE_WEBHOOK_URL ?? "http://localhost:8080", + VERCEL_MARKETPLACE_BASE_URL: + process.env.VERCEL_MARKETPLACE_BASE_URL ?? "https://marketplace.vercel.com", + ...overrides, + }) as unknown as Env; + +export const testWorkerEnv = makeTestWorkerEnv();