feat: podpora high-availability a multi-replica nasazení
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 20s
CI / Build server (push) Successful in 26s
CI / Build client (push) Successful in 35s
CI / Playwright E2E tests (push) Failing after 1m56s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 1s

- 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
parent a26d6cf85c
commit 67abbf19b5
32 changed files with 1265 additions and 552 deletions
+89 -39
View File
@@ -1,12 +1,17 @@
import webpush from 'web-push';
import crypto from 'crypto';
import getStorage from './storage';
import { getRedisClient } from './storage/redis';
import { getClientData, getToday } from './service';
import { getIsWeekend } from './utils';
import { LunchChoices } from '../../types';
const storage = getStorage();
const REGISTRY_KEY = 'push_reminder_registry';
const LEADER_LEASE_KEY = 'luncher:reminder:leader';
const LEASE_TTL_SECONDS = 90;
const POD_ID = process.env.POD_ID ?? `local-${process.pid}`;
interface RegistryEntry {
time: string;
@@ -20,6 +25,8 @@ const lastReminded = new Map<string, number>();
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
let reminderInterval: ReturnType<typeof setInterval> | undefined;
function getCurrentTimeHHMM(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
@@ -36,27 +43,76 @@ function userHasChoice(choices: LunchChoices, login: string): boolean {
return false;
}
async function getRegistry(): Promise<Registry> {
return await storage.getData<Registry>(REGISTRY_KEY) ?? {};
/**
* Pokusí se získat nebo obnovit leader lease pro scheduler připomínek.
* Vrátí true pokud tato instance smí spustit připomínky.
* Při non-Redis storage vždy vrací true (single-process, leader election není potřeba).
*/
async function tryAcquireOrRenewLease(): Promise<boolean> {
if (process.env.STORAGE?.toLowerCase() !== 'redis') return true;
try {
const c = getRedisClient();
if (!c) return true;
// Zkusíme získat lease atomicky (SET NX EX)
const acquired = await c.set(LEADER_LEASE_KEY, POD_ID, { NX: true, EX: LEASE_TTL_SECONDS });
if (acquired !== null) return true; // lease čerstvě získána
// Pokud jsme ji nedostali, ověříme zda ji držíme my
const currentHolder = await c.get(LEADER_LEASE_KEY);
if (currentHolder === POD_ID) {
// Naše lease — obnovíme TTL
await c.set(LEADER_LEASE_KEY, POD_ID, { EX: LEASE_TTL_SECONDS });
return true;
}
return false; // lease drží jiná instance
} catch (e) {
console.error('Push reminder: chyba při získávání lease, připomínky budou odeslány', e);
return true; // při chybě raději spustíme, než vynecháme
}
}
async function saveRegistry(registry: Registry): Promise<void> {
await storage.setData(REGISTRY_KEY, registry);
/** Uvolní leader lease při graceful shutdown. */
export async function releaseReminderLease(): Promise<void> {
if (process.env.STORAGE?.toLowerCase() !== 'redis') return;
try {
const c = getRedisClient();
if (!c) return;
const currentHolder = await c.get(LEADER_LEASE_KEY);
if (currentHolder === POD_ID) {
await c.del(LEADER_LEASE_KEY);
console.log('Push reminder: lease uvolněna');
}
} catch (e) {
console.error('Push reminder: chyba při uvolňování lease', e);
}
}
/** Stopne scheduler připomínek. Volá se při graceful shutdown. */
export function stopReminderScheduler(): void {
if (reminderInterval) {
clearInterval(reminderInterval);
reminderInterval = undefined;
}
}
/** Přidá nebo aktualizuje push subscription pro uživatele. */
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> {
const registry = await getRegistry();
registry[login] = { time: reminderTime, subscription };
await saveRegistry(registry);
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
const registry = current ?? {};
registry[login] = { time: reminderTime, subscription };
return registry;
});
console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`);
}
/** Odebere push subscription pro uživatele. */
export async function unsubscribePush(login: string): Promise<void> {
const registry = await getRegistry();
delete registry[login];
await saveRegistry(registry);
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
const registry = current ?? {};
delete registry[login];
return registry;
});
lastReminded.delete(login);
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
}
@@ -79,23 +135,20 @@ export function verifyQuickChoiceToken(login: string, token: string): boolean {
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
}
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
async function checkAndSendReminders(): Promise<void> {
// Přeskočit víkendy
if (getIsWeekend(getToday())) {
return;
}
if (getIsWeekend(getToday())) return;
const registry = await getRegistry();
// Leader election — pouze jeden pod spouští připomínky
const isLeader = await tryAcquireOrRenewLease();
if (!isLeader) return;
const registry = await storage.getData<Registry>(REGISTRY_KEY) ?? {};
const entries = Object.entries(registry);
if (entries.length === 0) {
return;
}
if (entries.length === 0) return;
const currentTime = getCurrentTimeHHMM();
// Získáme data pro dnešek jednou pro všechny uživatele
let clientData;
try {
clientData = await getClientData(getToday());
@@ -104,24 +157,16 @@ async function checkAndSendReminders(): Promise<void> {
return;
}
const expiredLogins: string[] = [];
for (const [login, entry] of entries) {
// Ještě nedosáhl čas připomínky
if (currentTime < entry.time) {
continue;
}
if (currentTime < entry.time) continue;
// Cooldown — nepřipomínat častěji než jednou za hodinu
const last = lastReminded.get(login) ?? 0;
if (Date.now() - last < REMINDER_COOLDOWN_MS) {
continue;
}
if (Date.now() - last < REMINDER_COOLDOWN_MS) continue;
// Uživatel už má zvolenou možnost
if (clientData.choices && userHasChoice(clientData.choices, login)) {
continue;
}
if (clientData.choices && userHasChoice(clientData.choices, login)) continue;
// Odešleme push notifikaci
try {
await webpush.sendNotification(
entry.subscription,
@@ -136,15 +181,21 @@ async function checkAndSendReminders(): Promise<void> {
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription expirovala nebo je neplatná — odebereme z registry
console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`);
delete registry[login];
await saveRegistry(registry);
expiredLogins.push(login);
} else {
console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error);
}
}
}
if (expiredLogins.length > 0) {
await storage.updateData<Registry>(REGISTRY_KEY, (current) => {
const r = current ?? {};
for (const login of expiredLogins) delete r[login];
return r;
});
}
}
/** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */
@@ -160,7 +211,6 @@ export function startReminderScheduler(): void {
webpush.setVapidDetails(subject, publicKey, privateKey);
// Spustíme kontrolu každou minutu
setInterval(checkAndSendReminders, 60_000);
console.log('Push reminder: scheduler spuštěn');
reminderInterval = setInterval(checkAndSendReminders, 60_000);
console.log(`Push reminder: scheduler spuštěn (POD_ID=${POD_ID})`);
}