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> {
let cursor = "0";
const batch: string[] = [];
const pipeline = this.redis.pipeline();
let pending = 0;
const flush = async () => {
if (batch.length > 0) {
const pipeline = this.redis.pipeline();
for (const k of batch.splice(0, batch.length)) pipeline.del(k);
await pipeline.exec();
if (pending === 0) {
return;
}
await pipeline.exec();
pending = 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) {
batch.push(...keys);
if (batch.length >= 1000) {
await flush();
}
await this.scanPattern(pattern, async keys => {
keys.forEach(key => {
pipeline.del(key);
pending += 1;
});
if (pending >= 1000) {
await flush();
}
} while (cursor !== "0");
});
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> {
return (await this.redis.exists(key)) === 1;
}
@ -91,4 +119,24 @@ export class CacheService {
await this.set(key, fresh, ttlSeconds);
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 } {
let result = { totalKeys: 0, memoryUsage: 0 };
async getStats(): Promise<{ totalKeys: number; memoryUsage: number }> {
const pattern = `${this.CACHE_PREFIX}:*`;
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) {
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> {

View File

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

View File

@ -154,16 +154,7 @@ export class UserProfileService {
if (!mapping?.whmcsClientId) {
this.logger.warn(`No WHMCS mapping found for user ${userId}`);
let 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 currency = "JPY";
const summary: DashboardSummary = {
stats: {

View File

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