Harden the Redis layer for production.
- Locks no longer fail open: an unreachable backend throws LockUnavailableError and withLock skips the run (or opts into running unlocked, as template seeding does). The lnbits payment poller keeps polling through an outage, made safe by only sending confirmation emails when a row actually transitioned. - Rate limiter INCR+PEXPIRE now runs as one Lua script, self-healing counters stranded without a TTL. - Per-email login lockout moves to a Redis-backed store shared across replicas (in-memory fallback preserved), case-insensitive on email. - Graceful shutdown on SIGTERM/SIGINT: stop periodic jobs, close the server, force-close lingering SSE sockets, close Redis. - Active PING probe backs the health flag; /health reports last ping. SSE payment streams also poll the DB as a pub/sub-gap fallback. - Scale compose gains requirepass, maxmemory 256mb with noeviction, and an authenticated healthcheck; .env.example documents passwords, TLS (rediss://), and DB-index selection. - First tests in the repo: vitest + ioredis-mock covering the lock, rate limiter, and login lockout stores (27 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
71c277045b
commit
e38d14970d
@@ -12,7 +12,7 @@ import {
|
||||
} from '../lib/lnbits.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { getPubSub } from '../lib/stores/pubsub.js';
|
||||
import { getLock } from '../lib/stores/lock.js';
|
||||
import { getLock, LockUnavailableError } from '../lib/stores/lock.js';
|
||||
|
||||
const lnbitsRouter = new Hono();
|
||||
|
||||
@@ -106,12 +106,26 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
|
||||
|
||||
const expiryMs = expirySeconds * 1000;
|
||||
|
||||
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
|
||||
if (!lockToken) {
|
||||
let lockToken: string | null = null;
|
||||
let lockUnavailable = false;
|
||||
try {
|
||||
lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
|
||||
} catch (err) {
|
||||
if (!(err instanceof LockUnavailableError)) throw err;
|
||||
// Fail open: in webhook-less deployments this poller is the only way a
|
||||
// Lightning payment gets confirmed, so a Redis outage must not stop it.
|
||||
// Duplicate pollers across replicas are harmless because
|
||||
// handlePaymentComplete only acts on rows it actually transitions.
|
||||
console.warn(`[lnbits] lock backend unavailable, polling ticket ${ticketId} without lock:`, err.message);
|
||||
lockUnavailable = true;
|
||||
}
|
||||
if (!lockToken && !lockUnavailable) {
|
||||
// Another instance is already polling this ticket.
|
||||
return;
|
||||
}
|
||||
checkerLockTokens.set(ticketId, lockToken);
|
||||
if (lockToken) {
|
||||
checkerLockTokens.set(ticketId, lockToken);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let checkCount = 0;
|
||||
@@ -270,25 +284,35 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
}
|
||||
|
||||
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
|
||||
let transitioned = 0;
|
||||
for (const ticket of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
const result: any = await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
|
||||
|
||||
transitioned += result?.changes ?? result?.rowCount ?? 0;
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
.set({
|
||||
status: 'paid',
|
||||
reference: paymentHash,
|
||||
paidAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending')));
|
||||
|
||||
|
||||
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||
}
|
||||
|
||||
|
||||
// Only the caller that actually flipped rows sends the emails. The webhook
|
||||
// and the background poller (and pollers on multiple replicas during a Redis
|
||||
// outage) can all land here; without this guard they would each email.
|
||||
if (transitioned === 0) {
|
||||
console.log(`Ticket ${ticketId} was already confirmed by a concurrent caller, skipping emails`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get primary payment for sending receipt
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -296,7 +320,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
|
||||
// Send confirmation emails asynchronously
|
||||
// For multi-ticket bookings, send email with all ticket info
|
||||
Promise.all([
|
||||
@@ -372,8 +396,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// Clean up on disconnect
|
||||
stream.onAbort(() => {
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
const connections = activeConnections.get(ticketId);
|
||||
if (connections) {
|
||||
@@ -388,11 +411,28 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the stream open
|
||||
while (true) {
|
||||
await stream.sleep(30000);
|
||||
};
|
||||
|
||||
// Clean up on disconnect
|
||||
stream.onAbort(cleanup);
|
||||
|
||||
// Keep the stream open, and every 15s fall back to reading the ticket
|
||||
// status from the DB. Pub/sub is best-effort: a message published while the
|
||||
// subscriber connection was reconnecting is lost, and without this check
|
||||
// the client would wait forever on a payment that already confirmed.
|
||||
try {
|
||||
while (true) {
|
||||
await stream.sleep(15000);
|
||||
const current = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
if (current?.status === 'confirmed') {
|
||||
await sendEvent({ type: 'paid', ticketId });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user