got all tests passing

This commit is contained in:
John Yeo
2026-04-19 15:21:44 +01:00
parent 04ef54726a
commit ef447a3df0
161 changed files with 5873 additions and 2552 deletions

16
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,3 @@
[submodule "ai"]
path = ai
url = https://github.com/useautumn/ai

View File

@@ -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"
]
}

View File

@@ -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
View File

@@ -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

Submodule ai added at 69df20acac

View 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();

View File

@@ -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);
}
}

View File

@@ -95,6 +95,7 @@ const buildSubjectBalance = ({ index }: { index: number }) => ({
customerPrice: null,
customerProductOptions: null,
customerProductQuantity: 1,
isEntityLevel: false,
});
const buildCachedFullSubject = (): CachedFullSubject => {

View File

@@ -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
})

View File

@@ -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"

View File

@@ -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"

View File

@@ -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'

View 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" })

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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 })

View File

@@ -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

View File

@@ -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

View File

@@ -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 })

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}`;

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -16,5 +16,7 @@ export const clearCusEntsFromCache = async ({
if (customersToDelete.length === 0) return;
await batchDeleteCachedFullCustomers({ customers: customersToDelete });
await batchDeleteCachedFullCustomers({
customers: customersToDelete,
});
};

View File

@@ -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;
};

View File

@@ -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,
});
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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`,

View File

@@ -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`,
);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
});
};

View File

@@ -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({

View File

@@ -56,6 +56,7 @@ export const updateNextResetAtV2 = async ({
ctx,
id: targetCusEnt.id,
updates: { next_reset_at: nextResetAt },
incrementCacheVersion: false,
});
await updateSubjectBalanceCache({

View File

@@ -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,
},
});
}

View File

@@ -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,
},
});
}

View File

@@ -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 = ({

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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],
],
),
),
};
};

View File

@@ -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,

View File

@@ -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";

View File

@@ -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}`,
);
}
};

View File

@@ -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),
});
}
};

View File

@@ -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;

View File

@@ -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,
});
}

View File

@@ -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,
});

View File

@@ -26,6 +26,8 @@ export const getApiCustomerByRollout = async ({
source,
});
// console.log("fullSubject", fullSubject);
return getApiCustomerV2({
ctx,
fullSubject,

View File

@@ -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],
// });
// }
}),
);
}
};

View File

@@ -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 });
};

View File

@@ -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`,
);

View File

@@ -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;
}
};

View File

@@ -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;
};

View File

@@ -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;
}
};

View File

@@ -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}`,
);
}
};

View File

@@ -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;
};

View File

@@ -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}`,

View File

@@ -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;
}
}

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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,
);
};

View File

@@ -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,
});
}

View File

@@ -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(

View 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)`,
);
}

View File

@@ -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}`,

View File

@@ -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;
}
}

View File

@@ -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,
});
};

View File

@@ -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";
};

View File

@@ -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,
);
};

View File

@@ -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,
};
});
};

View 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;
}
};

View File

@@ -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;

View 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,
};
};

View 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 };
},
),
};
};

View File

@@ -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,
});
}

View File

@@ -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`;

View File

@@ -0,0 +1,9 @@
import type { AppEnv } from "@autumn/shared";
export const buildFullSubjectOrgEnvKey = ({
orgId,
env,
}: {
orgId: string;
env: AppEnv;
}) => `${orgId}:${env}`;

View File

@@ -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`;

View File

@@ -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";

View File

@@ -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,

View File

@@ -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";

View File

@@ -26,6 +26,7 @@ const rolloverShapeSpec: ShapeSpec = {
};
const subjectBalanceShapeSpec: ShapeSpec = {
replaceables: "array",
rollovers: { items: rolloverShapeSpec },
entities: "nullable_record",
entitlement: entitlementShapeSpec,

View File

@@ -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",
// });
// }
};

View File

@@ -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;
};

View 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;
}
};

View File

@@ -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(

View File

@@ -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,
},
}),
]);
};

View File

@@ -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;

View File

@@ -46,7 +46,6 @@ export const deleteCachedFullCustomer = async ({
customerId,
entityId,
source,
skipGuard,
}),
];

View File

@@ -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,

View File

@@ -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