Refactor CacheService and MappingCacheService for improved performance and functionality

- Enhanced CacheService by implementing a more efficient pattern deletion method using a pipeline for batch processing.
- Added new methods in CacheService to count keys and calculate memory usage by pattern, improving cache management capabilities.
- Updated MappingCacheService to asynchronously retrieve cache statistics, ensuring accurate reporting of total keys and memory usage.
- Refactored existing methods for better readability and maintainability, including the introduction of a private scanPattern method for key scanning.
This commit is contained in:
barsa 2025-11-18 11:03:25 +09:00
parent c741ece844
commit 6d327d3ede
5 changed files with 119 additions and 96 deletions

View File

@ -45,34 +45,62 @@ export class CacheService {
} }
async delPattern(pattern: string): Promise<void> { async delPattern(pattern: string): Promise<void> {
let cursor = "0"; const pipeline = this.redis.pipeline();
const batch: string[] = []; let pending = 0;
const flush = async () => { const flush = async () => {
if (batch.length > 0) { if (pending === 0) {
const pipeline = this.redis.pipeline(); return;
for (const k of batch.splice(0, batch.length)) pipeline.del(k);
await pipeline.exec();
} }
await pipeline.exec();
pending = 0;
}; };
do {
const [next, keys] = (await this.redis.scan( await this.scanPattern(pattern, async keys => {
cursor, keys.forEach(key => {
"MATCH", pipeline.del(key);
pattern, pending += 1;
"COUNT", });
1000 if (pending >= 1000) {
)) as unknown as [string, string[]]; await flush();
cursor = next;
if (keys && keys.length) {
batch.push(...keys);
if (batch.length >= 1000) {
await flush();
}
} }
} while (cursor !== "0"); });
await flush(); await flush();
} }
async countByPattern(pattern: string): Promise<number> {
let total = 0;
await this.scanPattern(pattern, keys => {
total += keys.length;
});
return total;
}
async memoryUsageByPattern(pattern: string): Promise<number> {
let total = 0;
await this.scanPattern(pattern, async keys => {
const pipeline = this.redis.pipeline();
keys.forEach(key => {
pipeline.memory("usage", key);
});
const results = await pipeline.exec();
if (!results) {
return;
}
results.forEach(result => {
if (!result) {
return;
}
const [error, usage] = result as [Error | null, unknown];
if (!error && typeof usage === "number") {
total += usage;
}
});
});
return total;
}
async exists(key: string): Promise<boolean> { async exists(key: string): Promise<boolean> {
return (await this.redis.exists(key)) === 1; return (await this.redis.exists(key)) === 1;
} }
@ -91,4 +119,24 @@ export class CacheService {
await this.set(key, fresh, ttlSeconds); await this.set(key, fresh, ttlSeconds);
return fresh; return fresh;
} }
private async scanPattern(
pattern: string,
onKeys: (keys: string[]) => Promise<void> | void
): Promise<void> {
let cursor = "0";
do {
const [next, keys] = (await this.redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
1000
)) as unknown as [string, string[]];
cursor = next;
if (keys && keys.length) {
await onKeys(keys);
}
} while (cursor !== "0");
}
} }

View File

