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:
2026-05-20 17:01:33 +02:00
committed by batmanisko
parent 17132d4124
commit df5423511f
31 changed files with 1252 additions and 510 deletions
+11 -20
View File
@@ -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>;
}
+12 -2
View File
@@ -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);
}
}
+11
View File
@@ -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);
}
}
+36 -3
View File
@@ -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();
}