* fix: migration for newly created self-hosting instances * fix: build script * wip * wip * wip * fix: tailwind css
241 lines
6.8 KiB
TypeScript
241 lines
6.8 KiB
TypeScript
import { getRedisCache } from '@openpanel/redis';
|
|
import type { Operation } from '@prisma/client/runtime/client';
|
|
import { Prisma, type PrismaClient } from './generated/prisma/client';
|
|
import { logger } from './logger';
|
|
import { getAlsSessionId } from './session-context';
|
|
|
|
type BarePrismaClient = {
|
|
$queryRaw: <T>(query: TemplateStringsArray, ...args: unknown[]) => Promise<T>;
|
|
};
|
|
|
|
// WAL LSN tracking for read-after-write consistency
|
|
const LSN_CACHE_PREFIX = 'db:wal_lsn:';
|
|
const LSN_CACHE_TTL = 5;
|
|
const MAX_RETRY_ATTEMPTS = 5;
|
|
const INITIAL_RETRY_DELAY_MS = 10;
|
|
|
|
const READ_OPERATIONS: Operation[] = [
|
|
'findUnique',
|
|
'findUniqueOrThrow',
|
|
'findFirst',
|
|
'findFirstOrThrow',
|
|
'findMany',
|
|
'aggregate',
|
|
'groupBy',
|
|
'count',
|
|
];
|
|
|
|
const WRITE_OPERATIONS: Operation[] = [
|
|
'create',
|
|
'update',
|
|
'delete',
|
|
'createMany',
|
|
'createManyAndReturn',
|
|
'updateMany',
|
|
'deleteMany',
|
|
'upsert',
|
|
];
|
|
|
|
const isWriteOperation = (operation: string) =>
|
|
WRITE_OPERATIONS.includes(operation as Operation);
|
|
|
|
const isReadOperation = (operation: string) =>
|
|
READ_OPERATIONS.includes(operation as Operation);
|
|
|
|
async function getCurrentWalLsn(
|
|
prismaClient: BarePrismaClient,
|
|
): Promise<string | null> {
|
|
try {
|
|
const result = await prismaClient.$queryRaw<[{ lsn: string }]>`
|
|
SELECT pg_current_wal_lsn()::text AS lsn
|
|
`;
|
|
return result[0]?.lsn || null;
|
|
} catch (error) {
|
|
logger.error('Failed to get WAL LSN', { error });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function cacheWalLsnForSession(
|
|
sessionId: string,
|
|
lsn: string,
|
|
): Promise<void> {
|
|
try {
|
|
const redis = getRedisCache();
|
|
await redis.setex(`${LSN_CACHE_PREFIX}${sessionId}`, LSN_CACHE_TTL, lsn);
|
|
} catch (error) {
|
|
logger.error('Failed to cache WAL LSN', { error, sessionId });
|
|
}
|
|
}
|
|
|
|
async function getCachedWalLsn(sessionId: string): Promise<string | null> {
|
|
try {
|
|
const redis = getRedisCache();
|
|
return await redis.get(`${LSN_CACHE_PREFIX}${sessionId}`);
|
|
} catch (error) {
|
|
logger.error('Failed to get cached WAL LSN', { error, sessionId });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function compareWalLsn(lsn1: string, lsn2: string): number {
|
|
const [x1, y1] = lsn1.split('/').map((x) => BigInt(`0x${x}`));
|
|
const [x2, y2] = lsn2.split('/').map((x) => BigInt(`0x${x}`));
|
|
|
|
const v1 = ((x1 ?? 0n) << 32n) + (y1 ?? 0n);
|
|
const v2 = ((x2 ?? 0n) << 32n) + (y2 ?? 0n);
|
|
|
|
if (v1 < v2) return -1;
|
|
if (v1 > v2) return 1;
|
|
return 0;
|
|
}
|
|
|
|
async function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// Method not used for now,
|
|
// Need a way to check LSN on the actual replica that will be used for the read.
|
|
async function waitForReplicaCatchup(
|
|
prismaClient: BarePrismaClient,
|
|
sessionId: string,
|
|
): Promise<boolean> {
|
|
const expectedLsn = await getCachedWalLsn(sessionId);
|
|
|
|
if (!expectedLsn) {
|
|
return true;
|
|
}
|
|
|
|
for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
|
|
const currentLsn = await getCurrentWalLsn(prismaClient);
|
|
if (!currentLsn) {
|
|
return true;
|
|
}
|
|
|
|
// Check if replica has caught up (current >= expected)
|
|
if (compareWalLsn(currentLsn, expectedLsn) >= 0) {
|
|
logger.debug('Replica caught up', {
|
|
attempt: attempt + 1,
|
|
currentLsn,
|
|
expectedLsn,
|
|
sessionId,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// Exponential backoff
|
|
if (attempt < MAX_RETRY_ATTEMPTS - 1) {
|
|
const delayMs = INITIAL_RETRY_DELAY_MS * 2 ** attempt;
|
|
logger.debug('Waiting for replica to catch up', {
|
|
attempt: attempt + 1,
|
|
delayMs,
|
|
currentLsn,
|
|
expectedLsn,
|
|
sessionId,
|
|
});
|
|
await sleep(delayMs);
|
|
}
|
|
}
|
|
|
|
logger.warn(
|
|
'Replica did not catch up after max retries, falling back to primary',
|
|
{
|
|
sessionId,
|
|
expectedLsn,
|
|
},
|
|
);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Prisma extension for session-based read-after-write consistency.
|
|
*
|
|
* This extension tracks WAL LSN positions after writes and ensures that
|
|
* subsequent reads within the same session see those writes, even when
|
|
* using read replicas.
|
|
*
|
|
* How it works:
|
|
* 1. After any write operation with a session ID, it captures the WAL LSN
|
|
* 2. Before read operations with a session ID, it checks if the replica has caught up
|
|
* 3. If the replica hasn't caught up after retries, it forces the read to the primary
|
|
*
|
|
*/
|
|
export function sessionConsistency() {
|
|
return Prisma.defineExtension((client) =>
|
|
client.$extends({
|
|
name: 'session-consistency',
|
|
query: {
|
|
$allOperations: async ({
|
|
operation,
|
|
model,
|
|
args,
|
|
query,
|
|
// This is a hack to force reads to primary when replica hasn't caught up.
|
|
// The readReplicas extension routes queries to primary when in a transaction,
|
|
// so we set __internalParams.transaction = true to achieve this.
|
|
// @ts-expect-error - __internalParams is not in the types
|
|
__internalParams,
|
|
}) => {
|
|
const sessionId = getAlsSessionId();
|
|
|
|
// For write operations with session: cache WAL LSN after write
|
|
if (isWriteOperation(operation)) {
|
|
// logger.info('Prisma operation', {
|
|
// operation,
|
|
// args,
|
|
// model,
|
|
// });
|
|
|
|
const result = await query(args);
|
|
|
|
if (sessionId) {
|
|
// Get current WAL LSN and cache it for this session
|
|
const lsn = await getCurrentWalLsn(client);
|
|
if (lsn) {
|
|
await cacheWalLsnForSession(sessionId, lsn);
|
|
logger.debug('Cached WAL LSN after write', {
|
|
sessionId,
|
|
lsn,
|
|
operation,
|
|
model,
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// For now, we just force the read to the primary without checking the replica
|
|
// Since the check probably goes to the primary anyways it will always be true,
|
|
// Not sure how to check LSN on the actual replica that will be used for the read.
|
|
if (
|
|
model !== 'Session' &&
|
|
isReadOperation(operation) &&
|
|
sessionId &&
|
|
(await getCachedWalLsn(sessionId))
|
|
) {
|
|
const MAX_RETRIES = 3;
|
|
const INITIAL_RETRY_DELAY_MS = 50;
|
|
|
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
const result = await query(args);
|
|
|
|
if (result !== null) {
|
|
return result;
|
|
}
|
|
|
|
// If not the last attempt, wait with exponential backoff before retrying
|
|
if (attempt < MAX_RETRIES - 1) {
|
|
const delayMs = INITIAL_RETRY_DELAY_MS * 2 ** attempt;
|
|
await sleep(delayMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
return query(args);
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|