@ -83,14 +83,17 @@ export class MappingCacheService {
} }
} }
getStats(): { totalKeys: number; memoryUsage: number } { async getStats(): Promise<{ totalKeys: number; memoryUsage: number }> {
let result = { totalKeys: 0, memoryUsage: 0 }; const pattern = `${this.CACHE_PREFIX}:*`;
try { try {
result = { totalKeys: 0, memoryUsage: 0 }; const totalKeys = await this.cacheService.countByPattern(pattern);
const memoryUsage =
totalKeys === 0 ? 0 : await this.cacheService.memoryUsageByPattern(pattern);
return { totalKeys, memoryUsage };
} catch (error) { } catch (error) {
this.logger.error("Failed to get cache stats", { error: getErrorMessage(error) }); this.logger.error("Failed to get cache stats", { error: getErrorMessage(error) });
return { totalKeys: 0, memoryUsage: 0 };
} }
return result;
} }
private async get<T>(key: string): Promise<T | null> { private async get<T>(key: string): Promise<T | null> {

View File

@ -186,13 +186,6 @@ export class SubscriptionsService {
cancelled: number; cancelled: number;
}> { }> {
try { try {
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
// Get all subscriptions and aggregate customer-facing stats only
const subscriptionList = await this.getSubscriptions(userId); const subscriptionList = await this.getSubscriptions(userId);
const subscriptions: Subscription[] = subscriptionList.subscriptions; const subscriptions: Subscription[] = subscriptionList.subscriptions;
@ -282,7 +275,7 @@ export class SubscriptionsService {
async searchSubscriptions(userId: string, query: string): Promise<Subscription[]> { async searchSubscriptions(userId: string, query: string): Promise<Subscription[]> {
try { try {
if (!query || query.trim().length < 2) { if (!query || query.trim().length < 2) {
throw new Error("Search query must be at least 2 characters long"); throw new BadRequestException("Search query must be at least 2 characters long");
} }
const subscriptionList = await this.getSubscriptions(userId); const subscriptionList = await this.getSubscriptions(userId);
@ -318,6 +311,7 @@ export class SubscriptionsService {
options: { page?: number; limit?: number } = {} options: { page?: number; limit?: number } = {}
): Promise<InvoiceList> { ): Promise<InvoiceList> {
const { page = 1, limit = 10 } = options; const { page = 1, limit = 10 } = options;
const batchSize = Math.min(100, Math.max(limit, 25));
try { try {
// Validate subscription exists and belongs to user // Validate subscription exists and belongs to user
@ -329,51 +323,35 @@ export class SubscriptionsService {
throw new NotFoundException("WHMCS client mapping not found"); throw new NotFoundException("WHMCS client mapping not found");
} }
// Get all invoices for the user WITH ITEMS (needed for subscription linking) const relatedInvoices: Invoice[] = [];
// Note: Server-side filtering is handled by the WHMCS service via status and date filters. let currentPage = 1;
// Further performance optimization may involve pagination or selective field retrieval. let totalPages = 1;
const invoicesResponse = await this.whmcsService.getInvoicesWithItems(
mapping.whmcsClientId,
userId,
{ page: 1, limit: 1000 } // Get more to filter locally
);
const allInvoices = invoicesResponse; do {
const invoiceBatch = await this.whmcsService.getInvoicesWithItems(
mapping.whmcsClientId,
userId,
{ page: currentPage, limit: batchSize }
);
// Filter invoices that have items related to this subscription totalPages = invoiceBatch.pagination.totalPages;
// Note: subscriptionId is the same as serviceId in our current WHMCS mapping
this.logger.debug(
`Filtering ${allInvoices.invoices.length} invoices for subscription ${subscriptionId}`,
{
totalInvoices: allInvoices.invoices.length,
invoicesWithItems: allInvoices.invoices.filter(
(inv: Invoice) => inv.items && inv.items.length > 0
).length,
subscriptionId,
}
);
const relatedInvoices = allInvoices.invoices.filter((invoice: Invoice) => { invoiceBatch.invoices.forEach(invoice => {
const hasItems = invoice.items && invoice.items.length > 0; if (!invoice.items?.length) {
if (!hasItems) { return;
this.logger.debug(`Invoice ${invoice.id} has no items`); }
return false;
}
const hasMatchingService = invoice.items?.some((item: InvoiceItem) => { const hasMatchingService = invoice.items.some(
this.logger.debug( (item: InvoiceItem) => item.serviceId === subscriptionId
`Checking item: serviceId=${item.serviceId}, subscriptionId=${subscriptionId}`,
{
itemServiceId: item.serviceId,
subscriptionId,
matches: item.serviceId === subscriptionId,
}
); );
return item.serviceId === subscriptionId;
if (hasMatchingService) {
relatedInvoices.push(invoice);
}
}); });
return hasMatchingService; currentPage += 1;
}); } while (currentPage <= totalPages);
// Apply pagination to filtered results // Apply pagination to filtered results
const startIndex = (page - 1) * limit; const startIndex = (page - 1) * limit;
@ -384,7 +362,7 @@ export class SubscriptionsService {
invoices: paginatedInvoices, invoices: paginatedInvoices,
pagination: { pagination: {
page, page,
totalPages: Math.ceil(relatedInvoices.length / limit), totalPages: relatedInvoices.length === 0 ? 0 : Math.ceil(relatedInvoices.length / limit),
totalItems: relatedInvoices.length, totalItems: relatedInvoices.length,
}, },
}; };

View File

@ -154,16 +154,7 @@ export class UserProfileService {
if (!mapping?.whmcsClientId) { if (!mapping?.whmcsClientId) {
this.logger.warn(`No WHMCS mapping found for user ${userId}`); this.logger.warn(`No WHMCS mapping found for user ${userId}`);
let currency = "JPY"; const currency = "JPY";
try {
const profile = await this.getProfile(userId);
currency = profile.currency_code || currency;
} catch (error) {
this.logger.warn("Could not fetch currency from profile", {
userId,
error: getErrorMessage(error),
});
}
const summary: DashboardSummary = { const summary: DashboardSummary = {
stats: { stats: {

View File

@ -1,4 +1,5 @@
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { log } from "@/lib/logger";
import { import {
orderDetailsSchema, orderDetailsSchema,
orderSummarySchema, orderSummarySchema,
@ -15,26 +16,28 @@ async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: st
...(payload.configurations ? { configurations: payload.configurations } : {}), ...(payload.configurations ? { configurations: payload.configurations } : {}),
}; };
// Debug logging log.debug("Creating order", {
console.log("[DEBUG] Creating order with payload:", JSON.stringify(body, null, 2)); orderType: body.orderType,
console.log("[DEBUG] Order Type:", body.orderType); skuCount: body.skus.length,
console.log("[DEBUG] SKUs:", body.skus); hasConfigurations: !!body.configurations,
console.log("[DEBUG] Configurations:", body.configurations); });
try { try {
const response = await apiClient.POST("/api/orders", { body }); const response = await apiClient.POST("/api/orders", { body });
console.log("[DEBUG] Response:", response);
const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>( const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>(
response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }> response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }>
); );
return { sfOrderId: parsed.data.sfOrderId }; return { sfOrderId: parsed.data.sfOrderId };
} catch (error) { } catch (error) {
console.error("[DEBUG] Order creation failed:", error); log.error(
if (error && typeof error === "object" && "body" in error) { "Order creation failed",
console.error("[DEBUG] Error body:", error.body); error instanceof Error ? error : undefined,
} {
orderType: body.orderType,
skuCount: body.skus.length,
}
);
throw error; throw error;
} }
} }