优化数据库读写
This commit is contained in:
Vendored
+55
-10
@@ -5,26 +5,69 @@ function positiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
|
||||
}
|
||||
|
||||
function disabledCache(status = 'disabled') {
|
||||
function disabledCache(status = 'disabled', { ttlSeconds = 60, maxEntries = 200 } = {}) {
|
||||
const values = new Map();
|
||||
const pending = new Map();
|
||||
const generations = new Map();
|
||||
|
||||
const cacheKey = (namespace, key) => `${namespace}:${key}`;
|
||||
const generation = namespace => generations.get(namespace) || 0;
|
||||
const prune = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of values) if (entry.expiresAt <= now) values.delete(key);
|
||||
while (values.size > maxEntries) values.delete(values.keys().next().value);
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
status,
|
||||
async remember(_namespace, _key, loader) {
|
||||
return loader();
|
||||
async remember(namespace, key, loader, options = {}) {
|
||||
const fullKey = cacheKey(namespace, key);
|
||||
const cached = values.get(fullKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
if (cached) values.delete(fullKey);
|
||||
const startedGeneration = generation(namespace);
|
||||
const active = pending.get(fullKey);
|
||||
if (active?.generation === startedGeneration) return active.promise;
|
||||
|
||||
const loading = Promise.resolve(loader()).then(value => {
|
||||
if (generation(namespace) === startedGeneration) {
|
||||
const lifetime = positiveInteger(options.ttlSeconds, ttlSeconds, 86400);
|
||||
values.set(fullKey, { value, expiresAt: Date.now() + lifetime * 1000 });
|
||||
prune();
|
||||
}
|
||||
return value;
|
||||
}).finally(() => {
|
||||
if (pending.get(fullKey)?.promise === loading) pending.delete(fullKey);
|
||||
});
|
||||
pending.set(fullKey, { generation: startedGeneration, promise: loading });
|
||||
return loading;
|
||||
},
|
||||
async invalidate() { return false; },
|
||||
async close() {}
|
||||
async invalidate(namespace) {
|
||||
generations.set(namespace, generation(namespace) + 1);
|
||||
const prefix = `${namespace}:`;
|
||||
for (const key of values.keys()) if (key.startsWith(prefix)) values.delete(key);
|
||||
// Preserve the public meaning of this return value: no Redis namespace
|
||||
// was refreshed, even though the local fallback was invalidated.
|
||||
return false;
|
||||
},
|
||||
async close() {
|
||||
values.clear();
|
||||
pending.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRedisCache({ env = process.env, logger = console, clientFactory = createClient } = {}) {
|
||||
const url = String(env.REDIS_URL || '').trim();
|
||||
if (!url) return disabledCache();
|
||||
const defaultTtlSeconds = positiveInteger(env.REDIS_CACHE_TTL_SECONDS, 60, 86400);
|
||||
const localMaxEntries = positiveInteger(env.LOCAL_CACHE_MAX_ENTRIES, 200, 5000);
|
||||
if (!url) return disabledCache('disabled', { ttlSeconds: defaultTtlSeconds, maxEntries: localMaxEntries });
|
||||
const fallback = disabledCache('unavailable', { ttlSeconds: defaultTtlSeconds, maxEntries: localMaxEntries });
|
||||
|
||||
const prefix = String(env.REDIS_CACHE_PREFIX || 'exam-information')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9:_-]/g, '-') || 'exam-information';
|
||||
const defaultTtlSeconds = positiveInteger(env.REDIS_CACHE_TTL_SECONDS, 60, 86400);
|
||||
const connectTimeout = positiveInteger(env.REDIS_CONNECT_TIMEOUT_MS, 1500, 30000);
|
||||
const pending = new Map();
|
||||
let warningReported = false;
|
||||
@@ -54,7 +97,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
|
||||
} catch (error) {
|
||||
warn(error);
|
||||
if (client.isOpen) client.destroy();
|
||||
return disabledCache('unavailable');
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const versionKey = namespace => `${prefix}:namespace:${namespace}`;
|
||||
@@ -75,7 +118,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
|
||||
return client.isReady ? 'ready' : 'unavailable';
|
||||
},
|
||||
async remember(namespace, key, loader, { ttlSeconds = defaultTtlSeconds } = {}) {
|
||||
if (!client.isReady) return loader();
|
||||
if (!client.isReady) return fallback.remember(namespace, key, loader, { ttlSeconds });
|
||||
try {
|
||||
const version = await namespaceVersion(namespace);
|
||||
const cacheKey = `${prefix}:${namespace}:${version}:${key}`;
|
||||
@@ -99,10 +142,11 @@ export async function createRedisCache({ env = process.env, logger = console, cl
|
||||
return loading;
|
||||
} catch (error) {
|
||||
warn(error);
|
||||
return loader();
|
||||
return fallback.remember(namespace, key, loader, { ttlSeconds });
|
||||
}
|
||||
},
|
||||
async invalidate(namespace) {
|
||||
await fallback.invalidate(namespace);
|
||||
if (!client.isReady) return false;
|
||||
try {
|
||||
await client.incr(versionKey(namespace));
|
||||
@@ -113,6 +157,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
await fallback.close();
|
||||
if (client.isOpen) await client.quit();
|
||||
}
|
||||
};
|
||||
|
||||
+18
-1
@@ -1,4 +1,6 @@
|
||||
export async function api(path, options = {}) {
|
||||
const pendingReads = new Map();
|
||||
|
||||
async function request(path, options) {
|
||||
const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData;
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
@@ -15,3 +17,18 @@ export async function api(path, options = {}) {
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function api(path, options = {}) {
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
if (method !== 'GET' || options.body != null || options.signal) return request(path, options);
|
||||
|
||||
// A quick double click or repeated render must not download and parse the
|
||||
// same large JSON response more than once while the first request is active.
|
||||
const key = String(path);
|
||||
if (pendingReads.has(key)) return pendingReads.get(key);
|
||||
const loading = request(path, options).finally(() => {
|
||||
if (pendingReads.get(key) === loading) pendingReads.delete(key);
|
||||
});
|
||||
pendingReads.set(key, loading);
|
||||
return loading;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { synchronizeMysqlPartitions } from './partition-storage.mjs';
|
||||
|
||||
import { createStateCache } from './state-cache.mjs';
|
||||
|
||||
export function createMysqlAdapter(context) {
|
||||
const {
|
||||
mkdir,
|
||||
@@ -408,7 +410,9 @@ export function createMysqlAdapter(context) {
|
||||
partitionConnection.release();
|
||||
}
|
||||
|
||||
let stateCache;
|
||||
const transaction = async operations => {
|
||||
stateCache?.invalidate();
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
@@ -419,10 +423,11 @@ export function createMysqlAdapter(context) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
stateCache?.invalidate();
|
||||
connection.release();
|
||||
}
|
||||
};
|
||||
const read = async () => {
|
||||
const loadState = async () => {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
@@ -436,10 +441,11 @@ export function createMysqlAdapter(context) {
|
||||
connection.release();
|
||||
}
|
||||
};
|
||||
stateCache = createStateCache({ load: loadState });
|
||||
return createRepository({
|
||||
client: 'mysql',
|
||||
location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`,
|
||||
read,
|
||||
read: stateCache.read,
|
||||
transaction,
|
||||
close: async () => pool.end()
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { synchronizeSqlitePartitions } from './partition-storage.mjs';
|
||||
import { createStateCache } from './state-cache.mjs';
|
||||
|
||||
export function createSqliteAdapter(context) {
|
||||
const {
|
||||
@@ -19,8 +20,14 @@ export function createSqliteAdapter(context) {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
|
||||
const connection = new DatabaseSync(path, { timeout: 5000 });
|
||||
connection.exec('PRAGMA journal_mode = WAL;');
|
||||
connection.exec('PRAGMA synchronous = NORMAL;');
|
||||
connection.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA cache_size = -65536;
|
||||
PRAGMA mmap_size = 268435456;
|
||||
PRAGMA wal_autocheckpoint = 1000;
|
||||
`);
|
||||
const tableExists = name => Boolean(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||
const ensureColumns = (table, columns) => {
|
||||
if (!tableExists(table)) return;
|
||||
@@ -484,7 +491,16 @@ export function createSqliteAdapter(context) {
|
||||
|
||||
synchronizeSqlitePartitions(connection);
|
||||
|
||||
const dataVersion = connection.prepare('PRAGMA data_version');
|
||||
const stateCache = createStateCache({
|
||||
load: () => stateFromRows(readSqliteRows(connection)),
|
||||
version: () => Number(dataVersion.get().data_version)
|
||||
});
|
||||
|
||||
const transaction = async operations => {
|
||||
// Call this before the first possible await so request-local mutations of
|
||||
// the previous snapshot can never be observed by another request.
|
||||
stateCache.invalidate();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const item of operations) connection.prepare(item.sql).run(...item.params);
|
||||
@@ -493,12 +509,14 @@ export function createSqliteAdapter(context) {
|
||||
} catch (error) {
|
||||
connection.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
stateCache.invalidate();
|
||||
}
|
||||
};
|
||||
return createRepository({
|
||||
client: 'sqlite',
|
||||
location: path,
|
||||
read: async () => stateFromRows(readSqliteRows(connection)),
|
||||
read: stateCache.read,
|
||||
transaction,
|
||||
close: async () => connection.close()
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
function cacheDuration(value, fallback = 30000) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.min(Math.trunc(parsed), 3600000) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the materialized application state in process instead of rebuilding it
|
||||
* from every relational table for every HTTP request. Writes explicitly
|
||||
* invalidate the snapshot; an optional version reader also detects changes made
|
||||
* by another database connection.
|
||||
*/
|
||||
export function createStateCache({ load, version, maxAgeMs = process.env.DATABASE_STATE_CACHE_TTL_MS }) {
|
||||
// A database-native version token is stronger than a timer, so SQLite can
|
||||
// keep the snapshot indefinitely while still observing external commits.
|
||||
const ttlMs = version ? 0 : cacheDuration(maxAgeMs);
|
||||
let snapshot = null;
|
||||
let snapshotVersion;
|
||||
let loadedAt = 0;
|
||||
let generation = 0;
|
||||
let pending = null;
|
||||
|
||||
function invalidate() {
|
||||
generation += 1;
|
||||
snapshot = null;
|
||||
snapshotVersion = undefined;
|
||||
loadedAt = 0;
|
||||
pending = null;
|
||||
}
|
||||
|
||||
async function read() {
|
||||
const currentVersion = version ? await version() : undefined;
|
||||
const freshByAge = !ttlMs || Date.now() - loadedAt < ttlMs;
|
||||
if (snapshot && freshByAge && (!version || currentVersion === snapshotVersion)) return snapshot;
|
||||
|
||||
if (pending && (!version || pending.version === currentVersion)) return pending.promise;
|
||||
|
||||
const startedGeneration = generation;
|
||||
const loading = Promise.resolve().then(load).then(state => {
|
||||
if (generation === startedGeneration) {
|
||||
snapshot = state;
|
||||
snapshotVersion = currentVersion;
|
||||
loadedAt = Date.now();
|
||||
}
|
||||
return state;
|
||||
});
|
||||
pending = { version: currentVersion, promise: loading };
|
||||
try {
|
||||
return await loading;
|
||||
} finally {
|
||||
if (pending?.promise === loading) pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { read, invalidate };
|
||||
}
|
||||
Reference in New Issue
Block a user