got all tests passing
This commit is contained in:
16
.gitignore
vendored
16
.gitignore
vendored
@@ -129,3 +129,19 @@ TAKEHOME.md
|
||||
|
||||
|
||||
.openlogs
|
||||
|
||||
|
||||
.cursor/*.log
|
||||
|
||||
|
||||
# project context (personal working state)
|
||||
.context/
|
||||
|
||||
# ai-sync: generated files (source of truth is ai/)
|
||||
.cursor/
|
||||
.claude/
|
||||
.codex/
|
||||
.agents/
|
||||
.mcp.json
|
||||
.opencode/skills/
|
||||
AGENTS.md
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "ai"]
|
||||
path = ai
|
||||
url = https://github.com/useautumn/ai
|
||||
@@ -1,36 +1,37 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"axiom": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.axiom.co/mcp"
|
||||
},
|
||||
"linear": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.linear.app/mcp",
|
||||
"oauth": {}
|
||||
},
|
||||
"trigger": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"bunx",
|
||||
"trigger.dev@latest",
|
||||
"mcp"
|
||||
]
|
||||
},
|
||||
"tinybird": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.tinybird.co?token={env:TINYBIRD_READ_TOKEN}"
|
||||
},
|
||||
"mintlify": {
|
||||
"type": "remote",
|
||||
"url": "https://mintlify.com/docs/mcp"
|
||||
},
|
||||
|
||||
"planetscale": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.pscale.dev/mcp/planetscale"
|
||||
},
|
||||
"axiom": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.axiom.co/mcp"
|
||||
},
|
||||
"tinybird": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.tinybird.co?token={env:TINYBIRD_READ_TOKEN}",
|
||||
"oauth": false,
|
||||
"enabled": true
|
||||
},
|
||||
"vercel": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.vercel.com",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
|
||||
"plugin": ["opencode-supermemory@latest"]
|
||||
"plugin": [
|
||||
"opencode-supermemory@latest"
|
||||
]
|
||||
}
|
||||
|
||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -32,6 +32,12 @@
|
||||
// "**/.claude": true,
|
||||
"**/.cursor": true,
|
||||
// "**/.github": true,
|
||||
"**/.superset": true
|
||||
"**/.superset": true,
|
||||
".claude": true,
|
||||
".codex": true,
|
||||
".agents": true,
|
||||
// ".cursor": true,
|
||||
".mcp.json": true,
|
||||
".zed": true
|
||||
}
|
||||
}
|
||||
|
||||
159
AGENTS.md
159
AGENTS.md
@@ -1,123 +1,92 @@
|
||||
# Basic rules
|
||||
- Never run a "dev" or "build" command, chances are I'm already running it in the background. Just ask me to check for updates or whatever you need
|
||||
- Never ever ever write a "TO DO" comment. If you've been told to do something, DO IT. Don't stop halfway. Never give up and just leave a "to do" comment and say - "haha heres working code :)" - that is unacceptible. Always finish your task, no matter how many iterations you need to perform.
|
||||
- DO NOT alter .gitignore
|
||||
- JS Doc comments should be SHORT and SWEET. Don't need examples unless ABSOLUTELY necessary
|
||||
- When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas.
|
||||
<!-- Generated by ai-sync. Edit ai/rules/ instead. -->
|
||||
|
||||
# Testing
|
||||
- When writing tests, ALWAYS read:
|
||||
1. `server/tests/_guides/general-test-guide.md` - Common patterns, client initialization, public keys
|
||||
2. Case-specific guide (e.g., `server/tests/_guides/check-endpoint-tests.md` for `/check` tests)
|
||||
- When running tests, ALL server-side console logs go to the server's logs which you do not have access to. You must ask the user to paste you in the logs, instead of expecting the server logs to magically appear
|
||||
in the test logs. Use your common sense
|
||||
# Scope Cache Refresh Changes Safely
|
||||
|
||||
# Linting and Codebase rules
|
||||
- You can access the biome linter by running `bunx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write <folder or file path>`
|
||||
When changing cache-refresh behavior for API routes:
|
||||
|
||||
- Note, biome does not perform typechecking. For typechecks, either `cd` into the relevant workspace and run `bun ts`, or run `tsgo` directly. If you need to install it first, the npm package is `@typescript/native-preview`. Do not use `tsc` for workspace typechecks.
|
||||
- Prefer editing `server/src/honoMiddlewares/refreshCacheConfigs.ts` to add or remove route entries.
|
||||
- Do **not** remove or bypass logic in `server/src/honoMiddlewares/refreshCacheMiddleware.ts` unless the user explicitly asks to change middleware behavior globally.
|
||||
- If the request is ambiguous, ask whether they want a route-level config change or a global middleware behavior change.
|
||||
|
||||
- The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead.
|
||||
# Cache Version Increment Policy
|
||||
|
||||
- This codebase uses Bun as its preferred package manager and Node runtime.
|
||||
Treat `cache_version` as a DB-side stale-sync guard for `syncItemV4`, not a general cache mutation counter.
|
||||
|
||||
- **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";`
|
||||
## Increment cache_version only when needed
|
||||
|
||||
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
|
||||
Increment only for DB updates that must not be overwritten by stale sync payloads read from cache (for example lifecycle or billing transitions).
|
||||
|
||||
- When creating "hooks" folders, don't nest them under "components"
|
||||
## Do not increment on cache-side/runtime patch paths
|
||||
|
||||
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
|
||||
For runtime balance/reset/cache patch flows, do not bump `cache_version` in cache writers or Lua update scripts.
|
||||
|
||||
- For regular functions, use inline object types in the function signature rather than creating separate type definitions. Only create named types when they're reused across multiple functions or exported.
|
||||
```typescript
|
||||
// ❌ BAD - Unnecessary type definition for single-use params
|
||||
type DoSomethingParams = {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
};
|
||||
const doSomething = async ({ ctx, customerId }: DoSomethingParams) => { ... }
|
||||
Examples:
|
||||
|
||||
// ✅ GOOD - Inline object type
|
||||
const doSomething = async ({ ctx, customerId }: { ctx: AutumnContext; customerId: string }) => { ... }
|
||||
```
|
||||
- FullSubject cache patch helpers (`updateSubjectBalanceCache`, reset/deduction cache patch sync helpers)
|
||||
- FullSubject Lua cache update scripts (`updateSubjectBalances`)
|
||||
- Update-balance runtime paths that patch Redis and then sync
|
||||
|
||||
- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why <package name>` which will tell you why a certain package was installed and by who.
|
||||
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
|
||||
## Call-site rule for CusEntService.update
|
||||
|
||||
- If you need the TypeScript CLI directly, run `tsgo` itself (package: `@typescript/native-preview`), not `npx tsc`.
|
||||
When using `CusEntService.update(...)` in runtime FullSubject cache patch flows, set `incrementCacheVersion` explicitly.
|
||||
|
||||
- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })`
|
||||
- Use `incrementCacheVersion: false` for routine balance/reset/adjustment updates that are mirrored to Redis.
|
||||
- Use `incrementCacheVersion: true` only for intentional DB-side stale-write protection transitions.
|
||||
|
||||
- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();`
|
||||
## Why
|
||||
|
||||
- When referring to a `customer_entitlement` object (or plural `customer_entitlements`), always use the full name. Do not abbreviate to "entitlement" or "entitlements" as this will be confused with the separate `entitlement` object.
|
||||
Incorrect version bumps create `CACHE_VERSION_MISMATCH` conflicts in `syncItemV4`, causing repeated invalidation and stale/lost update behavior.
|
||||
|
||||
## Error Handling in API Routes
|
||||
- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes
|
||||
- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc.
|
||||
- For internal/unexpected errors (like missing configuration, database errors, etc.), throw `InternalError` from `@autumn/shared`
|
||||
- The onError middleware automatically converts these errors to appropriate HTTP responses
|
||||
- Examples:
|
||||
```typescript
|
||||
// ❌ BAD - Don't do this
|
||||
if (!org) {
|
||||
return c.json({ message: "Org not found", code: "not_found" }, 404);
|
||||
}
|
||||
## Legacy exception (review-required)
|
||||
|
||||
// ✅ GOOD - Validation/expected errors use RecaseError
|
||||
if (!org) {
|
||||
throw new RecaseError({
|
||||
message: "Org not found",
|
||||
code: ErrCode.NotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
There is a legacy-compatibility exception in the adjust-balance flow:
|
||||
|
||||
// ✅ GOOD - Internal/unexpected errors use InternalError
|
||||
if (!upstash) {
|
||||
throw new InternalError({
|
||||
message: "Upstash not configured",
|
||||
code: "upstash_not_configured",
|
||||
});
|
||||
}
|
||||
```
|
||||
- `adjustBalanceDbAndCache` currently uses `CusEntService.increment/decrement`, which increments `cache_version`.
|
||||
- Treat this as a reviewable legacy action, not a pattern to copy into new runtime/cache patch paths.
|
||||
- Any new or refactored runtime balance/reset/cache patch code should continue following this policy and avoid adding new cache-version bumps by default.
|
||||
|
||||
## Bad example
|
||||
/ root
|
||||
-> components
|
||||
|-> hooks
|
||||
## Good example
|
||||
/ root
|
||||
-> components
|
||||
-> hooks
|
||||
## Project Context System
|
||||
|
||||
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
|
||||
Projects maintain state in `.context/<project>/` folders across sessions. Tasks are optional parallel workstreams within a project.
|
||||
|
||||
- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why <package name>` which will tell you why a certain package was installed and by who.
|
||||
### Reading context (session start)
|
||||
When the user mentions a project or task name and `.context/<name>/` exists:
|
||||
1. Read project STATUS.md first (20-30 line "resume card")
|
||||
2. If the project has `tasks/`, list active tasks
|
||||
3. If the user mentions a specific task, read `tasks/<task>/STATUS.md`
|
||||
4. Read the most recent session summary if more detail is needed
|
||||
5. Do NOT read everything upfront. Use progressive disclosure.
|
||||
|
||||
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
|
||||
### Updating context (at breakpoints, NOT continuously)
|
||||
Update at these moments ONLY:
|
||||
- Phase or milestone completed
|
||||
- Architectural decision made (append to DECISIONS.md)
|
||||
- User says they're done or switching tasks
|
||||
- Blocker discovered or resolved
|
||||
- Task created, completed, or handed off
|
||||
|
||||
Do NOT update context during normal coding work. Work first, compact at breakpoints.
|
||||
|
||||
# Figma MCP guidance
|
||||
- When you are using the Figma MCP server, you **must** follow our design system. Below is an example implementation of CVA with out design system
|
||||
### Compaction quality
|
||||
STATUS.md must be:
|
||||
- Correct (reflects actual current state, not stale)
|
||||
- Complete (no critical information missing)
|
||||
- Concise (project < 30 lines, task < 20 lines)
|
||||
|
||||
## File Naming
|
||||
DON'T name files one word (like index.ts, model.ts, etc.). Give proper indication in the filename to which resource it's targeting. For example, a utility file for organizations should be named orgUtils.ts. This is because it's easier to search for files like this. That being said, the filename shouldn't be overly long (less than three words is ideal)
|
||||
Always REWRITE STATUS.md completely rather than append.
|
||||
|
||||
# Vite
|
||||
## Components
|
||||
- Always use v2 components from `@/components/v2/` (buttons, inputs, dialogs, sheets, selects, etc.) for new features. Old components in `@/components/ui/` are deprecated.
|
||||
### File structure
|
||||
```
|
||||
.context/<project>/
|
||||
STATUS.md -- project resume card (includes Active Tasks section)
|
||||
PLAN.md -- phases and architecture
|
||||
DECISIONS.md -- append-only decision log
|
||||
sessions/ -- dated session summaries
|
||||
tasks/ -- optional parallel workstreams
|
||||
<task>/
|
||||
STATUS.md -- task resume card
|
||||
DECISIONS.md
|
||||
```
|
||||
|
||||
## Sheets
|
||||
- Use `Sheet.tsx` for overlay sheets (modal-style with backdrop). Use `SheetHeader`, `SheetFooter`, `SheetSection` from `SharedSheetComponents.tsx` for consistent styling.
|
||||
- `InlineSheet.tsx` provides `SheetContainer` for inline sheets (embedded in page layout). It re-exports shared components for backwards compatibility.
|
||||
- Both sheet types support the same header/footer/section components, ensuring consistent UI patterns across overlay and inline implementations.
|
||||
|
||||
## Styling
|
||||
- DO NOT hardcode styles when possible. Always try to reuse existing Tailwind classes or component patterns from similar components in the codebase.
|
||||
- When adding interactive elements (hover, focus, active states), look for existing patterns in similar components and reuse those class combinations.
|
||||
- Consistency is key - if a pattern exists, use it rather than creating a new one.
|
||||
|
||||
## Form Elements
|
||||
- When creating form input elements (inputs, selects, textareas, etc.) in the vite folder, ALWAYS read `vite/FORM_DESIGN_GUIDELINES.md` first to understand the atomic CSS class system.
|
||||
### Task handoff
|
||||
When a task is being handed to another agent, ensure the task's STATUS.md is up to date -- it's the handoff artifact.
|
||||
|
||||
1
ai
Submodule
1
ai
Submodule
Submodule ai added at 69df20acac
73
server/experiments/explainEntityAggregate.ts
Normal file
73
server/experiments/explainEntityAggregate.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
initDrizzle,
|
||||
prodTestCustomerId,
|
||||
prodTestOrgId,
|
||||
} from "./experimentEnv";
|
||||
|
||||
const { getEntityAggregateForSync } = await import(
|
||||
"../src/internal/customers/repos/getFullSubject/getEntityAggregateForSync"
|
||||
);
|
||||
|
||||
// Run with `bun run experiments/explainEntityAggregate.ts`
|
||||
|
||||
const main = async () => {
|
||||
const orgId = prodTestOrgId;
|
||||
const env = AppEnv.Live;
|
||||
const customerId = prodTestCustomerId;
|
||||
|
||||
const { db } = initDrizzle();
|
||||
|
||||
console.log("--- Running entity aggregate query ---");
|
||||
const start = performance.now();
|
||||
const result = await getEntityAggregateForSync({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
});
|
||||
const elapsed = performance.now() - start;
|
||||
console.log(`Rows returned: ${result.length}`);
|
||||
console.log(`Wall-clock time: ${elapsed.toFixed(2)}ms`);
|
||||
console.log("Result:", JSON.stringify(result, null, 2));
|
||||
console.log();
|
||||
|
||||
// Build the same query inline for EXPLAIN ANALYZE
|
||||
const { getEntityAggregateFragments } = await import(
|
||||
"../src/internal/customers/repos/getFullSubject/getEntityAggregateFragments"
|
||||
);
|
||||
|
||||
const statusFilter = sql`AND cp.status = ANY(ARRAY['active', 'past_due', 'scheduled'])`;
|
||||
const entityFragments = getEntityAggregateFragments({ statusFilter });
|
||||
|
||||
const query = sql`
|
||||
WITH subject_customer_records AS (
|
||||
SELECT *
|
||||
FROM customers c
|
||||
WHERE c.org_id = ${orgId}
|
||||
AND c.env = ${env}
|
||||
AND (c.id = ${customerId} OR c.internal_id = ${customerId})
|
||||
ORDER BY (c.id = ${customerId}) DESC
|
||||
LIMIT 1
|
||||
)
|
||||
|
||||
${entityFragments.ctes}
|
||||
|
||||
SELECT *
|
||||
FROM entity_aggregated_cus_entitlements
|
||||
`;
|
||||
|
||||
console.log("--- EXPLAIN (ANALYZE, BUFFERS) ---\n");
|
||||
const explainQuery = sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`;
|
||||
const explainResult = await db.execute(explainQuery);
|
||||
|
||||
for (const row of explainResult) {
|
||||
const line = (row as Record<string, unknown>)["QUERY PLAN"];
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
await main();
|
||||
@@ -267,9 +267,11 @@ const generateNormalized = (): NormalizedFullSubject => {
|
||||
},
|
||||
},
|
||||
rollovers: [] as any,
|
||||
replaceables: [] as any,
|
||||
customerPrice: null as any,
|
||||
customerProductOptions: [] as any,
|
||||
customerProductQuantity: customerProduct.quantity,
|
||||
isEntityLevel: false,
|
||||
} as SubjectBalance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ const buildSubjectBalance = ({ index }: { index: number }) => ({
|
||||
customerPrice: null,
|
||||
customerProductOptions: null,
|
||||
customerProductQuantity: 1,
|
||||
isEntityLevel: false,
|
||||
});
|
||||
|
||||
const buildCachedFullSubject = (): CachedFullSubject => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
--[[
|
||||
Lua Script: Adjust Subject Balance in V2 Cache (single entitlement)
|
||||
|
||||
Atomically increments/decrements one SubjectBalance.balance entry in a single
|
||||
per-feature hash. The hash field is cusEntId -> JSON(SubjectBalance).
|
||||
|
||||
Uses shared helper composition from:
|
||||
- luaUtils.lua (safe_number, safe_decode, is_nil)
|
||||
- updateContextUtils.lua (init_update_context)
|
||||
- updateAggregatedBalances.lua (entity-level aggregate propagation)
|
||||
|
||||
KEYS[1] = balance hash key
|
||||
e.g. {customerId}:orgId:env:full_subject:shared_balances:{featureId}
|
||||
|
||||
ARGV[1] = JSON params:
|
||||
{
|
||||
cus_ent_id: string,
|
||||
delta: number,
|
||||
ttl_seconds: number | null
|
||||
}
|
||||
|
||||
Returns JSON:
|
||||
{ ok: true, new_balance: number, new_cache_version: number } |
|
||||
{ ok: false, error: string }
|
||||
]]
|
||||
|
||||
local balance_key = KEYS[1]
|
||||
local params = cjson.decode(ARGV[1] or "{}")
|
||||
|
||||
local customer_entitlement_id = params.cus_ent_id
|
||||
local delta = tonumber(params.delta)
|
||||
local ttl_seconds = tonumber(params.ttl_seconds)
|
||||
|
||||
if not customer_entitlement_id or delta == nil then
|
||||
return cjson.encode({ ok = false, error = "missing cus_ent_id or delta" })
|
||||
end
|
||||
|
||||
if redis.call("EXISTS", balance_key) == 0 then
|
||||
return cjson.encode({ ok = false, error = "cache_miss" })
|
||||
end
|
||||
|
||||
local context = init_update_context({
|
||||
balance_key = balance_key,
|
||||
updates = {
|
||||
{ cus_ent_id = customer_entitlement_id }
|
||||
},
|
||||
})
|
||||
|
||||
local entitlement_data = context.customer_entitlements[customer_entitlement_id]
|
||||
if not entitlement_data then
|
||||
return cjson.encode({ ok = false, error = "cus_ent_not_found" })
|
||||
end
|
||||
|
||||
local subject_balance = entitlement_data.subject_balance
|
||||
local previous_balance = safe_number(subject_balance.balance)
|
||||
local next_balance = previous_balance + delta
|
||||
subject_balance.balance = next_balance
|
||||
|
||||
-- Legacy-compatibility exception:
|
||||
-- Keep FullSubject cache_version aligned with DB increment/decrement flows
|
||||
-- used by adjustBalanceDbAndCache. This is reviewable behavior, not a
|
||||
-- general pattern for all runtime cache patch scripts.
|
||||
local previous_cache_version = safe_number(subject_balance.cache_version)
|
||||
subject_balance.cache_version = previous_cache_version + 1
|
||||
|
||||
if subject_balance.isEntityLevel then
|
||||
local entity_id = subject_balance.internal_entity_id
|
||||
if is_nil(entity_id) then entity_id = cjson.null end
|
||||
|
||||
table.insert(context.mutation_logs, {
|
||||
target_type = "customer_entitlement",
|
||||
customer_entitlement_id = customer_entitlement_id,
|
||||
entity_id = entity_id,
|
||||
balance_delta = delta,
|
||||
adjustment_delta = 0,
|
||||
})
|
||||
end
|
||||
|
||||
redis.call("HSET", balance_key, customer_entitlement_id, cjson.encode(subject_balance))
|
||||
|
||||
update_aggregated_balances({
|
||||
context = context,
|
||||
mutation_logs = context.mutation_logs,
|
||||
})
|
||||
|
||||
if ttl_seconds and ttl_seconds > 0 then
|
||||
redis.call("EXPIRE", balance_key, ttl_seconds)
|
||||
end
|
||||
|
||||
return cjson.encode({
|
||||
ok = true,
|
||||
new_balance = next_balance,
|
||||
new_cache_version = subject_balance.cache_version
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
--[[
|
||||
Release a FullSubject write reservation if the token matches.
|
||||
|
||||
KEYS:
|
||||
[1] reserveKey
|
||||
|
||||
ARGV:
|
||||
[1] token
|
||||
|
||||
Returns:
|
||||
"RELEASED" = reservation matched and was deleted
|
||||
"SKIPPED" = key missing or token mismatch
|
||||
]]
|
||||
|
||||
local reserveKey = KEYS[1]
|
||||
local token = ARGV[1]
|
||||
|
||||
local existingToken = redis.call("GET", reserveKey)
|
||||
if existingToken ~= token then
|
||||
return "SKIPPED"
|
||||
end
|
||||
|
||||
redis.call("DEL", reserveKey)
|
||||
return "RELEASED"
|
||||
@@ -1,50 +0,0 @@
|
||||
--[[
|
||||
Reserve a FullSubject write so only one non-overwrite writer proceeds.
|
||||
|
||||
KEYS:
|
||||
[1] subjectKey
|
||||
[2] reserveKey
|
||||
[3] guardKey
|
||||
|
||||
ARGV:
|
||||
[1] token
|
||||
[2] reserveTtl
|
||||
[3] overwrite - "true" to bypass reservation, "false" to reserve if missing
|
||||
[4] fetchTimeMs
|
||||
|
||||
Returns:
|
||||
"RESERVED" = caller may proceed with the write
|
||||
"CACHE_EXISTS" = subject already exists or another writer already reserved it
|
||||
"STALE_WRITE" = guard exists with newer timestamp than this write
|
||||
]]
|
||||
|
||||
local subjectKey = KEYS[1]
|
||||
local reserveKey = KEYS[2]
|
||||
local guardKey = KEYS[3]
|
||||
local token = ARGV[1]
|
||||
local reserveTtl = tonumber(ARGV[2])
|
||||
local overwrite = ARGV[3] == "true"
|
||||
local fetchTimeMs = tonumber(ARGV[4])
|
||||
|
||||
if overwrite then
|
||||
return "RESERVED"
|
||||
end
|
||||
|
||||
local guardTime = redis.call("GET", guardKey)
|
||||
if guardTime and guardTime ~= cjson.null and fetchTimeMs then
|
||||
local guardTimeNum = tonumber(guardTime)
|
||||
if guardTimeNum and guardTimeNum > fetchTimeMs then
|
||||
return "STALE_WRITE"
|
||||
end
|
||||
end
|
||||
|
||||
if redis.call("EXISTS", subjectKey) == 1 then
|
||||
return "CACHE_EXISTS"
|
||||
end
|
||||
|
||||
local reserved = redis.call("SET", reserveKey, token, "EX", reserveTtl, "NX")
|
||||
if not reserved then
|
||||
return "CACHE_EXISTS"
|
||||
end
|
||||
|
||||
return "RESERVED"
|
||||
@@ -0,0 +1,59 @@
|
||||
--[[
|
||||
Atomically set a FullSubject cache: subject view + all balance hashes.
|
||||
|
||||
Guarantees no partial-write window: either everything is written or nothing.
|
||||
|
||||
KEYS[1] = subjectKey (existence check + subject view write)
|
||||
KEYS[2] = epochKey (staleness check)
|
||||
KEYS[3..N] = balance hash keys (one per metered feature)
|
||||
|
||||
ARGV[1] = expected epoch value
|
||||
ARGV[2] = TTL seconds (applies to subject key and all balance keys)
|
||||
ARGV[3] = subject view JSON string
|
||||
ARGV[4] = number of balance keys (N - 2)
|
||||
ARGV[5..M] = for each balance key: field_count, then field_count pairs of (field_name, field_value_json)
|
||||
|
||||
Returns:
|
||||
"OK" = all keys written
|
||||
"CACHE_EXISTS" = subject key already exists, nothing written
|
||||
"STALE_WRITE" = epoch mismatch, nothing written
|
||||
]]
|
||||
|
||||
local subject_key = KEYS[1]
|
||||
local epoch_key = KEYS[2]
|
||||
local expected_epoch = ARGV[1]
|
||||
local ttl = tonumber(ARGV[2])
|
||||
local subject_view_json = ARGV[3]
|
||||
local num_balance_keys = tonumber(ARGV[4])
|
||||
|
||||
if redis.call('EXISTS', subject_key) == 1 then
|
||||
return 'CACHE_EXISTS'
|
||||
end
|
||||
|
||||
local current_epoch = redis.call('GET', epoch_key)
|
||||
if current_epoch ~= false and current_epoch ~= expected_epoch then
|
||||
return 'STALE_WRITE'
|
||||
end
|
||||
|
||||
local argv_index = 5
|
||||
|
||||
for i = 1, num_balance_keys do
|
||||
local balance_key = KEYS[2 + i]
|
||||
local field_count = tonumber(ARGV[argv_index])
|
||||
argv_index = argv_index + 1
|
||||
|
||||
if field_count > 0 then
|
||||
for j = 1, field_count do
|
||||
local field_name = ARGV[argv_index]
|
||||
local field_value = ARGV[argv_index + 1]
|
||||
redis.call('HSETNX', balance_key, field_name, field_value)
|
||||
argv_index = argv_index + 2
|
||||
end
|
||||
end
|
||||
|
||||
redis.call('EXPIRE', balance_key, ttl)
|
||||
end
|
||||
|
||||
redis.call('SET', subject_key, subject_view_json, 'EX', ttl)
|
||||
|
||||
return 'OK'
|
||||
106
server/src/_luaScriptsV2/fullSubject/updateCachedInvoice.lua
Normal file
106
server/src/_luaScriptsV2/fullSubject/updateCachedInvoice.lua
Normal file
@@ -0,0 +1,106 @@
|
||||
--[[
|
||||
Lua Script: Upsert Invoice in FullSubject V2 cache
|
||||
|
||||
Atomically upserts an invoice in the cached FullSubject invoices array:
|
||||
- If invoice with same stripe_id exists: replace it
|
||||
- Otherwise: append
|
||||
|
||||
KEYS[1] = FullSubject cache key
|
||||
|
||||
ARGV[1] = JSON-encoded invoice object (must have stripe_id for match/replace)
|
||||
ARGV[2] = cache TTL in seconds
|
||||
ARGV[3] = current timestamp in ms (reserved for consistency)
|
||||
|
||||
Returns JSON:
|
||||
{ "success": true, "action": "appended" }
|
||||
{ "success": true, "action": "updated" }
|
||||
{ "success": false, "cache_miss": true }
|
||||
]]
|
||||
|
||||
local subject_key = KEYS[1]
|
||||
local invoice_json = ARGV[1]
|
||||
local cache_ttl = tonumber(ARGV[2])
|
||||
|
||||
local current_raw = redis.call("GET", subject_key)
|
||||
if not current_raw then
|
||||
return cjson.encode({ success = false, cache_miss = true })
|
||||
end
|
||||
|
||||
local cached = cjson.decode(current_raw)
|
||||
local invoice = cjson.decode(invoice_json)
|
||||
local stripe_id = invoice.stripe_id
|
||||
|
||||
local invoices = cached.invoices
|
||||
if type(invoices) ~= "table" then
|
||||
invoices = {}
|
||||
end
|
||||
|
||||
local function invoice_created_at(invoice_row)
|
||||
if type(invoice_row) ~= "table" then
|
||||
return 0
|
||||
end
|
||||
|
||||
local created_at = tonumber(invoice_row.created_at)
|
||||
if created_at == nil then
|
||||
return 0
|
||||
end
|
||||
|
||||
return created_at
|
||||
end
|
||||
|
||||
local function invoice_id(invoice_row)
|
||||
if type(invoice_row) ~= "table" then
|
||||
return ""
|
||||
end
|
||||
|
||||
if type(invoice_row.id) == "string" then
|
||||
return invoice_row.id
|
||||
end
|
||||
|
||||
return ""
|
||||
end
|
||||
|
||||
local did_update = false
|
||||
|
||||
if stripe_id then
|
||||
for index, existing_invoice in ipairs(invoices) do
|
||||
if type(existing_invoice) == "table" and existing_invoice.stripe_id == stripe_id then
|
||||
invoices[index] = invoice
|
||||
did_update = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not did_update then
|
||||
table.insert(invoices, invoice)
|
||||
end
|
||||
|
||||
-- Keep cached invoice ordering aligned with DB query ordering:
|
||||
-- created_at DESC, id DESC.
|
||||
table.sort(invoices, function(a, b)
|
||||
local a_created_at = invoice_created_at(a)
|
||||
local b_created_at = invoice_created_at(b)
|
||||
|
||||
if a_created_at ~= b_created_at then
|
||||
return a_created_at > b_created_at
|
||||
end
|
||||
|
||||
local a_id = invoice_id(a)
|
||||
local b_id = invoice_id(b)
|
||||
return a_id > b_id
|
||||
end)
|
||||
|
||||
while #invoices > 10 do
|
||||
table.remove(invoices)
|
||||
end
|
||||
|
||||
cached.invoices = invoices
|
||||
|
||||
redis.call("SET", subject_key, cjson.encode(cached), "EX", cache_ttl)
|
||||
|
||||
if did_update then
|
||||
return cjson.encode({ success = true, action = "updated" })
|
||||
end
|
||||
|
||||
return cjson.encode({ success = true, action = "appended" })
|
||||
@@ -0,0 +1,6 @@
|
||||
local function update_customer_product_options(params)
|
||||
local customer_product = params.customer_product
|
||||
local options = params.options
|
||||
|
||||
customer_product.options = options
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
--[[
|
||||
Lua Script: Update Customer Product in FullSubject V2 cache
|
||||
|
||||
Atomically updates specific fields on a customer product inside a cached
|
||||
FullSubject payload stored as a plain Redis string.
|
||||
|
||||
KEYS[1] = full subject key
|
||||
|
||||
ARGV[1] = JSON params:
|
||||
{
|
||||
cus_product_id: string,
|
||||
updates: { field: value, ... }
|
||||
}
|
||||
ARGV[2] = cache TTL in seconds
|
||||
ARGV[3] = current timestamp in ms (reserved for consistency)
|
||||
|
||||
Returns JSON:
|
||||
{ "success": true, "updated_fields": ["options", "status"] }
|
||||
{ "success": false, "cache_miss": true }
|
||||
{ "success": false, "cus_product_not_found": true }
|
||||
]]
|
||||
|
||||
local subject_key = KEYS[1]
|
||||
local request_params = cjson.decode(ARGV[1])
|
||||
local cache_ttl = tonumber(ARGV[2])
|
||||
|
||||
local customer_product_id = request_params.cus_product_id
|
||||
local updates = request_params.updates
|
||||
|
||||
if not customer_product_id then
|
||||
return cjson.encode({ success = false, error = "missing_cus_product_id" })
|
||||
end
|
||||
|
||||
if not updates then
|
||||
return cjson.encode({ success = false, error = "missing_updates" })
|
||||
end
|
||||
|
||||
local has_updates = false
|
||||
for _ in pairs(updates) do
|
||||
has_updates = true
|
||||
break
|
||||
end
|
||||
|
||||
if not has_updates then
|
||||
return cjson.encode({ success = true, updated_fields = {} })
|
||||
end
|
||||
|
||||
local current_raw = redis.call("GET", subject_key)
|
||||
if not current_raw then
|
||||
return cjson.encode({ success = false, cache_miss = true })
|
||||
end
|
||||
|
||||
local cached_subject = cjson.decode(current_raw)
|
||||
local customer_products = cached_subject.customer_products
|
||||
|
||||
if type(customer_products) ~= "table" then
|
||||
return cjson.encode({ success = false, cus_product_not_found = true })
|
||||
end
|
||||
|
||||
local target_index = nil
|
||||
for index, customer_product in ipairs(customer_products) do
|
||||
if customer_product.id == customer_product_id then
|
||||
target_index = index
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not target_index then
|
||||
return cjson.encode({ success = false, cus_product_not_found = true })
|
||||
end
|
||||
|
||||
local target_customer_product = customer_products[target_index]
|
||||
local updated_fields = {}
|
||||
|
||||
for field_name, field_value in pairs(updates) do
|
||||
if field_name == "options" then
|
||||
update_customer_product_options({
|
||||
customer_product = target_customer_product,
|
||||
options = field_value,
|
||||
})
|
||||
else
|
||||
target_customer_product[field_name] = field_value
|
||||
end
|
||||
|
||||
table.insert(updated_fields, field_name)
|
||||
end
|
||||
|
||||
customer_products[target_index] = target_customer_product
|
||||
cached_subject.customer_products = customer_products
|
||||
|
||||
redis.call("SET", subject_key, cjson.encode(cached_subject), "EX", cache_ttl)
|
||||
|
||||
return cjson.encode({
|
||||
success = true,
|
||||
updated_fields = updated_fields,
|
||||
})
|
||||
@@ -1,185 +0,0 @@
|
||||
--[[
|
||||
Lua Script: Update Subject Balances in V2 Cache (per-feature hash)
|
||||
|
||||
Atomically updates SubjectBalance entries in a single per-feature
|
||||
balance hash. Each hash field is cusEntId → JSON(SubjectBalance).
|
||||
|
||||
Supports: scalar field updates, rollover operations (insert/overwrite/delete),
|
||||
replaceable operations (insert/delete), and an expected_next_reset_at guard.
|
||||
|
||||
Helper functions prepended via string interpolation from:
|
||||
- luaUtils.lua (safe_number, is_nil, safe_table)
|
||||
|
||||
KEYS[1] = balance hash key
|
||||
e.g. {customerId}:orgId:env:full_subject:shared_balances:{featureId}
|
||||
|
||||
ARGV[1] = JSON params:
|
||||
{
|
||||
ttl_seconds: number,
|
||||
updates: [{
|
||||
cus_ent_id: string,
|
||||
balance: number | null,
|
||||
additional_balance: number | null,
|
||||
adjustment: number | null,
|
||||
entities: object | null,
|
||||
next_reset_at: number | null,
|
||||
expected_next_reset_at: number | null,
|
||||
rollover_insert: { id, cus_ent_id, balance, usage, expires_at, entities } | null,
|
||||
rollover_overwrites: [{ id, balance, usage, entities }] | null,
|
||||
rollover_delete_ids: string[] | null,
|
||||
new_replaceables: Replaceable[] | null,
|
||||
deleted_replaceable_ids: string[] | null,
|
||||
}]
|
||||
}
|
||||
|
||||
Returns JSON:
|
||||
{ "applied": { "<cus_ent_id>": true }, "skipped": ["id1"] }
|
||||
]]
|
||||
|
||||
local balance_key = KEYS[1]
|
||||
local params = cjson.decode(ARGV[1])
|
||||
local updates = params.updates or {}
|
||||
local ttl_seconds = params.ttl_seconds
|
||||
|
||||
if #updates == 0 then
|
||||
return cjson.encode({ applied = {}, skipped = {} })
|
||||
end
|
||||
|
||||
local applied = {}
|
||||
local skipped = {}
|
||||
|
||||
for _, update in ipairs(updates) do
|
||||
local cus_ent_id = update.cus_ent_id
|
||||
|
||||
-- Read the SubjectBalance from the hash field
|
||||
local raw = redis.call('HGET', balance_key, cus_ent_id)
|
||||
if not raw then
|
||||
table.insert(skipped, cus_ent_id)
|
||||
else
|
||||
local subject_balance = cjson.decode(raw)
|
||||
|
||||
-- Optimistic guard: skip if expected_next_reset_at doesn't match
|
||||
local should_skip = false
|
||||
if not is_nil(update.expected_next_reset_at) then
|
||||
local current_reset_at = safe_number(subject_balance.next_reset_at)
|
||||
if current_reset_at ~= update.expected_next_reset_at then
|
||||
should_skip = true
|
||||
end
|
||||
end
|
||||
|
||||
if should_skip then
|
||||
table.insert(skipped, cus_ent_id)
|
||||
else
|
||||
-- ================================================================
|
||||
-- Apply scalar field updates
|
||||
-- ================================================================
|
||||
if not is_nil(update.balance) then
|
||||
subject_balance.balance = update.balance
|
||||
end
|
||||
|
||||
if not is_nil(update.additional_balance) then
|
||||
subject_balance.additional_balance = update.additional_balance
|
||||
end
|
||||
|
||||
if not is_nil(update.adjustment) then
|
||||
subject_balance.adjustment = update.adjustment
|
||||
end
|
||||
|
||||
if not is_nil(update.entities) then
|
||||
subject_balance.entities = update.entities
|
||||
end
|
||||
|
||||
if not is_nil(update.next_reset_at) then
|
||||
subject_balance.next_reset_at = update.next_reset_at
|
||||
end
|
||||
|
||||
-- ================================================================
|
||||
-- Rollover operations
|
||||
-- ================================================================
|
||||
|
||||
-- Ensure rollovers array exists
|
||||
subject_balance.rollovers = safe_table(subject_balance.rollovers)
|
||||
|
||||
-- APPEND a new rollover
|
||||
if not is_nil(update.rollover_insert) then
|
||||
table.insert(subject_balance.rollovers, update.rollover_insert)
|
||||
end
|
||||
|
||||
-- OVERWRITE existing rollovers by ID
|
||||
if not is_nil(update.rollover_overwrites) then
|
||||
local overwrite_map = {}
|
||||
for _, ow in ipairs(update.rollover_overwrites) do
|
||||
overwrite_map[ow.id] = ow
|
||||
end
|
||||
|
||||
for i, rollover in ipairs(subject_balance.rollovers) do
|
||||
local ow = overwrite_map[rollover.id]
|
||||
if ow then
|
||||
subject_balance.rollovers[i].balance = ow.balance
|
||||
subject_balance.rollovers[i].usage = ow.usage
|
||||
if not is_nil(ow.entities) then
|
||||
subject_balance.rollovers[i].entities = ow.entities
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- DELETE rollovers by ID
|
||||
if not is_nil(update.rollover_delete_ids) then
|
||||
local delete_set = {}
|
||||
for _, del_id in ipairs(update.rollover_delete_ids) do
|
||||
delete_set[del_id] = true
|
||||
end
|
||||
|
||||
local new_rollovers = {}
|
||||
for _, rollover in ipairs(subject_balance.rollovers) do
|
||||
if not delete_set[rollover.id] then
|
||||
table.insert(new_rollovers, rollover)
|
||||
end
|
||||
end
|
||||
subject_balance.rollovers = new_rollovers
|
||||
end
|
||||
|
||||
-- ================================================================
|
||||
-- Replaceable operations
|
||||
-- ================================================================
|
||||
|
||||
-- APPEND new replaceables
|
||||
if not is_nil(update.new_replaceables) then
|
||||
subject_balance.replaceables = safe_table(subject_balance.replaceables)
|
||||
for _, replaceable in ipairs(update.new_replaceables) do
|
||||
table.insert(subject_balance.replaceables, replaceable)
|
||||
end
|
||||
end
|
||||
|
||||
-- DELETE replaceables by ID
|
||||
if not is_nil(update.deleted_replaceable_ids) then
|
||||
if not is_nil(subject_balance.replaceables) then
|
||||
local delete_set = {}
|
||||
for _, del_id in ipairs(update.deleted_replaceable_ids) do
|
||||
delete_set[del_id] = true
|
||||
end
|
||||
|
||||
local new_replaceables = {}
|
||||
for _, replaceable in ipairs(subject_balance.replaceables) do
|
||||
if not delete_set[replaceable.id] then
|
||||
table.insert(new_replaceables, replaceable)
|
||||
end
|
||||
end
|
||||
subject_balance.replaceables = new_replaceables
|
||||
end
|
||||
end
|
||||
|
||||
-- Write back the updated SubjectBalance
|
||||
redis.call('HSET', balance_key, cus_ent_id, cjson.encode(subject_balance))
|
||||
applied[cus_ent_id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Refresh TTL on the hash key
|
||||
if ttl_seconds and ttl_seconds > 0 then
|
||||
redis.call('EXPIRE', balance_key, ttl_seconds)
|
||||
end
|
||||
|
||||
return cjson.encode({ applied = applied, skipped = skipped })
|
||||
@@ -0,0 +1,216 @@
|
||||
-- ============================================================================
|
||||
-- APPLY FIELD UPDATES
|
||||
-- Per-field helpers that apply updates to a SubjectBalance in place.
|
||||
-- Entity-level updates append mutation logs for aggregation propagation.
|
||||
-- ============================================================================
|
||||
|
||||
local function apply_balance_and_adjustment_update(params)
|
||||
local subject_balance = params.subject_balance
|
||||
local update = params.update
|
||||
local context = params.context
|
||||
local cus_ent_id = params.cus_ent_id
|
||||
|
||||
local old_balance = safe_number(subject_balance.balance)
|
||||
local old_adjustment = safe_number(subject_balance.adjustment)
|
||||
|
||||
if not is_absent(update.balance) then
|
||||
subject_balance.balance = update.balance
|
||||
end
|
||||
if not is_absent(update.additional_balance) then
|
||||
subject_balance.additional_balance = update.additional_balance
|
||||
end
|
||||
if not is_absent(update.adjustment) then
|
||||
subject_balance.adjustment = update.adjustment
|
||||
end
|
||||
|
||||
if subject_balance.isEntityLevel then
|
||||
local bal_delta = safe_number(subject_balance.balance) - old_balance
|
||||
local adj_delta = safe_number(subject_balance.adjustment) - old_adjustment
|
||||
|
||||
if bal_delta ~= 0 or adj_delta ~= 0 then
|
||||
local entity_id = subject_balance.internal_entity_id
|
||||
if is_absent(entity_id) then entity_id = cjson.null end
|
||||
|
||||
table.insert(context.mutation_logs, {
|
||||
target_type = 'customer_entitlement',
|
||||
customer_entitlement_id = cus_ent_id,
|
||||
entity_id = entity_id,
|
||||
balance_delta = bal_delta,
|
||||
adjustment_delta = adj_delta,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function apply_entities_update(params)
|
||||
local subject_balance = params.subject_balance
|
||||
local update = params.update
|
||||
local context = params.context
|
||||
local cus_ent_id = params.cus_ent_id
|
||||
|
||||
if is_absent(update.entities) then return end
|
||||
|
||||
if not subject_balance.isEntityLevel then
|
||||
subject_balance.entities = update.entities
|
||||
return
|
||||
end
|
||||
|
||||
local old_entities = safe_table(subject_balance.entities)
|
||||
|
||||
subject_balance.entities = update.entities
|
||||
|
||||
local new_entities = safe_table(subject_balance.entities)
|
||||
|
||||
local all_entity_ids = {}
|
||||
for eid, _ in pairs(old_entities) do all_entity_ids[eid] = true end
|
||||
for eid, _ in pairs(new_entities) do all_entity_ids[eid] = true end
|
||||
|
||||
for eid, _ in pairs(all_entity_ids) do
|
||||
local old_bal = old_entities[eid] and safe_number(old_entities[eid].balance) or 0
|
||||
local old_adj = old_entities[eid] and safe_number(old_entities[eid].adjustment) or 0
|
||||
local new_bal = new_entities[eid] and safe_number(new_entities[eid].balance) or 0
|
||||
local new_adj = new_entities[eid] and safe_number(new_entities[eid].adjustment) or 0
|
||||
|
||||
local bal_delta = new_bal - old_bal
|
||||
local adj_delta = new_adj - old_adj
|
||||
|
||||
if bal_delta ~= 0 or adj_delta ~= 0 then
|
||||
table.insert(context.mutation_logs, {
|
||||
target_type = 'customer_entitlement',
|
||||
customer_entitlement_id = cus_ent_id,
|
||||
entity_id = eid,
|
||||
balance_delta = bal_delta,
|
||||
adjustment_delta = adj_delta,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function apply_next_reset_at_update(params)
|
||||
local subject_balance = params.subject_balance
|
||||
local update = params.update
|
||||
|
||||
if not is_absent(update.next_reset_at) then
|
||||
subject_balance.next_reset_at = update.next_reset_at
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Emits rollover mutation logs for one rollover row with a signed multiplier.
|
||||
sign = +1 for insert, -1 for delete. Overwrites = delete(old) + insert(new).
|
||||
Per-entity rollovers emit one log per entity; top-level rollovers fall back
|
||||
to subject_balance.internal_entity_id. Gated on subject_balance.isEntityLevel.
|
||||
]]
|
||||
local function emit_rollover_logs(context, sb, cus_ent_id, rollover, sign)
|
||||
if not sb.isEntityLevel or type(rollover) ~= 'table' then return end
|
||||
|
||||
local function push(entity_id, bal, use)
|
||||
local bd = sign * safe_number(bal)
|
||||
local ud = sign * safe_number(use)
|
||||
if bd == 0 and ud == 0 then return end
|
||||
table.insert(context.mutation_logs, {
|
||||
target_type = 'rollover',
|
||||
customer_entitlement_id = cus_ent_id,
|
||||
entity_id = is_absent(entity_id) and cjson.null or entity_id,
|
||||
balance_delta = bd,
|
||||
usage_delta = ud,
|
||||
})
|
||||
end
|
||||
|
||||
local entities = safe_table(rollover.entities)
|
||||
if next(entities) == nil then
|
||||
push(sb.internal_entity_id, rollover.balance, rollover.usage)
|
||||
return
|
||||
end
|
||||
|
||||
for eid, entry in pairs(entities) do
|
||||
if type(entry) == 'table' then push(eid, entry.balance, entry.usage) end
|
||||
end
|
||||
end
|
||||
|
||||
local function apply_rollover_updates(params)
|
||||
local subject_balance = params.subject_balance
|
||||
local update = params.update
|
||||
local context = params.context
|
||||
local cus_ent_id = params.cus_ent_id
|
||||
|
||||
subject_balance.rollovers = safe_table(subject_balance.rollovers)
|
||||
|
||||
if not is_absent(update.rollover_insert) then
|
||||
emit_rollover_logs(context, subject_balance, cus_ent_id, update.rollover_insert, 1)
|
||||
table.insert(subject_balance.rollovers, update.rollover_insert)
|
||||
end
|
||||
|
||||
if not is_absent(update.rollover_overwrites) then
|
||||
local overwrite_map = {}
|
||||
for _, ow in ipairs(update.rollover_overwrites) do
|
||||
overwrite_map[ow.id] = ow
|
||||
end
|
||||
|
||||
for i, rollover in ipairs(subject_balance.rollovers) do
|
||||
local ow = overwrite_map[rollover.id]
|
||||
if ow then
|
||||
-- Overwrite = delete(old) + insert(new). If ow.entities is absent,
|
||||
-- the new row keeps the existing entities map (matches apply semantics).
|
||||
emit_rollover_logs(context, subject_balance, cus_ent_id, rollover, -1)
|
||||
emit_rollover_logs(context, subject_balance, cus_ent_id, {
|
||||
balance = ow.balance,
|
||||
usage = ow.usage,
|
||||
entities = is_absent(ow.entities) and rollover.entities or ow.entities,
|
||||
}, 1)
|
||||
|
||||
subject_balance.rollovers[i].balance = ow.balance
|
||||
subject_balance.rollovers[i].usage = ow.usage
|
||||
if not is_absent(ow.entities) then
|
||||
subject_balance.rollovers[i].entities = ow.entities
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not is_absent(update.rollover_delete_ids) then
|
||||
local delete_set = {}
|
||||
for _, del_id in ipairs(update.rollover_delete_ids) do
|
||||
delete_set[del_id] = true
|
||||
end
|
||||
|
||||
local new_rollovers = {}
|
||||
for _, rollover in ipairs(subject_balance.rollovers) do
|
||||
if delete_set[rollover.id] then
|
||||
emit_rollover_logs(context, subject_balance, cus_ent_id, rollover, -1)
|
||||
else
|
||||
table.insert(new_rollovers, rollover)
|
||||
end
|
||||
end
|
||||
subject_balance.rollovers = new_rollovers
|
||||
end
|
||||
end
|
||||
|
||||
local function apply_replaceable_updates(params)
|
||||
local subject_balance = params.subject_balance
|
||||
local update = params.update
|
||||
|
||||
if not is_absent(update.new_replaceables) then
|
||||
subject_balance.replaceables = safe_table(subject_balance.replaceables)
|
||||
for _, replaceable in ipairs(update.new_replaceables) do
|
||||
table.insert(subject_balance.replaceables, replaceable)
|
||||
end
|
||||
end
|
||||
|
||||
if not is_absent(update.deleted_replaceable_ids) then
|
||||
if not is_absent(subject_balance.replaceables) then
|
||||
local delete_set = {}
|
||||
for _, del_id in ipairs(update.deleted_replaceable_ids) do
|
||||
delete_set[del_id] = true
|
||||
end
|
||||
|
||||
local new_replaceables = {}
|
||||
for _, replaceable in ipairs(subject_balance.replaceables) do
|
||||
if not delete_set[replaceable.id] then
|
||||
table.insert(new_replaceables, replaceable)
|
||||
end
|
||||
end
|
||||
subject_balance.replaceables = new_replaceables
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ============================================================================
|
||||
-- UPDATE CONTEXT UTILITIES
|
||||
-- Builds a context object for updateSubjectBalances by reading existing
|
||||
-- SubjectBalance entries from the per-feature hash.
|
||||
-- ============================================================================
|
||||
|
||||
local function init_update_context(params)
|
||||
local balance_key = params.balance_key
|
||||
local updates = params.updates
|
||||
|
||||
local logs = {}
|
||||
local logger = {
|
||||
log = function(fmt, ...)
|
||||
table.insert(logs, string.format(fmt, ...))
|
||||
end,
|
||||
}
|
||||
|
||||
local context = {
|
||||
customer_entitlements = {},
|
||||
mutation_logs = {},
|
||||
balance_key = balance_key,
|
||||
logs = logs,
|
||||
logger = logger,
|
||||
}
|
||||
|
||||
local cus_ent_ids = {}
|
||||
for _, update in ipairs(updates) do
|
||||
table.insert(cus_ent_ids, update.cus_ent_id)
|
||||
end
|
||||
|
||||
if #cus_ent_ids > 0 then
|
||||
local raw_values = redis.call('HMGET', balance_key, unpack(cus_ent_ids))
|
||||
|
||||
for i, cus_ent_id in ipairs(cus_ent_ids) do
|
||||
local subject_balance = safe_decode(raw_values[i])
|
||||
if type(subject_balance) == 'table' then
|
||||
context.customer_entitlements[cus_ent_id] = {
|
||||
balance_key = balance_key,
|
||||
subject_balance = subject_balance,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return context
|
||||
end
|
||||
@@ -0,0 +1,132 @@
|
||||
--[[
|
||||
Lua Script: Update Subject Balances in V2 Cache (per-feature hash)
|
||||
|
||||
Atomically updates SubjectBalance entries in a single per-feature
|
||||
balance hash. Each hash field is cusEntId -> JSON(SubjectBalance).
|
||||
|
||||
Supports: scalar field updates, rollover operations (insert/overwrite/delete),
|
||||
replaceable operations (insert/delete), and an expected_next_reset_at guard.
|
||||
This script intentionally does not increment or overwrite cache_version.
|
||||
cache_version is owned by DB-side lifecycle/billing transitions.
|
||||
|
||||
After applying updates, propagates entity-level balance/adjustment deltas
|
||||
to the _aggregated field on the same hash via update_aggregated_balances.
|
||||
|
||||
Helper functions prepended via string interpolation from:
|
||||
- luaUtils.lua (safe_number, is_nil, safe_table)
|
||||
- updateContextUtils.lua (init_update_context)
|
||||
- applyFieldUpdates.lua (per-field update helpers)
|
||||
- updateAggregatedBalances.lua (shared aggregation utility)
|
||||
|
||||
KEYS[1] = balance hash key
|
||||
e.g. {customerId}:orgId:env:full_subject:shared_balances:{featureId}
|
||||
|
||||
ARGV[1] = JSON params:
|
||||
{
|
||||
ttl_seconds: number,
|
||||
updates: [{
|
||||
cus_ent_id: string,
|
||||
balance: number | null,
|
||||
additional_balance: number | null,
|
||||
adjustment: number | null,
|
||||
entities: object | null,
|
||||
next_reset_at: number | null,
|
||||
expected_next_reset_at: number | null,
|
||||
rollover_insert: { id, cus_ent_id, balance, usage, expires_at, entities } | null,
|
||||
rollover_overwrites: [{ id, balance, usage, entities }] | null,
|
||||
rollover_delete_ids: string[] | null,
|
||||
new_replaceables: Replaceable[] | null,
|
||||
deleted_replaceable_ids: string[] | null,
|
||||
}]
|
||||
}
|
||||
|
||||
Returns JSON:
|
||||
{ "applied": { "<cus_ent_id>": true }, "skipped": ["id1"] }
|
||||
]]
|
||||
|
||||
local balance_key = KEYS[1]
|
||||
local params = cjson.decode(ARGV[1])
|
||||
local updates = params.updates or {}
|
||||
local ttl_seconds = params.ttl_seconds
|
||||
|
||||
if #updates == 0 then
|
||||
return cjson.encode({ applied = {}, skipped = {} })
|
||||
end
|
||||
|
||||
local context = init_update_context({
|
||||
balance_key = balance_key,
|
||||
updates = updates,
|
||||
})
|
||||
|
||||
local logger = context.logger
|
||||
logger.log("=== UPDATE SUBJECT BALANCES START ===")
|
||||
logger.log(" balance_key: %s", balance_key)
|
||||
logger.log(" update_count: %d", #updates)
|
||||
|
||||
local applied = {}
|
||||
local skipped = {}
|
||||
|
||||
for _, update in ipairs(updates) do
|
||||
local cus_ent_id = update.cus_ent_id
|
||||
local ent_data = context.customer_entitlements[cus_ent_id]
|
||||
|
||||
if not ent_data then
|
||||
logger.log(" [%s] SKIPPED: not found in hash", cus_ent_id)
|
||||
table.insert(skipped, cus_ent_id)
|
||||
else
|
||||
local subject_balance = ent_data.subject_balance
|
||||
|
||||
local should_skip = false
|
||||
if not is_nil(update.expected_next_reset_at) and update.expected_next_reset_at ~= false then
|
||||
local current = safe_number(subject_balance.next_reset_at)
|
||||
if current ~= update.expected_next_reset_at then
|
||||
logger.log(" [%s] SKIPPED: expected_next_reset_at mismatch (current=%s, expected=%s)",
|
||||
cus_ent_id, tostring(current), tostring(update.expected_next_reset_at))
|
||||
should_skip = true
|
||||
end
|
||||
end
|
||||
|
||||
if should_skip then
|
||||
table.insert(skipped, cus_ent_id)
|
||||
else
|
||||
local has_entities = type(update.entities) == 'table'
|
||||
logger.log(" [%s] APPLYING: isEntityLevel=%s, balance=%s, adjustment=%s, has_entities=%s",
|
||||
cus_ent_id,
|
||||
tostring(subject_balance.isEntityLevel or false),
|
||||
tostring(update.balance),
|
||||
tostring(update.adjustment),
|
||||
tostring(has_entities))
|
||||
|
||||
local helper_params = {
|
||||
subject_balance = subject_balance,
|
||||
update = update,
|
||||
context = context,
|
||||
cus_ent_id = cus_ent_id,
|
||||
}
|
||||
|
||||
apply_balance_and_adjustment_update(helper_params)
|
||||
apply_entities_update(helper_params)
|
||||
apply_next_reset_at_update(helper_params)
|
||||
apply_rollover_updates(helper_params)
|
||||
apply_replaceable_updates(helper_params)
|
||||
|
||||
redis.call('HSET', balance_key, cus_ent_id, cjson.encode(subject_balance))
|
||||
applied[cus_ent_id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
logger.log(" mutation_logs_count: %d", #context.mutation_logs)
|
||||
|
||||
update_aggregated_balances({
|
||||
context = context,
|
||||
mutation_logs = context.mutation_logs,
|
||||
})
|
||||
|
||||
if ttl_seconds and ttl_seconds > 0 then
|
||||
redis.call('EXPIRE', balance_key, ttl_seconds)
|
||||
end
|
||||
|
||||
logger.log("=== UPDATE SUBJECT BALANCES END ===")
|
||||
|
||||
return cjson.encode({ applied = applied, skipped = skipped, logs = context.logs })
|
||||
@@ -252,6 +252,40 @@ local function queue_rollover_update(params)
|
||||
})
|
||||
end
|
||||
|
||||
local function collect_modified_customer_entitlement_ids(params)
|
||||
local context = params.context
|
||||
local extra_customer_entitlement_ids =
|
||||
safe_table(params.extra_customer_entitlement_ids)
|
||||
local modified_customer_entitlement_ids = {}
|
||||
local seen_modified_customer_entitlement_ids = {}
|
||||
|
||||
for _, customer_entitlement_id in ipairs(context.pending_writes or {}) do
|
||||
if not is_nil(customer_entitlement_id)
|
||||
and not seen_modified_customer_entitlement_ids[customer_entitlement_id]
|
||||
then
|
||||
seen_modified_customer_entitlement_ids[customer_entitlement_id] = true
|
||||
table.insert(
|
||||
modified_customer_entitlement_ids,
|
||||
customer_entitlement_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
for _, customer_entitlement_id in ipairs(extra_customer_entitlement_ids) do
|
||||
if not is_nil(customer_entitlement_id)
|
||||
and not seen_modified_customer_entitlement_ids[customer_entitlement_id]
|
||||
then
|
||||
seen_modified_customer_entitlement_ids[customer_entitlement_id] = true
|
||||
table.insert(
|
||||
modified_customer_entitlement_ids,
|
||||
customer_entitlement_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
return modified_customer_entitlement_ids
|
||||
end
|
||||
|
||||
local function update_in_memory_rollover(params)
|
||||
update_in_memory_rollover_mutation({
|
||||
target = params.target,
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
{
|
||||
updates: { [cus_ent_id]: { balance, additional_balance, adjustment, entities, deducted, additional_deducted } },
|
||||
rollover_updates: { [rollover_id]: { balance, usage, entities } },
|
||||
modified_customer_entitlement_ids: string[],
|
||||
remaining: number,
|
||||
error: string | null,
|
||||
feature_id: string | null
|
||||
@@ -82,6 +83,7 @@ if #customer_entitlement_deductions == 0 then
|
||||
return cjson.encode({
|
||||
updates = {},
|
||||
rollover_updates = {},
|
||||
modified_customer_entitlement_ids = empty_logs,
|
||||
mutation_logs = empty_logs,
|
||||
remaining = 0,
|
||||
error = cjson.null,
|
||||
@@ -101,6 +103,7 @@ if #(context.missing_customer_entitlement_ids or {}) > 0 then
|
||||
error = 'SUBJECT_BALANCE_NOT_FOUND',
|
||||
updates = {},
|
||||
rollover_updates = {},
|
||||
modified_customer_entitlement_ids = empty_logs,
|
||||
mutation_logs = empty_logs,
|
||||
remaining = 0,
|
||||
logs = context.logs,
|
||||
@@ -122,6 +125,7 @@ if not is_nil(unwind_value) and safe_number(unwind_value) > 0 then
|
||||
error = unwind_result.error,
|
||||
updates = {},
|
||||
rollover_updates = {},
|
||||
modified_customer_entitlement_ids = empty_logs,
|
||||
mutation_logs = context.mutation_logs or cjson.decode('[]'),
|
||||
remaining = 0,
|
||||
logs = context.logs,
|
||||
@@ -181,6 +185,11 @@ for _, cus_ent_id in ipairs(unwind_modified_cus_ent_ids) do
|
||||
end
|
||||
end
|
||||
|
||||
local modified_customer_entitlement_ids = collect_modified_customer_entitlement_ids({
|
||||
context = context,
|
||||
extra_customer_entitlement_ids = unwind_modified_cus_ent_ids,
|
||||
})
|
||||
|
||||
logger.log(" remaining_amount: %s", tostring(remaining_amount or "nil"))
|
||||
logger.log(" is_refund: %s", tostring(remaining_amount < 0 or false))
|
||||
local mutation_logs = context.mutation_logs
|
||||
@@ -194,6 +203,7 @@ if remaining_amount > 0 and overage_behaviour == 'reject' then
|
||||
feature_id = feature_id,
|
||||
remaining = remaining_amount,
|
||||
updates = {},
|
||||
modified_customer_entitlement_ids = empty_logs,
|
||||
mutation_logs = mutation_logs,
|
||||
logs = context.logs
|
||||
})
|
||||
@@ -216,6 +226,7 @@ then
|
||||
remaining = 0,
|
||||
updates = {},
|
||||
rollover_updates = rollover_updates,
|
||||
modified_customer_entitlement_ids = modified_customer_entitlement_ids,
|
||||
mutation_logs = mutation_logs,
|
||||
logs = context.logs
|
||||
})
|
||||
@@ -242,11 +253,17 @@ end
|
||||
-- Apply all pending writes to Redis (only after validation passes)
|
||||
apply_pending_writes(routing_key, context)
|
||||
|
||||
update_aggregated_balances({
|
||||
context = context,
|
||||
mutation_logs = mutation_logs,
|
||||
})
|
||||
|
||||
logger.log("=== LUA DEDUCTION END ===")
|
||||
|
||||
return cjson.encode({
|
||||
updates = updates,
|
||||
rollover_updates = rollover_updates,
|
||||
modified_customer_entitlement_ids = modified_customer_entitlement_ids,
|
||||
mutation_logs = mutation_logs,
|
||||
remaining = remaining_amount,
|
||||
error = cjson.null,
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
-- ============================================================================
|
||||
-- LOCK UNWIND HELPERS
|
||||
-- Step-by-step helpers for reconciling a lock receipt to a final value
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- HELPER: Normalize a receipt identifier field to nil
|
||||
-- ============================================================================
|
||||
local function normalize_lock_item_id(value)
|
||||
if is_nil(value) then
|
||||
return nil
|
||||
end
|
||||
|
||||
return value
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- HELPER: Normalize delta signs for one mutation item
|
||||
-- ============================================================================
|
||||
local function get_lock_item_delta_signs(params)
|
||||
local item = params.item or {}
|
||||
|
||||
return {
|
||||
value_sign = safe_number(item.value_delta) >= 0 and 1 or -1,
|
||||
balance_sign = safe_number(item.balance_delta) >= 0 and 1 or -1,
|
||||
adjustment_sign = safe_number(item.adjustment_delta) >= 0 and 1 or -1,
|
||||
usage_sign = safe_number(item.usage_delta) >= 0 and 1 or -1,
|
||||
}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- HELPER: Calculate how much of one item should be unwound in this iteration
|
||||
-- ============================================================================
|
||||
local function calculate_unwind_iteration_value(params)
|
||||
local item = params.item or {}
|
||||
local remaining_unwind_value = safe_number(params.remaining_unwind_value)
|
||||
local item_value_magnitude = math.abs(safe_number(item.value_delta))
|
||||
|
||||
return math.min(item_value_magnitude, remaining_unwind_value)
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- STEP 1: Calculate the current signed lock value from receipt items
|
||||
-- ============================================================================
|
||||
local function calculate_lock_value(params)
|
||||
local items = safe_table(params.items)
|
||||
local lock_value = 0
|
||||
|
||||
for _, item in ipairs(items) do
|
||||
lock_value = lock_value + safe_number(item.value_delta)
|
||||
end
|
||||
|
||||
return lock_value
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- STEP 2: Calculate how much value should be unwound from the current lock
|
||||
-- ============================================================================
|
||||
local function calculate_unwind_value(params)
|
||||
local lock_value = safe_number(params.lock_value)
|
||||
local final_value = safe_number(params.final_value)
|
||||
local lock_magnitude = math.abs(lock_value)
|
||||
local final_magnitude = math.abs(final_value)
|
||||
|
||||
if lock_value == 0 then
|
||||
return {
|
||||
unwind_value = 0,
|
||||
}
|
||||
end
|
||||
|
||||
if final_value == 0 then
|
||||
return {
|
||||
unwind_value = lock_magnitude,
|
||||
}
|
||||
end
|
||||
|
||||
local lock_sign = lock_value > 0 and 1 or -1
|
||||
local final_sign = final_value > 0 and 1 or -1
|
||||
|
||||
if lock_sign ~= final_sign then
|
||||
return {
|
||||
unwind_value = lock_magnitude,
|
||||
}
|
||||
end
|
||||
|
||||
if final_magnitude >= lock_magnitude then
|
||||
return {
|
||||
unwind_value = 0,
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
unwind_value = lock_magnitude - final_magnitude,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- STEP 3: Unwind one lock receipt item iteration
|
||||
--
|
||||
-- params:
|
||||
-- context: table
|
||||
-- item: mutation log item
|
||||
-- remaining_unwind_value: positive magnitude
|
||||
--
|
||||
-- Returns:
|
||||
-- {
|
||||
-- applied = boolean,
|
||||
-- unwind_iteration_value = number,
|
||||
-- remaining_unwind_value = number,
|
||||
-- error = string | nil,
|
||||
-- }
|
||||
-- ============================================================================
|
||||
local function unwind_lock_item_iteration(params)
|
||||
local context = params.context
|
||||
local item = params.item or {}
|
||||
local remaining_unwind_value = safe_number(params.remaining_unwind_value)
|
||||
|
||||
if remaining_unwind_value <= 0 then
|
||||
return {
|
||||
applied = false,
|
||||
unwind_iteration_value = 0,
|
||||
remaining_unwind_value = 0,
|
||||
error = nil,
|
||||
}
|
||||
end
|
||||
|
||||
local unwind_iteration_value = calculate_unwind_iteration_value({
|
||||
item = item,
|
||||
remaining_unwind_value = remaining_unwind_value,
|
||||
})
|
||||
|
||||
if unwind_iteration_value <= 0 then
|
||||
return {
|
||||
applied = false,
|
||||
unwind_iteration_value = 0,
|
||||
remaining_unwind_value = remaining_unwind_value,
|
||||
error = nil,
|
||||
}
|
||||
end
|
||||
|
||||
local signs = get_lock_item_delta_signs({
|
||||
item = item,
|
||||
})
|
||||
|
||||
-- Calculate the amount of credits to unwind
|
||||
local credits_to_unwind =
|
||||
unwind_iteration_value * safe_number(item.credit_cost or 1)
|
||||
local inverse_balance_delta = -signs.balance_sign * credits_to_unwind
|
||||
local inverse_adjustment_delta = safe_number(item.adjustment_delta) ~= 0
|
||||
and (-signs.adjustment_sign * credits_to_unwind)
|
||||
or 0 --[[ default to 0 if adjustment_delta is not set ]]
|
||||
local inverse_usage_delta = safe_number(item.usage_delta) ~= 0
|
||||
and (-signs.usage_sign * credits_to_unwind)
|
||||
or 0 --[[ default to 0 if usage_delta is not set ]]
|
||||
local inverse_value_delta = -signs.value_sign * unwind_iteration_value
|
||||
local entity_id = normalize_lock_item_id(item.entity_id)
|
||||
|
||||
if item.target_type == 'customer_entitlement' then
|
||||
local customer_entitlement_id =
|
||||
normalize_lock_item_id(item.customer_entitlement_id)
|
||||
local ent_data = context.customer_entitlements[customer_entitlement_id]
|
||||
if not ent_data then
|
||||
-- Entitlement no longer exists (e.g. product upgraded mid-flight).
|
||||
-- Skip this item and leave remaining_unwind_value unchanged so the
|
||||
-- caller can compensate against current live entitlements.
|
||||
return {
|
||||
applied = false,
|
||||
unwind_iteration_value = 0,
|
||||
remaining_unwind_value = remaining_unwind_value,
|
||||
error = nil,
|
||||
}
|
||||
end
|
||||
|
||||
queue_customer_entitlement_mutation({
|
||||
context = context,
|
||||
customer_entitlement_id = customer_entitlement_id,
|
||||
entity_id = entity_id,
|
||||
credit_cost = safe_number(item.credit_cost or 1),
|
||||
balance_delta = inverse_balance_delta,
|
||||
adjustment_delta = inverse_adjustment_delta,
|
||||
value_delta = inverse_value_delta,
|
||||
})
|
||||
|
||||
update_in_memory_customer_entitlement_mutation({
|
||||
target = entity_id and ent_data.entities or ent_data,
|
||||
entity_id = entity_id,
|
||||
balance_delta = inverse_balance_delta,
|
||||
adjustment_delta = inverse_adjustment_delta,
|
||||
})
|
||||
|
||||
return {
|
||||
applied = true,
|
||||
unwind_iteration_value = unwind_iteration_value,
|
||||
remaining_unwind_value = remaining_unwind_value - unwind_iteration_value,
|
||||
error = nil,
|
||||
}
|
||||
end
|
||||
|
||||
if item.target_type == 'rollover' then
|
||||
local rollover_id = normalize_lock_item_id(item.rollover_id)
|
||||
local rollover_data = context.rollovers[rollover_id]
|
||||
if not rollover_data then
|
||||
-- Rollover no longer exists (e.g. expired or removed mid-flight).
|
||||
-- Skip this item and leave remaining_unwind_value unchanged.
|
||||
return {
|
||||
applied = false,
|
||||
unwind_iteration_value = 0,
|
||||
remaining_unwind_value = remaining_unwind_value,
|
||||
error = nil,
|
||||
}
|
||||
end
|
||||
|
||||
queue_rollover_mutation({
|
||||
context = context,
|
||||
rollover_id = rollover_id,
|
||||
entity_id = entity_id,
|
||||
credit_cost = safe_number(item.credit_cost or 1),
|
||||
balance_delta = inverse_balance_delta,
|
||||
usage_delta = inverse_usage_delta,
|
||||
value_delta = inverse_value_delta,
|
||||
})
|
||||
|
||||
update_in_memory_rollover_mutation({
|
||||
target = entity_id and rollover_data.entities or rollover_data,
|
||||
entity_id = entity_id,
|
||||
balance_delta = inverse_balance_delta,
|
||||
usage_delta = inverse_usage_delta,
|
||||
})
|
||||
|
||||
return {
|
||||
applied = true,
|
||||
unwind_iteration_value = unwind_iteration_value,
|
||||
remaining_unwind_value = remaining_unwind_value - unwind_iteration_value,
|
||||
error = nil,
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
applied = false,
|
||||
unwind_iteration_value = 0,
|
||||
remaining_unwind_value = remaining_unwind_value,
|
||||
error = 'INVALID_LOCK_ITEM_TARGET_TYPE',
|
||||
}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- STEP 4: Iterate through receipt items backwards and unwind them
|
||||
--
|
||||
-- params:
|
||||
-- context: table
|
||||
-- items: ordered receipt items
|
||||
-- unwind_value: positive magnitude
|
||||
--
|
||||
-- Returns:
|
||||
-- {
|
||||
-- applied = boolean,
|
||||
-- remaining_unwind_value = number,
|
||||
-- iterations = array,
|
||||
-- error = string | nil,
|
||||
-- }
|
||||
-- ============================================================================
|
||||
local function unwind_lock_items(params)
|
||||
local context = params.context
|
||||
local items = safe_table(params.items)
|
||||
local remaining_unwind_value = safe_number(params.unwind_value)
|
||||
local iterations = {}
|
||||
|
||||
if remaining_unwind_value <= 0 then
|
||||
return {
|
||||
applied = false,
|
||||
remaining_unwind_value = 0,
|
||||
iterations = iterations,
|
||||
error = nil,
|
||||
}
|
||||
end
|
||||
|
||||
for index = #items, 1, -1 do
|
||||
if remaining_unwind_value <= 0 then
|
||||
break
|
||||
end
|
||||
|
||||
local item = items[index]
|
||||
local result = unwind_lock_item_iteration({
|
||||
context = context,
|
||||
item = item,
|
||||
remaining_unwind_value = remaining_unwind_value,
|
||||
})
|
||||
|
||||
context.logger.log(
|
||||
"[unwind_lock_items] index=%d cus_ent_id=%s applied=%s unwound=%s remaining=%s error=%s",
|
||||
index,
|
||||
tostring(item.customer_entitlement_id or item.rollover_id or "?"),
|
||||
tostring(result.applied),
|
||||
tostring(result.unwind_iteration_value),
|
||||
tostring(result.remaining_unwind_value),
|
||||
tostring(result.error or "nil")
|
||||
)
|
||||
|
||||
if not is_nil(result.error) then
|
||||
return {
|
||||
applied = #iterations > 0,
|
||||
remaining_unwind_value = remaining_unwind_value,
|
||||
iterations = iterations,
|
||||
error = result.error,
|
||||
}
|
||||
end
|
||||
|
||||
if result.applied then
|
||||
table.insert(iterations, {
|
||||
item = item,
|
||||
unwind_iteration_value = result.unwind_iteration_value,
|
||||
})
|
||||
end
|
||||
|
||||
remaining_unwind_value = result.remaining_unwind_value
|
||||
end
|
||||
|
||||
return {
|
||||
applied = #iterations > 0,
|
||||
remaining_unwind_value = remaining_unwind_value,
|
||||
iterations = iterations,
|
||||
error = nil,
|
||||
}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- STEP 5: Collect modified IDs from unwind iterations
|
||||
-- ============================================================================
|
||||
local function collect_unwind_modified_ids(params)
|
||||
local iterations = safe_table(params.iterations)
|
||||
local modified_customer_entitlement_ids = {}
|
||||
local modified_rollover_ids = {}
|
||||
local seen_customer_entitlements = {}
|
||||
local seen_rollovers = {}
|
||||
|
||||
for _, iteration in ipairs(iterations) do
|
||||
local item = iteration.item or {}
|
||||
|
||||
if not is_nil(item.customer_entitlement_id)
|
||||
and not seen_customer_entitlements[item.customer_entitlement_id]
|
||||
then
|
||||
seen_customer_entitlements[item.customer_entitlement_id] = true
|
||||
table.insert(modified_customer_entitlement_ids, item.customer_entitlement_id)
|
||||
end
|
||||
|
||||
if not is_nil(item.rollover_id)
|
||||
and not seen_rollovers[item.rollover_id]
|
||||
then
|
||||
seen_rollovers[item.rollover_id] = true
|
||||
table.insert(modified_rollover_ids, item.rollover_id)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
modified_customer_entitlement_ids = modified_customer_entitlement_ids,
|
||||
modified_rollover_ids = modified_rollover_ids,
|
||||
}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- STEP 6: Unwind a lock receipt against an initialized context
|
||||
-- ============================================================================
|
||||
local function unwind_lock_on_context(params)
|
||||
local context = params.context
|
||||
local lock_receipt_key = params.lock_receipt_key
|
||||
local unwind_value = params.unwind_value or 0
|
||||
local empty_result = {
|
||||
modified_customer_entitlement_ids = cjson.decode('[]'),
|
||||
modified_rollover_ids = cjson.decode('[]'),
|
||||
mutation_logs = cjson.decode('[]'),
|
||||
}
|
||||
|
||||
local receipt = load_lock_receipt(lock_receipt_key)
|
||||
|
||||
local pending_error = require_processing_receipt(receipt)
|
||||
if not is_nil(pending_error) then
|
||||
context.logger.log("[unwind_lock] receipt not in processing state: %s", pending_error)
|
||||
empty_result.error = pending_error
|
||||
return empty_result
|
||||
end
|
||||
|
||||
local items = receipt.items or cjson.decode('[]')
|
||||
context.logger.log("[unwind_lock] unwinding %d items, unwind_value=%s", #items, tostring(unwind_value))
|
||||
|
||||
-- Compute lock_sign from the sum of value_deltas across all receipt items.
|
||||
-- unwind_value is always a positive magnitude; the caller needs lock_sign to
|
||||
-- know the direction of the original deduction so it can compensate for any
|
||||
-- items that were skipped (entitlement/rollover no longer exists).
|
||||
local lock_value_sum = 0
|
||||
for _, item in ipairs(items) do
|
||||
lock_value_sum = lock_value_sum + safe_number(item.value_delta)
|
||||
end
|
||||
local lock_sign = lock_value_sum >= 0 and 1 or -1
|
||||
|
||||
local unwind_items_result = unwind_lock_items({
|
||||
context = context,
|
||||
items = items,
|
||||
unwind_value = unwind_value,
|
||||
})
|
||||
|
||||
if not is_nil(unwind_items_result.error) then
|
||||
context.logger.log("[unwind_lock] unwind error: %s", unwind_items_result.error)
|
||||
empty_result.error = unwind_items_result.error
|
||||
return empty_result
|
||||
end
|
||||
|
||||
local skipped_unwind = unwind_items_result.remaining_unwind_value
|
||||
-- remaining_signed_unwind_value: the signed amount that could not be unwound
|
||||
-- because the target entitlement/rollover no longer exists.
|
||||
-- Callers can add this directly to additional_value to compensate:
|
||||
-- effective_additional = additional_value + remaining_signed_unwind_value
|
||||
-- A positive lock (deduction) that couldn't be restored → negative signed value
|
||||
-- (a refund against current entitlements).
|
||||
-- A negative lock (credit) that couldn't be taken back → positive signed value
|
||||
-- (a deduction against current entitlements).
|
||||
local remaining_signed_unwind_value = -lock_sign * skipped_unwind
|
||||
|
||||
if skipped_unwind > 0 then
|
||||
context.logger.log(
|
||||
"[unwind_lock] skipped_unwind=%s, lock_sign=%d, remaining_signed_unwind_value=%s",
|
||||
tostring(skipped_unwind), lock_sign, tostring(remaining_signed_unwind_value)
|
||||
)
|
||||
end
|
||||
|
||||
local modified_ids = collect_unwind_modified_ids({
|
||||
iterations = unwind_items_result.iterations,
|
||||
})
|
||||
|
||||
context.logger.log(
|
||||
"[unwind_lock] done: applied=%s, remaining=%s, cus_ents=%d, rollovers=%d",
|
||||
tostring(unwind_items_result.applied),
|
||||
tostring(unwind_items_result.remaining_unwind_value),
|
||||
#modified_ids.modified_customer_entitlement_ids,
|
||||
#modified_ids.modified_rollover_ids
|
||||
)
|
||||
|
||||
return {
|
||||
error = cjson.null,
|
||||
unwind_value = unwind_value,
|
||||
remaining_signed_unwind_value = remaining_signed_unwind_value,
|
||||
modified_customer_entitlement_ids = modified_ids.modified_customer_entitlement_ids,
|
||||
modified_rollover_ids = modified_ids.modified_rollover_ids,
|
||||
mutation_logs = context.mutation_logs,
|
||||
}
|
||||
end
|
||||
@@ -15,15 +15,10 @@ local function build_shared_subject_balance_key(params)
|
||||
end
|
||||
|
||||
local function decode_subject_balance(raw_value)
|
||||
if is_nil(raw_value) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local decoded = cjson.decode(raw_value)
|
||||
local decoded = safe_decode(raw_value)
|
||||
if type(decoded) ~= 'table' then
|
||||
return nil
|
||||
end
|
||||
|
||||
return decoded
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
-- ============================================================================
|
||||
-- UPDATE AGGREGATED BALANCES
|
||||
-- Applies main-balance (customer_entitlement) and rollover deltas from
|
||||
-- entity-level mutation logs to the _aggregated field on each affected
|
||||
-- shared balance hash. Rollover logs bump rollover_balance/rollover_usage;
|
||||
-- customer_entitlement logs bump balance/adjustment. Both are per-scope
|
||||
-- (top-level + per-entity) using the same (entity_id) attribution.
|
||||
-- ============================================================================
|
||||
|
||||
local function update_aggregated_balances(params)
|
||||
local context = params.context
|
||||
local mutation_logs = params.mutation_logs
|
||||
local logger = context.logger
|
||||
|
||||
local deltas_by_balance_key = {}
|
||||
|
||||
local function ensure_agg(balance_key)
|
||||
if not deltas_by_balance_key[balance_key] then
|
||||
deltas_by_balance_key[balance_key] = {
|
||||
balance_delta = 0,
|
||||
adjustment_delta = 0,
|
||||
rollover_balance_delta = 0,
|
||||
rollover_usage_delta = 0,
|
||||
entity_deltas = {},
|
||||
}
|
||||
end
|
||||
return deltas_by_balance_key[balance_key]
|
||||
end
|
||||
|
||||
local function ensure_entity(agg, entity_id)
|
||||
if not agg.entity_deltas[entity_id] then
|
||||
agg.entity_deltas[entity_id] = {
|
||||
balance_delta = 0,
|
||||
adjustment_delta = 0,
|
||||
rollover_balance_delta = 0,
|
||||
rollover_usage_delta = 0,
|
||||
}
|
||||
end
|
||||
return agg.entity_deltas[entity_id]
|
||||
end
|
||||
|
||||
for _, log_entry in ipairs(type(mutation_logs) == 'table' and mutation_logs or {}) do
|
||||
local target_type = log_entry.target_type
|
||||
local is_ce = target_type == 'customer_entitlement'
|
||||
local is_rollover = target_type == 'rollover'
|
||||
|
||||
if (is_ce or is_rollover)
|
||||
and not is_nil(log_entry.customer_entitlement_id)
|
||||
and log_entry.customer_entitlement_id ~= cjson.null then
|
||||
local ent_data = context.customer_entitlements[log_entry.customer_entitlement_id]
|
||||
if ent_data and ent_data.subject_balance and ent_data.subject_balance.isEntityLevel then
|
||||
local balance_key = ent_data.balance_key
|
||||
if balance_key then
|
||||
local agg = ensure_agg(balance_key)
|
||||
local balance_delta = safe_number(log_entry.balance_delta)
|
||||
local adjustment_delta = safe_number(log_entry.adjustment_delta)
|
||||
local usage_delta = safe_number(log_entry.usage_delta)
|
||||
|
||||
if is_ce then
|
||||
agg.balance_delta = agg.balance_delta + balance_delta
|
||||
agg.adjustment_delta = agg.adjustment_delta + adjustment_delta
|
||||
else
|
||||
agg.rollover_balance_delta = agg.rollover_balance_delta + balance_delta
|
||||
agg.rollover_usage_delta = agg.rollover_usage_delta + usage_delta
|
||||
end
|
||||
|
||||
local entity_id = log_entry.entity_id
|
||||
if not is_nil(entity_id) and entity_id ~= cjson.null then
|
||||
local ent_delta = ensure_entity(agg, entity_id)
|
||||
if is_ce then
|
||||
ent_delta.balance_delta = ent_delta.balance_delta + balance_delta
|
||||
ent_delta.adjustment_delta = ent_delta.adjustment_delta + adjustment_delta
|
||||
else
|
||||
ent_delta.rollover_balance_delta = ent_delta.rollover_balance_delta + balance_delta
|
||||
ent_delta.rollover_usage_delta = ent_delta.rollover_usage_delta + usage_delta
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for balance_key, deltas in pairs(deltas_by_balance_key) do
|
||||
local raw = redis.call('HGET', balance_key, '_aggregated')
|
||||
local agg_data = safe_decode(raw)
|
||||
if type(agg_data) == 'table' then
|
||||
agg_data.balance = safe_number(agg_data.balance) + deltas.balance_delta
|
||||
agg_data.adjustment = safe_number(agg_data.adjustment) + deltas.adjustment_delta
|
||||
agg_data.rollover_balance =
|
||||
safe_number(agg_data.rollover_balance) + deltas.rollover_balance_delta
|
||||
agg_data.rollover_usage =
|
||||
safe_number(agg_data.rollover_usage) + deltas.rollover_usage_delta
|
||||
|
||||
if type(agg_data.entities) == 'table' then
|
||||
for entity_id, entity_delta in pairs(deltas.entity_deltas) do
|
||||
if agg_data.entities[entity_id] then
|
||||
local entity_agg = agg_data.entities[entity_id]
|
||||
entity_agg.balance =
|
||||
safe_number(entity_agg.balance) + entity_delta.balance_delta
|
||||
entity_agg.adjustment =
|
||||
safe_number(entity_agg.adjustment) + entity_delta.adjustment_delta
|
||||
entity_agg.rollover_balance =
|
||||
safe_number(entity_agg.rollover_balance) + entity_delta.rollover_balance_delta
|
||||
entity_agg.rollover_usage =
|
||||
safe_number(entity_agg.rollover_usage) + entity_delta.rollover_usage_delta
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
redis.call('HSET', balance_key, '_aggregated', cjson.encode(agg_data))
|
||||
logger.log(
|
||||
"Updated _aggregated on %s: balance_delta=%s, adjustment_delta=%s, rollover_balance_delta=%s, rollover_usage_delta=%s",
|
||||
balance_key,
|
||||
deltas.balance_delta,
|
||||
deltas.adjustment_delta,
|
||||
deltas.rollover_balance_delta,
|
||||
deltas.rollover_usage_delta
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -84,6 +84,11 @@ const LOCK_UNWIND_UTILS = readFileSync(
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const LOCK_UNWIND_UTILS_V2 = readFileSync(
|
||||
join(FULL_SUBJECT_DEDUCTION_DIR, "lock", "unwindLockV2.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// FULL CUSTOMER KEY BUILDER LUA (version interpolated from TS config)
|
||||
// ============================================================================
|
||||
@@ -167,22 +172,13 @@ const setFullCustomerCacheScript = readFileSync(
|
||||
export const SET_FULL_CUSTOMER_CACHE_SCRIPT = `${FULL_CUSTOMER_KEY_BUILDERS}
|
||||
${setFullCustomerCacheScript}`;
|
||||
|
||||
const reserveFullSubjectWriteScript = readFileSync(
|
||||
join(FULL_SUBJECT_DIR, "reserveFullSubjectWrite.lua"),
|
||||
const setCachedFullSubjectScript = readFileSync(
|
||||
join(FULL_SUBJECT_DIR, "setCachedFullSubject.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
/** Reserve a FullSubject write so only one non-overwrite writer proceeds. */
|
||||
export const RESERVE_FULL_SUBJECT_WRITE_SCRIPT = reserveFullSubjectWriteScript;
|
||||
|
||||
const releaseFullSubjectReservationScript = readFileSync(
|
||||
join(FULL_SUBJECT_DIR, "releaseFullSubjectReservation.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
/** Release a FullSubject write reservation if the token still matches. */
|
||||
export const RELEASE_FULL_SUBJECT_RESERVATION_SCRIPT =
|
||||
releaseFullSubjectReservationScript;
|
||||
/** Atomically set a FullSubject cache: subject view + all balance hashes. */
|
||||
export const SET_CACHED_FULL_SUBJECT_SCRIPT = setCachedFullSubjectScript;
|
||||
|
||||
const updateCustomerDataV2Script = readFileSync(
|
||||
join(FULL_SUBJECT_DIR, "updateCustomerDataV2.lua"),
|
||||
@@ -200,6 +196,38 @@ const updateEntityDataV2Script = readFileSync(
|
||||
/** Atomically update top-level entity fields in the cached FullSubject. */
|
||||
export const UPDATE_ENTITY_DATA_V2_SCRIPT = updateEntityDataV2Script;
|
||||
|
||||
const updateCachedInvoiceV2Script = readFileSync(
|
||||
join(FULL_SUBJECT_DIR, "updateCachedInvoice.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
/** Atomically upsert an invoice in the cached FullSubject invoices array. */
|
||||
export const UPDATE_CACHED_INVOICE_V2_SCRIPT = updateCachedInvoiceV2Script;
|
||||
|
||||
const UPDATE_CUSTOMER_PRODUCT_DIR = join(
|
||||
FULL_SUBJECT_DIR,
|
||||
"updateCustomerProduct",
|
||||
);
|
||||
|
||||
const updateCustomerProductOptionsScript = readFileSync(
|
||||
join(UPDATE_CUSTOMER_PRODUCT_DIR, "updateCustomerProductOptions.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const updateCustomerProductV2MainScript = readFileSync(
|
||||
join(UPDATE_CUSTOMER_PRODUCT_DIR, "updateCustomerProductV2.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
/** Atomically update customer product fields in the cached FullSubject. */
|
||||
export const UPDATE_CUSTOMER_PRODUCT_V2_SCRIPT = `${updateCustomerProductOptionsScript}
|
||||
${updateCustomerProductV2MainScript}`;
|
||||
|
||||
const adjustSubjectBalanceMainScript = readFileSync(
|
||||
join(FULL_SUBJECT_DIR, "adjustSubjectBalance.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// RESET CUSTOMER ENTITLEMENTS SCRIPT (deprecated — kept for backward compat)
|
||||
// ============================================================================
|
||||
@@ -344,6 +372,11 @@ const SPEND_LIMIT_UTILS_V2 = readFileSync(
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const UPDATE_AGGREGATED_BALANCES = readFileSync(
|
||||
join(FULL_SUBJECT_DEDUCTION_DIR, "updateAggregatedBalances.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const DEDUCT_FROM_SUBJECT_BALANCES_MAIN = readFileSync(
|
||||
join(FULL_SUBJECT_DEDUCTION_DIR, "deductFromSubjectBalances.lua"),
|
||||
"utf-8",
|
||||
@@ -365,23 +398,53 @@ ${RUN_DEDUCTION_ON_CONTEXT_V2}
|
||||
${MUTATION_ITEM_UTILS}
|
||||
${LOCK_RECEIPT_UTILS}
|
||||
${LOCK_STATE_UTILS}
|
||||
${LOCK_UNWIND_UTILS}
|
||||
${LOCK_UNWIND_UTILS_V2}
|
||||
${UPDATE_AGGREGATED_BALANCES}
|
||||
${DEDUCT_FROM_SUBJECT_BALANCES_MAIN}`;
|
||||
|
||||
// ============================================================================
|
||||
// UPDATE SUBJECT BALANCES SCRIPT (V2 cache — per-feature hash updates)
|
||||
// ============================================================================
|
||||
|
||||
const UPDATE_SUBJECT_BALANCES_DIR = join(
|
||||
FULL_SUBJECT_DIR,
|
||||
"updateSubjectBalances",
|
||||
);
|
||||
|
||||
const UPDATE_CONTEXT_UTILS = readFileSync(
|
||||
join(UPDATE_SUBJECT_BALANCES_DIR, "updateContextUtils.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const APPLY_FIELD_UPDATES = readFileSync(
|
||||
join(UPDATE_SUBJECT_BALANCES_DIR, "applyFieldUpdates.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const UPDATE_SUBJECT_BALANCES_MAIN = readFileSync(
|
||||
join(FULL_SUBJECT_DIR, "updateSubjectBalances.lua"),
|
||||
join(UPDATE_SUBJECT_BALANCES_DIR, "updateSubjectBalances.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
/**
|
||||
* Lua script for atomically adjusting one SubjectBalance.balance entry in a
|
||||
* per-feature hash. Emits entity-level mutation logs so aggregated balances
|
||||
* stay in sync.
|
||||
*/
|
||||
export const ADJUST_SUBJECT_BALANCE_SCRIPT = `${LUA_UTILS}
|
||||
${UPDATE_CONTEXT_UTILS}
|
||||
${UPDATE_AGGREGATED_BALANCES}
|
||||
${adjustSubjectBalanceMainScript}`;
|
||||
|
||||
/**
|
||||
* Lua script for atomically updating SubjectBalance entries in a single
|
||||
* per-feature balance hash. Supports scalar updates, rollover ops,
|
||||
* replaceable ops, and expected_next_reset_at guard.
|
||||
* replaceable ops, expected_next_reset_at guard, and entity-level
|
||||
* aggregated balance propagation.
|
||||
* Called once per feature via pipeline.
|
||||
*/
|
||||
export const UPDATE_SUBJECT_BALANCES_SCRIPT = `${LUA_UTILS}
|
||||
${UPDATE_CONTEXT_UTILS}
|
||||
${APPLY_FIELD_UPDATES}
|
||||
${UPDATE_AGGREGATED_BALANCES}
|
||||
${UPDATE_SUBJECT_BALANCES_MAIN}`;
|
||||
|
||||
@@ -21,6 +21,19 @@ local function is_nil(val)
|
||||
return val == nil or val == cjson.null
|
||||
end
|
||||
|
||||
local function is_absent(val)
|
||||
return val == nil or val == cjson.null or val == false
|
||||
end
|
||||
|
||||
local function safe_decode(raw)
|
||||
if raw == nil or raw == false or raw == cjson.null then
|
||||
return nil
|
||||
end
|
||||
local ok, decoded = pcall(cjson.decode, raw)
|
||||
if not ok then return nil end
|
||||
return decoded
|
||||
end
|
||||
|
||||
local function sorted_keys(tbl)
|
||||
local keys = {}
|
||||
for k in pairs(tbl) do
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
type AppEnv,
|
||||
type Feature,
|
||||
type Organization,
|
||||
customerPrices,
|
||||
customerProducts,
|
||||
customers,
|
||||
@@ -28,6 +30,8 @@ export type ExpiredTrialRow = {
|
||||
|
||||
export type OrgEnvExpiredTrials = {
|
||||
ctx: AutumnContext;
|
||||
org: Organization;
|
||||
features: Feature[];
|
||||
rows: ExpiredTrialRow[];
|
||||
};
|
||||
|
||||
@@ -103,7 +107,12 @@ export const groupByOrgEnv = async ({
|
||||
workerId: generateId("product-cron"),
|
||||
});
|
||||
|
||||
groups.push({ ctx, rows });
|
||||
groups.push({
|
||||
ctx,
|
||||
org: ctx.org,
|
||||
features: ctx.features,
|
||||
rows,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { type AppEnv, CusProductStatus, ms } from "@autumn/shared";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
ms,
|
||||
orgToFeaturesByOrgEnv,
|
||||
} from "@autumn/shared";
|
||||
import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects";
|
||||
import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
|
||||
import { batchDeleteCachedFullCustomers } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers";
|
||||
import { ProductService } from "@/internal/products/ProductService";
|
||||
import type { CronContext } from "../utils/CronContext";
|
||||
import {
|
||||
@@ -39,7 +44,7 @@ export const runProductCron = async ({
|
||||
|
||||
const resultsByOrgEnv = await groupByOrgEnv({ results, cronContext });
|
||||
|
||||
for (const { ctx, rows } of resultsByOrgEnv) {
|
||||
for (const { ctx, org, features, rows } of resultsByOrgEnv) {
|
||||
const defaultProducts = await ProductService.listDefault({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
@@ -57,12 +62,20 @@ export const runProductCron = async ({
|
||||
},
|
||||
})),
|
||||
});
|
||||
await batchDeleteCachedFullCustomers({
|
||||
customers: rows.map((row) => ({
|
||||
orgId: row.customer.org_id,
|
||||
env: row.customer.env as AppEnv,
|
||||
customerId: row.customer.id ?? "",
|
||||
})),
|
||||
const customersToDelete = rows.map((row) => ({
|
||||
orgId: row.customer.org_id,
|
||||
env: row.customer.env as AppEnv,
|
||||
customerId: row.customer.id ?? "",
|
||||
}));
|
||||
const featuresByOrgEnv = orgToFeaturesByOrgEnv({
|
||||
org,
|
||||
env: ctx.env,
|
||||
features,
|
||||
});
|
||||
|
||||
await batchInvalidateCachedFullSubjects({
|
||||
customers: customersToDelete,
|
||||
featuresByOrgEnv,
|
||||
});
|
||||
console.log(`Expired ${rows.length} customer products`);
|
||||
continue;
|
||||
|
||||
@@ -16,5 +16,7 @@ export const clearCusEntsFromCache = async ({
|
||||
|
||||
if (customersToDelete.length === 0) return;
|
||||
|
||||
await batchDeleteCachedFullCustomers({ customers: customersToDelete });
|
||||
await batchDeleteCachedFullCustomers({
|
||||
customers: customersToDelete,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { UTCDate } from "@date-fns/utc";
|
||||
import { format } from "date-fns";
|
||||
import type { RepoContext } from "@/db/repoContext";
|
||||
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";
|
||||
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService";
|
||||
@@ -22,7 +23,7 @@ import { resetShortDurationCustomerEntitlement } from "./resetShortDurationCusto
|
||||
|
||||
const shortDurations = [EntInterval.Minute, EntInterval.Hour, EntInterval.Day];
|
||||
|
||||
export const resetCustomerEntitlement = async ({
|
||||
const resetCustomerEntitlementInDb = async ({
|
||||
ctx,
|
||||
cusEnt,
|
||||
updatedCusEnts,
|
||||
@@ -180,3 +181,30 @@ export const resetCustomerEntitlement = async ({
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const resetCustomerEntitlement = async ({
|
||||
ctx,
|
||||
cusEnt,
|
||||
updatedCusEnts,
|
||||
persistFreeOverage = false,
|
||||
}: {
|
||||
ctx: CronContext;
|
||||
cusEnt: ResetCusEnt;
|
||||
updatedCusEnts: ResetCusEnt[];
|
||||
persistFreeOverage?: boolean;
|
||||
}) => {
|
||||
const result = await resetCustomerEntitlementInDb({
|
||||
ctx,
|
||||
cusEnt,
|
||||
updatedCusEnts,
|
||||
persistFreeOverage,
|
||||
});
|
||||
await invalidateCustomerEntitlementBalance({
|
||||
orgId: cusEnt.customer.org_id,
|
||||
env: cusEnt.customer.env,
|
||||
customerId: cusEnt.customer_id ?? "",
|
||||
featureId: cusEnt.entitlement.feature.id,
|
||||
customerEntitlementId: cusEnt.id,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
type CustomerEntitlement,
|
||||
type Feature,
|
||||
notNullish,
|
||||
type OrgConfig,
|
||||
OrgConfigSchema,
|
||||
type Organization,
|
||||
type ResetCusEnt,
|
||||
} from "@autumn/shared";
|
||||
import { UTCDate } from "@date-fns/utc";
|
||||
import * as Sentry from "@sentry/bun";
|
||||
import { format } from "date-fns";
|
||||
import { buildFullSubjectOrgEnvKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectOrgEnvKey.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import type { CronContext } from "../utils/CronContext";
|
||||
@@ -21,33 +23,43 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
|
||||
const timeoutMs = 60_000; // 1 minute
|
||||
const startTime = Date.now();
|
||||
|
||||
const orgConfigCache = new Map<string, OrgConfig>();
|
||||
const orgWithFeaturesCache = new Map<
|
||||
string,
|
||||
{ org: Organization; features: Feature[] }
|
||||
>();
|
||||
|
||||
const getOrgConfig = async ({
|
||||
const getOrgWithFeatures = async ({
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
orgId: string;
|
||||
}): Promise<OrgConfig | undefined> => {
|
||||
const cached = orgConfigCache.get(orgId);
|
||||
env: AppEnv;
|
||||
}): Promise<{ org: Organization; features: Feature[] } | undefined> => {
|
||||
const cacheKey = buildFullSubjectOrgEnvKey({ orgId, env });
|
||||
const cached = orgWithFeaturesCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const org = await OrgService.get({ db, orgId });
|
||||
const config = OrgConfigSchema.parse(org.config || {});
|
||||
orgConfigCache.set(orgId, config);
|
||||
return config;
|
||||
const orgWithFeatures = await OrgService.getWithFeatures({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
if (!orgWithFeatures) return undefined;
|
||||
orgWithFeaturesCache.set(cacheKey, orgWithFeatures);
|
||||
return orgWithFeatures;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Reset cron: failed to fetch org config for orgId=${orgId}, skipping cusEnts for this org`,
|
||||
`Reset cron: failed to fetch org with features for orgId=${orgId}, env=${env}, skipping cusEnts for this org`,
|
||||
error,
|
||||
);
|
||||
|
||||
logger.error(
|
||||
`Reset cron: failed to fetch org config for orgId=${orgId}, skipping cusEnts for this org ${error}`,
|
||||
`Reset cron: failed to fetch org with features for orgId=${orgId}, env=${env}, skipping cusEnts for this org ${error}`,
|
||||
);
|
||||
|
||||
Sentry.captureException(error, {
|
||||
extra: { orgId, context: "runResetCron.getOrgConfig" },
|
||||
extra: { orgId, env, context: "runResetCron.getOrgWithFeatures" },
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
@@ -80,24 +92,39 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
|
||||
for (let i = 0; i < cusEnts.length; i += batchSize) {
|
||||
const batch = cusEnts.slice(i, i + batchSize);
|
||||
|
||||
// Pre-fetch org configs for all unique org_ids in this batch
|
||||
const uniqueOrgIds = new Set(batch.map((ce) => ce.customer.org_id));
|
||||
const uniqueOrgEnvs = new Map<string, { orgId: string; env: AppEnv }>();
|
||||
for (const customerEntitlement of batch) {
|
||||
const env = customerEntitlement.customer.env as AppEnv;
|
||||
const orgId = customerEntitlement.customer.org_id;
|
||||
uniqueOrgEnvs.set(buildFullSubjectOrgEnvKey({ orgId, env }), {
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
}
|
||||
await Promise.all(
|
||||
[...uniqueOrgIds].map((orgId) => getOrgConfig({ orgId })),
|
||||
[...uniqueOrgEnvs.values()].map(({ orgId, env }) =>
|
||||
getOrgWithFeatures({ orgId, env }),
|
||||
),
|
||||
);
|
||||
|
||||
const batchResets = [];
|
||||
const updatedCusEnts: ResetCusEnt[] = [];
|
||||
for (const cusEnt of batch) {
|
||||
const orgConfig = orgConfigCache.get(cusEnt.customer.org_id);
|
||||
if (!orgConfig) continue;
|
||||
const orgWithFeatures = orgWithFeaturesCache.get(
|
||||
buildFullSubjectOrgEnvKey({
|
||||
orgId: cusEnt.customer.org_id,
|
||||
env: cusEnt.customer.env as AppEnv,
|
||||
}),
|
||||
);
|
||||
if (!orgWithFeatures) continue;
|
||||
|
||||
batchResets.push(
|
||||
resetCustomerEntitlement({
|
||||
ctx,
|
||||
cusEnt: cusEnt,
|
||||
updatedCusEnts,
|
||||
persistFreeOverage: orgConfig.persist_free_overage ?? false,
|
||||
persistFreeOverage:
|
||||
orgWithFeatures.org.config.persist_free_overage ?? false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -111,7 +138,9 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
|
||||
});
|
||||
console.log(`Upserted ${toUpsert.length} short entitlements`);
|
||||
|
||||
await clearCusEntsFromCache({ cusEnts: updatedCusEnts });
|
||||
await clearCusEntsFromCache({
|
||||
cusEnts: updatedCusEnts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,19 +112,10 @@ declare module "ioredis" {
|
||||
overwrite: string,
|
||||
pathIndexJson: string,
|
||||
): Promise<"STALE_WRITE" | "CACHE_EXISTS" | "OK">;
|
||||
reserveFullSubjectWrite(
|
||||
subjectKey: string,
|
||||
reserveKey: string,
|
||||
guardKey: string,
|
||||
token: string,
|
||||
reserveTtl: string,
|
||||
overwrite: string,
|
||||
fetchTimeMs: string,
|
||||
): Promise<"CACHE_EXISTS" | "RESERVED" | "STALE_WRITE">;
|
||||
releaseFullSubjectReservation(
|
||||
reserveKey: string,
|
||||
token: string,
|
||||
): Promise<"RELEASED" | "SKIPPED">;
|
||||
setCachedFullSubject(
|
||||
numKeys: number,
|
||||
...args: string[]
|
||||
): Promise<"OK" | "CACHE_EXISTS" | "STALE_WRITE">;
|
||||
resetCustomerEntitlements(
|
||||
cacheKey: string,
|
||||
paramsJson: string,
|
||||
@@ -137,6 +128,10 @@ declare module "ioredis" {
|
||||
cacheKey: string,
|
||||
paramsJson: string,
|
||||
): Promise<string>;
|
||||
adjustSubjectBalance(
|
||||
balanceKey: string,
|
||||
paramsJson: string,
|
||||
): Promise<string>;
|
||||
updateCustomerData(cacheKey: string, paramsJson: string): Promise<string>;
|
||||
updateFullSubjectCustomerDataV2(
|
||||
subjectKey: string,
|
||||
@@ -150,6 +145,18 @@ declare module "ioredis" {
|
||||
cacheTtlSeconds: string,
|
||||
nowMs: string,
|
||||
): Promise<string>;
|
||||
updateFullSubjectCustomerProductV2(
|
||||
subjectKey: string,
|
||||
paramsJson: string,
|
||||
cacheTtlSeconds: string,
|
||||
nowMs: string,
|
||||
): Promise<string>;
|
||||
upsertInvoiceInFullSubjectV2(
|
||||
subjectKey: string,
|
||||
invoiceJson: string,
|
||||
cacheTtlSeconds: string,
|
||||
nowMs: string,
|
||||
): Promise<string>;
|
||||
appendEntityToCustomer(
|
||||
cacheKey: string,
|
||||
entityJson: string,
|
||||
|
||||
@@ -15,20 +15,22 @@ import {
|
||||
} from "../../../_luaScripts/luaScripts.js";
|
||||
import {
|
||||
ADJUST_CUSTOMER_ENTITLEMENT_BALANCE_SCRIPT,
|
||||
ADJUST_SUBJECT_BALANCE_SCRIPT,
|
||||
APPEND_ENTITY_TO_CUSTOMER_SCRIPT,
|
||||
CLAIM_LOCK_RECEIPT_SCRIPT,
|
||||
DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
||||
DEDUCT_FROM_SUBJECT_BALANCES_SCRIPT,
|
||||
DELETE_FULL_CUSTOMER_CACHE_SCRIPT,
|
||||
RELEASE_FULL_SUBJECT_RESERVATION_SCRIPT,
|
||||
RESERVE_FULL_SUBJECT_WRITE_SCRIPT,
|
||||
RESET_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
||||
SET_CACHED_FULL_SUBJECT_SCRIPT,
|
||||
SET_FULL_CUSTOMER_CACHE_SCRIPT,
|
||||
UPDATE_CACHED_INVOICE_V2_SCRIPT,
|
||||
UPDATE_CUSTOMER_DATA_SCRIPT,
|
||||
UPDATE_CUSTOMER_DATA_V2_SCRIPT,
|
||||
UPDATE_ENTITY_DATA_V2_SCRIPT,
|
||||
UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT,
|
||||
UPDATE_CUSTOMER_PRODUCT_SCRIPT,
|
||||
UPDATE_CUSTOMER_PRODUCT_V2_SCRIPT,
|
||||
UPDATE_ENTITY_DATA_V2_SCRIPT,
|
||||
UPDATE_ENTITY_IN_CUSTOMER_SCRIPT,
|
||||
UPDATE_SUBJECT_BALANCES_SCRIPT,
|
||||
UPSERT_INVOICE_IN_CUSTOMER_SCRIPT,
|
||||
@@ -127,14 +129,8 @@ export const registerRedisCommands = ({
|
||||
lua: SET_FULL_CUSTOMER_CACHE_SCRIPT,
|
||||
});
|
||||
|
||||
redisInstance.defineCommand("reserveFullSubjectWrite", {
|
||||
numberOfKeys: 3,
|
||||
lua: RESERVE_FULL_SUBJECT_WRITE_SCRIPT,
|
||||
});
|
||||
|
||||
redisInstance.defineCommand("releaseFullSubjectReservation", {
|
||||
numberOfKeys: 1,
|
||||
lua: RELEASE_FULL_SUBJECT_RESERVATION_SCRIPT,
|
||||
redisInstance.defineCommand("setCachedFullSubject", {
|
||||
lua: SET_CACHED_FULL_SUBJECT_SCRIPT,
|
||||
});
|
||||
|
||||
redisInstance.defineCommand("resetCustomerEntitlements", {
|
||||
@@ -162,6 +158,16 @@ export const registerRedisCommands = ({
|
||||
lua: UPDATE_ENTITY_DATA_V2_SCRIPT,
|
||||
});
|
||||
|
||||
redisInstance.defineCommand("updateFullSubjectCustomerProductV2", {
|
||||
numberOfKeys: 1,
|
||||
lua: UPDATE_CUSTOMER_PRODUCT_V2_SCRIPT,
|
||||
});
|
||||
|
||||
redisInstance.defineCommand("upsertInvoiceInFullSubjectV2", {
|
||||
numberOfKeys: 1,
|
||||
lua: UPDATE_CACHED_INVOICE_V2_SCRIPT,
|
||||
});
|
||||
|
||||
redisInstance.defineCommand("appendEntityToCustomer", {
|
||||
numberOfKeys: 1,
|
||||
lua: APPEND_ENTITY_TO_CUSTOMER_SCRIPT,
|
||||
@@ -182,6 +188,11 @@ export const registerRedisCommands = ({
|
||||
lua: ADJUST_CUSTOMER_ENTITLEMENT_BALANCE_SCRIPT,
|
||||
});
|
||||
|
||||
redisInstance.defineCommand("adjustSubjectBalance", {
|
||||
numberOfKeys: 1,
|
||||
lua: ADJUST_SUBJECT_BALANCE_SCRIPT,
|
||||
});
|
||||
|
||||
redisInstance.defineCommand("updateCustomerProduct", {
|
||||
numberOfKeys: 1,
|
||||
lua: UPDATE_CUSTOMER_PRODUCT_SCRIPT,
|
||||
|
||||
@@ -20,16 +20,6 @@ export const REFRESH_CACHE_ROUTE_CONFIGS: RefreshCacheRouteConfig[] = [
|
||||
url: "/customers/:customer_id",
|
||||
}),
|
||||
|
||||
route({
|
||||
method: "POST",
|
||||
url: "/customers/:customer_id",
|
||||
}),
|
||||
|
||||
route({
|
||||
method: "PATCH",
|
||||
url: "/customers/:customer_id",
|
||||
}),
|
||||
|
||||
route({
|
||||
method: "POST",
|
||||
url: "/customers/:customer_id/balances",
|
||||
@@ -121,16 +111,6 @@ export const REFRESH_CACHE_ROUTE_CONFIGS: RefreshCacheRouteConfig[] = [
|
||||
url: "/entities.delete",
|
||||
}),
|
||||
|
||||
route({
|
||||
method: "POST",
|
||||
url: "/entities.update",
|
||||
}),
|
||||
|
||||
route({
|
||||
method: "POST",
|
||||
url: "/customers.update",
|
||||
}),
|
||||
|
||||
route({
|
||||
method: "POST",
|
||||
url: "/customers.delete",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBilling
|
||||
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.js";
|
||||
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult.js";
|
||||
import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan.js";
|
||||
import { updateCachedCustomerProductV2 } from "@/internal/customers/cache/fullSubject/actions/updateCachedCustomerProduct.js";
|
||||
import type { AutoTopUpPayload } from "@/queue/workflows.js";
|
||||
import { computeAutoTopupPlan } from "./compute/computeAutoTopupPlan.js";
|
||||
import { buildAutoTopUpLockKey } from "./helpers/autoTopUpUtils.js";
|
||||
@@ -98,6 +99,18 @@ export const autoTopup = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Manually update cached options here since we're not refreshing cache.
|
||||
const customerProductUpdate = autumnBillingPlan.updateCustomerProduct;
|
||||
if (customerProductUpdate?.updates.options) {
|
||||
const cusProductId = customerProductUpdate.customerProduct.id;
|
||||
await updateCachedCustomerProductV2({
|
||||
ctx,
|
||||
customerId,
|
||||
customerProductId: cusProductId,
|
||||
updates: customerProductUpdate.updates,
|
||||
});
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
logger.info(
|
||||
`[autoTopup] Completed for feature ${featureId}, customer ${customerId}, duration: ${durationMs}ms`,
|
||||
|
||||
@@ -2,16 +2,77 @@ import {
|
||||
ACTIVE_STATUSES,
|
||||
BillingVersion,
|
||||
cusProductToProduct,
|
||||
type FullCustomer,
|
||||
fullSubjectToFullCustomer,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { getCachedFullSubject } from "@/internal/customers/cache/fullSubject/actions/getCachedFullSubject.js";
|
||||
import { getCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.js";
|
||||
import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js";
|
||||
import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
||||
import type { AutoTopUpPayload } from "@/queue/workflows.js";
|
||||
import type { AutoTopupContext } from "../autoTopupContext.js";
|
||||
import { fullCustomerToAutoTopupObjects } from "../helpers/fullCustomerToAutoTopupObjects.js";
|
||||
import { preflightAutoTopupLimits } from "../helpers/limits/preflightAutoTopupLimits.js";
|
||||
|
||||
const getAutoTopupFullCustomer = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
}): Promise<FullCustomer | undefined> => {
|
||||
console.log("customerId", customerId);
|
||||
if (isFullSubjectRolloutEnabled({ ctx })) {
|
||||
const cachedFullSubject = await getCachedFullSubject({
|
||||
ctx,
|
||||
customerId,
|
||||
source: "setupAutoTopupContext",
|
||||
});
|
||||
|
||||
if (cachedFullSubject) {
|
||||
return fullSubjectToFullCustomer({
|
||||
fullSubject: cachedFullSubject,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedFullSubject = await getFullSubjectNormalized({
|
||||
ctx,
|
||||
customerId,
|
||||
inStatuses: ACTIVE_STATUSES,
|
||||
});
|
||||
|
||||
if (normalizedFullSubject) {
|
||||
return fullSubjectToFullCustomer({
|
||||
fullSubject: normalizedFullSubject.fullSubject,
|
||||
});
|
||||
}
|
||||
|
||||
// Safety fallback to preserve previous behavior if subject query returns no row.
|
||||
return CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
inStatuses: ACTIVE_STATUSES,
|
||||
withSubs: true,
|
||||
});
|
||||
}
|
||||
|
||||
let fullCustomer = await getCachedFullCustomer({ ctx, customerId });
|
||||
|
||||
if (!fullCustomer) {
|
||||
fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
inStatuses: ACTIVE_STATUSES,
|
||||
withSubs: true,
|
||||
});
|
||||
}
|
||||
|
||||
return fullCustomer;
|
||||
};
|
||||
|
||||
/** Fetch full customer, auto-topup config, cusEnt, and Stripe context. Returns null if any prerequisite is missing. */
|
||||
export const setupAutoTopupContext = async ({
|
||||
ctx,
|
||||
@@ -23,19 +84,15 @@ export const setupAutoTopupContext = async ({
|
||||
const { logger } = ctx;
|
||||
const { customerId, featureId } = payload;
|
||||
|
||||
// 1. Fetch FullCustomer — Redis cache first (has latest deducted balance), fall back to DB
|
||||
let fullCustomer = await getCachedFullCustomer({ ctx, customerId });
|
||||
// 1. Fetch FullCustomer with rollout-aware cache source:
|
||||
// - FullSubject cache when rollout is enabled for this customer bucket.
|
||||
// - Legacy FullCustomer cache otherwise.
|
||||
const fullCustomer = await getAutoTopupFullCustomer({
|
||||
ctx,
|
||||
customerId,
|
||||
});
|
||||
|
||||
if (!fullCustomer) {
|
||||
fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
inStatuses: ACTIVE_STATUSES,
|
||||
withSubs: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!fullCustomer || !fullCustomer.processor?.id) {
|
||||
if (!fullCustomer?.processor?.id) {
|
||||
logger.warn(
|
||||
`[setupAutoTopupContext] Customer ${customerId} not found or no Stripe customer ID, skipping`,
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type Feature,
|
||||
FeatureNotFoundError,
|
||||
findFeatureById,
|
||||
fullSubjectToFullCustomer,
|
||||
getFeatureToUseForCheck,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from "@/internal/customers/cache/fullSubject/index.js";
|
||||
import { getApiSubject } from "@/internal/customers/cusUtils/getApiCustomerV2/getApiSubject.js";
|
||||
import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js";
|
||||
import { triggerAutoTopUp } from "../autoTopUp/triggerAutoTopUp.js";
|
||||
import type { CheckDataV2 } from "./checkTypes/CheckDataV2.js";
|
||||
|
||||
const getFeatureAndCreditSystems = ({
|
||||
@@ -73,7 +75,7 @@ export const getCheckDataV2 = async ({
|
||||
source: "getCheckDataV2",
|
||||
});
|
||||
|
||||
console.log("Full subject", fullSubject);
|
||||
// console.log("Full subject", fullSubject);
|
||||
|
||||
const apiSubject = await getApiSubject({
|
||||
ctx,
|
||||
@@ -99,6 +101,15 @@ export const getCheckDataV2 = async ({
|
||||
errorOnNotFound: true,
|
||||
});
|
||||
|
||||
// Trigger auto top-up
|
||||
triggerAutoTopUp({
|
||||
ctx,
|
||||
newFullCus: fullSubjectToFullCustomer({ fullSubject }),
|
||||
feature: featureToUse,
|
||||
}).catch((error) => {
|
||||
ctx.logger.error(`[getCheckData] Failed to trigger auto top-up: ${error}`);
|
||||
});
|
||||
|
||||
return {
|
||||
customerId: customer_id,
|
||||
entityId: entity_id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ApiVersion,
|
||||
CheckResponseV3Schema,
|
||||
ErrCode,
|
||||
FeatureType,
|
||||
@@ -82,6 +83,7 @@ export const runCheckWithTrackV2 = async ({
|
||||
ctx,
|
||||
body: trackBody,
|
||||
featureDeductions,
|
||||
apiVersion: ApiVersion.V2_1,
|
||||
});
|
||||
|
||||
checkData.apiBalance = response.balance ?? undefined;
|
||||
|
||||
@@ -51,6 +51,7 @@ export const updateGrantedBalance = async ({
|
||||
.toNumber();
|
||||
|
||||
const targetCusEnt = cusEnts[0];
|
||||
const targetFeatureId = featureId ?? targetCusEnt.entitlement.feature.id;
|
||||
const isEntityScoped = isEntityScopedCusEnt(targetCusEnt);
|
||||
const entityId = fullCustomer.entity?.id;
|
||||
|
||||
@@ -83,6 +84,7 @@ export const updateGrantedBalance = async ({
|
||||
customerId: fullCustomer.id ?? "",
|
||||
cusEntId: targetCusEnt.id,
|
||||
updates: { entities: newEntities },
|
||||
featureId: targetFeatureId,
|
||||
});
|
||||
|
||||
targetCusEnt.entities = newEntities;
|
||||
@@ -92,6 +94,7 @@ export const updateGrantedBalance = async ({
|
||||
customerId: fullCustomer.id ?? "",
|
||||
cusEntId: targetCusEnt.id,
|
||||
updates: { adjustment: requiredAdjustment },
|
||||
featureId: targetFeatureId,
|
||||
});
|
||||
|
||||
targetCusEnt.adjustment = requiredAdjustment;
|
||||
|
||||
@@ -43,6 +43,7 @@ export const updateNextResetAt = async ({
|
||||
});
|
||||
|
||||
const targetCusEnt = sorted[0];
|
||||
const targetFeatureId = featureId ?? targetCusEnt.entitlement.feature.id;
|
||||
|
||||
if (targetCusEnt.entitlement.interval === EntInterval.Lifetime) {
|
||||
throw new RecaseError({
|
||||
@@ -55,5 +56,6 @@ export const updateNextResetAt = async ({
|
||||
customerId: fullCustomer.id ?? "",
|
||||
cusEntId: targetCusEnt.id,
|
||||
updates: { next_reset_at: nextResetAt },
|
||||
featureId: targetFeatureId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -84,6 +84,7 @@ export const updateIncludedGrantV2 = async ({
|
||||
ctx,
|
||||
id: targetCusEnt.id,
|
||||
updates: { entities: newEntities },
|
||||
incrementCacheVersion: false,
|
||||
});
|
||||
|
||||
await updateSubjectBalanceCache({
|
||||
@@ -98,6 +99,7 @@ export const updateIncludedGrantV2 = async ({
|
||||
ctx,
|
||||
id: targetCusEnt.id,
|
||||
updates: { adjustment: requiredAdjustment },
|
||||
incrementCacheVersion: false,
|
||||
});
|
||||
|
||||
await updateSubjectBalanceCache({
|
||||
|
||||
@@ -56,6 +56,7 @@ export const updateNextResetAtV2 = async ({
|
||||
ctx,
|
||||
id: targetCusEnt.id,
|
||||
updates: { next_reset_at: nextResetAt },
|
||||
incrementCacheVersion: false,
|
||||
});
|
||||
|
||||
await updateSubjectBalanceCache({
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { executeRedisDeductionV2 } from "@/internal/balances/utils/deductionV2/executeRedisDeductionV2.js";
|
||||
import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js";
|
||||
import { syncItemV4 } from "@/internal/balances/utils/sync/syncItemV4.js";
|
||||
import { buildCustomerEntitlementFilters } from "../../utils/buildCustomerEntitlementFilters.js";
|
||||
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
|
||||
import { handleUpdateBalanceDeductionErrorV2 } from "./handleUpdateBalanceDeductionErrorV2.js";
|
||||
@@ -72,14 +72,17 @@ export const updateRemainingV2 = async ({
|
||||
const rolloverIds = Object.keys(rolloverUpdates);
|
||||
|
||||
if (cusEntIds.length > 0 || rolloverIds.length > 0) {
|
||||
globalSyncBatchingManagerV3.addSyncItem({
|
||||
customerId: fullSubject.customerId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
cusEntIds,
|
||||
rolloverIds,
|
||||
entityId: fullSubject.entityId,
|
||||
modifiedCusEntIdsByFeatureId,
|
||||
await syncItemV4({
|
||||
ctx,
|
||||
payload: {
|
||||
customerId: fullSubject.customerId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
timestamp: Date.now(),
|
||||
rolloverIds,
|
||||
entityId: fullSubject.entityId,
|
||||
modifiedCusEntIdsByFeatureId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { executeRedisDeductionV2 } from "@/internal/balances/utils/deductionV2/executeRedisDeductionV2.js";
|
||||
import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js";
|
||||
import { syncItemV4 } from "@/internal/balances/utils/sync/syncItemV4.js";
|
||||
import { buildCustomerEntitlementFilters } from "../../utils/buildCustomerEntitlementFilters.js";
|
||||
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
|
||||
import { handleUpdateBalanceDeductionErrorV2 } from "./handleUpdateBalanceDeductionErrorV2.js";
|
||||
@@ -116,14 +116,17 @@ export const updateUsageV2 = async ({
|
||||
const rolloverIds = Object.keys(rolloverUpdates);
|
||||
|
||||
if (cusEntIds.length > 0 || rolloverIds.length > 0) {
|
||||
globalSyncBatchingManagerV3.addSyncItem({
|
||||
customerId: fullSubject.customerId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
cusEntIds,
|
||||
rolloverIds,
|
||||
entityId: fullSubject.entityId,
|
||||
modifiedCusEntIdsByFeatureId,
|
||||
await syncItemV4({
|
||||
ctx,
|
||||
payload: {
|
||||
customerId: fullSubject.customerId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
timestamp: Date.now(),
|
||||
rolloverIds,
|
||||
entityId: fullSubject.entityId,
|
||||
modifiedCusEntIdsByFeatureId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
import type { FullSubject } from "@autumn/shared";
|
||||
import type { DeductionUpdate } from "../types/deductionUpdate.js";
|
||||
|
||||
const getUpdatedReplaceables = ({
|
||||
replaceables,
|
||||
update,
|
||||
}: {
|
||||
replaceables: FullSubject["extra_customer_entitlements"][number]["replaceables"];
|
||||
update: DeductionUpdate;
|
||||
}) => {
|
||||
let nextReplaceables = replaceables ?? [];
|
||||
|
||||
if (update.newReplaceables) {
|
||||
nextReplaceables = [
|
||||
...nextReplaceables,
|
||||
...update.newReplaceables.map((replaceable) => ({
|
||||
...replaceable,
|
||||
delete_next_cycle: replaceable.delete_next_cycle ?? true,
|
||||
from_entity_id: replaceable.from_entity_id ?? null,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
if (update.deletedReplaceables) {
|
||||
const deletedReplaceableIds = new Set(
|
||||
update.deletedReplaceables.map((replaceable) => replaceable.id),
|
||||
);
|
||||
nextReplaceables = nextReplaceables.filter(
|
||||
(replaceable) => !deletedReplaceableIds.has(replaceable.id),
|
||||
);
|
||||
}
|
||||
|
||||
return nextReplaceables;
|
||||
};
|
||||
|
||||
const applyUpdate = ({
|
||||
customerEntitlement,
|
||||
update,
|
||||
@@ -10,8 +42,13 @@ const applyUpdate = ({
|
||||
}) => ({
|
||||
...customerEntitlement,
|
||||
balance: update.balance,
|
||||
additional_balance: update.additional_balance,
|
||||
adjustment: update.adjustment,
|
||||
entities: update.entities,
|
||||
replaceables: getUpdatedReplaceables({
|
||||
replaceables: customerEntitlement.replaceables,
|
||||
update,
|
||||
}),
|
||||
});
|
||||
|
||||
export const applyDeductionUpdateToFullSubject = ({
|
||||
|
||||
@@ -20,6 +20,7 @@ import { applyDeductionUpdateToFullSubject } from "./applyDeductionUpdateToFullS
|
||||
import { applyRolloverUpdatesToFullSubject } from "./applyRolloverUpdatesToFullSubject.js";
|
||||
import { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js";
|
||||
import { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js";
|
||||
import { normalizeDeductionSyncStateV2 } from "./normalizeDeductionSyncStateV2.js";
|
||||
import { prepareDeductionOptionsV2 } from "./prepareDeductionOptionsV2.js";
|
||||
import { prepareFeatureDeductionV2 } from "./prepareFeatureDeductionV2.js";
|
||||
import { rollbackDeductionV2 } from "./rollbackDeductionV2.js";
|
||||
@@ -87,6 +88,7 @@ export const executePostgresDeductionV2 = async ({
|
||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||
}> => {
|
||||
let allUpdates: Record<string, DeductionUpdate> = {};
|
||||
let allSyncUpdates: Record<string, DeductionUpdate> = {};
|
||||
let allRolloverOverwrites: RolloverOverwrite[] = [];
|
||||
let allMutationLogs: MutationLogItem[] = [];
|
||||
const allModifiedCusEntIdsByFeatureId: Record<string, string[]> = {};
|
||||
@@ -154,6 +156,7 @@ export const executePostgresDeductionV2 = async ({
|
||||
}
|
||||
|
||||
const { updates, rollover_updates, mutation_logs } = resultJson;
|
||||
|
||||
logDeductionUpdatesV2({
|
||||
ctx,
|
||||
fullSubject,
|
||||
@@ -166,15 +169,18 @@ export const executePostgresDeductionV2 = async ({
|
||||
allRolloverOverwrites = [...allRolloverOverwrites, ...rollover_updates];
|
||||
}
|
||||
|
||||
for (const ced of customerEntitlementDeductions) {
|
||||
if (!updates[ced.customer_entitlement_id]) continue;
|
||||
if (!allModifiedCusEntIdsByFeatureId[ced.feature_id]) {
|
||||
allModifiedCusEntIdsByFeatureId[ced.feature_id] = [];
|
||||
}
|
||||
allModifiedCusEntIdsByFeatureId[ced.feature_id].push(
|
||||
ced.customer_entitlement_id,
|
||||
);
|
||||
}
|
||||
const syncState = normalizeDeductionSyncStateV2({
|
||||
customerEntitlements,
|
||||
updates,
|
||||
mutationLogs: mutation_logs ?? [],
|
||||
syncUpdates: allSyncUpdates,
|
||||
modifiedCusEntIdsByFeatureId: allModifiedCusEntIdsByFeatureId,
|
||||
});
|
||||
allSyncUpdates = syncState.syncUpdates;
|
||||
Object.assign(
|
||||
allModifiedCusEntIdsByFeatureId,
|
||||
syncState.modifiedCusEntIdsByFeatureId,
|
||||
);
|
||||
|
||||
const oldFullCustomer = fullSubjectToFullCustomer({
|
||||
fullSubject: oldFullSubject,
|
||||
@@ -274,7 +280,7 @@ export const executePostgresDeductionV2 = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
fullSubject: oldFullSubject,
|
||||
cusEntUpdates: allUpdates,
|
||||
cusEntUpdates: allSyncUpdates,
|
||||
rolloverOverwrites: allRolloverOverwrites,
|
||||
modifiedCusEntIdsByFeatureId: allModifiedCusEntIdsByFeatureId,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { applyDeductionUpdateToFullSubject } from "./applyDeductionUpdateToFullS
|
||||
import { applyRolloverUpdatesToFullSubject } from "./applyRolloverUpdatesToFullSubject.js";
|
||||
import { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js";
|
||||
import { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js";
|
||||
import { normalizeDeductionSyncStateV2 } from "./normalizeDeductionSyncStateV2.js";
|
||||
import { prepareDeductionOptionsV2 } from "./prepareDeductionOptionsV2.js";
|
||||
import { prepareFeatureDeductionV2 } from "./prepareFeatureDeductionV2.js";
|
||||
import { rollbackDeductionV2 } from "./rollbackDeductionV2.js";
|
||||
@@ -185,6 +186,11 @@ export const executeRedisDeductionV2 = async ({
|
||||
const mutationLogs = Array.isArray(resultJson.mutation_logs)
|
||||
? resultJson.mutation_logs
|
||||
: [];
|
||||
const modifiedCustomerEntitlementIds = Array.isArray(
|
||||
resultJson.modified_customer_entitlement_ids,
|
||||
)
|
||||
? resultJson.modified_customer_entitlement_ids
|
||||
: Object.keys(updates);
|
||||
|
||||
logDeductionUpdatesV2({
|
||||
ctx,
|
||||
@@ -197,15 +203,18 @@ export const executeRedisDeductionV2 = async ({
|
||||
allRolloverUpdates = { ...allRolloverUpdates, ...rollover_updates };
|
||||
allMutationLogs = [...allMutationLogs, ...mutationLogs];
|
||||
|
||||
for (const ced of customerEntitlementDeductions) {
|
||||
if (!updates[ced.customer_entitlement_id]) continue;
|
||||
if (!allModifiedCusEntIdsByFeatureId[ced.feature_id]) {
|
||||
allModifiedCusEntIdsByFeatureId[ced.feature_id] = [];
|
||||
}
|
||||
allModifiedCusEntIdsByFeatureId[ced.feature_id].push(
|
||||
ced.customer_entitlement_id,
|
||||
);
|
||||
}
|
||||
const syncState = normalizeDeductionSyncStateV2({
|
||||
customerEntitlements,
|
||||
updates,
|
||||
mutationLogs,
|
||||
modifiedCustomerEntitlementIds,
|
||||
syncUpdates: {},
|
||||
modifiedCusEntIdsByFeatureId: allModifiedCusEntIdsByFeatureId,
|
||||
});
|
||||
Object.assign(
|
||||
allModifiedCusEntIdsByFeatureId,
|
||||
syncState.modifiedCusEntIdsByFeatureId,
|
||||
);
|
||||
|
||||
const oldFullCustomer = fullSubjectToFullCustomer({
|
||||
fullSubject: oldFullSubject,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { FullCusEntWithFullCusProduct } from "@autumn/shared";
|
||||
import type { DeductionUpdate } from "../types/deductionUpdate.js";
|
||||
import type { MutationLogItem } from "../types/mutationLogItem.js";
|
||||
|
||||
const buildZeroDeductionUpdate = ({
|
||||
customerEntitlement,
|
||||
}: {
|
||||
customerEntitlement: FullCusEntWithFullCusProduct;
|
||||
}): DeductionUpdate => ({
|
||||
balance: customerEntitlement.balance ?? 0,
|
||||
additional_balance: customerEntitlement.additional_balance ?? 0,
|
||||
adjustment: customerEntitlement.adjustment ?? 0,
|
||||
entities: customerEntitlement.entities ?? {},
|
||||
deducted: 0,
|
||||
});
|
||||
|
||||
const getTouchedCustomerEntitlementIds = ({
|
||||
updates,
|
||||
mutationLogs,
|
||||
modifiedCustomerEntitlementIds,
|
||||
}: {
|
||||
updates: Record<string, DeductionUpdate>;
|
||||
mutationLogs: MutationLogItem[];
|
||||
modifiedCustomerEntitlementIds?: string[];
|
||||
}): string[] => {
|
||||
const touchedCustomerEntitlementIds = new Set(Object.keys(updates));
|
||||
|
||||
for (const customerEntitlementId of modifiedCustomerEntitlementIds ?? []) {
|
||||
if (customerEntitlementId) {
|
||||
touchedCustomerEntitlementIds.add(customerEntitlementId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const mutationLog of mutationLogs) {
|
||||
if (mutationLog.customer_entitlement_id) {
|
||||
touchedCustomerEntitlementIds.add(mutationLog.customer_entitlement_id);
|
||||
}
|
||||
}
|
||||
|
||||
return [...touchedCustomerEntitlementIds];
|
||||
};
|
||||
|
||||
export const normalizeDeductionSyncStateV2 = ({
|
||||
customerEntitlements,
|
||||
updates,
|
||||
mutationLogs,
|
||||
modifiedCustomerEntitlementIds,
|
||||
syncUpdates,
|
||||
modifiedCusEntIdsByFeatureId,
|
||||
}: {
|
||||
customerEntitlements: FullCusEntWithFullCusProduct[];
|
||||
updates: Record<string, DeductionUpdate>;
|
||||
mutationLogs: MutationLogItem[];
|
||||
modifiedCustomerEntitlementIds?: string[];
|
||||
syncUpdates: Record<string, DeductionUpdate>;
|
||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||
}): {
|
||||
syncUpdates: Record<string, DeductionUpdate>;
|
||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||
} => {
|
||||
const nextSyncUpdates = { ...syncUpdates };
|
||||
const customerEntitlementById = new Map(
|
||||
customerEntitlements.map((customerEntitlement) => [
|
||||
customerEntitlement.id,
|
||||
customerEntitlement,
|
||||
]),
|
||||
);
|
||||
const modifiedCusEntIdSetsByFeatureId = new Map<string, Set<string>>();
|
||||
|
||||
for (const [featureId, customerEntitlementIds] of Object.entries(
|
||||
modifiedCusEntIdsByFeatureId,
|
||||
)) {
|
||||
modifiedCusEntIdSetsByFeatureId.set(
|
||||
featureId,
|
||||
new Set(customerEntitlementIds),
|
||||
);
|
||||
}
|
||||
|
||||
const touchedCustomerEntitlementIds = getTouchedCustomerEntitlementIds({
|
||||
updates,
|
||||
mutationLogs,
|
||||
modifiedCustomerEntitlementIds,
|
||||
});
|
||||
|
||||
for (const customerEntitlementId of touchedCustomerEntitlementIds) {
|
||||
const customerEntitlement = customerEntitlementById.get(
|
||||
customerEntitlementId,
|
||||
);
|
||||
if (!customerEntitlement) continue;
|
||||
|
||||
if (updates[customerEntitlementId]) {
|
||||
nextSyncUpdates[customerEntitlementId] = updates[customerEntitlementId];
|
||||
} else if (!nextSyncUpdates[customerEntitlementId]) {
|
||||
nextSyncUpdates[customerEntitlementId] = buildZeroDeductionUpdate({
|
||||
customerEntitlement,
|
||||
});
|
||||
}
|
||||
|
||||
const featureId = customerEntitlement.entitlement.feature.id;
|
||||
if (!modifiedCusEntIdSetsByFeatureId.has(featureId)) {
|
||||
modifiedCusEntIdSetsByFeatureId.set(featureId, new Set());
|
||||
}
|
||||
modifiedCusEntIdSetsByFeatureId.get(featureId)?.add(customerEntitlementId);
|
||||
}
|
||||
|
||||
return {
|
||||
syncUpdates: nextSyncUpdates,
|
||||
modifiedCusEntIdsByFeatureId: Object.fromEntries(
|
||||
[...modifiedCusEntIdSetsByFeatureId.entries()].map(
|
||||
([featureId, customerEntitlementIds]) => [
|
||||
featureId,
|
||||
[...customerEntitlementIds],
|
||||
],
|
||||
),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -33,6 +33,7 @@ interface SubjectBalanceUpdate {
|
||||
* Syncs deduction updates to the V2 FullSubject balance hashes.
|
||||
* Groups updates by featureId and pipelines one Lua call per feature.
|
||||
* Fire-and-forget — failures are logged but don't propagate.
|
||||
* Intentionally does not mutate cache_version; DB-side flows own version bumps.
|
||||
*/
|
||||
export const syncDeductionUpdatesToFullSubjectCache = async ({
|
||||
ctx,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ErrCode, InternalError, RecaseError } from "@autumn/shared";
|
||||
import { ErrCode, RecaseError } from "@autumn/shared";
|
||||
import { redis } from "@/external/redis/initRedis.js";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
@@ -66,12 +66,12 @@ export const fetchLockReceipt = async ({
|
||||
),
|
||||
]);
|
||||
|
||||
if (rawReceiptV1 && rawReceiptV2) {
|
||||
throw new InternalError({
|
||||
message: `Lock receipt found in both Redis stores for ID: ${lockId}`,
|
||||
code: "lock_receipt_found_in_both_stores",
|
||||
});
|
||||
}
|
||||
// if (rawReceiptV1 && rawReceiptV2) {
|
||||
// throw new InternalError({
|
||||
// message: `Lock receipt found in both Redis stores for ID: ${lockId}`,
|
||||
// code: "lock_receipt_found_in_both_stores",
|
||||
// });
|
||||
// }
|
||||
|
||||
const rawReceipt = rawReceiptV2 ?? rawReceiptV1;
|
||||
const source: LockReceiptSource = rawReceiptV2 ? "redis_v2" : "redis_v1";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
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 { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { getEntityAggregateForSync } from "@/internal/customers/repos/getFullSubject/getEntityAggregateForSync.js";
|
||||
|
||||
/**
|
||||
* After DB sync, recompute entity aggregation from the now-authoritative DB
|
||||
* and HSET `_aggregated` on the affected balance hashes.
|
||||
*/
|
||||
export const refreshEntityAggregateCache = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
orgId,
|
||||
env,
|
||||
featureIds,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
featureIds: string[];
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
const aggregated = await getEntityAggregateForSync({
|
||||
db: ctx.db,
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
});
|
||||
|
||||
if (aggregated.length === 0) return;
|
||||
|
||||
const affectedFeatureIds = new Set(featureIds);
|
||||
const pipeline = redisV2.pipeline();
|
||||
let writeCount = 0;
|
||||
|
||||
for (const entry of aggregated) {
|
||||
if (!affectedFeatureIds.has(entry.feature_id)) continue;
|
||||
|
||||
const balanceKey = buildSharedFullSubjectBalanceKey({
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
featureId: entry.feature_id,
|
||||
});
|
||||
pipeline.hset(
|
||||
balanceKey,
|
||||
AGGREGATED_BALANCE_FIELD,
|
||||
JSON.stringify(entry),
|
||||
);
|
||||
writeCount++;
|
||||
}
|
||||
|
||||
if (writeCount > 0) {
|
||||
await tryRedisWrite(() => pipeline.exec(), redisV2);
|
||||
ctx.logger.info(
|
||||
`[SYNC V4] (${customerId}) Refreshed _aggregated for ${writeCount} features`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger.warn(
|
||||
`[SYNC V4] (${customerId}) Failed to refresh entity aggregation: ${error}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
type EntityBalance,
|
||||
type EntityRolloverBalance,
|
||||
type SubjectBalance,
|
||||
@@ -8,6 +9,7 @@ import { sql } from "drizzle-orm";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { invalidateCachedFullSubject } from "@/internal/customers/cache/fullSubject/actions/invalidate/invalidateFullSubject.js";
|
||||
import { getCachedFeatureBalance } from "@/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.js";
|
||||
import { refreshEntityAggregateCache } from "./refreshEntityAggregateCache.js";
|
||||
|
||||
const SYNC_CONFLICT_CODES = {
|
||||
ResetAtMismatch: "RESET_AT_MISMATCH",
|
||||
@@ -61,7 +63,7 @@ interface SyncItemV4 {
|
||||
customerId: string;
|
||||
entityId?: string;
|
||||
orgId: string;
|
||||
env: string;
|
||||
env: AppEnv;
|
||||
timestamp: number;
|
||||
rolloverIds?: string[];
|
||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||
@@ -151,6 +153,7 @@ export const syncItemV4 = async ({
|
||||
customerId,
|
||||
featureId,
|
||||
customerEntitlementIds,
|
||||
readMaster: true,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
@@ -237,4 +240,17 @@ export const syncItemV4 = async ({
|
||||
logger.info(
|
||||
`[SYNC V4] (${customerId}) Done: ${updateCount} cus_ents, ${rolloverUpdateCount} rollovers updated`,
|
||||
);
|
||||
|
||||
const hasEntityLevel = allSubjectBalances.some(
|
||||
(subjectBalance) => subjectBalance.isEntityLevel,
|
||||
);
|
||||
if (hasEntityLevel) {
|
||||
await refreshEntityAggregateCache({
|
||||
ctx,
|
||||
customerId,
|
||||
orgId,
|
||||
env,
|
||||
featureIds: Object.keys(modifiedCusEntIdsByFeatureId),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RolloverUpdate } from "./rolloverUpdate.js";
|
||||
export interface LuaDeductionResult {
|
||||
updates: Record<string, DeductionUpdate>;
|
||||
rollover_updates: Record<string, RolloverUpdate>;
|
||||
modified_customer_entitlement_ids: string[];
|
||||
mutation_logs: MutationLogItem[];
|
||||
remaining: number;
|
||||
error?: string;
|
||||
|
||||
@@ -30,6 +30,7 @@ export const updateCustomerEntitlements = async ({
|
||||
`updating customer entitlement ${customerEntitlement.id} ${balanceChange ? `+${balanceChange}` : updates ? JSON.stringify(updates) : "none"}`,
|
||||
);
|
||||
|
||||
const featureId = customerEntitlement.entitlement.feature.id;
|
||||
// 1. Handle field-level updates (e.g. next_reset_at, adjustment, entities)
|
||||
if (updates) {
|
||||
await customerEntitlementActions.updateDbAndCache({
|
||||
@@ -38,6 +39,7 @@ export const updateCustomerEntitlements = async ({
|
||||
cusEntId: customerEntitlement.id,
|
||||
updates,
|
||||
incrementCacheVersion: true,
|
||||
featureId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -49,6 +51,7 @@ export const updateCustomerEntitlements = async ({
|
||||
customerId,
|
||||
cusEntId: customerEntitlement.id,
|
||||
delta: balanceChange,
|
||||
featureId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type Stripe from "stripe";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts";
|
||||
import { updateCustomerEntitlements } from "@/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements";
|
||||
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
|
||||
import { invoiceActions } from "@/internal/invoices/actions";
|
||||
@@ -74,13 +73,12 @@ export const executeAutumnBillingPlan = async ({
|
||||
newCusProducts: insertCustomerProducts,
|
||||
});
|
||||
|
||||
// 3. Update customer product (DB + cache)
|
||||
// 3. Update customer product (DB only)
|
||||
if (updateCustomerProduct) {
|
||||
const { customerProduct, updates } = updateCustomerProduct;
|
||||
|
||||
await customerProductActions.updateDbAndCache({
|
||||
await CusProductService.update({
|
||||
ctx,
|
||||
customerId: autumnBillingPlan.customerId,
|
||||
cusProductId: customerProduct.id,
|
||||
updates,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ export const getApiCustomerByRollout = async ({
|
||||
source,
|
||||
});
|
||||
|
||||
// console.log("fullSubject", fullSubject);
|
||||
|
||||
return getApiCustomerV2({
|
||||
ctx,
|
||||
fullSubject,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type { BatchResetCusEntsPayload } from "@/queue/workflows.js";
|
||||
// import { getFullSubject } from "../../repos/getFullSubject/getFullSubject.js";
|
||||
// import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
||||
import { CusService } from "../../CusService.js";
|
||||
|
||||
/**
|
||||
@@ -24,13 +26,22 @@ export const batchResetCustomerEntitlements = async ({
|
||||
const batch = resets.slice(i, i + BATCH_SIZE);
|
||||
|
||||
await Promise.all(
|
||||
batch.map((reset) =>
|
||||
CusService.getFull({
|
||||
batch.map(async (reset) => {
|
||||
await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: reset.internalCustomerId,
|
||||
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// V2 subject cache path: triggers lazyResetSubjectEntitlements
|
||||
// if (isFullSubjectRolloutEnabled({ ctx })) {
|
||||
// await getFullSubject({
|
||||
// ctx,
|
||||
// customerId: reset.internalCustomerId,
|
||||
// inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
|
||||
// });
|
||||
// }
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
CusProductStatus,
|
||||
cusEntToCusPrice,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
type FullCustomer,
|
||||
fullCustomerToCustomerEntitlements,
|
||||
} from "@autumn/shared";
|
||||
import { getResettableCustomerEntitlements } from "../resetCustomerEntitlementsV2/getResettableCustomerEntitlements.js";
|
||||
|
||||
/** Collects cusEnts from a FullCustomer that need resetting (next_reset_at < now). */
|
||||
export const getCusEntsNeedingReset = ({
|
||||
@@ -14,24 +14,10 @@ export const getCusEntsNeedingReset = ({
|
||||
fullCus: FullCustomer;
|
||||
now: number;
|
||||
}): FullCusEntWithFullCusProduct[] => {
|
||||
const result: FullCusEntWithFullCusProduct[] = [];
|
||||
|
||||
const cusEnts = fullCustomerToCustomerEntitlements({
|
||||
const customerEntitlements = fullCustomerToCustomerEntitlements({
|
||||
fullCustomer: fullCus,
|
||||
inStatuses: [CusProductStatus.Active],
|
||||
});
|
||||
|
||||
for (const cusEnt of cusEnts) {
|
||||
if (!cusEnt.next_reset_at || cusEnt.next_reset_at >= now) continue;
|
||||
|
||||
const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||
if (cusPrice) continue;
|
||||
|
||||
result.push({
|
||||
...cusEnt,
|
||||
customer_product: cusEnt.customer_product,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return getResettableCustomerEntitlements({ customerEntitlements, now });
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ResetCusEntParam,
|
||||
resetCusEnts,
|
||||
} from "@/internal/balances/utils/sql/client.js";
|
||||
import { resetSubjectCache } from "../resetCustomerEntitlementsV2/resetSubjectCache.js";
|
||||
import { applyResetResults } from "./applyResetResults.js";
|
||||
import { executeResetCache } from "./executeResetCache.js";
|
||||
import { getCusEntsNeedingReset } from "./getCusEntsNeedingReset.js";
|
||||
@@ -101,12 +102,13 @@ export const resetCustomerEntitlements = async ({
|
||||
// Only needed when we actually wrote to DB — skipped means cache was
|
||||
// already updated by the winning request.
|
||||
if (Object.keys(applied).length > 0) {
|
||||
// Build map of cusEntId -> old next_reset_at for the optimistic guard
|
||||
const oldNextResetAts: Record<string, number> = {};
|
||||
const customerEntitlementFeatureIds: Record<string, string> = {};
|
||||
for (const cusEnt of cusEntsNeedingReset) {
|
||||
if (cusEnt.next_reset_at) {
|
||||
oldNextResetAts[cusEnt.id] = cusEnt.next_reset_at;
|
||||
}
|
||||
customerEntitlementFeatureIds[cusEnt.id] = cusEnt.feature_id;
|
||||
}
|
||||
|
||||
await executeResetCache({
|
||||
@@ -117,6 +119,15 @@ export const resetCustomerEntitlements = async ({
|
||||
clearingMap,
|
||||
});
|
||||
|
||||
await resetSubjectCache({
|
||||
ctx,
|
||||
customerId,
|
||||
resets,
|
||||
oldNextResetAts,
|
||||
clearingMap,
|
||||
customerEntitlementFeatureIds,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[resetCustomerEntitlements] customer=${customerId}, Redis cache updated`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
FullCusEntWithProduct,
|
||||
FullSubject,
|
||||
NormalizedFullSubject,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js";
|
||||
import type { RolloverClearingInfo } from "../resetCustomerEntitlements/applyResetResults.js";
|
||||
import type { ProcessResetResult } from "../resetCustomerEntitlements/processReset.js";
|
||||
|
||||
/** Find a customer entitlement on the FullSubject by ID. Uses Object.assign to
|
||||
* preserve the original reference so in-place mutations propagate back. */
|
||||
const findCustomerEntitlement = ({
|
||||
fullSubject,
|
||||
customerEntitlementId,
|
||||
}: {
|
||||
fullSubject: FullSubject;
|
||||
customerEntitlementId: string;
|
||||
}): FullCusEntWithProduct | null => {
|
||||
for (const customerProduct of fullSubject.customer_products) {
|
||||
for (const customerEntitlement of customerProduct.customer_entitlements) {
|
||||
if (customerEntitlement.id === customerEntitlementId)
|
||||
return Object.assign(customerEntitlement, {
|
||||
customer_product: customerProduct,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const customerEntitlement of fullSubject.extra_customer_entitlements ||
|
||||
[]) {
|
||||
if (customerEntitlement.id === customerEntitlementId)
|
||||
return Object.assign(customerEntitlement, { customer_product: null });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies computed reset values to in-memory FullSubject for all customer entitlements,
|
||||
* and runs rollover max-clearing only for DB-applied (non-skipped) ones.
|
||||
* For skipped entries (another request won the race), re-reads rollovers from DB.
|
||||
* Returns per-cusEnt clearing info so the cache update can propagate deletes/overwrites.
|
||||
*/
|
||||
export const applyResetResultsToFullSubject = async ({
|
||||
ctx,
|
||||
fullSubject,
|
||||
computed,
|
||||
skipped,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullSubject: FullSubject;
|
||||
computed: Array<{
|
||||
customerEntitlementId: string;
|
||||
result: ProcessResetResult;
|
||||
}>;
|
||||
skipped: string[];
|
||||
}): Promise<Record<string, RolloverClearingInfo>> => {
|
||||
const skippedSet = new Set(skipped);
|
||||
const clearingMap: Record<string, RolloverClearingInfo> = {};
|
||||
|
||||
const generalCtx = { ...ctx, db: ctx.dbGeneral };
|
||||
|
||||
for (const { customerEntitlementId, result } of computed) {
|
||||
const original = findCustomerEntitlement({
|
||||
fullSubject,
|
||||
customerEntitlementId,
|
||||
});
|
||||
if (!original) continue;
|
||||
|
||||
const { updates } = result;
|
||||
if (updates.balance !== null) original.balance = updates.balance;
|
||||
if (updates.additional_balance !== null)
|
||||
original.additional_balance = updates.additional_balance;
|
||||
original.adjustment = updates.adjustment;
|
||||
if (updates.entities !== null) original.entities = updates.entities;
|
||||
original.next_reset_at = updates.next_reset_at;
|
||||
|
||||
if (!result.rolloverInsert) continue;
|
||||
|
||||
if (!skippedSet.has(customerEntitlementId)) {
|
||||
const { rollovers, deletedIds, overwrites } =
|
||||
await RolloverService.clearExcessRollovers({
|
||||
ctx: generalCtx,
|
||||
newRows: result.rolloverInsert.rows,
|
||||
fullCusEnt: original,
|
||||
});
|
||||
original.rollovers = rollovers;
|
||||
|
||||
if (deletedIds.length > 0 || overwrites.length > 0) {
|
||||
clearingMap[customerEntitlementId] = { deletedIds, overwrites };
|
||||
}
|
||||
} else {
|
||||
original.rollovers = await RolloverService.getCurrentRollovers({
|
||||
ctx: generalCtx,
|
||||
cusEntID: customerEntitlementId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return clearingMap;
|
||||
};
|
||||
|
||||
/** Applies the same reset field updates to normalized.customer_entitlements (SubjectBalance entries). */
|
||||
export const applyResetResultsToNormalized = ({
|
||||
normalized,
|
||||
computed,
|
||||
}: {
|
||||
normalized: NormalizedFullSubject;
|
||||
computed: Array<{
|
||||
customerEntitlementId: string;
|
||||
result: ProcessResetResult;
|
||||
}>;
|
||||
}) => {
|
||||
for (const { customerEntitlementId, result } of computed) {
|
||||
const subjectBalance = normalized.customer_entitlements.find(
|
||||
(customerEntitlement) => customerEntitlement.id === customerEntitlementId,
|
||||
);
|
||||
if (!subjectBalance) continue;
|
||||
|
||||
const { updates } = result;
|
||||
if (updates.balance !== null) subjectBalance.balance = updates.balance;
|
||||
if (updates.additional_balance !== null)
|
||||
subjectBalance.additional_balance = updates.additional_balance;
|
||||
subjectBalance.adjustment = updates.adjustment;
|
||||
if (updates.entities !== null) subjectBalance.entities = updates.entities;
|
||||
subjectBalance.next_reset_at = updates.next_reset_at;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
cusEntToCusPrice,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
} from "@autumn/shared";
|
||||
|
||||
/** Filters customer entitlements to those needing reset: overdue next_reset_at, not price-backed. */
|
||||
export const getResettableCustomerEntitlements = ({
|
||||
customerEntitlements,
|
||||
now,
|
||||
}: {
|
||||
customerEntitlements: FullCusEntWithFullCusProduct[];
|
||||
now: number;
|
||||
}): FullCusEntWithFullCusProduct[] => {
|
||||
const result: FullCusEntWithFullCusProduct[] = [];
|
||||
|
||||
for (const customerEntitlement of customerEntitlements) {
|
||||
if (
|
||||
!customerEntitlement.next_reset_at ||
|
||||
customerEntitlement.next_reset_at >= now
|
||||
)
|
||||
continue;
|
||||
|
||||
const customerPrice = cusEntToCusPrice({ cusEnt: customerEntitlement });
|
||||
if (customerPrice) continue;
|
||||
|
||||
result.push(customerEntitlement);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
CusProductStatus,
|
||||
type FullSubject,
|
||||
fullSubjectToCustomerEntitlements,
|
||||
type NormalizedFullSubject,
|
||||
} from "@autumn/shared";
|
||||
import * as Sentry from "@sentry/bun";
|
||||
import { getDbHealth, PgHealth } from "@/db/pgHealthMonitor.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import {
|
||||
type ResetCusEntParam,
|
||||
resetCusEnts,
|
||||
} from "@/internal/balances/utils/sql/client.js";
|
||||
import type { ProcessResetResult } from "../resetCustomerEntitlements/processReset.js";
|
||||
import { processReset } from "../resetCustomerEntitlements/processReset.js";
|
||||
import {
|
||||
applyResetResultsToFullSubject,
|
||||
applyResetResultsToNormalized,
|
||||
} from "./applyResetResultsToFullSubject.js";
|
||||
import { getResettableCustomerEntitlements } from "./getResettableCustomerEntitlements.js";
|
||||
import { resetSubjectCache } from "./resetSubjectCache.js";
|
||||
|
||||
const toResetParam = ({
|
||||
customerEntitlementId,
|
||||
result,
|
||||
}: {
|
||||
customerEntitlementId: string;
|
||||
result: ProcessResetResult;
|
||||
}): ResetCusEntParam => {
|
||||
const { updates } = result;
|
||||
const firstRollover = result.rolloverInsert?.rows[0] ?? null;
|
||||
|
||||
return {
|
||||
cus_ent_id: customerEntitlementId,
|
||||
balance: updates.balance,
|
||||
additional_balance: updates.additional_balance,
|
||||
adjustment: updates.adjustment,
|
||||
entities: updates.entities,
|
||||
next_reset_at: updates.next_reset_at,
|
||||
rollover_insert: firstRollover,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Lazily resets overdue customer entitlements from a FullSubject.
|
||||
* Same DB semantics as resetCustomerEntitlements but works on FullSubject.
|
||||
* Mutates the FullSubject in-memory and patches shared balance hashes.
|
||||
* Returns true if any entitlements were reset.
|
||||
*/
|
||||
export const lazyResetSubjectEntitlements = async ({
|
||||
ctx,
|
||||
fullSubject,
|
||||
normalized,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullSubject: FullSubject;
|
||||
normalized?: NormalizedFullSubject;
|
||||
}): Promise<boolean> => {
|
||||
if (getDbHealth() === PgHealth.Degraded) return false;
|
||||
|
||||
const now = Date.now();
|
||||
const { logger } = ctx;
|
||||
const customerId = fullSubject.customerId;
|
||||
|
||||
const allCustomerEntitlements = fullSubjectToCustomerEntitlements({
|
||||
fullSubject,
|
||||
inStatuses: [CusProductStatus.Active],
|
||||
});
|
||||
|
||||
const customerEntitlementsNeedingReset = getResettableCustomerEntitlements({
|
||||
customerEntitlements: allCustomerEntitlements,
|
||||
now,
|
||||
});
|
||||
|
||||
if (customerEntitlementsNeedingReset.length === 0) return false;
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`[lazyResetSubjectEntitlements] customer: ${customerId}, needing reset: ${customerEntitlementsNeedingReset.length}`,
|
||||
);
|
||||
|
||||
const computed: Array<{
|
||||
customerEntitlementId: string;
|
||||
result: ProcessResetResult;
|
||||
}> = [];
|
||||
|
||||
for (const customerEntitlement of customerEntitlementsNeedingReset) {
|
||||
const result = await processReset({
|
||||
cusEnt: customerEntitlement,
|
||||
ctx,
|
||||
});
|
||||
if (!result) continue;
|
||||
computed.push({
|
||||
customerEntitlementId: customerEntitlement.id,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
if (computed.length === 0) return false;
|
||||
|
||||
const resets = computed.map(({ customerEntitlementId, result }) =>
|
||||
toResetParam({ customerEntitlementId, result }),
|
||||
);
|
||||
|
||||
const { applied, skipped } = await resetCusEnts({ ctx, resets });
|
||||
|
||||
logger.info(
|
||||
`[lazyResetSubjectEntitlements] customer: ${customerId}, applied: ${Object.keys(applied).length}, skipped: ${skipped.length}`,
|
||||
);
|
||||
|
||||
const clearingMap = await applyResetResultsToFullSubject({
|
||||
ctx,
|
||||
fullSubject,
|
||||
computed,
|
||||
skipped,
|
||||
});
|
||||
|
||||
if (normalized) {
|
||||
applyResetResultsToNormalized({ normalized, computed });
|
||||
}
|
||||
|
||||
if (Object.keys(applied).length > 0) {
|
||||
const oldNextResetAts: Record<string, number> = {};
|
||||
const customerEntitlementFeatureIds: Record<string, string> = {};
|
||||
|
||||
for (const customerEntitlement of customerEntitlementsNeedingReset) {
|
||||
if (customerEntitlement.next_reset_at) {
|
||||
oldNextResetAts[customerEntitlement.id] =
|
||||
customerEntitlement.next_reset_at;
|
||||
}
|
||||
customerEntitlementFeatureIds[customerEntitlement.id] =
|
||||
customerEntitlement.feature_id;
|
||||
}
|
||||
|
||||
await resetSubjectCache({
|
||||
ctx,
|
||||
customerId,
|
||||
resets,
|
||||
oldNextResetAts,
|
||||
clearingMap,
|
||||
customerEntitlementFeatureIds,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[lazyResetSubjectEntitlements] customer: ${customerId}, subject cache updated`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[lazyResetSubjectEntitlements] customer: ${customerId}, failed: ${error}`,
|
||||
);
|
||||
Sentry.captureException(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
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 type { RolloverClearingInfo } from "../resetCustomerEntitlements/applyResetResults.js";
|
||||
|
||||
interface SubjectBalanceUpdate {
|
||||
cus_ent_id: string;
|
||||
balance: number | null;
|
||||
additional_balance: number | null;
|
||||
adjustment: number | null;
|
||||
entities: Record<string, unknown> | 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.
|
||||
*/
|
||||
export const resetSubjectCache = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
resets,
|
||||
oldNextResetAts,
|
||||
clearingMap,
|
||||
customerEntitlementFeatureIds,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
resets: ResetCusEntParam[];
|
||||
oldNextResetAts: Record<string, number>;
|
||||
clearingMap: Record<string, RolloverClearingInfo>;
|
||||
customerEntitlementFeatureIds: Record<string, string>;
|
||||
}): Promise<void> => {
|
||||
if (resets.length === 0) return;
|
||||
|
||||
try {
|
||||
const { org, env } = ctx;
|
||||
|
||||
const updatesByFeatureId: Record<string, SubjectBalanceUpdate[]> = {};
|
||||
|
||||
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<string, boolean>;
|
||||
skipped?: string[];
|
||||
logs?: string[];
|
||||
};
|
||||
if (parsed.logs && parsed.logs.length > 0) {
|
||||
ctx.logger.debug(
|
||||
`[resetSubjectCache] Lua logs:\n${parsed.logs.join("\n")}`,
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger.error(
|
||||
`[resetSubjectCache] customer=${customerId}, failed: ${error}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { invalidateCachedFullSubject } from "@/internal/customers/cache/fullSubject/actions/invalidate/invalidateFullSubject.js";
|
||||
import { updateCachedCustomerData } from "@/internal/customers/cache/fullSubject/actions/updateCachedCustomerData.js";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
|
||||
export const updateCustomer = async ({
|
||||
@@ -134,5 +136,23 @@ export const updateCustomer = async ({
|
||||
update: updateData,
|
||||
});
|
||||
|
||||
return newCustomerId ?? customerId;
|
||||
const originalCustomerId = originalCustomer.id || originalCustomer.internal_id;
|
||||
const updatedCustomerId = newCustomerId ?? customerId;
|
||||
|
||||
if (updatedCustomerId !== originalCustomerId) {
|
||||
await invalidateCachedFullSubject({
|
||||
ctx,
|
||||
customerId: originalCustomerId,
|
||||
source: "updateCustomer:id_changed",
|
||||
});
|
||||
return updatedCustomerId;
|
||||
}
|
||||
|
||||
await updateCachedCustomerData({
|
||||
ctx,
|
||||
customerId: originalCustomerId,
|
||||
updates: updateData,
|
||||
});
|
||||
|
||||
return updatedCustomerId;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type FullSubject, normalizedToFullSubject } from "@autumn/shared";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
|
||||
import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
||||
import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js";
|
||||
import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { applyLiveAggregatedBalances } from "../balances/applyLiveAggregatedBalances.js";
|
||||
import { getCachedFeatureBalancesBatch } from "../balances/getCachedFeatureBalances.js";
|
||||
import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js";
|
||||
import {
|
||||
@@ -93,12 +95,14 @@ export const getCachedFullSubject = async ({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isCustomerSubject = !entityId;
|
||||
const balances = await getCachedFeatureBalancesBatch({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
featureIds: cached.meteredFeatures,
|
||||
customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId,
|
||||
includeAggregated: isCustomerSubject,
|
||||
});
|
||||
|
||||
if (!balances || balances.length !== cached.meteredFeatures.length) {
|
||||
@@ -120,7 +124,16 @@ export const getCachedFullSubject = async ({
|
||||
customerEntitlements: balances.flatMap((balance) => balance.balances),
|
||||
});
|
||||
|
||||
return normalizedToFullSubject({ normalized });
|
||||
if (isCustomerSubject) {
|
||||
applyLiveAggregatedBalances({
|
||||
normalized,
|
||||
featureBalances: balances,
|
||||
});
|
||||
}
|
||||
|
||||
const fullSubject = normalizedToFullSubject({ normalized });
|
||||
await lazyResetSubjectEntitlements({ ctx, fullSubject });
|
||||
return fullSubject;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[getCachedFullSubject] Failed to hydrate cached subject for ${customerId}${entityId ? `:${entityId}` : ""}, source: ${source}, error: ${error}`,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
type CheckParams,
|
||||
type FullSubject,
|
||||
fullCustomerToFullSubject,
|
||||
normalizedToFullSubject,
|
||||
SubjectType,
|
||||
type TrackParams,
|
||||
} from "@autumn/shared";
|
||||
@@ -27,7 +26,6 @@ export const getOrCreateCachedFullSubject = async ({
|
||||
source?: string;
|
||||
}): Promise<FullSubject> => {
|
||||
const { skipCache, logger } = ctx;
|
||||
const fetchTimeMs = Date.now();
|
||||
const {
|
||||
customer_id: customerId,
|
||||
customer_data: customerData,
|
||||
@@ -36,7 +34,7 @@ export const getOrCreateCachedFullSubject = async ({
|
||||
} = params;
|
||||
|
||||
let fullSubject: FullSubject | undefined;
|
||||
let normalized: Awaited<ReturnType<typeof getFullSubjectNormalized>>;
|
||||
let normalizedResult: Awaited<ReturnType<typeof getFullSubjectNormalized>>;
|
||||
let setCache = true;
|
||||
let fetchedSubjectViewEpoch = 0;
|
||||
|
||||
@@ -59,13 +57,13 @@ export const getOrCreateCachedFullSubject = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
});
|
||||
normalized = await getFullSubjectNormalized({
|
||||
normalizedResult = await getFullSubjectNormalized({
|
||||
ctx,
|
||||
customerId,
|
||||
entityId,
|
||||
});
|
||||
if (normalized) {
|
||||
fullSubject = normalizedToFullSubject({ normalized });
|
||||
if (normalizedResult) {
|
||||
fullSubject = normalizedResult.fullSubject;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,24 +106,23 @@ export const getOrCreateCachedFullSubject = async ({
|
||||
}
|
||||
|
||||
if (!skipCache && setCache) {
|
||||
if (!normalized) {
|
||||
normalized = await getFullSubjectNormalized({
|
||||
if (!normalizedResult) {
|
||||
normalizedResult = await getFullSubjectNormalized({
|
||||
ctx,
|
||||
customerId: fullSubject.customer.id || fullSubject.customer.internal_id,
|
||||
entityId: fullSubject.entity?.id || entityId,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalized) {
|
||||
if (normalizedResult) {
|
||||
await setCachedFullSubject({
|
||||
ctx,
|
||||
normalized,
|
||||
fetchTimeMs,
|
||||
normalized: normalizedResult.normalized,
|
||||
fetchedSubjectViewEpoch,
|
||||
}).catch((error) =>
|
||||
logger.error(`Failed to set full subject cache: ${error}`),
|
||||
);
|
||||
fullSubject = normalizedToFullSubject({ normalized });
|
||||
fullSubject = normalizedResult.fullSubject;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
CustomerNotFoundError,
|
||||
EntityNotFoundError,
|
||||
type FullSubject,
|
||||
normalizedToFullSubject,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js";
|
||||
@@ -22,7 +21,6 @@ export const getOrSetCachedFullSubject = async ({
|
||||
source?: string;
|
||||
}): Promise<FullSubject> => {
|
||||
const { skipCache, logger } = ctx;
|
||||
const fetchTimeMs = Date.now();
|
||||
|
||||
if (!skipCache) {
|
||||
const cached = await getCachedFullSubject({
|
||||
@@ -48,25 +46,39 @@ export const getOrSetCachedFullSubject = async ({
|
||||
customerId,
|
||||
});
|
||||
|
||||
const normalized = await getFullSubjectNormalized({
|
||||
const result = await getFullSubjectNormalized({
|
||||
ctx,
|
||||
customerId,
|
||||
entityId,
|
||||
});
|
||||
|
||||
if (!normalized) {
|
||||
if (!result) {
|
||||
if (entityId) throw new EntityNotFoundError({ entityId });
|
||||
throw new CustomerNotFoundError({ customerId });
|
||||
}
|
||||
|
||||
const { normalized, fullSubject } = result;
|
||||
|
||||
if (!skipCache) {
|
||||
await setCachedFullSubject({
|
||||
ctx,
|
||||
normalized,
|
||||
fetchTimeMs,
|
||||
fetchedSubjectViewEpoch,
|
||||
});
|
||||
|
||||
// Re-read from cache instead of returning the DB-fetched fullSubject.
|
||||
// Balance hash fields use HSETNX, so in-flight Lua deduction patches
|
||||
// survive the setCachedFullSubject write. The DB data may be stale
|
||||
// (e.g. entity view rebuilt before sync completes), but the balance
|
||||
// hash reflects the true Redis state.
|
||||
const freshCached = await getCachedFullSubject({
|
||||
ctx,
|
||||
customerId,
|
||||
entityId,
|
||||
source: `${source}:post-set`,
|
||||
});
|
||||
if (freshCached) return freshCached;
|
||||
}
|
||||
|
||||
return normalizedToFullSubject({ normalized });
|
||||
return fullSubject;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { AppEnv, Feature } from "@autumn/shared";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import { batchDeleteCachedFullCustomers } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.js";
|
||||
import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.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;
|
||||
env: AppEnv;
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
type FeaturesByOrgEnv = Record<string, Feature[]>;
|
||||
|
||||
export const batchInvalidateCachedFullSubjects = async ({
|
||||
customers,
|
||||
featuresByOrgEnv,
|
||||
}: {
|
||||
customers: BatchInvalidateCustomer[];
|
||||
featuresByOrgEnv: FeaturesByOrgEnv;
|
||||
}): Promise<number> => {
|
||||
if (customers.length === 0) return 0;
|
||||
|
||||
const deleted = await batchDeleteCachedFullCustomers({ customers });
|
||||
if (redisV2.status !== "ready") return deleted;
|
||||
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < customers.length;
|
||||
offset += PIPELINE_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(() => readPipeline.exec(), redisV2);
|
||||
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(() => writePipeline.exec(), redisV2);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js";
|
||||
import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js";
|
||||
|
||||
export const invalidateCustomerEntitlementBalance = async ({
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
featureId,
|
||||
customerEntitlementId,
|
||||
}: {
|
||||
orgId: string;
|
||||
env: string;
|
||||
customerId: string;
|
||||
featureId: string;
|
||||
customerEntitlementId: string;
|
||||
}): Promise<void> => {
|
||||
if (
|
||||
!orgId ||
|
||||
!env ||
|
||||
!customerId ||
|
||||
!featureId ||
|
||||
!customerEntitlementId ||
|
||||
redisV2.status !== "ready"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const balanceKey = buildSharedFullSubjectBalanceKey({
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
featureId,
|
||||
});
|
||||
|
||||
await tryRedisWrite(
|
||||
() =>
|
||||
redisV2.hdel(
|
||||
balanceKey,
|
||||
customerEntitlementId,
|
||||
AGGREGATED_BALANCE_FIELD,
|
||||
),
|
||||
redisV2,
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,30 @@
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { incrementFullSubjectViewEpoch } from "./incrementFullSubjectViewEpoch.js";
|
||||
import { invalidateCachedFullSubjectExact } from "./invalidateFullSubjectExact.js";
|
||||
import { invalidateSharedBalanceFields } from "./invalidateSharedBalanceFields.js";
|
||||
|
||||
export const invalidateCachedFullSubject = async ({
|
||||
customerId,
|
||||
entityId,
|
||||
ctx,
|
||||
source,
|
||||
skipGuard = false,
|
||||
}: {
|
||||
customerId: string;
|
||||
entityId?: string;
|
||||
ctx: AutumnContext;
|
||||
source?: string;
|
||||
skipGuard?: boolean;
|
||||
}): Promise<void> => {
|
||||
if (!customerId) return;
|
||||
|
||||
await invalidateSharedBalanceFields({
|
||||
ctx,
|
||||
customerId,
|
||||
});
|
||||
|
||||
await invalidateCachedFullSubjectExact({
|
||||
ctx,
|
||||
customerId,
|
||||
source,
|
||||
skipGuard,
|
||||
});
|
||||
|
||||
if (entityId) {
|
||||
@@ -30,7 +33,6 @@ export const invalidateCachedFullSubject = async ({
|
||||
customerId,
|
||||
entityId,
|
||||
source,
|
||||
skipGuard,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { buildFullSubjectGuardKey } from "../../builders/buildFullSubjectGuardKey.js";
|
||||
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
|
||||
import { buildFullSubjectReserveKey } from "../../builders/buildFullSubjectReserveKey.js";
|
||||
import { FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS } from "../../config/fullSubjectCacheConfig.js";
|
||||
|
||||
export const invalidateCachedFullSubjectExact = async ({
|
||||
customerId,
|
||||
entityId,
|
||||
ctx,
|
||||
source,
|
||||
skipGuard = false,
|
||||
}: {
|
||||
customerId: string;
|
||||
entityId?: string;
|
||||
ctx: AutumnContext;
|
||||
source?: string;
|
||||
skipGuard?: boolean;
|
||||
}): Promise<void> => {
|
||||
const { org, env, logger } = ctx;
|
||||
if (!customerId || redisV2.status !== "ready") return;
|
||||
@@ -28,39 +23,15 @@ export const invalidateCachedFullSubjectExact = async ({
|
||||
customerId,
|
||||
entityId,
|
||||
});
|
||||
const guardKey = buildFullSubjectGuardKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
entityId,
|
||||
});
|
||||
const reserveKey = buildFullSubjectReserveKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
entityId,
|
||||
});
|
||||
const subjectLabel = entityId ? `${customerId}:${entityId}` : customerId;
|
||||
const guardTimestamp = Date.now().toString();
|
||||
|
||||
try {
|
||||
await tryRedisWrite(async () => {
|
||||
const multi = redisV2.multi();
|
||||
if (!skipGuard) {
|
||||
multi.set(
|
||||
guardKey,
|
||||
guardTimestamp,
|
||||
"EX",
|
||||
FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
multi.unlink(subjectKey);
|
||||
multi.unlink(reserveKey);
|
||||
await multi.exec();
|
||||
await redisV2.unlink(subjectKey);
|
||||
}, redisV2);
|
||||
|
||||
logger.info(
|
||||
`[invalidateCachedFullSubject] subject: ${subjectLabel}, source: ${source}, skipGuard: ${skipGuard}`,
|
||||
`[invalidateCachedFullSubject] subject: ${subjectLabel}, source: ${source}`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
||||
115
server/src/internal/customers/cache/fullSubject/actions/invalidate/invalidateSharedBalanceFields.ts
vendored
Normal file
115
server/src/internal/customers/cache/fullSubject/actions/invalidate/invalidateSharedBalanceFields.ts
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
|
||||
import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js";
|
||||
import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js";
|
||||
import type { CachedFullSubject } from "../../fullSubjectCacheModel.js";
|
||||
|
||||
/**
|
||||
* Deletes shared balance hash fields for a customer during structural
|
||||
* invalidation. Reads the subject view manifest to target specific cusEnt
|
||||
* fields + _aggregated per feature hash. Falls back to UNLINK-ing all
|
||||
* possible balance hash keys (built from ctx.features) when the subject
|
||||
* view is already gone.
|
||||
*
|
||||
* Must be called BEFORE the subject view key is deleted.
|
||||
*/
|
||||
export const invalidateSharedBalanceFields = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
}): Promise<void> => {
|
||||
if (!customerId || redisV2.status !== "ready") return;
|
||||
|
||||
const { org, env } = ctx;
|
||||
const subjectKey = buildFullSubjectKey({ orgId: org.id, env, customerId });
|
||||
|
||||
const cachedRaw = await tryRedisRead(() => redisV2.get(subjectKey), redisV2);
|
||||
|
||||
if (cachedRaw) {
|
||||
await deleteFieldsFromManifest({ ctx, customerId, cachedRaw });
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteAllBalanceKeys({ ctx, customerId });
|
||||
};
|
||||
|
||||
async function deleteFieldsFromManifest({
|
||||
ctx,
|
||||
customerId,
|
||||
cachedRaw,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
cachedRaw: string;
|
||||
}) {
|
||||
const { org, env, logger } = ctx;
|
||||
|
||||
let manifest: CachedFullSubject;
|
||||
try {
|
||||
manifest = JSON.parse(cachedRaw) as CachedFullSubject;
|
||||
} catch {
|
||||
logger.warn(
|
||||
`[invalidateSharedBalanceFields] Failed to parse subject view for ${customerId}, skipping field deletion`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { customerEntitlementIdsByFeatureId } = manifest;
|
||||
if (!customerEntitlementIdsByFeatureId) return;
|
||||
|
||||
const pipeline = redisV2.pipeline();
|
||||
let fieldCount = 0;
|
||||
|
||||
for (const [featureId, cusEntIds] of Object.entries(
|
||||
customerEntitlementIdsByFeatureId,
|
||||
)) {
|
||||
const balanceKey = buildSharedFullSubjectBalanceKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
featureId,
|
||||
});
|
||||
const fieldsToDelete = [...cusEntIds, AGGREGATED_BALANCE_FIELD];
|
||||
pipeline.hdel(balanceKey, ...fieldsToDelete);
|
||||
fieldCount += fieldsToDelete.length;
|
||||
}
|
||||
|
||||
if (fieldCount > 0) {
|
||||
await tryRedisWrite(() => pipeline.exec(), redisV2);
|
||||
logger.info(
|
||||
`[invalidateSharedBalanceFields] ${customerId}: HDEL ${fieldCount} fields from manifest`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllBalanceKeys({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
}) {
|
||||
const { org, env, features, logger } = ctx;
|
||||
if (features.length === 0) return;
|
||||
|
||||
const pipeline = redisV2.pipeline();
|
||||
|
||||
for (const feature of features) {
|
||||
const balanceKey = buildSharedFullSubjectBalanceKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
featureId: feature.id,
|
||||
});
|
||||
pipeline.unlink(balanceKey);
|
||||
}
|
||||
|
||||
await tryRedisWrite(() => pipeline.exec(), redisV2);
|
||||
logger.info(
|
||||
`[invalidateSharedBalanceFields] ${customerId}: UNLINK ${features.length} balance keys (fallback)`,
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import type { FullSubject } from "@autumn/shared";
|
||||
import { normalizedToFullSubject } from "@autumn/shared";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
|
||||
import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
|
||||
import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js";
|
||||
import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { applyLiveAggregatedBalances } from "../../balances/applyLiveAggregatedBalances.js";
|
||||
import { getCachedFeatureBalancesBatch } from "../../balances/getCachedFeatureBalances.js";
|
||||
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
|
||||
import { filterNormalizedFullSubjectByFeatureIds } from "../../filterFullSubjectByFeatureIds.js";
|
||||
@@ -120,12 +122,14 @@ export const getCachedPartialFullSubject = async ({
|
||||
cached.meteredFeatures.includes(featureId),
|
||||
);
|
||||
|
||||
const isCustomerSubject = !entityId;
|
||||
const featureBalances = await getCachedFeatureBalancesBatch({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
featureIds: meteredFeatureIdsToFetch,
|
||||
customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId,
|
||||
includeAggregated: isCustomerSubject,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -157,7 +161,16 @@ export const getCachedPartialFullSubject = async ({
|
||||
featureIds,
|
||||
});
|
||||
|
||||
return normalizedToFullSubject({ normalized });
|
||||
if (isCustomerSubject) {
|
||||
applyLiveAggregatedBalances({
|
||||
normalized,
|
||||
featureBalances,
|
||||
});
|
||||
}
|
||||
|
||||
const fullSubject = normalizedToFullSubject({ normalized });
|
||||
await lazyResetSubjectEntitlements({ ctx, fullSubject, normalized });
|
||||
return fullSubject;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[getCachedPartialFullSubject] Failed to hydrate cached subject for ${customerId}${entityId ? `:${entityId}` : ""}, source: ${source}, error: ${error}`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CheckParams, FullSubject, TrackParams } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { updateCustomerData } from "@/internal/customers/actions/updateCustomerData.js";
|
||||
import { filterFullSubjectByFeatureIds } from "../../filterFullSubjectByFeatureIds.js";
|
||||
import { getOrCreateCachedFullSubject } from "../getOrCreateCachedFullSubject.js";
|
||||
import { getCachedPartialFullSubject } from "./getCachedPartialFullSubject.js";
|
||||
@@ -33,6 +34,11 @@ export const getOrCreateCachedPartialFullSubject = async ({
|
||||
logger.debug(
|
||||
`[getOrCreateCachedPartialFullSubject] Cache hit: ${customerId}`,
|
||||
);
|
||||
await updateCustomerData({
|
||||
ctx,
|
||||
fullSubject: cached,
|
||||
customerData: params.customer_data,
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import {
|
||||
CustomerNotFoundError,
|
||||
EntityNotFoundError,
|
||||
type FullSubject,
|
||||
normalizedToFullSubject,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js";
|
||||
import { filterNormalizedFullSubjectByFeatureIds } from "../../filterFullSubjectByFeatureIds.js";
|
||||
import { filterFullSubjectByFeatureIds } from "../../filterFullSubjectByFeatureIds.js";
|
||||
import { getOrInitFullSubjectViewEpoch } from "../invalidate/getOrInitFullSubjectViewEpoch.js";
|
||||
import { setCachedFullSubject } from "../setCachedFullSubject/setCachedFullSubject.js";
|
||||
import { getCachedPartialFullSubject } from "./getCachedPartialFullSubject.js";
|
||||
@@ -25,7 +24,6 @@ export const getOrSetCachedPartialFullSubject = async ({
|
||||
source?: string;
|
||||
}): Promise<FullSubject> => {
|
||||
const { skipCache, logger } = ctx;
|
||||
const fetchTimeMs = Date.now();
|
||||
|
||||
if (!skipCache) {
|
||||
const cached = await getCachedPartialFullSubject({
|
||||
@@ -52,30 +50,43 @@ export const getOrSetCachedPartialFullSubject = async ({
|
||||
customerId,
|
||||
});
|
||||
|
||||
const normalized = await getFullSubjectNormalized({
|
||||
const result = await getFullSubjectNormalized({
|
||||
ctx,
|
||||
customerId,
|
||||
entityId,
|
||||
});
|
||||
|
||||
if (!normalized) {
|
||||
if (!result) {
|
||||
if (entityId) throw new EntityNotFoundError({ entityId });
|
||||
throw new CustomerNotFoundError({ customerId });
|
||||
}
|
||||
|
||||
const { normalized, fullSubject } = result;
|
||||
|
||||
if (!skipCache) {
|
||||
await setCachedFullSubject({
|
||||
ctx,
|
||||
normalized,
|
||||
fetchTimeMs,
|
||||
fetchedSubjectViewEpoch,
|
||||
});
|
||||
|
||||
// Re-read from cache instead of returning the DB-fetched fullSubject.
|
||||
// Balance hash fields use HSETNX, so in-flight Lua deduction patches
|
||||
// survive the setCachedFullSubject write. The DB data may be stale
|
||||
// (e.g. entity view rebuilt before sync completes), but the balance
|
||||
// hash reflects the true Redis state.
|
||||
const freshCached = await getCachedPartialFullSubject({
|
||||
ctx,
|
||||
customerId,
|
||||
entityId,
|
||||
featureIds,
|
||||
source: `${source}:post-set`,
|
||||
});
|
||||
if (freshCached) return freshCached;
|
||||
}
|
||||
|
||||
return normalizedToFullSubject({
|
||||
normalized: filterNormalizedFullSubjectByFeatureIds({
|
||||
normalized,
|
||||
featureIds,
|
||||
}),
|
||||
return filterFullSubjectByFeatureIds({
|
||||
fullSubject,
|
||||
featureIds,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,100 +2,82 @@ import type { NormalizedFullSubject } from "@autumn/shared";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
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 } from "../../config/fullSubjectCacheConfig.js";
|
||||
import { normalizedToCachedFullSubject } from "../../fullSubjectCacheModel.js";
|
||||
import { getOrInitFullSubjectViewEpoch } from "../invalidate/getOrInitFullSubjectViewEpoch.js";
|
||||
import type { SetCachedFullSubjectResult } from "./fullSubjectWriteTypes.js";
|
||||
import {
|
||||
appendCachedFullSubjectViewWrite,
|
||||
releaseCachedFullSubjectViewWrite,
|
||||
reserveCachedFullSubjectViewWrite,
|
||||
} from "./setCachedFullSubjectView.js";
|
||||
import { appendSharedFullSubjectBalanceWrite } from "./setSharedFullSubjectBalances.js";
|
||||
import { buildSharedBalanceWrites } from "./setSharedFullSubjectBalances.js";
|
||||
|
||||
export type { SetCachedFullSubjectResult } from "./fullSubjectWriteTypes.js";
|
||||
|
||||
export const setCachedFullSubject = async ({
|
||||
ctx,
|
||||
normalized,
|
||||
fetchTimeMs,
|
||||
fetchedSubjectViewEpoch,
|
||||
overwrite = false,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
normalized: NormalizedFullSubject;
|
||||
fetchTimeMs: number;
|
||||
fetchedSubjectViewEpoch: number;
|
||||
overwrite?: boolean;
|
||||
}): Promise<SetCachedFullSubjectResult> => {
|
||||
const { logger } = ctx;
|
||||
const { logger, org, env } = ctx;
|
||||
const { customerId, entityId } = normalized;
|
||||
const currentSubjectViewEpoch = await getOrInitFullSubjectViewEpoch({
|
||||
ctx,
|
||||
customerId,
|
||||
});
|
||||
if (currentSubjectViewEpoch !== fetchedSubjectViewEpoch) {
|
||||
return "STALE_WRITE";
|
||||
}
|
||||
|
||||
const cached = normalizedToCachedFullSubject({
|
||||
normalized,
|
||||
subjectViewEpoch: currentSubjectViewEpoch,
|
||||
subjectViewEpoch: fetchedSubjectViewEpoch,
|
||||
});
|
||||
const subjectViewReservation = await reserveCachedFullSubjectViewWrite({
|
||||
ctx,
|
||||
|
||||
const subjectKey = buildFullSubjectKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
entityId,
|
||||
fetchTimeMs,
|
||||
overwrite,
|
||||
});
|
||||
|
||||
if (subjectViewReservation.status !== "OK") {
|
||||
return subjectViewReservation.status;
|
||||
}
|
||||
|
||||
const latestSubjectViewEpoch = await getOrInitFullSubjectViewEpoch({
|
||||
ctx,
|
||||
const epochKey = buildFullSubjectViewEpochKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
});
|
||||
if (latestSubjectViewEpoch !== fetchedSubjectViewEpoch) {
|
||||
await releaseCachedFullSubjectViewWrite({
|
||||
reservation: subjectViewReservation.reservation,
|
||||
});
|
||||
return "STALE_WRITE";
|
||||
|
||||
const balanceWrites = buildSharedBalanceWrites({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
customerEntitlements: normalized.customer_entitlements,
|
||||
aggregatedCustomerEntitlements:
|
||||
normalized.entity_aggregations?.aggregated_customer_entitlements ?? [],
|
||||
});
|
||||
|
||||
const keys: string[] = [subjectKey, epochKey];
|
||||
for (const { balanceKey } of balanceWrites) {
|
||||
keys.push(balanceKey);
|
||||
}
|
||||
|
||||
const result = await tryRedisWrite(async () => {
|
||||
const multi = redisV2.multi();
|
||||
const argv: string[] = [
|
||||
String(fetchedSubjectViewEpoch),
|
||||
String(FULL_SUBJECT_CACHE_TTL_SECONDS),
|
||||
JSON.stringify(cached),
|
||||
String(balanceWrites.length),
|
||||
];
|
||||
|
||||
await appendSharedFullSubjectBalanceWrite({
|
||||
ctx,
|
||||
multi,
|
||||
normalized,
|
||||
meteredFeatures: cached.meteredFeatures,
|
||||
overwrite,
|
||||
ttlSeconds: FULL_SUBJECT_CACHE_TTL_SECONDS,
|
||||
});
|
||||
appendCachedFullSubjectViewWrite({
|
||||
multi,
|
||||
subjectKey: subjectViewReservation.subjectKey,
|
||||
cached,
|
||||
ttlSeconds: FULL_SUBJECT_CACHE_TTL_SECONDS,
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await multi.exec();
|
||||
return "OK" as const;
|
||||
}, redisV2);
|
||||
const result = await tryRedisWrite(
|
||||
() => redisV2.setCachedFullSubject(keys.length, ...keys, ...argv),
|
||||
redisV2,
|
||||
);
|
||||
|
||||
const subjectLabel = entityId ? `${customerId}:${entityId}` : customerId;
|
||||
try {
|
||||
logger.info(
|
||||
`[setCachedFullSubject] ${subjectLabel}: ${result ?? "FAILED"}, balances=${cached.meteredFeatures.length}`,
|
||||
);
|
||||
} finally {
|
||||
await releaseCachedFullSubjectViewWrite({
|
||||
reservation: subjectViewReservation.reservation,
|
||||
});
|
||||
}
|
||||
logger.info(
|
||||
`[setCachedFullSubject] ${subjectLabel}: ${result ?? "FAILED"}, balances=${cached.meteredFeatures.length}`,
|
||||
);
|
||||
|
||||
return result ?? "FAILED";
|
||||
};
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
import { buildFullSubjectGuardKey } from "../../builders/buildFullSubjectGuardKey.js";
|
||||
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
|
||||
import { buildFullSubjectReserveKey } from "../../builders/buildFullSubjectReserveKey.js";
|
||||
import { FULL_SUBJECT_CACHE_RESERVE_TTL_SECONDS } from "../../config/fullSubjectCacheConfig.js";
|
||||
import type { CachedFullSubject } from "../../fullSubjectCacheModel.js";
|
||||
|
||||
export const reserveCachedFullSubjectViewWrite = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
entityId,
|
||||
fetchTimeMs,
|
||||
overwrite,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
entityId?: string;
|
||||
fetchTimeMs: number;
|
||||
overwrite: boolean;
|
||||
}): Promise<
|
||||
| {
|
||||
status: "OK";
|
||||
subjectKey: string;
|
||||
reservation?: {
|
||||
reserveKey: string;
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
status: "CACHE_EXISTS" | "STALE_WRITE";
|
||||
}
|
||||
> => {
|
||||
const { org, env } = ctx;
|
||||
const subjectKey = buildFullSubjectKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
entityId,
|
||||
});
|
||||
|
||||
if (overwrite) {
|
||||
return {
|
||||
status: "OK",
|
||||
subjectKey,
|
||||
};
|
||||
}
|
||||
|
||||
const reserveKey = buildFullSubjectReserveKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
entityId,
|
||||
});
|
||||
const guardKey = buildFullSubjectGuardKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
entityId,
|
||||
});
|
||||
const token = generateId("full_subject_res");
|
||||
const reserveResult = await redisV2.reserveFullSubjectWrite(
|
||||
subjectKey,
|
||||
reserveKey,
|
||||
guardKey,
|
||||
token,
|
||||
String(FULL_SUBJECT_CACHE_RESERVE_TTL_SECONDS),
|
||||
String(overwrite),
|
||||
String(fetchTimeMs),
|
||||
);
|
||||
|
||||
if (reserveResult === "CACHE_EXISTS" || reserveResult === "STALE_WRITE") {
|
||||
return {
|
||||
status: reserveResult,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "OK",
|
||||
subjectKey,
|
||||
reservation: {
|
||||
reserveKey,
|
||||
token,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const appendCachedFullSubjectViewWrite = ({
|
||||
multi,
|
||||
subjectKey,
|
||||
cached,
|
||||
ttlSeconds,
|
||||
}: {
|
||||
multi: ReturnType<typeof redisV2.multi>;
|
||||
subjectKey: string;
|
||||
cached: CachedFullSubject;
|
||||
ttlSeconds: number;
|
||||
}) => {
|
||||
multi.set(subjectKey, JSON.stringify(cached), "EX", ttlSeconds);
|
||||
};
|
||||
|
||||
export const releaseCachedFullSubjectViewWrite = async ({
|
||||
reservation,
|
||||
}: {
|
||||
reservation?: {
|
||||
reserveKey: string;
|
||||
token: string;
|
||||
};
|
||||
}) => {
|
||||
if (!reservation) return;
|
||||
|
||||
await tryRedisWrite(
|
||||
() =>
|
||||
redisV2.releaseFullSubjectReservation(
|
||||
reservation.reserveKey,
|
||||
reservation.token,
|
||||
),
|
||||
redisV2,
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,28 @@
|
||||
import type { NormalizedFullSubject } from "@autumn/shared";
|
||||
import type { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type {
|
||||
AggregatedFeatureBalance,
|
||||
NormalizedFullSubject,
|
||||
} from "@autumn/shared";
|
||||
import { featureBalancesToHashFields } from "../../balances/featureBalancesToHashFields.js";
|
||||
import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js";
|
||||
import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js";
|
||||
|
||||
type SharedBalanceWrite = {
|
||||
export type SharedBalanceWrite = {
|
||||
balanceKey: string;
|
||||
fields: Record<string, string>;
|
||||
};
|
||||
|
||||
const buildSharedBalanceWrites = ({
|
||||
export const buildSharedBalanceWrites = ({
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
customerEntitlements,
|
||||
aggregatedCustomerEntitlements,
|
||||
}: {
|
||||
orgId: string;
|
||||
env: string;
|
||||
customerId: string;
|
||||
customerEntitlements: NormalizedFullSubject["customer_entitlements"];
|
||||
aggregatedCustomerEntitlements: AggregatedFeatureBalance[];
|
||||
}): SharedBalanceWrite[] => {
|
||||
const balancesByFeatureId = new Map<string, typeof customerEntitlements>();
|
||||
|
||||
@@ -29,56 +33,33 @@ const buildSharedBalanceWrites = ({
|
||||
balancesByFeatureId.set(customerEntitlement.feature_id, existingBalances);
|
||||
}
|
||||
|
||||
return Array.from(balancesByFeatureId.entries()).map(
|
||||
([featureId, balances]) => {
|
||||
return {
|
||||
balanceKey: buildSharedFullSubjectBalanceKey({
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
featureId,
|
||||
}),
|
||||
fields: featureBalancesToHashFields({ balances }),
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
const aggregatedByFeatureId = new Map<string, AggregatedFeatureBalance>();
|
||||
for (const aggregated of aggregatedCustomerEntitlements) {
|
||||
aggregatedByFeatureId.set(aggregated.feature_id, aggregated);
|
||||
}
|
||||
|
||||
export const appendSharedFullSubjectBalanceWrite = async ({
|
||||
ctx,
|
||||
multi,
|
||||
normalized,
|
||||
meteredFeatures: _meteredFeatures,
|
||||
overwrite,
|
||||
ttlSeconds,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
multi: ReturnType<typeof redisV2.multi>;
|
||||
normalized: NormalizedFullSubject;
|
||||
meteredFeatures: string[];
|
||||
overwrite: boolean;
|
||||
ttlSeconds: number;
|
||||
}) => {
|
||||
const { org, env } = ctx;
|
||||
const { customerId } = normalized;
|
||||
const balanceWrites = buildSharedBalanceWrites({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
customerEntitlements: normalized.customer_entitlements,
|
||||
});
|
||||
const allFeatureIds = new Set([
|
||||
...balancesByFeatureId.keys(),
|
||||
...aggregatedByFeatureId.keys(),
|
||||
]);
|
||||
|
||||
for (const { balanceKey, fields } of balanceWrites) {
|
||||
if (Object.keys(fields).length > 0) {
|
||||
if (overwrite) {
|
||||
multi.hset(balanceKey, fields);
|
||||
} else {
|
||||
for (const [field, value] of Object.entries(fields)) {
|
||||
multi.hsetnx(balanceKey, field, value);
|
||||
}
|
||||
}
|
||||
return Array.from(allFeatureIds).map((featureId) => {
|
||||
const balances = balancesByFeatureId.get(featureId) ?? [];
|
||||
const fields = featureBalancesToHashFields({ balances });
|
||||
|
||||
const aggregated = aggregatedByFeatureId.get(featureId);
|
||||
if (aggregated) {
|
||||
fields[AGGREGATED_BALANCE_FIELD] = JSON.stringify(aggregated);
|
||||
}
|
||||
|
||||
multi.expire(balanceKey, ttlSeconds);
|
||||
}
|
||||
return {
|
||||
balanceKey: buildSharedFullSubjectBalanceKey({
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
featureId,
|
||||
}),
|
||||
fields,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
117
server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerProduct.ts
vendored
Normal file
117
server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerProduct.ts
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { InsertCustomerProduct } from "@autumn/shared";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { updateCachedCustomerProduct } from "@/internal/customers/cusProducts/actions/cache/updateCachedCustomerProduct.js";
|
||||
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js";
|
||||
import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "../config/fullSubjectCacheConfig.js";
|
||||
|
||||
type UpdateCachedSubjectCustomerProductResult = {
|
||||
success: boolean;
|
||||
updatedFields?: string[];
|
||||
cacheMiss?: boolean;
|
||||
cusProductNotFound?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const updateCachedCustomerProductV2 = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
customerProductId,
|
||||
updates,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
customerProductId: string;
|
||||
updates: Partial<InsertCustomerProduct>;
|
||||
}): Promise<UpdateCachedSubjectCustomerProductResult | null> => {
|
||||
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 {
|
||||
if (!customerId) {
|
||||
ctx.logger.warn(
|
||||
`[updateCachedCustomerProductV2] Skipping subject cache update for cusProduct ${customerProductId} because customerId is missing`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return null;
|
||||
const { org, env, logger } = ctx;
|
||||
const subjectKey = buildFullSubjectKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
});
|
||||
const paramsJson = JSON.stringify({
|
||||
cus_product_id: customerProductId,
|
||||
updates,
|
||||
});
|
||||
|
||||
const result = await tryRedisWrite(
|
||||
() =>
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -19,7 +19,9 @@ export const updateCachedEntityData = async ({
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
entityId: string;
|
||||
updates: Partial<Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">>;
|
||||
updates: Partial<
|
||||
Pick<Entity, "spend_limits" | "usage_alerts" | "overage_allowed">
|
||||
>;
|
||||
}): Promise<void> => {
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
|
||||
|
||||
95
server/src/internal/customers/cache/fullSubject/actions/upsertCachedInvoiceV2.ts
vendored
Normal file
95
server/src/internal/customers/cache/fullSubject/actions/upsertCachedInvoiceV2.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Invoice } from "@autumn/shared";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
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";
|
||||
|
||||
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,
|
||||
invoice,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
invoice: Invoice;
|
||||
}): Promise<UpsertCachedInvoiceV2Result | null> => {
|
||||
if (!customerId) {
|
||||
ctx.logger.warn(
|
||||
`[upsertCachedInvoiceV2] Skipping cache update for invoice ${invoice.stripe_id} because customerId is missing`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { org, env, logger } = ctx;
|
||||
const subjectKey = buildFullSubjectKey({
|
||||
orgId: org.id,
|
||||
env,
|
||||
customerId,
|
||||
});
|
||||
const invoiceJson = JSON.stringify(invoice);
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = await tryRedisWrite(
|
||||
async () =>
|
||||
await redisV2.upsertInvoiceInFullSubjectV2(
|
||||
subjectKey,
|
||||
invoiceJson,
|
||||
String(FULL_SUBJECT_CACHE_TTL_SECONDS),
|
||||
String(Date.now()),
|
||||
),
|
||||
redisV2,
|
||||
);
|
||||
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,
|
||||
};
|
||||
};
|
||||
40
server/src/internal/customers/cache/fullSubject/balances/applyLiveAggregatedBalances.ts
vendored
Normal file
40
server/src/internal/customers/cache/fullSubject/balances/applyLiveAggregatedBalances.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
AggregatedFeatureBalance,
|
||||
NormalizedFullSubject,
|
||||
} from "@autumn/shared";
|
||||
import type { FeatureBalanceResult } from "./getCachedFeatureBalances.js";
|
||||
|
||||
/**
|
||||
* Replaces stale aggregated_customer_entitlements on the normalized subject
|
||||
* with live values read from the shared balance hash `_aggregated` fields.
|
||||
*/
|
||||
export const applyLiveAggregatedBalances = ({
|
||||
normalized,
|
||||
featureBalances,
|
||||
}: {
|
||||
normalized: NormalizedFullSubject;
|
||||
featureBalances: FeatureBalanceResult[];
|
||||
}): void => {
|
||||
if (!normalized.entity_aggregations) return;
|
||||
|
||||
const liveByFeatureId = new Map<string, AggregatedFeatureBalance>();
|
||||
for (const result of featureBalances) {
|
||||
if (result.aggregated) {
|
||||
liveByFeatureId.set(result.featureId, result.aggregated);
|
||||
}
|
||||
}
|
||||
|
||||
if (liveByFeatureId.size === 0) return;
|
||||
|
||||
normalized.entity_aggregations = {
|
||||
...normalized.entity_aggregations,
|
||||
aggregated_customer_entitlements:
|
||||
normalized.entity_aggregations.aggregated_customer_entitlements.map(
|
||||
(staleEntry) => {
|
||||
const live = liveByFeatureId.get(staleEntry.feature_id);
|
||||
if (!live) return staleEntry;
|
||||
return { ...staleEntry, ...live };
|
||||
},
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -1,12 +1,32 @@
|
||||
import type { SubjectBalance } from "@autumn/shared";
|
||||
import type { AggregatedFeatureBalance, SubjectBalance } from "@autumn/shared";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { buildSharedFullSubjectBalanceKey } from "../builders/buildSharedFullSubjectBalanceKey.js";
|
||||
import { AGGREGATED_BALANCE_FIELD } from "../config/fullSubjectCacheConfig.js";
|
||||
import { sanitizeCachedSubjectBalance } from "../sanitize/index.js";
|
||||
|
||||
export type FeatureBalanceResult = {
|
||||
featureId: string;
|
||||
balances: SubjectBalance[];
|
||||
aggregated?: AggregatedFeatureBalance;
|
||||
};
|
||||
|
||||
const readFeatureBalancesFromMaster = async ({
|
||||
balanceKey,
|
||||
customerEntitlementIds,
|
||||
}: {
|
||||
balanceKey: string;
|
||||
customerEntitlementIds: string[];
|
||||
}): Promise<(string | null)[] | null> => {
|
||||
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;
|
||||
};
|
||||
|
||||
export const getCachedFeatureBalance = async ({
|
||||
@@ -15,12 +35,14 @@ export const getCachedFeatureBalance = async ({
|
||||
customerId,
|
||||
featureId,
|
||||
customerEntitlementIds,
|
||||
readMaster = false,
|
||||
}: {
|
||||
orgId: string;
|
||||
env: string;
|
||||
customerId: string;
|
||||
featureId: string;
|
||||
customerEntitlementIds: string[];
|
||||
readMaster?: boolean;
|
||||
}): Promise<FeatureBalanceResult | undefined> => {
|
||||
const balanceKey = buildSharedFullSubjectBalanceKey({
|
||||
orgId,
|
||||
@@ -33,10 +55,19 @@ export const getCachedFeatureBalance = async ({
|
||||
return { featureId, balances: [] };
|
||||
}
|
||||
|
||||
const results = await tryRedisRead(
|
||||
() => redisV2.hmget(balanceKey, ...customerEntitlementIds),
|
||||
redisV2,
|
||||
);
|
||||
const results = readMaster
|
||||
? await tryRedisRead(
|
||||
() =>
|
||||
readFeatureBalancesFromMaster({
|
||||
balanceKey,
|
||||
customerEntitlementIds,
|
||||
}),
|
||||
redisV2,
|
||||
)
|
||||
: await tryRedisRead(
|
||||
() => redisV2.hmget(balanceKey, ...customerEntitlementIds),
|
||||
redisV2,
|
||||
);
|
||||
if (!results) return undefined;
|
||||
|
||||
const balances: SubjectBalance[] = [];
|
||||
@@ -64,12 +95,14 @@ export const getCachedFeatureBalancesBatch = async ({
|
||||
customerId,
|
||||
featureIds,
|
||||
customerEntitlementIdsByFeatureId,
|
||||
includeAggregated = false,
|
||||
}: {
|
||||
orgId: string;
|
||||
env: string;
|
||||
customerId: string;
|
||||
featureIds: string[];
|
||||
customerEntitlementIdsByFeatureId: Record<string, string[]>;
|
||||
includeAggregated?: boolean;
|
||||
}): Promise<FeatureBalanceResult[] | undefined> => {
|
||||
if (featureIds.length === 0) return [];
|
||||
|
||||
@@ -77,6 +110,9 @@ export const getCachedFeatureBalancesBatch = async ({
|
||||
for (const featureId of featureIds) {
|
||||
const customerEntitlementIds =
|
||||
customerEntitlementIdsByFeatureId[featureId] ?? [];
|
||||
const fields = includeAggregated
|
||||
? [...customerEntitlementIds, AGGREGATED_BALANCE_FIELD]
|
||||
: customerEntitlementIds;
|
||||
pipeline.hmget(
|
||||
buildSharedFullSubjectBalanceKey({
|
||||
orgId,
|
||||
@@ -84,7 +120,7 @@ export const getCachedFeatureBalancesBatch = async ({
|
||||
customerId,
|
||||
featureId,
|
||||
}),
|
||||
...customerEntitlementIds,
|
||||
...fields,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,20 +132,35 @@ export const getCachedFeatureBalancesBatch = async ({
|
||||
for (let i = 0; i < featureIds.length; i++) {
|
||||
const customerEntitlementIds =
|
||||
customerEntitlementIdsByFeatureId[featureIds[i]] ?? [];
|
||||
const values = results[i]?.[1] as (string | null)[] | null;
|
||||
if (!values || values.length !== customerEntitlementIds.length) {
|
||||
return undefined;
|
||||
const allValues = results[i]?.[1] as (string | null)[] | null;
|
||||
if (!allValues) return undefined;
|
||||
|
||||
let aggregated: AggregatedFeatureBalance | undefined;
|
||||
let ceValues: (string | null)[];
|
||||
|
||||
if (includeAggregated) {
|
||||
const aggregatedJson = allValues.pop() ?? null;
|
||||
if (aggregatedJson) {
|
||||
try {
|
||||
aggregated = JSON.parse(aggregatedJson) as AggregatedFeatureBalance;
|
||||
} catch {
|
||||
// Malformed _aggregated is non-fatal; fall back to subject string value
|
||||
}
|
||||
}
|
||||
ceValues = allValues;
|
||||
} else {
|
||||
ceValues = allValues;
|
||||
}
|
||||
|
||||
if (ceValues.length !== customerEntitlementIds.length) return undefined;
|
||||
|
||||
const balances: SubjectBalance[] = [];
|
||||
for (const entryJson of values) {
|
||||
for (const entryJson of ceValues) {
|
||||
if (!entryJson) return undefined;
|
||||
try {
|
||||
const parsedBalance = JSON.parse(entryJson) as SubjectBalance;
|
||||
balances.push(
|
||||
sanitizeCachedSubjectBalance({
|
||||
subjectBalance: parsedBalance,
|
||||
}),
|
||||
sanitizeCachedSubjectBalance({ subjectBalance: parsedBalance }),
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -119,6 +170,7 @@ export const getCachedFeatureBalancesBatch = async ({
|
||||
featureBalances.push({
|
||||
featureId: featureIds[i],
|
||||
balances,
|
||||
aggregated,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { buildFullSubjectKey } from "./buildFullSubjectKey.js";
|
||||
|
||||
export const buildFullSubjectGuardKey = ({
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
entityId,
|
||||
}: {
|
||||
orgId: string;
|
||||
env: string;
|
||||
customerId: string;
|
||||
entityId?: string;
|
||||
}) =>
|
||||
`${buildFullSubjectKey({
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
entityId,
|
||||
})}:guard`;
|
||||
9
server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectOrgEnvKey.ts
vendored
Normal file
9
server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectOrgEnvKey.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
|
||||
export const buildFullSubjectOrgEnvKey = ({
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
}) => `${orgId}:${env}`;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { buildFullSubjectKey } from "./buildFullSubjectKey.js";
|
||||
|
||||
export const buildFullSubjectReserveKey = ({
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
entityId,
|
||||
}: {
|
||||
orgId: string;
|
||||
env: string;
|
||||
customerId: string;
|
||||
entityId?: string;
|
||||
}) =>
|
||||
`${buildFullSubjectKey({
|
||||
orgId,
|
||||
env,
|
||||
customerId,
|
||||
entityId,
|
||||
})}:reserve`;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { seconds } from "@autumn/shared";
|
||||
|
||||
export const FULL_SUBJECT_CACHE_TTL_SECONDS = seconds.days(3);
|
||||
export const FULL_SUBJECT_CACHE_RESERVE_TTL_SECONDS = 60;
|
||||
export const FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS = 1;
|
||||
export const FULL_SUBJECT_EPOCH_TTL_SECONDS = seconds.days(5);
|
||||
export const AGGREGATED_BALANCE_FIELD = "_aggregated";
|
||||
|
||||
@@ -27,7 +27,18 @@ export const normalizedToCachedFullSubject = ({
|
||||
existingMembership;
|
||||
}
|
||||
|
||||
const meteredFeatures = Object.keys(customerEntitlementIdsByFeatureId);
|
||||
const meteredFeatureSet = new Set(
|
||||
Object.keys(customerEntitlementIdsByFeatureId),
|
||||
);
|
||||
|
||||
for (const aggregatedCustomerEntitlement of normalized.entity_aggregations
|
||||
?.aggregated_customer_entitlements ?? []) {
|
||||
if (aggregatedCustomerEntitlement.feature_id) {
|
||||
meteredFeatureSet.add(aggregatedCustomerEntitlement.feature_id);
|
||||
}
|
||||
}
|
||||
|
||||
const meteredFeatures = [...meteredFeatureSet];
|
||||
|
||||
return {
|
||||
subjectType: normalized.subjectType,
|
||||
|
||||
@@ -10,19 +10,19 @@ export { getOrCreateCachedPartialFullSubject } from "./actions/partial/getOrCrea
|
||||
export { getOrSetCachedPartialFullSubject } from "./actions/partial/getOrSetCachedPartialFullSubject.js";
|
||||
export { setCachedFullSubject } from "./actions/setCachedFullSubject/setCachedFullSubject.js";
|
||||
export { updateCachedCustomerData } from "./actions/updateCachedCustomerData.js";
|
||||
export { updateCachedCustomerProductV2 } from "./actions/updateCachedCustomerProduct.js";
|
||||
export {
|
||||
type UpsertCachedInvoiceV2Result,
|
||||
upsertCachedInvoiceV2,
|
||||
} from "./actions/upsertCachedInvoiceV2.js";
|
||||
export type { FeatureBalanceResult } from "./balances/getCachedFeatureBalances.js";
|
||||
export {
|
||||
getCachedFeatureBalance,
|
||||
getCachedFeatureBalancesBatch,
|
||||
} from "./balances/getCachedFeatureBalances.js";
|
||||
export { buildFullSubjectBalanceKey } from "./builders/buildFullSubjectBalanceKey.js";
|
||||
export { buildFullSubjectGuardKey } from "./builders/buildFullSubjectGuardKey.js";
|
||||
export { buildFullSubjectKey } from "./builders/buildFullSubjectKey.js";
|
||||
export { buildFullSubjectReserveKey } from "./builders/buildFullSubjectReserveKey.js";
|
||||
export { buildFullSubjectOrgEnvKey } from "./builders/buildFullSubjectOrgEnvKey.js";
|
||||
export { buildFullSubjectViewEpochKey } from "./builders/buildFullSubjectViewEpochKey.js";
|
||||
export { buildSharedFullSubjectBalanceKey } from "./builders/buildSharedFullSubjectBalanceKey.js";
|
||||
export {
|
||||
FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS,
|
||||
FULL_SUBJECT_CACHE_RESERVE_TTL_SECONDS,
|
||||
FULL_SUBJECT_CACHE_TTL_SECONDS,
|
||||
} from "./config/fullSubjectCacheConfig.js";
|
||||
export { FULL_SUBJECT_CACHE_TTL_SECONDS } from "./config/fullSubjectCacheConfig.js";
|
||||
|
||||
@@ -26,6 +26,7 @@ const rolloverShapeSpec: ShapeSpec = {
|
||||
};
|
||||
|
||||
const subjectBalanceShapeSpec: ShapeSpec = {
|
||||
replaceables: "array",
|
||||
rollovers: { items: rolloverShapeSpec },
|
||||
entities: "nullable_record",
|
||||
entitlement: entitlementShapeSpec,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { InsertCustomerProduct } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js";
|
||||
import { updateCachedCustomerProductV2 } from "@/internal/customers/cache/fullSubject/actions/updateCachedCustomerProduct.js";
|
||||
import { CusProductService } from "../CusProductService.js";
|
||||
import { updateCachedCustomerProduct } from "./cache/updateCachedCustomerProduct.js";
|
||||
|
||||
/**
|
||||
* Updates a customer product in both Postgres and the Redis FullCustomer cache.
|
||||
@@ -30,21 +29,23 @@ export const updateCustomerProductDbAndCache = async ({
|
||||
updates,
|
||||
});
|
||||
|
||||
const result = await updateCachedCustomerProduct({
|
||||
ctx,
|
||||
customerId,
|
||||
cusProductId,
|
||||
updates,
|
||||
});
|
||||
|
||||
if (result?.error === "cache_miss") {
|
||||
ctx.logger.info(
|
||||
`[updateCustomerProductDbAndCache] cache_miss for cusProduct ${cusProductId}, rebuilding cache from DB`,
|
||||
);
|
||||
await getOrSetCachedFullCustomer({
|
||||
await Promise.all([
|
||||
updateCachedCustomerProductV2({
|
||||
ctx,
|
||||
customerId,
|
||||
source: "updateDbAndCache:cache_miss_fallback",
|
||||
});
|
||||
}
|
||||
customerProductId: cusProductId,
|
||||
updates,
|
||||
}),
|
||||
]);
|
||||
|
||||
// if (result?.error === "cache_miss") {
|
||||
// ctx.logger.info(
|
||||
// `[updateCustomerProductDbAndCache] cache_miss for cusProduct ${cusProductId}, rebuilding cache from DB`,
|
||||
// );
|
||||
// await getOrSetCachedFullCustomer({
|
||||
// ctx,
|
||||
// customerId,
|
||||
// source: "updateDbAndCache:cache_miss_fallback",
|
||||
// });
|
||||
// }
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { CusEntService } from "../CusEntitlementService.js";
|
||||
import { adjustSubjectBalanceCache } from "./cache/adjustSubjectBalanceCache.js";
|
||||
import { incrementCachedCusEntBalance } from "./cache/incrementCachedCusEntBalance.js";
|
||||
|
||||
/**
|
||||
@@ -11,23 +12,43 @@ export const adjustBalanceDbAndCache = async ({
|
||||
customerId,
|
||||
cusEntId,
|
||||
delta,
|
||||
featureId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
cusEntId: string;
|
||||
delta: number;
|
||||
}) => {
|
||||
if (delta === 0) return;
|
||||
featureId: string;
|
||||
}): Promise<
|
||||
Awaited<ReturnType<typeof CusEntService.increment>>[number] | undefined
|
||||
> => {
|
||||
if (delta === 0) return undefined;
|
||||
|
||||
if (delta > 0) {
|
||||
await CusEntService.increment({ ctx, id: cusEntId, amount: delta });
|
||||
} else {
|
||||
await CusEntService.decrement({
|
||||
const updatedRows =
|
||||
delta > 0
|
||||
? await CusEntService.increment({
|
||||
ctx,
|
||||
id: cusEntId,
|
||||
amount: delta,
|
||||
})
|
||||
: await CusEntService.decrement({
|
||||
ctx,
|
||||
id: cusEntId,
|
||||
amount: Math.abs(delta),
|
||||
});
|
||||
|
||||
const updatedCustomerEntitlement = updatedRows[0];
|
||||
|
||||
await Promise.all([
|
||||
incrementCachedCusEntBalance({ ctx, customerId, cusEntId, delta }),
|
||||
adjustSubjectBalanceCache({
|
||||
ctx,
|
||||
id: cusEntId,
|
||||
amount: Math.abs(delta),
|
||||
});
|
||||
}
|
||||
customerId,
|
||||
featureId,
|
||||
customerEntitlementId: cusEntId,
|
||||
delta,
|
||||
}),
|
||||
]);
|
||||
|
||||
await incrementCachedCusEntBalance({ ctx, customerId, cusEntId, delta });
|
||||
return updatedCustomerEntitlement;
|
||||
};
|
||||
|
||||
77
server/src/internal/customers/cusProducts/cusEnts/actions/cache/adjustSubjectBalanceCache.ts
vendored
Normal file
77
server/src/internal/customers/cusProducts/cusEnts/actions/cache/adjustSubjectBalanceCache.ts
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { RepoContext } from "@/db/repoContext.js";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js";
|
||||
import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js";
|
||||
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
|
||||
type AdjustSubjectBalanceCacheResult = {
|
||||
ok: boolean;
|
||||
newBalance?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const adjustSubjectBalanceCache = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
featureId,
|
||||
customerEntitlementId,
|
||||
delta,
|
||||
}: {
|
||||
ctx: RepoContext;
|
||||
customerId: string;
|
||||
featureId: string;
|
||||
customerEntitlementId: string;
|
||||
delta: number;
|
||||
}): Promise<AdjustSubjectBalanceCacheResult | null> => {
|
||||
try {
|
||||
const balanceKey = buildSharedFullSubjectBalanceKey({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId,
|
||||
featureId,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.logger.error(
|
||||
`[adjustSubjectBalanceCache] customer entitlement ${customerEntitlementId}: error, ${error}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
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";
|
||||
@@ -11,7 +11,7 @@ export const updateSubjectBalanceCache = async ({
|
||||
customerEntitlementId,
|
||||
updates,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
ctx: RepoContext;
|
||||
customerId: string;
|
||||
featureId: string;
|
||||
customerEntitlementId: string;
|
||||
@@ -30,6 +30,8 @@ export const updateSubjectBalanceCache = async ({
|
||||
featureId,
|
||||
});
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { InsertCustomerEntitlement } from "@autumn/shared";
|
||||
import type { RepoContext } from "@/db/repoContext.js";
|
||||
import { redis } from "@/external/redis/initRedis.js";
|
||||
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";
|
||||
@@ -14,13 +15,15 @@ export const updateCusEntDbAndCache = async ({
|
||||
cusEntId,
|
||||
updates,
|
||||
incrementCacheVersion = false,
|
||||
featureId,
|
||||
}: {
|
||||
ctx: RepoContext;
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
cusEntId: string;
|
||||
updates: Partial<InsertCustomerEntitlement>;
|
||||
incrementCacheVersion?: boolean;
|
||||
}) => {
|
||||
featureId: string;
|
||||
}): Promise<void> => {
|
||||
await CusEntService.update({
|
||||
ctx,
|
||||
id: cusEntId,
|
||||
@@ -51,10 +54,25 @@ export const updateCusEntDbAndCache = async ({
|
||||
},
|
||||
];
|
||||
|
||||
await tryRedisWrite(() =>
|
||||
redis.updateCustomerEntitlements(
|
||||
cacheKey,
|
||||
JSON.stringify({ updates: cacheUpdates }),
|
||||
await Promise.all([
|
||||
tryRedisWrite(() =>
|
||||
redis.updateCustomerEntitlements(
|
||||
cacheKey,
|
||||
JSON.stringify({ updates: cacheUpdates }),
|
||||
),
|
||||
),
|
||||
);
|
||||
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,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
import type { Redis } from "ioredis";
|
||||
import type { Logger } from "@/external/logtail/logtailUtils.js";
|
||||
import {
|
||||
getConfiguredRegions,
|
||||
getRegionalRedis,
|
||||
@@ -14,7 +14,7 @@ import { buildTestFullCustomerCacheGuardKey } from "./testFullCustomerCacheGuard
|
||||
|
||||
type CustomerToDelete = {
|
||||
orgId: string;
|
||||
env: string;
|
||||
env: AppEnv;
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
@@ -119,7 +119,6 @@ export const batchDeleteCachedFullCustomers = async ({
|
||||
customers,
|
||||
}: {
|
||||
customers: CustomerToDelete[];
|
||||
logger?: Logger;
|
||||
}): Promise<number> => {
|
||||
if (customers.length === 0) return 0;
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ export const deleteCachedFullCustomer = async ({
|
||||
customerId,
|
||||
entityId,
|
||||
source,
|
||||
skipGuard,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -4,12 +4,17 @@ import type {
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
type FullAggregatedFeatureBalanceWithOptionsPrepaid =
|
||||
FullAggregatedFeatureBalance & {
|
||||
prepaid_grant_from_options?: number;
|
||||
};
|
||||
|
||||
export const mergeAggregatedBalanceIntoApiBalanceV2 = ({
|
||||
apiBalance,
|
||||
aggregatedFeatureBalance,
|
||||
}: {
|
||||
apiBalance: ApiBalanceV1;
|
||||
aggregatedFeatureBalance?: FullAggregatedFeatureBalance;
|
||||
aggregatedFeatureBalance?: FullAggregatedFeatureBalanceWithOptionsPrepaid;
|
||||
}): ApiBalanceV1 => {
|
||||
if (!aggregatedFeatureBalance) return apiBalance;
|
||||
|
||||
@@ -24,20 +29,37 @@ export const mergeAggregatedBalanceIntoApiBalanceV2 = ({
|
||||
breakdown: apiBalance.breakdown ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const aggregatedAllowance = aggregatedFeatureBalance.allowance_total ?? 0;
|
||||
const aggregatedPrepaidGrantFromOptions =
|
||||
aggregatedFeatureBalance.prepaid_grant_from_options ?? 0;
|
||||
const aggregatedAdjustment = aggregatedFeatureBalance.adjustment ?? 0;
|
||||
const aggregatedBalance = aggregatedFeatureBalance.balance ?? 0;
|
||||
const aggregatedRolloverBalance =
|
||||
aggregatedFeatureBalance.rollover_balance ?? 0;
|
||||
const aggregatedRolloverUsage = aggregatedFeatureBalance.rollover_usage ?? 0;
|
||||
|
||||
// Aggregate rows do not retain the full per-entity/per-product breakdown, so
|
||||
// the top-level summary is merged from the coarse aggregate values only.
|
||||
const granted = new Decimal(aggregatedAllowance)
|
||||
.add(aggregatedPrepaidGrantFromOptions)
|
||||
.add(aggregatedAdjustment)
|
||||
.toNumber();
|
||||
|
||||
const remaining = Decimal.max(0, new Decimal(aggregatedBalance)).toNumber();
|
||||
// Main remaining is floored at 0 (matches legacy behaviour). Rollover
|
||||
// remaining is added on top, since rollover balances are independent of
|
||||
// main balance sign.
|
||||
const mainRemaining = Decimal.max(
|
||||
0,
|
||||
new Decimal(aggregatedBalance),
|
||||
).toNumber();
|
||||
const remaining = new Decimal(mainRemaining)
|
||||
.add(aggregatedRolloverBalance)
|
||||
.toNumber();
|
||||
|
||||
const usage = new Decimal(granted).sub(aggregatedBalance);
|
||||
// Usage mirrors the entity-view formula: (granted - main balance) + rollover usage.
|
||||
const usage = new Decimal(granted)
|
||||
.sub(aggregatedBalance)
|
||||
.add(aggregatedRolloverUsage);
|
||||
|
||||
return {
|
||||
...apiBalance,
|
||||
|
||||
@@ -129,6 +129,9 @@ export const getApiBalanceV2 = ({
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// console.log("customerEntitlements", customerEntitlements);
|
||||
// console.log("aggregatedFeatureBalance", aggregatedFeatureBalance);
|
||||
|
||||
if (customerEntitlements.length === 0) {
|
||||
return {
|
||||
data: mergeAggregatedBalanceIntoApiBalanceV2({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user