feat: podpora high-availability a multi-replica nasazení
- Socket.io Redis adapter pro sdílený stav přes repliky - graceful shutdown serveru - WATCH/MULTI v updateData pro race-condition-safe aktualizace - lease mechanismus pro push reminder (zabrání duplicitnímu odesílání) - k8s/ manifesty pro testovací kind cluster - Dockerfile: opraven EXPOSE port na 3001 - .gitignore: ignorovány Claude pracovní soubory
This commit is contained in:
@@ -1,33 +1,14 @@
|
||||
/**
|
||||
* Interface pro úložiště dat.
|
||||
*
|
||||
* Aktuálně pouze "primitivní" has, get a set odrážející původní JSON DB.
|
||||
* Postupem času lze předělat pro efektivnější využití Redis.
|
||||
*/
|
||||
export interface StorageInterface {
|
||||
|
||||
/**
|
||||
* Inicializuje úložiště, pokud je potřeba (např. připojení k databázi).
|
||||
*/
|
||||
initialize?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Vrátí příznak, zda existují data pro předaný klíč.
|
||||
* @param key klíč, pro který zjišťujeme data (typicky datum)
|
||||
*/
|
||||
hasData(key: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Vrátí veškerá data pro předaný klíč.
|
||||
* @param key klíč, pro který vrátit data (typicky datum)
|
||||
*/
|
||||
getData<Type>(key: string): Promise<Type | undefined>;
|
||||
|
||||
/**
|
||||
* Uloží data pod předaný klíč.
|
||||
* @param key klíč, pod kterým uložit data (typicky datum)
|
||||
* @param data data pro uložení
|
||||
*/
|
||||
setData<Type>(key: string, data: Type): Promise<void>;
|
||||
|
||||
/**
|
||||
@@ -35,4 +16,14 @@ export interface StorageInterface {
|
||||
* @param contains volitelný podřetězec, který musí klíč obsahovat (např. '_extra')
|
||||
*/
|
||||
listKeys(contains?: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomicky načte, zmutuje a uloží data pod daným klíčem.
|
||||
* V Redis implementaci používá WATCH/MULTI/EXEC retry loop.
|
||||
* Vrátí výslednou hodnotu po aplikaci mutátoru.
|
||||
*/
|
||||
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type>;
|
||||
|
||||
/** Ověří dostupnost úložiště. Vrátí false pokud není dostupné. */
|
||||
healthCheck?(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import * as path from 'path';
|
||||
const dbPath = path.resolve(__dirname, '../../data/db.json');
|
||||
const dbDir = path.dirname(dbPath);
|
||||
|
||||
// Zajistěte, že adresář existuje
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
@@ -34,4 +33,15 @@ export default class JsonStorage implements StorageInterface {
|
||||
const keys = Object.keys(db.JSON());
|
||||
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
|
||||
}
|
||||
}
|
||||
|
||||
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
|
||||
const current = db.get(key) as Type | undefined;
|
||||
const next = mutator(current);
|
||||
db.set(key, next);
|
||||
return Promise.resolve(next);
|
||||
}
|
||||
|
||||
healthCheck(): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,15 @@ export default class MemoryStorage implements StorageInterface {
|
||||
const keys = Array.from(store.keys());
|
||||
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
|
||||
}
|
||||
|
||||
updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
|
||||
const current = store.get(key) as Type | undefined;
|
||||
const next = mutator(current);
|
||||
store.set(key, next);
|
||||
return Promise.resolve(next);
|
||||
}
|
||||
|
||||
healthCheck(): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default class RedisStorage implements StorageInterface {
|
||||
constructor() {
|
||||
const HOST = process.env.REDIS_HOST ?? 'localhost';
|
||||
const PORT = process.env.REDIS_PORT ?? 6379;
|
||||
client = createClient({ url: `redis://${HOST}:${PORT}` });
|
||||
client = createClient({ url: `redis://${HOST}:${PORT}` }) as RedisClientType;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
@@ -29,7 +29,6 @@ export default class RedisStorage implements StorageInterface {
|
||||
|
||||
async setData<Type>(key: string, data: Type) {
|
||||
await client.json.set(key, '.', data as any);
|
||||
await client.json.get(key);
|
||||
}
|
||||
|
||||
async listKeys(contains?: string): Promise<string[]> {
|
||||
@@ -43,4 +42,38 @@ export default class RedisStorage implements StorageInterface {
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
async updateData<Type>(key: string, mutator: (current: Type | undefined) => Type): Promise<Type> {
|
||||
return (client as any).executeIsolated(async (c: any) => {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
await c.watch(key);
|
||||
const current = await c.json.get(key, { path: '.' }) as Type | undefined;
|
||||
const next = mutator(current);
|
||||
const multi = c.multi();
|
||||
multi.json.set(key, '.', next);
|
||||
const result = await multi.exec();
|
||||
if (result !== null) return next;
|
||||
}
|
||||
throw new Error(`updateData: optimistic lock failed after 10 retries for key: ${key}`);
|
||||
});
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const pong = await client.ping();
|
||||
return pong === 'PONG';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Vrátí hlavní Redis klient — používá se pro lease připomínkovače a shutdown. */
|
||||
export function getRedisClient(): RedisClientType | undefined {
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Zavře připojení k Redisu. Volá se při graceful shutdown. */
|
||||
export async function shutdownRedisStorage(): Promise<void> {
|
||||
await client?.quit();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user