feat: المجموعة G — Redis خط أول والقاعدة خط احتياط
الأساس: common/cache — كاش-جانبي فوق Redis. مبدأ صارم: فشل Redis لا يُسقط الطلب (يُسجَّل ويُرجَع للقاعدة) — الكاش تحسين أداء لا مصدر حقيقة. - G1: لغة المستخدم في Redis (كانت استعلاماً قبل كل إشعار)؛ تُكتب عند PATCH /users/me فيصير الإصابة دائمة - G2/G3: توكنات FCM في Redis — يُبطَل عند register وعند اكتشاف توكن ميت. البث الجماعي صار بلا استعلامات قاعدة - G4: كاش tenant resolve/config + إبطال عند الإنشاء - G5: كاش التعرفة و ride-types + إبطال صريح عند أي إنشاء - G6: التقييم يتراكم في Redis، والقاعدة تُكتب مرة واحدة يومياً لكل طرف (حجز الكتابة ذرّي بـLua). التأخير مقصود ليبعد الاحتكاك بين الطرفين. أُضيف ratings.target_user_id و users.rating — تقييم السائق للراكب كان يُخزَّن بلا هدف فلا يُجمَّع أبداً - كاش الخرائط (route/reverse/geocode) بإحداثيات مقرَّبة 4 خانات (~11م): نداء انطلق كان يهيمن على p50 (2.9s). لا يُخزَّن الرجوع لخط مستقيم ولا استجابة فاشلة تصحيح: ادّعيت سابقاً أن tenants.resolve يعمل على كل request — خطأ. tenant_id يأتي من الـJWT؛ resolve يُستدعى عند الدخول و3 كنترولرات فقط. الحمل الأثقل فعلياً هو رفع موقع السائق (المجموعة H). هجرة: RatingsBothParties. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
857652404b
commit
a83dd7e5e5
+9
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CacheService],
|
||||
exports: [CacheService],
|
||||
})
|
||||
export class CacheModule {}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import RedisMock from 'ioredis-mock';
|
||||
import { CacheService, CacheKeys } from './cache.service';
|
||||
|
||||
describe('CacheService', () => {
|
||||
let redis: any;
|
||||
let cache: CacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
redis = new RedisMock({ keyPrefix: 'tripz:' });
|
||||
cache = new CacheService(redis);
|
||||
});
|
||||
|
||||
it('يخزّن ويقرأ قيمة مركّبة', async () => {
|
||||
await cache.set('k', { a: 1, b: ['x'] }, 60);
|
||||
expect(await cache.get<{ a: number; b: string[] }>('k')).toEqual({ a: 1, b: ['x'] });
|
||||
});
|
||||
|
||||
it('يرجع null لمفتاح غائب', async () => {
|
||||
expect(await cache.get('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('wrap: يحمّل من المصدر مرة واحدة ثم يخدم من الكاش', async () => {
|
||||
const loader = jest.fn().mockResolvedValue({ id: 't1' });
|
||||
|
||||
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
|
||||
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('wrap: لا يخزّن null — الغياب المؤقّت لا يُثبَّت', async () => {
|
||||
const loader = jest.fn().mockResolvedValue(null);
|
||||
|
||||
expect(await cache.wrap('k', 60, loader)).toBeNull();
|
||||
expect(await cache.wrap('k', 60, loader)).toBeNull();
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('del يُبطل المفتاح فيعود المصدر', async () => {
|
||||
const loader = jest.fn().mockResolvedValue('v1');
|
||||
await cache.wrap('k', 60, loader);
|
||||
await cache.del('k');
|
||||
await cache.wrap('k', 60, loader);
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('mget يقرأ عدة مفاتيح ويعطي null للغائب', async () => {
|
||||
await cache.set('a', 1, 60);
|
||||
await cache.set('c', 3, 60);
|
||||
expect(await cache.mget<number>(['a', 'b', 'c'])).toEqual([1, null, 3]);
|
||||
});
|
||||
|
||||
it('فشل Redis لا يُسقط الطلب — يرجع للمصدر', async () => {
|
||||
const broken = {
|
||||
get: jest.fn().mockRejectedValue(new Error('redis down')),
|
||||
set: jest.fn().mockRejectedValue(new Error('redis down')),
|
||||
} as any;
|
||||
const c = new CacheService(broken);
|
||||
const loader = jest.fn().mockResolvedValue('from-db');
|
||||
|
||||
expect(await c.wrap('k', 60, loader)).toBe('from-db');
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('المفاتيح معزولة بالمستأجر', () => {
|
||||
expect(CacheKeys.userLang('t1', 'u1')).not.toBe(CacheKeys.userLang('t2', 'u1'));
|
||||
expect(CacheKeys.tariff('t1', 'amman', 'economy')).toBe('tariff:t1:amman:economy');
|
||||
});
|
||||
});
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS } from '../redis/redis.module';
|
||||
|
||||
/** أعمار الكاش بالثواني — مجمّعة هنا ليسهل ضبطها (docs/17 — G). */
|
||||
export const TTL = {
|
||||
/** المستأجر يتغيّر نادراً جداً. */
|
||||
tenant: 3600,
|
||||
/** لغة المستخدم — يرفعها فلاتر عند الفتح. */
|
||||
userLang: 24 * 3600,
|
||||
/** توكنات FCM للجهاز. */
|
||||
deviceTokens: 6 * 3600,
|
||||
/** التعرفة و ride-types — شبه ثابتة، وتُبطَل صراحةً عند تعديل الأدمن. */
|
||||
catalog: 900,
|
||||
/** نتائج الخرائط — عناوين ومسارات لا تتغيّر عملياً. */
|
||||
maps: 24 * 3600,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* كاش-جانبي (cache-aside) فوق Redis — «الريدز خط أول، القاعدة خط احتياط»
|
||||
* (docs/17 — G).
|
||||
*
|
||||
* مبدأ صارم: **فشل Redis لا يُسقط الطلب**. أي خطأ هنا يُسجَّل ويُرجَع للقاعدة،
|
||||
* لأن الكاش تحسين أداء لا مصدر حقيقة.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
private readonly logger = new Logger('Cache');
|
||||
|
||||
constructor(@Inject(REDIS) private readonly redis: Redis) {}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await this.redis.get(key);
|
||||
return raw == null ? null : (JSON.parse(raw) as T);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`get ${key} failed: ${e?.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, value: unknown, ttlSec: number): Promise<void> {
|
||||
try {
|
||||
await this.redis.set(key, JSON.stringify(value), 'EX', ttlSec);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`set ${key} failed: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async del(...keys: string[]): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
try {
|
||||
await this.redis.del(...keys);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`del failed: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* يقرأ من الكاش، وعند الغياب يحمّل من المصدر ويخزّن.
|
||||
* `null` من المصدر لا يُخزَّن — لئلا يُثبَّت غياب مؤقّت لمدة TTL كاملة.
|
||||
*/
|
||||
async wrap<T>(key: string, ttlSec: number, loader: () => Promise<T | null>): Promise<T | null> {
|
||||
const hit = await this.get<T>(key);
|
||||
if (hit !== null) return hit;
|
||||
|
||||
const fresh = await loader();
|
||||
if (fresh !== null && fresh !== undefined) await this.set(key, fresh, ttlSec);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** قراءة عدة مفاتيح بجولة واحدة — للبث الجماعي (docs/17 — G3). */
|
||||
async mget<T>(keys: string[]): Promise<(T | null)[]> {
|
||||
if (keys.length === 0) return [];
|
||||
try {
|
||||
const raws = await this.redis.mget(...keys);
|
||||
return raws.map((raw) => {
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`mget failed: ${e?.message}`);
|
||||
return keys.map(() => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** مفاتيح الكاش — مجمّعة لتفادي التضارب ولتسهيل الإبطال. */
|
||||
export const CacheKeys = {
|
||||
tenant: (slugOrId: string) => `tenant:${slugOrId}`,
|
||||
userLang: (tenantId: string, userId: string) => `user:lang:${tenantId}:${userId}`,
|
||||
deviceTokens: (tenantId: string, userId: string) => `user:fcm:${tenantId}:${userId}`,
|
||||
tariff: (tenantId: string, city: string, serviceClass: string) =>
|
||||
`tariff:${tenantId}:${city}:${serviceClass}`,
|
||||
rideTypes: (tenantId: string) => `ridetypes:${tenantId}`,
|
||||
mapsRoute: (from: string, to: string) => `maps:route:${from}:${to}`,
|
||||
mapsReverse: (lat: string, lng: string) => `maps:rev:${lat}:${lng}`,
|
||||
mapsGeocode: (country: string, q: string) => `maps:geo:${country}:${q}`,
|
||||
};
|
||||
Reference in New Issue
Block a user